diff --git a/docs/concepts/hooks-and-overrides.rst b/docs/concepts/hooks-and-overrides.rst index 43d5f281c..3fcac8369 100644 --- a/docs/concepts/hooks-and-overrides.rst +++ b/docs/concepts/hooks-and-overrides.rst @@ -1,7 +1,7 @@ Hooks and Overrides =================== -Fromager has two plugin systems that serve different purposes: +Fromager has two plugin systems based on entry points: **per-package overrides** replace default behavior for a specific package, while **global hooks** broadcast notifications after events for every package. @@ -82,3 +82,13 @@ entry-point group in their ``pyproject.toml``, mapping a hook name to a callable. See :doc:`/customization` for examples and argument details. + +Configured Wheel Build Tag Hook +------------------------------- + +.. versionadded:: 0.99.0 + +The ``wheels.build_tag_hook`` setting names one callable with a Python +import string in global ``settings.yaml``. Its return value supplies +wheel filename suffixes during builds and cache lookups. See +:doc:`/customization` for the configuration and hook signature. diff --git a/docs/customization.md b/docs/customization.md index 578db04be..feece1dc3 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -393,6 +393,78 @@ $ tox -e cli -- canonicalize flit-core flit_core ``` +## Global settings + +Global settings are configured in the `settings.yaml` file passed via the +`--settings-file` flag. These settings apply to all packages being built. + +### Wheel build tag hook + +```{versionadded} 0.99.0 +``` + +The `build_tag_hook` is a configuration option that allows you to customize +wheel filenames by appending configuration-specific suffixes to the build tag. +This is useful for creating unique, deterministic filenames that reflect the +build configuration and distinguish wheels built for different variants. + +Process hooks register multiple event callbacks through entry points. +`build_tag_hook` selects one callable in `settings.yaml` because Fromager +uses its return value when building wheels and checking the cache. + +Configure the hook in your global `settings.yaml`: + +```yaml +wheels: + build_tag_hook: "myproject.hooks:build_tag_hook" +``` + +The module must be importable by the Python environment running Fromager. + +The hook function receives keyword-only arguments and returns a sequence of +suffix segments (strings) to append to the wheel build tag: + +```python +from typing import Sequence + +from packaging.requirements import Requirement +from packaging.tags import Tag +from packaging.version import Version + +from fromager import context + + +def build_tag_hook( + *, + ctx: context.WorkContext, + req: Requirement, + version: Version, + wheel_tags: frozenset[Tag], +) -> Sequence[str]: + """Return a valid suffix for each configured variant.""" + suffixes = {"cpu": "cpu", "cuda-13": "cuda13"} + return [suffixes[ctx.variant]] +``` + +Fromager joins segments in order with `_`. If the changelog build tag is `2`, +returning `["cuda13", "torch2.10"]` produces `2_cuda13_torch2.10`. + +**Important notes:** + +- The hook runs only when the package has a changelog-derived build tag. It + is skipped when there is no changelog entry or the package is prebuilt. +- Return a sequence of strings, not a single string or bytes object. Each + segment must be non-empty and contain only ASCII letters, digits, or dots. + Map variant names with other characters to distinct valid segments. +- The hook must be deterministic and independent of wheel contents, build + environment, or ELF metadata, so that fresh builds and cache lookups + produce identical tags. +- Use `wheel_tags` only to distinguish pure wheels from platform wheels. + The hook must return identical results across architectures for the same + configured variant. +- Installers do not use build tags to select a variant. Keep incompatible + variants in separate package indexes. + ## Process hooks Fromager supports plugging in Python hooks to be run after build events. diff --git a/docs/reference/config-reference.rst b/docs/reference/config-reference.rst index d81a37fb7..f9c1df4d0 100644 --- a/docs/reference/config-reference.rst +++ b/docs/reference/config-reference.rst @@ -96,3 +96,5 @@ If you prefer managing a single settings file, per-package settings can also be kept in this file. .. autopydantic_model:: fromager.packagesettings.SettingsFile + +.. autopydantic_model:: fromager.packagesettings.WheelSettings diff --git a/src/fromager/bootstrapper/_cache.py b/src/fromager/bootstrapper/_cache.py index e8a3c6830..089895f5b 100644 --- a/src/fromager/bootstrapper/_cache.py +++ b/src/fromager/bootstrapper/_cache.py @@ -86,29 +86,34 @@ def _look_for_existing_wheel( search_in: pathlib.Path, ) -> tuple[pathlib.Path | None, pathlib.Path | None]: pbi = ctx.package_build_info(req) - expected_build_tag = pbi.build_tag(resolved_version) + base_build_tag = pbi.build_tag(resolved_version) logger.info( - f"looking for existing wheel for version {resolved_version} with build tag {expected_build_tag} in {search_in}" + f"looking for existing wheel for version {resolved_version} with build tag {base_build_tag} in {search_in}" ) - wheel_filename = finders.find_wheel( + wheel_filenames = finders.find_wheels( downloads_dir=search_in, req=req, dist_version=str(resolved_version), - build_tag=expected_build_tag, + build_tag=base_build_tag, ) - if not wheel_filename: - return None, None - _, _, build_tag, _ = wheels.extract_info_from_wheel_file(req, wheel_filename) - if expected_build_tag and expected_build_tag != build_tag: - logger.info( - f"found wheel for {resolved_version} in {wheel_filename} but build tag does not match. Got {build_tag} but expected {expected_build_tag}" + for wheel_filename in wheel_filenames: + _, _, actual_build_tag, wheel_tags = wheels.extract_info_from_wheel_file( + req, wheel_filename ) - return None, None - logger.info(f"found existing wheel {wheel_filename}") - build_reqs_dir = _extract_build_reqs_from_wheel( - ctx.work_dir, req, resolved_version, wheel_filename - ) - return wheel_filename, build_reqs_dir + expected_build_tag = wheels.get_build_tag( + ctx=ctx, req=req, version=resolved_version, wheel_tags=wheel_tags + ) + if expected_build_tag and expected_build_tag != actual_build_tag: + logger.info( + f"found wheel for {resolved_version} in {wheel_filename} but build tag does not match. Got {actual_build_tag} but expected {expected_build_tag}" + ) + continue + logger.info(f"found existing wheel {wheel_filename}") + build_reqs_dir = _extract_build_reqs_from_wheel( + ctx.work_dir, req, resolved_version, wheel_filename + ) + return wheel_filename, build_reqs_dir + return None, None def _download_wheel_from_cache( @@ -127,31 +132,6 @@ def _download_wheel_from_cache( constraints=ctx.constraints, ) results = resolver.find_all_matching_from_provider(provider, pinned_req) - wheel_url, _ = results[0] - wheelfile_name = pathlib.Path(urlparse(wheel_url).path) - pbi = ctx.package_build_info(req) - expected_build_tag = pbi.build_tag(resolved_version) - logger.info(f"has expected build tag {expected_build_tag}") - changelogs = pbi.get_changelog(resolved_version) - logger.debug(f"has change logs {changelogs}") - - _, _, build_tag, _ = wheels.extract_info_from_wheel_file(req, wheelfile_name) - if expected_build_tag and expected_build_tag != build_tag: - logger.info( - f"found wheel for {resolved_version} in cache but build tag does not match. Got {build_tag} but expected {expected_build_tag}" - ) - return None, None - - cached_wheel = wheels.download_wheel( - req=req, wheel_url=wheel_url, output_directory=ctx.wheels_downloads - ) - if cache_wheel_server_url != ctx.wheel_server_url: - server.update_wheel_mirror(ctx) - logger.info("found built wheel on cache server") - unpack_dir = _extract_build_reqs_from_wheel( - ctx.work_dir, req, resolved_version, cached_wheel - ) - return cached_wheel, unpack_dir except ResolverException: logger.info( f"did not find wheel for {resolved_version} in {cache_wheel_server_url}" @@ -170,6 +150,56 @@ def _download_wheel_from_cache( ) return None, None + for wheel_url, _ in results: + try: + wheel_filename = pathlib.Path(urlparse(wheel_url).path) + _, _, actual_build_tag, wheel_tags = wheels.extract_info_from_wheel_file( + req, wheel_filename + ) + except Exception as err: + logger.warning(f"could not parse cached wheel {wheel_url}: {err}") + continue + + pbi = ctx.package_build_info(req) + expected_build_tag = wheels.get_build_tag( + ctx=ctx, req=req, version=resolved_version, wheel_tags=wheel_tags + ) + logger.info(f"has expected build tag {expected_build_tag}") + changelogs = pbi.get_changelog(resolved_version) + logger.debug(f"has change logs {changelogs}") + + if expected_build_tag and expected_build_tag != actual_build_tag: + logger.info( + f"found wheel for {resolved_version} in cache but build tag does not match. Got {actual_build_tag} but expected {expected_build_tag}" + ) + continue + + try: + cached_wheel = wheels.download_wheel( + req=req, wheel_url=wheel_url, output_directory=ctx.wheels_downloads + ) + if cache_wheel_server_url != ctx.wheel_server_url: + server.update_wheel_mirror(ctx) + logger.info("found built wheel on cache server") + unpack_dir = _extract_build_reqs_from_wheel( + ctx.work_dir, req, resolved_version, cached_wheel + ) + return cached_wheel, unpack_dir + except requests.exceptions.RequestException as err: + logger.warning( + f"network error checking wheel cache for {resolved_version} " + f"at {cache_wheel_server_url}: {err}" + ) + return None, None + except Exception as err: + logger.warning( + f"unexpected error checking wheel cache for {resolved_version} " + f"at {cache_wheel_server_url}: {err}" + ) + return None, None + + return None, None + def find_cached_wheel( ctx: context.WorkContext, diff --git a/src/fromager/commands/build.py b/src/fromager/commands/build.py index 67f96439a..05284bc3e 100644 --- a/src/fromager/commands/build.py +++ b/src/fromager/commands/build.py @@ -489,69 +489,6 @@ def _is_wheel_built( req, cache_wheel_server_url=cache_wheel_server_url, ) - logger.info( - "checking if a suitable wheel for %s was already built on %s", - req, - servers, - ) - - url: str | None = None - for server_url in servers: - try: - if pbi.wheel_server_url: - # Upstream pre-built index. Release-age cooldown stays on. - url, _ = wheels.resolve_prebuilt_wheel( - ctx=wkctx, - req=req, - wheel_server_urls=[server_url], - ) - else: - # Local server and --cache-wheel-server-url are trusted caches. - url, _ = wheels.resolve_cached_wheel( - ctx=wkctx, - req=req, - cache_server_url=server_url, - ) - break - except Exception: - logger.debug("wheel not found on %s", server_url, exc_info=True) - - if url is None: - logger.info("could not locate existing wheel") - return None - - logger.info("found candidate wheel %s", url) - build_tag_from_settings = pbi.build_tag(resolved_version) - build_tag = build_tag_from_settings if build_tag_from_settings else (0, "") - wheel_basename = downloads.extract_filename_from_url(url) - _, _, build_tag_from_name, _ = parse_wheel_filename(wheel_basename) - existing_build_tag = build_tag_from_name if build_tag_from_name else (0, "") - if ( - existing_build_tag[0] > build_tag[0] - and existing_build_tag[1] == build_tag[1] - ): - raise ValueError( - f"{dist_name}: changelog for version {resolved_version} is inconsistent. Found build tag {existing_build_tag} but expected {build_tag}" - ) - if existing_build_tag != build_tag: - logger.info( - f"candidate wheel build tag {existing_build_tag} does not match expected build tag {build_tag}" - ) - return None - - wheel_filename: pathlib.Path | None = None - if wkctx.wheel_server_url and url.startswith(wkctx.wheel_server_url): - logging.debug("found wheel on local server") - wheel_filename = wkctx.wheels_downloads / wheel_basename - if not wheel_filename.exists(): - logger.info("wheel not found in local cache, preparing to download") - wheel_filename = None - - if not wheel_filename: - logger.info("downloading wheel from %s", url) - wheel_filename = wheels.download_wheel(req, url, wkctx.wheels_downloads) - - return wheel_filename except Exception: logger.debug( "could not locate existing wheel %s-%s", @@ -562,6 +499,96 @@ def _is_wheel_built( logger.info("could not locate existing wheel") return None + logger.info( + "checking if a suitable wheel for %s was already built on %s", + req, + servers, + ) + for server_url in servers: + try: + if pbi.wheel_server_url: + # Upstream pre-built index. Release-age cooldown stays on. + candidates = wheels.resolve_all_prebuilt_wheels( + ctx=wkctx, + req=req, + wheel_server_urls=[server_url], + ) + else: + # Local server and --cache-wheel-server-url are trusted caches. + candidates = wheels.resolve_all_cached_wheels( + ctx=wkctx, + req=req, + cache_server_url=server_url, + ) + except Exception: + logger.debug("wheel not found on %s", server_url, exc_info=True) + continue + + for url, candidate_version in candidates: + logger.info("found candidate wheel %s", url) + try: + wheel_basename = downloads.extract_filename_from_url(url) + _, _, build_tag_from_name, wheel_tags = parse_wheel_filename( + wheel_basename + ) + except Exception: + logger.debug("could not parse candidate wheel %s", url, exc_info=True) + continue + + # Keep hook validation errors visible instead of treating them as + # a cache miss. + expected_tag = wheels.get_build_tag( + ctx=wkctx, + req=req, + version=candidate_version, + wheel_tags=wheel_tags, + ) + build_tag = expected_tag if expected_tag else (0, "") + existing_build_tag = build_tag_from_name if build_tag_from_name else (0, "") + if ( + existing_build_tag[0] > build_tag[0] + and existing_build_tag[1] == build_tag[1] + ): + raise ValueError( + f"{dist_name}: changelog for version {candidate_version} is inconsistent. Found build tag {existing_build_tag} but expected {build_tag}" + ) + if existing_build_tag != build_tag: + logger.info( + "candidate wheel build tag %s does not match expected build tag %s", + existing_build_tag, + build_tag, + ) + continue + + wheel_filename: pathlib.Path | None = None + if wkctx.wheel_server_url and url.startswith(wkctx.wheel_server_url): + logging.debug("found wheel on local server") + wheel_filename = wkctx.wheels_downloads / wheel_basename + if not wheel_filename.exists(): + logger.info("wheel not found in local cache, preparing to download") + wheel_filename = None + + if not wheel_filename: + try: + logger.info("downloading wheel from %s", url) + wheel_filename = wheels.download_wheel( + req, url, wkctx.wheels_downloads + ) + except Exception: + logger.debug( + "failed to download prebuilt wheel %s-%s", + dist_name, + candidate_version, + exc_info=True, + ) + logger.info("could not download prebuilt wheel") + continue + + return wheel_filename + + logger.info("could not locate existing wheel") + return None + def _build_parallel( wkctx: context.WorkContext, diff --git a/src/fromager/finders.py b/src/fromager/finders.py index 69c31e331..619f87e74 100644 --- a/src/fromager/finders.py +++ b/src/fromager/finders.py @@ -153,7 +153,18 @@ def find_wheel( dist_version: str, build_tag: BuildTag = (), ) -> pathlib.Path | None: - """Find a wheel file in downloads_dir for the given requirement. + """Return the first matching wheel file for the given requirement.""" + matches = find_wheels(downloads_dir, req, dist_version, build_tag) + return matches[0] if matches else None + + +def find_wheels( + downloads_dir: pathlib.Path, + req: Requirement, + dist_version: str, + build_tag: BuildTag = (), +) -> list[pathlib.Path]: + """Find matching wheel files in downloads_dir for the given requirement. Tries four naming conventions (PEP 427 transformed, canonical, original, and dotted), each suffixed with the build tag when present. Uses @@ -162,37 +173,35 @@ def find_wheel( """ filename_prefix = _dist_name_to_filename(req.name) canonical_name = canonicalize_name(req.name) - # if build tag is 0 then we can ignore to handle non tagged wheels for backward compatibility - candidate_bases_build_tag = f"{build_tag[0]}{build_tag[1]}-" if build_tag else "" - candidate_bases = set( - [ - # First check if the file is there using the canonically - # transformed name. - f"{filename_prefix}-{dist_version}-{candidate_bases_build_tag}", - # If that didn't work, try the canonical dist name. That's not - # "correct" but we do see it. (charset-normalizer-3.3.2- - # and setuptools-scm-8.0.4-) for example - f"{canonical_name}-{dist_version}-{candidate_bases_build_tag}", - # If *that* didn't work, try the dist name we've been - # given as a dependency. That's not "correct", either but we do - # see it. (oslo.messaging-14.7.0-) for example - f"{req.name}-{dist_version}-{candidate_bases_build_tag}", - # Sometimes the sdist uses '.' instead of '-' in the - # package name portion. - f"{req.name.replace('-', '.')}-{dist_version}-{candidate_bases_build_tag}", - ] - ) - # Case-insensitive globbing was added to Python 3.12, but we - # have to run with older versions, too, so do our own name - # comparison. - for base in candidate_bases: - logger.debug('looking for wheel as "%s"', base) - for filename in downloads_dir.glob("*.whl"): - if str(filename.name).lower().startswith(base.lower()): - return filename + build_tag_prefixes: list[str] = [] + if build_tag: + build_tag_prefixes.append(f"{build_tag[0]}{build_tag[1]}-") + if not build_tag[1]: + build_tag_prefixes.append(f"{build_tag[0]}_") + else: + build_tag_prefixes.append("") - return None + name_variants = [ + filename_prefix, + canonical_name, + req.name, + req.name.replace("-", "."), + ] + + candidate_bases: set[str] = set() + for name in name_variants: + for btp in build_tag_prefixes: + candidate_bases.add(f"{name}-{dist_version}-{btp}") + + for base in sorted(candidate_bases): + logger.debug('looking for wheel as "%s"', base) + lower_bases = {base.lower() for base in candidate_bases} + return [ + filename + for filename in sorted(downloads_dir.glob("*.whl")) + if any(filename.name.lower().startswith(base) for base in lower_bases) + ] def default_expected_source_directory_name(req: Requirement, dist_version: str) -> str: diff --git a/src/fromager/packagesettings/__init__.py b/src/fromager/packagesettings/__init__.py index abca2f21d..7bcb9cdf5 100644 --- a/src/fromager/packagesettings/__init__.py +++ b/src/fromager/packagesettings/__init__.py @@ -12,6 +12,7 @@ ResolverDist, SbomSettings, VariantInfo, + WheelSettings, ) from ._pbi import PackageBuildInfo from ._resolver import ( @@ -88,6 +89,7 @@ "Variant", "VariantChangelog", "VariantInfo", + "WheelSettings", "default_update_extra_environ", "get_extra_environ", "pep440_tag_matcher", diff --git a/src/fromager/packagesettings/_models.py b/src/fromager/packagesettings/_models.py index e87230613..a3ad12816 100644 --- a/src/fromager/packagesettings/_models.py +++ b/src/fromager/packagesettings/_models.py @@ -33,6 +33,48 @@ logger = logging.getLogger(__name__) +class WheelSettings(pydantic.BaseModel): + """Global wheel build settings + + :: + + wheels: + build_tag_hook: "mypackage.hooks:build_tag_hook" + + .. versionadded:: 0.99.0 + """ + + model_config = MODEL_CONFIG + + build_tag_hook: pydantic.ImportString[typing.Callable[..., typing.Any]] | None = ( + None + ) + """Callable that returns suffix segments for the wheel build tag. + + The callable receives keyword-only arguments ``ctx``, ``req``, + ``version``, and ``wheel_tags`` and returns + ``Sequence[str]`` of suffix segments. + + Only invoked when the package already has a non-empty build tag + from its changelog entry for the given version; otherwise the hook + is skipped and no build tag is added. The callable must be + deterministic and independent of wheel contents, build environment, + or ELF metadata so fresh builds and cache lookups compute the same + tag. + + .. note:: + + The ``wheel_tags`` argument should only be used to distinguish + platlib wheels (platform-specific) from purelib wheels + (``py3-none-any``), for example to skip variant suffixes on + pure-python packages. Do not use it for platform-specific + decisions -- the hook must produce identical results across + architectures for the same variant. + + .. versionadded:: 0.99.0 + """ + + class SbomSettings(pydantic.BaseModel): """Global SBOM generation settings diff --git a/src/fromager/packagesettings/_settings.py b/src/fromager/packagesettings/_settings.py index 888e6b225..f2077826c 100644 --- a/src/fromager/packagesettings/_settings.py +++ b/src/fromager/packagesettings/_settings.py @@ -13,7 +13,7 @@ from pydantic import Field from .. import overrides -from ._models import ExternalCommands, PackageSettings, SbomSettings +from ._models import ExternalCommands, PackageSettings, SbomSettings, WheelSettings from ._pbi import PackageBuildInfo from ._typedefs import MODEL_CONFIG, GlobalChangelog, Package, Variant @@ -55,6 +55,14 @@ class SettingsFile(pydantic.BaseModel): .. versionadded:: 0.92.0 """ + wheels: WheelSettings | None = None + """Wheel build settings + + Configures wheel build tag hooks and other wheel-specific options. + + .. versionadded:: 0.99.0 + """ + @classmethod def from_string( cls, @@ -193,6 +201,16 @@ def external_commands(self) -> ExternalCommands: """ return self._settings.external_commands + @property + def build_tag_hook(self) -> typing.Callable[..., typing.Any] | None: + """Get the wheel build tag hook callable, or None if not configured. + + .. versionadded:: 0.99.0 + """ + if self._settings.wheels is None: + return None + return self._settings.wheels.build_tag_hook + def variant_changelog(self) -> list[str]: """Get global changelog for current variant""" return list(self._settings.changelog.get(self.variant, [])) diff --git a/src/fromager/wheels.py b/src/fromager/wheels.py index 6c3535e56..943edea8d 100644 --- a/src/fromager/wheels.py +++ b/src/fromager/wheels.py @@ -4,6 +4,7 @@ import logging import os import pathlib +import re import shutil import sys import tempfile @@ -39,12 +40,69 @@ logger = logging.getLogger(__name__) +_BUILD_TAG_SEGMENT_RE = re.compile(r"^[a-zA-Z0-9.]+$") + FROMAGER_BUILD_SETTINGS = "fromager-build-settings" FROMAGER_ELF_PROVIDES = "fromager-elf-provides.txt" FROMAGER_ELF_REQUIRES = "fromager-elf-requires.txt" FROMAGER_BUILD_REQ_PREFIX = "fromager" +def _validate_build_tag_segments(segments: list[str]) -> None: + """Validate that each segment matches ``[a-zA-Z0-9.]``.""" + for seg in segments: + if not isinstance(seg, str): + raise ValueError( + f"build_tag_hook must return strings, got {type(seg).__name__}" + ) + if not _BUILD_TAG_SEGMENT_RE.fullmatch(seg): + raise ValueError( + f"build tag hook returned invalid segment {seg!r}: " + "each segment must match [a-zA-Z0-9.]" + ) + + +def get_build_tag( + *, + ctx: context.WorkContext, + req: Requirement, + version: Version, + wheel_tags: frozenset[Tag], +) -> BuildTag: + """Compute the full build tag including any hook-provided suffix. + + Calls ``pbi.build_tag(version)`` for the numeric base, then invokes + the configured ``build_tag_hook`` (if any) to append variant + suffix segments. The hook should use *wheel_tags* only to + distinguish platlib from purelib wheels, not for platform-specific + decisions. + + .. versionadded:: 0.99.0 + """ + pbi = ctx.package_build_info(req) + base_tag = pbi.build_tag(version) + if not base_tag: + return base_tag + + hook = ctx.settings.build_tag_hook + if hook is None: + return base_tag + + raw = hook(ctx=ctx, req=req, version=version, wheel_tags=wheel_tags) + if isinstance(raw, str | bytes): + raise ValueError( + "build_tag_hook must return a sequence of strings, not a single string" + ) + segments = list(raw) + _validate_build_tag_segments(segments) + + if not segments: + return base_tag + + suffix = base_tag[1] + "_" + "_".join(segments) + return (base_tag[0], suffix) + + def _log_existing_sboms( req: Requirement, dist_info_dir: pathlib.Path, @@ -265,8 +323,11 @@ def add_extra_metadata_to_wheels( ) sbom.write_sbom(sbom=sbom_doc, dist_info_dir=dist_info_dir) - build_tag_from_settings = pbi.build_tag(version) - build_tag = build_tag_from_settings if build_tag_from_settings else (0, "") + build_tag = get_build_tag( + ctx=ctx, req=req, version=version, wheel_tags=wheel_tags + ) + if not build_tag: + build_tag = (0, "") cmd = [ "wheel", @@ -509,27 +570,52 @@ def get_prebuilt_wheel_provider( ) -@metrics.timeit(description="resolve wheel") -def resolve_cached_wheel( +def _resolve_all_cached_wheels( *, ctx: context.WorkContext, req: Requirement, cache_server_url: str, -) -> tuple[str, Version]: - """Resolve a wheel from a trusted cache server (local or remote). - - Uses ``PyPICacheProvider`` -- no cooldown, no hooks, no upload-time checks. - """ +) -> list[tuple[str, Version]]: + """Return matching wheels from a trusted cache server.""" provider = finders.PyPICacheProvider( cache_server_url=cache_server_url, constraints=ctx.constraints, ) results = resolver.find_all_matching_from_provider(provider, req) - wheel_url, version = results[0] - return str(wheel_url), version + return [(str(wheel_url), version) for wheel_url, version in results] -def resolve_all_prebuilt_wheels( +@metrics.timeit(description="resolve wheel") +def resolve_all_cached_wheels( + *, + ctx: context.WorkContext, + req: Requirement, + cache_server_url: str, +) -> list[tuple[str, Version]]: + """Return all matching wheels from a trusted cache server. + + Uses ``PyPICacheProvider`` -- no cooldown, no hooks, no upload-time checks. + """ + return _resolve_all_cached_wheels( + ctx=ctx, req=req, cache_server_url=cache_server_url + ) + + +@metrics.timeit(description="resolve wheel") +def resolve_cached_wheel( + *, + ctx: context.WorkContext, + req: Requirement, + cache_server_url: str, +) -> tuple[str, Version]: + """Resolve the best wheel from a trusted cache server (local or remote).""" + results = _resolve_all_cached_wheels( + ctx=ctx, req=req, cache_server_url=cache_server_url + ) + return results[0] + + +def _resolve_all_prebuilt_wheels( *, ctx: context.WorkContext, req: Requirement, @@ -567,6 +653,20 @@ def resolve_all_prebuilt_wheels( ) +@metrics.timeit(description="resolve wheel") +def resolve_all_prebuilt_wheels( + *, + ctx: context.WorkContext, + req: Requirement, + wheel_server_urls: list[str], + req_type: requirements_file.RequirementType | None = None, +) -> list[tuple[str, Version]]: + """Return all matching prebuilt wheels from the first successful server.""" + return _resolve_all_prebuilt_wheels( + ctx=ctx, req=req, wheel_server_urls=wheel_server_urls, req_type=req_type + ) + + @metrics.timeit(description="resolve wheel") def resolve_prebuilt_wheel( *, @@ -580,7 +680,7 @@ def resolve_prebuilt_wheel( Tries wheel servers in order and returns the highest matching version from the first server that succeeds. """ - results = resolve_all_prebuilt_wheels( + results = _resolve_all_prebuilt_wheels( ctx=ctx, req=req, wheel_server_urls=wheel_server_urls, req_type=req_type ) # Return highest version (first in sorted list) diff --git a/tests/test_bootstrapper.py b/tests/test_bootstrapper.py index e640cfb07..3d5c6e47a 100644 --- a/tests/test_bootstrapper.py +++ b/tests/test_bootstrapper.py @@ -15,6 +15,7 @@ from fromager.bootstrapper._build import Build from fromager.bootstrapper._cache import ( _download_wheel_from_cache, + _look_for_existing_wheel, bg_prepare_prebuilt, find_cached_wheel, ) @@ -36,6 +37,7 @@ from fromager.bootstrapper._work_item import WorkItem from fromager.context import WorkContext from fromager.dependency_graph import ROOT +from fromager.packagesettings import WheelSettings from fromager.requirements_file import RequirementType, SourceType @@ -492,6 +494,109 @@ def _make_cache_bootstrapper( return bt +def _set_cache_build_tag_hook( + ctx: WorkContext, hook: typing.Callable[..., list[str]] +) -> None: + """Configure a build tag hook for cache lookup tests.""" + ctx.settings._settings = ctx.settings._settings.model_copy( + update={"wheels": WheelSettings(build_tag_hook=hook)} + ) + + +def test_cache_lookup_checks_all_local_build_tags( + testdata_context: WorkContext, +) -> None: + """A stale local suffix does not hide a wheel with the expected suffix.""" + + def hook(**kwargs: object) -> list[str]: + return ["zzz"] + + _set_cache_build_tag_hook(testdata_context, hook) + req = Requirement("test-pkg") + version = Version("1.0.1") + stale = testdata_context.wheels_build / "test_pkg-1.0.1-1_aaa-py3-none-any.whl" + matching = testdata_context.wheels_build / "test_pkg-1.0.1-1_zzz-py3-none-any.whl" + stale.touch() + matching.touch() + + with patch( + "fromager.bootstrapper._cache._extract_build_reqs_from_wheel", + return_value=None, + ): + wheel, _ = _look_for_existing_wheel( + testdata_context, req, version, testdata_context.wheels_build + ) + + assert wheel == matching + + +def test_cache_lookup_checks_all_remote_build_tags( + testdata_context: WorkContext, +) -> None: + """A stale remote suffix does not hide a wheel with the expected suffix.""" + + def hook(**kwargs: object) -> list[str]: + return ["zzz"] + + _set_cache_build_tag_hook(testdata_context, hook) + req = Requirement("test-pkg") + version = Version("1.0.1") + cache_url = "https://cache.test/simple" + testdata_context.wheel_server_url = cache_url + stale_url = f"{cache_url}/test_pkg-1.0.1-1_aaa-py3-none-any.whl" + matching_name = "test_pkg-1.0.1-1_zzz-py3-none-any.whl" + matching_url = f"{cache_url}/{matching_name}" + matching = testdata_context.wheels_downloads / matching_name + + with ( + patch( + "fromager.resolver.find_all_matching_from_provider", + return_value=[(stale_url, version), (matching_url, version)], + ), + patch("fromager.wheels.download_wheel", return_value=matching) as download, + patch( + "fromager.bootstrapper._cache._extract_build_reqs_from_wheel", + return_value=None, + ), + ): + wheel, _ = _download_wheel_from_cache(testdata_context, cache_url, req, version) + + assert wheel == matching + download.assert_called_once_with( + req=req, + wheel_url=matching_url, + output_directory=testdata_context.wheels_downloads, + ) + + +def test_cache_lookup_propagates_build_tag_hook_error( + testdata_context: WorkContext, +) -> None: + """Invalid hook output fails the lookup instead of becoming a cache miss.""" + + def hook(**kwargs: object) -> list[str]: + return ["bad-suffix"] + + _set_cache_build_tag_hook(testdata_context, hook) + req = Requirement("test-pkg") + version = Version("1.0.1") + cache_url = "https://cache.test/simple" + testdata_context.wheel_server_url = cache_url + wheel_url = f"{cache_url}/test_pkg-1.0.1-1_valid-py3-none-any.whl" + + with ( + patch( + "fromager.resolver.find_all_matching_from_provider", + return_value=[(wheel_url, version)], + ), + patch("fromager.wheels.download_wheel") as download, + pytest.raises(ValueError, match="invalid segment"), + ): + _download_wheel_from_cache(testdata_context, cache_url, req, version) + + download.assert_not_called() + + def test_cache_lookup_resolver_exception_logs_info( tmp_context: WorkContext, caplog: pytest.LogCaptureFixture, diff --git a/tests/test_cooldown.py b/tests/test_cooldown.py index dfa1305d7..4eee08b9d 100644 --- a/tests/test_cooldown.py +++ b/tests/test_cooldown.py @@ -28,6 +28,7 @@ wheels, ) from fromager.commands import build as build_command +from fromager.packagesettings import WheelSettings from fromager.requirements_file import RequirementType _BOOTSTRAP_TIME = datetime.datetime(2026, 3, 26, 0, 0, 0, tzinfo=datetime.UTC) @@ -875,6 +876,23 @@ def _simple_wheel_index( } +def _wheel_index_for_filenames( + server_url: str, filenames: list[str] +) -> dict[str, typing.Any]: + return { + "meta": {"api-version": "1.1"}, + "name": "test-pkg", + "files": [ + { + "filename": filename, + "url": f"{server_url}test-pkg/{filename}", + "hashes": {"sha256": "bbb"}, + } + for filename in filenames + ], + } + + def _empty_index() -> dict[str, typing.Any]: return {"meta": {"api-version": "1.1"}, "name": "test-pkg", "files": []} @@ -927,6 +945,141 @@ def _download( return _download +def _set_existing_wheel_build_tag_hook( + ctx: context.WorkContext, suffixes: list[str] +) -> None: + """Configure a hook and changelog for build-tag lookup tests.""" + + def hook(**kwargs: object) -> list[str]: + return suffixes + + ctx.settings._settings = ctx.settings._settings.model_copy( + update={ + "changelog": {"cpu": ["test entry"]}, + "wheels": WheelSettings(build_tag_hook=hook), + } + ) + ctx.settings._pbi_cache.clear() + + +def test_is_wheel_built_checks_all_candidates( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A mismatched candidate does not hide a later match on the same server.""" + ctx = _context_for_existing_wheel(tmp_path) + _set_existing_wheel_build_tag_hook(ctx, ["aaa"]) + + version = Version("1.3.2") + stale_name = "test_pkg-1.3.2-1_zzz-py3-none-any.whl" + matching_name = "test_pkg-1.3.2-1_aaa-py3-none-any.whl" + matching_url = f"{_LOCAL_WHEEL_SERVER}test-pkg/{matching_name}" + + downloaded: list[str] = [] + monkeypatch.setattr( + build_command.wheels, "download_wheel", _record_download(downloaded) + ) + + with requests_mock.Mocker() as r: + r.get( + f"{_LOCAL_WHEEL_SERVER}test-pkg/", + json=_wheel_index_for_filenames( + _LOCAL_WHEEL_SERVER, [stale_name, matching_name] + ), + headers={"Content-Type": _PYPI_SIMPLE_JSON_CONTENT_TYPE}, + ) + found = build_command._is_wheel_built(ctx, "test-pkg", version) + + assert found is not None + assert found.name == matching_name + assert downloaded == [matching_url] + assert [request.url for request in r.request_history] == [ + f"{_LOCAL_WHEEL_SERVER}test-pkg/" + ] + + +def test_is_wheel_built_checks_next_server_after_tag_mismatch( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A mismatch on the local server does not hide a match in the job cache.""" + ctx = _context_for_existing_wheel(tmp_path) + _set_existing_wheel_build_tag_hook(ctx, ["aaa"]) + + version = Version("1.3.2") + stale_name = "test_pkg-1.3.2-1_zzz-py3-none-any.whl" + matching_name = "test_pkg-1.3.2-1_aaa-py3-none-any.whl" + matching_url = f"{_CACHE_WHEEL_SERVER}test-pkg/{matching_name}" + downloaded: list[str] = [] + monkeypatch.setattr( + build_command.wheels, "download_wheel", _record_download(downloaded) + ) + + with requests_mock.Mocker() as r: + r.get( + f"{_LOCAL_WHEEL_SERVER}test-pkg/", + json=_wheel_index_for_filenames(_LOCAL_WHEEL_SERVER, [stale_name]), + headers={"Content-Type": _PYPI_SIMPLE_JSON_CONTENT_TYPE}, + ) + r.get( + f"{_CACHE_WHEEL_SERVER}test-pkg/", + json=_wheel_index_for_filenames(_CACHE_WHEEL_SERVER, [matching_name]), + headers={"Content-Type": _PYPI_SIMPLE_JSON_CONTENT_TYPE}, + ) + found = build_command._is_wheel_built( + ctx, + "test-pkg", + version, + cache_wheel_server_url=_CACHE_WHEEL_SERVER, + ) + + assert found is not None + assert found.name == matching_name + assert [request.url for request in r.request_history] == [ + f"{_LOCAL_WHEEL_SERVER}test-pkg/", + f"{_CACHE_WHEEL_SERVER}test-pkg/", + ] + assert downloaded == [matching_url] + + +def test_is_wheel_built_propagates_build_tag_hook_error( + tmp_path: pathlib.Path, +) -> None: + """Invalid hook output is not treated as a cache miss.""" + ctx = _context_for_existing_wheel(tmp_path) + _set_existing_wheel_build_tag_hook(ctx, ["invalid-suffix"]) + version = Version("1.3.2") + filename = "test_pkg-1.3.2-1_aaa-py3-none-any.whl" + + with requests_mock.Mocker() as r: + r.get( + f"{_LOCAL_WHEEL_SERVER}test-pkg/", + json=_wheel_index_for_filenames(_LOCAL_WHEEL_SERVER, [filename]), + headers={"Content-Type": _PYPI_SIMPLE_JSON_CONTENT_TYPE}, + ) + with pytest.raises(ValueError, match="invalid segment"): + build_command._is_wheel_built(ctx, "test-pkg", version) + + +def test_is_wheel_built_propagates_inconsistent_build_tag( + tmp_path: pathlib.Path, +) -> None: + """A higher build number with the expected suffix remains an error.""" + ctx = _context_for_existing_wheel(tmp_path) + _set_existing_wheel_build_tag_hook(ctx, ["aaa"]) + version = Version("1.3.2") + filename = "test_pkg-1.3.2-2_aaa-py3-none-any.whl" + + with requests_mock.Mocker() as r: + r.get( + f"{_LOCAL_WHEEL_SERVER}test-pkg/", + json=_wheel_index_for_filenames(_LOCAL_WHEEL_SERVER, [filename]), + headers={"Content-Type": _PYPI_SIMPLE_JSON_CONTENT_TYPE}, + ) + with pytest.raises(ValueError, match=r"changelog.*inconsistent"): + build_command._is_wheel_built(ctx, "test-pkg", version) + + def test_is_wheel_built_uses_package_wheel_server_url( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_finders.py b/tests/test_finders.py index 110ccfa1f..691fdfebf 100644 --- a/tests/test_finders.py +++ b/tests/test_finders.py @@ -146,3 +146,48 @@ def test_pypi_cache_provider() -> None: finders.PyPICacheProvider( cache_server_url=url, include_sdists=False, include_wheels=False ) + + +class TestFindWheelBuildTagSuffix: + """Tests for ``find_wheel`` with build tag suffixes.""" + + def test_find_wheel_with_exact_build_tag(self, tmp_path: pathlib.Path) -> None: + """Plain build tag matches a plain-tagged wheel.""" + downloads = tmp_path / "downloads" + downloads.mkdir() + wheel = downloads / "mypkg-1.0.0-2-py3-none-any.whl" + wheel.write_text("not-empty") + result = finders.find_wheel(downloads, Requirement("mypkg"), "1.0.0", (2, "")) + assert result == wheel + + def test_find_wheel_matches_suffixed_with_base_tag( + self, tmp_path: pathlib.Path + ) -> None: + """Base tag (2, '') matches a suffixed wheel when no plain one exists.""" + downloads = tmp_path / "downloads" + downloads.mkdir() + wheel = downloads / "mypkg-1.0.0-2_el9.6-cp312-cp312-linux_x86_64.whl" + wheel.write_text("not-empty") + result = finders.find_wheel(downloads, Requirement("mypkg"), "1.0.0", (2, "")) + assert result == wheel + + def test_find_wheel_no_false_positive_on_higher_number( + self, tmp_path: pathlib.Path + ) -> None: + """Build tag 2 does not match build tag 20.""" + downloads = tmp_path / "downloads" + downloads.mkdir() + (downloads / "mypkg-1.0.0-20-py3-none-any.whl").write_text("not-empty") + result = finders.find_wheel(downloads, Requirement("mypkg"), "1.0.0", (2, "")) + assert result is None + + def test_find_wheel_with_full_suffix(self, tmp_path: pathlib.Path) -> None: + """Exact suffixed build tag matches the right wheel.""" + downloads = tmp_path / "downloads" + downloads.mkdir() + wheel = downloads / "mypkg-1.0.0-2_el9.6_cuda13.0-cp312-cp312-linux_x86_64.whl" + wheel.write_text("not-empty") + result = finders.find_wheel( + downloads, Requirement("mypkg"), "1.0.0", (2, "_el9.6_cuda13.0") + ) + assert result == wheel diff --git a/tests/test_packagesettings.py b/tests/test_packagesettings.py index a5107ad98..2c3f8f3fc 100644 --- a/tests/test_packagesettings.py +++ b/tests/test_packagesettings.py @@ -1,3 +1,4 @@ +import os import pathlib import typing from unittest.mock import Mock, patch @@ -1152,3 +1153,52 @@ def test_filter_env( ) -> None: ec = ExternalCommands(keep_env=keep, delete_env=delete) assert ec.filter_env(env) == expected + + +class TestWheelSettings: + """Tests for ``WheelSettings`` and ``SettingsFile.wheels``.""" + + def test_settings_file_parses_wheels_section(self) -> None: + """wheels.build_tag_hook is loaded from YAML.""" + sf = SettingsFile.from_string( + """ +wheels: + build_tag_hook: "os.path:join" +""" + ) + assert sf.wheels is not None + assert sf.wheels.build_tag_hook is os.path.join + + def test_settings_file_defaults_to_no_wheels(self) -> None: + """When wheels section is absent, wheels is None.""" + sf = SettingsFile.from_string("") + assert sf.wheels is None + + def test_settings_build_tag_hook_property(self) -> None: + """Settings.build_tag_hook exposes the hook callable.""" + sf = SettingsFile.from_string( + """ +wheels: + build_tag_hook: "os.path:join" +""" + ) + settings = Settings( + settings=sf, + package_settings=[], + variant="cpu", + patches_dir=pathlib.Path("/tmp"), + max_jobs=None, + ) + assert settings.build_tag_hook is os.path.join + + def test_settings_build_tag_hook_none_when_unset(self) -> None: + """Settings.build_tag_hook is None when not configured.""" + sf = SettingsFile.from_string("") + settings = Settings( + settings=sf, + package_settings=[], + variant="cpu", + patches_dir=pathlib.Path("/tmp"), + max_jobs=None, + ) + assert settings.build_tag_hook is None diff --git a/tests/test_wheels.py b/tests/test_wheels.py index 1c589d012..91bb698e2 100644 --- a/tests/test_wheels.py +++ b/tests/test_wheels.py @@ -1,14 +1,16 @@ import pathlib +import typing import zipfile from unittest.mock import Mock, patch import pytest from conftest import make_sbom_ctx from packaging.requirements import Requirement +from packaging.tags import Tag from packaging.version import Version from fromager import build_environment, context, downloads, wheels -from fromager.packagesettings import SbomSettings +from fromager.packagesettings import SbomSettings, Settings, SettingsFile, WheelSettings @patch("pyproject_hooks.BuildBackendHookCaller.build_wheel") @@ -337,3 +339,198 @@ def test_validate_wheel_file( else: with pytest.raises(ValueError): wheels.validate_wheel_filename(req, version, wheel_file) + + +def _ctx_with_hook( + tmp_path: pathlib.Path, + hook: typing.Callable[..., typing.Any] | None = None, +) -> context.WorkContext: + """Create a WorkContext with an optional build_tag_hook.""" + sf = SettingsFile.from_string("") + if hook is not None: + sf = sf.model_copy(update={"wheels": WheelSettings(build_tag_hook=hook)}) + settings = Settings( + settings=sf, + package_settings=[], + variant="cpu", + patches_dir=tmp_path / "patches", + max_jobs=None, + ) + ctx = context.WorkContext( + active_settings=settings, + patches_dir=tmp_path / "patches", + sdists_repo=tmp_path / "sdists-repo", + wheels_repo=tmp_path / "wheels-repo", + work_dir=tmp_path / "work-dir", + variant="cpu", + ) + ctx.setup() + return ctx + + +class TestGetBuildTag: + """Tests for ``wheels.get_build_tag()``.""" + + def test_no_hook_returns_base_tag(self, tmp_path: pathlib.Path) -> None: + """Without a hook, get_build_tag returns pbi.build_tag() unchanged.""" + ctx = _ctx_with_hook(tmp_path) + req = Requirement("mypkg") + version = Version("1.0") + tags = frozenset({Tag("cp312", "cp312", "linux_x86_64")}) + result = wheels.get_build_tag( + ctx=ctx, req=req, version=version, wheel_tags=tags + ) + pbi = ctx.package_build_info(req) + assert result == pbi.build_tag(version) + + def test_hook_appends_suffix_segments( + self, testdata_context: context.WorkContext + ) -> None: + """Hook-provided segments are joined and appended to the base tag.""" + + def hook(**kwargs: object) -> list[str]: + return ["el9.6", "rocm7.1"] + + testdata_context.settings._settings = ( + testdata_context.settings._settings.model_copy( + update={"wheels": WheelSettings(build_tag_hook=hook)} + ) + ) + req = Requirement("test-pkg") + version = Version("1.0.1") + tags = frozenset({Tag("cp312", "cp312", "linux_x86_64")}) + pbi = testdata_context.package_build_info(req) + base = pbi.build_tag(version) + assert base, "test-pkg must have a changelog entry for 1.0.1" + result = wheels.get_build_tag( + ctx=testdata_context, req=req, version=version, wheel_tags=tags + ) + assert len(result) == 2 + assert result[0] == base[0] + assert result[1] == base[1] + "_el9.6_rocm7.1" + + def test_hook_empty_segments_returns_base( + self, testdata_context: context.WorkContext + ) -> None: + """When hook returns empty list, base tag is returned.""" + + def hook(**kwargs: object) -> list[str]: + return [] + + testdata_context.settings._settings = ( + testdata_context.settings._settings.model_copy( + update={"wheels": WheelSettings(build_tag_hook=hook)} + ) + ) + req = Requirement("test-pkg") + version = Version("1.0.1") + tags = frozenset({Tag("py3", "none", "any")}) + pbi = testdata_context.package_build_info(req) + base = pbi.build_tag(version) + result = wheels.get_build_tag( + ctx=testdata_context, req=req, version=version, wheel_tags=tags + ) + assert result == base + + def test_hook_returning_string_raises( + self, testdata_context: context.WorkContext + ) -> None: + """Single string return is rejected (would be iterated as chars).""" + + def hook(**kwargs: object) -> str: + return "el9.6" + + testdata_context.settings._settings = ( + testdata_context.settings._settings.model_copy( + update={"wheels": WheelSettings(build_tag_hook=hook)} + ) + ) + req = Requirement("test-pkg") + version = Version("1.0.1") + tags = frozenset({Tag("cp312", "cp312", "linux_x86_64")}) + with pytest.raises(ValueError, match="sequence of strings"): + wheels.get_build_tag( + ctx=testdata_context, req=req, version=version, wheel_tags=tags + ) + + @pytest.mark.parametrize("invalid_segment", ["bad-char", "el9.6\n"]) + def test_hook_invalid_segment_chars_raises( + self, testdata_context: context.WorkContext, invalid_segment: str + ) -> None: + """Segments with invalid characters are rejected.""" + + def hook(**kwargs: object) -> list[str]: + return ["el9.6", invalid_segment] + + testdata_context.settings._settings = ( + testdata_context.settings._settings.model_copy( + update={"wheels": WheelSettings(build_tag_hook=hook)} + ) + ) + req = Requirement("test-pkg") + version = Version("1.0.1") + tags = frozenset({Tag("cp312", "cp312", "linux_x86_64")}) + with pytest.raises(ValueError, match="invalid segment"): + wheels.get_build_tag( + ctx=testdata_context, req=req, version=version, wheel_tags=tags + ) + + def test_hook_returning_bytes_raises( + self, testdata_context: context.WorkContext + ) -> None: + """Bytes return is rejected the same way as str.""" + + def hook(**kwargs: object) -> bytes: + return b"el9.6" + + testdata_context.settings._settings = ( + testdata_context.settings._settings.model_copy( + update={"wheels": WheelSettings(build_tag_hook=hook)} + ) + ) + req = Requirement("test-pkg") + version = Version("1.0.1") + tags = frozenset({Tag("cp312", "cp312", "linux_x86_64")}) + with pytest.raises(ValueError, match="sequence of strings"): + wheels.get_build_tag( + ctx=testdata_context, req=req, version=version, wheel_tags=tags + ) + + def test_hook_exception_propagates( + self, testdata_context: context.WorkContext + ) -> None: + """Hook exceptions propagate to the caller.""" + + def hook(**kwargs: object) -> list[str]: + raise RuntimeError("hook failure") + + testdata_context.settings._settings = ( + testdata_context.settings._settings.model_copy( + update={"wheels": WheelSettings(build_tag_hook=hook)} + ) + ) + req = Requirement("test-pkg") + version = Version("1.0.1") + tags = frozenset({Tag("cp312", "cp312", "linux_x86_64")}) + with pytest.raises(RuntimeError, match="hook failure"): + wheels.get_build_tag( + ctx=testdata_context, req=req, version=version, wheel_tags=tags + ) + + def test_hook_not_called_without_base_tag(self, tmp_path: pathlib.Path) -> None: + """Hook is skipped when the package has no changelog build tag.""" + calls: list[dict[str, object]] = [] + + def hook(**kwargs: object) -> list[str]: + calls.append(kwargs) + return ["el9.6"] + + ctx = _ctx_with_hook(tmp_path, hook=hook) + req = Requirement("mypkg") + version = Version("1.0") + tags = frozenset({Tag("cp312", "cp312", "linux_x86_64")}) + result = wheels.get_build_tag( + ctx=ctx, req=req, version=version, wheel_tags=tags + ) + assert result == () + assert calls == []