Skip to content

Commit be670dd

Browse files
tiranclaude
andcommitted
feat: add protocol for override hooks
OverrideHookProtocol documents the interface for per-package override hooks and provides runtime validation of hook signatures via check_signature(). The lint command now checks override hook signatures. Co-Authored-By: Claude <claude@anthropic.com> Signed-off-by: Christian Heimes <cheimes@redhat.com>
1 parent 5fd5d20 commit be670dd

3 files changed

Lines changed: 337 additions & 0 deletions

File tree

src/fromager/commands/lint.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,5 +61,21 @@ def lint(
6161
errors += 1
6262
logger.error(f"ERROR: plugin name {name} should be {expected_name}")
6363

64+
logger.info("Checking override hook signatures...")
65+
hook_names = overrides.OverrideHookProtocol.list_hooks()
66+
for ext in exts:
67+
mod = ext.plugin
68+
for hook_name in hook_names:
69+
func = getattr(mod, hook_name, None)
70+
if func is None:
71+
continue
72+
try:
73+
overrides.OverrideHookProtocol.check_signature(
74+
func, hook_name=hook_name
75+
)
76+
except TypeError as e:
77+
errors += 1
78+
logger.error(f"ERROR: override {ext.name}.{hook_name}: {e}")
79+
6480
if errors:
6581
raise SystemExit(f"Found {errors} errors")

src/fromager/overrides.py

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
from __future__ import annotations
2+
3+
import importlib
14
import inspect
25
import logging
36
import pathlib
@@ -6,8 +9,14 @@
69

710
from packaging.requirements import Requirement
811
from packaging.utils import canonicalize_name
12+
from packaging.version import Version
913
from stevedore import extension
1014

15+
if typing.TYPE_CHECKING:
16+
from . import build_environment, context
17+
from .requirements_file import RequirementType
18+
from .resolver import BaseProvider
19+
1120
# An interface for reretrieving per-package information which influences
1221
# the build process for a particular package - i.e. for a given package
1322
# and build target, what patches should we apply, what environment variables
@@ -134,3 +143,266 @@ def find_override_method(distname: str, method: str) -> typing.Callable | None:
134143
return None
135144
logger.info("%s: found %s override", distname, method)
136145
return typing.cast(typing.Callable, getattr(mod, method))
146+
147+
148+
_F = typing.TypeVar("_F", bound=typing.Callable[..., typing.Any])
149+
150+
151+
def _default_hook(module: str, func: str) -> typing.Callable[[_F], _F]:
152+
"""Decorator that annotates a Protocol method with its default implementation.
153+
154+
Stores a ``fromager_default`` attribute as a ``(module, func)`` tuple
155+
on the decorated function so the mapping from hook name to default can
156+
be discovered at runtime.
157+
"""
158+
159+
def decorator(fn: _F) -> _F:
160+
fn.fromager_default = (module, func) # type: ignore[attr-defined]
161+
return fn
162+
163+
return decorator
164+
165+
166+
class OverrideHookProtocol(typing.Protocol):
167+
"""Protocol defining the interface for per-package override hooks.
168+
169+
Override modules may implement any subset of these methods to customize
170+
the build process for a specific package. See the default implementations
171+
for each hook's behavior when no override is provided.
172+
"""
173+
174+
@classmethod
175+
def list_hooks(cls) -> list[str]:
176+
"""Return a list of hook names defined on this Protocol."""
177+
return [
178+
name for name, obj in vars(cls).items() if hasattr(obj, "fromager_default")
179+
]
180+
181+
@classmethod
182+
def get_default(cls, hook_name: str) -> typing.Callable[..., typing.Any]:
183+
"""Return the default function object for a hook name."""
184+
obj = vars(cls).get(hook_name)
185+
if obj is None or not hasattr(obj, "fromager_default"):
186+
raise KeyError(hook_name)
187+
module_name, func_name = obj.fromager_default
188+
module = importlib.import_module(module_name)
189+
return typing.cast(typing.Callable[..., typing.Any], getattr(module, func_name))
190+
191+
@classmethod
192+
def check_signature(
193+
cls,
194+
func: typing.Callable[..., typing.Any],
195+
*,
196+
hook_name: str | None = None,
197+
) -> None:
198+
"""Check that a function's argument names match the protocol method.
199+
200+
Only argument names are compared; the check ignores whether arguments
201+
are positional or keyword-only because all hooks are called with
202+
keyword arguments.
203+
"""
204+
if hook_name is None:
205+
hook_name = func.__name__
206+
proto_method = vars(cls).get(hook_name)
207+
if proto_method is None or not hasattr(proto_method, "fromager_default"):
208+
raise KeyError(hook_name)
209+
proto_spec = inspect.getfullargspec(proto_method)
210+
# Skip 'self' (first parameter of a protocol method)
211+
expected_args = set(proto_spec.args[1:] + proto_spec.kwonlyargs)
212+
func_spec = inspect.getfullargspec(func)
213+
func_args = set(func_spec.args + func_spec.kwonlyargs)
214+
if expected_args != func_args:
215+
raise TypeError(
216+
f"{hook_name}: argument names mismatch: "
217+
f"expected {sorted(expected_args)}, got {sorted(func_args)}"
218+
)
219+
220+
@_default_hook("fromager.wheels", "default_add_extra_metadata_to_wheels")
221+
def add_extra_metadata_to_wheels(
222+
self,
223+
ctx: context.WorkContext,
224+
req: Requirement,
225+
version: Version,
226+
extra_environ: dict[str, str],
227+
sdist_root_dir: pathlib.Path,
228+
dist_info_dir: pathlib.Path,
229+
) -> dict[str, typing.Any]:
230+
"""Add extra metadata files to built wheels.
231+
232+
Default: :func:`fromager.wheels.default_add_extra_metadata_to_wheels`
233+
"""
234+
235+
@_default_hook("fromager.sources", "default_build_sdist")
236+
def build_sdist(
237+
self,
238+
ctx: context.WorkContext,
239+
extra_environ: dict,
240+
req: Requirement,
241+
version: Version,
242+
sdist_root_dir: pathlib.Path,
243+
build_env: build_environment.BuildEnvironment,
244+
build_dir: pathlib.Path,
245+
) -> pathlib.Path:
246+
"""Build an sdist from the prepared source tree.
247+
248+
Default: :func:`fromager.sources.default_build_sdist`
249+
"""
250+
251+
@_default_hook("fromager.wheels", "default_build_wheel")
252+
def build_wheel(
253+
self,
254+
ctx: context.WorkContext,
255+
build_env: build_environment.BuildEnvironment,
256+
extra_environ: dict[str, str],
257+
req: Requirement,
258+
sdist_root_dir: pathlib.Path,
259+
version: Version,
260+
build_dir: pathlib.Path,
261+
) -> pathlib.Path:
262+
"""Build a wheel from the prepared source tree.
263+
264+
Default: :func:`fromager.wheels.default_build_wheel`
265+
"""
266+
267+
@_default_hook("fromager.sources", "default_download_source")
268+
def download_source(
269+
self,
270+
ctx: context.WorkContext,
271+
req: Requirement,
272+
version: Version,
273+
download_url: str,
274+
sdists_downloads_dir: pathlib.Path,
275+
) -> pathlib.Path:
276+
"""Download the source archive for a requirement.
277+
278+
Default: :func:`fromager.sources.default_download_source`
279+
"""
280+
281+
@_default_hook("fromager.finders", "default_expected_source_archive_name")
282+
def expected_source_archive_name(
283+
self,
284+
ctx: context.WorkContext,
285+
req: Requirement,
286+
dist_version: str,
287+
) -> str | None:
288+
"""Return the expected filename for a source archive.
289+
290+
Default: :func:`fromager.finders.default_expected_source_archive_name`
291+
"""
292+
293+
@_default_hook("fromager.finders", "default_expected_source_directory_name")
294+
def expected_source_directory_name(
295+
self,
296+
req: Requirement,
297+
dist_version: str,
298+
) -> str:
299+
"""Return the expected directory name after unpacking a source archive.
300+
301+
Default: :func:`fromager.finders.default_expected_source_directory_name`
302+
"""
303+
304+
@_default_hook("fromager.dependencies", "default_get_build_backend_dependencies")
305+
def get_build_backend_dependencies(
306+
self,
307+
ctx: context.WorkContext,
308+
req: Requirement,
309+
sdist_root_dir: pathlib.Path,
310+
build_dir: pathlib.Path,
311+
extra_environ: dict[str, str],
312+
build_env: build_environment.BuildEnvironment,
313+
) -> typing.Iterable[str]:
314+
"""Get build backend dependencies (PEP 517 get_requires_for_build_wheel).
315+
316+
Default: :func:`fromager.dependencies.default_get_build_backend_dependencies`
317+
"""
318+
319+
@_default_hook("fromager.dependencies", "default_get_build_sdist_dependencies")
320+
def get_build_sdist_dependencies(
321+
self,
322+
ctx: context.WorkContext,
323+
req: Requirement,
324+
sdist_root_dir: pathlib.Path,
325+
build_dir: pathlib.Path,
326+
extra_environ: dict[str, str],
327+
build_env: build_environment.BuildEnvironment,
328+
) -> typing.Iterable[str]:
329+
"""Get build sdist dependencies.
330+
331+
Default: :func:`fromager.dependencies.default_get_build_sdist_dependencies`
332+
"""
333+
334+
@_default_hook("fromager.dependencies", "default_get_build_system_dependencies")
335+
def get_build_system_dependencies(
336+
self,
337+
ctx: context.WorkContext,
338+
req: Requirement,
339+
sdist_root_dir: pathlib.Path,
340+
build_dir: pathlib.Path,
341+
) -> typing.Iterable[str]:
342+
"""Get build system dependencies from pyproject.toml [build-system] requires.
343+
344+
Default: :func:`fromager.dependencies.default_get_build_system_dependencies`
345+
"""
346+
347+
@_default_hook("fromager.dependencies", "default_get_install_dependencies_of_sdist")
348+
def get_install_dependencies_of_sdist(
349+
self,
350+
*,
351+
ctx: context.WorkContext,
352+
req: Requirement,
353+
version: Version,
354+
sdist_root_dir: pathlib.Path,
355+
build_env: build_environment.BuildEnvironment,
356+
extra_environ: dict[str, str],
357+
build_dir: pathlib.Path,
358+
config_settings: dict[str, str],
359+
) -> set[Requirement]:
360+
"""Get install dependencies (Requires-Dist) from source distribution.
361+
362+
Default: :func:`fromager.dependencies.default_get_install_dependencies_of_sdist`
363+
"""
364+
365+
@_default_hook("fromager.resolver", "default_resolver_provider")
366+
def get_resolver_provider(
367+
self,
368+
ctx: context.WorkContext,
369+
req: Requirement,
370+
sdist_server_url: str,
371+
include_sdists: bool,
372+
include_wheels: bool,
373+
req_type: RequirementType | None = None,
374+
ignore_platform: bool = False,
375+
) -> BaseProvider:
376+
"""Return a resolver provider for resolving package versions.
377+
378+
Default: :func:`fromager.resolver.default_resolver_provider`
379+
"""
380+
381+
@_default_hook("fromager.sources", "default_prepare_source")
382+
def prepare_source(
383+
self,
384+
ctx: context.WorkContext,
385+
req: Requirement,
386+
source_filename: pathlib.Path,
387+
version: Version,
388+
) -> tuple[pathlib.Path, bool]:
389+
"""Unpack, modify, and prepare source for building.
390+
391+
Default: :func:`fromager.sources.default_prepare_source`
392+
"""
393+
394+
@_default_hook("fromager.packagesettings", "default_update_extra_environ")
395+
def update_extra_environ(
396+
self,
397+
*,
398+
ctx: context.WorkContext,
399+
req: Requirement,
400+
version: Version | None,
401+
sdist_root_dir: pathlib.Path,
402+
extra_environ: dict[str, str],
403+
build_env: build_environment.BuildEnvironment,
404+
) -> None:
405+
"""Update extra_environ dict in-place with additional environment variables.
406+
407+
Default: :func:`fromager.packagesettings.default_update_extra_environ`
408+
"""

tests/test_overrides.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,52 @@ def default_foo(arg1: typing.Any) -> bool:
4141
assert overrides.find_and_invoke(
4242
"pkg", "foo", default_foo, arg1="value1", arg2="value2"
4343
)
44+
45+
46+
def test_list_hooks() -> None:
47+
hooks = overrides.OverrideHookProtocol.list_hooks()
48+
assert isinstance(hooks, list)
49+
assert len(hooks) == 13
50+
51+
52+
def test_get_default_unknown_hook() -> None:
53+
with pytest.raises(KeyError):
54+
overrides.OverrideHookProtocol.get_default("no_such_hook")
55+
56+
57+
def test_check_signature_matching() -> None:
58+
def build_wheel(
59+
ctx: typing.Any,
60+
build_env: typing.Any,
61+
extra_environ: typing.Any,
62+
req: typing.Any,
63+
sdist_root_dir: typing.Any,
64+
version: typing.Any,
65+
build_dir: typing.Any,
66+
) -> None:
67+
pass
68+
69+
overrides.OverrideHookProtocol.check_signature(build_wheel)
70+
71+
72+
def test_check_signature_unknown_hook() -> None:
73+
def no_such_hook() -> None:
74+
pass
75+
76+
with pytest.raises(KeyError):
77+
overrides.OverrideHookProtocol.check_signature(no_such_hook)
78+
79+
80+
def test_check_signature_args_mismatch() -> None:
81+
def build_wheel(ctx: typing.Any) -> None:
82+
pass
83+
84+
with pytest.raises(TypeError, match="argument names mismatch"):
85+
overrides.OverrideHookProtocol.check_signature(build_wheel)
86+
87+
88+
@pytest.mark.parametrize("hook_name", overrides.OverrideHookProtocol.list_hooks())
89+
def test_protocol_signature_matches_default(hook_name: str) -> None:
90+
default_fn = overrides.OverrideHookProtocol.get_default(hook_name)
91+
assert callable(default_fn)
92+
overrides.OverrideHookProtocol.check_signature(default_fn, hook_name=hook_name)

0 commit comments

Comments
 (0)