Skip to content

Commit 52719f9

Browse files
tiranclaude
andcommitted
feat(resolver): add LocalIndexProvider
`LocalIndexProvider` resolves packages from Fromager's local cache directories by scanning for wheel and sdist files. It supports flat and nested (PyPI-like) directory layouts. Caching is disabled since `os.scandir` and `parse_sdist/wheel_filename` are fast (1-3 microseconds per file). In the future, `fromager.finders`' `find_sdist()` and `find_wheel()` will use `LocalIndexProvider` internally. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Christian Heimes <cheimes@redhat.com>
1 parent 7b10135 commit 52719f9

2 files changed

Lines changed: 296 additions & 4 deletions

File tree

src/fromager/finders.py

Lines changed: 135 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,153 @@
11
from __future__ import annotations
22

33
import logging
4+
import os
45
import pathlib
56
import re
67
import typing
78

89
from packaging.requirements import Requirement
9-
from packaging.utils import BuildTag, canonicalize_name
10-
11-
from . import overrides
10+
from packaging.tags import Tag
11+
from packaging.utils import (
12+
BuildTag,
13+
canonicalize_name,
14+
parse_sdist_filename,
15+
parse_wheel_filename,
16+
)
17+
18+
from . import overrides, requirements_file, resolver
19+
from .candidate import Candidate
20+
from .constraints import Constraints
1221

1322
if typing.TYPE_CHECKING:
1423
from . import context
1524

1625
logger = logging.getLogger(__name__)
1726

1827

28+
class LocalIndexProvider(resolver.BaseProvider):
29+
"""Lookup distributions in a local directory
30+
31+
The local index provider is designed for Fromager's internal cache
32+
directories. It looks up sdist and wheels files in a local directory or
33+
directory tree. A flat provider has all files in one directory. If flat
34+
is false, then the provider expects nested directories like on PyPI:
35+
36+
- flat: ``{path}/meson_python-0.19.0-2-py3-none-any.whl``
37+
- nested: ``{path}/meson-python/meson_python-0.19.0-2-py3-none-any.whl``
38+
39+
Caching is disabled, so the provider picks up local changes immedately.
40+
Local file lookups and wheel/sdist filename parsing are fast.
41+
"""
42+
43+
provider_description: typing.ClassVar[str] = (
44+
"Local provider (path: {self.path}, flat: {self.flat})"
45+
)
46+
47+
def __init__(
48+
self,
49+
*,
50+
path: pathlib.Path,
51+
flat: bool,
52+
include_sdists: bool = True,
53+
include_wheels: bool = True,
54+
constraints: Constraints | None = None,
55+
req_type: requirements_file.RequirementType | None = None,
56+
):
57+
super().__init__(
58+
constraints=constraints,
59+
req_type=req_type,
60+
use_resolver_cache=False,
61+
cooldown=None,
62+
)
63+
self.path = path
64+
self.flat = flat
65+
self.include_sdists = include_sdists
66+
self.include_wheels = include_wheels
67+
self.supports_upload_time = False
68+
69+
@property
70+
def cache_key(self) -> str:
71+
"""No caching, local lookup is fast"""
72+
raise NotImplementedError()
73+
74+
def find_candidates(self, identifier: str) -> resolver.Candidates:
75+
"""Find matching distribution files for the given package identifier."""
76+
normalized_identifier = canonicalize_name(identifier)
77+
# directory uses normalized name with dash
78+
path = self.path if self.flat else self.path / normalized_identifier
79+
80+
try:
81+
entries = os.scandir(path)
82+
except OSError as err:
83+
logger.debug("cannot read from directory %r: %s", path, err)
84+
return []
85+
86+
candidates: list[Candidate] = []
87+
for entry in entries:
88+
if not entry.is_file():
89+
# not a file, ignore
90+
continue
91+
92+
if entry.name.endswith((".tar.gz", ".zip")):
93+
if not self.include_sdists:
94+
if resolver.DEBUG_RESOLVER:
95+
logger.debug("skipping %r because it is an sdist", entry.name)
96+
continue
97+
is_sdist = True
98+
elif entry.name.endswith(".whl"):
99+
if not self.include_wheels:
100+
if resolver.DEBUG_RESOLVER:
101+
logger.debug("skipping %r because it is a wheel", entry.name)
102+
continue
103+
is_sdist = False
104+
else:
105+
# ignore other files
106+
continue
107+
108+
try:
109+
if is_sdist:
110+
name, version = parse_sdist_filename(entry.name)
111+
tags: frozenset[Tag] = frozenset()
112+
build_tag: BuildTag = ()
113+
else:
114+
name, version, build_tag, tags = parse_wheel_filename(entry.name)
115+
except Exception as err:
116+
# Ignore files with invalid versions
117+
if resolver.DEBUG_RESOLVER:
118+
logger.debug("invalid file name %r: %s", entry.name, err)
119+
continue
120+
121+
if name != normalized_identifier:
122+
if resolver.DEBUG_RESOLVER:
123+
logger.debug(
124+
"dist name %r of %r does not match identifier %r",
125+
name,
126+
entry.name,
127+
identifier,
128+
)
129+
continue
130+
131+
if not is_sdist and not tags.intersection(resolver.SUPPORTED_TAGS):
132+
# ignore wheels unless they have supported tags
133+
if resolver.DEBUG_RESOLVER:
134+
logger.debug("ignoring %r with tags %r", entry.name, tags)
135+
continue
136+
137+
c = Candidate(
138+
name=name,
139+
version=version,
140+
url=entry.path,
141+
is_sdist=is_sdist,
142+
build_tag=build_tag,
143+
has_metadata=False,
144+
upload_time=None,
145+
)
146+
candidates.append(c)
147+
148+
return candidates
149+
150+
19151
def _dist_name_to_filename(dist_name: str) -> str:
20152
"""Transform the dist name into a prefix for a filename.
21153

tests/test_finders.py

Lines changed: 161 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import pathlib
22

33
import pytest
4+
import resolvelib
45
from packaging.requirements import Requirement
6+
from packaging.version import Version
57

6-
from fromager import context, finders
8+
from fromager import constraints, context, finders
79

810

911
@pytest.mark.parametrize(
@@ -110,3 +112,161 @@ def test_find_source_dir(
110112
req = Requirement(dist_name)
111113
actual = finders.find_source_dir(tmp_context, work_dir, req, version_string)
112114
assert str(source_dir) == str(actual)
115+
116+
117+
# -- LocalIndexProvider ------------------------------------------------------
118+
119+
120+
def _create_local_file(directory: pathlib.Path, filename: str) -> pathlib.Path:
121+
"""Create an empty file in the given directory."""
122+
path = directory / filename
123+
path.touch()
124+
return path
125+
126+
127+
def test_local_init(tmp_path: pathlib.Path) -> None:
128+
provider = finders.LocalIndexProvider(path=tmp_path, flat=True)
129+
assert provider.path == tmp_path
130+
assert provider.flat is True
131+
assert provider.include_sdists is True
132+
assert provider.include_wheels is True
133+
assert provider.supports_upload_time is False
134+
assert provider.use_cache_candidates is False
135+
assert provider.cooldown is None
136+
137+
with pytest.raises(NotImplementedError):
138+
_ = provider.cache_key
139+
140+
desc = provider.get_provider_description()
141+
assert "Local" in desc
142+
assert str(tmp_path) in desc
143+
144+
145+
def test_local_find_candidates_flat(tmp_path: pathlib.Path) -> None:
146+
"""Flat mode finds wheels, sdists, and build-tagged wheels in one directory."""
147+
# wheel and sdist with same version
148+
_create_local_file(tmp_path, "example_pkg-1.0.0-py3-none-any.whl")
149+
_create_local_file(tmp_path, "example_pkg-1.0.0.tar.gz")
150+
# sdist filenames can use non-normalized names (dash, underscore, dot)
151+
_create_local_file(tmp_path, "example-pkg-2.0.0.tar.gz")
152+
_create_local_file(tmp_path, "example.pkg-3.0.0.tar.gz")
153+
_create_local_file(tmp_path, "example_pkg-4.0.0-2-py3-none-any.whl")
154+
# noise: other package, directory shaped like a wheel, non-dist file, invalid sdist
155+
_create_local_file(tmp_path, "other_pkg-1.0.0-py3-none-any.whl")
156+
tmp_path.joinpath("example_pkg-9.0.0-py3-none-any.whl").mkdir()
157+
_create_local_file(tmp_path, "README.md")
158+
_create_local_file(tmp_path, "not-a-valid-sdist.tar.gz")
159+
160+
provider = finders.LocalIndexProvider(path=tmp_path, flat=True)
161+
candidates = list(provider.find_candidates("example-pkg"))
162+
163+
# find_candidates filters by normalized name; other_pkg is excluded
164+
assert len(candidates) == 5
165+
assert all(c.name == "example-pkg" for c in candidates)
166+
167+
# version 1.0.0 has both a wheel and an sdist
168+
v1 = [c for c in candidates if c.version == Version("1.0.0")]
169+
assert len(v1) == 2
170+
assert {c.is_sdist for c in v1} == {True, False}
171+
whl = next(c for c in v1 if not c.is_sdist)
172+
assert whl.upload_time is None
173+
assert whl.has_metadata is False
174+
175+
# all sdist name variants normalize to example-pkg
176+
sdists = [c for c in candidates if c.is_sdist]
177+
assert {str(c.version) for c in sdists} == {"1.0.0", "2.0.0", "3.0.0"}
178+
179+
assert next(c for c in candidates if c.version == Version("4.0.0")).build_tag == (
180+
2,
181+
"",
182+
)
183+
184+
185+
def test_local_find_candidates_nested(tmp_path: pathlib.Path) -> None:
186+
"""Nested mode looks up a subdirectory using the canonicalized name."""
187+
pkg_dir = tmp_path / "my-package"
188+
pkg_dir.mkdir()
189+
_create_local_file(pkg_dir, "my_package-1.0.0-py3-none-any.whl")
190+
191+
provider = finders.LocalIndexProvider(path=tmp_path, flat=False)
192+
193+
# Various spellings should all resolve via canonicalize_name
194+
for name in ("My_Package", "my.package", "MY-PACKAGE", "my-package"):
195+
candidates = list(provider.find_candidates(name))
196+
assert len(candidates) == 1, f"failed for {name!r}"
197+
assert candidates[0].name == "my-package"
198+
assert candidates[0].version == Version("1.0.0")
199+
200+
# missing subdirectory returns no candidates
201+
assert list(provider.find_candidates("no-such-pkg")) == []
202+
203+
204+
def test_local_find_candidates_include_flags(tmp_path: pathlib.Path) -> None:
205+
"""include_sdists=False / include_wheels=False filters correctly."""
206+
_create_local_file(tmp_path, "example_pkg-1.0.0.tar.gz")
207+
_create_local_file(tmp_path, "example_pkg-1.0.0-py3-none-any.whl")
208+
209+
wheels_only = finders.LocalIndexProvider(
210+
path=tmp_path, flat=True, include_sdists=False
211+
)
212+
assert all(not c.is_sdist for c in wheels_only.find_candidates("example-pkg"))
213+
214+
sdists_only = finders.LocalIndexProvider(
215+
path=tmp_path, flat=True, include_wheels=False
216+
)
217+
assert all(c.is_sdist for c in sdists_only.find_candidates("example-pkg"))
218+
219+
220+
def test_local_find_candidates_empty_dir(tmp_path: pathlib.Path) -> None:
221+
provider = finders.LocalIndexProvider(path=tmp_path, flat=True)
222+
assert list(provider.find_candidates("nonexistent-pkg")) == []
223+
224+
225+
def test_local_find_matches(tmp_path: pathlib.Path) -> None:
226+
"""find_matches sorts by version descending and respects constraints."""
227+
_create_local_file(tmp_path, "example_pkg-1.0.0-py3-none-any.whl")
228+
_create_local_file(tmp_path, "example_pkg-1.5.0-py3-none-any.whl")
229+
_create_local_file(tmp_path, "example_pkg-2.0.0-py3-none-any.whl")
230+
_create_local_file(tmp_path, "other_pkg-5.0.0-py3-none-any.whl")
231+
232+
provider = finders.LocalIndexProvider(path=tmp_path, flat=True)
233+
req = Requirement("example-pkg")
234+
identifier = provider.identify(req)
235+
matches = list(
236+
provider.find_matches(
237+
identifier=identifier,
238+
requirements={identifier: [req]},
239+
incompatibilities={},
240+
)
241+
)
242+
# other_pkg is excluded by name; results sorted highest first
243+
assert [m.version for m in matches] == [
244+
Version("2.0.0"),
245+
Version("1.5.0"),
246+
Version("1.0.0"),
247+
]
248+
249+
# constraint filters out 2.0.0
250+
c = constraints.Constraints()
251+
c.add_constraint("example-pkg<2")
252+
provider_c = finders.LocalIndexProvider(path=tmp_path, flat=True, constraints=c)
253+
matches_c = list(
254+
provider_c.find_matches(
255+
identifier=identifier,
256+
requirements={identifier: [req]},
257+
incompatibilities={},
258+
)
259+
)
260+
assert [m.version for m in matches_c] == [Version("1.5.0"), Version("1.0.0")]
261+
262+
# no match raises
263+
provider_miss = finders.LocalIndexProvider(path=tmp_path, flat=True)
264+
req_miss = Requirement("example-pkg>=5.0")
265+
with pytest.raises(resolvelib.resolvers.ResolverException):
266+
list(
267+
provider_miss.find_matches(
268+
identifier=identifier,
269+
requirements={identifier: [req_miss]},
270+
incompatibilities={},
271+
)
272+
)

0 commit comments

Comments
 (0)