From f1ac43c1cb04be0de7629cf8639e098f4e87e934 Mon Sep 17 00:00:00 2001 From: Mondo Diaz Date: Wed, 4 Feb 2026 13:45:15 -0600 Subject: [PATCH] fix: use lenient conflict handling for dependency resolution Instead of failing with 409 on version conflicts, use "first version wins" strategy. This allows resolution to succeed for complex dependency trees like tensorflow where transitive dependencies may have overlapping but not identical version requirements. The resolver now: - Checks if an already-resolved version satisfies a new constraint - If yes, reuses the existing version - If no, logs the mismatch and uses the first-encountered version This matches pip's behavior of picking a working version rather than failing on theoretical conflicts. --- backend/app/dependencies.py | 106 ++++++++++++++++++----------- backend/tests/test_dependencies.py | 26 +++---- 2 files changed, 80 insertions(+), 52 deletions(-) diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py index 8cbd42e..7a3a537 100644 --- a/backend/app/dependencies.py +++ b/backend/app/dependencies.py @@ -332,6 +332,33 @@ def _is_version_constraint(version_str: str) -> bool: return any(op in version_str for op in ['>=', '<=', '!=', '~=', '>', '<', '==', '*']) +def _version_satisfies_constraint(version: str, constraint: str) -> bool: + """ + Check if a version satisfies a constraint. + + Args: + version: A version string (e.g., '1.26.0') + constraint: A version constraint (e.g., '>=1.20', '>=1.20,<2.0', '*') + + Returns: + True if the version satisfies the constraint, False otherwise + """ + if not HAS_PACKAGING: + return False + + # Wildcard matches everything + if constraint == '*' or not constraint: + return True + + try: + spec = SpecifierSet(constraint) + v = Version(version) + return v in spec + except (InvalidSpecifier, InvalidVersion): + # If we can't parse, assume it doesn't match + return False + + def _resolve_version_constraint( db: Session, package: Package, @@ -727,30 +754,18 @@ def resolve_dependencies( cycle = [cycle_start, pkg_key] raise CircularDependencyError(cycle) - # Conflict detection - check if we've seen this package before with a different version + # Version conflict handling - use first resolved version (lenient mode) if pkg_key in version_requirements: existing_versions = {r["version"] for r in version_requirements[pkg_key]} if version_or_tag not in existing_versions: - # Conflict detected - same package, different version - requirements = version_requirements[pkg_key] + [ - {"version": version_or_tag, "required_by": required_by} - ] - raise DependencyConflictError([ - DependencyConflict( - project=proj_name, - package=pkg_name, - requirements=[ - { - "version": r["version"], - "required_by": [{"path": r["required_by"]}] if r["required_by"] else [] - } - for r in requirements - ], - ) - ]) - # Same version already resolved - skip - if artifact_id in visited: - return + # Different version requested - log and use existing (first wins) + existing = version_requirements[pkg_key][0]["version"] + logger.debug( + f"Version mismatch for {pkg_key}: using {existing} " + f"(also requested: {version_or_tag} by {required_by})" + ) + # Already resolved this package - skip + return if artifact_id in visited: return @@ -1104,28 +1119,18 @@ async def resolve_dependencies_with_fetch( cycle = [cycle_start, pkg_key] raise CircularDependencyError(cycle) - # Conflict detection + # Version conflict handling - use first resolved version (lenient mode) if pkg_key in version_requirements: existing_versions = {r["version"] for r in version_requirements[pkg_key]} if version_or_tag not in existing_versions: - requirements = version_requirements[pkg_key] + [ - {"version": version_or_tag, "required_by": required_by} - ] - raise DependencyConflictError([ - DependencyConflict( - project=proj_name, - package=pkg_name, - requirements=[ - { - "version": r["version"], - "required_by": [{"path": r["required_by"]}] if r["required_by"] else [] - } - for r in requirements - ], - ) - ]) - if artifact_id in visited: - return + # Different version requested - log and use existing (first wins) + existing = version_requirements[pkg_key][0]["version"] + logger.debug( + f"Version mismatch for {pkg_key}: using {existing} " + f"(also requested: {version_or_tag} by {required_by})" + ) + # Already resolved this package - skip + return if artifact_id in visited: return @@ -1210,6 +1215,27 @@ async def resolve_dependencies_with_fetch( ) continue + # Check if we've already resolved this package to a different version + dep_pkg_key = f"{dep.dependency_project}/{dep.dependency_package}" + if dep_pkg_key in version_requirements: + existing_version = version_requirements[dep_pkg_key][0]["version"] + if existing_version != dep_version: + # Different version resolved - check if existing satisfies new constraint + if HAS_PACKAGING and _version_satisfies_constraint(existing_version, dep.version_constraint): + logger.debug( + f"Reusing existing version {existing_version} for {dep_pkg_key} " + f"(satisfies constraint {dep.version_constraint})" + ) + continue + else: + logger.debug( + f"Version conflict for {dep_pkg_key}: have {existing_version}, " + f"need {dep.version_constraint} (resolved to {dep_version})" + ) + # Don't raise error - just use the first version we resolved + # This is more lenient than strict conflict detection + continue + await _resolve_recursive_async( dep_artifact_id, dep.dependency_project, diff --git a/backend/tests/test_dependencies.py b/backend/tests/test_dependencies.py index 71f3003..233ee65 100644 --- a/backend/tests/test_dependencies.py +++ b/backend/tests/test_dependencies.py @@ -873,10 +873,14 @@ class TestCircularDependencyDetection: class TestConflictDetection: - """Tests for #81: Dependency Conflict Detection and Reporting""" + """Tests for dependency conflict handling. + + The resolver uses "first version wins" strategy for version conflicts, + allowing resolution to succeed rather than failing with an error. + """ @pytest.mark.integration - def test_detect_version_conflict( + def test_version_conflict_uses_first_version( self, integration_client, test_project, unique_test_id ): """Test conflict when two deps require different versions of same package.""" @@ -968,21 +972,19 @@ class TestConflictDetection: ) assert response.status_code == 200 - # Try to resolve app - should report conflict + # Try to resolve app - with lenient conflict handling, this should succeed + # The resolver uses "first version wins" strategy for conflicting versions response = integration_client.get( f"/api/v1/project/{test_project}/{pkg_app}/+/1.0.0/resolve" ) - assert response.status_code == 409 + assert response.status_code == 200 data = response.json() - # Error details are nested in "detail" for HTTPException - detail = data.get("detail", data) - assert detail.get("error") == "dependency_conflict" - assert len(detail.get("conflicts", [])) > 0 - # Verify conflict details - conflict = detail["conflicts"][0] - assert conflict["package"] == pkg_common - assert len(conflict["requirements"]) == 2 + # Resolution should succeed with first-encountered version of common + assert data["artifact_count"] >= 1 + # Find the common package in resolved list + common_resolved = [r for r in data["resolved"] if r["package"] == pkg_common] + assert len(common_resolved) == 1 # Only one version should be included finally: for pkg in [pkg_app, pkg_lib_a, pkg_lib_b, pkg_common]: