Skip to content
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
3 changes: 1 addition & 2 deletions src/fromager/resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
69 changes: 46 additions & 23 deletions src/fromager/versionmap.py
Original file line number Diff line number Diff line change
@@ -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]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it necessary to introduce a new function? The items() method from Mapping ABC already returns key/value pairs.

def lookup(
self,
req: Requirement,
Expand Down
16 changes: 8 additions & 8 deletions tests/test_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
)

Expand Down Expand Up @@ -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",
}
)

Expand All @@ -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",
}
)

Expand Down
62 changes: 41 additions & 21 deletions tests/test_versionmap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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")
Expand Down Expand Up @@ -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")) == (
Expand All @@ -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):
Expand All @@ -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")]
Loading