Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion docs/concepts/package-settings.rst
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,39 @@ adds variant awareness, patch discovery, and template resolution.
- Merged list of unversioned + version-specific + variant-specific
patch files, sorted by filename
* - **Metadata**
- Build tag (from changelog length), annotations, PURL config,
- Build tag (explicit number or changelog length), annotations, PURL config,
pyproject.toml overrides

See :doc:`/reference/config-reference` for the full field reference.

Explicit Build Numbers
----------------------

``build_number`` maps public versions to nonnegative integers, replacing the
combined variant and package changelog count for those versions in every
variant. Variant-specific explicit numbers are not supported. Changelogs
remain available as history. Keys with local version suffixes are rejected;
requested versions with local suffixes use their public version for lookup.

.. code-block:: yaml

build_number:
"1.2.3": 0
"2.0": 7

.. versionadded:: 0.102.0

Explicit numbers require exact build tags in local and remote cache lookups
across ``bootstrap``, ``build`` and ``build-sequence``. Zero produces a numeric
``0`` tag; a missing or different tag is a cache miss. ``build_tag_hook`` still
appends suffixes, including for zero. Unconfigured versions retain their
changelog-derived tags and cache behavior; pre-built wheels keep their tags.

Any loaded non-prebuilt package with a non-empty ``build_number`` mapping
switches all build-environment dependency installation to a filtered local
wheel view, even if its configured versions are not used. Fromager logs this
mode and reinstalls all dependencies, including unconfigured versions and
pre-built wheels. Installation does not fetch from ``--wheel-server-url``;
all required dependency wheels must already be available locally. Installed
``WHEEL`` metadata is checked before building. Without such settings,
dependency installation retains its existing behavior.
16 changes: 12 additions & 4 deletions docs/customization.md
Original file line number Diff line number Diff line change
Expand Up @@ -446,19 +446,27 @@ def build_tag_hook(
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`.
Fromager joins segments in order with `_`. If the numeric part of the build
tag is `2`, returning `["cuda13", "torch2.10"]` produces
`2_cuda13_torch2.10`. With an explicit `build_number` of zero, the same
segments produce `0_cuda13_torch2.10`.

**Important notes:**

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Nit] — docs/customization.md (line 454, build_tag_hook notes) and src/fromager/build_environment.py (lines 232, 305)

The hook is now sometimes called with a fake, bare requirement — but the docs still imply it always gets the real one.

The new install-time filtering and post-install validation call wheels.get_build_tag(req=Requirement(name), ...) — a requirement reconstructed from just the package name, no extras, no marker, no URL. Meanwhile the build and cache paths pass the real requirement from the resolve graph.

Why that matters: a build_tag_hook that looks at anything beyond the name (say, req.extras or req.marker) will compute a different expected tag during install-time filtering than during the actual build — so wheels get filtered out or installs rejected for no visible reason. The docs say the hook must be "deterministic and independent of wheel contents, build environment, or ELF metadata" — which a hook reading req.extras technically satisfies today, yet it would still break under this PR.

The bare requirement is inherent to the design (those code paths iterate all local wheels / all installed dists, so there is no real requirement to thread through). Fine — but then the tightened contract should be written down. One extra bullet in the "Important notes" list:

  • The suffix must depend only on the requirement name, version, variant, and wheel_tags: during dependency-installation filtering, Fromager re-evaluates the hook with a bare requirement containing just the name.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also ties into my reply on the installation policy. Deferring exact dependency-installation enforcement would remove these installation-time hook calls with bare requirements. If we retain enforcement, this hook-contract question still needs addressing.

@mikedep333 mikedep333 Oct 23, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’ve documented the narrower contract for explicit-counter installation checks: the suffix must use only the name, version, variant, and wheel_tags, within the existing compatibility-tag restrictions. Those calls lack the original extras, markers, specifier, and URL.

This remains a design question in #1392: accept that restriction for enforcement, or preserve the original requirement context. The generation/cache-only option would avoid adding these installation-time hook calls.


- 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.
- The hook runs when the package version has an explicit `build_number`
(including zero) or a changelog-derived build tag. It is skipped for
pre-built wheels, or when neither an explicit number nor a non-empty
changelog supplies a build tag.
- 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.
- When explicit counters enable dependency-installation checks, the suffix
must depend only on the requirement name, version, variant, and `wheel_tags`.
These checks reconstruct a bare requirement from the wheel or installed
distribution name, without the original extras, markers, specifier, or URL.
- Use `wheel_tags` only to distinguish pure wheels from platform wheels.
The hook must return identical results across architectures for the same
configured variant.
Expand Down
53 changes: 53 additions & 0 deletions e2e/test_bootstrap_build_tags.sh
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,56 @@ if ! grep -q "added extra metadata and build tag" "$LOGFILE"; then
fi

$pass

# Explicit numbers override both changelogs and rebuild in a reused repository.
mkdir -p "$OUTDIR/explicit-build-settings"
cat > "$OUTDIR/explicit-global-settings.yaml" <<EOF
changelog:
cpu:
- Imported variant change
EOF
for counter in 0 1; do
cat > "$OUTDIR/explicit-build-settings/stevedore.yaml" <<EOF
build_number:
"5.2.0": $counter
changelog:
"5.2.0":
- First imported change
- Second imported change
EOF

LOGFILE="$OUTDIR/explicit-build-$counter.log"
fromager \
--log-file="$LOGFILE" \
--error-log-file="$OUTDIR/fromager-errors.log" \
--sdists-repo="$OUTDIR/sdists-repo" \
--wheels-repo="$OUTDIR/explicit-wheels-repo" \
--work-dir="$OUTDIR/explicit-work-dir" \
--settings-file="$OUTDIR/explicit-global-settings.yaml" \
--settings-dir="$OUTDIR/explicit-build-settings" \
bootstrap 'stevedore==5.2.0'

grep -q "added extra metadata and build tag" "$LOGFILE"
python - "$OUTDIR/explicit-wheels-repo/downloads" "$counter" <<'PY'
import email.parser
import pathlib
import sys
import zipfile

from packaging.utils import parse_wheel_filename

directory = pathlib.Path(sys.argv[1])
counter = int(sys.argv[2])
wheel = directory / f"stevedore-5.2.0-{counter}-py3-none-any.whl"
assert wheel.is_file(), wheel
assert parse_wheel_filename(wheel.name)[2] == (counter, "")
with zipfile.ZipFile(wheel) as archive:
metadata_path = "stevedore-5.2.0.dist-info/WHEEL"
metadata = email.parser.Parser().parsestr(archive.read(metadata_path).decode())
assert metadata["Build"] == str(counter), metadata
assert metadata.get_all("Tag") == ["py3-none-any"], metadata
print(f"Verified {wheel.name}: Build: {counter}")
PY
done
test -f "$OUTDIR/explicit-wheels-repo/downloads/stevedore-5.2.0-0-py3-none-any.whl"
test -f "$OUTDIR/explicit-wheels-repo/downloads/stevedore-5.2.0-1-py3-none-any.whl"
147 changes: 129 additions & 18 deletions src/fromager/build_environment.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import contextlib
import json
import logging
import os
Expand All @@ -8,13 +9,17 @@
import re
import subprocess
import sys
import tempfile
import typing
from email.parser import Parser
from io import TextIOWrapper

from packaging.requirements import Requirement
from packaging.tags import Tag, parse_tag
from packaging.utils import BuildTag, NormalizedName, parse_wheel_filename
from packaging.version import Version

from . import dependencies, external_commands, metrics, resolver
from . import dependencies, external_commands, metrics, resolver, wheels
from .requirements_file import RequirementType

if typing.TYPE_CHECKING:
Expand All @@ -37,6 +42,53 @@
)


class _InstallationWheelView:
"""Reuse links within one worker, refreshing selection before each install."""

def __init__(self, work_dir: pathlib.Path) -> None:
self._temporary = tempfile.TemporaryDirectory(
prefix="install-wheels-", dir=work_dir
)
self.path = pathlib.Path(self._temporary.name)
self._links: dict[str, pathlib.Path] = {}
self._identities: dict[
str, tuple[NormalizedName, Version, BuildTag, frozenset[Tag]]
] = {}

def update(self, ctx: context.WorkContext) -> None:
"""Refresh changed links without caching settings or hook results."""
desired: dict[str, pathlib.Path] = {}
seen: set[str] = set()
for source in (ctx.wheels_downloads, ctx.wheels_prebuilt):
for path in sorted(source.glob("*.whl")):
seen.add(path.name)
identity = self._identities.get(path.name)
if identity is None:
identity = parse_wheel_filename(path.name)
self._identities[path.name] = identity
name, version, actual_tag, wheel_tags = identity
pbi = ctx.package_build_info(name)
if not pbi.pre_built and pbi.get_build_number(version) is not None:
expected = wheels.get_build_tag(
ctx=ctx,
req=Requirement(name),
version=version,
wheel_tags=wheel_tags,
)
if actual_tag != expected:
continue
desired.setdefault(path.name, path)
for filename, target in self._links.items():
if desired.get(filename) != target:
(self.path / filename).unlink()
for filename, target in desired.items():
if self._links.get(filename) != target:
(self.path / filename).symlink_to(target)
self._links = desired
for filename in self._identities.keys() - seen:
del self._identities[filename]


class MissingDependency(Exception): # noqa: N818
def __init__(
self,
Expand Down Expand Up @@ -212,6 +264,30 @@ def _createenv(self) -> None:
)
logger.info("created build environment in %s", self.path)

@contextlib.contextmanager
def _installation_wheel_args(self, explicit: bool) -> typing.Iterator[list[str]]:
"""Expose permitted local builds, or preserve legacy source arguments."""
if not explicit:
yield self._ctx.pip_wheel_server_args
return

view: _InstallationWheelView | None = getattr(
self._ctx._installation_wheel_views, "view", None
)
if view is None:
logger.info(
"Explicit build numbers in loaded settings enable local-only "
"dependency installation for all packages, including unrelated "
"versions. --wheel-server-url is not used for installation; "
"all required wheels must be available locally."
)
view = _InstallationWheelView(self._ctx.work_dir)
self._ctx._installation_wheel_views.view = view
view.update(self._ctx)
# uv treats build tags as preferences, not constraints. Keep other
# builds out of its candidate set, including its installed/cache state.
yield ["--no-index", "--offline", "--find-links", str(view.path), "--reinstall"]

def install(self, reqs: typing.Iterable[Requirement]) -> None:
if not reqs:
return
Expand All @@ -227,20 +303,48 @@ def install(self, reqs: typing.Iterable[Requirement]) -> None:
":all:",
]
cmd.extend(self._ctx.pip_constraint_args)
cmd.extend(self._ctx.pip_wheel_server_args)
cmd.extend(str(req) for req in reqs)

self.run(
cmd,
cwd=str(self.path.parent),
network_isolation=False,
)
explicit = self._ctx.settings.has_explicit_build_numbers
with self._installation_wheel_args(explicit) as source_args:
cmd.extend(source_args)
cmd.extend(str(req) for req in reqs)

self.run(
cmd,
cwd=str(self.path.parent),
network_isolation=False,
)
if explicit:
self._validate_installed_build_numbers()
logger.info(
"installed dependencies %s into build environment in %s",
reqs,
self.path,
)

def _validate_installed_build_numbers(self) -> None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Nit] — src/fromager/build_environment.py (line 291, _validate_installed_build_numbers) vs. get_distributions()

The new validation re-implements the existing get_distributions() introspection inline — a second, slightly different copy of the same mechanism.

The inline python -c one-liner (json.dump([(d.metadata['Name'], d.version, d.read_text('WHEEL')) for d in distributions()], sys.stdout)) is a near-copy of get_distributions() a few lines away (json.dump({d.name: d.version for d in distributions()}, sys.stdout)) — same trick, different shape, no shared helper. Running a python -c snippet and JSON-decoding the result is an established pattern in this file, so the style itself is fine. The problem is having two divergent copies: a future fix applied to one — JSON error handling (the existing one wraps json.loads with logging on failure; the new one doesn't), or the d.metadata['Name'] vs d.name canonicalization difference — will silently miss the other.

One small helper returning (name, version, wheel_text) tuples serves both call sites:

Suggested change
def _validate_installed_build_numbers(self) -> None:
def _installed_distributions(self) -> list[tuple[str, str, str | None]]:
"""Return (name, version, WHEEL metadata text) for each installed dist."""
script = (
"import json,sys; from importlib.metadata import distributions; "
"json.dump([(d.metadata['Name'], d.version, d.read_text('WHEEL')) "
"for d in distributions()], sys.stdout)"
)
return json.loads(self.run([str(self.python), "-c", script]))

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See my reply on the installation policy. I’m leaning toward deferring exact dependency-installation enforcement, which would remove _validate_installed_build_numbers() and this duplication from the PR.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented the shared _installed_distributions() helper in 09fb462. Both callers now use the same name/version/WHEEL introspection, with JSON-decoding diagnostics in one place. get_distributions() preserves its sorted name-to-Version mapping.

"""Fail before compilation if direct references bypass the filtered source."""
for name, raw_version, raw_wheel in self._installed_distributions():
version = Version(raw_version)
pbi = self._ctx.package_build_info(name)
if pbi.pre_built or pbi.get_build_number(version) is None:
continue
metadata = Parser().parsestr(raw_wheel or "")
tags = frozenset(
tag for value in metadata.get_all("Tag", []) for tag in parse_tag(value)
)
expected = wheels.get_build_tag(
ctx=self._ctx, req=Requirement(name), version=version, wheel_tags=tags
)
assert expected # Explicit counters, including zero, are non-empty.
expected_text = f"{expected[0]}{expected[1]}"
match = re.fullmatch(r"([0-9]+)(.*)", metadata.get("Build") or "")
actual: BuildTag = (int(match[1]), match[2]) if match else ()
if not tags or actual != expected:
raise ValueError(
f"{name}=={version}: installed wheel build {metadata.get('Build')!r} "
f"does not match explicit build {expected_text!r}"
)

def get_sysconfig_path(self, name: str) -> str:
"""Get ``sysconfig.get_path(name)`` in environment"""
if not name.isidentifier():
Expand All @@ -255,18 +359,25 @@ def get_sysconfig_path(self, name: str) -> str:

def get_distributions(self) -> typing.Mapping[str, Version]:
"""Get a mapping of installed package names and their versions in the build env"""
lines = [
"import sys,json",
"from importlib.metadata import distributions",
"json.dump({d.name: d.version for d in distributions()}, sys.stdout)",
]
result = self.run([str(self.python), "-c", "; ".join(lines)])
mapping = {
name: Version(version)
for name, version, _ in self._installed_distributions()
}
return dict(sorted(mapping.items()))

def _installed_distributions(self) -> list[tuple[str, str, str | None]]:
"""Read distribution identities and wheel metadata in the build env."""
script = (
"import json,sys; from importlib.metadata import distributions; "
"json.dump([(d.name, d.version, d.read_text('WHEEL')) "
"for d in distributions()], sys.stdout)"
)
result = self.run([str(self.python), "-c", script])
try:
mapping = json.loads(result.strip())
except Exception:
return typing.cast(list[tuple[str, str, str | None]], json.loads(result))
except json.JSONDecodeError:
logger.exception("failed to de-serialize JSON: %s", result)
raise
return {name: Version(version) for name, version in sorted(mapping.items())}


@metrics.timeit(description="prepare build environment")
Expand Down
24 changes: 14 additions & 10 deletions src/fromager/commands/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -547,16 +547,20 @@ def _is_wheel_built(
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}"
)
explicit = pbi.get_build_number(candidate_version) is not None
# An explicit zero is not an untagged wheel. Higher counters are
# cache misses for explicit settings, just as in bootstrap.
if explicit:
existing_build_tag = build_tag_from_name
else:
existing_build_tag = build_tag_from_name or (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",
Expand Down
2 changes: 2 additions & 0 deletions src/fromager/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ def __init__(
self.time_description_store: dict[str, str] = {}

self._parallel_builds = False
# Each worker reuses its own filtered dependency wheel view.
self._installation_wheel_views = threading.local()

self.cooldown: candidate.Cooldown | None = cooldown
self._max_release_age: datetime.timedelta | None = max_release_age
Expand Down
Loading
Loading