From a5fafd94b05f37c252f96561b18ca5f8e2487ebb Mon Sep 17 00:00:00 2001 From: Rohan Devasthale Date: Wed, 25 Mar 2026 15:04:13 -0400 Subject: [PATCH] feat(resolver): add VersionMapProvider for custom version resolution Introduces VersionMapProvider class that wraps the VersionMap interface, allowing it to be used as a resolver provider in override hooks. This enables packages to define custom version-to-URL mappings that integrate seamlessly with the existing resolver infrastructure (PyPIProvider, GitHubTagProvider, etc.). The provider supports constraints, caching, and follows the same patterns as other resolver providers, making it suitable for use in get_resolver_provider override hooks. Co-Authored-By: Claude Sonnet 4.5 Signed-off-by: Rohan Devasthale --- src/fromager/resolver.py | 61 +++++++++++++++++++++++++++- src/fromager/versionmap.py | 10 +++++ tests/test_resolver.py | 81 ++++++++++++++++++++++++++++++++++++++ tests/test_versionmap.py | 23 +++++++++++ 4 files changed, 174 insertions(+), 1 deletion(-) 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"]