Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support multi-file version constraints #546

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions conda_lock/models/lock_spec.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
from __future__ import annotations

import copy
import hashlib
import json
import pathlib
import typing

from fnmatch import fnmatchcase
from typing import Dict, List, Optional, Union

from pydantic import BaseModel, Field, validator
Expand All @@ -24,23 +28,143 @@ class _BaseDependency(StrictModel):
def sorted_extras(cls, v: List[str]) -> List[str]:
return sorted(v)

def _merge_base(self, other: _BaseDependency) -> _BaseDependency:
if other is None:
return self
if self.name != other.name or self.manager != other.manager:
raise ValueError(
"Cannot merge incompatible dependencies: {self} != {other}"
)
return _BaseDependency(
name=self.name,
manager=self.manager,
category=self.category, # TODO: Merge categories
extras=list(set(self.extras + other.extras)),
)


class VersionedDependency(_BaseDependency):
version: str
build: Optional[str] = None
conda_channel: Optional[str] = None

@staticmethod
def _merge_versions(
version1: str,
version2: str,
) -> str:
if version1 == version2:
return version1
if version1 == "" or version1 == "*":
return version2
if version2 == "" or version2 == "*":
return version1
return f"{version1},{version2}"

@staticmethod
def _merge_builds(
build1: Optional[str],
build2: Optional[str],
) -> Optional[str]:
if build1 == build2:
return build1
if build1 is None or build1 == "":
return build2
if build2 is None or build2 == "":
return build1
if fnmatchcase(build1, build2):
return build1
if fnmatchcase(build2, build1):
return build2
raise ValueError(f"Found incompatible constraint {build1}, {build2}")

def merge(self, other: Optional[VersionedDependency]) -> VersionedDependency:
if other is None:
return self

if (
self.conda_channel is not None
and other.conda_channel is not None
and self.conda_channel != other.conda_channel
):
raise ValueError(
f"VersionedDependency has two different conda_channels:\n{self}\n{other}"
)
merged_base = self._merge_base(other)
try:
build = self._merge_builds(self.build, other.build)
except ValueError as exc:
raise ValueError(
f"Unsupported usage of two incompatible builds for same dependency {self}, {other}"
) from exc

return VersionedDependency(
name=merged_base.name,
manager=merged_base.manager,
category=merged_base.category,
extras=merged_base.extras,
version=self._merge_versions(self.version, other.version),
build=build,
conda_channel=self.conda_channel or other.conda_channel,
)


class URLDependency(_BaseDependency):
url: str
hashes: List[str]

def merge(self, other: Optional[URLDependency]) -> URLDependency:
if other is None:
return self
if self.url != other.url:
raise ValueError(f"URLDependency has two different urls:\n{self}\n{other}")

if self.hashes != other.hashes:
raise ValueError(
f"URLDependency has two different hashess:\n{self}\n{other}"
)
merged_base = self._merge_base(other)

return URLDependency(
name=merged_base.name,
manager=merged_base.manager,
category=merged_base.category,
extras=merged_base.extras,
url=self.url,
hashes=self.hashes,
)


class VCSDependency(_BaseDependency):
source: str
vcs: str
rev: Optional[str] = None

def merge(self, other: Optional[VCSDependency]) -> VCSDependency:
if other is None:
return self
if self.source != other.source:
raise ValueError(
f"VCSDependency has two different sources:\n{self}\n{other}"
)

if self.vcs != other.vcs:
raise ValueError(f"VCSDependency has two different vcss:\n{self}\n{other}")

if self.rev is not None and other.rev is not None and self.rev != other.rev:
raise ValueError(f"VCSDependency has two different revs:\n{self}\n{other}")
merged_base = self._merge_base(other)

return VCSDependency(
name=merged_base.name,
manager=merged_base.manager,
category=merged_base.category,
extras=merged_base.extras,
source=self.source,
vcs=self.vcs,
rev=self.rev or other.rev,
)


Dependency = Union[VersionedDependency, URLDependency, VCSDependency]

Expand Down
7 changes: 6 additions & 1 deletion conda_lock/src_parser/aggregation.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,12 @@ def aggregate_lock_specs(
lock_spec.dependencies.get(platform, []) for lock_spec in lock_specs
):
key = (dep.manager, dep.name)
unique_deps[key] = dep
existing_dep = unique_deps.get(key)
if existing_dep is not None and type(unique_deps[key]) != type(dep):
raise ValueError(
f"Unsupported use of different dependency types for same package:\n{dep}\n{unique_deps[key]}"
)
unique_deps[key] = dep.merge(existing_dep) # type: ignore

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

Expand Down
144 changes: 136 additions & 8 deletions tests/test_conda_lock.py
Original file line number Diff line number Diff line change
Expand Up @@ -1625,22 +1625,150 @@ def test_aggregate_lock_specs():
assert actual.content_hash() == expected.content_hash()


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

second_spec = LockSpecification(
dependencies={"linux-64": [_make_spec("package", "<2.0")]},
channels=[Channel.from_string("internal"), Channel.from_string("conda-forge")],
sources=[Path("additional.yml")],
)

result_spec = LockSpecification(
dependencies={"linux-64": [_make_spec("package", "<2.0,>1.0")]},
channels=[Channel.from_string("internal"), Channel.from_string("conda-forge")],
sources=[Path("result.yml")],
)

agg_spec = aggregate_lock_specs([first_spec, second_spec], platforms=["linux-64"])

assert agg_spec.dependencies == result_spec.dependencies


def test_aggregate_lock_specs_combine_build():
first_spec = LockSpecification(
dependencies={
"linux-64": [
VersionedDependency(name="openblas", version="*", build="openmp*"),
VersionedDependency(
name="_openmp_mutex", version="4.5", build="*_llvm"
),
]
},
channels=[Channel.from_string("conda-forge")],
sources=[Path("base.yml")],
)

override_spec = LockSpecification(
dependencies={"linux-64": [_make_spec("package", "=2.0")]},
second_spec = LockSpecification(
dependencies={
"linux-64": [
VersionedDependency(
name="openblas", version="0.3.20", build="openmp_h53a8fd6_1"
),
VersionedDependency(
name="_openmp_mutex", version="4.5", build="2_kmp_llvm"
),
]
},
channels=[Channel.from_string("internal"), Channel.from_string("conda-forge")],
sources=[Path("second.yml")],
)

third_spec = LockSpecification(
dependencies={
"linux-64": [
VersionedDependency(
name="openblas", version="*", build="openmp_h53a8fd6_1"
),
VersionedDependency(
name="_openmp_mutex", version="4.5", build="*_kmp_llvm"
),
]
},
channels=[Channel.from_string("internal"), Channel.from_string("conda-forge")],
sources=[Path("override.yml")],
sources=[Path("third.yml")],
)

agg_spec = aggregate_lock_specs([base_spec, override_spec], platforms=["linux-64"])
result_spec = LockSpecification(
dependencies={
"linux-64": [
VersionedDependency(
name="openblas", version="0.3.20", build="openmp_h53a8fd6_1"
),
VersionedDependency(
name="_openmp_mutex", version="4.5", build="2_kmp_llvm"
),
]
},
channels=[Channel.from_string("internal"), Channel.from_string("conda-forge")],
sources=[Path("result.yml")],
)

agg_spec = aggregate_lock_specs(
[first_spec, second_spec, third_spec], platforms=["linux-64"]
)

assert agg_spec.dependencies == result_spec.dependencies


def test_merging_matchspec_parts():
assert VersionedDependency._merge_versions("4.5", "*") == "4.5"
assert VersionedDependency._merge_versions("*", "4.5") == "4.5"
assert VersionedDependency._merge_versions("", "4.5") == "4.5"
assert VersionedDependency._merge_versions("4.5", "") == "4.5"
assert VersionedDependency._merge_versions("", "") == ""
assert VersionedDependency._merge_versions("*", "*") == "*"
assert VersionedDependency._merge_versions("*", "") == ""
assert VersionedDependency._merge_versions("", "*") == "*"

assert VersionedDependency._merge_builds("", "") == ""
assert VersionedDependency._merge_builds("*", "*") == "*"
assert VersionedDependency._merge_builds(None, None) is None

assert VersionedDependency._merge_builds("", "2_kmp_llvm") == "2_kmp_llvm"
assert VersionedDependency._merge_builds("*", "2_kmp_llvm") == "2_kmp_llvm"
assert VersionedDependency._merge_builds("*_llvm", "2_kmp_llvm") == "2_kmp_llvm"
assert VersionedDependency._merge_builds("*_llvm", "*_kmp_llvm") == "*_kmp_llvm"
assert VersionedDependency._merge_builds(None, "2_kmp_llvm") == "2_kmp_llvm"

assert VersionedDependency._merge_builds("2_kmp_llvm", "") == "2_kmp_llvm"
assert VersionedDependency._merge_builds("2_kmp_llvm", "*") == "2_kmp_llvm"
assert VersionedDependency._merge_builds("2_kmp_llvm", "*_llvm") == "2_kmp_llvm"
assert VersionedDependency._merge_builds("*_kmp_llvm", "*_llvm") == "*_kmp_llvm"
assert VersionedDependency._merge_builds("2_kmp_llvm", None) == "2_kmp_llvm"


def test_aggregate_lock_specs_combine_build_incompatible():
first_spec = LockSpecification(
dependencies={
"linux-64": [
VersionedDependency(
name="openblas", version="0.3.20", build="openmp_h53a8fd6_2"
),
]
},
channels=[Channel.from_string("conda-forge")],
sources=[Path("base.yml")],
)

second_spec = LockSpecification(
dependencies={
"linux-64": [
VersionedDependency(
name="openblas", version="0.3.20", build="openmp_h53a8fd6_1"
),
]
},
channels=[Channel.from_string("internal"), Channel.from_string("conda-forge")],
sources=[Path("second.yml")],
)

assert agg_spec.dependencies == override_spec.dependencies
with pytest.raises(ValueError):
aggregate_lock_specs([first_spec, second_spec], platforms=["linux-64"])


def test_aggregate_lock_specs_invalid_channels():
Expand Down