From dbd9afce389fb69af65db40711fa158f6057d3aa Mon Sep 17 00:00:00 2001 From: Justin Larkin Date: Tue, 15 Sep 2026 09:58:07 -0400 Subject: [PATCH 01/22] fix(sources): normalize sdist filenames per PEP 625 The default_build_sdist function was constructing sdist filenames using req.name directly, which could contain uppercase letters, dots, or hyphens that were not normalized per PEP 625. This caused filenames to disagree with upstream artifacts and broke filename-keyed comparison operations. Apply PEP 503 canonicalization (lowercase, replace [._-] with _) when constructing sdist filenames, ensuring compliance with PEP 625. Fixes #1317 Co-Authored-By: Claude Haiku 4.5 Signed-off-by: Justin Larkin --- src/fromager/sources.py | 3 ++- tests/test_sources.py | 44 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/fromager/sources.py b/src/fromager/sources.py index 534573d41..da7cc5926 100644 --- a/src/fromager/sources.py +++ b/src/fromager/sources.py @@ -13,6 +13,7 @@ import resolvelib from packaging.requirements import Requirement from packaging.utils import ( + canonicalize_name, parse_sdist_filename, ) from packaging.version import Version @@ -533,7 +534,7 @@ def default_build_sdist( # # For cases where the PEP 517 approach works, use # pep517_build_sdist(). - sdist_filename = ctx.sdists_builds / f"{req.name}-{version}.tar.gz" + sdist_filename = ctx.sdists_builds / f"{canonicalize_name(req.name).replace('-', '_')}-{version}.tar.gz" if sdist_filename.exists(): sdist_filename.unlink() ensure_pkg_info( diff --git a/tests/test_sources.py b/tests/test_sources.py index 1ed0904b8..c5f9e3d47 100644 --- a/tests/test_sources.py +++ b/tests/test_sources.py @@ -742,3 +742,47 @@ def test_build_sdist_raises_wrong_directory( sdist_root_dir=tmp_path / "src", build_env=build_env, ) + + +@pytest.mark.parametrize( + "req_name,expected_filename_part", + [ + ("colpali-engine", "colpali_engine"), # dashes -> underscores + ("Colpali-Engine", "colpali_engine"), # case-insensitive + normalization + ("Cython", "cython"), # case-insensitive + ("oslo.messaging", "oslo_messaging"), # dots -> underscores + ("ruamel-yaml", "ruamel_yaml"), # dashes -> underscores + ], +) +def test_default_build_sdist_normalizes_filename( + req_name: str, + expected_filename_part: str, + tmp_context: context.WorkContext, + tmp_path: pathlib.Path, +) -> None: + """Test that default_build_sdist creates normalized PEP 625 filenames.""" + req = Requirement(f"{req_name}==1.0.0") + version = Version("1.0.0") + sdist_root_dir = tmp_path / "src" + sdist_root_dir.mkdir(parents=True) + + build_env = Mock() + + with patch("fromager.packagesettings.get_extra_environ", return_value={}): + with patch("fromager.sources.ensure_pkg_info"): + with patch("fromager.sources.tarballs.tar_reproducible"): + # Call default_build_sdist + sdist_file = sources.default_build_sdist( + ctx=tmp_context, + extra_environ={}, + req=req, + version=version, + sdist_root_dir=sdist_root_dir, + build_env=build_env, + build_dir=sdist_root_dir, + ) + + # Verify the filename is normalized according to PEP 625 + expected_filename = f"{expected_filename_part}-1.0.0.tar.gz" + assert sdist_file.name == expected_filename + assert sdist_file.parent == tmp_context.sdists_builds From a8609e51e2b4e1f935dacd1cb7ffbc386a24cb77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Prpi=C4=8D?= Date: Tue, 15 Sep 2026 15:30:13 -0400 Subject: [PATCH 02/22] fix(dependencies): sort requirements files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_write_requirements_file()` wrote requirement sets in iteration order so rebuilds of identical sources could produced different files even though the content was the same. Co-authored-by: Claude Opus 4.8 Signed-off-by: Martin Prpič --- src/fromager/dependencies.py | 2 +- tests/test_dependencies.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/fromager/dependencies.py b/src/fromager/dependencies.py index d80899e85..18dcdbef7 100644 --- a/src/fromager/dependencies.py +++ b/src/fromager/dependencies.py @@ -574,7 +574,7 @@ def _write_requirements_file( filename: pathlib.Path, ) -> None: with open(filename, "w") as f: - for r in requirements: + for r in sorted(requirements, key=str): f.write(f"{r}\n") diff --git a/tests/test_dependencies.py b/tests/test_dependencies.py index 6f83a93e8..34a68ee82 100644 --- a/tests/test_dependencies.py +++ b/tests/test_dependencies.py @@ -179,6 +179,20 @@ def test_get_build_system_dependencies_cached( assert results == set([Requirement("foo==1.0")]) +def test_write_requirements_file_sorted(tmp_path: pathlib.Path) -> None: + """Verify the file content does not depend on input or set order.""" + req_file = tmp_path / "build-system-requirements.txt" + requirements = [ + Requirement("setuptools>=42"), + Requirement("setuptools-scm[toml]>=3.4"), + Requirement("cython"), + ] + dependencies._write_requirements_file(requirements, req_file) + assert req_file.read_text() == ( + "cython\nsetuptools-scm[toml]>=3.4\nsetuptools>=42\n" + ) + + @patch("fromager.dependencies._write_requirements_file") @_clean_build_artifacts @pytest.mark.network From 358253c86713f49ca09743a0a132a2d8ccfb14a4 Mon Sep 17 00:00:00 2001 From: Justin Larkin Date: Thu, 17 Sep 2026 17:35:58 -0400 Subject: [PATCH 03/22] fix(sources): break long line to meet 88-character limit Extract the normalized package name to a variable to keep lines within the project's configured line length limit of 88 characters. Co-Authored-By: Claude Haiku 4.5 Signed-off-by: Justin Larkin --- src/fromager/sources.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/fromager/sources.py b/src/fromager/sources.py index da7cc5926..5409a181f 100644 --- a/src/fromager/sources.py +++ b/src/fromager/sources.py @@ -534,7 +534,8 @@ def default_build_sdist( # # For cases where the PEP 517 approach works, use # pep517_build_sdist(). - sdist_filename = ctx.sdists_builds / f"{canonicalize_name(req.name).replace('-', '_')}-{version}.tar.gz" + normalized_name = canonicalize_name(req.name).replace("-", "_") + sdist_filename = ctx.sdists_builds / f"{normalized_name}-{version}.tar.gz" if sdist_filename.exists(): sdist_filename.unlink() ensure_pkg_info( From a70ff9c22211ac98d5d28f653b36ebc25363e5a8 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Fri, 18 Sep 2026 09:27:29 -0400 Subject: [PATCH 04/22] fix(resolver): warn when a version is skipped due to missing sdist Log a warning when a candidate version is filtered out because it has no sdist (wheel-only release). Previously this was gated behind DEBUG_RESOLVER and invisible at default verbosity. Fixes #1332 Co-Authored-By: Claude Opus 4.6 Signed-off-by: Ryan Petrello --- src/fromager/resolver.py | 20 +++++++++- tests/test_resolver.py | 82 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 1 deletion(-) diff --git a/src/fromager/resolver.py b/src/fromager/resolver.py index d23819bbb..b3067c7dd 100644 --- a/src/fromager/resolver.py +++ b/src/fromager/resolver.py @@ -1019,7 +1019,25 @@ def find_matches( requirements: RequirementsMap, incompatibilities: CandidatesMap, ) -> Candidates: - return super().find_matches(identifier, requirements, incompatibilities) + candidates = super().find_matches(identifier, requirements, incompatibilities) + if not self.include_wheels: + accepted_versions = {c.version for c in candidates} + all_candidates = self._find_cached_candidates(identifier) + sdist_versions = {c.version for c in all_candidates if c.is_sdist} + reqs = requirements.get(identifier, []) + for c in all_candidates: + if ( + not c.is_sdist + and c.version not in accepted_versions + and c.version not in sdist_versions + and all(c.version in r.specifier for r in reqs) + ): + logger.warning( + "%s==%s: no sdist available, only a wheel (skipped)", + c.name, + c.version, + ) + return candidates class MatchFunction(typing.Protocol): diff --git a/tests/test_resolver.py b/tests/test_resolver.py index 690a12886..f6d05a899 100644 --- a/tests/test_resolver.py +++ b/tests/test_resolver.py @@ -39,6 +39,33 @@ """ +_wheel_only_simple_response = """ + + + + +Links for testpkg + + +

Links for testpkg

+testpkg-1.0.0.tar.gz +
+testpkg-1.0.0-py3-none-any.whl +
+testpkg-1.0.1.tar.gz +
+testpkg-1.0.1-py3-none-any.whl +
+testpkg-1.0.2-py3-none-any.whl +
+testpkg-1.0.3.tar.gz +
+testpkg-1.0.3-py3-none-any.whl + + +""" + + _numpy_simple_response = """ @@ -1278,3 +1305,58 @@ def test_cli_package_resolver( assert "- PyPI versions: 1.2.2, 1.3.1+local, 1.3.2, 2.0.0a1" in result.stdout assert "- only wheels on PyPI: 1.3.1+local, 2.0.0a1" in result.stdout assert "- missing from Fromager: 1.3.1+local, 2.0.0a1" in result.stdout + + +def test_warn_wheel_only_version(caplog: pytest.LogCaptureFixture) -> None: + """Warn when a version matching the specifier has no sdist.""" + with requests_mock.Mocker() as r: + r.get( + "https://pypi.org/simple/testpkg/", + text=_wheel_only_simple_response, + ) + + provider = resolver.PyPIProvider(include_wheels=False) + reporter: resolvelib.BaseReporter = resolvelib.BaseReporter() + rslvr = resolvelib.Resolver(provider, reporter) + + result = rslvr.resolve([Requirement("testpkg>=1.0.0")]) + candidate = result.mapping["testpkg"] + assert str(candidate.version) == "1.0.3" + + assert "testpkg==1.0.2: no sdist available, only a wheel (skipped)" in caplog.text + + +def test_no_warn_when_sdist_exists(caplog: pytest.LogCaptureFixture) -> None: + """No warning for versions that have both sdist and wheel.""" + with requests_mock.Mocker() as r: + r.get( + "https://pypi.org/simple/testpkg/", + text=_wheel_only_simple_response, + ) + + provider = resolver.PyPIProvider(include_wheels=False) + reporter: resolvelib.BaseReporter = resolvelib.BaseReporter() + rslvr = resolvelib.Resolver(provider, reporter) + + rslvr.resolve([Requirement("testpkg>=1.0.0")]) + + assert "testpkg==1.0.0" not in caplog.text + assert "testpkg==1.0.1" not in caplog.text + assert "testpkg==1.0.3" not in caplog.text + + +def test_no_warn_outside_specifier(caplog: pytest.LogCaptureFixture) -> None: + """No warning for wheel-only versions outside the requirement specifier.""" + with requests_mock.Mocker() as r: + r.get( + "https://pypi.org/simple/testpkg/", + text=_wheel_only_simple_response, + ) + + provider = resolver.PyPIProvider(include_wheels=False) + reporter: resolvelib.BaseReporter = resolvelib.BaseReporter() + rslvr = resolvelib.Resolver(provider, reporter) + + rslvr.resolve([Requirement("testpkg>=1.0.3")]) + + assert "testpkg==1.0.2" not in caplog.text From e23ed8a68374f0cdb485b52f58136ab978546771 Mon Sep 17 00:00:00 2001 From: Andre Lustosa Date: Fri, 18 Sep 2026 09:52:58 -0400 Subject: [PATCH 05/22] fix(bootstrap): forget seen-markers of cascade-removed dependencies In multiple-versions mode a failed version is removed from the graph together with every descendant that becomes orphaned, including ones that already completed bootstrap with their build-system edges. Only the failed package's own seen-markers were cleared, so when an orphaned descendant was encountered again it was re-added as a bare node by Start and skipped as already seen. graph.json then carried the node with no build edges and build-parallel scheduled it in round 1 before its build backend was available. Make remove_dependency return the removed nodes and clear the seen-markers for every one of them, so a re-encountered orphan goes through PrepareSource again. Closes #1335 Co-Authored-By: Claude Opus Signed-off-by: Andre Lustosa --- src/fromager/bootstrapper/_bootstrapper.py | 24 +++-- src/fromager/dependency_graph.py | 12 ++- tests/test_bootstrapper_iterative.py | 105 +++++++++++++++++++++ tests/test_dependency_graph.py | 19 +++- 4 files changed, 151 insertions(+), 9 deletions(-) diff --git a/src/fromager/bootstrapper/_bootstrapper.py b/src/fromager/bootstrapper/_bootstrapper.py index a12e67532..7ab6fb17e 100644 --- a/src/fromager/bootstrapper/_bootstrapper.py +++ b/src/fromager/bootstrapper/_bootstrapper.py @@ -641,6 +641,16 @@ def mark_as_seen( # Mark wheel seen only for wheel build self._seen_requirements.add(self._resolved_key(req, version, "wheel")) + def _forget_seen(self, name: NormalizedName, version: Version) -> None: + """Drop every seen-marker for name==version, whatever extras or type.""" + version_str = str(version) + stale = { + key + for key in self._seen_requirements + if key[0] == name and key[2] == version_str + } + self._seen_requirements.difference_update(stale) + def has_been_seen( self, req: Requirement, @@ -1021,13 +1031,15 @@ def _handle_phase_error( err, f"failed during {type(item).phase} phase", ) - self.ctx.dependency_graph.remove_dependency(pkg_name, wi.resolved_version) - self._seen_requirements.discard( - self._resolved_key(wi.req, wi.resolved_version, "sdist") - ) - self._seen_requirements.discard( - self._resolved_key(wi.req, wi.resolved_version, "wheel") + removed_nodes = self.ctx.dependency_graph.remove_dependency( + pkg_name, wi.resolved_version ) + # The failed version may not be in the graph yet. Descendants + # removed with it lost their edges; forget them too so a later + # encounter goes through PrepareSource again. + self._forget_seen(pkg_name, wi.resolved_version) + for node in removed_nodes: + self._forget_seen(node.canonicalized_name, node.version) self._write_graph_async() return [] diff --git a/src/fromager/dependency_graph.py b/src/fromager/dependency_graph.py index 6d8aa3496..2584d3cc3 100644 --- a/src/fromager/dependency_graph.py +++ b/src/fromager/dependency_graph.py @@ -364,7 +364,7 @@ def remove_dependency( self, req_name: NormalizedName, req_version: Version, - ) -> None: + ) -> list[DependencyNode]: """Remove a dependency node and any orphaned descendants from the graph. Removes the node and all edges pointing to it. Child nodes that have @@ -373,11 +373,16 @@ def remove_dependency( Args: req_name: Canonical name of the package req_version: Version of the package + + Returns: + The removed nodes: the requested node first, then the orphaned + descendants. Empty if the node is not in the graph. """ + removed: list[DependencyNode] = [] key = f"{req_name}=={req_version}" if key not in self.nodes: logger.debug(f"Cannot remove {key} - not in graph") - return + return removed queue: collections.deque[str] = collections.deque([key]) @@ -392,6 +397,7 @@ def remove_dependency( logger.debug(f"Removing failed dependency {key} from graph") deleted_node = self.nodes[key] + removed.append(deleted_node) # Remove references to this node from its direct children children = [] @@ -424,6 +430,8 @@ def remove_dependency( if child.key != ROOT and child.key in self.nodes and not child.parents: queue.append(child.key) + return removed + def get_dependency_edges( self, match_dep_types: list[RequirementType] | None = None ) -> typing.Iterable[DependencyEdge]: diff --git a/tests/test_bootstrapper_iterative.py b/tests/test_bootstrapper_iterative.py index 29fb89bff..c52555e2d 100644 --- a/tests/test_bootstrapper_iterative.py +++ b/tests/test_bootstrapper_iterative.py @@ -1066,6 +1066,111 @@ def test_multiple_versions_logs_phase( assert "prepare-build phase" in caplog.text assert "compile error" in caplog.text + def test_multiple_versions_forgets_cascade_removed_descendants( + self, tmp_context: WorkContext + ) -> None: + bt = bootstrapper.Bootstrapper(tmp_context, multiple_versions=True) + bt.why = [] + graph = tmp_context.dependency_graph + req_a = Requirement("a") + req_b = Requirement("b[cli]>=1") + req_backend = Requirement("backend") + v1 = Version("1.0") + graph.add_dependency( + parent_name=None, + parent_version=None, + req_type=RequirementType.TOP_LEVEL, + req=req_a, + req_version=v1, + download_url="https://example.com/a-1.0.tar.gz", + ) + graph.add_dependency( + parent_name=canonicalize_name("a"), + parent_version=v1, + req_type=RequirementType.BUILD_SYSTEM, + req=req_b, + req_version=v1, + download_url="https://example.com/b-1.0.tar.gz", + ) + graph.add_dependency( + parent_name=canonicalize_name("b"), + parent_version=v1, + req_type=RequirementType.BUILD_SYSTEM, + req=req_backend, + req_version=v1, + download_url="https://example.com/backend-1.0.tar.gz", + ) + for req in (req_a, req_b, req_backend): + bt.mark_as_seen(req, v1) + + item = _make_build_item(req="a", version="1.0", phase=BootstrapPhase.BUILD) + bt._handle_phase_error(item, ValueError("build failed")) + + # a and its orphaned descendants are gone from the graph and forgotten + assert "b==1.0" not in graph.nodes + assert "backend==1.0" not in graph.nodes + assert not bt.has_been_seen(Requirement("b"), v1) + assert not bt.has_been_seen(req_b, v1) + assert not bt.has_been_seen(req_backend, v1) + assert not bt.has_failed_version(canonicalize_name("b"), v1) + + # b encountered again is processed, not skipped as already seen + req_c = Requirement("c") + graph.add_dependency( + parent_name=None, + parent_version=None, + req_type=RequirementType.TOP_LEVEL, + req=req_c, + req_version=v1, + download_url="https://example.com/c-1.0.tar.gz", + ) + start_b = Start( + WorkItem( + req=Requirement("b"), + req_type=RequirementType.INSTALL, + why_snapshot=[], + parent=(req_c, v1), + source_url="https://example.com/b-1.0.tar.gz", + resolved_version=v1, + ) + ) + result = start_b.run(bt) + assert len(result) == 1 + assert isinstance(result[0], PrepareSource) + + # PrepareSource finds the cached wheel and restores the build-system + # edge from the requirements file extracted from it, without a rebuild + prepare_b = result[0] + unpacked = tmp_context.work_dir / "b-1.0" + unpacked.mkdir(parents=True) + unpacked.joinpath("build-system-requirements.txt").write_text("backend\n") + cached_wheel = tmp_context.wheels_build / "b-1.0-py3-none-any.whl" + prepare_b.bg_future = _make_resolved_future( + PreparedSourceData( + sdist_root_dir=unpacked / "b-1.0", + cached_wheel_filename=cached_wheel, + ) + ) + with patch("fromager.build_environment.BuildEnvironment", return_value=Mock()): + prepare_result = prepare_b.run(bt) + assert prepare_b.work_item.cached_wheel_filename == cached_wheel + resolve_items = [it for it in prepare_result if isinstance(it, Resolve)] + assert [str(it.work_item.req) for it in resolve_items] == ["backend"] + assert resolve_items[0].work_item.req_type == RequirementType.BUILD_SYSTEM + Start( + WorkItem( + req=req_backend, + req_type=RequirementType.BUILD_SYSTEM, + why_snapshot=[], + parent=(Requirement("b"), v1), + source_url="https://example.com/backend-1.0.tar.gz", + resolved_version=v1, + ) + ).run(bt) + assert [n.key for n in graph.nodes["b==1.0"].iter_build_requirements()] == [ + "backend==1.0" + ] + # -- Normal mode errors -- def test_normal_mode_raises(self, tmp_context: WorkContext) -> None: diff --git a/tests/test_dependency_graph.py b/tests/test_dependency_graph.py index c4086f4a4..71f71a829 100644 --- a/tests/test_dependency_graph.py +++ b/tests/test_dependency_graph.py @@ -702,6 +702,23 @@ def test_remove_dependency_nonexistent() -> None: graph = _build_graph(("ROOT", "a", "toplevel")) node_count = len(graph.nodes) - graph.remove_dependency(canonicalize_name("nonexistent"), Version("1.0")) + removed = graph.remove_dependency(canonicalize_name("nonexistent"), Version("1.0")) + assert removed == [] assert len(graph.nodes) == node_count + + +def test_remove_dependency_returns_removed_nodes() -> None: + graph = _build_graph( + ("ROOT", "a", "toplevel"), + ("ROOT", "d", "toplevel"), + ("a", "b", "build-system"), + ("b", "c", "build-backend"), + ("a", "shared", "install"), + ("d", "shared", "install"), + ) + + removed = graph.remove_dependency(canonicalize_name("a"), Version("1.0")) + + assert [n.key for n in removed] == ["a==1.0", "b==1.0", "c==1.0"] + assert "shared==1.0" in graph.nodes From 9335af13cbcf4cc8465c089627e8b3eeeacb2a36 Mon Sep 17 00:00:00 2001 From: Andre Lustosa Date: Fri, 18 Sep 2026 09:44:47 -0400 Subject: [PATCH 06/22] fix(bootstrap): warm the resolver cache before adding top-level nodes Top-level requirements were resolved and recorded in the dependency graph one rule at a time. A later rule for the same package, such as an exact pin that bypasses the release-age cooldown, widens the resolver's shared per-package cache, so the Resolve phase then returned a different version than the one already written to the graph. Because Start skips graph insertion for top-level requirements, the stale node stayed in graph.json with no build-system edges and build-parallel scheduled it in round 1 before its build backend was available. Resolve every top-level rule once before any of them is added to the graph, so the recorded version matches what the Resolve phase uses. Each warm-up call runs inside req_ctxvar_context() so log messages carry the package name, and deferred errors are logged at debug level instead of silently swallowed. Closes #1334 Co-Authored-By: Claude Opus Signed-off-by: Andre Lustosa --- src/fromager/bootstrapper/_bootstrapper.py | 19 +++++ tests/test_bootstrapper.py | 80 ++++++++++++++++++++++ 2 files changed, 99 insertions(+) diff --git a/src/fromager/bootstrapper/_bootstrapper.py b/src/fromager/bootstrapper/_bootstrapper.py index a12e67532..e0ef7c55e 100644 --- a/src/fromager/bootstrapper/_bootstrapper.py +++ b/src/fromager/bootstrapper/_bootstrapper.py @@ -266,6 +266,25 @@ def bootstrap(self, requirements: list[Requirement]) -> None: Args: requirements: Top-level requirements to resolve and bootstrap. """ + # Warm the resolver cache with every top-level rule before adding any + # of them to the graph: a later rule for the same package (e.g. an + # exact pin that bypasses the release-age cooldown) changes what an + # earlier rule resolves to. Errors are reported by + # _resolve_and_add_top_level below. + for req in requirements: + with req_ctxvar_context(req): + try: + self.resolve_versions( + req=req, + req_type=RequirementType.TOP_LEVEL, + parent_req=None, + return_all_versions=self.multiple_versions, + ) + except Exception as err: + logger.debug( + "deferring resolution error to top-level handling: %s", err + ) + # Resolve all top-level reqs and build initial stack. # Use the token pattern (no try/finally) so that if resolution raises # in normal mode, the context var stays set for the top-level error diff --git a/tests/test_bootstrapper.py b/tests/test_bootstrapper.py index 1f3b25162..0a924eec1 100644 --- a/tests/test_bootstrapper.py +++ b/tests/test_bootstrapper.py @@ -35,6 +35,7 @@ ) from fromager.bootstrapper._work_item import WorkItem from fromager.context import WorkContext +from fromager.dependency_graph import ROOT from fromager.requirements_file import RequirementType, SourceType @@ -955,6 +956,85 @@ def capture_run(self: Resolve, bt_arg: bootstrapper.Bootstrapper) -> list[Phase] assert sorted(dispatch_calls) == ["pkg1", "pkg2"] +def _bootstrap_and_record_started( + bt: bootstrapper.Bootstrapper, + requirements: list[Requirement], + versions_by_rule: dict[str, list[tuple[str, Version]]], +) -> set[str]: + """Run bootstrap() against a fake index and return the node keys Start saw. + + versions_by_rule maps each top-level rule string to what the index + returns for it, which is how the release-age cooldown looks to the + bootstrapper: an unpinned rule does not see a version inside the + cooldown window, an exact pin does. + """ + started: set[str] = set() + + def fake_lookup( + provider: typing.Any, req: Requirement, **kwargs: typing.Any + ) -> list[tuple[str, Version]]: + return list(versions_by_rule[str(req)]) + + def fake_start_run(self: Start, bt_arg: bootstrapper.Bootstrapper) -> list[Phase]: + wi = self.work_item + started.add(f"{canonicalize_name(wi.req.name)}=={wi.resolved_version}") + return [] + + with ( + patch.object(bt._resolver, "_resolve_from_graph", return_value=None), + patch("fromager.bootstrap_requirement_resolver.sources.get_source_provider"), + patch( + "fromager.bootstrap_requirement_resolver.resolver" + ".find_all_matching_from_provider", + side_effect=fake_lookup, + ), + patch.object(Start, "run", fake_start_run), + patch.object(bt, "_record_stack_state"), + ): + bt.bootstrap(requirements) + + return started + + +_OLD = ("https://files.test/pkg-0.39.0.tar.gz", Version("0.39.0")) +_NEW = ("https://files.test/pkg-0.39.1.tar.gz", Version("0.39.1")) + + +def test_bootstrap_toplevel_unpinned_then_pin_leaves_no_stale_node( + tmp_context: WorkContext, +) -> None: + bt = bootstrapper.Bootstrapper(tmp_context) + requirements = [Requirement("pkg"), Requirement("pkg==0.39.1")] + + started = _bootstrap_and_record_started( + bt, requirements, {"pkg": [_OLD], "pkg==0.39.1": [_NEW]} + ) + + graph_nodes = set(tmp_context.dependency_graph.nodes) - {ROOT} + assert started == {"pkg==0.39.1"} + assert graph_nodes == started + root_edges = tmp_context.dependency_graph.nodes[ROOT].children + assert sorted(str(edge.req) for edge in root_edges) == ["pkg", "pkg==0.39.1"] + assert {edge.destination_node.key for edge in root_edges} == {"pkg==0.39.1"} + + +def test_bootstrap_toplevel_multiple_versions_graph_matches_started( + tmp_context: WorkContext, +) -> None: + bt = bootstrapper.Bootstrapper(tmp_context, multiple_versions=True) + requirements = [Requirement("pkg"), Requirement("pkg==0.39.1")] + + with patch("fromager.bootstrapper._resolve._cache.find_cached_wheel") as cached: + cached.return_value = (None, None) + started = _bootstrap_and_record_started( + bt, requirements, {"pkg": [_OLD], "pkg==0.39.1": [_NEW]} + ) + + graph_nodes = set(tmp_context.dependency_graph.nodes) - {ROOT} + assert started == {"pkg==0.39.0", "pkg==0.39.1"} + assert graph_nodes == started + + def test_bg_prepare_source_log_prefix_includes_version( tmp_context: WorkContext, caplog: pytest.LogCaptureFixture, From ce3e7a9bd3a61190b2e061833b17cedcec02e642 Mon Sep 17 00:00:00 2001 From: Lalatendu Mohanty Date: Mon, 21 Sep 2026 16:30:44 -0400 Subject: [PATCH 07/22] fix(tests): use .test TLD instead of example.com in bootstrapper test The `test_multiple_versions_forgets_cascade_removed_descendants` test used example.com URLs, inconsistent with the rest of the file which uses the .test TLD per project convention. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Lalatendu Mohanty --- tests/test_bootstrapper_iterative.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/test_bootstrapper_iterative.py b/tests/test_bootstrapper_iterative.py index c52555e2d..7852997d4 100644 --- a/tests/test_bootstrapper_iterative.py +++ b/tests/test_bootstrapper_iterative.py @@ -1082,7 +1082,7 @@ def test_multiple_versions_forgets_cascade_removed_descendants( req_type=RequirementType.TOP_LEVEL, req=req_a, req_version=v1, - download_url="https://example.com/a-1.0.tar.gz", + download_url="https://pkg.test/a-1.0.tar.gz", ) graph.add_dependency( parent_name=canonicalize_name("a"), @@ -1090,7 +1090,7 @@ def test_multiple_versions_forgets_cascade_removed_descendants( req_type=RequirementType.BUILD_SYSTEM, req=req_b, req_version=v1, - download_url="https://example.com/b-1.0.tar.gz", + download_url="https://pkg.test/b-1.0.tar.gz", ) graph.add_dependency( parent_name=canonicalize_name("b"), @@ -1098,7 +1098,7 @@ def test_multiple_versions_forgets_cascade_removed_descendants( req_type=RequirementType.BUILD_SYSTEM, req=req_backend, req_version=v1, - download_url="https://example.com/backend-1.0.tar.gz", + download_url="https://pkg.test/backend-1.0.tar.gz", ) for req in (req_a, req_b, req_backend): bt.mark_as_seen(req, v1) @@ -1122,7 +1122,7 @@ def test_multiple_versions_forgets_cascade_removed_descendants( req_type=RequirementType.TOP_LEVEL, req=req_c, req_version=v1, - download_url="https://example.com/c-1.0.tar.gz", + download_url="https://pkg.test/c-1.0.tar.gz", ) start_b = Start( WorkItem( @@ -1130,7 +1130,7 @@ def test_multiple_versions_forgets_cascade_removed_descendants( req_type=RequirementType.INSTALL, why_snapshot=[], parent=(req_c, v1), - source_url="https://example.com/b-1.0.tar.gz", + source_url="https://pkg.test/b-1.0.tar.gz", resolved_version=v1, ) ) @@ -1163,7 +1163,7 @@ def test_multiple_versions_forgets_cascade_removed_descendants( req_type=RequirementType.BUILD_SYSTEM, why_snapshot=[], parent=(Requirement("b"), v1), - source_url="https://example.com/backend-1.0.tar.gz", + source_url="https://pkg.test/backend-1.0.tar.gz", resolved_version=v1, ) ).run(bt) From 0c17ead4921619a904a404db73ce8c79acebec1b Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Tue, 22 Sep 2026 13:57:26 -0400 Subject: [PATCH 08/22] fix(resolver): suppress cooldown warning for localhost indexes Localhost simple indexes never provide PEP 691 upload timestamps. Override `is_blocked_by_cooldown` in `PyPIProvider` to silently skip the check when the server is localhost, instead of emitting a noisy warning for every package. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Ryan Petrello --- src/fromager/resolver.py | 15 +++++++++++++++ tests/test_cooldown.py | 6 +++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/fromager/resolver.py b/src/fromager/resolver.py index b3067c7dd..a9b7388db 100644 --- a/src/fromager/resolver.py +++ b/src/fromager/resolver.py @@ -50,6 +50,7 @@ PYTHON_VERSION = Version(python_version()) DEBUG_RESOLVER = os.environ.get("DEBUG_RESOLVER", "") PYPI_SERVER_URL = "https://pypi.org/simple" +_LOCALHOST_NAMES = frozenset({"localhost", "127.0.0.1", "::1"}) GITHUB_URL = "https://github.com" # all supported tags @@ -911,6 +912,20 @@ def __init__( self.ignore_platform = ignore_platform self.override_download_url = override_download_url + def is_blocked_by_cooldown(self, candidate: Candidate) -> bool: + """Return True if the candidate is rejected by the release-age cooldown. + + Silently skips the cooldown check for localhost indexes, which never + provide upload timestamps. + """ + if ( + self.cooldown is not None + and candidate.upload_time is None + and urlparse(self.sdist_server_url).hostname in _LOCALHOST_NAMES + ): + return False + return super().is_blocked_by_cooldown(candidate) + @property def cache_key(self) -> str: # ignore platform parameter changes behavior of find_candidates() diff --git a/tests/test_cooldown.py b/tests/test_cooldown.py index 151f9eaf1..5ced99785 100644 --- a/tests/test_cooldown.py +++ b/tests/test_cooldown.py @@ -650,12 +650,12 @@ def test_local_wheel_server_allows_without_upload_time( caplog: pytest.LogCaptureFixture, ) -> None: """resolve_all_prebuilt_wheels() allows candidates from the local wheel server - even when upload_time is missing. + even when upload_time is missing, without emitting a warning. The local fromager wheel server is PEP 503-only and serves packages that were already resolved and built earlier in the same run. They are trusted and must not be fail-closed by the cooldown just because the local server cannot supply - upload timestamps. + upload timestamps. Localhost indexes silently skip the cooldown check. """ local_server_url = "http://127.0.0.1:9999/simple/" ctx = context.WorkContext( @@ -696,7 +696,7 @@ def test_local_wheel_server_allows_without_upload_time( assert len(results) == 1 _, version = results[0] assert str(version) == "1.3.2" - assert "cooldown check skipped" in caplog.text + assert "cooldown cannot be enforced" not in caplog.text # --------------------------------------------------------------------------- From bfcb74b05fe512ebcbd06ee6ce02df5fde533808 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Tue, 22 Sep 2026 16:38:19 -0400 Subject: [PATCH 09/22] fix(resolver): warn when cooldown blocks versions Promote the "cooldown blocked N version(s)" log message from INFO to WARNING. When the release-age cooldown actively rejects versions, this is a significant event that should be visible at default verbosity. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Ryan Petrello --- src/fromager/resolver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fromager/resolver.py b/src/fromager/resolver.py index a9b7388db..e60c72d62 100644 --- a/src/fromager/resolver.py +++ b/src/fromager/resolver.py @@ -857,7 +857,7 @@ def find_matches( for b in blocked: candidates.remove(b) versions = ", ".join(str(b.version) for b in blocked) - logger.info( + logger.warning( "cooldown blocked %d version(s): %s", len(blocked), versions, From 38cf13abca2c17767469b6dc5ad61c602a4a14a6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 26 Sep 2026 15:22:26 +0000 Subject: [PATCH 10/22] build(deps): bump dtolnay/rust-toolchain Bumps [dtolnay/rust-toolchain](https://github.com/dtolnay/rust-toolchain) from 6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 to 02cb101ec7c40f2c49e1d9714d64511d8e1b74de. - [Release notes](https://github.com/dtolnay/rust-toolchain/releases) - [Commits](https://github.com/dtolnay/rust-toolchain/compare/6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772...02cb101ec7c40f2c49e1d9714d64511d8e1b74de) --- updated-dependencies: - dependency-name: dtolnay/rust-toolchain dependency-version: 02cb101ec7c40f2c49e1d9714d64511d8e1b74de dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- .github/workflows/test.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 2dc993608..14feb4776 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -43,7 +43,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Set up Rust ${{ matrix.rust-version }} - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master + uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # master with: toolchain: ${{ matrix.rust-version }} @@ -117,7 +117,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Set up Rust ${{ matrix.rust-version }} - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master + uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # master with: toolchain: ${{ matrix.rust-version }} From 77b7bc79452b6735e72339ea20693aed3305a2b9 Mon Sep 17 00:00:00 2001 From: Ihar Hrachyshka Date: Sun, 27 Sep 2026 14:54:11 -0400 Subject: [PATCH 11/22] test(bootstrapper): prevent unintended background downloads Tests replacing PrepareSource.run() do not consume background results. Disable background work too so placeholder URLs never trigger requests. This becomes important in sandboxed environments where network requests don't quickly return an error but instead go into a backoff retry loop. Co-Authored-By: OpenAI Codex Signed-off-by: Ihar Hrachyshka --- tests/test_bootstrapper.py | 5 ++++- tests/test_bootstrapper_iterative.py | 5 +++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/test_bootstrapper.py b/tests/test_bootstrapper.py index 0a924eec1..e640cfb07 100644 --- a/tests/test_bootstrapper.py +++ b/tests/test_bootstrapper.py @@ -412,7 +412,10 @@ def prepare_source_run( req = Requirement("testpkg>=1.0") - with patch.object(PrepareSource, "run", prepare_source_run): + with ( + patch.object(PrepareSource, "background_work", return_value=None), + patch.object(PrepareSource, "run", prepare_source_run), + ): with patch.object(bt, "has_been_seen", return_value=False): bt._bootstrap_one( req=req, diff --git a/tests/test_bootstrapper_iterative.py b/tests/test_bootstrapper_iterative.py index 7852997d4..d68d45d4e 100644 --- a/tests/test_bootstrapper_iterative.py +++ b/tests/test_bootstrapper_iterative.py @@ -1247,6 +1247,7 @@ def complete_run( with ( patch.object(Resolve, "run", resolve_run), patch.object(Start, "run", start_run), + patch.object(PrepareSource, "background_work", return_value=None), patch.object(PrepareSource, "run", prepare_source_run), patch.object(PrepareBuild, "run", prepare_build_run), patch.object(Build, "run", build_run), @@ -1336,6 +1337,7 @@ def complete_run( with ( patch.object(Resolve, "run", resolve_run), patch.object(Start, "run", start_run), + patch.object(PrepareSource, "background_work", return_value=None), patch.object(PrepareSource, "run", prepare_source_run), patch.object(Complete, "run", complete_run), patch.object( @@ -1376,6 +1378,7 @@ def prepare_source_run( return [] with ( + patch.object(PrepareSource, "background_work", return_value=None), patch.object(PrepareSource, "run", prepare_source_run), patch.object( bt._resolver, @@ -1420,6 +1423,7 @@ def prepare_source_run( with ( patch.object(Resolve, "run", resolve_run), + patch.object(PrepareSource, "background_work", return_value=None), patch.object(PrepareSource, "run", prepare_source_run), patch.object( bt._resolver, @@ -1452,6 +1456,7 @@ def prepare_source_run( return [] with ( + patch.object(PrepareSource, "background_work", return_value=None), patch.object(PrepareSource, "run", prepare_source_run), patch.object( bt._resolver, From 7f3e746aff3469aa46b53b667a1f5d1522d72dc0 Mon Sep 17 00:00:00 2001 From: Ihar Hrachyshka Date: Sun, 27 Sep 2026 15:08:25 -0400 Subject: [PATCH 12/22] fix(context): handle concurrent directory creation Parallel pytest workers can share the default repository paths. Test runs may fail with: Two workers may both observe a missing directory before one creates it, causing the other worker's mkdir() call to fail. Use `exist_ok=True` so `WorkContext.setup()` tolerates that race. Co-Authored-By: OpenAI Codex Signed-off-by: Ihar Hrachyshka --- src/fromager/context.py | 2 +- tests/test_context.py | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/fromager/context.py b/src/fromager/context.py index 8ce9dd71d..5525de804 100644 --- a/src/fromager/context.py +++ b/src/fromager/context.py @@ -183,7 +183,7 @@ def setup(self) -> None: ]: if not p.exists(): logger.debug("creating %s", p) - p.mkdir(parents=True) + p.mkdir(parents=True, exist_ok=True) self.write_constraints() def write_constraints(self) -> None: diff --git a/tests/test_context.py b/tests/test_context.py index 38a262c8d..103b9f496 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -116,6 +116,17 @@ def test_setup_is_idempotent(tmp_path: pathlib.Path) -> None: assert test_file.read_text() == "test text" +def test_setup_handles_directory_created_after_exists_check( + tmp_path: pathlib.Path, +) -> None: + ctx = _make_context(tmp_path) + for directory in _all_setup_dirs(ctx): + directory.mkdir(parents=True, exist_ok=True) + + with patch.object(pathlib.Path, "exists", return_value=False): + ctx.setup() + + def test_package_build_info_extracts_name_from_requirement( tmp_context: context.WorkContext, ) -> None: From 8c44b4c3fecdbf12b1a3e2884d2c7590166b793c Mon Sep 17 00:00:00 2001 From: Justin Larkin Date: Mon, 28 Sep 2026 10:36:11 -0400 Subject: [PATCH 13/22] fix(sources): use correct top-level directory name in sdists Name the archive root from the normalized package name and version. Test the returned sdist archive and the tar helper. Fixes #1315 Co-Authored-By: Claude Haiku 4.5 Co-Authored-By: Codex Signed-off-by: Justin Larkin --- src/fromager/sources.py | 5 +++-- src/fromager/tarballs.py | 17 ++++++++++++++--- tests/test_sources.py | 35 +++++++++++++++++++++++++++++++++++ tests/test_tarballs.py | 31 +++++++++++++++++++++++++++++++ 4 files changed, 83 insertions(+), 5 deletions(-) diff --git a/src/fromager/sources.py b/src/fromager/sources.py index 5409a181f..1e9603bb7 100644 --- a/src/fromager/sources.py +++ b/src/fromager/sources.py @@ -534,8 +534,8 @@ def default_build_sdist( # # For cases where the PEP 517 approach works, use # pep517_build_sdist(). - normalized_name = canonicalize_name(req.name).replace("-", "_") - sdist_filename = ctx.sdists_builds / f"{normalized_name}-{version}.tar.gz" + dist_name = canonicalize_name(req.name).replace("-", "_") + sdist_filename = ctx.sdists_builds / f"{dist_name}-{version}.tar.gz" if sdist_filename.exists(): sdist_filename.unlink() ensure_pkg_info( @@ -552,6 +552,7 @@ def default_build_sdist( tar=sdist, basedir=build_dir, prefix=build_dir.parent, + arcname_root=f"{dist_name}-{version}", ) return sdist_filename diff --git a/src/fromager/tarballs.py b/src/fromager/tarballs.py index d425a2283..f112c9835 100644 --- a/src/fromager/tarballs.py +++ b/src/fromager/tarballs.py @@ -30,12 +30,17 @@ def tar_reproducible( prefix: pathlib.Path | None = None, *, exclude_vcs: bool = False, + arcname_root: str | None = None, ) -> None: """Create reproducible tar file Add content from basedir to already opened tar. If prefix is provided, use it to set relative paths for the content being added. + If arcname_root is provided, prepend it to all archive entry names. + This allows the top-level directory to be explicitly set regardless of + the basedir or prefix. + If ``exclude_vcs`` is True, then Bazaar, git, Mercurial, and subversion directories and files are excluded. """ @@ -53,7 +58,13 @@ def tar_reproducible( content.sort() for fn in content: - # Ensure that the paths in the tarfile are rooted at the prefix - # directory, if we have one. - arcname = fn if prefix is None else os.path.relpath(fn, prefix) + if arcname_root is not None: + # When arcname_root is specified, compute paths relative to basedir + # to avoid including intermediate directory names from build_dir + rel = os.path.relpath(fn, basedir) + arcname = arcname_root if rel == "." else os.path.join(arcname_root, rel) + else: + # Ensure that the paths in the tarfile are rooted at the prefix + # directory, if we have one. + arcname = fn if prefix is None else os.path.relpath(fn, prefix) tar.add(fn, filter=_tar_reset, recursive=False, arcname=arcname) diff --git a/tests/test_sources.py b/tests/test_sources.py index c5f9e3d47..f6ab27cf8 100644 --- a/tests/test_sources.py +++ b/tests/test_sources.py @@ -786,3 +786,38 @@ def test_default_build_sdist_normalizes_filename( expected_filename = f"{expected_filename_part}-1.0.0.tar.gz" assert sdist_file.name == expected_filename assert sdist_file.parent == tmp_context.sdists_builds + + +def test_default_build_sdist_normalizes_name_and_root( + tmp_context: context.WorkContext, + tmp_path: pathlib.Path, +) -> None: + """Verify the sdist filename and archive root for a monorepo package.""" + sdist_root = tmp_path / "Foo.Bar-1.0" + build_dir = sdist_root / "src" + build_dir.mkdir(parents=True) + (build_dir / "setup.py").write_text("from setuptools import setup; setup()\n") + (build_dir / "module.py").write_text("# module\n") + + req = Requirement("Foo.Bar==1.0") + version = Version("1.0") + build_env = Mock() + + sdist_file = sources.default_build_sdist( + ctx=tmp_context, + extra_environ={}, + req=req, + version=version, + sdist_root_dir=sdist_root, + build_env=build_env, + build_dir=build_dir, + ) + + assert sdist_file.name == "foo_bar-1.0.tar.gz" + assert sdist_file.parent == tmp_context.sdists_builds + + with tarfile.open(sdist_file, "r:gz") as tar: + names = tar.getnames() + top_levels = {name.split("/")[0] for name in names} + assert top_levels == {"foo_bar-1.0"} + assert "foo_bar-1.0/setup.py" in names diff --git a/tests/test_tarballs.py b/tests/test_tarballs.py index 85004c8ec..a912b89e4 100644 --- a/tests/test_tarballs.py +++ b/tests/test_tarballs.py @@ -93,3 +93,34 @@ def test_vcs_exclude(tmp_path: pathlib.Path) -> None: with tarfile.open(t1, "r") as tf: names = tf.getnames() assert names == [str(p).lstrip(os.sep) for p in [root, root / "a"]] + + +def test_arcname_root(tmp_path: pathlib.Path) -> None: + """Test that arcname_root sets the top-level directory name. + + This reproduces issue #1315: when basedir is a subdirectory (monorepo case), + arcname_root should ensure the top-level archive entry is {name}-{version}, + not the basedir's name. + """ + # Simulate a monorepo structure: mypkg-1.0/python/ + sdist_root = tmp_path / "mypkg-1.0" + build_dir = sdist_root / "python" + build_dir.mkdir(parents=True) + (build_dir / "setup.py").write_text("from setuptools import setup; setup()\n") + + t1 = tmp_path / "out.tar" + with tarfile.open(t1, "w") as tf: + tarballs.tar_reproducible( + tar=tf, + basedir=build_dir, + prefix=sdist_root, + arcname_root="mypkg-1.0", + ) + with tarfile.open(t1, "r") as tf: + names = tf.getnames() + + # All entries should be rooted at mypkg-1.0, not python/ + # This ensures the sdist unpacks to mypkg-1.0/, not python/ + assert "mypkg-1.0" in names[0] + assert "python" not in names[0] # build_dir's name should not appear + assert "mypkg-1.0/setup.py" in names From 20999a054d9fa28de7301fe0de43439ab3a216ec Mon Sep 17 00:00:00 2001 From: Shanmukh Pawan Date: Tue, 12 May 2026 18:13:48 -0400 Subject: [PATCH 14/22] feat(wheels): separate cache wheel resolution from pre-built path The local wheel server and --cache-wheel-server-url only hold wheels Fromager already built. Cooldown was applied when the sdist was resolved. The cache upload-time is when we uploaded the wheel, not when the package was published, so --min-release-age rejects a wheel we should reuse. A package wheel_server_url is an upstream pre-built index, so that lookup stays on the pre-built path and cooldown still applies. Follows up on #1143 and closes #1140. Co-Authored-By: Claude Co-Authored-By: Cursor Grok 4.7 Signed-off-by: Shanmukh Pawan --- src/fromager/commands/build.py | 69 +++- src/fromager/commands/download_sequence.py | 29 +- src/fromager/wheels.py | 32 +- tests/test_cooldown.py | 383 +++++++++++++++++++++ 4 files changed, 478 insertions(+), 35 deletions(-) diff --git a/src/fromager/commands/build.py b/src/fromager/commands/build.py index 2e33f8f0e..67f96439a 100644 --- a/src/fromager/commands/build.py +++ b/src/fromager/commands/build.py @@ -346,17 +346,13 @@ def _build( pbi = wkctx.package_build_info(req) prebuilt = pbi.pre_built - wheel_server_urls = wheels.get_wheel_server_urls( - wkctx, req, cache_wheel_server_url=cache_wheel_server_url - ) - # See if we can reuse an existing wheel. if not force: wheel_filename = _is_wheel_built( wkctx, req.name, resolved_version, - wheel_server_urls, + cache_wheel_server_url=cache_wheel_server_url, ) if wheel_filename: logger.info("using existing wheel from %s", wheel_filename) @@ -469,23 +465,62 @@ def _is_wheel_built( wkctx: context.WorkContext, dist_name: str, resolved_version: Version, - wheel_server_urls: list[str], + *, + cache_wheel_server_url: str | None = None, ) -> pathlib.Path | None: req = Requirement(f"{dist_name}=={resolved_version}") + pbi = wkctx.package_build_info(req) + # No package index and no local or job cache means there is nothing to reuse. + if ( + not pbi.wheel_server_url + and not wkctx.wheel_server_url + and not cache_wheel_server_url + ): + logger.debug( + "no wheel server configured for %s, skipping existing-wheel check", + req, + ) + return None try: + # Package wheel_server_url is exclusive: it replaces local and cache. + servers = wheels.get_wheel_server_urls( + wkctx, + req, + cache_wheel_server_url=cache_wheel_server_url, + ) logger.info( "checking if a suitable wheel for %s was already built on %s", req, - wheel_server_urls, - ) - url, _ = wheels.resolve_prebuilt_wheel( - ctx=wkctx, - req=req, - wheel_server_urls=wheel_server_urls, + servers, ) + + url: str | None = None + for server_url in servers: + try: + if pbi.wheel_server_url: + # Upstream pre-built index. Release-age cooldown stays on. + url, _ = wheels.resolve_prebuilt_wheel( + ctx=wkctx, + req=req, + wheel_server_urls=[server_url], + ) + else: + # Local server and --cache-wheel-server-url are trusted caches. + url, _ = wheels.resolve_cached_wheel( + ctx=wkctx, + req=req, + cache_server_url=server_url, + ) + break + except Exception: + logger.debug("wheel not found on %s", server_url, exc_info=True) + + if url is None: + logger.info("could not locate existing wheel") + return None + logger.info("found candidate wheel %s", url) - pbi = wkctx.package_build_info(req) build_tag_from_settings = pbi.build_tag(resolved_version) build_tag = build_tag_from_settings if build_tag_from_settings else (0, "") wheel_basename = downloads.extract_filename_from_url(url) @@ -505,7 +540,7 @@ def _is_wheel_built( return None wheel_filename: pathlib.Path | None = None - if url.startswith(wkctx.wheel_server_url): + if wkctx.wheel_server_url and url.startswith(wkctx.wheel_server_url): logging.debug("found wheel on local server") wheel_filename = wkctx.wheels_downloads / wheel_basename if not wheel_filename.exists(): @@ -513,20 +548,18 @@ def _is_wheel_built( wheel_filename = None if not wheel_filename: - # if the found wheel was on an external server, then download it logger.info("downloading wheel from %s", url) wheel_filename = wheels.download_wheel(req, url, wkctx.wheels_downloads) return wheel_filename except Exception: logger.debug( - "could not locate prebuilt wheel %s-%s on %s", + "could not locate existing wheel %s-%s", dist_name, resolved_version, - wheel_server_urls, exc_info=True, ) - logger.info("could not locate prebuilt wheel") + logger.info("could not locate existing wheel") return None diff --git a/src/fromager/commands/download_sequence.py b/src/fromager/commands/download_sequence.py index 9409c4b35..177440fd5 100644 --- a/src/fromager/commands/download_sequence.py +++ b/src/fromager/commands/download_sequence.py @@ -51,10 +51,13 @@ def download_sequence( the build order file that have source_url_type as sdist. """ - if wkctx.wheel_server_url: - wheel_servers = [wkctx.wheel_server_url] - else: - wheel_servers = [sdist_server_url] + # The local wheel server is a trusted cache (wheels built in this or + # prior runs). An external sdist_server_url used as a wheel fallback + # is an upstream index that needs cooldown and override hooks. + use_cache_resolution = bool(wkctx.wheel_server_url) + wheel_server_url = ( + wkctx.wheel_server_url if use_cache_resolution else sdist_server_url + ) logger.info("reading build order from %s", build_order_file) with read.open_file_or_url(build_order_file) as f: @@ -82,7 +85,6 @@ def download_one(entry: dict[str, typing.Any]) -> None: except Exception as err: logger.error(f"failed to download sdist for {req}: {err}") if not ignore_missing_sdists: - # Re-raise with package context since context var is lost across threads raise RuntimeError(f"Failed to download sdist for {req}") from err else: logger.info( @@ -91,12 +93,21 @@ def download_one(entry: dict[str, typing.Any]) -> None: if include_wheels: try: - wheel_url, _ = wheels.resolve_prebuilt_wheel( - ctx=wkctx, req=req, wheel_server_urls=wheel_servers - ) + if use_cache_resolution: + resolved_url, _ = wheels.resolve_cached_wheel( + ctx=wkctx, + req=req, + cache_server_url=wheel_server_url, + ) + else: + resolved_url, _ = wheels.resolve_prebuilt_wheel( + ctx=wkctx, + req=req, + wheel_server_urls=[wheel_server_url], + ) wheels.download_wheel( req=req, - wheel_url=wheel_url, + wheel_url=resolved_url, output_directory=wkctx.wheels_downloads, ) except Exception as err: diff --git a/src/fromager/wheels.py b/src/fromager/wheels.py index dc9bd5241..6c3535e56 100644 --- a/src/fromager/wheels.py +++ b/src/fromager/wheels.py @@ -24,6 +24,7 @@ dependencies, downloads, external_commands, + finders, metrics, overrides, packagesettings, @@ -508,6 +509,26 @@ def get_prebuilt_wheel_provider( ) +@metrics.timeit(description="resolve wheel") +def resolve_cached_wheel( + *, + ctx: context.WorkContext, + req: Requirement, + cache_server_url: str, +) -> tuple[str, Version]: + """Resolve a wheel from a trusted cache server (local or remote). + + Uses ``PyPICacheProvider`` -- no cooldown, no hooks, no upload-time checks. + """ + provider = finders.PyPICacheProvider( + cache_server_url=cache_server_url, + constraints=ctx.constraints, + ) + results = resolver.find_all_matching_from_provider(provider, req) + wheel_url, version = results[0] + return str(wheel_url), version + + def resolve_all_prebuilt_wheels( *, ctx: context.WorkContext, @@ -532,11 +553,6 @@ def resolve_all_prebuilt_wheels( provider.cooldown = resolver.resolve_package_cooldown( ctx, req, req_type=req_type ) - # The local fromager wheel server is PEP 503-only and serves - # packages that were already resolved and vetted earlier in the - # same run. Don't fail-closed on missing upload_time there. - if ctx.wheel_server_url and url == ctx.wheel_server_url: - provider.supports_upload_time = False # Get all matching candidates from provider results = resolver.find_all_matching_from_provider(provider, req) @@ -559,10 +575,10 @@ def resolve_prebuilt_wheel( wheel_server_urls: list[str], req_type: requirements_file.RequirementType | None = None, ) -> tuple[str, Version]: - """Return (URL, version) for the best matching wheel version. + """Return (URL, version) for the best matching pre-built wheel. - Tries wheel servers in order and returns result from the first that succeeds. - Returns the highest matching version. + Tries wheel servers in order and returns the highest matching version + from the first server that succeeds. """ results = resolve_all_prebuilt_wheels( ctx=ctx, req=req, wheel_server_urls=wheel_server_urls, req_type=req_type diff --git a/tests/test_cooldown.py b/tests/test_cooldown.py index 5ced99785..dfa1305d7 100644 --- a/tests/test_cooldown.py +++ b/tests/test_cooldown.py @@ -27,6 +27,7 @@ sources, wheels, ) +from fromager.commands import build as build_command from fromager.requirements_file import RequirementType _BOOTSTRAP_TIME = datetime.datetime(2026, 3, 26, 0, 0, 0, tzinfo=datetime.UTC) @@ -699,6 +700,388 @@ def test_local_wheel_server_allows_without_upload_time( assert "cooldown cannot be enforced" not in caplog.text +def test_local_wheel_server_skips_cooldown_entirely( + tmp_path: pathlib.Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """resolve_cached_wheel() disables cooldown for the local wheel server. + + The local fromager wheel server serves packages that were already resolved + and built earlier in the same run. They are trusted, so resolve_cached_wheel + uses PyPICacheProvider (cooldown=None) and no cooldown-related warnings are + emitted. + """ + local_server_url = "http://127.0.0.1:9999/simple/" + ctx = context.WorkContext( + active_settings=None, + patches_dir=tmp_path / "patches", + sdists_repo=tmp_path / "sdists-repo", + wheels_repo=tmp_path / "wheels-repo", + work_dir=tmp_path / "work-dir", + cooldown=_COOLDOWN, + ) + + no_timestamp_response = { + "meta": {"api-version": "1.1"}, + "name": "test-pkg", + "files": [ + { + "filename": "test_pkg-1.3.2-py3-none-any.whl", + "url": f"{local_server_url}test-pkg/test_pkg-1.3.2-py3-none-any.whl", + "hashes": {"sha256": "bbb"}, + }, + ], + } + with caplog.at_level(logging.WARNING, logger="fromager.resolver"): + with requests_mock.Mocker() as r: + r.get( + f"{local_server_url}test-pkg/", + json=no_timestamp_response, + headers={"Content-Type": _PYPI_SIMPLE_JSON_CONTENT_TYPE}, + ) + _url, version = wheels.resolve_cached_wheel( + ctx=ctx, + req=Requirement("test-pkg"), + cache_server_url=local_server_url, + ) + + assert str(version) == "1.3.2" + assert "cooldown" not in caplog.text.lower() + + +def test_cache_wheel_server_skips_cooldown_entirely( + tmp_path: pathlib.Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """resolve_cached_wheel() disables cooldown for the cache wheel server. + + The cache wheel server contains wheels from previous fromager runs that + were already vetted during original sdist resolution. Cooldown is not + applicable. + """ + cache_server_url = "https://registry.test/packages/pypi/simple/" + ctx = context.WorkContext( + active_settings=None, + patches_dir=tmp_path / "patches", + sdists_repo=tmp_path / "sdists-repo", + wheels_repo=tmp_path / "wheels-repo", + work_dir=tmp_path / "work-dir", + cooldown=_COOLDOWN, + ) + + no_timestamp_response = { + "meta": {"api-version": "1.1"}, + "name": "test-pkg", + "files": [ + { + "filename": "test_pkg-1.3.2-py3-none-any.whl", + "url": f"{cache_server_url}test-pkg/test_pkg-1.3.2-py3-none-any.whl", + "hashes": {"sha256": "bbb"}, + }, + ], + } + with caplog.at_level(logging.WARNING, logger="fromager.resolver"): + with requests_mock.Mocker() as r: + r.get( + f"{cache_server_url}test-pkg/", + json=no_timestamp_response, + headers={"Content-Type": _PYPI_SIMPLE_JSON_CONTENT_TYPE}, + ) + _url, version = wheels.resolve_cached_wheel( + ctx=ctx, + req=Requirement("test-pkg"), + cache_server_url=cache_server_url, + ) + + assert str(version) == "1.3.2" + assert "cooldown" not in caplog.text.lower() + + +def test_non_cache_server_retains_cooldown( + tmp_path: pathlib.Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """resolve_all_prebuilt_wheels() keeps cooldown active for non-cache servers. + + When a server URL is an external/upstream index (not local or cache), + cooldown remains active. Candidates without upload timestamps trigger a + warning because the server does not support upload_time. + """ + external_server_url = "https://external.test/simple/" + ctx = context.WorkContext( + active_settings=None, + patches_dir=tmp_path / "patches", + sdists_repo=tmp_path / "sdists-repo", + wheels_repo=tmp_path / "wheels-repo", + work_dir=tmp_path / "work-dir", + cooldown=_COOLDOWN, + ) + + no_timestamp_response = { + "meta": {"api-version": "1.1"}, + "name": "test-pkg", + "files": [ + { + "filename": "test_pkg-1.3.2-py3-none-any.whl", + "url": f"{external_server_url}test-pkg/test_pkg-1.3.2-py3-none-any.whl", + "hashes": {"sha256": "bbb"}, + }, + ], + } + with caplog.at_level(logging.WARNING, logger="fromager.resolver"): + with requests_mock.Mocker() as r: + r.get( + f"{external_server_url}test-pkg/", + json=no_timestamp_response, + headers={"Content-Type": _PYPI_SIMPLE_JSON_CONTENT_TYPE}, + ) + results = wheels.resolve_all_prebuilt_wheels( + ctx=ctx, + req=Requirement("test-pkg"), + wheel_server_urls=[external_server_url], + ) + + assert len(results) == 1 + _, version = results[0] + assert str(version) == "1.3.2" + assert "cooldown cannot be enforced" in caplog.text + + +_LOCAL_WHEEL_SERVER = "http://127.0.0.1:8080/simple/" +_CACHE_WHEEL_SERVER = "https://cache.test/simple/" +_PACKAGE_WHEEL_SERVER = "https://custom.test/simple/" +_RECENT_UPLOAD_TIME = "2026-03-25T00:00:00+00:00" +_OLD_UPLOAD_TIME = "2026-03-15T00:00:00+00:00" + + +def _simple_wheel_index( + server_url: str, + *, + upload_time: str | None = None, + version: str = "1.3.2", +) -> dict[str, typing.Any]: + filename = f"test_pkg-{version}-py3-none-any.whl" + file_entry: dict[str, typing.Any] = { + "filename": filename, + "url": f"{server_url}test-pkg/{filename}", + "hashes": {"sha256": "bbb"}, + } + if upload_time is not None: + file_entry["upload-time"] = upload_time + return { + "meta": {"api-version": "1.1"}, + "name": "test-pkg", + "files": [file_entry], + } + + +def _empty_index() -> dict[str, typing.Any]: + return {"meta": {"api-version": "1.1"}, "name": "test-pkg", "files": []} + + +def _context_for_existing_wheel( + tmp_path: pathlib.Path, + *, + package_wheel_server_url: str | None = None, +) -> context.WorkContext: + settings_dir = tmp_path / "settings" + settings_dir.mkdir() + if package_wheel_server_url is not None: + (settings_dir / "test-pkg.yaml").write_text( + f"variants:\n cpu:\n wheel_server_url: {package_wheel_server_url}\n", + encoding="utf-8", + ) + settings = packagesettings.Settings.from_files( + settings_file=tmp_path / "settings.yaml", + settings_dir=settings_dir, + variant="cpu", + patches_dir=tmp_path / "patches", + max_jobs=None, + ) + return context.WorkContext( + active_settings=settings, + patches_dir=tmp_path / "patches", + sdists_repo=tmp_path / "sdists-repo", + wheels_repo=tmp_path / "wheels-repo", + work_dir=tmp_path / "work-dir", + wheel_server_url=_LOCAL_WHEEL_SERVER, + cooldown=_COOLDOWN, + ) + + +def _record_download( + seen: list[str], +) -> typing.Callable[[Requirement, str, pathlib.Path], pathlib.Path]: + def _download( + req: Requirement, + wheel_url: str, + output_directory: pathlib.Path, + ) -> pathlib.Path: + del req + seen.append(wheel_url) + output_directory.mkdir(parents=True, exist_ok=True) + path = output_directory / wheel_url.rsplit("/", 1)[-1] + path.write_bytes(b"") + return path + + return _download + + +def test_is_wheel_built_uses_package_wheel_server_url( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """A package wheel_server_url is the only index consulted. + + The job cache and local server are not fallbacks. A missing upload + timestamp warns and still allows the wheel, because this path uses the + pre-built resolver rather than the trusted cache. + """ + ctx = _context_for_existing_wheel( + tmp_path, + package_wheel_server_url=_PACKAGE_WHEEL_SERVER, + ) + seen: list[str] = [] + monkeypatch.setattr(build_command.wheels, "download_wheel", _record_download(seen)) + + with caplog.at_level(logging.WARNING, logger="fromager.resolver"): + with requests_mock.Mocker() as r: + r.get( + f"{_PACKAGE_WHEEL_SERVER}test-pkg/", + json=_simple_wheel_index(_PACKAGE_WHEEL_SERVER), + headers={"Content-Type": _PYPI_SIMPLE_JSON_CONTENT_TYPE}, + ) + r.get( + f"{_CACHE_WHEEL_SERVER}test-pkg/", + json=_simple_wheel_index( + _CACHE_WHEEL_SERVER, + upload_time=_OLD_UPLOAD_TIME, + ), + headers={"Content-Type": _PYPI_SIMPLE_JSON_CONTENT_TYPE}, + ) + r.get( + f"{_LOCAL_WHEEL_SERVER}test-pkg/", + json=_simple_wheel_index(_LOCAL_WHEEL_SERVER), + headers={"Content-Type": _PYPI_SIMPLE_JSON_CONTENT_TYPE}, + ) + found = build_command._is_wheel_built( + ctx, + "test-pkg", + Version("1.3.2"), + cache_wheel_server_url=_CACHE_WHEEL_SERVER, + ) + + assert found is not None + assert found.name == "test_pkg-1.3.2-py3-none-any.whl" + assert seen == [f"{_PACKAGE_WHEEL_SERVER}test-pkg/test_pkg-1.3.2-py3-none-any.whl"] + requested = [request.url for request in r.request_history] + assert any(url.startswith(_PACKAGE_WHEEL_SERVER) for url in requested) + assert all(not url.startswith(_CACHE_WHEEL_SERVER) for url in requested) + assert all(not url.startswith(_LOCAL_WHEEL_SERVER) for url in requested) + assert "cooldown cannot be enforced" in caplog.text + + +def test_is_wheel_built_package_wheel_server_keeps_cooldown( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """A too-new wheel on a package index is rejected and the cache is not used. + + The cache holds an older matching wheel. Falling through to it would hide + the cooldown that must stay on for an upstream pre-built index. + """ + ctx = _context_for_existing_wheel( + tmp_path, + package_wheel_server_url=_PACKAGE_WHEEL_SERVER, + ) + seen: list[str] = [] + monkeypatch.setattr(build_command.wheels, "download_wheel", _record_download(seen)) + + with caplog.at_level(logging.WARNING, logger="fromager.resolver"): + with requests_mock.Mocker() as r: + r.get( + f"{_PACKAGE_WHEEL_SERVER}test-pkg/", + json=_simple_wheel_index( + _PACKAGE_WHEEL_SERVER, + upload_time=_RECENT_UPLOAD_TIME, + ), + headers={"Content-Type": _PYPI_SIMPLE_JSON_CONTENT_TYPE}, + ) + r.get( + f"{_CACHE_WHEEL_SERVER}test-pkg/", + json=_simple_wheel_index( + _CACHE_WHEEL_SERVER, + upload_time=_OLD_UPLOAD_TIME, + ), + headers={"Content-Type": _PYPI_SIMPLE_JSON_CONTENT_TYPE}, + ) + r.get( + f"{_LOCAL_WHEEL_SERVER}test-pkg/", + json=_simple_wheel_index( + _LOCAL_WHEEL_SERVER, + upload_time=_OLD_UPLOAD_TIME, + ), + headers={"Content-Type": _PYPI_SIMPLE_JSON_CONTENT_TYPE}, + ) + found = build_command._is_wheel_built( + ctx, + "test-pkg", + Version("1.3.2"), + cache_wheel_server_url=_CACHE_WHEEL_SERVER, + ) + + assert found is None + assert seen == [] + requested = [request.url for request in r.request_history] + assert any(url.startswith(_PACKAGE_WHEEL_SERVER) for url in requested) + assert all(not url.startswith(_CACHE_WHEEL_SERVER) for url in requested) + assert all(not url.startswith(_LOCAL_WHEEL_SERVER) for url in requested) + assert "cooldown blocked" in caplog.text + + +def test_is_wheel_built_cache_ignores_recent_upload_time( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """The job cache is trusted even when its upload-time is inside the cooldown. + + Pulp reports the time the artifact was added to the index, not the PyPI + publish date. That timestamp must not cause a cached wheel to be rebuilt. + """ + ctx = _context_for_existing_wheel(tmp_path) + seen: list[str] = [] + monkeypatch.setattr(build_command.wheels, "download_wheel", _record_download(seen)) + + with caplog.at_level(logging.WARNING, logger="fromager.resolver"): + with requests_mock.Mocker() as r: + r.get( + f"{_LOCAL_WHEEL_SERVER}test-pkg/", + json=_empty_index(), + headers={"Content-Type": _PYPI_SIMPLE_JSON_CONTENT_TYPE}, + ) + r.get( + f"{_CACHE_WHEEL_SERVER}test-pkg/", + json=_simple_wheel_index( + _CACHE_WHEEL_SERVER, + upload_time=_RECENT_UPLOAD_TIME, + ), + headers={"Content-Type": _PYPI_SIMPLE_JSON_CONTENT_TYPE}, + ) + found = build_command._is_wheel_built( + ctx, + "test-pkg", + Version("1.3.2"), + cache_wheel_server_url=_CACHE_WHEEL_SERVER, + ) + + assert found is not None + assert seen == [f"{_CACHE_WHEEL_SERVER}test-pkg/test_pkg-1.3.2-py3-none-any.whl"] + assert "cooldown" not in caplog.text.lower() + + # --------------------------------------------------------------------------- # max-release-age tests # --------------------------------------------------------------------------- From 1d07f3088f7c365ecbc0bec229c3beb7086ebcf0 Mon Sep 17 00:00:00 2001 From: Justin Larkin Date: Tue, 29 Sep 2026 09:41:48 -0400 Subject: [PATCH 15/22] feat(wheels): add configurable build tag hook for wheel filenames Add the global wheels.build_tag_hook setting to append validated environment suffixes to wheel build tags. Apply the hook consistently when building wheels and checking local or remote caches. Document the hook configuration and explain how its settings import string differs from process hooks discovered through entry points. Closes: #1181 Co-Authored-By: GPT Sol Signed-off-by: Justin Larkin --- docs/concepts/hooks-and-overrides.rst | 12 +- docs/customization.md | 72 ++++++++ docs/reference/config-reference.rst | 2 + src/fromager/bootstrapper/_cache.py | 112 +++++++----- src/fromager/commands/build.py | 153 ++++++++++------- src/fromager/finders.py | 69 ++++---- src/fromager/packagesettings/__init__.py | 2 + src/fromager/packagesettings/_models.py | 42 +++++ src/fromager/packagesettings/_settings.py | 20 ++- src/fromager/wheels.py | 126 ++++++++++++-- tests/test_bootstrapper.py | 105 ++++++++++++ tests/test_cooldown.py | 153 +++++++++++++++++ tests/test_finders.py | 45 +++++ tests/test_packagesettings.py | 50 ++++++ tests/test_wheels.py | 199 +++++++++++++++++++++- 15 files changed, 1012 insertions(+), 150 deletions(-) diff --git a/docs/concepts/hooks-and-overrides.rst b/docs/concepts/hooks-and-overrides.rst index 43d5f281c..3fcac8369 100644 --- a/docs/concepts/hooks-and-overrides.rst +++ b/docs/concepts/hooks-and-overrides.rst @@ -1,7 +1,7 @@ Hooks and Overrides =================== -Fromager has two plugin systems that serve different purposes: +Fromager has two plugin systems based on entry points: **per-package overrides** replace default behavior for a specific package, while **global hooks** broadcast notifications after events for every package. @@ -82,3 +82,13 @@ entry-point group in their ``pyproject.toml``, mapping a hook name to a callable. See :doc:`/customization` for examples and argument details. + +Configured Wheel Build Tag Hook +------------------------------- + +.. versionadded:: 0.99.0 + +The ``wheels.build_tag_hook`` setting names one callable with a Python +import string in global ``settings.yaml``. Its return value supplies +wheel filename suffixes during builds and cache lookups. See +:doc:`/customization` for the configuration and hook signature. diff --git a/docs/customization.md b/docs/customization.md index 578db04be..feece1dc3 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -393,6 +393,78 @@ $ tox -e cli -- canonicalize flit-core flit_core ``` +## Global settings + +Global settings are configured in the `settings.yaml` file passed via the +`--settings-file` flag. These settings apply to all packages being built. + +### Wheel build tag hook + +```{versionadded} 0.99.0 +``` + +The `build_tag_hook` is a configuration option that allows you to customize +wheel filenames by appending configuration-specific suffixes to the build tag. +This is useful for creating unique, deterministic filenames that reflect the +build configuration and distinguish wheels built for different variants. + +Process hooks register multiple event callbacks through entry points. +`build_tag_hook` selects one callable in `settings.yaml` because Fromager +uses its return value when building wheels and checking the cache. + +Configure the hook in your global `settings.yaml`: + +```yaml +wheels: + build_tag_hook: "myproject.hooks:build_tag_hook" +``` + +The module must be importable by the Python environment running Fromager. + +The hook function receives keyword-only arguments and returns a sequence of +suffix segments (strings) to append to the wheel build tag: + +```python +from typing import Sequence + +from packaging.requirements import Requirement +from packaging.tags import Tag +from packaging.version import Version + +from fromager import context + + +def build_tag_hook( + *, + ctx: context.WorkContext, + req: Requirement, + version: Version, + wheel_tags: frozenset[Tag], +) -> Sequence[str]: + """Return a valid suffix for each configured variant.""" + suffixes = {"cpu": "cpu", "cuda-13": "cuda13"} + return [suffixes[ctx.variant]] +``` + +Fromager joins segments in order with `_`. If the changelog build tag is `2`, +returning `["cuda13", "torch2.10"]` produces `2_cuda13_torch2.10`. + +**Important notes:** + +- The hook runs only when the package has a changelog-derived build tag. It + is skipped when there is no changelog entry or the package is prebuilt. +- Return a sequence of strings, not a single string or bytes object. Each + segment must be non-empty and contain only ASCII letters, digits, or dots. + Map variant names with other characters to distinct valid segments. +- The hook must be deterministic and independent of wheel contents, build + environment, or ELF metadata, so that fresh builds and cache lookups + produce identical tags. +- Use `wheel_tags` only to distinguish pure wheels from platform wheels. + The hook must return identical results across architectures for the same + configured variant. +- Installers do not use build tags to select a variant. Keep incompatible + variants in separate package indexes. + ## Process hooks Fromager supports plugging in Python hooks to be run after build events. diff --git a/docs/reference/config-reference.rst b/docs/reference/config-reference.rst index d81a37fb7..f9c1df4d0 100644 --- a/docs/reference/config-reference.rst +++ b/docs/reference/config-reference.rst @@ -96,3 +96,5 @@ If you prefer managing a single settings file, per-package settings can also be kept in this file. .. autopydantic_model:: fromager.packagesettings.SettingsFile + +.. autopydantic_model:: fromager.packagesettings.WheelSettings diff --git a/src/fromager/bootstrapper/_cache.py b/src/fromager/bootstrapper/_cache.py index e8a3c6830..089895f5b 100644 --- a/src/fromager/bootstrapper/_cache.py +++ b/src/fromager/bootstrapper/_cache.py @@ -86,29 +86,34 @@ def _look_for_existing_wheel( search_in: pathlib.Path, ) -> tuple[pathlib.Path | None, pathlib.Path | None]: pbi = ctx.package_build_info(req) - expected_build_tag = pbi.build_tag(resolved_version) + base_build_tag = pbi.build_tag(resolved_version) logger.info( - f"looking for existing wheel for version {resolved_version} with build tag {expected_build_tag} in {search_in}" + f"looking for existing wheel for version {resolved_version} with build tag {base_build_tag} in {search_in}" ) - wheel_filename = finders.find_wheel( + wheel_filenames = finders.find_wheels( downloads_dir=search_in, req=req, dist_version=str(resolved_version), - build_tag=expected_build_tag, + build_tag=base_build_tag, ) - if not wheel_filename: - return None, None - _, _, build_tag, _ = wheels.extract_info_from_wheel_file(req, wheel_filename) - if expected_build_tag and expected_build_tag != build_tag: - logger.info( - f"found wheel for {resolved_version} in {wheel_filename} but build tag does not match. Got {build_tag} but expected {expected_build_tag}" + for wheel_filename in wheel_filenames: + _, _, actual_build_tag, wheel_tags = wheels.extract_info_from_wheel_file( + req, wheel_filename ) - return None, None - logger.info(f"found existing wheel {wheel_filename}") - build_reqs_dir = _extract_build_reqs_from_wheel( - ctx.work_dir, req, resolved_version, wheel_filename - ) - return wheel_filename, build_reqs_dir + expected_build_tag = wheels.get_build_tag( + ctx=ctx, req=req, version=resolved_version, wheel_tags=wheel_tags + ) + if expected_build_tag and expected_build_tag != actual_build_tag: + logger.info( + f"found wheel for {resolved_version} in {wheel_filename} but build tag does not match. Got {actual_build_tag} but expected {expected_build_tag}" + ) + continue + logger.info(f"found existing wheel {wheel_filename}") + build_reqs_dir = _extract_build_reqs_from_wheel( + ctx.work_dir, req, resolved_version, wheel_filename + ) + return wheel_filename, build_reqs_dir + return None, None def _download_wheel_from_cache( @@ -127,31 +132,6 @@ def _download_wheel_from_cache( constraints=ctx.constraints, ) results = resolver.find_all_matching_from_provider(provider, pinned_req) - wheel_url, _ = results[0] - wheelfile_name = pathlib.Path(urlparse(wheel_url).path) - pbi = ctx.package_build_info(req) - expected_build_tag = pbi.build_tag(resolved_version) - logger.info(f"has expected build tag {expected_build_tag}") - changelogs = pbi.get_changelog(resolved_version) - logger.debug(f"has change logs {changelogs}") - - _, _, build_tag, _ = wheels.extract_info_from_wheel_file(req, wheelfile_name) - if expected_build_tag and expected_build_tag != build_tag: - logger.info( - f"found wheel for {resolved_version} in cache but build tag does not match. Got {build_tag} but expected {expected_build_tag}" - ) - return None, None - - cached_wheel = wheels.download_wheel( - req=req, wheel_url=wheel_url, output_directory=ctx.wheels_downloads - ) - if cache_wheel_server_url != ctx.wheel_server_url: - server.update_wheel_mirror(ctx) - logger.info("found built wheel on cache server") - unpack_dir = _extract_build_reqs_from_wheel( - ctx.work_dir, req, resolved_version, cached_wheel - ) - return cached_wheel, unpack_dir except ResolverException: logger.info( f"did not find wheel for {resolved_version} in {cache_wheel_server_url}" @@ -170,6 +150,56 @@ def _download_wheel_from_cache( ) return None, None + for wheel_url, _ in results: + try: + wheel_filename = pathlib.Path(urlparse(wheel_url).path) + _, _, actual_build_tag, wheel_tags = wheels.extract_info_from_wheel_file( + req, wheel_filename + ) + except Exception as err: + logger.warning(f"could not parse cached wheel {wheel_url}: {err}") + continue + + pbi = ctx.package_build_info(req) + expected_build_tag = wheels.get_build_tag( + ctx=ctx, req=req, version=resolved_version, wheel_tags=wheel_tags + ) + logger.info(f"has expected build tag {expected_build_tag}") + changelogs = pbi.get_changelog(resolved_version) + logger.debug(f"has change logs {changelogs}") + + if expected_build_tag and expected_build_tag != actual_build_tag: + logger.info( + f"found wheel for {resolved_version} in cache but build tag does not match. Got {actual_build_tag} but expected {expected_build_tag}" + ) + continue + + try: + cached_wheel = wheels.download_wheel( + req=req, wheel_url=wheel_url, output_directory=ctx.wheels_downloads + ) + if cache_wheel_server_url != ctx.wheel_server_url: + server.update_wheel_mirror(ctx) + logger.info("found built wheel on cache server") + unpack_dir = _extract_build_reqs_from_wheel( + ctx.work_dir, req, resolved_version, cached_wheel + ) + return cached_wheel, unpack_dir + except requests.exceptions.RequestException as err: + logger.warning( + f"network error checking wheel cache for {resolved_version} " + f"at {cache_wheel_server_url}: {err}" + ) + return None, None + except Exception as err: + logger.warning( + f"unexpected error checking wheel cache for {resolved_version} " + f"at {cache_wheel_server_url}: {err}" + ) + return None, None + + return None, None + def find_cached_wheel( ctx: context.WorkContext, diff --git a/src/fromager/commands/build.py b/src/fromager/commands/build.py index 67f96439a..05284bc3e 100644 --- a/src/fromager/commands/build.py +++ b/src/fromager/commands/build.py @@ -489,69 +489,6 @@ def _is_wheel_built( req, cache_wheel_server_url=cache_wheel_server_url, ) - logger.info( - "checking if a suitable wheel for %s was already built on %s", - req, - servers, - ) - - url: str | None = None - for server_url in servers: - try: - if pbi.wheel_server_url: - # Upstream pre-built index. Release-age cooldown stays on. - url, _ = wheels.resolve_prebuilt_wheel( - ctx=wkctx, - req=req, - wheel_server_urls=[server_url], - ) - else: - # Local server and --cache-wheel-server-url are trusted caches. - url, _ = wheels.resolve_cached_wheel( - ctx=wkctx, - req=req, - cache_server_url=server_url, - ) - break - except Exception: - logger.debug("wheel not found on %s", server_url, exc_info=True) - - if url is None: - logger.info("could not locate existing wheel") - return None - - logger.info("found candidate wheel %s", url) - build_tag_from_settings = pbi.build_tag(resolved_version) - build_tag = build_tag_from_settings if build_tag_from_settings else (0, "") - wheel_basename = downloads.extract_filename_from_url(url) - _, _, build_tag_from_name, _ = parse_wheel_filename(wheel_basename) - existing_build_tag = build_tag_from_name if build_tag_from_name else (0, "") - if ( - existing_build_tag[0] > build_tag[0] - and existing_build_tag[1] == build_tag[1] - ): - raise ValueError( - f"{dist_name}: changelog for version {resolved_version} is inconsistent. Found build tag {existing_build_tag} but expected {build_tag}" - ) - if existing_build_tag != build_tag: - logger.info( - f"candidate wheel build tag {existing_build_tag} does not match expected build tag {build_tag}" - ) - return None - - wheel_filename: pathlib.Path | None = None - if wkctx.wheel_server_url and url.startswith(wkctx.wheel_server_url): - logging.debug("found wheel on local server") - wheel_filename = wkctx.wheels_downloads / wheel_basename - if not wheel_filename.exists(): - logger.info("wheel not found in local cache, preparing to download") - wheel_filename = None - - if not wheel_filename: - logger.info("downloading wheel from %s", url) - wheel_filename = wheels.download_wheel(req, url, wkctx.wheels_downloads) - - return wheel_filename except Exception: logger.debug( "could not locate existing wheel %s-%s", @@ -562,6 +499,96 @@ def _is_wheel_built( logger.info("could not locate existing wheel") return None + logger.info( + "checking if a suitable wheel for %s was already built on %s", + req, + servers, + ) + for server_url in servers: + try: + if pbi.wheel_server_url: + # Upstream pre-built index. Release-age cooldown stays on. + candidates = wheels.resolve_all_prebuilt_wheels( + ctx=wkctx, + req=req, + wheel_server_urls=[server_url], + ) + else: + # Local server and --cache-wheel-server-url are trusted caches. + candidates = wheels.resolve_all_cached_wheels( + ctx=wkctx, + req=req, + cache_server_url=server_url, + ) + except Exception: + logger.debug("wheel not found on %s", server_url, exc_info=True) + continue + + for url, candidate_version in candidates: + logger.info("found candidate wheel %s", url) + try: + wheel_basename = downloads.extract_filename_from_url(url) + _, _, build_tag_from_name, wheel_tags = parse_wheel_filename( + wheel_basename + ) + except Exception: + logger.debug("could not parse candidate wheel %s", url, exc_info=True) + continue + + # Keep hook validation errors visible instead of treating them as + # a cache miss. + expected_tag = wheels.get_build_tag( + ctx=wkctx, + req=req, + version=candidate_version, + wheel_tags=wheel_tags, + ) + build_tag = expected_tag if expected_tag else (0, "") + existing_build_tag = build_tag_from_name if build_tag_from_name else (0, "") + if ( + existing_build_tag[0] > build_tag[0] + and existing_build_tag[1] == build_tag[1] + ): + raise ValueError( + f"{dist_name}: changelog for version {candidate_version} is inconsistent. Found build tag {existing_build_tag} but expected {build_tag}" + ) + if existing_build_tag != build_tag: + logger.info( + "candidate wheel build tag %s does not match expected build tag %s", + existing_build_tag, + build_tag, + ) + continue + + wheel_filename: pathlib.Path | None = None + if wkctx.wheel_server_url and url.startswith(wkctx.wheel_server_url): + logging.debug("found wheel on local server") + wheel_filename = wkctx.wheels_downloads / wheel_basename + if not wheel_filename.exists(): + logger.info("wheel not found in local cache, preparing to download") + wheel_filename = None + + if not wheel_filename: + try: + logger.info("downloading wheel from %s", url) + wheel_filename = wheels.download_wheel( + req, url, wkctx.wheels_downloads + ) + except Exception: + logger.debug( + "failed to download prebuilt wheel %s-%s", + dist_name, + candidate_version, + exc_info=True, + ) + logger.info("could not download prebuilt wheel") + continue + + return wheel_filename + + logger.info("could not locate existing wheel") + return None + def _build_parallel( wkctx: context.WorkContext, diff --git a/src/fromager/finders.py b/src/fromager/finders.py index 69c31e331..619f87e74 100644 --- a/src/fromager/finders.py +++ b/src/fromager/finders.py @@ -153,7 +153,18 @@ def find_wheel( dist_version: str, build_tag: BuildTag = (), ) -> pathlib.Path | None: - """Find a wheel file in downloads_dir for the given requirement. + """Return the first matching wheel file for the given requirement.""" + matches = find_wheels(downloads_dir, req, dist_version, build_tag) + return matches[0] if matches else None + + +def find_wheels( + downloads_dir: pathlib.Path, + req: Requirement, + dist_version: str, + build_tag: BuildTag = (), +) -> list[pathlib.Path]: + """Find matching wheel files in downloads_dir for the given requirement. Tries four naming conventions (PEP 427 transformed, canonical, original, and dotted), each suffixed with the build tag when present. Uses @@ -162,37 +173,35 @@ def find_wheel( """ filename_prefix = _dist_name_to_filename(req.name) canonical_name = canonicalize_name(req.name) - # if build tag is 0 then we can ignore to handle non tagged wheels for backward compatibility - candidate_bases_build_tag = f"{build_tag[0]}{build_tag[1]}-" if build_tag else "" - candidate_bases = set( - [ - # First check if the file is there using the canonically - # transformed name. - f"{filename_prefix}-{dist_version}-{candidate_bases_build_tag}", - # If that didn't work, try the canonical dist name. That's not - # "correct" but we do see it. (charset-normalizer-3.3.2- - # and setuptools-scm-8.0.4-) for example - f"{canonical_name}-{dist_version}-{candidate_bases_build_tag}", - # If *that* didn't work, try the dist name we've been - # given as a dependency. That's not "correct", either but we do - # see it. (oslo.messaging-14.7.0-) for example - f"{req.name}-{dist_version}-{candidate_bases_build_tag}", - # Sometimes the sdist uses '.' instead of '-' in the - # package name portion. - f"{req.name.replace('-', '.')}-{dist_version}-{candidate_bases_build_tag}", - ] - ) - # Case-insensitive globbing was added to Python 3.12, but we - # have to run with older versions, too, so do our own name - # comparison. - for base in candidate_bases: - logger.debug('looking for wheel as "%s"', base) - for filename in downloads_dir.glob("*.whl"): - if str(filename.name).lower().startswith(base.lower()): - return filename + build_tag_prefixes: list[str] = [] + if build_tag: + build_tag_prefixes.append(f"{build_tag[0]}{build_tag[1]}-") + if not build_tag[1]: + build_tag_prefixes.append(f"{build_tag[0]}_") + else: + build_tag_prefixes.append("") - return None + name_variants = [ + filename_prefix, + canonical_name, + req.name, + req.name.replace("-", "."), + ] + + candidate_bases: set[str] = set() + for name in name_variants: + for btp in build_tag_prefixes: + candidate_bases.add(f"{name}-{dist_version}-{btp}") + + for base in sorted(candidate_bases): + logger.debug('looking for wheel as "%s"', base) + lower_bases = {base.lower() for base in candidate_bases} + return [ + filename + for filename in sorted(downloads_dir.glob("*.whl")) + if any(filename.name.lower().startswith(base) for base in lower_bases) + ] def default_expected_source_directory_name(req: Requirement, dist_version: str) -> str: diff --git a/src/fromager/packagesettings/__init__.py b/src/fromager/packagesettings/__init__.py index abca2f21d..7bcb9cdf5 100644 --- a/src/fromager/packagesettings/__init__.py +++ b/src/fromager/packagesettings/__init__.py @@ -12,6 +12,7 @@ ResolverDist, SbomSettings, VariantInfo, + WheelSettings, ) from ._pbi import PackageBuildInfo from ._resolver import ( @@ -88,6 +89,7 @@ "Variant", "VariantChangelog", "VariantInfo", + "WheelSettings", "default_update_extra_environ", "get_extra_environ", "pep440_tag_matcher", diff --git a/src/fromager/packagesettings/_models.py b/src/fromager/packagesettings/_models.py index e87230613..a3ad12816 100644 --- a/src/fromager/packagesettings/_models.py +++ b/src/fromager/packagesettings/_models.py @@ -33,6 +33,48 @@ logger = logging.getLogger(__name__) +class WheelSettings(pydantic.BaseModel): + """Global wheel build settings + + :: + + wheels: + build_tag_hook: "mypackage.hooks:build_tag_hook" + + .. versionadded:: 0.99.0 + """ + + model_config = MODEL_CONFIG + + build_tag_hook: pydantic.ImportString[typing.Callable[..., typing.Any]] | None = ( + None + ) + """Callable that returns suffix segments for the wheel build tag. + + The callable receives keyword-only arguments ``ctx``, ``req``, + ``version``, and ``wheel_tags`` and returns + ``Sequence[str]`` of suffix segments. + + Only invoked when the package already has a non-empty build tag + from its changelog entry for the given version; otherwise the hook + is skipped and no build tag is added. The callable must be + deterministic and independent of wheel contents, build environment, + or ELF metadata so fresh builds and cache lookups compute the same + tag. + + .. note:: + + The ``wheel_tags`` argument should only be used to distinguish + platlib wheels (platform-specific) from purelib wheels + (``py3-none-any``), for example to skip variant suffixes on + pure-python packages. Do not use it for platform-specific + decisions -- the hook must produce identical results across + architectures for the same variant. + + .. versionadded:: 0.99.0 + """ + + class SbomSettings(pydantic.BaseModel): """Global SBOM generation settings diff --git a/src/fromager/packagesettings/_settings.py b/src/fromager/packagesettings/_settings.py index 888e6b225..f2077826c 100644 --- a/src/fromager/packagesettings/_settings.py +++ b/src/fromager/packagesettings/_settings.py @@ -13,7 +13,7 @@ from pydantic import Field from .. import overrides -from ._models import ExternalCommands, PackageSettings, SbomSettings +from ._models import ExternalCommands, PackageSettings, SbomSettings, WheelSettings from ._pbi import PackageBuildInfo from ._typedefs import MODEL_CONFIG, GlobalChangelog, Package, Variant @@ -55,6 +55,14 @@ class SettingsFile(pydantic.BaseModel): .. versionadded:: 0.92.0 """ + wheels: WheelSettings | None = None + """Wheel build settings + + Configures wheel build tag hooks and other wheel-specific options. + + .. versionadded:: 0.99.0 + """ + @classmethod def from_string( cls, @@ -193,6 +201,16 @@ def external_commands(self) -> ExternalCommands: """ return self._settings.external_commands + @property + def build_tag_hook(self) -> typing.Callable[..., typing.Any] | None: + """Get the wheel build tag hook callable, or None if not configured. + + .. versionadded:: 0.99.0 + """ + if self._settings.wheels is None: + return None + return self._settings.wheels.build_tag_hook + def variant_changelog(self) -> list[str]: """Get global changelog for current variant""" return list(self._settings.changelog.get(self.variant, [])) diff --git a/src/fromager/wheels.py b/src/fromager/wheels.py index 6c3535e56..943edea8d 100644 --- a/src/fromager/wheels.py +++ b/src/fromager/wheels.py @@ -4,6 +4,7 @@ import logging import os import pathlib +import re import shutil import sys import tempfile @@ -39,12 +40,69 @@ logger = logging.getLogger(__name__) +_BUILD_TAG_SEGMENT_RE = re.compile(r"^[a-zA-Z0-9.]+$") + FROMAGER_BUILD_SETTINGS = "fromager-build-settings" FROMAGER_ELF_PROVIDES = "fromager-elf-provides.txt" FROMAGER_ELF_REQUIRES = "fromager-elf-requires.txt" FROMAGER_BUILD_REQ_PREFIX = "fromager" +def _validate_build_tag_segments(segments: list[str]) -> None: + """Validate that each segment matches ``[a-zA-Z0-9.]``.""" + for seg in segments: + if not isinstance(seg, str): + raise ValueError( + f"build_tag_hook must return strings, got {type(seg).__name__}" + ) + if not _BUILD_TAG_SEGMENT_RE.fullmatch(seg): + raise ValueError( + f"build tag hook returned invalid segment {seg!r}: " + "each segment must match [a-zA-Z0-9.]" + ) + + +def get_build_tag( + *, + ctx: context.WorkContext, + req: Requirement, + version: Version, + wheel_tags: frozenset[Tag], +) -> BuildTag: + """Compute the full build tag including any hook-provided suffix. + + Calls ``pbi.build_tag(version)`` for the numeric base, then invokes + the configured ``build_tag_hook`` (if any) to append variant + suffix segments. The hook should use *wheel_tags* only to + distinguish platlib from purelib wheels, not for platform-specific + decisions. + + .. versionadded:: 0.99.0 + """ + pbi = ctx.package_build_info(req) + base_tag = pbi.build_tag(version) + if not base_tag: + return base_tag + + hook = ctx.settings.build_tag_hook + if hook is None: + return base_tag + + raw = hook(ctx=ctx, req=req, version=version, wheel_tags=wheel_tags) + if isinstance(raw, str | bytes): + raise ValueError( + "build_tag_hook must return a sequence of strings, not a single string" + ) + segments = list(raw) + _validate_build_tag_segments(segments) + + if not segments: + return base_tag + + suffix = base_tag[1] + "_" + "_".join(segments) + return (base_tag[0], suffix) + + def _log_existing_sboms( req: Requirement, dist_info_dir: pathlib.Path, @@ -265,8 +323,11 @@ def add_extra_metadata_to_wheels( ) sbom.write_sbom(sbom=sbom_doc, dist_info_dir=dist_info_dir) - build_tag_from_settings = pbi.build_tag(version) - build_tag = build_tag_from_settings if build_tag_from_settings else (0, "") + build_tag = get_build_tag( + ctx=ctx, req=req, version=version, wheel_tags=wheel_tags + ) + if not build_tag: + build_tag = (0, "") cmd = [ "wheel", @@ -509,27 +570,52 @@ def get_prebuilt_wheel_provider( ) -@metrics.timeit(description="resolve wheel") -def resolve_cached_wheel( +def _resolve_all_cached_wheels( *, ctx: context.WorkContext, req: Requirement, cache_server_url: str, -) -> tuple[str, Version]: - """Resolve a wheel from a trusted cache server (local or remote). - - Uses ``PyPICacheProvider`` -- no cooldown, no hooks, no upload-time checks. - """ +) -> list[tuple[str, Version]]: + """Return matching wheels from a trusted cache server.""" provider = finders.PyPICacheProvider( cache_server_url=cache_server_url, constraints=ctx.constraints, ) results = resolver.find_all_matching_from_provider(provider, req) - wheel_url, version = results[0] - return str(wheel_url), version + return [(str(wheel_url), version) for wheel_url, version in results] -def resolve_all_prebuilt_wheels( +@metrics.timeit(description="resolve wheel") +def resolve_all_cached_wheels( + *, + ctx: context.WorkContext, + req: Requirement, + cache_server_url: str, +) -> list[tuple[str, Version]]: + """Return all matching wheels from a trusted cache server. + + Uses ``PyPICacheProvider`` -- no cooldown, no hooks, no upload-time checks. + """ + return _resolve_all_cached_wheels( + ctx=ctx, req=req, cache_server_url=cache_server_url + ) + + +@metrics.timeit(description="resolve wheel") +def resolve_cached_wheel( + *, + ctx: context.WorkContext, + req: Requirement, + cache_server_url: str, +) -> tuple[str, Version]: + """Resolve the best wheel from a trusted cache server (local or remote).""" + results = _resolve_all_cached_wheels( + ctx=ctx, req=req, cache_server_url=cache_server_url + ) + return results[0] + + +def _resolve_all_prebuilt_wheels( *, ctx: context.WorkContext, req: Requirement, @@ -567,6 +653,20 @@ def resolve_all_prebuilt_wheels( ) +@metrics.timeit(description="resolve wheel") +def resolve_all_prebuilt_wheels( + *, + ctx: context.WorkContext, + req: Requirement, + wheel_server_urls: list[str], + req_type: requirements_file.RequirementType | None = None, +) -> list[tuple[str, Version]]: + """Return all matching prebuilt wheels from the first successful server.""" + return _resolve_all_prebuilt_wheels( + ctx=ctx, req=req, wheel_server_urls=wheel_server_urls, req_type=req_type + ) + + @metrics.timeit(description="resolve wheel") def resolve_prebuilt_wheel( *, @@ -580,7 +680,7 @@ def resolve_prebuilt_wheel( Tries wheel servers in order and returns the highest matching version from the first server that succeeds. """ - results = resolve_all_prebuilt_wheels( + results = _resolve_all_prebuilt_wheels( ctx=ctx, req=req, wheel_server_urls=wheel_server_urls, req_type=req_type ) # Return highest version (first in sorted list) diff --git a/tests/test_bootstrapper.py b/tests/test_bootstrapper.py index e640cfb07..3d5c6e47a 100644 --- a/tests/test_bootstrapper.py +++ b/tests/test_bootstrapper.py @@ -15,6 +15,7 @@ from fromager.bootstrapper._build import Build from fromager.bootstrapper._cache import ( _download_wheel_from_cache, + _look_for_existing_wheel, bg_prepare_prebuilt, find_cached_wheel, ) @@ -36,6 +37,7 @@ from fromager.bootstrapper._work_item import WorkItem from fromager.context import WorkContext from fromager.dependency_graph import ROOT +from fromager.packagesettings import WheelSettings from fromager.requirements_file import RequirementType, SourceType @@ -492,6 +494,109 @@ def _make_cache_bootstrapper( return bt +def _set_cache_build_tag_hook( + ctx: WorkContext, hook: typing.Callable[..., list[str]] +) -> None: + """Configure a build tag hook for cache lookup tests.""" + ctx.settings._settings = ctx.settings._settings.model_copy( + update={"wheels": WheelSettings(build_tag_hook=hook)} + ) + + +def test_cache_lookup_checks_all_local_build_tags( + testdata_context: WorkContext, +) -> None: + """A stale local suffix does not hide a wheel with the expected suffix.""" + + def hook(**kwargs: object) -> list[str]: + return ["zzz"] + + _set_cache_build_tag_hook(testdata_context, hook) + req = Requirement("test-pkg") + version = Version("1.0.1") + stale = testdata_context.wheels_build / "test_pkg-1.0.1-1_aaa-py3-none-any.whl" + matching = testdata_context.wheels_build / "test_pkg-1.0.1-1_zzz-py3-none-any.whl" + stale.touch() + matching.touch() + + with patch( + "fromager.bootstrapper._cache._extract_build_reqs_from_wheel", + return_value=None, + ): + wheel, _ = _look_for_existing_wheel( + testdata_context, req, version, testdata_context.wheels_build + ) + + assert wheel == matching + + +def test_cache_lookup_checks_all_remote_build_tags( + testdata_context: WorkContext, +) -> None: + """A stale remote suffix does not hide a wheel with the expected suffix.""" + + def hook(**kwargs: object) -> list[str]: + return ["zzz"] + + _set_cache_build_tag_hook(testdata_context, hook) + req = Requirement("test-pkg") + version = Version("1.0.1") + cache_url = "https://cache.test/simple" + testdata_context.wheel_server_url = cache_url + stale_url = f"{cache_url}/test_pkg-1.0.1-1_aaa-py3-none-any.whl" + matching_name = "test_pkg-1.0.1-1_zzz-py3-none-any.whl" + matching_url = f"{cache_url}/{matching_name}" + matching = testdata_context.wheels_downloads / matching_name + + with ( + patch( + "fromager.resolver.find_all_matching_from_provider", + return_value=[(stale_url, version), (matching_url, version)], + ), + patch("fromager.wheels.download_wheel", return_value=matching) as download, + patch( + "fromager.bootstrapper._cache._extract_build_reqs_from_wheel", + return_value=None, + ), + ): + wheel, _ = _download_wheel_from_cache(testdata_context, cache_url, req, version) + + assert wheel == matching + download.assert_called_once_with( + req=req, + wheel_url=matching_url, + output_directory=testdata_context.wheels_downloads, + ) + + +def test_cache_lookup_propagates_build_tag_hook_error( + testdata_context: WorkContext, +) -> None: + """Invalid hook output fails the lookup instead of becoming a cache miss.""" + + def hook(**kwargs: object) -> list[str]: + return ["bad-suffix"] + + _set_cache_build_tag_hook(testdata_context, hook) + req = Requirement("test-pkg") + version = Version("1.0.1") + cache_url = "https://cache.test/simple" + testdata_context.wheel_server_url = cache_url + wheel_url = f"{cache_url}/test_pkg-1.0.1-1_valid-py3-none-any.whl" + + with ( + patch( + "fromager.resolver.find_all_matching_from_provider", + return_value=[(wheel_url, version)], + ), + patch("fromager.wheels.download_wheel") as download, + pytest.raises(ValueError, match="invalid segment"), + ): + _download_wheel_from_cache(testdata_context, cache_url, req, version) + + download.assert_not_called() + + def test_cache_lookup_resolver_exception_logs_info( tmp_context: WorkContext, caplog: pytest.LogCaptureFixture, diff --git a/tests/test_cooldown.py b/tests/test_cooldown.py index dfa1305d7..4eee08b9d 100644 --- a/tests/test_cooldown.py +++ b/tests/test_cooldown.py @@ -28,6 +28,7 @@ wheels, ) from fromager.commands import build as build_command +from fromager.packagesettings import WheelSettings from fromager.requirements_file import RequirementType _BOOTSTRAP_TIME = datetime.datetime(2026, 3, 26, 0, 0, 0, tzinfo=datetime.UTC) @@ -875,6 +876,23 @@ def _simple_wheel_index( } +def _wheel_index_for_filenames( + server_url: str, filenames: list[str] +) -> dict[str, typing.Any]: + return { + "meta": {"api-version": "1.1"}, + "name": "test-pkg", + "files": [ + { + "filename": filename, + "url": f"{server_url}test-pkg/{filename}", + "hashes": {"sha256": "bbb"}, + } + for filename in filenames + ], + } + + def _empty_index() -> dict[str, typing.Any]: return {"meta": {"api-version": "1.1"}, "name": "test-pkg", "files": []} @@ -927,6 +945,141 @@ def _download( return _download +def _set_existing_wheel_build_tag_hook( + ctx: context.WorkContext, suffixes: list[str] +) -> None: + """Configure a hook and changelog for build-tag lookup tests.""" + + def hook(**kwargs: object) -> list[str]: + return suffixes + + ctx.settings._settings = ctx.settings._settings.model_copy( + update={ + "changelog": {"cpu": ["test entry"]}, + "wheels": WheelSettings(build_tag_hook=hook), + } + ) + ctx.settings._pbi_cache.clear() + + +def test_is_wheel_built_checks_all_candidates( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A mismatched candidate does not hide a later match on the same server.""" + ctx = _context_for_existing_wheel(tmp_path) + _set_existing_wheel_build_tag_hook(ctx, ["aaa"]) + + version = Version("1.3.2") + stale_name = "test_pkg-1.3.2-1_zzz-py3-none-any.whl" + matching_name = "test_pkg-1.3.2-1_aaa-py3-none-any.whl" + matching_url = f"{_LOCAL_WHEEL_SERVER}test-pkg/{matching_name}" + + downloaded: list[str] = [] + monkeypatch.setattr( + build_command.wheels, "download_wheel", _record_download(downloaded) + ) + + with requests_mock.Mocker() as r: + r.get( + f"{_LOCAL_WHEEL_SERVER}test-pkg/", + json=_wheel_index_for_filenames( + _LOCAL_WHEEL_SERVER, [stale_name, matching_name] + ), + headers={"Content-Type": _PYPI_SIMPLE_JSON_CONTENT_TYPE}, + ) + found = build_command._is_wheel_built(ctx, "test-pkg", version) + + assert found is not None + assert found.name == matching_name + assert downloaded == [matching_url] + assert [request.url for request in r.request_history] == [ + f"{_LOCAL_WHEEL_SERVER}test-pkg/" + ] + + +def test_is_wheel_built_checks_next_server_after_tag_mismatch( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A mismatch on the local server does not hide a match in the job cache.""" + ctx = _context_for_existing_wheel(tmp_path) + _set_existing_wheel_build_tag_hook(ctx, ["aaa"]) + + version = Version("1.3.2") + stale_name = "test_pkg-1.3.2-1_zzz-py3-none-any.whl" + matching_name = "test_pkg-1.3.2-1_aaa-py3-none-any.whl" + matching_url = f"{_CACHE_WHEEL_SERVER}test-pkg/{matching_name}" + downloaded: list[str] = [] + monkeypatch.setattr( + build_command.wheels, "download_wheel", _record_download(downloaded) + ) + + with requests_mock.Mocker() as r: + r.get( + f"{_LOCAL_WHEEL_SERVER}test-pkg/", + json=_wheel_index_for_filenames(_LOCAL_WHEEL_SERVER, [stale_name]), + headers={"Content-Type": _PYPI_SIMPLE_JSON_CONTENT_TYPE}, + ) + r.get( + f"{_CACHE_WHEEL_SERVER}test-pkg/", + json=_wheel_index_for_filenames(_CACHE_WHEEL_SERVER, [matching_name]), + headers={"Content-Type": _PYPI_SIMPLE_JSON_CONTENT_TYPE}, + ) + found = build_command._is_wheel_built( + ctx, + "test-pkg", + version, + cache_wheel_server_url=_CACHE_WHEEL_SERVER, + ) + + assert found is not None + assert found.name == matching_name + assert [request.url for request in r.request_history] == [ + f"{_LOCAL_WHEEL_SERVER}test-pkg/", + f"{_CACHE_WHEEL_SERVER}test-pkg/", + ] + assert downloaded == [matching_url] + + +def test_is_wheel_built_propagates_build_tag_hook_error( + tmp_path: pathlib.Path, +) -> None: + """Invalid hook output is not treated as a cache miss.""" + ctx = _context_for_existing_wheel(tmp_path) + _set_existing_wheel_build_tag_hook(ctx, ["invalid-suffix"]) + version = Version("1.3.2") + filename = "test_pkg-1.3.2-1_aaa-py3-none-any.whl" + + with requests_mock.Mocker() as r: + r.get( + f"{_LOCAL_WHEEL_SERVER}test-pkg/", + json=_wheel_index_for_filenames(_LOCAL_WHEEL_SERVER, [filename]), + headers={"Content-Type": _PYPI_SIMPLE_JSON_CONTENT_TYPE}, + ) + with pytest.raises(ValueError, match="invalid segment"): + build_command._is_wheel_built(ctx, "test-pkg", version) + + +def test_is_wheel_built_propagates_inconsistent_build_tag( + tmp_path: pathlib.Path, +) -> None: + """A higher build number with the expected suffix remains an error.""" + ctx = _context_for_existing_wheel(tmp_path) + _set_existing_wheel_build_tag_hook(ctx, ["aaa"]) + version = Version("1.3.2") + filename = "test_pkg-1.3.2-2_aaa-py3-none-any.whl" + + with requests_mock.Mocker() as r: + r.get( + f"{_LOCAL_WHEEL_SERVER}test-pkg/", + json=_wheel_index_for_filenames(_LOCAL_WHEEL_SERVER, [filename]), + headers={"Content-Type": _PYPI_SIMPLE_JSON_CONTENT_TYPE}, + ) + with pytest.raises(ValueError, match=r"changelog.*inconsistent"): + build_command._is_wheel_built(ctx, "test-pkg", version) + + def test_is_wheel_built_uses_package_wheel_server_url( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_finders.py b/tests/test_finders.py index 110ccfa1f..691fdfebf 100644 --- a/tests/test_finders.py +++ b/tests/test_finders.py @@ -146,3 +146,48 @@ def test_pypi_cache_provider() -> None: finders.PyPICacheProvider( cache_server_url=url, include_sdists=False, include_wheels=False ) + + +class TestFindWheelBuildTagSuffix: + """Tests for ``find_wheel`` with build tag suffixes.""" + + def test_find_wheel_with_exact_build_tag(self, tmp_path: pathlib.Path) -> None: + """Plain build tag matches a plain-tagged wheel.""" + downloads = tmp_path / "downloads" + downloads.mkdir() + wheel = downloads / "mypkg-1.0.0-2-py3-none-any.whl" + wheel.write_text("not-empty") + result = finders.find_wheel(downloads, Requirement("mypkg"), "1.0.0", (2, "")) + assert result == wheel + + def test_find_wheel_matches_suffixed_with_base_tag( + self, tmp_path: pathlib.Path + ) -> None: + """Base tag (2, '') matches a suffixed wheel when no plain one exists.""" + downloads = tmp_path / "downloads" + downloads.mkdir() + wheel = downloads / "mypkg-1.0.0-2_el9.6-cp312-cp312-linux_x86_64.whl" + wheel.write_text("not-empty") + result = finders.find_wheel(downloads, Requirement("mypkg"), "1.0.0", (2, "")) + assert result == wheel + + def test_find_wheel_no_false_positive_on_higher_number( + self, tmp_path: pathlib.Path + ) -> None: + """Build tag 2 does not match build tag 20.""" + downloads = tmp_path / "downloads" + downloads.mkdir() + (downloads / "mypkg-1.0.0-20-py3-none-any.whl").write_text("not-empty") + result = finders.find_wheel(downloads, Requirement("mypkg"), "1.0.0", (2, "")) + assert result is None + + def test_find_wheel_with_full_suffix(self, tmp_path: pathlib.Path) -> None: + """Exact suffixed build tag matches the right wheel.""" + downloads = tmp_path / "downloads" + downloads.mkdir() + wheel = downloads / "mypkg-1.0.0-2_el9.6_cuda13.0-cp312-cp312-linux_x86_64.whl" + wheel.write_text("not-empty") + result = finders.find_wheel( + downloads, Requirement("mypkg"), "1.0.0", (2, "_el9.6_cuda13.0") + ) + assert result == wheel diff --git a/tests/test_packagesettings.py b/tests/test_packagesettings.py index a5107ad98..2c3f8f3fc 100644 --- a/tests/test_packagesettings.py +++ b/tests/test_packagesettings.py @@ -1,3 +1,4 @@ +import os import pathlib import typing from unittest.mock import Mock, patch @@ -1152,3 +1153,52 @@ def test_filter_env( ) -> None: ec = ExternalCommands(keep_env=keep, delete_env=delete) assert ec.filter_env(env) == expected + + +class TestWheelSettings: + """Tests for ``WheelSettings`` and ``SettingsFile.wheels``.""" + + def test_settings_file_parses_wheels_section(self) -> None: + """wheels.build_tag_hook is loaded from YAML.""" + sf = SettingsFile.from_string( + """ +wheels: + build_tag_hook: "os.path:join" +""" + ) + assert sf.wheels is not None + assert sf.wheels.build_tag_hook is os.path.join + + def test_settings_file_defaults_to_no_wheels(self) -> None: + """When wheels section is absent, wheels is None.""" + sf = SettingsFile.from_string("") + assert sf.wheels is None + + def test_settings_build_tag_hook_property(self) -> None: + """Settings.build_tag_hook exposes the hook callable.""" + sf = SettingsFile.from_string( + """ +wheels: + build_tag_hook: "os.path:join" +""" + ) + settings = Settings( + settings=sf, + package_settings=[], + variant="cpu", + patches_dir=pathlib.Path("/tmp"), + max_jobs=None, + ) + assert settings.build_tag_hook is os.path.join + + def test_settings_build_tag_hook_none_when_unset(self) -> None: + """Settings.build_tag_hook is None when not configured.""" + sf = SettingsFile.from_string("") + settings = Settings( + settings=sf, + package_settings=[], + variant="cpu", + patches_dir=pathlib.Path("/tmp"), + max_jobs=None, + ) + assert settings.build_tag_hook is None diff --git a/tests/test_wheels.py b/tests/test_wheels.py index 1c589d012..91bb698e2 100644 --- a/tests/test_wheels.py +++ b/tests/test_wheels.py @@ -1,14 +1,16 @@ import pathlib +import typing import zipfile from unittest.mock import Mock, patch import pytest from conftest import make_sbom_ctx from packaging.requirements import Requirement +from packaging.tags import Tag from packaging.version import Version from fromager import build_environment, context, downloads, wheels -from fromager.packagesettings import SbomSettings +from fromager.packagesettings import SbomSettings, Settings, SettingsFile, WheelSettings @patch("pyproject_hooks.BuildBackendHookCaller.build_wheel") @@ -337,3 +339,198 @@ def test_validate_wheel_file( else: with pytest.raises(ValueError): wheels.validate_wheel_filename(req, version, wheel_file) + + +def _ctx_with_hook( + tmp_path: pathlib.Path, + hook: typing.Callable[..., typing.Any] | None = None, +) -> context.WorkContext: + """Create a WorkContext with an optional build_tag_hook.""" + sf = SettingsFile.from_string("") + if hook is not None: + sf = sf.model_copy(update={"wheels": WheelSettings(build_tag_hook=hook)}) + settings = Settings( + settings=sf, + package_settings=[], + variant="cpu", + patches_dir=tmp_path / "patches", + max_jobs=None, + ) + ctx = context.WorkContext( + active_settings=settings, + patches_dir=tmp_path / "patches", + sdists_repo=tmp_path / "sdists-repo", + wheels_repo=tmp_path / "wheels-repo", + work_dir=tmp_path / "work-dir", + variant="cpu", + ) + ctx.setup() + return ctx + + +class TestGetBuildTag: + """Tests for ``wheels.get_build_tag()``.""" + + def test_no_hook_returns_base_tag(self, tmp_path: pathlib.Path) -> None: + """Without a hook, get_build_tag returns pbi.build_tag() unchanged.""" + ctx = _ctx_with_hook(tmp_path) + req = Requirement("mypkg") + version = Version("1.0") + tags = frozenset({Tag("cp312", "cp312", "linux_x86_64")}) + result = wheels.get_build_tag( + ctx=ctx, req=req, version=version, wheel_tags=tags + ) + pbi = ctx.package_build_info(req) + assert result == pbi.build_tag(version) + + def test_hook_appends_suffix_segments( + self, testdata_context: context.WorkContext + ) -> None: + """Hook-provided segments are joined and appended to the base tag.""" + + def hook(**kwargs: object) -> list[str]: + return ["el9.6", "rocm7.1"] + + testdata_context.settings._settings = ( + testdata_context.settings._settings.model_copy( + update={"wheels": WheelSettings(build_tag_hook=hook)} + ) + ) + req = Requirement("test-pkg") + version = Version("1.0.1") + tags = frozenset({Tag("cp312", "cp312", "linux_x86_64")}) + pbi = testdata_context.package_build_info(req) + base = pbi.build_tag(version) + assert base, "test-pkg must have a changelog entry for 1.0.1" + result = wheels.get_build_tag( + ctx=testdata_context, req=req, version=version, wheel_tags=tags + ) + assert len(result) == 2 + assert result[0] == base[0] + assert result[1] == base[1] + "_el9.6_rocm7.1" + + def test_hook_empty_segments_returns_base( + self, testdata_context: context.WorkContext + ) -> None: + """When hook returns empty list, base tag is returned.""" + + def hook(**kwargs: object) -> list[str]: + return [] + + testdata_context.settings._settings = ( + testdata_context.settings._settings.model_copy( + update={"wheels": WheelSettings(build_tag_hook=hook)} + ) + ) + req = Requirement("test-pkg") + version = Version("1.0.1") + tags = frozenset({Tag("py3", "none", "any")}) + pbi = testdata_context.package_build_info(req) + base = pbi.build_tag(version) + result = wheels.get_build_tag( + ctx=testdata_context, req=req, version=version, wheel_tags=tags + ) + assert result == base + + def test_hook_returning_string_raises( + self, testdata_context: context.WorkContext + ) -> None: + """Single string return is rejected (would be iterated as chars).""" + + def hook(**kwargs: object) -> str: + return "el9.6" + + testdata_context.settings._settings = ( + testdata_context.settings._settings.model_copy( + update={"wheels": WheelSettings(build_tag_hook=hook)} + ) + ) + req = Requirement("test-pkg") + version = Version("1.0.1") + tags = frozenset({Tag("cp312", "cp312", "linux_x86_64")}) + with pytest.raises(ValueError, match="sequence of strings"): + wheels.get_build_tag( + ctx=testdata_context, req=req, version=version, wheel_tags=tags + ) + + @pytest.mark.parametrize("invalid_segment", ["bad-char", "el9.6\n"]) + def test_hook_invalid_segment_chars_raises( + self, testdata_context: context.WorkContext, invalid_segment: str + ) -> None: + """Segments with invalid characters are rejected.""" + + def hook(**kwargs: object) -> list[str]: + return ["el9.6", invalid_segment] + + testdata_context.settings._settings = ( + testdata_context.settings._settings.model_copy( + update={"wheels": WheelSettings(build_tag_hook=hook)} + ) + ) + req = Requirement("test-pkg") + version = Version("1.0.1") + tags = frozenset({Tag("cp312", "cp312", "linux_x86_64")}) + with pytest.raises(ValueError, match="invalid segment"): + wheels.get_build_tag( + ctx=testdata_context, req=req, version=version, wheel_tags=tags + ) + + def test_hook_returning_bytes_raises( + self, testdata_context: context.WorkContext + ) -> None: + """Bytes return is rejected the same way as str.""" + + def hook(**kwargs: object) -> bytes: + return b"el9.6" + + testdata_context.settings._settings = ( + testdata_context.settings._settings.model_copy( + update={"wheels": WheelSettings(build_tag_hook=hook)} + ) + ) + req = Requirement("test-pkg") + version = Version("1.0.1") + tags = frozenset({Tag("cp312", "cp312", "linux_x86_64")}) + with pytest.raises(ValueError, match="sequence of strings"): + wheels.get_build_tag( + ctx=testdata_context, req=req, version=version, wheel_tags=tags + ) + + def test_hook_exception_propagates( + self, testdata_context: context.WorkContext + ) -> None: + """Hook exceptions propagate to the caller.""" + + def hook(**kwargs: object) -> list[str]: + raise RuntimeError("hook failure") + + testdata_context.settings._settings = ( + testdata_context.settings._settings.model_copy( + update={"wheels": WheelSettings(build_tag_hook=hook)} + ) + ) + req = Requirement("test-pkg") + version = Version("1.0.1") + tags = frozenset({Tag("cp312", "cp312", "linux_x86_64")}) + with pytest.raises(RuntimeError, match="hook failure"): + wheels.get_build_tag( + ctx=testdata_context, req=req, version=version, wheel_tags=tags + ) + + def test_hook_not_called_without_base_tag(self, tmp_path: pathlib.Path) -> None: + """Hook is skipped when the package has no changelog build tag.""" + calls: list[dict[str, object]] = [] + + def hook(**kwargs: object) -> list[str]: + calls.append(kwargs) + return ["el9.6"] + + ctx = _ctx_with_hook(tmp_path, hook=hook) + req = Requirement("mypkg") + version = Version("1.0") + tags = frozenset({Tag("cp312", "cp312", "linux_x86_64")}) + result = wheels.get_build_tag( + ctx=ctx, req=req, version=version, wheel_tags=tags + ) + assert result == () + assert calls == [] From 7912bded72c86b1ff1c1eeb48c3963d50f7570fb Mon Sep 17 00:00:00 2001 From: Jenna Date: Thu, 24 Sep 2026 15:30:43 -0400 Subject: [PATCH 16/22] fix(bootstrap): Remove dead cache-server fallback Use AgeFallback.NEWEST to retain the newest matching source version in multi-version mode when age filtering removes all candidates. Remove the unreachable cache-server fallback API and document the separate built-wheel cache lookup.Use AgeFallback.NEWEST to retain the newest matching source version in multi-version mode when age filtering removes all candidates. Remove the unreachable cache-server fallback API and document the separate built-wheel cache lookup. Co-Authored-By: OpenAI Codex Signed-off-by: Jenna --- docs/concepts/resolver-architecture.rst | 21 +-- .../bootstrap_requirement_resolver.py | 64 +------ src/fromager/bootstrapper/_bootstrapper.py | 1 - tests/test_bootstrap_requirement_resolver.py | 170 +----------------- 4 files changed, 20 insertions(+), 236 deletions(-) diff --git a/docs/concepts/resolver-architecture.rst b/docs/concepts/resolver-architecture.rst index 67fefa0e1..219d52cd1 100644 --- a/docs/concepts/resolver-architecture.rst +++ b/docs/concepts/resolver-architecture.rst @@ -30,10 +30,6 @@ priority order and accumulates results in a thread-safe session cache: │ ▼ │ │ 3. Network (PyPI / GitHub / GitLab) │ │ (per-package configurable) │ - │ │ age filter empties result │ - │ ▼ │ - │ 4. Cache server fallback │ - │ (multiple_versions mode only) │ └───────────────────────────────────────────────────────┘ Git URL requirements (top-level only) bypass this @@ -44,6 +40,10 @@ Versions discovered from any source are merged into the session cache. Subsequent requests for the same package skip the network entirely if the cache already contains a matching version. +The configured wheel cache server is not a version-resolution fallback. +After a version has been selected, the bootstrapper checks the cache +server separately for a previously built wheel. + Provider Hierarchy ------------------ @@ -73,8 +73,8 @@ CLI commands interact with providers through a common Filters by platform tags, Python version, and yanked status. * - ``PyPICacheProvider`` - Subclass of ``PyPIProvider`` pointing at fromager's own wheel - server. No cooldown applied. Used as a fallback when age - filtering eliminates all candidates. + server. No cooldown applied. Used by the bootstrapper's + previously-built-wheel lookup, not by source version resolution. * - ``GenericProvider`` - Callback-based provider that pairs a version source function with a configurable match function (plain parse or regex). @@ -117,10 +117,11 @@ Two age-based filters can narrow the set of acceptable versions: ``multiple_versions`` mode to limit the range of versions built. When both are active, only versions published within the window are -considered. If all candidates are filtered out, the behavior depends -on the mode: in single-version mode a warning is logged and all -candidates are kept; in ``multiple_versions`` mode the cache server -fallback is tried instead. +considered. If all source candidates are filtered out, the behavior +depends on the mode: in single-version mode a warning is logged and all +candidates are kept; in ``multiple_versions`` mode only the newest +candidate is retained. A source provider with no matching candidates +raises a resolution error; it is not replaced by a cache-server lookup. Flat Resolution by Design ------------------------- diff --git a/src/fromager/bootstrap_requirement_resolver.py b/src/fromager/bootstrap_requirement_resolver.py index c4943096a..8ee267511 100644 --- a/src/fromager/bootstrap_requirement_resolver.py +++ b/src/fromager/bootstrap_requirement_resolver.py @@ -14,7 +14,7 @@ from packaging.utils import NormalizedName, canonicalize_name from packaging.version import Version -from . import finders, resolver, sources, wheels +from . import resolver, sources, wheels from .dependency_graph import DependencyGraph from .requirements_file import RequirementType @@ -43,24 +43,21 @@ def __init__( ctx: context.WorkContext, prev_graph: DependencyGraph | None = None, multiple_versions: bool = False, - cache_wheel_server_url: str = "", ) -> None: """Initialize requirement resolver. Args: ctx: Work context with constraints and settings prev_graph: Optional previous dependency graph for caching - multiple_versions: If ``True`` and no results are found through - any other approach, takes the latest candidate from the - cache server, ignoring the age filters. In all other - cases, returns an empty list when no candidates are found. - cache_wheel_server_url: URL of the remote wheel cache server. - Used as a fallback when age filtering produces no candidates. + multiple_versions: If ``True``, keep the newest matching source + version when the age filter removes all candidates. If + ``False``, keep all matching versions in that case. If no + source version matches the requirement, resolution raises an + error. """ self.ctx = ctx self.prev_graph = prev_graph self.multiple_versions = multiple_versions - self.cache_wheel_server_url = cache_wheel_server_url # All known versions for a package, accumulated across resolution # contexts. Versions discovered via different specifiers or req_types # are merged so that later lookups see the widest set. @@ -211,19 +208,9 @@ def _resolve_and_extend( age_fallback=age_fallback, ) - if not results and self.multiple_versions and self.cache_wheel_server_url: - logger.info( - "no results found with normal resolution, " - "falling back to the cache server %s", - self.cache_wheel_server_url, - ) - results = self._resolve_from_cache_server(req) - if not results: logger.warning( - "resolver returned no results " - "(wheel server URL %s, %s version mode)", - self.cache_wheel_server_url or "(none)", + "resolver returned no results (%s version mode)", "multiple" if self.multiple_versions else "single", ) @@ -235,43 +222,6 @@ def _resolve_and_extend( versions[version] = url self._resolved_rules.add((str(req), pre_built)) - def _resolve_from_cache_server(self, req: Requirement) -> list[tuple[str, Version]]: - """Fall back to the remote wheel cache server for a cached version. - - When age filtering removes all candidates in multi-version mode, - queries the remote cache server for the newest available wheel. - Returns at most one version so that transitive dependencies are - re-processed without rebuilding every old version. - """ - logger.info( - "checking cache server %s for existing build", - self.cache_wheel_server_url, - ) - best: tuple[str, Version] | None = None - for include_sdists, include_wheels in [(False, True), (True, False)]: - try: - provider = finders.PyPICacheProvider( - cache_server_url=self.cache_wheel_server_url, - constraints=self.ctx.constraints, - include_sdists=include_sdists, - include_wheels=include_wheels, - ) - results = resolver.find_all_matching_from_provider(provider, req) - if results: - url, version = results[0] - if best is None or version > best[1]: - best = (url, version) - except Exception as err: - logger.warning( - "error checking cache server %s: %s", - self.cache_wheel_server_url, - err, - ) - if best is not None: - logger.info("found version %s on cache server", best[1]) - return [best] - return [] - def get_matching_versions( self, req: Requirement, diff --git a/src/fromager/bootstrapper/_bootstrapper.py b/src/fromager/bootstrapper/_bootstrapper.py index af5df5424..8b1ac16f6 100644 --- a/src/fromager/bootstrapper/_bootstrapper.py +++ b/src/fromager/bootstrapper/_bootstrapper.py @@ -91,7 +91,6 @@ def __init__( ctx=ctx, prev_graph=prev_graph, multiple_versions=multiple_versions, - cache_wheel_server_url=self.cache_wheel_server_url, ) # Push items onto the stack as we start to resolve their # dependencies so at the end we have a list of items that need to diff --git a/tests/test_bootstrap_requirement_resolver.py b/tests/test_bootstrap_requirement_resolver.py index 7222a6fbc..a0de1ffa7 100644 --- a/tests/test_bootstrap_requirement_resolver.py +++ b/tests/test_bootstrap_requirement_resolver.py @@ -658,173 +658,8 @@ def test_resolve_prebuilt_after_source_uses_separate_cache( assert version2 == Version("1.5") -class TestResolveFromCacheServer: - """Tests for the cache server fallback in _resolve_from_cache_server.""" - - def test_returns_newest_version_from_cache(self, tmp_context: WorkContext) -> None: - """Falls back to cache server and returns only the newest version.""" - brr = BootstrapRequirementResolver( - tmp_context, - multiple_versions=True, - cache_wheel_server_url="http://cache.test/simple", - ) - req = Requirement("testpkg") - - with patch( - "fromager.bootstrap_requirement_resolver.resolver" - ".find_all_matching_from_provider", - return_value=[ - ("http://cache.test/testpkg-3.0.whl", Version("3.0")), - ("http://cache.test/testpkg-2.0.whl", Version("2.0")), - ], - ): - result = brr._resolve_from_cache_server(req) - - assert len(result) == 1 - assert result[0][1] == Version("3.0") - - def test_returns_empty_when_cache_has_no_match( - self, tmp_context: WorkContext - ) -> None: - """Returns empty list when cache server has nothing.""" - brr = BootstrapRequirementResolver( - tmp_context, - multiple_versions=True, - cache_wheel_server_url="http://cache.test/simple", - ) - req = Requirement("testpkg") - - with patch( - "fromager.bootstrap_requirement_resolver.resolver" - ".find_all_matching_from_provider", - return_value=[], - ): - result = brr._resolve_from_cache_server(req) - - assert result == [] - - def test_returns_empty_on_exception(self, tmp_context: WorkContext) -> None: - """Returns empty list when cache server query fails.""" - brr = BootstrapRequirementResolver( - tmp_context, - multiple_versions=True, - cache_wheel_server_url="http://cache.test/simple", - ) - req = Requirement("testpkg") - - with patch( - "fromager.bootstrap_requirement_resolver.resolver" - ".find_all_matching_from_provider", - side_effect=RuntimeError("connection refused"), - ): - result = brr._resolve_from_cache_server(req) - - assert result == [] - - def test_resolve_uses_cache_fallback_when_age_filter_empties( - self, tmp_context: WorkContext - ) -> None: - """resolve() falls back to cache server when age filter produces empty result.""" - brr = BootstrapRequirementResolver( - tmp_context, - multiple_versions=True, - cache_wheel_server_url="http://cache.test/simple", - ) - req = Requirement("testpkg") - - with ( - patch.object(brr, "_resolve_from_graph", return_value=None), - patch( - "fromager.bootstrap_requirement_resolver.sources.get_source_provider", - ), - patch( - "fromager.bootstrap_requirement_resolver.resolver" - ".find_all_matching_from_provider", - return_value=[], - ) as mock_pypi, - patch.object( - brr, - "_resolve_from_cache_server", - return_value=[("http://cache.test/testpkg-1.0.whl", Version("1.0"))], - ) as mock_cache, - ): - result = brr.resolve( - req, - RequirementType.INSTALL, - parent_req=None, - pre_built=False, - return_all_versions=True, - ) - - mock_pypi.assert_called_once() - mock_cache.assert_called_once_with(req) - assert len(result) == 1 - assert result[0][1] == Version("1.0") - - def test_resolve_skips_cache_fallback_in_single_version_mode( - self, tmp_context: WorkContext - ) -> None: - """resolve() does not fall back to cache server in single-version mode.""" - brr = BootstrapRequirementResolver( - tmp_context, - multiple_versions=False, - cache_wheel_server_url="http://cache.test/simple", - ) - req = Requirement("testpkg") - - with ( - patch.object(brr, "_resolve_from_graph", return_value=None), - patch( - "fromager.bootstrap_requirement_resolver.sources.get_source_provider", - ), - patch( - "fromager.bootstrap_requirement_resolver.resolver" - ".find_all_matching_from_provider", - return_value=[("url", Version("1.0"))], - ), - patch.object(brr, "_resolve_from_cache_server") as mock_cache, - ): - brr.resolve( - req, - RequirementType.INSTALL, - parent_req=None, - pre_built=False, - ) - - mock_cache.assert_not_called() - - def test_resolve_skips_cache_fallback_when_no_server_url( - self, tmp_context: WorkContext - ) -> None: - """resolve() does not fall back when no cache_wheel_server_url is set.""" - brr = BootstrapRequirementResolver( - tmp_context, - multiple_versions=True, - cache_wheel_server_url="", - ) - req = Requirement("testpkg") - - with ( - patch.object(brr, "_resolve_from_graph", return_value=None), - patch( - "fromager.bootstrap_requirement_resolver.sources.get_source_provider", - ), - patch( - "fromager.bootstrap_requirement_resolver.resolver" - ".find_all_matching_from_provider", - return_value=[], - ), - patch.object(brr, "_resolve_from_cache_server") as mock_cache, - ): - brr.resolve( - req, - RequirementType.INSTALL, - parent_req=None, - pre_built=False, - return_all_versions=True, - ) - - mock_cache.assert_not_called() +class TestAgeFallback: + """Tests for age filtering fallback behavior.""" def test_multi_version_passes_newest_age_fallback( self, tmp_context: WorkContext @@ -833,7 +668,6 @@ def test_multi_version_passes_newest_age_fallback( brr = BootstrapRequirementResolver( tmp_context, multiple_versions=True, - cache_wheel_server_url="http://cache.test/simple", ) req = Requirement("testpkg") From e263ce9c6bdec46d4e00480d8455862bec85f1f8 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Fri, 2 Oct 2026 07:02:01 -0400 Subject: [PATCH 17/22] fix(resolver): log wheel-only candidate skips at INFO Keep routine candidate skips in CI logs without sending WARNING telemetry events. Co-Authored-By: Codex Signed-off-by: Ryan Petrello --- src/fromager/resolver.py | 2 +- tests/test_resolver.py | 24 +++++++++++++++++------- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/fromager/resolver.py b/src/fromager/resolver.py index e60c72d62..addc9b411 100644 --- a/src/fromager/resolver.py +++ b/src/fromager/resolver.py @@ -1047,7 +1047,7 @@ def find_matches( and c.version not in sdist_versions and all(c.version in r.specifier for r in reqs) ): - logger.warning( + logger.info( "%s==%s: no sdist available, only a wheel (skipped)", c.name, c.version, diff --git a/tests/test_resolver.py b/tests/test_resolver.py index f6d05a899..74ec9b582 100644 --- a/tests/test_resolver.py +++ b/tests/test_resolver.py @@ -1,4 +1,5 @@ import datetime +import logging import re import typing @@ -1307,8 +1308,9 @@ def test_cli_package_resolver( assert "- missing from Fromager: 1.3.1+local, 2.0.0a1" in result.stdout -def test_warn_wheel_only_version(caplog: pytest.LogCaptureFixture) -> None: - """Warn when a version matching the specifier has no sdist.""" +def test_log_wheel_only_version_at_info(caplog: pytest.LogCaptureFixture) -> None: + """Log an eligible wheel-only version at INFO level.""" + caplog.set_level(logging.INFO) with requests_mock.Mocker() as r: r.get( "https://pypi.org/simple/testpkg/", @@ -1323,11 +1325,18 @@ def test_warn_wheel_only_version(caplog: pytest.LogCaptureFixture) -> None: candidate = result.mapping["testpkg"] assert str(candidate.version) == "1.0.3" - assert "testpkg==1.0.2: no sdist available, only a wheel (skipped)" in caplog.text + assert any( + record.levelno == logging.INFO + and record.getMessage().endswith( + "testpkg==1.0.2: no sdist available, only a wheel (skipped)" + ) + for record in caplog.records + ) -def test_no_warn_when_sdist_exists(caplog: pytest.LogCaptureFixture) -> None: - """No warning for versions that have both sdist and wheel.""" +def test_no_log_when_sdist_exists(caplog: pytest.LogCaptureFixture) -> None: + """Do not log versions that have both sdist and wheel.""" + caplog.set_level(logging.INFO) with requests_mock.Mocker() as r: r.get( "https://pypi.org/simple/testpkg/", @@ -1345,8 +1354,9 @@ def test_no_warn_when_sdist_exists(caplog: pytest.LogCaptureFixture) -> None: assert "testpkg==1.0.3" not in caplog.text -def test_no_warn_outside_specifier(caplog: pytest.LogCaptureFixture) -> None: - """No warning for wheel-only versions outside the requirement specifier.""" +def test_no_log_outside_specifier(caplog: pytest.LogCaptureFixture) -> None: + """Do not log wheel-only versions outside the requirement specifier.""" + caplog.set_level(logging.INFO) with requests_mock.Mocker() as r: r.get( "https://pypi.org/simple/testpkg/", From 0a9266311760103c7e3eb1735691d394e334fde3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:22:31 +0000 Subject: [PATCH 18/22] build(deps): bump dtolnay/rust-toolchain Bumps [dtolnay/rust-toolchain](https://github.com/dtolnay/rust-toolchain) from 02cb101ec7c40f2c49e1d9714d64511d8e1b74de to 7e38f4b43b4db5c8dd498af069a4f6196df1d067. - [Release notes](https://github.com/dtolnay/rust-toolchain/releases) - [Commits](https://github.com/dtolnay/rust-toolchain/compare/02cb101ec7c40f2c49e1d9714d64511d8e1b74de...7e38f4b43b4db5c8dd498af069a4f6196df1d067) --- updated-dependencies: - dependency-name: dtolnay/rust-toolchain dependency-version: 7e38f4b43b4db5c8dd498af069a4f6196df1d067 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- .github/workflows/test.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 14feb4776..088e20299 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -43,7 +43,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Set up Rust ${{ matrix.rust-version }} - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # master + uses: dtolnay/rust-toolchain@7e38f4b43b4db5c8dd498af069a4f6196df1d067 # master with: toolchain: ${{ matrix.rust-version }} @@ -117,7 +117,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Set up Rust ${{ matrix.rust-version }} - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # master + uses: dtolnay/rust-toolchain@7e38f4b43b4db5c8dd498af069a4f6196df1d067 # master with: toolchain: ${{ matrix.rust-version }} From a2c053db0c4e998a1b69d658dca496203dd206f6 Mon Sep 17 00:00:00 2001 From: Christian Heimes Date: Mon, 5 Oct 2026 10:19:15 +0200 Subject: [PATCH 19/22] fix(deps): read pyproject.toml with tomllib instead of tomlkit get_pyproject_contents() only reads [build-system], so it does not need tomlkit's format-preserving parser. tomlkit (0.15.1) rejects some spec-valid files that re-open a super-table out of order, failing builds for affected packages (e.g. geventhttpclient). stdlib tomllib parses them correctly and is always available (Python >= 3.12). Co-Authored-By: Claude Closes: #1356 Signed-off-by: Christian Heimes --- src/fromager/dependencies.py | 6 ++++-- tests/test_dependencies.py | 27 +++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/fromager/dependencies.py b/src/fromager/dependencies.py index 18dcdbef7..51aa174d3 100644 --- a/src/fromager/dependencies.py +++ b/src/fromager/dependencies.py @@ -5,11 +5,11 @@ import os import pathlib import tempfile +import tomllib import typing import zipfile import pyproject_hooks -import tomlkit from packaging.metadata import Metadata from packaging.requirements import Requirement from packaging.utils import NormalizedName, canonicalize_name @@ -492,7 +492,9 @@ def get_pyproject_contents(sdist_root_dir: pathlib.Path) -> dict[str, typing.Any pyproject_toml_filename = sdist_root_dir / "pyproject.toml" if not os.path.exists(pyproject_toml_filename): return {} - return tomlkit.loads(pyproject_toml_filename.read_text()) + # Read-only parse: use stdlib tomllib, not tomlkit. tomlkit rejects some + # spec-valid files (out-of-order tables), e.g. #1356. + return tomllib.loads(pyproject_toml_filename.read_text()) # From pypa/build/src/build/__main__.py diff --git a/tests/test_dependencies.py b/tests/test_dependencies.py index 34a68ee82..f97850fd0 100644 --- a/tests/test_dependencies.py +++ b/tests/test_dependencies.py @@ -129,6 +129,33 @@ def test_get_build_backend( assert expected_results == actual +def test_get_pyproject_contents_out_of_order_tables(tmp_path: pathlib.Path) -> None: + # Regression test for #1356: a spec-valid pyproject.toml that re-opens a + # super-table (created via dotted keys) out of order. tomlkit rejects this, + # stdlib tomllib accepts it. + tmp_path.joinpath("pyproject.toml").write_text( + textwrap.dedent("""\ + [build-system] + requires = ["setuptools", "wheel"] + build-backend = "setuptools.build_meta" + + [tool.ruff] + lint.extend-select = ["UP"] + lint.ignore = ["E402"] + + [[tool.mypy.overrides]] + module = ["pkg.mod"] + + [tool.ruff.lint.per-file-ignores] + "__init__.py" = ["F401"] + """) + ) + + contents = dependencies.get_pyproject_contents(tmp_path) + + assert contents["build-system"]["requires"] == ["setuptools", "wheel"] + + def _clean_build_artifacts(f: typing.Callable[..., None]) -> typing.Callable[..., None]: @functools.wraps(f) def _with_cleanup(*args: typing.Any, **kwds: typing.Any) -> None: From 7db7b5ab9a701998d5b63d763839ad3cee9a85e0 Mon Sep 17 00:00:00 2001 From: Shanmukh Pawan Date: Mon, 5 Oct 2026 09:00:57 -0400 Subject: [PATCH 20/22] fix(build): reuse pre-built wheels that carry a build number Pre-built packages have an empty changelog build tag, which the reuse check treated as 0. A publisher build number higher than 0 then failed the job instead of downloading the wheel. Skip that comparison for pre-built packages and keep it for wheels Fromager builds. Co-Authored-By: Grok Signed-off-by: Shanmukh Pawan --- src/fromager/commands/build.py | 49 ++++++++++-------- tests/test_cooldown.py | 93 +++++++++++++++++++++++++++++++++- 2 files changed, 118 insertions(+), 24 deletions(-) diff --git a/src/fromager/commands/build.py b/src/fromager/commands/build.py index 05284bc3e..a5ef79f06 100644 --- a/src/fromager/commands/build.py +++ b/src/fromager/commands/build.py @@ -535,30 +535,35 @@ def _is_wheel_built( logger.debug("could not parse candidate wheel %s", url, exc_info=True) continue - # Keep hook validation errors visible instead of treating them as - # a cache miss. - expected_tag = wheels.get_build_tag( - ctx=wkctx, - req=req, - version=candidate_version, - wheel_tags=wheel_tags, - ) - build_tag = expected_tag if expected_tag else (0, "") - existing_build_tag = build_tag_from_name if build_tag_from_name else (0, "") - if ( - existing_build_tag[0] > build_tag[0] - and existing_build_tag[1] == build_tag[1] - ): - raise ValueError( - f"{dist_name}: changelog for version {candidate_version} is inconsistent. Found build tag {existing_build_tag} but expected {build_tag}" + # The publisher owns the build number of a pre-built wheel. + # Changelog tags apply only to wheels Fromager builds. + if not pbi.pre_built: + # Keep hook validation errors visible instead of treating them as + # a cache miss. + expected_tag = wheels.get_build_tag( + ctx=wkctx, + req=req, + version=candidate_version, + wheel_tags=wheel_tags, ) - if existing_build_tag != build_tag: - logger.info( - "candidate wheel build tag %s does not match expected build tag %s", - existing_build_tag, - build_tag, + build_tag = expected_tag if expected_tag else (0, "") + existing_build_tag = ( + build_tag_from_name if build_tag_from_name else (0, "") ) - continue + if ( + existing_build_tag[0] > build_tag[0] + and existing_build_tag[1] == build_tag[1] + ): + raise ValueError( + f"{dist_name}: changelog for version {candidate_version} is inconsistent. Found build tag {existing_build_tag} but expected {build_tag}" + ) + if existing_build_tag != build_tag: + logger.info( + "candidate wheel build tag %s does not match expected build tag %s", + existing_build_tag, + build_tag, + ) + continue wheel_filename: pathlib.Path | None = None if wkctx.wheel_server_url and url.startswith(wkctx.wheel_server_url): diff --git a/tests/test_cooldown.py b/tests/test_cooldown.py index 4eee08b9d..7a384b4d3 100644 --- a/tests/test_cooldown.py +++ b/tests/test_cooldown.py @@ -901,12 +901,18 @@ def _context_for_existing_wheel( tmp_path: pathlib.Path, *, package_wheel_server_url: str | None = None, + pre_built: bool = False, ) -> context.WorkContext: settings_dir = tmp_path / "settings" settings_dir.mkdir() - if package_wheel_server_url is not None: + if package_wheel_server_url is not None or pre_built: + variant_lines = ["variants:", " cpu:"] + if pre_built: + variant_lines.append(" pre_built: true") + if package_wheel_server_url is not None: + variant_lines.append(f" wheel_server_url: {package_wheel_server_url}") (settings_dir / "test-pkg.yaml").write_text( - f"variants:\n cpu:\n wheel_server_url: {package_wheel_server_url}\n", + "\n".join(variant_lines) + "\n", encoding="utf-8", ) settings = packagesettings.Settings.from_files( @@ -1080,6 +1086,89 @@ def test_is_wheel_built_propagates_inconsistent_build_tag( build_command._is_wheel_built(ctx, "test-pkg", version) +def test_is_wheel_built_prebuilt_keeps_publisher_build_tag( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A pre-built wheel with a publisher build number is reused. + + The changelog tag is empty for pre-built packages. That must not reject + a higher build number, and the highest published build number is used. + """ + ctx = _context_for_existing_wheel( + tmp_path, + package_wheel_server_url=_PACKAGE_WHEEL_SERVER, + pre_built=True, + ) + version = Version("1.3.2") + older_name = "test_pkg-1.3.2-1-py3-none-any.whl" + newer_name = "test_pkg-1.3.2-2-py3-none-any.whl" + newer_url = f"{_PACKAGE_WHEEL_SERVER}test-pkg/{newer_name}" + downloaded: list[str] = [] + monkeypatch.setattr( + build_command.wheels, "download_wheel", _record_download(downloaded) + ) + + with requests_mock.Mocker() as r: + r.get( + f"{_PACKAGE_WHEEL_SERVER}test-pkg/", + json=_wheel_index_for_filenames( + _PACKAGE_WHEEL_SERVER, [older_name, newer_name] + ), + headers={"Content-Type": _PYPI_SIMPLE_JSON_CONTENT_TYPE}, + ) + found = build_command._is_wheel_built(ctx, "test-pkg", version) + + assert found is not None + assert found.name == newer_name + assert downloaded == [newer_url] + + +def test_is_wheel_built_prebuilt_reuses_cache_build_tag( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A pre-built package reuses a build-tagged wheel from the job cache. + + No package wheel_server_url is set, so the lookup uses the local server + and --cache-wheel-server-url. The publisher build number must not raise. + """ + ctx = _context_for_existing_wheel(tmp_path, pre_built=True) + version = Version("1.3.2") + wheel_name = "test_pkg-1.3.2-2-py3-none-any.whl" + wheel_url = f"{_CACHE_WHEEL_SERVER}test-pkg/{wheel_name}" + downloaded: list[str] = [] + monkeypatch.setattr( + build_command.wheels, "download_wheel", _record_download(downloaded) + ) + + with requests_mock.Mocker() as r: + r.get( + f"{_LOCAL_WHEEL_SERVER}test-pkg/", + json=_empty_index(), + headers={"Content-Type": _PYPI_SIMPLE_JSON_CONTENT_TYPE}, + ) + r.get( + f"{_CACHE_WHEEL_SERVER}test-pkg/", + json=_wheel_index_for_filenames(_CACHE_WHEEL_SERVER, [wheel_name]), + headers={"Content-Type": _PYPI_SIMPLE_JSON_CONTENT_TYPE}, + ) + found = build_command._is_wheel_built( + ctx, + "test-pkg", + version, + cache_wheel_server_url=_CACHE_WHEEL_SERVER, + ) + + assert found is not None + assert found.name == wheel_name + assert downloaded == [wheel_url] + assert [request.url for request in r.request_history] == [ + f"{_LOCAL_WHEEL_SERVER}test-pkg/", + f"{_CACHE_WHEEL_SERVER}test-pkg/", + ] + + def test_is_wheel_built_uses_package_wheel_server_url( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, From c4ccc98d0743b27a23832c3f1b25035972403d01 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Wed, 7 Oct 2026 07:41:39 -0400 Subject: [PATCH 21/22] fix(sources): log PKG-INFO stub creation at INFO Use INFO for expected stub creation in `ensure_pkg_info()`. Co-Authored-By: Codex Signed-off-by: Ryan Petrello --- src/fromager/sources.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fromager/sources.py b/src/fromager/sources.py index 1e9603bb7..16d936082 100644 --- a/src/fromager/sources.py +++ b/src/fromager/sources.py @@ -613,7 +613,7 @@ def ensure_pkg_info( for directory in directories: pkg_info_file = directory / "PKG-INFO" if not pkg_info_file.is_file(): - logger.warning( + logger.info( f"PKG-INFO file is missing from {directory}, creating stub file" ) pkg_info_file.write_text( From 31771eb427bbbb4c71d044de1a8c8e3d13961693 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:22:24 +0000 Subject: [PATCH 22/22] build(deps): bump dtolnay/rust-toolchain Bumps [dtolnay/rust-toolchain](https://github.com/dtolnay/rust-toolchain) from 7e38f4b43b4db5c8dd498af069a4f6196df1d067 to e2a55d2ffb04f378e9626c28d38b36d230d1e12f. - [Release notes](https://github.com/dtolnay/rust-toolchain/releases) - [Commits](https://github.com/dtolnay/rust-toolchain/compare/7e38f4b43b4db5c8dd498af069a4f6196df1d067...e2a55d2ffb04f378e9626c28d38b36d230d1e12f) --- updated-dependencies: - dependency-name: dtolnay/rust-toolchain dependency-version: e2a55d2ffb04f378e9626c28d38b36d230d1e12f dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- .github/workflows/test.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 088e20299..6de2dd0c6 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -43,7 +43,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Set up Rust ${{ matrix.rust-version }} - uses: dtolnay/rust-toolchain@7e38f4b43b4db5c8dd498af069a4f6196df1d067 # master + uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # master with: toolchain: ${{ matrix.rust-version }} @@ -117,7 +117,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Set up Rust ${{ matrix.rust-version }} - uses: dtolnay/rust-toolchain@7e38f4b43b4db5c8dd498af069a4f6196df1d067 # master + uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # master with: toolchain: ${{ matrix.rust-version }}