diff --git a/src/fromager/resolver.py b/src/fromager/resolver.py index d6790ef2..44f81465 100644 --- a/src/fromager/resolver.py +++ b/src/fromager/resolver.py @@ -1065,8 +1065,7 @@ def find_candidates(self, identifier: str) -> Candidates: objects with the associated URLs. """ candidates: list[Candidate] = [] - for version in self.version_map.versions(): - url = self.version_map[version] + for version, url in self.version_map.iter_pairs(): candidate = Candidate( name=identifier, version=version, diff --git a/src/fromager/versionmap.py b/src/fromager/versionmap.py index 280d0f9f..c8cb84c9 100644 --- a/src/fromager/versionmap.py +++ b/src/fromager/versionmap.py @@ -1,49 +1,72 @@ """VersionMap interface for managing package settings in plugins.""" import typing +from collections.abc import Iterator, Mapping from packaging.requirements import Requirement from packaging.version import Version -class VersionMap: +class VersionMap(Mapping[Version, typing.Any]): + """Read-only mapping protocol over versions with helpers for resolution. + + Keys must be :class:`packaging.version.Version` instances; callers are + responsible for parsing strings. Mutate the map via :meth:`add`. + """ + + _content: dict[Version, typing.Any] + def __init__( - self, initial_content: dict[Version | str, typing.Any] | None = None + self, initial_content: Mapping[Version, typing.Any] | None = None ) -> None: - """Initialize the VersionMap + """Initialize the VersionMap. - Stores the inputs associating versions and arbitrary data. If the - versions are strings, they are converted to Version instances - internally. Any exceptions from the conversion are propagated. + Stores associations between versions and arbitrary values (for example + download URLs for resolution). """ - self._content: dict[Version, typing.Any] = {} + self._content = {} for k, v in (initial_content or {}).items(): self.add(k, v) - def add(self, key: Version | str, value: typing.Any) -> None: - """Add a single value associated with a version - - String keys are converted to Version instances. Any exceptions from the - conversion are propagated. - """ + def add(self, key: Version, value: typing.Any) -> None: + """Associate a value with a version.""" if not isinstance(key, Version): - key = Version(key) + msg = ( + "VersionMap keys must be packaging.version.Version instances, " + f"not {type(key).__name__}" + ) + raise TypeError(msg) 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. - """ + def __getitem__(self, key: Version) -> typing.Any: + """Return the value for a version. Raises KeyError if missing.""" if not isinstance(key, Version): - key = Version(key) + msg = ( + "VersionMap keys must be packaging.version.Version instances, " + f"not {type(key).__name__}" + ) + raise TypeError(msg) return self._content[key] - def versions(self) -> typing.Iterable[Version]: - """Return the known versions, sorted in descending order.""" + def __iter__(self) -> Iterator[Version]: + """Iterate versions in descending order (highest first).""" return reversed(sorted(self._content.keys())) + def __len__(self) -> int: + return len(self._content) + + def versions(self) -> Iterator[Version]: + """Return known versions, sorted in descending order.""" + return iter(self) + + def iter_pairs(self) -> Iterator[tuple[Version, typing.Any]]: + """Yield ``(version, value)`` tuples in descending version order. + + Typical use is iteration over versions and URLs for custom providers. + """ + for version in self.versions(): + yield version, self._content[version] + def lookup( self, req: Requirement, diff --git a/tests/test_resolver.py b/tests/test_resolver.py index 2121a867..0052b4e0 100644 --- a/tests/test_resolver.py +++ b/tests/test_resolver.py @@ -844,9 +844,9 @@ def test_resolve_versionmap() -> None: 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", + Version("1.2"): "https://example.com/pkg-1.2.tar.gz", + Version("1.3"): "https://example.com/pkg-1.3.tar.gz", + Version("1.4.1"): "https://example.com/pkg-1.4.1.tar.gz", } ) @@ -877,9 +877,9 @@ def test_resolve_versionmap_with_constraint() -> None: 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", + Version("1.2"): "https://example.com/pkg-1.2.tar.gz", + Version("1.3"): "https://example.com/pkg-1.3.tar.gz", + Version("1.4.1"): "https://example.com/pkg-1.4.1.tar.gz", } ) @@ -905,8 +905,8 @@ def test_resolve_versionmap_no_match() -> None: version_map = VersionMap( { - "1.2": "https://example.com/pkg-1.2.tar.gz", - "1.3": "https://example.com/pkg-1.3.tar.gz", + Version("1.2"): "https://example.com/pkg-1.2.tar.gz", + Version("1.3"): "https://example.com/pkg-1.3.tar.gz", } ) diff --git a/tests/test_versionmap.py b/tests/test_versionmap.py index b0d75f1e..783d9cce 100644 --- a/tests/test_versionmap.py +++ b/tests/test_versionmap.py @@ -8,20 +8,25 @@ def test_initialize() -> None: m = VersionMap( { - "1.2": "value for 1.2", + Version("1.2"): "value for 1.2", Version("1.3"): "value for 1.3", - "1.0": "value for 1.0", + Version("1.0"): "value for 1.0", } ) assert list(m.versions()) == [Version("1.3"), Version("1.2"), Version("1.0")] + assert list(m.iter_pairs()) == [ + (Version("1.3"), "value for 1.3"), + (Version("1.2"), "value for 1.2"), + (Version("1.0"), "value for 1.0"), + ] def test_lookup() -> None: m = VersionMap( { - "1.2": "value for 1.2", + Version("1.2"): "value for 1.2", Version("1.3"): "value for 1.3", - "1.0": "value for 1.0", + Version("1.0"): "value for 1.0", } ) assert m.lookup(Requirement("pkg")) == (Version("1.3"), "value for 1.3") @@ -33,10 +38,10 @@ def test_prerelease() -> None: m = VersionMap( { Version("0.4.1b0"): "value for 0.4.1b0", - "1.2": "value for 1.2", + Version("1.2"): "value for 1.2", Version("1.3"): "value for 1.3", - "1.0": "value for 1.0", - "1.5.0a0": "value for 1.5.0a0", + Version("1.0"): "value for 1.0", + Version("1.5.0a0"): "value for 1.5.0a0", } ) assert m.lookup(Requirement("pkg")) == (Version("1.3"), "value for 1.3") @@ -73,9 +78,9 @@ def test_only_prerelease() -> None: def test_with_constraint() -> None: m = VersionMap( { - "1.2": "value for 1.2", + Version("1.2"): "value for 1.2", Version("1.3"): "value for 1.3", - "1.0": "value for 1.0", + Version("1.0"): "value for 1.0", } ) assert m.lookup(Requirement("pkg"), Requirement("pkg<1.3")) == ( @@ -91,9 +96,9 @@ def test_with_constraint() -> None: def test_no_match() -> None: m = VersionMap( { - "1.2": "value for 1.2", + Version("1.2"): "value for 1.2", Version("1.3"): "value for 1.3", - "1.0": "value for 1.0", + Version("1.0"): "value for 1.0", } ) with pytest.raises(ValueError): @@ -105,21 +110,36 @@ def test_no_match() -> None: def test_getitem() -> None: m = VersionMap( { - "1.2": "value for 1.2", + Version("1.2"): "value for 1.2", Version("1.3"): "value for 1.3", - "1.0": "value for 1.0", + Version("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"] + + +def test_str_keys_rejected() -> None: + m: VersionMap = VersionMap() + with pytest.raises(TypeError, match="Version"): + m.add("1.0", "x") # type: ignore[arg-type] + m_clean = VersionMap({Version("1.0"): "ok"}) + with pytest.raises(TypeError, match="Version"): + _ = m_clean["1.0"] # type: ignore[index] + + +def test_mapping_interface() -> None: + m = VersionMap( + { + Version("1.2"): "a", + Version("1.0"): "b", + } + ) + assert len(m) == 2 + assert Version("1.2") in m + assert list(m.keys()) == [Version("1.2"), Version("1.0")] + assert list(m.values()) == ["a", "b"] + assert list(m.items()) == [(Version("1.2"), "a"), (Version("1.0"), "b")]