"""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), }