Skip to content

Commit 7c17832

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, bytes, invalid chars - 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>
1 parent ebbd889 commit 7c17832

10 files changed

Lines changed: 464 additions & 52 deletions

File tree

src/fromager/bootstrapper/_cache.py

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -86,22 +86,27 @@ 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+
if expected_build_tag and expected_build_tag != actual_build_tag:
103108
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}"
109+
f"found wheel for {resolved_version} in {wheel_filename} but build tag does not match. Got {actual_build_tag} but expected {expected_build_tag}"
105110
)
106111
return None, None
107112
logger.info(f"found existing wheel {wheel_filename}")
@@ -129,16 +134,20 @@ def _download_wheel_from_cache(
129134
results = resolver.find_all_matching_from_provider(provider, pinned_req)
130135
wheel_url, _ = results[0]
131136
wheelfile_name = pathlib.Path(urlparse(wheel_url).path)
137+
_, _, actual_build_tag, wheel_tags = wheels.extract_info_from_wheel_file(
138+
req, wheelfile_name
139+
)
132140
pbi = ctx.package_build_info(req)
133-
expected_build_tag = pbi.build_tag(resolved_version)
141+
expected_build_tag = wheels.get_build_tag(
142+
ctx=ctx, req=req, version=resolved_version, wheel_tags=wheel_tags
143+
)
134144
logger.info(f"has expected build tag {expected_build_tag}")
135145
changelogs = pbi.get_changelog(resolved_version)
136146
logger.debug(f"has change logs {changelogs}")
137147

138-
_, _, build_tag, _ = wheels.extract_info_from_wheel_file(req, wheelfile_name)
139-
if expected_build_tag and expected_build_tag != build_tag:
148+
if expected_build_tag and expected_build_tag != actual_build_tag:
140149
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}"
150+
f"found wheel for {resolved_version} in cache but build tag does not match. Got {actual_build_tag} but expected {expected_build_tag}"
142151
)
143152
return None, None
144153

src/fromager/commands/build.py

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -485,11 +485,25 @@ 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)
489+
_, _, build_tag_from_name, wheel_tags = parse_wheel_filename(wheel_basename)
490+
except Exception:
491+
logger.debug(
492+
"could not locate prebuilt wheel %s-%s on %s",
493+
dist_name,
494+
resolved_version,
495+
wheel_server_urls,
496+
exc_info=True,
497+
)
498+
logger.info("could not locate prebuilt wheel")
499+
return None
500+
else:
501+
# Compute expected build tag in the else clause so hook
502+
# 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, "")
493507
existing_build_tag = build_tag_from_name if build_tag_from_name else (0, "")
494508
if (
495509
existing_build_tag[0] > build_tag[0]
@@ -513,21 +527,10 @@ def _is_wheel_built(
513527
wheel_filename = None
514528

515529
if not wheel_filename:
516-
# if the found wheel was on an external server, then download it
517530
logger.info("downloading wheel from %s", url)
518531
wheel_filename = wheels.download_wheel(req, url, wkctx.wheels_downloads)
519532

520533
return wheel_filename
521-
except Exception:
522-
logger.debug(
523-
"could not locate prebuilt wheel %s-%s on %s",
524-
dist_name,
525-
resolved_version,
526-
wheel_server_urls,
527-
exc_info=True,
528-
)
529-
logger.info("could not locate prebuilt wheel")
530-
return None
531534

532535

533536
def _build_parallel(

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: set[str] = set()
182+
for name in name_variants:
183+
for btp in build_tag_prefixes:
184+
candidate_bases.add(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.93.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.93.0
66+
"""
67+
68+
3669
class SbomSettings(pydantic.BaseModel):
3770
"""Global SBOM generation settings
3871

src/fromager/packagesettings/_settings.py

Lines changed: 19 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.93.0
64+
"""
65+
5866
@classmethod
5967
def from_string(
6068
cls,
@@ -193,6 +201,16 @@ 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+
208+
.. versionadded:: 0.93.0
209+
"""
210+
if self._settings.wheels is None:
211+
return None
212+
return self._settings.wheels.build_tag_hook
213+
196214
def variant_changelog(self) -> list[str]:
197215
"""Get global changelog for current variant"""
198216
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.93.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",

tests/test_finders.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,3 +146,48 @@ def test_pypi_cache_provider() -> None:
146146
finders.PyPICacheProvider(
147147
cache_server_url=url, include_sdists=False, include_wheels=False
148148
)
149+
150+
151+
class TestFindWheelBuildTagSuffix:
152+
"""Tests for ``find_wheel`` with build tag suffixes."""
153+
154+
def test_find_wheel_with_exact_build_tag(self, tmp_path: pathlib.Path) -> None:
155+
"""Plain build tag matches a plain-tagged wheel."""
156+
downloads = tmp_path / "downloads"
157+
downloads.mkdir()
158+
wheel = downloads / "mypkg-1.0.0-2-py3-none-any.whl"
159+
wheel.write_text("not-empty")
160+
result = finders.find_wheel(downloads, Requirement("mypkg"), "1.0.0", (2, ""))
161+
assert result == wheel
162+
163+
def test_find_wheel_matches_suffixed_with_base_tag(
164+
self, tmp_path: pathlib.Path
165+
) -> None:
166+
"""Base tag (2, '') matches a suffixed wheel when no plain one exists."""
167+
downloads = tmp_path / "downloads"
168+
downloads.mkdir()
169+
wheel = downloads / "mypkg-1.0.0-2_el9.6-cp312-cp312-linux_x86_64.whl"
170+
wheel.write_text("not-empty")
171+
result = finders.find_wheel(downloads, Requirement("mypkg"), "1.0.0", (2, ""))
172+
assert result == wheel
173+
174+
def test_find_wheel_no_false_positive_on_higher_number(
175+
self, tmp_path: pathlib.Path
176+
) -> None:
177+
"""Build tag 2 does not match build tag 20."""
178+
downloads = tmp_path / "downloads"
179+
downloads.mkdir()
180+
(downloads / "mypkg-1.0.0-20-py3-none-any.whl").write_text("not-empty")
181+
result = finders.find_wheel(downloads, Requirement("mypkg"), "1.0.0", (2, ""))
182+
assert result is None
183+
184+
def test_find_wheel_with_full_suffix(self, tmp_path: pathlib.Path) -> None:
185+
"""Exact suffixed build tag matches the right wheel."""
186+
downloads = tmp_path / "downloads"
187+
downloads.mkdir()
188+
wheel = downloads / "mypkg-1.0.0-2_el9.6_cuda13.0-cp312-cp312-linux_x86_64.whl"
189+
wheel.write_text("not-empty")
190+
result = finders.find_wheel(
191+
downloads, Requirement("mypkg"), "1.0.0", (2, "_el9.6_cuda13.0")
192+
)
193+
assert result == wheel

0 commit comments

Comments
 (0)