Skip to content

Commit 26ddfe4

Browse files
goodboynicoddemus
goodboy
authored andcommitted
Hook call mismatch warnings (#42)
* Future warn about hookspec vs. call mis-matches Warn when either a hook call doesn't match a hookspec. Additionally, - Extend `varnames()` to return the list of both the arg and kwarg names for a function - Rename `_MultiCall.kwargs` to `caller_kwargs` to be more explicit - Store hookspec kwargs on `_HookRelay` and `HookImpl` instances Relates to #15 * Port tests to match new `varnames()` return value * Handle py2.6 and better docs - don't use "Positional" in warning message - support python 2.6 sets - don't include __multicall__ in args comparison - document `varnames()` new pair return value * Add a call vs. spec mismatch warning test * Drop "Positional" adjective
1 parent cf059aa commit 26ddfe4

File tree

3 files changed

+102
-45
lines changed

3 files changed

+102
-45
lines changed

Diff for: pluggy.py

+63-37
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import sys
22
import inspect
3+
import warnings
34

45
__version__ = '0.5.0'
56

@@ -9,6 +10,14 @@
910
_py3 = sys.version_info > (3, 0)
1011

1112

13+
class PluginValidationError(Exception):
14+
""" plugin failed validation. """
15+
16+
17+
class HookCallError(Exception):
18+
""" Hook was called wrongly. """
19+
20+
1221
class HookspecMarker(object):
1322
""" Decorator helper class for marking functions as hook specifications.
1423
@@ -266,7 +275,9 @@ def __init__(self, project_name, implprefix=None):
266275
self.hook = _HookRelay(self.trace.root.get("hook"))
267276
self._implprefix = implprefix
268277
self._inner_hookexec = lambda hook, methods, kwargs: \
269-
_MultiCall(methods, kwargs, hook.spec_opts).execute()
278+
_MultiCall(
279+
methods, kwargs, specopts=hook.spec_opts, hook=hook
280+
).execute()
270281

271282
def _hookexec(self, hook, methods, kwargs):
272283
# called from all hookcaller instances.
@@ -412,14 +423,16 @@ def _verify_hook(self, hook, hookimpl):
412423
"Plugin %r\nhook %r\nhistoric incompatible to hookwrapper" %
413424
(hookimpl.plugin_name, hook.name))
414425

415-
for arg in hookimpl.argnames:
416-
if arg not in hook.argnames:
417-
raise PluginValidationError(
418-
"Plugin %r\nhook %r\nargument %r not available\n"
419-
"plugin definition: %s\n"
420-
"available hookargs: %s" %
421-
(hookimpl.plugin_name, hook.name, arg,
422-
_formatdef(hookimpl.function), ", ".join(hook.argnames)))
426+
# positional arg checking
427+
notinspec = set(hookimpl.argnames) - set(hook.argnames)
428+
if notinspec:
429+
raise PluginValidationError(
430+
"Plugin %r for hook %r\nhookimpl definition: %s\n"
431+
"Argument(s) %s are declared in the hookimpl but "
432+
"can not be found in the hookspec" %
433+
(hookimpl.plugin_name, hook.name,
434+
_formatdef(hookimpl.function), notinspec)
435+
)
423436

424437
def check_pending(self):
425438
""" Verify that all hooks which have not been verified against
@@ -526,24 +539,25 @@ class _MultiCall(object):
526539
# so we can remove it soon, allowing to avoid the below recursion
527540
# in execute() and simplify/speed up the execute loop.
528541

529-
def __init__(self, hook_impls, kwargs, specopts={}):
542+
def __init__(self, hook_impls, kwargs, specopts={}, hook=None):
543+
self.hook = hook
530544
self.hook_impls = hook_impls
531-
self.kwargs = kwargs
532-
self.kwargs["__multicall__"] = self
533-
self.specopts = specopts
545+
self.caller_kwargs = kwargs # come from _HookCaller.__call__()
546+
self.caller_kwargs["__multicall__"] = self
547+
self.specopts = hook.spec_opts if hook else specopts
534548

535549
def execute(self):
536-
all_kwargs = self.kwargs
550+
caller_kwargs = self.caller_kwargs
537551
self.results = results = []
538552
firstresult = self.specopts.get("firstresult")
539553

540554
while self.hook_impls:
541555
hook_impl = self.hook_impls.pop()
542556
try:
543-
args = [all_kwargs[argname] for argname in hook_impl.argnames]
557+
args = [caller_kwargs[argname] for argname in hook_impl.argnames]
544558
except KeyError:
545559
for argname in hook_impl.argnames:
546-
if argname not in all_kwargs:
560+
if argname not in caller_kwargs:
547561
raise HookCallError(
548562
"hook call must provide argument %r" % (argname,))
549563
if hook_impl.hookwrapper:
@@ -561,15 +575,15 @@ def __repr__(self):
561575
status = "%d meths" % (len(self.hook_impls),)
562576
if hasattr(self, "results"):
563577
status = ("%d results, " % len(self.results)) + status
564-
return "<_MultiCall %s, kwargs=%r>" % (status, self.kwargs)
578+
return "<_MultiCall %s, kwargs=%r>" % (status, self.caller_kwargs)
565579

566580

567581
def varnames(func):
568-
"""Return argument name tuple for a function, method, class or callable.
582+
"""Return tuple of positional and keywrord argument names for a function,
583+
method, class or callable.
569584
570-
In case of a class, its "__init__" method is considered.
571-
For methods the "self" parameter is not included unless you are passing
572-
an unbound method with Python3 (which has no support for unbound methods)
585+
In case of a class, its ``__init__`` method is considered.
586+
For methods the ``self`` parameter is not included.
573587
"""
574588
cache = getattr(func, "__dict__", {})
575589
try:
@@ -581,7 +595,7 @@ def varnames(func):
581595
try:
582596
func = func.__init__
583597
except AttributeError:
584-
return ()
598+
return (), ()
585599
elif not inspect.isroutine(func): # callable object?
586600
try:
587601
func = getattr(func, '__call__', func)
@@ -591,10 +605,14 @@ def varnames(func):
591605
try: # func MUST be a function or method here or we won't parse any args
592606
spec = inspect.getargspec(func)
593607
except TypeError:
594-
return ()
608+
return (), ()
595609

596-
args, defaults = spec.args, spec.defaults
597-
args = args[:-len(defaults)] if defaults else args
610+
args, defaults = tuple(spec.args), spec.defaults
611+
if defaults:
612+
index = -len(defaults)
613+
args, defaults = args[:index], tuple(args[index:])
614+
else:
615+
defaults = ()
598616

599617
# strip any implicit instance arg
600618
if args:
@@ -605,10 +623,10 @@ def varnames(func):
605623

606624
assert "self" not in args # best naming practises check?
607625
try:
608-
cache["_varnames"] = args
626+
cache["_varnames"] = args, defaults
609627
except TypeError:
610628
pass
611-
return tuple(args)
629+
return args, defaults
612630

613631

614632
class _HookRelay(object):
@@ -627,6 +645,8 @@ def __init__(self, name, hook_execute, specmodule_or_class=None, spec_opts=None)
627645
self._wrappers = []
628646
self._nonwrappers = []
629647
self._hookexec = hook_execute
648+
self.argnames = None
649+
self.kwargnames = None
630650
if specmodule_or_class is not None:
631651
assert spec_opts is not None
632652
self.set_specification(specmodule_or_class, spec_opts)
@@ -638,7 +658,8 @@ def set_specification(self, specmodule_or_class, spec_opts):
638658
assert not self.has_spec()
639659
self._specmodule_or_class = specmodule_or_class
640660
specfunc = getattr(specmodule_or_class, self.name)
641-
argnames = varnames(specfunc)
661+
# get spec arg signature
662+
argnames, self.kwargnames = varnames(specfunc)
642663
self.argnames = ["__multicall__"] + list(argnames)
643664
self.spec_opts = spec_opts
644665
if spec_opts.get("historic"):
@@ -658,6 +679,8 @@ def remove(wrappers):
658679
raise ValueError("plugin %r not found" % (plugin,))
659680

660681
def _add_hookimpl(self, hookimpl):
682+
"""A an implementation to the callback chain.
683+
"""
661684
if hookimpl.hookwrapper:
662685
methods = self._wrappers
663686
else:
@@ -679,6 +702,15 @@ def __repr__(self):
679702

680703
def __call__(self, **kwargs):
681704
assert not self.is_historic()
705+
if self.argnames:
706+
notincall = set(self.argnames) - set(['__multicall__']) - set(
707+
kwargs.keys())
708+
if notincall:
709+
warnings.warn(
710+
"Argument(s) {0} which are declared in the hookspec "
711+
"can not be found in this hook call"
712+
.format(tuple(notincall))
713+
)
682714
return self._hookexec(self, self._nonwrappers + self._wrappers, kwargs)
683715

684716
def call_historic(self, proc=None, kwargs=None):
@@ -708,6 +740,8 @@ def call_extra(self, methods, kwargs):
708740
self._nonwrappers, self._wrappers = old
709741

710742
def _maybe_apply_history(self, method):
743+
"""Apply call history to a new hookimpl if it is marked as historic.
744+
"""
711745
if self.is_historic():
712746
for kwargs, proc in self._call_history:
713747
res = self._hookexec(self, [method], kwargs)
@@ -718,21 +752,13 @@ def _maybe_apply_history(self, method):
718752
class HookImpl(object):
719753
def __init__(self, plugin, plugin_name, function, hook_impl_opts):
720754
self.function = function
721-
self.argnames = varnames(self.function)
755+
self.argnames, self.kwargnames = varnames(self.function)
722756
self.plugin = plugin
723757
self.opts = hook_impl_opts
724758
self.plugin_name = plugin_name
725759
self.__dict__.update(hook_impl_opts)
726760

727761

728-
class PluginValidationError(Exception):
729-
""" plugin failed validation. """
730-
731-
732-
class HookCallError(Exception):
733-
""" Hook was called wrongly. """
734-
735-
736762
if hasattr(inspect, 'signature'):
737763
def _formatdef(func):
738764
return "%s%s" % (

Diff for: testing/test_details.py

+31
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import warnings
12
from pluggy import PluginManager, HookimplMarker, HookspecMarker
23

34

@@ -62,3 +63,33 @@ class Module(object):
6263
# register() would raise an error
6364
pm.register(module, 'donttouch')
6465
assert pm.get_plugin('donttouch') is module
66+
67+
68+
def test_warning_on_call_vs_hookspec_arg_mismatch():
69+
"""Verify that is a hook is called with less arguments then defined in the
70+
spec that a warning is emitted.
71+
"""
72+
class Spec:
73+
@hookspec
74+
def myhook(self, arg1, arg2):
75+
pass
76+
77+
class Plugin:
78+
@hookimpl
79+
def myhook(self, arg1):
80+
pass
81+
82+
pm = PluginManager(hookspec.project_name)
83+
pm.register(Plugin())
84+
pm.add_hookspecs(Spec())
85+
86+
with warnings.catch_warnings(record=True) as warns:
87+
warnings.simplefilter('always')
88+
89+
# calling should trigger a warning
90+
pm.hook.myhook(arg1=1)
91+
92+
assert len(warns) == 1
93+
warning = warns[-1]
94+
assert issubclass(warning.category, Warning)
95+
assert "Argument(s) ('arg2',)" in str(warning.message)

Diff for: testing/test_helpers.py

+8-8
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,16 @@ class B(object):
1313
def __call__(self, z):
1414
pass
1515

16-
assert varnames(f) == ("x",)
17-
assert varnames(A().f) == ('y',)
18-
assert varnames(B()) == ('z',)
16+
assert varnames(f) == (("x",), ())
17+
assert varnames(A().f) == (('y',), ())
18+
assert varnames(B()) == (('z',), ())
1919

2020

2121
def test_varnames_default():
2222
def f(x, y=3):
2323
pass
2424

25-
assert varnames(f) == ("x",)
25+
assert varnames(f) == (("x",), ("y",))
2626

2727

2828
def test_varnames_class():
@@ -40,10 +40,10 @@ def __init__(self, x):
4040
class F(object):
4141
pass
4242

43-
assert varnames(C) == ("x",)
44-
assert varnames(D) == ()
45-
assert varnames(E) == ("x",)
46-
assert varnames(F) == ()
43+
assert varnames(C) == (("x",), ())
44+
assert varnames(D) == ((), ())
45+
assert varnames(E) == (("x",), ())
46+
assert varnames(F) == ((), ())
4747

4848

4949
def test_formatdef():

0 commit comments

Comments
 (0)