diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py index 845d602..e43790c 100644 --- a/backend/app/dependencies.py +++ b/backend/app/dependencies.py @@ -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