Skip to content

Commit e2ed294

Browse files
tiranclaude
andcommitted
feat(finders): add PyPICacheProvider for cache server lookups
Add a dedicated PyPICacheProvider that wraps PyPIProvider with a simplified interface for Fromager's local PyPI-compatible cache server. This makes it explicit that packages are coming from a cache server rather than an upstream index, and eliminates cache-irrelevant options like ignore_platform, override_download_url, cooldown, and supports_upload_time. The provider enforces that include_wheels and include_sdists are mutually exclusive and defaults to wheels only. Use PyPICacheProvider in _download_wheel_from_cache to replace the direct PyPIProvider instantiation. Co-Authored-By: Claude <claude@anthropic.com> Signed-off-by: Christian Heimes <cheimes@redhat.com>
1 parent 7b10135 commit e2ed294

4 files changed

Lines changed: 97 additions & 17 deletions

File tree

src/fromager/bootstrapper.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -907,13 +907,9 @@ def _download_wheel_from_cache(
907907
f"checking if wheel was already uploaded to {self.cache_wheel_server_url}"
908908
)
909909
try:
910-
# Use PyPIProvider directly for cache lookups, bypassing resolver
911-
# hooks. Cache servers are always simple PyPI index servers.
912910
pinned_req = Requirement(f"{req.name}=={resolved_version}")
913-
provider = resolver.PyPIProvider(
914-
sdist_server_url=self.cache_wheel_server_url,
915-
include_sdists=False,
916-
include_wheels=True,
911+
provider = finders.PyPICacheProvider(
912+
cache_server_url=self.cache_wheel_server_url,
917913
constraints=self.ctx.constraints,
918914
)
919915
results = resolver.find_all_matching_from_provider(provider, pinned_req)

src/fromager/finders.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,64 @@
88
from packaging.requirements import Requirement
99
from packaging.utils import BuildTag, canonicalize_name
1010

11-
from . import overrides
11+
from . import overrides, resolver
12+
from .constraints import Constraints
13+
from .requirements_file import RequirementType
1214

1315
if typing.TYPE_CHECKING:
1416
from . import context
1517

1618
logger = logging.getLogger(__name__)
1719

1820

21+
class PyPICacheProvider(resolver.PyPIProvider):
22+
"""Provider for Fromager's PyPI-compatible cache server.
23+
24+
Wraps ``PyPIProvider`` with a simplified interface tailored for cache
25+
usage: no ``ignore_platform``, no ``override_download_url``, no
26+
``supports_upload_time``, and ``cooldown`` is always ``None``.
27+
28+
``include_wheels`` and ``include_sdists`` are mutually exclusive;
29+
exactly one must be ``True``. Defaults to wheels only.
30+
31+
.. caution::
32+
Only use the ``PyPICacheProvider`` with an internal and fully-trusted
33+
cache index. Packages are not subject to cooldown or other checks.
34+
"""
35+
36+
provider_description: typing.ClassVar[str] = (
37+
"PyPI cache resolver (searching at {self.sdist_server_url})"
38+
)
39+
40+
def __init__(
41+
self,
42+
*,
43+
cache_server_url: str,
44+
include_sdists: bool = False,
45+
include_wheels: bool = True,
46+
constraints: Constraints | None = None,
47+
req_type: RequirementType | None = None,
48+
use_resolver_cache: bool = True,
49+
):
50+
if include_sdists == include_wheels:
51+
raise ValueError(
52+
"include_sdists and include_wheels are mutually exclusive, "
53+
"exactly one must be True"
54+
)
55+
super().__init__(
56+
include_sdists=include_sdists,
57+
include_wheels=include_wheels,
58+
sdist_server_url=cache_server_url,
59+
constraints=constraints,
60+
req_type=req_type,
61+
ignore_platform=False,
62+
use_resolver_cache=use_resolver_cache,
63+
override_download_url=None,
64+
cooldown=None,
65+
supports_upload_time=False,
66+
)
67+
68+
1969
def _dist_name_to_filename(dist_name: str) -> str:
2070
"""Transform the dist name into a prefix for a filename.
2171

tests/test_bootstrapper.py

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -376,18 +376,18 @@ def mock_dispatch(item: bootstrapper.WorkItem) -> list[bootstrapper.WorkItem]:
376376

377377

378378
@patch("fromager.resolver.find_all_matching_from_provider")
379-
@patch("fromager.resolver.PyPIProvider")
379+
@patch("fromager.finders.PyPICacheProvider")
380380
def test_download_wheel_from_cache_bypasses_hooks(
381-
mock_pypi_provider: Mock,
381+
mock_cache_provider: Mock,
382382
mock_find_all: Mock,
383383
tmp_context: WorkContext,
384384
) -> None:
385-
"""Verify _download_wheel_from_cache uses PyPIProvider directly, not hooks."""
385+
"""Verify _download_wheel_from_cache uses PyPICacheProvider, not hooks."""
386386
bt = bootstrapper.Bootstrapper(tmp_context)
387-
bt.cache_wheel_server_url = "https://cache.example.com/simple/"
387+
bt.cache_wheel_server_url = "https://cache.test/simple/"
388388

389389
mock_provider = Mock()
390-
mock_pypi_provider.return_value = mock_provider
390+
mock_cache_provider.return_value = mock_provider
391391
# Raise so the except clause returns (None, None) before hitting
392392
# network calls later in the function.
393393
mock_find_all.side_effect = RuntimeError("no match")
@@ -403,11 +403,9 @@ def test_download_wheel_from_cache_bypasses_hooks(
403403
# Hook system must NOT be called for cache lookups
404404
mock_override.assert_not_called()
405405

406-
# PyPIProvider must be instantiated directly
407-
mock_pypi_provider.assert_called_once_with(
408-
sdist_server_url="https://cache.example.com/simple/",
409-
include_sdists=False,
410-
include_wheels=True,
406+
# PyPICacheProvider must be instantiated directly
407+
mock_cache_provider.assert_called_once_with(
408+
cache_server_url="https://cache.test/simple/",
411409
constraints=tmp_context.constraints,
412410
)
413411
mock_find_all.assert_called_once_with(mock_provider, Requirement("test-pkg==1.0.0"))

tests/test_finders.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from packaging.requirements import Requirement
55

66
from fromager import context, finders
7+
from fromager.requirements_file import RequirementType
78

89

910
@pytest.mark.parametrize(
@@ -110,3 +111,38 @@ def test_find_source_dir(
110111
req = Requirement(dist_name)
111112
actual = finders.find_source_dir(tmp_context, work_dir, req, version_string)
112113
assert str(source_dir) == str(actual)
114+
115+
116+
def test_pypi_cache_provider() -> None:
117+
url = "https://cache.test/simple/"
118+
119+
# defaults: wheels only, hardcoded attributes
120+
provider = finders.PyPICacheProvider(cache_server_url=url)
121+
assert provider.sdist_server_url == url
122+
assert provider.include_sdists is False
123+
assert provider.include_wheels is True
124+
assert provider.ignore_platform is False
125+
assert provider.override_download_url is None
126+
assert provider.cooldown is None
127+
assert provider.supports_upload_time is False
128+
129+
# sdists only with req_type
130+
provider = finders.PyPICacheProvider(
131+
cache_server_url=url,
132+
include_sdists=True,
133+
include_wheels=False,
134+
req_type=RequirementType.TOP_LEVEL,
135+
)
136+
assert provider.include_sdists is True
137+
assert provider.include_wheels is False
138+
assert provider.req_type == RequirementType.TOP_LEVEL
139+
140+
# mutually exclusive: both True or both False
141+
with pytest.raises(ValueError, match="mutually exclusive"):
142+
finders.PyPICacheProvider(
143+
cache_server_url=url, include_sdists=True, include_wheels=True
144+
)
145+
with pytest.raises(ValueError, match="mutually exclusive"):
146+
finders.PyPICacheProvider(
147+
cache_server_url=url, include_sdists=False, include_wheels=False
148+
)

0 commit comments

Comments
 (0)