Skip to content

Commit a181bfa

Browse files
vshawrhclaude
andcommitted
docs(proposals): add global build-system dependencies hook proposal
Document the motivation, design, hook signature, chaining behavior, execution order, and interaction with existing mechanisms for the new get_build_system_dependencies global hook point. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Vikash Shaw <vshaw@redhat.com>
1 parent 2ffbe2d commit a181bfa

1 file changed

Lines changed: 360 additions & 0 deletions

File tree

Lines changed: 360 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,360 @@
1+
# Global hook for build-system dependency post-processing
2+
3+
- Author: Vikash Shaw
4+
- Created: 2026-07-24
5+
- Status: Proposed
6+
- GitHub issue: [#1263](https://github.com/python-wheel-build/fromager/issues/1263)
7+
- GitHub PR: [#1271](https://github.com/python-wheel-build/fromager/pull/1271)
8+
9+
## What
10+
11+
Add `get_build_system_dependencies` as a new global hook point under
12+
`fromager.hooks`. This allows downstream projects to register hooks that
13+
post-process the build-system dependencies list for **all** packages,
14+
without writing per-package plugins.
15+
16+
## Why
17+
18+
Fromager provides two extension mechanisms for customizing build behavior:
19+
20+
1. **Per-package plugins** (`fromager.project_overrides`): Override a
21+
specific hook for a single package. Registered via entry points keyed
22+
by the canonicalized package name. When present, the plugin **replaces**
23+
the default implementation entirely (`overrides.find_and_invoke` uses
24+
one or the other, never both).
25+
26+
2. **Global hooks** (`fromager.hooks`): Run for every package. Currently
27+
support `post_build`, `post_bootstrap`, and `prebuilt_wheel`. These
28+
are event callbacks that fire after an action has completed.
29+
30+
There is a gap: no global hook runs **during** dependency resolution.
31+
When a cross-cutting concern affects build dependencies for many
32+
packages, the only option today is to write identical per-package plugins
33+
for each affected package.
34+
35+
### The setuptools problem
36+
37+
A concrete example motivating this proposal: setuptools 81 removed
38+
`distutils.spawn(dry_run=...)` and `remove_tree(dry_run=...)`.
39+
setuptools 82 removed `pkg_resources` entirely. Many PyPI packages
40+
still reference these removed APIs in their `setup.py`, causing build
41+
failures when Fromager resolves an uncapped setuptools.
42+
43+
In one downstream builder, this required **22 identical per-package
44+
plugins** that each do the same thing: scan `setup.py` with `ast.parse`,
45+
detect usage of removed APIs, and append a setuptools version cap to the
46+
build dependencies. Each plugin is 24 lines of boilerplate that calls
47+
a shared utility function.
48+
49+
```
50+
package_plugins/albucore.py # identical
51+
package_plugins/antlr4_python3_runtime.py # identical
52+
package_plugins/blessed.py # identical
53+
... (19 more)
54+
```
55+
56+
Every time a new package triggers the same setuptools incompatibility, a
57+
new identical plugin must be added. This does not scale.
58+
59+
### Why not `update_build_requires`?
60+
61+
Fromager's YAML settings support `update_build_requires` for statically
62+
adding build dependencies. However, the setuptools cap is conditional:
63+
it depends on what APIs the package's `setup.py` actually uses. A static
64+
YAML entry would either over-constrain (cap all packages) or require
65+
per-package entries (same maintenance burden as plugins).
66+
67+
### Why not put this in Fromager core?
68+
69+
The initial approach (PR [#1264](https://github.com/python-wheel-build/fromager/pull/1264))
70+
proposed adding setuptools-capping logic directly into
71+
`default_get_build_system_dependencies`. Feedback from the maintainer
72+
(Doug Hellmann) identified that this is too opinionated for core:
73+
74+
> *"This feels like something that a user might want, or not, based on
75+
> what packages they are building or some other context about their
76+
> build. What if, instead of putting this in core at all, we introduced
77+
> the idea of 'global' plugins for 'fixing' requirements?"*
78+
79+
The global hook approach keeps Fromager generic and lets downstream
80+
projects opt into whatever dependency-fixing logic they need.
81+
82+
## Goals
83+
84+
- Extend the existing `fromager.hooks` system with a
85+
`get_build_system_dependencies` hook point
86+
- Allow multiple hooks to chain (output of one feeds into the next)
87+
- Preserve backward compatibility: per-package plugins still take
88+
full precedence
89+
- Follow the existing stevedore-based hook pattern used by `post_build`,
90+
`post_bootstrap`, and `prebuilt_wheel`
91+
92+
## Non-goals
93+
94+
- Adding setuptools-capping logic to Fromager core. That belongs in
95+
downstream hook implementations.
96+
- Replacing per-package plugins. Packages with truly custom build
97+
dependency logic should still use `fromager.project_overrides`.
98+
- Adding global hooks for `get_build_backend_dependencies` or
99+
`get_build_sdist_dependencies`. These can be added later if needed,
100+
following the same pattern.
101+
102+
## How
103+
104+
### Execution order
105+
106+
The hook runs inside `dependencies.get_build_system_dependencies()`,
107+
after the per-package override (or default) returns and before marker
108+
filtering:
109+
110+
```
111+
1. Check for cached requirements file (early return if exists)
112+
2. overrides.find_and_invoke() <-- per-package plugin or default
113+
3. hooks.run_get_build_system_dependencies_hooks() <-- NEW: global hooks
114+
4. _filter_requirements() <-- marker evaluation
115+
5. Write requirements cache file
116+
```
117+
118+
This ordering means:
119+
120+
- Per-package plugins produce the initial dependency list. If a package
121+
has a custom `get_build_system_dependencies` override, the global hook
122+
receives that override's output.
123+
- If no per-package plugin exists, the default implementation reads
124+
`[build-system] requires` from `pyproject.toml`, and the global hook
125+
receives that.
126+
- Global hooks can add, remove, or modify entries in the list.
127+
- Marker filtering happens last, so hooks do not need to evaluate
128+
environment markers themselves.
129+
- The result is cached to `build-system-requirements.txt`, so hooks run
130+
only once per package per build.
131+
132+
### Hook signature
133+
134+
```python
135+
def get_build_system_dependencies(
136+
*,
137+
ctx: context.WorkContext,
138+
req: Requirement,
139+
sdist_root_dir: pathlib.Path,
140+
build_dir: pathlib.Path,
141+
requirements: list[str],
142+
) -> list[str]:
143+
"""Post-process build-system dependencies for a package.
144+
145+
Args:
146+
ctx: The current work context (variant, settings, paths).
147+
req: The requirement being built.
148+
sdist_root_dir: Root directory of the unpacked sdist.
149+
build_dir: The build directory within the sdist.
150+
requirements: Current list of build-system requirement strings.
151+
152+
Returns:
153+
A (possibly modified) list of requirement strings.
154+
"""
155+
...
156+
```
157+
158+
Parameters:
159+
160+
| Parameter | Type | Description |
161+
| --- | --- | --- |
162+
| `ctx` | `context.WorkContext` | Build context with variant, settings, and paths |
163+
| `req` | `Requirement` | The package requirement being processed |
164+
| `sdist_root_dir` | `pathlib.Path` | Root of the unpacked source distribution |
165+
| `build_dir` | `pathlib.Path` | Build directory (may differ from sdist root) |
166+
| `requirements` | `list[str]` | Current build-system requirements as strings |
167+
168+
The hook **must** return a `list[str]`. It receives the output of the
169+
previous hook (or the initial requirements if it is the first hook).
170+
171+
### Chaining
172+
173+
When multiple hooks are registered, they are chained. The output of one
174+
becomes the `requirements` input of the next:
175+
176+
```
177+
initial requirements
178+
|
179+
v
180+
hook_a(requirements=[...]) -> [... + extra_a]
181+
|
182+
v
183+
hook_b(requirements=[... + extra_a]) -> [... + extra_a + extra_b]
184+
|
185+
v
186+
final requirements
187+
```
188+
189+
Hook execution order follows stevedore's `HookManager` iteration order
190+
(alphabetical by entry point name).
191+
192+
### Registration
193+
194+
Hooks are registered as entry points under the `fromager.hooks`
195+
namespace, with the name `get_build_system_dependencies`:
196+
197+
```toml
198+
# In the downstream project's pyproject.toml
199+
[project.entry-points."fromager.hooks"]
200+
get_build_system_dependencies = "my_package.hooks:get_build_system_dependencies"
201+
```
202+
203+
This uses the same stevedore `HookManager` infrastructure as the
204+
existing `post_build`, `post_bootstrap`, and `prebuilt_wheel` hooks.
205+
206+
### Implementation details
207+
208+
Two files are modified in Fromager:
209+
210+
**`src/fromager/hooks.py`:**
211+
212+
- Add `"get_build_system_dependencies"` to `GLOBAL_HOOK_NAMES`
213+
- Add `run_get_build_system_dependencies_hooks()` function that iterates
214+
over registered hooks, chaining the requirements list through each
215+
216+
```python
217+
def run_get_build_system_dependencies_hooks(
218+
ctx: context.WorkContext,
219+
req: Requirement,
220+
sdist_root_dir: pathlib.Path,
221+
build_dir: pathlib.Path,
222+
requirements: list[str],
223+
) -> list[str]:
224+
hook_mgr = _get_hooks("get_build_system_dependencies")
225+
for ext in hook_mgr:
226+
requirements = ext.plugin(
227+
ctx=ctx,
228+
req=req,
229+
sdist_root_dir=sdist_root_dir,
230+
build_dir=build_dir,
231+
requirements=requirements,
232+
)
233+
return requirements
234+
```
235+
236+
**`src/fromager/dependencies.py`:**
237+
238+
- Import `hooks` module
239+
- Call `hooks.run_get_build_system_dependencies_hooks()` after
240+
`overrides.find_and_invoke()` returns, before `_filter_requirements()`
241+
242+
### Example: setuptools constraint hook
243+
244+
This is the downstream hook that would replace the 22 identical plugins
245+
in the downstream builder:
246+
247+
```python
248+
import ast
249+
import logging
250+
import pathlib
251+
252+
from fromager import context
253+
from packaging.requirements import Requirement
254+
255+
logger = logging.getLogger(__name__)
256+
257+
258+
def get_build_system_dependencies(
259+
*,
260+
ctx: context.WorkContext,
261+
req: Requirement,
262+
sdist_root_dir: pathlib.Path,
263+
build_dir: pathlib.Path,
264+
requirements: list[str],
265+
) -> list[str]:
266+
"""Auto-cap setuptools for packages using removed APIs."""
267+
constraint = _get_setuptools_constraint(build_dir)
268+
if constraint:
269+
logger.info("%s: adding %s", req.name, constraint)
270+
requirements = requirements + [constraint]
271+
return requirements
272+
273+
274+
def _get_setuptools_constraint(sdist_root_dir: pathlib.Path) -> str | None:
275+
setup_py = sdist_root_dir / "setup.py"
276+
if not setup_py.is_file():
277+
return None
278+
279+
try:
280+
tree = ast.parse(setup_py.read_text())
281+
except SyntaxError:
282+
return None
283+
284+
has_pkg_resources = False
285+
has_dry_run = False
286+
287+
for node in ast.walk(tree):
288+
# Detect: import pkg_resources / from pkg_resources import ...
289+
if isinstance(node, ast.Import):
290+
for alias in node.names:
291+
if alias.name == "pkg_resources" or alias.name.startswith(
292+
"pkg_resources."
293+
):
294+
has_pkg_resources = True
295+
elif isinstance(node, ast.ImportFrom):
296+
if node.module and (
297+
node.module == "pkg_resources"
298+
or node.module.startswith("pkg_resources.")
299+
):
300+
has_pkg_resources = True
301+
# Detect: dry_run keyword argument
302+
elif isinstance(node, ast.keyword):
303+
if node.arg == "dry_run":
304+
has_dry_run = True
305+
306+
if has_dry_run:
307+
return "setuptools<81"
308+
if has_pkg_resources:
309+
return "setuptools<82"
310+
return None
311+
```
312+
313+
## Interaction with existing mechanisms
314+
315+
| Mechanism | Scope | Relationship to global hooks |
316+
| --- | --- | --- |
317+
| `update_build_requires` (YAML) | Per-package, static | Runs during `prepare_source` (before `get_build_system_dependencies`). Global hooks see the result. |
318+
| `remove_build_requires` (YAML) | Per-package, static | Same as above. |
319+
| Per-package plugin (`fromager.project_overrides`) | Per-package, dynamic | Runs first via `overrides.find_and_invoke()`. Global hooks receive its output. |
320+
| Cached `build-system-requirements.txt` | Per-package | If the cache file exists, the function returns early. Global hooks do not run. |
321+
| **Global hooks (this proposal)** | All packages, dynamic | Runs after per-package plugin, before marker filtering. |
322+
323+
## Testing
324+
325+
The PR includes 8 new tests:
326+
327+
**In `tests/test_hooks.py`:**
328+
- `test_run_get_build_system_dependencies_hooks_calls_plugin`: Verifies correct arguments are passed to the hook
329+
- `test_run_get_build_system_dependencies_hooks_chains`: Verifies multiple hooks chain correctly (each receives the previous hook's output)
330+
- `test_run_get_build_system_dependencies_hooks_no_hooks`: Verifies no-op when no hooks are registered
331+
- `test_run_get_build_system_dependencies_hooks_exception_propagates`: Verifies exceptions from hooks are not swallowed
332+
333+
**In `tests/test_dependencies.py`:**
334+
- `test_get_build_system_dependencies_runs_global_hooks`: Integration test verifying the hook is called from `get_build_system_dependencies()` with the correct initial requirements list
335+
336+
## Future extensions
337+
338+
The same pattern can be applied to other dependency resolution hooks if
339+
needed:
340+
341+
- `get_build_backend_dependencies`: Post-process backend dependencies
342+
(from `get_requires_for_build_wheel`)
343+
- `get_build_sdist_dependencies`: Post-process sdist build dependencies
344+
- `get_install_dependencies_of_sdist`: Post-process install dependencies
345+
346+
Each would follow the same chaining pattern and execution order (after
347+
per-package override, before marker filtering).
348+
349+
## Limitations
350+
351+
- Hook execution order depends on stevedore's `HookManager` iteration,
352+
which is alphabetical by entry point name. If ordering between hooks
353+
matters, users must choose entry point names carefully.
354+
- Global hooks cannot prevent a per-package plugin from running. If a
355+
package has a `get_build_system_dependencies` override, the global
356+
hook receives that override's output, not the default pyproject.toml
357+
requires.
358+
- The cached requirements file (`build-system-requirements.txt`) is
359+
written after global hooks run. If a hook's behavior changes between
360+
runs, the cache must be cleared manually.

0 commit comments

Comments
 (0)