Skip to content

Commit ff96cef

Browse files
jlarkin09claudecursoragent
committed
feat(wheels): add configurable build tag hook for wheel filenames
Implement the accepted proposal from docs/proposals/wheel-build-tag-hook.md (issue #1059, tracking issue #1181). Add a `wheels.build_tag_hook` option in global settings that lets downstream projects append environment-specific suffixes (OS, accelerator, torch ABI) to wheel build tags via a user-defined callable. The hook receives ctx, req, version, and wheel_tags and returns suffix segments joined with `_`. - Add `WheelSettings` model with `build_tag_hook: ImportString` to settings - Add `get_build_tag()` and `_validate_build_tag_segments()` to wheels.py - Update `add_extra_metadata_to_wheels()`, bootstrapper cache checks, and `_is_wheel_built()` to use computed build tags - Minimal finder update to match suffixed build tag filenames - Validate hook output: reject single strings, invalid chars, non-strings - No behavior change when hook is not configured Closes: #1181 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Justin Larkin <jlarkin@redhat.com> Co-authored-by: Cursor <cursoragent@cursor.com> Signed-off-by: Justin Larkin <jlarkin@redhat.com> Co-authored-by: Cursor <cursoragent@cursor.com> Signed-off-by: Justin Larkin <jlarkin@redhat.com> Co-authored-by: Cursor <cursoragent@cursor.com> Signed-off-by: Justin Larkin <jlarkin@redhat.com> Co-authored-by: Cursor <cursoragent@cursor.com> Signed-off-by: Justin Larkin <jlarkin@redhat.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent ebbd889 commit ff96cef

10 files changed

Lines changed: 435 additions & 72 deletions

File tree

src/fromager/bootstrapper/_cache.py

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -86,22 +86,29 @@ def _look_for_existing_wheel(
8686
search_in: pathlib.Path,
8787
) -> tuple[pathlib.Path | None, pathlib.Path | None]:
8888
pbi = ctx.package_build_info(req)
89-
expected_build_tag = pbi.build_tag(resolved_version)
89+
base_build_tag = pbi.build_tag(resolved_version)
9090
logger.info(
91-
f"looking for existing wheel for version {resolved_version} with build tag {expected_build_tag} in {search_in}"
91+
f"looking for existing wheel for version {resolved_version} with build tag {base_build_tag} in {search_in}"
9292
)
9393
wheel_filename = finders.find_wheel(
9494
downloads_dir=search_in,
9595
req=req,
9696
dist_version=str(resolved_version),
97-
build_tag=expected_build_tag,
97+
build_tag=base_build_tag,
9898
)
9999
if not wheel_filename:
100100
return None, None
101-
_, _, build_tag, _ = wheels.extract_info_from_wheel_file(req, wheel_filename)
102-
if expected_build_tag and expected_build_tag != build_tag:
101+
_, _, actual_build_tag, wheel_tags = wheels.extract_info_from_wheel_file(
102+
req, wheel_filename
103+
)
104+
expected_build_tag = wheels.get_build_tag(
105+
ctx=ctx, req=req, version=resolved_version, wheel_tags=wheel_tags
106+
)
107+
expected = expected_build_tag or (0, "")
108+
actual = actual_build_tag or (0, "")
109+
if expected != actual:
103110
logger.info(
104-
f"found wheel for {resolved_version} in {wheel_filename} but build tag does not match. Got {build_tag} but expected {expected_build_tag}"
111+
f"found wheel for {resolved_version} in {wheel_filename} but build tag does not match. Got {actual_build_tag} but expected {expected_build_tag}"
105112
)
106113
return None, None
107114
logger.info(f"found existing wheel {wheel_filename}")
@@ -129,16 +136,19 @@ def _download_wheel_from_cache(
129136
results = resolver.find_all_matching_from_provider(provider, pinned_req)
130137
wheel_url, _ = results[0]
131138
wheelfile_name = pathlib.Path(urlparse(wheel_url).path)
132-
pbi = ctx.package_build_info(req)
133-
expected_build_tag = pbi.build_tag(resolved_version)
139+
_, _, actual_build_tag, wheel_tags = wheels.extract_info_from_wheel_file(
140+
req, wheelfile_name
141+
)
142+
expected_build_tag = wheels.get_build_tag(
143+
ctx=ctx, req=req, version=resolved_version, wheel_tags=wheel_tags
144+
)
134145
logger.info(f"has expected build tag {expected_build_tag}")
135-
changelogs = pbi.get_changelog(resolved_version)
136-
logger.debug(f"has change logs {changelogs}")
137146

138-
_, _, build_tag, _ = wheels.extract_info_from_wheel_file(req, wheelfile_name)
139-
if expected_build_tag and expected_build_tag != build_tag:
147+
expected = expected_build_tag or (0, "")
148+
actual = actual_build_tag or (0, "")
149+
if expected != actual:
140150
logger.info(
141-
f"found wheel for {resolved_version} in cache but build tag does not match. Got {build_tag} but expected {expected_build_tag}"
151+
f"found wheel for {resolved_version} in cache but build tag does not match. Got {actual_build_tag} but expected {expected_build_tag}"
142152
)
143153
return None, None
144154

src/fromager/commands/build.py

Lines changed: 32 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -485,39 +485,8 @@ def _is_wheel_built(
485485
wheel_server_urls=wheel_server_urls,
486486
)
487487
logger.info("found candidate wheel %s", url)
488-
pbi = wkctx.package_build_info(req)
489-
build_tag_from_settings = pbi.build_tag(resolved_version)
490-
build_tag = build_tag_from_settings if build_tag_from_settings else (0, "")
491488
wheel_basename = downloads.extract_filename_from_url(url)
492-
_, _, build_tag_from_name, _ = parse_wheel_filename(wheel_basename)
493-
existing_build_tag = build_tag_from_name if build_tag_from_name else (0, "")
494-
if (
495-
existing_build_tag[0] > build_tag[0]
496-
and existing_build_tag[1] == build_tag[1]
497-
):
498-
raise ValueError(
499-
f"{dist_name}: changelog for version {resolved_version} is inconsistent. Found build tag {existing_build_tag} but expected {build_tag}"
500-
)
501-
if existing_build_tag != build_tag:
502-
logger.info(
503-
f"candidate wheel build tag {existing_build_tag} does not match expected build tag {build_tag}"
504-
)
505-
return None
506-
507-
wheel_filename: pathlib.Path | None = None
508-
if url.startswith(wkctx.wheel_server_url):
509-
logging.debug("found wheel on local server")
510-
wheel_filename = wkctx.wheels_downloads / wheel_basename
511-
if not wheel_filename.exists():
512-
logger.info("wheel not found in local cache, preparing to download")
513-
wheel_filename = None
514-
515-
if not wheel_filename:
516-
# if the found wheel was on an external server, then download it
517-
logger.info("downloading wheel from %s", url)
518-
wheel_filename = wheels.download_wheel(req, url, wkctx.wheels_downloads)
519-
520-
return wheel_filename
489+
_, _, build_tag_from_name, wheel_tags = parse_wheel_filename(wheel_basename)
521490
except Exception:
522491
logger.debug(
523492
"could not locate prebuilt wheel %s-%s on %s",
@@ -529,6 +498,37 @@ def _is_wheel_built(
529498
logger.info("could not locate prebuilt wheel")
530499
return None
531500

501+
# Compute expected build tag outside the broad exception handler
502+
# so hook validation errors propagate instead of being swallowed.
503+
expected_tag = wheels.get_build_tag(
504+
ctx=wkctx, req=req, version=resolved_version, wheel_tags=wheel_tags
505+
)
506+
build_tag = expected_tag if expected_tag else (0, "")
507+
existing_build_tag = build_tag_from_name if build_tag_from_name else (0, "")
508+
if existing_build_tag[0] > build_tag[0] and existing_build_tag[1] == build_tag[1]:
509+
raise ValueError(
510+
f"{dist_name}: changelog for version {resolved_version} is inconsistent. Found build tag {existing_build_tag} but expected {build_tag}"
511+
)
512+
if existing_build_tag != build_tag:
513+
logger.info(
514+
f"candidate wheel build tag {existing_build_tag} does not match expected build tag {build_tag}"
515+
)
516+
return None
517+
518+
wheel_filename: pathlib.Path | None = None
519+
if url.startswith(wkctx.wheel_server_url):
520+
logging.debug("found wheel on local server")
521+
wheel_filename = wkctx.wheels_downloads / wheel_basename
522+
if not wheel_filename.exists():
523+
logger.info("wheel not found in local cache, preparing to download")
524+
wheel_filename = None
525+
526+
if not wheel_filename:
527+
logger.info("downloading wheel from %s", url)
528+
wheel_filename = wheels.download_wheel(req, url, wkctx.wheels_downloads)
529+
530+
return wheel_filename
531+
532532

533533
def _build_parallel(
534534
wkctx: context.WorkContext,

src/fromager/finders.py

Lines changed: 20 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -162,30 +162,27 @@ def find_wheel(
162162
"""
163163
filename_prefix = _dist_name_to_filename(req.name)
164164
canonical_name = canonicalize_name(req.name)
165-
# if build tag is 0 then we can ignore to handle non tagged wheels for backward compatibility
166-
candidate_bases_build_tag = f"{build_tag[0]}{build_tag[1]}-" if build_tag else ""
167165

168-
candidate_bases = set(
169-
[
170-
# First check if the file is there using the canonically
171-
# transformed name.
172-
f"{filename_prefix}-{dist_version}-{candidate_bases_build_tag}",
173-
# If that didn't work, try the canonical dist name. That's not
174-
# "correct" but we do see it. (charset-normalizer-3.3.2-
175-
# and setuptools-scm-8.0.4-) for example
176-
f"{canonical_name}-{dist_version}-{candidate_bases_build_tag}",
177-
# If *that* didn't work, try the dist name we've been
178-
# given as a dependency. That's not "correct", either but we do
179-
# see it. (oslo.messaging-14.7.0-) for example
180-
f"{req.name}-{dist_version}-{candidate_bases_build_tag}",
181-
# Sometimes the sdist uses '.' instead of '-' in the
182-
# package name portion.
183-
f"{req.name.replace('-', '.')}-{dist_version}-{candidate_bases_build_tag}",
184-
]
185-
)
186-
# Case-insensitive globbing was added to Python 3.12, but we
187-
# have to run with older versions, too, so do our own name
188-
# comparison.
166+
build_tag_prefixes: list[str] = []
167+
if build_tag:
168+
build_tag_prefixes.append(f"{build_tag[0]}{build_tag[1]}-")
169+
if not build_tag[1]:
170+
build_tag_prefixes.append(f"{build_tag[0]}_")
171+
else:
172+
build_tag_prefixes.append("")
173+
174+
name_variants = [
175+
filename_prefix,
176+
canonical_name,
177+
req.name,
178+
req.name.replace("-", "."),
179+
]
180+
181+
candidate_bases: list[str] = []
182+
for name in name_variants:
183+
for btp in build_tag_prefixes:
184+
candidate_bases.append(f"{name}-{dist_version}-{btp}")
185+
189186
for base in candidate_bases:
190187
logger.debug('looking for wheel as "%s"', base)
191188
for filename in downloads_dir.glob("*.whl"):

src/fromager/packagesettings/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
ResolverDist,
1313
SbomSettings,
1414
VariantInfo,
15+
WheelSettings,
1516
)
1617
from ._pbi import PackageBuildInfo
1718
from ._resolver import (
@@ -88,6 +89,7 @@
8889
"Variant",
8990
"VariantChangelog",
9091
"VariantInfo",
92+
"WheelSettings",
9193
"default_update_extra_environ",
9294
"get_extra_environ",
9395
"pep440_tag_matcher",

src/fromager/packagesettings/_models.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,39 @@
3333
logger = logging.getLogger(__name__)
3434

3535

36+
class WheelSettings(pydantic.BaseModel):
37+
"""Global wheel build settings
38+
39+
::
40+
41+
wheels:
42+
build_tag_hook: "mypackage.hooks:build_tag_hook"
43+
44+
.. versionadded:: 0.92.0
45+
"""
46+
47+
model_config = MODEL_CONFIG
48+
49+
build_tag_hook: pydantic.ImportString[typing.Callable[..., typing.Any]] | None = (
50+
None
51+
)
52+
"""Callable that returns suffix segments for the wheel build tag.
53+
54+
The callable receives keyword-only arguments ``ctx``, ``req``,
55+
``version``, and ``wheel_tags`` and returns
56+
``Sequence[str]`` of suffix segments.
57+
58+
Only invoked when the package already has a non-empty build tag
59+
from its changelog entry for the given version; otherwise the hook
60+
is skipped and no build tag is added. The callable must be
61+
deterministic and independent of wheel contents, build environment,
62+
or ELF metadata so fresh builds and cache lookups compute the same
63+
tag.
64+
65+
.. versionadded:: 0.92.0
66+
"""
67+
68+
3669
class SbomSettings(pydantic.BaseModel):
3770
"""Global SBOM generation settings
3871

src/fromager/packagesettings/_settings.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from pydantic import Field
1414

1515
from .. import overrides
16-
from ._models import ExternalCommands, PackageSettings, SbomSettings
16+
from ._models import ExternalCommands, PackageSettings, SbomSettings, WheelSettings
1717
from ._pbi import PackageBuildInfo
1818
from ._typedefs import MODEL_CONFIG, GlobalChangelog, Package, Variant
1919

@@ -55,6 +55,14 @@ class SettingsFile(pydantic.BaseModel):
5555
.. versionadded:: 0.92.0
5656
"""
5757

58+
wheels: WheelSettings | None = None
59+
"""Wheel build settings
60+
61+
Configures wheel build tag hooks and other wheel-specific options.
62+
63+
.. versionadded:: 0.92.0
64+
"""
65+
5866
@classmethod
5967
def from_string(
6068
cls,
@@ -193,6 +201,13 @@ def external_commands(self) -> ExternalCommands:
193201
"""
194202
return self._settings.external_commands
195203

204+
@property
205+
def build_tag_hook(self) -> typing.Callable[..., typing.Any] | None:
206+
"""Get the wheel build tag hook callable, or None if not configured."""
207+
if self._settings.wheels is None:
208+
return None
209+
return self._settings.wheels.build_tag_hook
210+
196211
def variant_changelog(self) -> list[str]:
197212
"""Get global changelog for current variant"""
198213
return list(self._settings.changelog.get(self.variant, []))

src/fromager/wheels.py

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import logging
55
import os
66
import pathlib
7+
import re
78
import shutil
89
import sys
910
import tempfile
@@ -38,12 +39,67 @@
3839

3940
logger = logging.getLogger(__name__)
4041

42+
_BUILD_TAG_SEGMENT_RE = re.compile(r"^[a-zA-Z0-9.]+$")
43+
4144
FROMAGER_BUILD_SETTINGS = "fromager-build-settings"
4245
FROMAGER_ELF_PROVIDES = "fromager-elf-provides.txt"
4346
FROMAGER_ELF_REQUIRES = "fromager-elf-requires.txt"
4447
FROMAGER_BUILD_REQ_PREFIX = "fromager"
4548

4649

50+
def _validate_build_tag_segments(segments: list[str]) -> None:
51+
"""Validate that each segment matches ``[a-zA-Z0-9.]``."""
52+
for seg in segments:
53+
if not isinstance(seg, str):
54+
raise ValueError(
55+
f"build_tag_hook must return strings, got {type(seg).__name__}"
56+
)
57+
if not _BUILD_TAG_SEGMENT_RE.match(seg):
58+
raise ValueError(
59+
f"build tag hook returned invalid segment {seg!r}: "
60+
"each segment must match [a-zA-Z0-9.]"
61+
)
62+
63+
64+
def get_build_tag(
65+
*,
66+
ctx: context.WorkContext,
67+
req: Requirement,
68+
version: Version,
69+
wheel_tags: frozenset[Tag],
70+
) -> BuildTag:
71+
"""Compute the full build tag including any hook-provided suffix.
72+
73+
Calls ``pbi.build_tag(version)`` for the numeric base, then invokes
74+
the configured ``build_tag_hook`` (if any) to append environment
75+
suffix segments.
76+
77+
.. versionadded:: 0.92.0
78+
"""
79+
pbi = ctx.package_build_info(req)
80+
base_tag = pbi.build_tag(version)
81+
if not base_tag:
82+
return base_tag
83+
84+
hook = ctx.settings.build_tag_hook
85+
if hook is None:
86+
return base_tag
87+
88+
raw = hook(ctx=ctx, req=req, version=version, wheel_tags=wheel_tags)
89+
if isinstance(raw, (str, bytes)):
90+
raise ValueError(
91+
"build_tag_hook must return a sequence of strings, not a single string"
92+
)
93+
segments = list(raw)
94+
_validate_build_tag_segments(segments)
95+
96+
if not segments:
97+
return base_tag
98+
99+
suffix = base_tag[1] + "_" + "_".join(segments)
100+
return (base_tag[0], suffix)
101+
102+
47103
def _log_existing_sboms(
48104
req: Requirement,
49105
dist_info_dir: pathlib.Path,
@@ -264,8 +320,11 @@ def add_extra_metadata_to_wheels(
264320
)
265321
sbom.write_sbom(sbom=sbom_doc, dist_info_dir=dist_info_dir)
266322

267-
build_tag_from_settings = pbi.build_tag(version)
268-
build_tag = build_tag_from_settings if build_tag_from_settings else (0, "")
323+
build_tag = get_build_tag(
324+
ctx=ctx, req=req, version=version, wheel_tags=wheel_tags
325+
)
326+
if not build_tag:
327+
build_tag = (0, "")
269328

270329
cmd = [
271330
"wheel",

0 commit comments

Comments
 (0)