diff --git a/src/fromager/resolver.py b/src/fromager/resolver.py index 2fc100b2..bc1de09e 100644 --- a/src/fromager/resolver.py +++ b/src/fromager/resolver.py @@ -37,6 +37,7 @@ from .http_retry import RETRYABLE_EXCEPTIONS, retry_on_exception from .request_session import session from .requirements_file import RequirementType +from .versionmap import VersionMap if typing.TYPE_CHECKING: from . import context @@ -109,7 +110,13 @@ def default_resolver_provider( include_wheels: bool, req_type: RequirementType | None = None, ignore_platform: bool = False, -) -> PyPIProvider | GenericProvider | GitHubTagProvider: +) -> ( + PyPIProvider + | GenericProvider + | GitHubTagProvider + | GitLabTagProvider + | VersionMapProvider +): """Lookup resolver provider to resolve package versions""" return PyPIProvider( include_sdists=include_sdists, @@ -951,3 +958,55 @@ def _find_tags( # GitLab API uses Link headers for pagination nexturl = resp.links.get("next", {}).get("url") + + +class VersionMapProvider(BaseProvider): + """Lookup package versions from a VersionMap + + This provider wraps a VersionMap instance to provide versions and URLs + for package resolution. The VersionMap should contain Version keys mapped + to URL strings. + """ + + provider_description: typing.ClassVar[str] = ( + "VersionMap resolver (package: {self.package_name})" + ) + + def __init__( + self, + version_map: VersionMap, + package_name: str, + constraints: Constraints | None = None, + *, + req_type: RequirementType | None = None, + use_resolver_cache: bool = True, + ) -> None: + super().__init__( + constraints=constraints, + req_type=req_type, + use_resolver_cache=use_resolver_cache, + ) + self.version_map = version_map + self.package_name = package_name + + @property + def cache_key(self) -> str: + return f"versionmap:{self.package_name}" + + def find_candidates(self, identifier: str) -> Candidates: + """Find candidates from the VersionMap + + Iterates through all versions in the VersionMap and creates Candidate + objects with the associated URLs. + """ + candidates: list[Candidate] = [] + for version in self.version_map.versions(): + url = self.version_map[version] + candidate = Candidate( + name=identifier, + version=version, + url=url, + ) + candidates.append(candidate) + + return candidates diff --git a/src/fromager/versionmap.py b/src/fromager/versionmap.py index 7dfacf54..280d0f9f 100644 --- a/src/fromager/versionmap.py +++ b/src/fromager/versionmap.py @@ -30,6 +30,16 @@ def add(self, key: Version | str, value: typing.Any) -> None: key = Version(key) self._content[key] = value + def __getitem__(self, key: Version | str) -> typing.Any: + """Get the value associated with a version + + String keys are converted to Version instances. Raises KeyError if the + version is not found. + """ + if not isinstance(key, Version): + key = Version(key) + return self._content[key] + def versions(self) -> typing.Iterable[Version]: """Return the known versions, sorted in descending order.""" return reversed(sorted(self._content.keys())) diff --git a/tests/test_resolver.py b/tests/test_resolver.py index c5c491ea..a6684c7a 100644 --- a/tests/test_resolver.py +++ b/tests/test_resolver.py @@ -792,6 +792,87 @@ def _versions(*args: typing.Any, **kwds: typing.Any) -> list[tuple[str, str]]: assert provider.cache_key +def test_resolve_versionmap() -> None: + from fromager.versionmap import VersionMap + + version_map = VersionMap( + { + "1.2": "https://example.com/pkg-1.2.tar.gz", + "1.3": "https://example.com/pkg-1.3.tar.gz", + "1.4.1": "https://example.com/pkg-1.4.1.tar.gz", + } + ) + + provider = resolver.VersionMapProvider( + version_map=version_map, package_name="testpkg" + ) + reporter: resolvelib.BaseReporter = resolvelib.BaseReporter() + rslvr = resolvelib.Resolver(provider, reporter) + + result = rslvr.resolve([Requirement("testpkg")]) + assert "testpkg" in result.mapping + + candidate = result.mapping["testpkg"] + assert str(candidate.version) == "1.4.1" + assert candidate.url == "https://example.com/pkg-1.4.1.tar.gz" + + # VersionMapProvider uses resolver cache by default + cache = resolver.BaseProvider.resolver_cache + assert "testpkg" in cache + cached_candidates = cache["testpkg"][ + (resolver.VersionMapProvider, "versionmap:testpkg") + ] + assert len(cached_candidates) == 3 + + +def test_resolve_versionmap_with_constraint() -> None: + from fromager.versionmap import VersionMap + + version_map = VersionMap( + { + "1.2": "https://example.com/pkg-1.2.tar.gz", + "1.3": "https://example.com/pkg-1.3.tar.gz", + "1.4.1": "https://example.com/pkg-1.4.1.tar.gz", + } + ) + + c = constraints.Constraints() + c.add_constraint("testpkg<1.4") + + provider = resolver.VersionMapProvider( + version_map=version_map, package_name="testpkg", constraints=c + ) + reporter: resolvelib.BaseReporter = resolvelib.BaseReporter() + rslvr = resolvelib.Resolver(provider, reporter) + + result = rslvr.resolve([Requirement("testpkg")]) + assert "testpkg" in result.mapping + + candidate = result.mapping["testpkg"] + assert str(candidate.version) == "1.3" + assert candidate.url == "https://example.com/pkg-1.3.tar.gz" + + +def test_resolve_versionmap_no_match() -> None: + from fromager.versionmap import VersionMap + + version_map = VersionMap( + { + "1.2": "https://example.com/pkg-1.2.tar.gz", + "1.3": "https://example.com/pkg-1.3.tar.gz", + } + ) + + provider = resolver.VersionMapProvider( + version_map=version_map, package_name="testpkg" + ) + reporter: resolvelib.BaseReporter = resolvelib.BaseReporter() + rslvr = resolvelib.Resolver(provider, reporter) + + with pytest.raises(resolvelib.resolvers.ResolverException): + rslvr.resolve([Requirement("testpkg>=2.0")]) + + _gitlab_submodlib_repo_response = """ [ { diff --git a/tests/test_versionmap.py b/tests/test_versionmap.py index f2bf9940..b0d75f1e 100644 --- a/tests/test_versionmap.py +++ b/tests/test_versionmap.py @@ -100,3 +100,26 @@ def test_no_match() -> None: m.lookup(Requirement("pkg"), Requirement("pkg<1.0")) with pytest.raises(ValueError): m.lookup(Requirement("pkg>1.0"), Requirement("pkg<1.0")) + + +def test_getitem() -> None: + m = VersionMap( + { + "1.2": "value for 1.2", + Version("1.3"): "value for 1.3", + "1.0": "value for 1.0", + } + ) + # Access by Version object + assert m[Version("1.2")] == "value for 1.2" + assert m[Version("1.3")] == "value for 1.3" + + # Access by string (auto-converted to Version) + assert m["1.2"] == "value for 1.2" + assert m["1.0"] == "value for 1.0" + + # Non-existent version raises KeyError + with pytest.raises(KeyError): + m[Version("2.0")] + with pytest.raises(KeyError): + m["2.0"]