pypi-query-mcp/pypi_query_mcp/core/dependency_parser.py
Ryan Malloy 8b43927493 chore: upgrade all Python packages and fix linting issues
- Update all dependencies to latest versions (fastmcp, httpx, packaging, etc.)
- Downgrade click from yanked 8.2.2 to stable 8.1.7
- Fix code formatting and linting issues with ruff
- Most tests passing (2 test failures in dependency resolver need investigation)
2025-08-15 20:23:14 -06:00

217 lines
6.8 KiB
Python

"""Dependency parsing utilities for PyPI packages."""
import logging
import re
from typing import Any
from packaging.requirements import Requirement
from packaging.version import Version
logger = logging.getLogger(__name__)
class DependencyParser:
"""Parser for Python package dependencies."""
def __init__(self):
self.parsed_cache: dict[str, list[Requirement]] = {}
def parse_requirements(self, requires_dist: list[str]) -> list[Requirement]:
"""Parse requirements from requires_dist list.
Args:
requires_dist: List of requirement strings from PyPI metadata
Returns:
List of parsed Requirement objects
"""
requirements = []
for req_str in requires_dist or []:
if not req_str or not req_str.strip():
continue
try:
req = Requirement(req_str)
requirements.append(req)
except Exception as e:
logger.warning(f"Failed to parse requirement '{req_str}': {e}")
continue
return requirements
def filter_requirements_by_python_version(
self, requirements: list[Requirement], python_version: str
) -> list[Requirement]:
"""Filter requirements based on Python version.
Args:
requirements: List of Requirement objects
python_version: Target Python version (e.g., "3.10")
Returns:
Filtered list of requirements applicable to the Python version
"""
filtered = []
try:
target_version = Version(python_version)
except Exception as e:
logger.warning(f"Invalid Python version '{python_version}': {e}")
return requirements
for req in requirements:
if self._is_requirement_applicable(req, target_version):
filtered.append(req)
return filtered
def _is_requirement_applicable(
self, req: Requirement, python_version: Version
) -> bool:
"""Check if a requirement is applicable for the given Python version.
Args:
req: Requirement object
python_version: Target Python version
Returns:
True if requirement applies to the Python version
"""
if not req.marker:
return True
# If the marker contains 'extra ==', this is an extra dependency
# and should not be filtered by Python version. Extra dependencies
# are handled separately based on user selection.
marker_str = str(req.marker)
if "extra ==" in marker_str:
return True
# Create environment for marker evaluation
env = {
"python_version": str(python_version),
"python_full_version": str(python_version),
"platform_system": "Linux", # Default assumption
"platform_machine": "x86_64", # Default assumption
"implementation_name": "cpython",
"implementation_version": str(python_version),
}
try:
return req.marker.evaluate(env)
except Exception as e:
logger.warning(f"Failed to evaluate marker for {req}: {e}")
return True # Include by default if evaluation fails
def categorize_dependencies(
self, requirements: list[Requirement], provides_extra: list[str] = None
) -> dict[str, list[Requirement]]:
"""Categorize dependencies into runtime, development, and optional groups.
Args:
requirements: List of Requirement objects
provides_extra: List of available extras (from package metadata)
Returns:
Dictionary with categorized dependencies
"""
categories = {"runtime": [], "development": [], "optional": {}, "extras": {}}
# Define development-related extra names
dev_extra_names = {
"dev",
"development",
"test",
"testing",
"tests",
"lint",
"linting",
"doc",
"docs",
"documentation",
"build",
"check",
"cover",
"coverage",
"type",
"typing",
"mypy",
"style",
"format",
"quality",
}
for req in requirements:
if not req.marker:
# No marker means it's a runtime dependency
categories["runtime"].append(req)
continue
marker_str = str(req.marker)
# Check for extra dependencies
if "extra ==" in marker_str:
extra_match = re.search(r'extra\s*==\s*["\']([^"\']+)["\']', marker_str)
if extra_match:
extra_name = extra_match.group(1)
if extra_name not in categories["extras"]:
categories["extras"][extra_name] = []
categories["extras"][extra_name].append(req)
# Check if this extra is development-related
if extra_name.lower() in dev_extra_names:
categories["development"].append(req)
else:
# Store in optional for non-dev extras
if extra_name not in categories["optional"]:
categories["optional"][extra_name] = []
categories["optional"][extra_name].append(req)
continue
# Check for development dependencies in other markers
if any(
keyword in marker_str.lower()
for keyword in ["dev", "test", "lint", "doc"]
):
categories["development"].append(req)
else:
categories["runtime"].append(req)
return categories
def extract_package_names(self, requirements: list[Requirement]) -> set[str]:
"""Extract package names from requirements.
Args:
requirements: List of Requirement objects
Returns:
Set of package names
"""
return {req.name.lower() for req in requirements}
def get_version_constraints(self, req: Requirement) -> dict[str, Any]:
"""Get version constraints from a requirement.
Args:
req: Requirement object
Returns:
Dictionary with version constraint information
"""
if not req.specifier:
return {"constraints": [], "allows_any": True}
constraints = []
for spec in req.specifier:
constraints.append(
{"operator": spec.operator, "version": str(spec.version)}
)
return {
"constraints": constraints,
"allows_any": len(constraints) == 0,
"specifier_str": str(req.specifier),
}