Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
dbd9afc
fix(sources): normalize sdist filenames per PEP 625
jlarkin09 Sep 15, 2026
a8609e5
fix(dependencies): sort requirements files
mprpic Sep 15, 2026
a392a82
Merge pull request #1330 from mprpic/fix/sort-requirements-files
mergify[bot] Sep 15, 2026
358253c
fix(sources): break long line to meet 88-character limit
jlarkin09 Sep 17, 2026
0883ce2
Merge pull request #1328 from jlarkin09/fix/sdist-pep625-normalizatio…
LalatenduMohanty Sep 17, 2026
a70ff9c
fix(resolver): warn when a version is skipped due to missing sdist
ryanpetrello Sep 18, 2026
82e1250
Merge pull request #1333 from ryanpetrello/warn-missing-sdist
mergify[bot] Sep 18, 2026
e23ed8a
fix(bootstrap): forget seen-markers of cascade-removed dependencies
andre-motta Sep 18, 2026
9335af1
fix(bootstrap): warm the resolver cache before adding top-level nodes
andre-motta Sep 18, 2026
e406f29
Merge branch 'main' into fix-orphan-seen-keys
mergify[bot] Sep 21, 2026
62279db
Merge pull request #1337 from andre-motta/fix-orphan-seen-keys
mergify[bot] Sep 21, 2026
be23b79
Merge branch 'main' into fix-toplevel-stale-node
mergify[bot] Sep 21, 2026
9447d99
Merge pull request #1336 from andre-motta/fix-toplevel-stale-node
mergify[bot] Sep 21, 2026
ce3e7a9
fix(tests): use .test TLD instead of example.com in bootstrapper test
LalatenduMohanty Sep 21, 2026
07abc98
Merge pull request #1340 from LalatenduMohanty/fix-test-url-convention
mergify[bot] Sep 21, 2026
0c17ead
fix(resolver): suppress cooldown warning for localhost indexes
ryanpetrello Sep 22, 2026
130a08f
Merge pull request #1342 from ryanpetrello/fix/suppress-localhost-coo…
LalatenduMohanty Sep 22, 2026
bfcb74b
fix(resolver): warn when cooldown blocks versions
ryanpetrello Sep 22, 2026
e724cb4
Merge pull request #1343 from ryanpetrello/warn-cooldown-blocked
mergify[bot] Sep 22, 2026
38cf13a
build(deps): bump dtolnay/rust-toolchain
dependabot[bot] Sep 26, 2026
77b7bc7
test(bootstrapper): prevent unintended background downloads
booxter Sep 27, 2026
7f3e746
fix(context): handle concurrent directory creation
booxter Sep 27, 2026
cc3b9c4
Merge pull request #1348 from booxter/sandbox-test-retries
mergify[bot] Sep 28, 2026
8c44b4c
fix(sources): use correct top-level directory name in sdists
jlarkin09 Sep 28, 2026
20999a0
feat(wheels): separate cache wheel resolution from pre-built path
smoparth May 12, 2026
db8a93b
Merge pull request #1141 from python-wheel-build/fix/disable-cooldown…
mergify[bot] Sep 28, 2026
d208942
Merge pull request #1347 from python-wheel-build/dependabot/github_ac…
LalatenduMohanty Sep 29, 2026
1d07f30
feat(wheels): add configurable build tag hook for wheel filenames
jlarkin09 Sep 29, 2026
6917358
Merge pull request #1273 from jlarkin09/feat/wheel-build-tag-hook-1181
mergify[bot] Sep 29, 2026
27db2d6
Merge pull request #1349 from booxter/fromager-test-race-mkdir
LalatenduMohanty Sep 29, 2026
7912bde
fix(bootstrap): Remove dead cache-server fallback
jenlevy Sep 24, 2026
e263ce9
fix(resolver): log wheel-only candidate skips at INFO
ryanpetrello Oct 2, 2026
3d0038c
Merge pull request #1354 from ryanpetrello/fix/wheel-only-log-level
mergify[bot] Oct 2, 2026
0a92663
build(deps): bump dtolnay/rust-toolchain
dependabot[bot] Oct 3, 2026
a2c053d
fix(deps): read pyproject.toml with tomllib instead of tomlkit
tiran Oct 5, 2026
1e3c39d
Merge pull request #1357 from tiran/fix/pyproject-tomllib-read
LalatenduMohanty Oct 5, 2026
7db7b5a
fix(build): reuse pre-built wheels that carry a build number
smoparth Oct 5, 2026
dd034d8
Merge pull request #1359 from python-wheel-build/fix/prebuilt-ignore-…
mergify[bot] Oct 5, 2026
d1f7f02
Merge pull request #1355 from python-wheel-build/dependabot/github_ac…
LalatenduMohanty Oct 5, 2026
7c4954d
Merge pull request #1346 from jenlevy/AIPCC-29282
LalatenduMohanty Oct 5, 2026
5f5bbd9
Merge pull request #1329 from jlarkin09/fix/sdist-toplevel-directory-…
jlarkin09 Oct 6, 2026
c4ccc98
fix(sources): log PKG-INFO stub creation at INFO
ryanpetrello Oct 7, 2026
3d1b232
Merge pull request #1363 from ryanpetrello/fix/pkg-info-log-level
mergify[bot] Oct 7, 2026
31771eb
build(deps): bump dtolnay/rust-toolchain
dependabot[bot] Oct 10, 2026
f699f2f
Merge pull request #1365 from python-wheel-build/dependabot/github_ac…
LalatenduMohanty Oct 13, 2026
5eae3b4
chore(sync): merge main into new-resolver-config
LalatenduMohanty Oct 13, 2026
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
4 changes: 2 additions & 2 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ jobs:
python-version: ${{ matrix.python-version }}

- name: Set up Rust ${{ matrix.rust-version }}
uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master
uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # master
with:
toolchain: ${{ matrix.rust-version }}

Expand Down Expand Up @@ -117,7 +117,7 @@ jobs:
python-version: ${{ matrix.python-version }}

- name: Set up Rust ${{ matrix.rust-version }}
uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master
uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # master
with:
toolchain: ${{ matrix.rust-version }}

Expand Down
12 changes: 11 additions & 1 deletion docs/concepts/hooks-and-overrides.rst
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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.
21 changes: 11 additions & 10 deletions docs/concepts/resolver-architecture.rst
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,6 @@ priority order and accumulates results in a thread-safe session cache:
│ ▼ │
│ 3. Network (PyPI / GitHub / GitLab) │
│ (per-package configurable) │
│ │ age filter empties result │
│ ▼ │
│ 4. Cache server fallback │
│ (multiple_versions mode only) │
└───────────────────────────────────────────────────────┘

Git URL requirements (top-level only) bypass this
Expand All @@ -44,6 +40,10 @@ Versions discovered from any source are merged into the session cache.
Subsequent requests for the same package skip the network entirely if
the cache already contains a matching version.

The configured wheel cache server is not a version-resolution fallback.
After a version has been selected, the bootstrapper checks the cache
server separately for a previously built wheel.

Provider Hierarchy
------------------

Expand Down Expand Up @@ -73,8 +73,8 @@ CLI commands interact with providers through a common
Filters by platform tags, Python version, and yanked status.
* - ``PyPICacheProvider``
- Subclass of ``PyPIProvider`` pointing at fromager's own wheel
server. No cooldown applied. Used as a fallback when age
filtering eliminates all candidates.
server. No cooldown applied. Used by the bootstrapper's
previously-built-wheel lookup, not by source version resolution.
* - ``GenericProvider``
- Callback-based provider that pairs a version source function
with a configurable match function (plain parse or regex).
Expand Down Expand Up @@ -131,10 +131,11 @@ Two age-based filters can narrow the set of acceptable versions:
``multiple_versions`` mode to limit the range of versions built.

When both are active, only versions published within the window are
considered. If all candidates are filtered out, the behavior depends
on the mode: in single-version mode a warning is logged and all
candidates are kept; in ``multiple_versions`` mode the cache server
fallback is tried instead.
considered. If all source candidates are filtered out, the behavior
depends on the mode: in single-version mode a warning is logged and all
candidates are kept; in ``multiple_versions`` mode only the newest
candidate is retained. A source provider with no matching candidates
raises a resolution error; it is not replaced by a cache-server lookup.

Flat Resolution by Design
-------------------------
Expand Down
72 changes: 72 additions & 0 deletions docs/customization.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions docs/reference/config-reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
64 changes: 7 additions & 57 deletions src/fromager/bootstrap_requirement_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from packaging.utils import NormalizedName, canonicalize_name
from packaging.version import Version

from . import finders, resolver, sources, wheels
from . import resolver, sources, wheels
from .dependency_graph import DependencyGraph
from .requirements_file import RequirementType

Expand Down Expand Up @@ -43,24 +43,21 @@ def __init__(
ctx: context.WorkContext,
prev_graph: DependencyGraph | None = None,
multiple_versions: bool = False,
cache_wheel_server_url: str = "",
) -> None:
"""Initialize requirement resolver.

Args:
ctx: Work context with constraints and settings
prev_graph: Optional previous dependency graph for caching
multiple_versions: If ``True`` and no results are found through
any other approach, takes the latest candidate from the
cache server, ignoring the age filters. In all other
cases, returns an empty list when no candidates are found.
cache_wheel_server_url: URL of the remote wheel cache server.
Used as a fallback when age filtering produces no candidates.
multiple_versions: If ``True``, keep the newest matching source
version when the age filter removes all candidates. If
``False``, keep all matching versions in that case. If no
source version matches the requirement, resolution raises an
error.
"""
self.ctx = ctx
self.prev_graph = prev_graph
self.multiple_versions = multiple_versions
self.cache_wheel_server_url = cache_wheel_server_url
# All known versions for a package, accumulated across resolution
# contexts. Versions discovered via different specifiers or req_types
# are merged so that later lookups see the widest set.
Expand Down Expand Up @@ -211,19 +208,9 @@ def _resolve_and_extend(
age_fallback=age_fallback,
)

if not results and self.multiple_versions and self.cache_wheel_server_url:
logger.info(
"no results found with normal resolution, "
"falling back to the cache server %s",
self.cache_wheel_server_url,
)
results = self._resolve_from_cache_server(req)

if not results:
logger.warning(
"resolver returned no results "
"(wheel server URL %s, %s version mode)",
self.cache_wheel_server_url or "(none)",
"resolver returned no results (%s version mode)",
"multiple" if self.multiple_versions else "single",
)

Expand All @@ -235,43 +222,6 @@ def _resolve_and_extend(
versions[version] = url
self._resolved_rules.add((str(req), pre_built))

def _resolve_from_cache_server(self, req: Requirement) -> list[tuple[str, Version]]:
"""Fall back to the remote wheel cache server for a cached version.

When age filtering removes all candidates in multi-version mode,
queries the remote cache server for the newest available wheel.
Returns at most one version so that transitive dependencies are
re-processed without rebuilding every old version.
"""
logger.info(
"checking cache server %s for existing build",
self.cache_wheel_server_url,
)
best: tuple[str, Version] | None = None
for include_sdists, include_wheels in [(False, True), (True, False)]:
try:
provider = finders.PyPICacheProvider(
cache_server_url=self.cache_wheel_server_url,
constraints=self.ctx.constraints,
include_sdists=include_sdists,
include_wheels=include_wheels,
)
results = resolver.find_all_matching_from_provider(provider, req)
if results:
url, version = results[0]
if best is None or version > best[1]:
best = (url, version)
except Exception as err:
logger.warning(
"error checking cache server %s: %s",
self.cache_wheel_server_url,
err,
)
if best is not None:
logger.info("found version %s on cache server", best[1])
return [best]
return []

def get_matching_versions(
self,
req: Requirement,
Expand Down
44 changes: 37 additions & 7 deletions src/fromager/bootstrapper/_bootstrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,6 @@ def __init__(
ctx=ctx,
prev_graph=prev_graph,
multiple_versions=multiple_versions,
cache_wheel_server_url=self.cache_wheel_server_url,
)
# Push items onto the stack as we start to resolve their
# dependencies so at the end we have a list of items that need to
Expand Down Expand Up @@ -266,6 +265,25 @@ def bootstrap(self, requirements: list[Requirement]) -> None:
Args:
requirements: Top-level requirements to resolve and bootstrap.
"""
# Warm the resolver cache with every top-level rule before adding any
# of them to the graph: a later rule for the same package (e.g. an
# exact pin that bypasses the release-age cooldown) changes what an
# earlier rule resolves to. Errors are reported by
# _resolve_and_add_top_level below.
for req in requirements:
with req_ctxvar_context(req):
try:
self.resolve_versions(
req=req,
req_type=RequirementType.TOP_LEVEL,
parent_req=None,
return_all_versions=self.multiple_versions,
)
except Exception as err:
logger.debug(
"deferring resolution error to top-level handling: %s", err
)

# Resolve all top-level reqs and build initial stack.
# Use the token pattern (no try/finally) so that if resolution raises
# in normal mode, the context var stays set for the top-level error
Expand Down Expand Up @@ -642,6 +660,16 @@ def mark_as_seen(
# Mark wheel seen only for wheel build
self._seen_requirements.add(self._resolved_key(req, version, "wheel"))

def _forget_seen(self, name: NormalizedName, version: Version) -> None:
"""Drop every seen-marker for name==version, whatever extras or type."""
version_str = str(version)
stale = {
key
for key in self._seen_requirements
if key[0] == name and key[2] == version_str
}
self._seen_requirements.difference_update(stale)

def has_been_seen(
self,
req: Requirement,
Expand Down Expand Up @@ -1022,13 +1050,15 @@ def _handle_phase_error(
err,
f"failed during {type(item).phase} phase",
)
self.ctx.dependency_graph.remove_dependency(pkg_name, wi.resolved_version)
self._seen_requirements.discard(
self._resolved_key(wi.req, wi.resolved_version, "sdist")
)
self._seen_requirements.discard(
self._resolved_key(wi.req, wi.resolved_version, "wheel")
removed_nodes = self.ctx.dependency_graph.remove_dependency(
pkg_name, wi.resolved_version
)
# The failed version may not be in the graph yet. Descendants
# removed with it lost their edges; forget them too so a later
# encounter goes through PrepareSource again.
self._forget_seen(pkg_name, wi.resolved_version)
for node in removed_nodes:
self._forget_seen(node.canonicalized_name, node.version)
self._write_graph_async()
return []

Expand Down
Loading
Loading