diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 2dc993608..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@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # 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@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master + uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # master with: toolchain: ${{ matrix.rust-version }} 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/concepts/resolver-architecture.rst b/docs/concepts/resolver-architecture.rst index 8359eb9bf..4db347fb1 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). @@ -131,10 +131,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/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/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 db599e9aa..fa2451012 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 @@ -266,6 +265,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 @@ -642,6 +660,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, @@ -1022,13 +1050,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/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 831741bfc..42aaaf486 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,66 +465,135 @@ 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: - logger.info( - "checking if a suitable wheel for %s was already built on %s", + # Package wheel_server_url is exclusive: it replaces local and cache. + servers = wheels.get_wheel_server_urls( + wkctx, req, - wheel_server_urls, - ) - url, _ = wheels.resolve_prebuilt_wheel( - ctx=wkctx, - req=req, - wheel_server_urls=wheel_server_urls, + cache_wheel_server_url=cache_wheel_server_url, ) - 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) - _, _, 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 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: - # 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 + 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 + + # 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, + ) + 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/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/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/src/fromager/dependencies.py b/src/fromager/dependencies.py index d80899e85..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 @@ -574,7 +576,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/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/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 484ef57fe..7dafd8f5e 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 ( @@ -90,6 +91,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 783cd8d47..df7ac9807 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/resolver.py b/src/fromager/resolver.py index 033e21a4e..bcdde2476 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 @@ -871,7 +872,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, @@ -926,6 +927,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() @@ -1034,7 +1049,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.info( + "%s==%s: no sdist available, only a wheel (skipped)", + c.name, + c.version, + ) + return candidates class MatchFunction(typing.Protocol): diff --git a/src/fromager/sources.py b/src/fromager/sources.py index fa915435e..b5f674c05 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 @@ -567,7 +568,8 @@ 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" + 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( @@ -584,6 +586,7 @@ def default_build_sdist( tar=sdist, basedir=build_dir, prefix=build_dir.parent, + arcname_root=f"{dist_name}-{version}", ) return sdist_filename @@ -644,7 +647,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( 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/src/fromager/wheels.py b/src/fromager/wheels.py index dc9bd5241..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 @@ -24,6 +25,7 @@ dependencies, downloads, external_commands, + finders, metrics, overrides, packagesettings, @@ -38,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, @@ -264,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", @@ -508,7 +570,52 @@ def get_prebuilt_wheel_provider( ) -def resolve_all_prebuilt_wheels( +def _resolve_all_cached_wheels( + *, + ctx: context.WorkContext, + req: Requirement, + cache_server_url: str, +) -> 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) + return [(str(wheel_url), version) for wheel_url, version in results] + + +@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, @@ -532,11 +639,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) @@ -551,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( *, @@ -559,12 +675,12 @@ 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( + 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_bootstrap_requirement_resolver.py b/tests/test_bootstrap_requirement_resolver.py index 22374e1e3..af359313c 100644 --- a/tests/test_bootstrap_requirement_resolver.py +++ b/tests/test_bootstrap_requirement_resolver.py @@ -667,173 +667,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 @@ -842,7 +677,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") diff --git a/tests/test_bootstrapper.py b/tests/test_bootstrapper.py index 03cf7be6b..1a9cfd1e6 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, ) @@ -35,7 +36,8 @@ ) from fromager.bootstrapper._work_item import WorkItem from fromager.context import WorkContext -from fromager.packagesettings import DownloadKind +from fromager.dependency_graph import ROOT +from fromager.packagesettings import DownloadKind, WheelSettings from fromager.requirements_file import RequirementType, SourceType @@ -412,7 +414,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, @@ -489,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, @@ -899,11 +1007,13 @@ def capture_run(self: Resolve, bt_arg: bootstrapper.Bootstrapper) -> list[Phase] return [] with ( + patch.object(bt, "resolve_versions", return_value=[]), patch.object( bt, "_resolve_and_add_top_level", return_value=("http://example.test/testpkg-1.0.tar.gz", Version("1.0")), ), + patch.object(Resolve, "background_work", return_value=None), patch.object(Resolve, "run", capture_run), patch.object(bt, "_record_stack_state"), ): @@ -921,7 +1031,9 @@ def test_bootstrap_skips_failed_resolution(tmp_context: WorkContext) -> None: req = Requirement("badpkg") with ( + patch.object(bt, "resolve_versions", return_value=[]), patch.object(bt, "_resolve_and_add_top_level", return_value=None), + patch.object(Resolve, "background_work", return_value=None), patch.object(Resolve, "run") as mock_run, patch.object(bt, "_record_stack_state"), ): @@ -943,11 +1055,13 @@ def capture_run(self: Resolve, bt_arg: bootstrapper.Bootstrapper) -> list[Phase] return [] with ( + patch.object(bt, "resolve_versions", return_value=[]), patch.object( bt, "_resolve_and_add_top_level", return_value=("http://example.test/pkg-1.0.tar.gz", Version("1.0")), ), + patch.object(Resolve, "background_work", return_value=None), patch.object(Resolve, "run", capture_run), patch.object(bt, "_record_stack_state"), ): @@ -956,6 +1070,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, diff --git a/tests/test_bootstrapper_iterative.py b/tests/test_bootstrapper_iterative.py index 29fb89bff..d68d45d4e 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://pkg.test/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://pkg.test/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://pkg.test/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://pkg.test/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://pkg.test/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://pkg.test/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: @@ -1142,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), @@ -1231,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( @@ -1271,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, @@ -1315,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, @@ -1347,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, 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: diff --git a/tests/test_cooldown.py b/tests/test_cooldown.py index 151f9eaf1..7a384b4d3 100644 --- a/tests/test_cooldown.py +++ b/tests/test_cooldown.py @@ -27,6 +27,8 @@ sources, 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) @@ -650,12 +652,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 +698,630 @@ 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 + + +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 _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": []} + + +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 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( + "\n".join(variant_lines) + "\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 _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_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, + 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() # --------------------------------------------------------------------------- diff --git a/tests/test_dependencies.py b/tests/test_dependencies.py index 6f83a93e8..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: @@ -179,6 +206,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 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 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 5ecc745ce..51a8e8a9b 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 @@ -1349,3 +1350,52 @@ def test_source_exclusivity_error_lists_fields() -> None: error_str = str(exc_info.value) assert "download_source" in error_str assert "resolver_dist" in error_str + + +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_resolver.py b/tests/test_resolver.py index 690a12886..74ec9b582 100644 --- a/tests/test_resolver.py +++ b/tests/test_resolver.py @@ -1,4 +1,5 @@ import datetime +import logging import re import typing @@ -39,6 +40,33 @@ """ +_wheel_only_simple_response = """ + + +
+ +