Skip to content

Commit 2b9998a

Browse files
authored
Merge pull request #345 from bluetech/hooks1
Misc small improvements
2 parents 4576615 + a489e0c commit 2b9998a

File tree

4 files changed

+123
-62
lines changed

4 files changed

+123
-62
lines changed

src/pluggy/_hooks.py

Lines changed: 87 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
if TYPE_CHECKING:
2626
from typing_extensions import TypedDict
27+
from typing_extensions import Final
2728

2829

2930
_T = TypeVar("_T")
@@ -58,8 +59,10 @@ class HookspecMarker:
5859
if the :py:class:`.PluginManager` uses the same project_name.
5960
"""
6061

62+
__slots__ = ("project_name",)
63+
6164
def __init__(self, project_name: str) -> None:
62-
self.project_name = project_name
65+
self.project_name: "Final" = project_name
6366

6467
@overload
6568
def __call__(
@@ -127,8 +130,10 @@ class HookimplMarker:
127130
if the :py:class:`.PluginManager` uses the same project_name.
128131
"""
129132

133+
__slots__ = ("project_name",)
134+
130135
def __init__(self, project_name: str) -> None:
131-
self.project_name = project_name
136+
self.project_name: "Final" = project_name
132137

133138
@overload
134139
def __call__(
@@ -263,31 +268,41 @@ def varnames(func: object) -> Tuple[Tuple[str, ...], Tuple[str, ...]]:
263268

264269
class _HookRelay:
265270
"""hook holder object for performing 1:N hook calls where N is the number
266-
of registered plugins.
271+
of registered plugins."""
267272

268-
"""
273+
__slots__ = ("__dict__",)
269274

270275
if TYPE_CHECKING:
271276

272277
def __getattr__(self, name: str) -> "_HookCaller":
273278
...
274279

275280

281+
_CallHistory = List[Tuple[Mapping[str, object], Optional[Callable[[Any], None]]]]
282+
283+
276284
class _HookCaller:
285+
__slots__ = (
286+
"name",
287+
"spec",
288+
"_hookexec",
289+
"_wrappers",
290+
"_nonwrappers",
291+
"_call_history",
292+
)
293+
277294
def __init__(
278295
self,
279296
name: str,
280297
hook_execute: _HookExec,
281298
specmodule_or_class: Optional[_Namespace] = None,
282299
spec_opts: Optional["_HookSpecOpts"] = None,
283300
) -> None:
284-
self.name = name
285-
self._wrappers: List[HookImpl] = []
286-
self._nonwrappers: List[HookImpl] = []
287-
self._hookexec = hook_execute
288-
self._call_history: Optional[
289-
List[Tuple[Mapping[str, object], Optional[Callable[[Any], None]]]]
290-
] = None
301+
self.name: "Final" = name
302+
self._hookexec: "Final" = hook_execute
303+
self._wrappers: "Final[List[HookImpl]]" = []
304+
self._nonwrappers: "Final[List[HookImpl]]" = []
305+
self._call_history: Optional[_CallHistory] = None
291306
self.spec: Optional[HookSpec] = None
292307
if specmodule_or_class is not None:
293308
assert spec_opts is not None
@@ -346,27 +361,32 @@ def _add_hookimpl(self, hookimpl: "HookImpl") -> None:
346361
def __repr__(self) -> str:
347362
return f"<_HookCaller {self.name!r}>"
348363

349-
def __call__(self, *args: object, **kwargs: object) -> Any:
350-
if args:
351-
raise TypeError("hook calling supports only keyword arguments")
352-
assert not self.is_historic()
353-
364+
def _verify_all_args_are_provided(self, kwargs: Mapping[str, object]) -> None:
354365
# This is written to avoid expensive operations when not needed.
355366
if self.spec:
356367
for argname in self.spec.argnames:
357368
if argname not in kwargs:
358-
notincall = tuple(set(self.spec.argnames) - kwargs.keys())
369+
notincall = ", ".join(
370+
repr(argname)
371+
for argname in self.spec.argnames
372+
# Avoid self.spec.argnames - kwargs.keys() - doesn't preserve order.
373+
if argname not in kwargs.keys()
374+
)
359375
warnings.warn(
360376
"Argument(s) {} which are declared in the hookspec "
361-
"can not be found in this hook call".format(notincall),
377+
"cannot be found in this hook call".format(notincall),
362378
stacklevel=2,
363379
)
364380
break
365381

366-
firstresult = self.spec.opts.get("firstresult", False)
367-
else:
368-
firstresult = False
369-
382+
def __call__(self, *args: object, **kwargs: object) -> Any:
383+
if args:
384+
raise TypeError("hook calling supports only keyword arguments")
385+
assert (
386+
not self.is_historic()
387+
), "Cannot directly call a historic hook - use call_historic instead."
388+
self._verify_all_args_are_provided(kwargs)
389+
firstresult = self.spec.opts.get("firstresult", False) if self.spec else False
370390
return self._hookexec(self.name, self.get_hookimpls(), kwargs, firstresult)
371391

372392
def call_historic(
@@ -382,6 +402,7 @@ def call_historic(
382402
"""
383403
assert self._call_history is not None
384404
kwargs = kwargs or {}
405+
self._verify_all_args_are_provided(kwargs)
385406
self._call_history.append((kwargs, result_callback))
386407
# Historizing hooks don't return results.
387408
# Remember firstresult isn't compatible with historic.
@@ -397,21 +418,28 @@ def call_extra(
397418
) -> Any:
398419
"""Call the hook with some additional temporarily participating
399420
methods using the specified ``kwargs`` as call parameters."""
400-
old = list(self._nonwrappers), list(self._wrappers)
421+
assert (
422+
not self.is_historic()
423+
), "Cannot directly call a historic hook - use call_historic instead."
424+
self._verify_all_args_are_provided(kwargs)
425+
opts: "_HookImplOpts" = {
426+
"hookwrapper": False,
427+
"optionalhook": False,
428+
"trylast": False,
429+
"tryfirst": False,
430+
"specname": None,
431+
}
432+
hookimpls = self.get_hookimpls()
401433
for method in methods:
402-
opts: "_HookImplOpts" = {
403-
"hookwrapper": False,
404-
"optionalhook": False,
405-
"trylast": False,
406-
"tryfirst": False,
407-
"specname": None,
408-
}
409434
hookimpl = HookImpl(None, "<temp>", method, opts)
410-
self._add_hookimpl(hookimpl)
411-
try:
412-
return self(**kwargs)
413-
finally:
414-
self._nonwrappers, self._wrappers = old
435+
# Find last non-tryfirst nonwrapper method.
436+
i = len(hookimpls) - 1
437+
until = len(self._nonwrappers)
438+
while i >= until and hookimpls[i].tryfirst:
439+
i -= 1
440+
hookimpls.insert(i + 1, hookimpl)
441+
firstresult = self.spec.opts.get("firstresult", False) if self.spec else False
442+
return self._hookexec(self.name, hookimpls, kwargs, firstresult)
415443

416444
def _maybe_apply_history(self, method: "HookImpl") -> None:
417445
"""Apply call history to a new hookimpl if it is marked as historic."""
@@ -426,14 +454,27 @@ def _maybe_apply_history(self, method: "HookImpl") -> None:
426454

427455

428456
class HookImpl:
457+
__slots__ = (
458+
"function",
459+
"argnames",
460+
"kwargnames",
461+
"plugin",
462+
"opts",
463+
"plugin_name",
464+
"hookwrapper",
465+
"optionalhook",
466+
"tryfirst",
467+
"trylast",
468+
)
469+
429470
def __init__(
430471
self,
431472
plugin: _Plugin,
432473
plugin_name: str,
433474
function: _HookImplFunction[object],
434475
hook_impl_opts: "_HookImplOpts",
435476
) -> None:
436-
self.function = function
477+
self.function: "Final" = function
437478
self.argnames, self.kwargnames = varnames(self.function)
438479
self.plugin = plugin
439480
self.opts = hook_impl_opts
@@ -448,6 +489,16 @@ def __repr__(self) -> str:
448489

449490

450491
class HookSpec:
492+
__slots__ = (
493+
"namespace",
494+
"function",
495+
"name",
496+
"argnames",
497+
"kwargnames",
498+
"opts",
499+
"warn_on_impl",
500+
)
501+
451502
def __init__(self, namespace: _Namespace, name: str, opts: "_HookSpecOpts") -> None:
452503
self.namespace = namespace
453504
self.function: Callable[..., object] = getattr(namespace, name)

src/pluggy/_manager.py

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@
3838
import importlib_metadata
3939

4040
if TYPE_CHECKING:
41+
from typing_extensions import Final
42+
4143
from ._hooks import _HookImplOpts, _HookSpecOpts
4244

4345
_BeforeTrace = Callable[[str, Sequence[HookImpl], Mapping[str, Any]], None]
@@ -99,13 +101,23 @@ class PluginManager:
99101
which will subsequently send debug information to the trace helper.
100102
"""
101103

104+
__slots__ = (
105+
"project_name",
106+
"_name2plugin",
107+
"_plugin2hookcallers",
108+
"_plugin_distinfo",
109+
"trace",
110+
"hook",
111+
"_inner_hookexec",
112+
)
113+
102114
def __init__(self, project_name: str) -> None:
103-
self.project_name = project_name
104-
self._name2plugin: Dict[str, _Plugin] = {}
105-
self._plugin2hookcallers: Dict[_Plugin, List[_HookCaller]] = {}
106-
self._plugin_distinfo: List[Tuple[_Plugin, DistFacade]] = []
107-
self.trace = _tracing.TagTracer().get("pluginmanage")
108-
self.hook = _HookRelay()
115+
self.project_name: "Final" = project_name
116+
self._name2plugin: "Final[Dict[str, _Plugin]]" = {}
117+
self._plugin2hookcallers: "Final[Dict[_Plugin, List[_HookCaller]]]" = {}
118+
self._plugin_distinfo: "Final[List[Tuple[_Plugin, DistFacade]]]" = []
119+
self.trace: "Final" = _tracing.TagTracer().get("pluginmanage")
120+
self.hook: "Final" = _HookRelay()
109121
self._inner_hookexec = _multicall
110122

111123
def _hookexec(

src/pluggy/_result.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ class HookCallError(Exception):
3838

3939

4040
class _Result(Generic[_T]):
41+
__slots__ = ("_result", "_excinfo")
42+
4143
def __init__(self, result: Optional[_T], excinfo: Optional[_ExcInfo]) -> None:
4244
self._result = result
4345
self._excinfo = excinfo

testing/test_details.py

Lines changed: 16 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import warnings
21
import pytest
32
from pluggy import PluginManager, HookimplMarker, HookspecMarker
43

@@ -89,36 +88,33 @@ class Module:
8988
assert pm.get_plugin("donttouch") is module
9089

9190

92-
def test_warning_on_call_vs_hookspec_arg_mismatch() -> None:
93-
"""Verify that is a hook is called with less arguments then defined in the
94-
spec that a warning is emitted.
95-
"""
91+
def test_not_all_arguments_are_provided_issues_a_warning(pm: PluginManager) -> None:
92+
"""Calling a hook without providing all arguments specified in
93+
the hook spec issues a warning."""
9694

9795
class Spec:
9896
@hookspec
99-
def myhook(self, arg1, arg2):
97+
def hello(self, arg1, arg2):
10098
pass
10199

102-
class Plugin:
103-
@hookimpl
104-
def myhook(self, arg1):
100+
@hookspec(historic=True)
101+
def herstory(self, arg1, arg2):
105102
pass
106103

107-
pm = PluginManager(hookspec.project_name)
108-
pm.register(Plugin())
109104
pm.add_hookspecs(Spec)
110105

111-
with warnings.catch_warnings(record=True) as warns:
112-
warnings.simplefilter("always")
106+
with pytest.warns(UserWarning, match=r"'arg1', 'arg2'.*cannot be found.*$"):
107+
pm.hook.hello()
108+
with pytest.warns(UserWarning, match=r"'arg2'.*cannot be found.*$"):
109+
pm.hook.hello(arg1=1)
110+
with pytest.warns(UserWarning, match=r"'arg1'.*cannot be found.*$"):
111+
pm.hook.hello(arg2=2)
113112

114-
# calling should trigger a warning
115-
pm.hook.myhook(arg1=1)
113+
with pytest.warns(UserWarning, match=r"'arg1', 'arg2'.*cannot be found.*$"):
114+
pm.hook.hello.call_extra([], kwargs=dict())
116115

117-
assert warns is not None
118-
assert len(warns) == 1
119-
warning = warns[-1]
120-
assert issubclass(warning.category, Warning)
121-
assert "Argument(s) ('arg2',)" in str(warning.message)
116+
with pytest.warns(UserWarning, match=r"'arg1', 'arg2'.*cannot be found.*$"):
117+
pm.hook.herstory.call_historic(kwargs=dict())
122118

123119

124120
def test_repr() -> None:

0 commit comments

Comments
 (0)