Fix self-dependency detection to strip PyPI extras brackets
The circular dependency error '_pypi/psutil → _pypi/psutil' occurred because dependencies with extras like 'psutil[test]' weren't being recognized as self-dependencies. The comparison 'psutil[test] != psutil' failed. - Add _normalize_pypi_package_name() helper that strips extras brackets and normalizes separators per PEP 503 - Update _detect_package_cycle to use normalized names for cycle detection - Update check_circular_dependencies to use normalized initial path - Simplify self-dependency check in resolve_dependencies to use helper
This commit is contained in:
@@ -47,6 +47,22 @@ from .schemas import (
|
||||
)
|
||||
|
||||
|
||||
def _normalize_pypi_package_name(name: str) -> str:
|
||||
"""
|
||||
Normalize a PyPI package name for comparison.
|
||||
|
||||
- Strips extras brackets (e.g., "package[extra]" -> "package")
|
||||
- Replaces sequences of hyphens, underscores, and dots with a single hyphen
|
||||
- Lowercases the result
|
||||
|
||||
This follows PEP 503 normalization rules.
|
||||
"""
|
||||
# Strip extras brackets like [test], [dev], etc.
|
||||
base_name = re.sub(r'\[.*\]', '', name)
|
||||
# Normalize separators and lowercase
|
||||
return re.sub(r'[-_.]+', '-', base_name).lower()
|
||||
|
||||
|
||||
class DependencyError(Exception):
|
||||
"""Base exception for dependency errors."""
|
||||
pass
|
||||
@@ -514,10 +530,16 @@ def _detect_package_cycle(
|
||||
Returns:
|
||||
Cycle path if detected, None otherwise
|
||||
"""
|
||||
pkg_key = f"{project_name}/{package_name}"
|
||||
# Normalize names for comparison (handles extras like [test] and separators)
|
||||
pkg_normalized = _normalize_pypi_package_name(package_name)
|
||||
target_pkg_normalized = _normalize_pypi_package_name(target_package)
|
||||
|
||||
# Use normalized key for tracking
|
||||
pkg_key = f"{project_name.lower()}/{pkg_normalized}"
|
||||
|
||||
# Check if we've reached the target package (cycle detected)
|
||||
if project_name == target_project and package_name == target_package:
|
||||
# Use normalized comparison to handle extras and naming variations
|
||||
if project_name.lower() == target_project.lower() and pkg_normalized == target_pkg_normalized:
|
||||
return path + [pkg_key]
|
||||
|
||||
if pkg_key in visiting:
|
||||
@@ -619,12 +641,15 @@ def check_circular_dependencies(
|
||||
else:
|
||||
return None
|
||||
|
||||
# Normalize the initial path for consistency with _detect_package_cycle
|
||||
normalized_path = f"{target_project.lower()}/{_normalize_pypi_package_name(target_package)}"
|
||||
|
||||
# For each new dependency, check if it would create a cycle back to our package
|
||||
for dep in new_dependencies:
|
||||
# Check if this dependency (transitively) depends on us at the package level
|
||||
visiting: Set[str] = set()
|
||||
visited: Set[str] = set()
|
||||
path: List[str] = [current_path]
|
||||
path: List[str] = [normalized_path]
|
||||
|
||||
# Check from the dependency's package
|
||||
cycle = _detect_package_cycle(
|
||||
@@ -782,11 +807,11 @@ def resolve_dependencies(
|
||||
# Resolve each dependency first (depth-first)
|
||||
for dep in deps:
|
||||
# Skip self-dependencies (can happen with PyPI extras like pytest[testing])
|
||||
# Use case-insensitive comparison and normalize for PyPI naming conventions
|
||||
# Use normalized comparison for PyPI naming conventions (handles extras, separators)
|
||||
dep_proj_normalized = dep.dependency_project.lower()
|
||||
dep_pkg_normalized = re.sub(r'[-_.]+', '-', dep.dependency_package).lower()
|
||||
dep_pkg_normalized = _normalize_pypi_package_name(dep.dependency_package)
|
||||
curr_proj_normalized = proj_name.lower()
|
||||
curr_pkg_normalized = re.sub(r'[-_.]+', '-', pkg_name).lower()
|
||||
curr_pkg_normalized = _normalize_pypi_package_name(pkg_name)
|
||||
if dep_proj_normalized == curr_proj_normalized and dep_pkg_normalized == curr_pkg_normalized:
|
||||
continue
|
||||
|
||||
|
||||
Reference in New Issue
Block a user