Skip to content
Open
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
188 changes: 188 additions & 0 deletions docs/proposals/global-build-system-dependencies-hook.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
# Global hook for build-system dependency post-processing

- Author: Vikash Shaw
- Created: 2026-07-24
- Status: Proposed
- GitHub issue: [#1263](https://github.com/python-wheel-build/fromager/issues/1263)
- Implementation PR: [#1271](https://github.com/python-wheel-build/fromager/pull/1271)
- Proposal PR: [#1272](https://github.com/python-wheel-build/fromager/pull/1272)

## What

This proposal suggests adding `get_build_system_dependencies` as a new
global hook point under `fromager.hooks`, so that downstream projects
can register hooks to post-process the build-system dependencies list
for all packages without needing per-package plugins.
Comment on lines +12 to +15

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Limit the documented hook scope.

src/fromager/bootstrapper/_prepare_source.py returns ProcessInstallDeps for prebuilt wheels before it calls dependencies.get_build_system_dependencies(). The proposed hook therefore does not run for every package. State that it applies to packages that enter the source-build dependency-resolution path.

Suggested wording
- for all packages without needing per-package plugins.
+ for all packages that require source-build dependency resolution without
+ needing per-package plugins.

As per path instructions, this is a factual scope correction, not a formatting suggestion.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
This proposal suggests adding `get_build_system_dependencies` as a new
global hook point under `fromager.hooks`, so that downstream projects
can register hooks to post-process the build-system dependencies list
for all packages without needing per-package plugins.
This proposal suggests adding `get_build_system_dependencies` as a new
global hook point under `fromager.hooks`, so that downstream projects
can register hooks to post-process the build-system dependencies list
for all packages that require source-build dependency resolution without
needing per-package plugins.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/proposals/global-build-system-dependencies-hook.md` around lines 12 -
15, Update the proposal’s description of the global
get_build_system_dependencies hook to state that it applies only to packages
entering the source-build dependency-resolution path, not every package;
preserve the intended post-processing behavior for build-system dependency
lists.

Source: Path instructions


## Why

Fromager currently provides two extension mechanisms:

1. **Per-package plugins** (`fromager.project_overrides`): Override a
hook for a single package. When present, the plugin replaces the
default implementation entirely.

2. **Global hooks** (`fromager.hooks`): Run for every package. Currently
support `post_build`, `post_bootstrap`, and `prebuilt_wheel`, which
are event callbacks that fire after an action has completed.

Currently, no global hook runs during dependency resolution. When a
cross-cutting concern affects build dependencies for many packages, the
only option today is to write identical per-package plugins for each one.

### Motivating example

setuptools 81 removed support for the `setup.py --dry-run` option and
changed some related distutils/setuptools signatures. setuptools 82
removed `pkg_resources` entirely. Many PyPI packages still reference
these removed APIs in their `setup.py`, causing build failures when
Fromager resolves an uncapped setuptools.

In one downstream project, this led to **22 identical per-package
plugins**, each scanning `setup.py` to detect removed API usage and
appending a setuptools version cap. Every time a new package hits the
same incompatibility, another identical plugin must be added. This does
not scale well.

### Why not `update_build_requires`?

Fromager's YAML settings support `update_build_requires` for statically
adding build dependencies. However, the setuptools cap is conditional
and depends on what APIs a given `setup.py` actually uses. A static YAML
entry would either over-constrain all packages or still require
per-package entries, which reduces but does not eliminate the maintenance
overhead.

## How

### Execution order

The hook runs inside `dependencies.get_build_system_dependencies()`,
after the per-package override (or default) returns and before marker
filtering:

```
1. Check for cached requirements file (early return if exists)
2. overrides.find_and_invoke() <-- per-package plugin or default
3. hooks.run_get_build_system_dependencies_hooks() <-- NEW
4. _filter_requirements() <-- marker evaluation
5. Write requirements cache file
```

Per-package plugins still produce the initial dependency list. Global
hooks can then augment it. Marker filtering happens last, so hooks do
not need to handle markers themselves. The result is cached, so hooks
run only once per package per build.

### Input materialization

`overrides.find_and_invoke()` returns `typing.Iterable[str]`. Before
invoking the first global hook, Fromager materializes this into a
`list[str]` (via `list(orig_deps)` in the implementation). This ensures
that hooks always receive a concrete list, so expressions like
`requirements + ["setuptools<82"]` work regardless of whether the
per-package plugin returned a generator, tuple, or set.

### Hook signature

```python
def get_build_system_dependencies(
*,
ctx: context.WorkContext,
req: Requirement,
sdist_root_dir: pathlib.Path,
build_dir: pathlib.Path,
requirements: list[str],
) -> list[str]:
Comment thread
coderabbitai[bot] marked this conversation as resolved.
...
```

The hook receives the current requirements list and must return a
(possibly modified) `list[str]`.

### Chaining and ordering

Unlike the existing global hooks (`post_build`, `post_bootstrap`,
`prebuilt_wheel`), which are fire-and-forget callbacks that return
nothing, this hook chains: each hook receives the previous hook's output
and returns a modified list.

Hook execution order follows stevedore's `HookManager` discovery order.
Because hooks chain, the order can matter if two hooks modify the same
requirement. Hooks should be designed to be order-independent where
possible (e.g., appending constraints rather than replacing entries).
Comment on lines +110 to +113

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major

Define a deterministic order for chained hooks.

The proposal documents stevedore HookManager discovery order. The supplied loader only discovers extensions; it does not establish a stable order. Because each hook receives the previous output, discovery-order changes can change requirements. Define a Fromager-controlled order or require order-independent hooks and test multiple-hook behavior. This repeats the previous ordering finding, which remains in the current text.

As per path instructions, this addresses a dependency-resolution contract, not a style issue.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/proposals/global-build-system-dependencies-hook.md` around lines 110 -
113, Update the hook execution-order section to define a deterministic
Fromager-controlled order for chained hooks, rather than relying on stevedore
HookManager discovery order. If no ordering can be guaranteed, explicitly
require hooks to be order-independent and add coverage for multiple hooks whose
outputs are chained. Remove or revise the existing contradictory statement that
only recommends order independence.

Source: Path instructions


### Error handling

If a hook raises an exception, the chain stops immediately and the
exception propagates up. The build for that package fails. Subsequent
hooks in the chain are not called. This matches the behavior of the
existing global hooks, where an exception in any hook is not swallowed.

### Cache invalidation

The cached `build-system-requirements.txt` is written after global hooks
run. If a global hook is installed, removed, or changed after a previous
build has cached requirements, the cached file must be cleared for the
hook to take effect. In practice, this means clearing the work directory
between builds when hook configuration changes.

### Registration

Hooks are registered as entry points under the `fromager.hooks`
namespace, the same way `post_build` and other existing hooks work:

```toml
[project.entry-points."fromager.hooks"]
get_build_system_dependencies = "my_package.hooks:get_build_system_dependencies"
```

The hook could also be released as a standalone installable package
(e.g. `fromager-setuptools-hook`) so any Fromager user can opt in by
simply installing it, without writing any hook code themselves.

### Example hook

A minimal hook that appends a constraint:

```python
def get_build_system_dependencies(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Note: Today's global hooks (post_build, post_bootstrap, prebuilt_wheel) are fire-and-forget — they return nothing, they don't chain, and execution order doesn't matter. The proposed get_build_system_dependencies hook fundamentally changes this: hooks receive the previous hook's output and return a modified list.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@rd4398 You are absolutely correct.

The stevedore hooks are event listener hooks. They are designed to act on events like "wheel is ready". The hooks are registered and enabled at installation time of a package. The hooks are not suited to change behavior.

*,
ctx: context.WorkContext,
req: Requirement,
sdist_root_dir: pathlib.Path,
build_dir: pathlib.Path,
requirements: list[str],
) -> list[str]:
# Inspect sdist content and conditionally add constraints
if needs_constraint(build_dir):
return requirements + ["setuptools<82"]
return requirements
```

## Interaction with existing mechanisms

| Mechanism | Scope | Relationship |
| -- | -- | -- |
| `update_build_requires` (YAML) | Per-package, static | Runs during `prepare_source`, before this hook. |
| `remove_build_requires` (YAML) | Per-package, static | Same as above. |
| Per-package plugin | Per-package, dynamic | Runs first. Global hooks receive its output. |
| Cached `build-system-requirements.txt` | Per-package | If cache exists, function returns early. Hooks do not run. |
| **Global hooks (this proposal)** | All packages, dynamic | Runs after per-package plugin, before marker filtering. |

## Alternatives considered

### Core logic in Fromager (PR [#1264](https://github.com/python-wheel-build/fromager/pull/1264))

An earlier approach proposed adding setuptools detection logic directly
into `default_get_build_system_dependencies`. This would have required
zero downstream changes, but was rejected because it makes Fromager
opinionated about a specific problem. Different downstream projects have
different needs, and the maintainer preferred keeping Fromager generic
with an opt-in hook mechanism instead.

The global hook approach is more general-purpose: it can be used for
any cross-cutting build dependency concern, not just setuptools capping.
The same hook point could also be extended to other dependency types
(`get_build_backend_dependencies`, `get_build_sdist_dependencies`) in
the future if there is interest.