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