Skip to content
Merged
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
61 changes: 60 additions & 1 deletion src/fromager/resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
10 changes: 10 additions & 0 deletions src/fromager/versionmap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()))
Expand Down
81 changes: 81 additions & 0 deletions tests/test_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = """
[
{
Expand Down
23 changes: 23 additions & 0 deletions tests/test_versionmap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Loading