Skip to content

Commit c28c9a6

Browse files
committed
test(hooks): add coverage for hook plugin system
Closes #1033 Signed-off-by: Marcel Nadzam <mnadzam@redhat.com> Co-Authored-By: Cursor
1 parent d86f938 commit c28c9a6

1 file changed

Lines changed: 216 additions & 0 deletions

File tree

tests/test_hooks.py

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
from __future__ import annotations
2+
3+
import pathlib
4+
import typing
5+
from importlib.metadata import EntryPoint
6+
from unittest.mock import MagicMock, Mock, patch
7+
8+
import pytest
9+
from packaging.requirements import Requirement
10+
11+
from fromager import hooks
12+
13+
14+
@pytest.fixture(autouse=True)
15+
def _clear_hook_cache() -> typing.Generator[None, None, None]:
16+
hooks._mgrs.clear()
17+
yield
18+
hooks._mgrs.clear()
19+
20+
21+
def _make_fake_ext(plugin: typing.Callable[..., typing.Any]) -> Mock:
22+
ext = Mock()
23+
ext.plugin = plugin
24+
return ext
25+
26+
27+
def test_die_on_plugin_load_failure_raises() -> None:
28+
ep = EntryPoint(name="bad_plugin", value="some.module:func", group="fromager.hooks")
29+
original_err = ImportError("no such module")
30+
31+
with pytest.raises(RuntimeError, match="bad_plugin") as exc_info:
32+
hooks._die_on_plugin_load_failure(
33+
mgr=Mock(),
34+
ep=ep,
35+
err=original_err,
36+
)
37+
38+
assert exc_info.value.__cause__ is original_err
39+
40+
41+
@patch("fromager.hooks.hook.HookManager")
42+
def test_get_hooks_creates_manager(mock_hm_cls: Mock) -> None:
43+
fake_mgr = MagicMock()
44+
fake_mgr.names.return_value = ["my_hook"]
45+
mock_hm_cls.return_value = fake_mgr
46+
47+
result = hooks._get_hooks("post_build")
48+
49+
mock_hm_cls.assert_called_once_with(
50+
namespace="fromager.hooks",
51+
name="post_build",
52+
invoke_on_load=False,
53+
on_load_failure_callback=hooks._die_on_plugin_load_failure,
54+
)
55+
assert result is fake_mgr
56+
57+
58+
@patch("fromager.hooks.hook.HookManager")
59+
def test_get_hooks_returns_cached(mock_hm_cls: Mock) -> None:
60+
fake_mgr = MagicMock()
61+
fake_mgr.names.return_value = ["my_hook"]
62+
mock_hm_cls.return_value = fake_mgr
63+
64+
first = hooks._get_hooks("post_build")
65+
second = hooks._get_hooks("post_build")
66+
67+
mock_hm_cls.assert_called_once()
68+
assert first is second
69+
70+
71+
@patch("fromager.hooks._get_hooks")
72+
def test_run_post_build_hooks_exception_propagates(mock_get: Mock) -> None:
73+
74+
def bad_plugin(**kwargs: typing.Any) -> None:
75+
raise ValueError("hook failed")
76+
77+
fake_mgr = MagicMock()
78+
fake_mgr.names.return_value = ["bad"]
79+
fake_mgr.__iter__ = Mock(return_value=iter([_make_fake_ext(bad_plugin)]))
80+
mock_get.return_value = fake_mgr
81+
82+
with pytest.raises(ValueError, match="hook failed"):
83+
hooks.run_post_build_hooks(
84+
ctx=Mock(),
85+
req=Requirement("pkg"),
86+
dist_name="pkg",
87+
dist_version="1.0",
88+
sdist_filename=pathlib.Path("/tmp/a.tar.gz"),
89+
wheel_filename=pathlib.Path("/tmp/a.whl"),
90+
)
91+
92+
93+
@patch("fromager.hooks._get_hooks")
94+
def test_run_post_build_hooks_calls_plugin(mock_get: Mock) -> None:
95+
called_with: dict[str, typing.Any] = {}
96+
97+
def fake_plugin(**kwargs: typing.Any) -> None:
98+
called_with.update(kwargs)
99+
100+
fake_mgr = MagicMock()
101+
fake_mgr.names.return_value = ["my_hook"]
102+
fake_mgr.__iter__ = Mock(return_value=iter([_make_fake_ext(fake_plugin)]))
103+
mock_get.return_value = fake_mgr
104+
105+
ctx = Mock()
106+
req = Requirement("numpy>=1.0")
107+
sdist = pathlib.Path("/tmp/numpy-1.0.tar.gz")
108+
wheel = pathlib.Path("/tmp/numpy-1.0-cp312-linux_x86_64.whl")
109+
110+
hooks.run_post_build_hooks(
111+
ctx=ctx,
112+
req=req,
113+
dist_name="numpy",
114+
dist_version="1.0",
115+
sdist_filename=sdist,
116+
wheel_filename=wheel,
117+
)
118+
119+
assert called_with["ctx"] is ctx
120+
assert called_with["req"] is req
121+
assert called_with["dist_name"] == "numpy"
122+
assert called_with["dist_version"] == "1.0"
123+
assert called_with["sdist_filename"] is sdist
124+
assert called_with["wheel_filename"] is wheel
125+
126+
127+
@patch("fromager.hooks._get_hooks")
128+
def test_run_post_bootstrap_hooks_calls_plugin(mock_get: Mock) -> None:
129+
called_with: dict[str, typing.Any] = {}
130+
131+
def fake_plugin(**kwargs: typing.Any) -> None:
132+
called_with.update(kwargs)
133+
134+
fake_mgr = MagicMock()
135+
fake_mgr.names.return_value = ["my_hook"]
136+
fake_mgr.__iter__ = Mock(return_value=iter([_make_fake_ext(fake_plugin)]))
137+
mock_get.return_value = fake_mgr
138+
139+
ctx = Mock()
140+
req = Requirement("flask>=2.0")
141+
142+
hooks.run_post_bootstrap_hooks(
143+
ctx=ctx,
144+
req=req,
145+
dist_name="flask",
146+
dist_version="2.0",
147+
sdist_filename=None,
148+
wheel_filename=None,
149+
)
150+
151+
assert called_with["ctx"] is ctx
152+
assert called_with["req"] is req
153+
assert called_with["dist_name"] == "flask"
154+
assert called_with["dist_version"] == "2.0"
155+
assert called_with["sdist_filename"] is None
156+
assert called_with["wheel_filename"] is None
157+
158+
159+
@patch("fromager.hooks._get_hooks")
160+
def test_run_prebuilt_wheel_hooks_calls_plugin(mock_get: Mock) -> None:
161+
called_with: dict[str, typing.Any] = {}
162+
163+
def fake_plugin(**kwargs: typing.Any) -> None:
164+
called_with.update(kwargs)
165+
166+
fake_mgr = MagicMock()
167+
fake_mgr.names.return_value = ["my_hook"]
168+
fake_mgr.__iter__ = Mock(return_value=iter([_make_fake_ext(fake_plugin)]))
169+
mock_get.return_value = fake_mgr
170+
171+
ctx = Mock()
172+
req = Requirement("torch>=2.0")
173+
wheel = pathlib.Path("/tmp/torch-2.0-cp312-linux_x86_64.whl")
174+
175+
hooks.run_prebuilt_wheel_hooks(
176+
ctx=ctx,
177+
req=req,
178+
dist_name="torch",
179+
dist_version="2.0",
180+
wheel_filename=wheel,
181+
)
182+
183+
assert called_with["ctx"] is ctx
184+
assert called_with["req"] is req
185+
assert called_with["dist_name"] == "torch"
186+
assert called_with["dist_version"] == "2.0"
187+
assert called_with["wheel_filename"] is wheel
188+
assert "sdist_filename" not in called_with
189+
190+
191+
@patch("fromager.hooks.overrides._get_dist_info", return_value=("mypkg", "1.0.0"))
192+
@patch("fromager.hooks.extension.ExtensionManager")
193+
def test_log_hooks_logs_each_extension(
194+
mock_em_cls: Mock,
195+
mock_dist_info: Mock,
196+
) -> None:
197+
ext_a = Mock()
198+
ext_a.name = "post_build"
199+
ext_a.module_name = "my_plugins.hooks"
200+
201+
ext_b = Mock()
202+
ext_b.name = "post_bootstrap"
203+
ext_b.module_name = "other_plugins.hooks"
204+
205+
mock_em_cls.return_value = [ext_a, ext_b]
206+
207+
hooks.log_hooks()
208+
209+
mock_em_cls.assert_called_once_with(
210+
namespace="fromager.hooks",
211+
invoke_on_load=False,
212+
on_load_failure_callback=hooks._die_on_plugin_load_failure,
213+
)
214+
assert mock_dist_info.call_count == 2
215+
mock_dist_info.assert_any_call("my_plugins.hooks")
216+
mock_dist_info.assert_any_call("other_plugins.hooks")

0 commit comments

Comments
 (0)