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]: