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.
This commit is contained in:
Mondo Diaz
2026-02-04 13:45:15 -06:00
parent 23ffbada00
commit f1ac43c1cb
2 changed files with 80 additions and 52 deletions

View File

@@ -332,6 +332,33 @@ def _is_version_constraint(version_str: str) -> bool:
return any(op in version_str for op in ['>=', '<=', '!=', '~=', '>', '<', '==', '*']) 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( def _resolve_version_constraint(
db: Session, db: Session,
package: Package, package: Package,
@@ -727,29 +754,17 @@ def resolve_dependencies(
cycle = [cycle_start, pkg_key] cycle = [cycle_start, pkg_key]
raise CircularDependencyError(cycle) 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: if pkg_key in version_requirements:
existing_versions = {r["version"] for r in version_requirements[pkg_key]} existing_versions = {r["version"] for r in version_requirements[pkg_key]}
if version_or_tag not in existing_versions: if version_or_tag not in existing_versions:
# Conflict detected - same package, different version # Different version requested - log and use existing (first wins)
requirements = version_requirements[pkg_key] + [ existing = version_requirements[pkg_key][0]["version"]
{"version": version_or_tag, "required_by": required_by} logger.debug(
] f"Version mismatch for {pkg_key}: using {existing} "
raise DependencyConflictError([ f"(also requested: {version_or_tag} by {required_by})"
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
],
) )
]) # Already resolved this package - skip
# Same version already resolved - skip
if artifact_id in visited:
return return
if artifact_id in visited: if artifact_id in visited:
@@ -1104,27 +1119,17 @@ async def resolve_dependencies_with_fetch(
cycle = [cycle_start, pkg_key] cycle = [cycle_start, pkg_key]
raise CircularDependencyError(cycle) raise CircularDependencyError(cycle)
# Conflict detection # Version conflict handling - use first resolved version (lenient mode)
if pkg_key in version_requirements: if pkg_key in version_requirements:
existing_versions = {r["version"] for r in version_requirements[pkg_key]} existing_versions = {r["version"] for r in version_requirements[pkg_key]}
if version_or_tag not in existing_versions: if version_or_tag not in existing_versions:
requirements = version_requirements[pkg_key] + [ # Different version requested - log and use existing (first wins)
{"version": version_or_tag, "required_by": required_by} existing = version_requirements[pkg_key][0]["version"]
] logger.debug(
raise DependencyConflictError([ f"Version mismatch for {pkg_key}: using {existing} "
DependencyConflict( f"(also requested: {version_or_tag} by {required_by})"
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
],
) )
]) # Already resolved this package - skip
if artifact_id in visited:
return return
if artifact_id in visited: if artifact_id in visited:
@@ -1210,6 +1215,27 @@ async def resolve_dependencies_with_fetch(
) )
continue 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( await _resolve_recursive_async(
dep_artifact_id, dep_artifact_id,
dep.dependency_project, dep.dependency_project,

View File

@@ -873,10 +873,14 @@ class TestCircularDependencyDetection:
class TestConflictDetection: 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 @pytest.mark.integration
def test_detect_version_conflict( def test_version_conflict_uses_first_version(
self, integration_client, test_project, unique_test_id self, integration_client, test_project, unique_test_id
): ):
"""Test conflict when two deps require different versions of same package.""" """Test conflict when two deps require different versions of same package."""
@@ -968,21 +972,19 @@ class TestConflictDetection:
) )
assert response.status_code == 200 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( response = integration_client.get(
f"/api/v1/project/{test_project}/{pkg_app}/+/1.0.0/resolve" 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() 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 # Resolution should succeed with first-encountered version of common
conflict = detail["conflicts"][0] assert data["artifact_count"] >= 1
assert conflict["package"] == pkg_common # Find the common package in resolved list
assert len(conflict["requirements"]) == 2 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: finally:
for pkg in [pkg_app, pkg_lib_a, pkg_lib_b, pkg_common]: for pkg in [pkg_app, pkg_lib_a, pkg_lib_b, pkg_common]: