Skip to content

Commit 5593f32

Browse files
committed
Support multi-file version constraints
Until conda/conda#11612 is resolved we do a simplified version of combining build strings. This is a change in behavior as previously package version constraings would simply be overwritten instead of combined.
1 parent b2df9ad commit 5593f32

3 files changed

Lines changed: 236 additions & 9 deletions

File tree

conda_lock/models/lock_spec.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1+
from __future__ import annotations
2+
3+
import copy
14
import hashlib
25
import json
36
import pathlib
47
import typing
58

9+
from fnmatch import fnmatchcase
610
from typing import Dict, List, Optional, Union
711

812
from pydantic import BaseModel, Field, validator
@@ -24,23 +28,139 @@ class _BaseDependency(StrictModel):
2428
def sorted_extras(cls, v: List[str]) -> List[str]:
2529
return sorted(v)
2630

31+
def _merge_base(self, other: _BaseDependency) -> _BaseDependency:
32+
if other is None:
33+
return self
34+
if (
35+
self.name != other.name
36+
or self.manager != other.manager
37+
or self.category != other.category
38+
):
39+
raise ValueError(
40+
"Cannot merge incompatible dependencies: {self} != {other}"
41+
)
42+
return _BaseDependency(
43+
name=self.name,
44+
manager=self.manager,
45+
category=self.category,
46+
extras=list(set(self.extras + other.extras)),
47+
)
48+
2749

2850
class VersionedDependency(_BaseDependency):
2951
version: str
3052
build: Optional[str] = None
3153
conda_channel: Optional[str] = None
3254

55+
@staticmethod
56+
def _merge_matchspecs(
57+
matchspec1: Optional[str], matchspec2: Optional[str], combine_constraints=True
58+
) -> Optional[str]:
59+
if matchspec1 == matchspec2:
60+
return matchspec1
61+
if matchspec1 is None or matchspec1 == "":
62+
return matchspec2
63+
if matchspec2 is None or matchspec2 == "":
64+
return matchspec1
65+
if fnmatchcase(matchspec1, matchspec2):
66+
return matchspec1
67+
if fnmatchcase(matchspec2, matchspec1):
68+
return matchspec2
69+
if not combine_constraints:
70+
raise ValueError(
71+
f"Found incompatible constraint {matchspec1}, {matchspec2}"
72+
)
73+
return f"{matchspec1},{matchspec2}"
74+
75+
def merge(self, other: Optional[VersionedDependency]) -> VersionedDependency:
76+
if other is None:
77+
return self
78+
79+
if (
80+
self.conda_channel is not None
81+
and other.conda_channel is not None
82+
and self.conda_channel != other.conda_channel
83+
):
84+
raise ValueError(
85+
f"VersionedDependency has two different conda_channels:\n{self}\n{other}"
86+
)
87+
merged_base = self._merge_base(other)
88+
try:
89+
build = self._merge_matchspecs(
90+
self.build, other.build, combine_constraints=False
91+
)
92+
except ValueError as exc:
93+
raise ValueError(
94+
f"Unsupported usage of two incompatible builds for same dependency {self}, {other}"
95+
) from exc
96+
97+
return VersionedDependency(
98+
name=merged_base.name,
99+
manager=merged_base.manager,
100+
category=merged_base.category,
101+
extras=merged_base.extras,
102+
version=self._merge_matchspecs(self.version, other.version), # type: ignore
103+
build=build,
104+
conda_channel=self.conda_channel or other.conda_channel,
105+
)
106+
33107

34108
class URLDependency(_BaseDependency):
35109
url: str
36110
hashes: List[str]
37111

112+
def merge(self, other: Optional[URLDependency]) -> URLDependency:
113+
if other is None:
114+
return self
115+
if self.url != other.url:
116+
raise ValueError(f"URLDependency has two different urls:\n{self}\n{other}")
117+
118+
if self.hashes != other.hashes:
119+
raise ValueError(
120+
f"URLDependency has two different hashess:\n{self}\n{other}"
121+
)
122+
merged_base = self._merge_base(other)
123+
124+
return URLDependency(
125+
name=merged_base.name,
126+
manager=merged_base.manager,
127+
category=merged_base.category,
128+
extras=merged_base.extras,
129+
url=self.url,
130+
hashes=self.hashes,
131+
)
132+
38133

39134
class VCSDependency(_BaseDependency):
40135
source: str
41136
vcs: str
42137
rev: Optional[str] = None
43138

139+
def merge(self, other: Optional[VCSDependency]) -> VCSDependency:
140+
if other is None:
141+
return self
142+
if self.source != other.source:
143+
raise ValueError(
144+
f"VCSDependency has two different sources:\n{self}\n{other}"
145+
)
146+
147+
if self.vcs != other.vcs:
148+
raise ValueError(f"VCSDependency has two different vcss:\n{self}\n{other}")
149+
150+
if self.rev is not None and other.rev is not None and self.rev != other.rev:
151+
raise ValueError(f"VCSDependency has two different revs:\n{self}\n{other}")
152+
merged_base = self._merge_base(other)
153+
154+
return VCSDependency(
155+
name=merged_base.name,
156+
manager=merged_base.manager,
157+
category=merged_base.category,
158+
extras=merged_base.extras,
159+
source=self.source,
160+
vcs=self.vcs,
161+
rev=self.rev or other.rev,
162+
)
163+
44164

45165
Dependency = Union[VersionedDependency, URLDependency, VCSDependency]
46166

conda_lock/src_parser/aggregation.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,11 @@ def aggregate_lock_specs(
3434
lock_spec.dependencies.get(platform, []) for lock_spec in lock_specs
3535
):
3636
key = (dep.manager, dep.name)
37-
unique_deps[key] = dep
37+
if unique_deps.get(key) is not None and type(unique_deps[key]) != type(dep):
38+
raise ValueError(
39+
f"Unsupported use of different dependency types for same package:\n{dep}\n{unique_deps[key]}"
40+
)
41+
unique_deps[key] = dep.merge(unique_deps.get(key)) # type: ignore
3842

3943
dependencies[platform] = list(unique_deps.values())
4044

tests/test_conda_lock.py

Lines changed: 111 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1625,22 +1625,125 @@ def test_aggregate_lock_specs():
16251625
assert actual.content_hash() == expected.content_hash()
16261626

16271627

1628-
def test_aggregate_lock_specs_override_version():
1629-
base_spec = LockSpecification(
1630-
dependencies={"linux-64": [_make_spec("package", "=1.0")]},
1628+
def test_aggregate_lock_specs_combine_version():
1629+
first_spec = LockSpecification(
1630+
dependencies={"linux-64": [_make_spec("package", ">1.0")]},
16311631
channels=[Channel.from_string("conda-forge")],
16321632
sources=[Path("base.yml")],
16331633
)
16341634

1635-
override_spec = LockSpecification(
1636-
dependencies={"linux-64": [_make_spec("package", "=2.0")]},
1635+
second_spec = LockSpecification(
1636+
dependencies={"linux-64": [_make_spec("package", "<2.0")]},
16371637
channels=[Channel.from_string("internal"), Channel.from_string("conda-forge")],
1638-
sources=[Path("override.yml")],
1638+
sources=[Path("additional.yml")],
16391639
)
16401640

1641-
agg_spec = aggregate_lock_specs([base_spec, override_spec], platforms=["linux-64"])
1641+
result_spec = LockSpecification(
1642+
dependencies={"linux-64": [_make_spec("package", "<2.0,>1.0")]},
1643+
channels=[Channel.from_string("internal"), Channel.from_string("conda-forge")],
1644+
sources=[Path("result.yml")],
1645+
)
1646+
1647+
agg_spec = aggregate_lock_specs([first_spec, second_spec], platforms=["linux-64"])
1648+
1649+
assert agg_spec.dependencies == result_spec.dependencies
16421650

1643-
assert agg_spec.dependencies == override_spec.dependencies
1651+
1652+
def test_aggregate_lock_specs_combine_build():
1653+
first_spec = LockSpecification(
1654+
dependencies={
1655+
"linux-64": [
1656+
VersionedDependency(name="openblas", version="*", build="openmp*"),
1657+
VersionedDependency(
1658+
name="_openmp_mutex", version="4.5", build="*_llvm"
1659+
),
1660+
]
1661+
},
1662+
channels=[Channel.from_string("conda-forge")],
1663+
sources=[Path("base.yml")],
1664+
)
1665+
1666+
second_spec = LockSpecification(
1667+
dependencies={
1668+
"linux-64": [
1669+
VersionedDependency(
1670+
name="openblas", version="0.3.20", build="openmp_h53a8fd6_1"
1671+
),
1672+
VersionedDependency(
1673+
name="_openmp_mutex", version="4.5", build="2_kmp_llvm"
1674+
),
1675+
]
1676+
},
1677+
channels=[Channel.from_string("internal"), Channel.from_string("conda-forge")],
1678+
sources=[Path("second.yml")],
1679+
)
1680+
1681+
third_spec = LockSpecification(
1682+
dependencies={
1683+
"linux-64": [
1684+
VersionedDependency(
1685+
name="openblas", version="*", build="openmp_h53a8fd6_1"
1686+
),
1687+
VersionedDependency(
1688+
name="_openmp_mutex", version="4.5", build="*_kmp_llvm"
1689+
),
1690+
]
1691+
},
1692+
channels=[Channel.from_string("internal"), Channel.from_string("conda-forge")],
1693+
sources=[Path("third.yml")],
1694+
)
1695+
1696+
result_spec = LockSpecification(
1697+
dependencies={
1698+
"linux-64": [
1699+
VersionedDependency(
1700+
name="openblas", version="0.3.20", build="openmp_h53a8fd6_1"
1701+
),
1702+
VersionedDependency(
1703+
name="_openmp_mutex", version="4.5", build="2_kmp_llvm"
1704+
),
1705+
]
1706+
},
1707+
channels=[Channel.from_string("internal"), Channel.from_string("conda-forge")],
1708+
sources=[Path("result.yml")],
1709+
)
1710+
1711+
agg_spec = aggregate_lock_specs(
1712+
[first_spec, second_spec, third_spec], platforms=["linux-64"]
1713+
)
1714+
1715+
assert agg_spec.dependencies == result_spec.dependencies
1716+
1717+
1718+
def test_aggregate_lock_specs_combine_build_incompatible():
1719+
first_spec = LockSpecification(
1720+
dependencies={
1721+
"linux-64": [
1722+
VersionedDependency(
1723+
name="openblas", version="0.3.20", build="openmp_h53a8fd6_2"
1724+
),
1725+
]
1726+
},
1727+
channels=[Channel.from_string("conda-forge")],
1728+
sources=[Path("base.yml")],
1729+
)
1730+
1731+
second_spec = LockSpecification(
1732+
dependencies={
1733+
"linux-64": [
1734+
VersionedDependency(
1735+
name="openblas", version="0.3.20", build="openmp_h53a8fd6_1"
1736+
),
1737+
]
1738+
},
1739+
channels=[Channel.from_string("internal"), Channel.from_string("conda-forge")],
1740+
sources=[Path("second.yml")],
1741+
)
1742+
1743+
with pytest.raises(ValueError):
1744+
aggregate_lock_specs(
1745+
[first_spec, second_spec], platforms=["linux-64"]
1746+
)
16441747

16451748

16461749
def test_aggregate_lock_specs_invalid_channels():

0 commit comments

Comments
 (0)