pypi-query-mcp/pypi_query_mcp/tools/dependency_resolver.py
Ryan Malloy 114a7d8d5a fix: resolve include_extras parameter validation in resolve_dependencies
- Fix extra dependencies being filtered out by Python version checks
- Add proper handling for extra markers in dependency parsing
- Update parameter descriptions and documentation
- Add comprehensive examples and demo script
- Test with requests[socks], django[argon2,bcrypt], setuptools[test]
2025-08-15 11:53:54 -06:00

261 lines
9.3 KiB
Python

"""Dependency resolution tools for PyPI packages."""
import logging
from typing import Any
from ..core import PyPIClient, PyPIError
from ..core.dependency_parser import DependencyParser
from ..core.exceptions import (
InvalidPackageNameError,
NetworkError,
PackageNotFoundError,
)
logger = logging.getLogger(__name__)
class DependencyResolver:
"""Resolves package dependencies recursively."""
def __init__(self, max_depth: int = 10):
self.max_depth = max_depth
self.parser = DependencyParser()
self.resolved_cache: dict[str, dict[str, Any]] = {}
async def resolve_dependencies(
self,
package_name: str,
python_version: str | None = None,
include_extras: list[str] | None = None,
include_dev: bool = False,
max_depth: int | None = None,
) -> dict[str, Any]:
"""Resolve all dependencies for a package recursively.
Args:
package_name: Name of the package to resolve
python_version: Target Python version (e.g., "3.10")
include_extras: List of extra dependency groups to include (e.g., ['socks'] for requests,
['test', 'doc'] for setuptools). These are optional dependencies defined by the package.
include_dev: Whether to include development dependencies
max_depth: Maximum recursion depth (overrides instance default)
Returns:
Dictionary containing resolved dependency tree
"""
if not package_name or not package_name.strip():
raise InvalidPackageNameError(package_name)
max_depth = max_depth or self.max_depth
include_extras = include_extras or []
logger.info(
f"Resolving dependencies for {package_name} (Python {python_version})"
)
# Track visited packages to avoid circular dependencies
visited: set[str] = set()
dependency_tree = {}
try:
await self._resolve_recursive(
package_name=package_name,
python_version=python_version,
include_extras=include_extras,
include_dev=include_dev,
visited=visited,
dependency_tree=dependency_tree,
current_depth=0,
max_depth=max_depth,
)
# Check if main package was resolved
normalized_name = package_name.lower().replace("_", "-")
if normalized_name not in dependency_tree:
raise PackageNotFoundError(
f"Package '{package_name}' not found on PyPI"
)
# Generate summary
summary = self._generate_dependency_summary(dependency_tree)
return {
"package_name": package_name,
"python_version": python_version,
"include_extras": include_extras,
"include_dev": include_dev,
"dependency_tree": dependency_tree,
"summary": summary,
}
except PyPIError:
raise
except Exception as e:
logger.error(
f"Unexpected error resolving dependencies for {package_name}: {e}"
)
raise NetworkError(f"Failed to resolve dependencies: {e}", e) from e
async def _resolve_recursive(
self,
package_name: str,
python_version: str | None,
include_extras: list[str],
include_dev: bool,
visited: set[str],
dependency_tree: dict[str, Any],
current_depth: int,
max_depth: int,
) -> None:
"""Recursively resolve dependencies."""
# Normalize package name
normalized_name = package_name.lower().replace("_", "-")
# Check if already visited or max depth reached
if normalized_name in visited or current_depth >= max_depth:
return
visited.add(normalized_name)
try:
# Get package information
async with PyPIClient() as client:
package_data = await client.get_package_info(package_name)
info = package_data.get("info", {})
requires_dist = info.get("requires_dist", []) or []
# Parse requirements
requirements = self.parser.parse_requirements(requires_dist)
# Filter by Python version if specified
if python_version:
requirements = self.parser.filter_requirements_by_python_version(
requirements, python_version
)
# Categorize dependencies
categorized = self.parser.categorize_dependencies(requirements)
# Build dependency info for this package
package_info = {
"name": info.get("name", package_name),
"version": info.get("version", "unknown"),
"requires_python": info.get("requires_python", ""),
"dependencies": {
"runtime": [str(req) for req in categorized["runtime"]],
"development": [str(req) for req in categorized["development"]]
if include_dev
else [],
"extras": {},
},
"depth": current_depth,
"children": {},
}
# Add requested extras
for extra in include_extras:
if extra in categorized["extras"]:
package_info["dependencies"]["extras"][extra] = [
str(req) for req in categorized["extras"][extra]
]
dependency_tree[normalized_name] = package_info
# Collect all dependencies to resolve
deps_to_resolve = []
deps_to_resolve.extend(categorized["runtime"])
if include_dev:
deps_to_resolve.extend(categorized["development"])
for extra in include_extras:
if extra in categorized["extras"]:
deps_to_resolve.extend(categorized["extras"][extra])
# Resolve child dependencies
for dep_req in deps_to_resolve:
dep_name = dep_req.name
if dep_name.lower() not in visited:
await self._resolve_recursive(
package_name=dep_name,
python_version=python_version,
include_extras=[], # Don't propagate extras to children
include_dev=False, # Don't propagate dev deps to children
visited=visited,
dependency_tree=dependency_tree,
current_depth=current_depth + 1,
max_depth=max_depth,
)
# Add to children if resolved
if dep_name.lower() in dependency_tree:
package_info["children"][dep_name.lower()] = dependency_tree[
dep_name.lower()
]
except PackageNotFoundError:
logger.warning(f"Package {package_name} not found, skipping")
except Exception as e:
logger.error(f"Error resolving {package_name}: {e}")
# Continue with other dependencies
def _generate_dependency_summary(
self, dependency_tree: dict[str, Any]
) -> dict[str, Any]:
"""Generate summary statistics for the dependency tree."""
total_packages = len(dependency_tree)
total_runtime_deps = 0
total_dev_deps = 0
total_extra_deps = 0
max_depth = 0
for package_info in dependency_tree.values():
total_runtime_deps += len(package_info["dependencies"]["runtime"])
total_dev_deps += len(package_info["dependencies"]["development"])
for extra_deps in package_info["dependencies"]["extras"].values():
total_extra_deps += len(extra_deps)
max_depth = max(max_depth, package_info["depth"])
return {
"total_packages": total_packages,
"total_runtime_dependencies": total_runtime_deps,
"total_development_dependencies": total_dev_deps,
"total_extra_dependencies": total_extra_deps,
"max_depth": max_depth,
"package_list": list(dependency_tree.keys()),
}
async def resolve_package_dependencies(
package_name: str,
python_version: str | None = None,
include_extras: list[str] | None = None,
include_dev: bool = False,
max_depth: int = 5,
) -> dict[str, Any]:
"""Resolve package dependencies with comprehensive analysis.
Args:
package_name: Name of the package to resolve
python_version: Target Python version (e.g., "3.10")
include_extras: List of extra dependency groups to include (e.g., ['socks'] for requests,
['test', 'doc'] for setuptools). These are optional dependencies defined by the package.
include_dev: Whether to include development dependencies
max_depth: Maximum recursion depth
Returns:
Comprehensive dependency resolution results
"""
resolver = DependencyResolver(max_depth=max_depth)
return await resolver.resolve_dependencies(
package_name=package_name,
python_version=python_version,
include_extras=include_extras,
include_dev=include_dev,
)