-
Notifications
You must be signed in to change notification settings - Fork 125
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Hook call mismatch warnings #42
Changes from all commits
11955b7
e27338e
96b6494
4ab6b1f
e5cfb31
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,5 +1,6 @@ | ||
import sys | ||
import inspect | ||
import warnings | ||
|
||
__version__ = '0.5.0' | ||
|
||
|
@@ -9,6 +10,14 @@ | |
_py3 = sys.version_info > (3, 0) | ||
|
||
|
||
class PluginValidationError(Exception): | ||
""" plugin failed validation. """ | ||
|
||
|
||
class HookCallError(Exception): | ||
""" Hook was called wrongly. """ | ||
|
||
|
||
class HookspecMarker(object): | ||
""" Decorator helper class for marking functions as hook specifications. | ||
|
||
|
@@ -266,7 +275,9 @@ def __init__(self, project_name, implprefix=None): | |
self.hook = _HookRelay(self.trace.root.get("hook")) | ||
self._implprefix = implprefix | ||
self._inner_hookexec = lambda hook, methods, kwargs: \ | ||
_MultiCall(methods, kwargs, hook.spec_opts).execute() | ||
_MultiCall( | ||
methods, kwargs, specopts=hook.spec_opts, hook=hook | ||
).execute() | ||
|
||
def _hookexec(self, hook, methods, kwargs): | ||
# called from all hookcaller instances. | ||
|
@@ -412,14 +423,16 @@ def _verify_hook(self, hook, hookimpl): | |
"Plugin %r\nhook %r\nhistoric incompatible to hookwrapper" % | ||
(hookimpl.plugin_name, hook.name)) | ||
|
||
for arg in hookimpl.argnames: | ||
if arg not in hook.argnames: | ||
raise PluginValidationError( | ||
"Plugin %r\nhook %r\nargument %r not available\n" | ||
"plugin definition: %s\n" | ||
"available hookargs: %s" % | ||
(hookimpl.plugin_name, hook.name, arg, | ||
_formatdef(hookimpl.function), ", ".join(hook.argnames))) | ||
# positional arg checking | ||
notinspec = set(hookimpl.argnames) - set(hook.argnames) | ||
if notinspec: | ||
raise PluginValidationError( | ||
"Plugin %r for hook %r\nhookimpl definition: %s\n" | ||
"Argument(s) %s are declared in the hookimpl but " | ||
"can not be found in the hookspec" % | ||
(hookimpl.plugin_name, hook.name, | ||
_formatdef(hookimpl.function), notinspec) | ||
) | ||
|
||
def check_pending(self): | ||
""" Verify that all hooks which have not been verified against | ||
|
@@ -526,24 +539,25 @@ class _MultiCall(object): | |
# so we can remove it soon, allowing to avoid the below recursion | ||
# in execute() and simplify/speed up the execute loop. | ||
|
||
def __init__(self, hook_impls, kwargs, specopts={}): | ||
def __init__(self, hook_impls, kwargs, specopts={}, hook=None): | ||
self.hook = hook | ||
self.hook_impls = hook_impls | ||
self.kwargs = kwargs | ||
self.kwargs["__multicall__"] = self | ||
self.specopts = specopts | ||
self.caller_kwargs = kwargs # come from _HookCaller.__call__() | ||
self.caller_kwargs["__multicall__"] = self | ||
self.specopts = hook.spec_opts if hook else specopts | ||
|
||
def execute(self): | ||
all_kwargs = self.kwargs | ||
caller_kwargs = self.caller_kwargs | ||
self.results = results = [] | ||
firstresult = self.specopts.get("firstresult") | ||
|
||
while self.hook_impls: | ||
hook_impl = self.hook_impls.pop() | ||
try: | ||
args = [all_kwargs[argname] for argname in hook_impl.argnames] | ||
args = [caller_kwargs[argname] for argname in hook_impl.argnames] | ||
except KeyError: | ||
for argname in hook_impl.argnames: | ||
if argname not in all_kwargs: | ||
if argname not in caller_kwargs: | ||
raise HookCallError( | ||
"hook call must provide argument %r" % (argname,)) | ||
if hook_impl.hookwrapper: | ||
|
@@ -561,15 +575,15 @@ def __repr__(self): | |
status = "%d meths" % (len(self.hook_impls),) | ||
if hasattr(self, "results"): | ||
status = ("%d results, " % len(self.results)) + status | ||
return "<_MultiCall %s, kwargs=%r>" % (status, self.kwargs) | ||
return "<_MultiCall %s, kwargs=%r>" % (status, self.caller_kwargs) | ||
|
||
|
||
def varnames(func): | ||
"""Return argument name tuple for a function, method, class or callable. | ||
"""Return tuple of positional and keywrord argument names for a function, | ||
method, class or callable. | ||
|
||
In case of a class, its "__init__" method is considered. | ||
For methods the "self" parameter is not included unless you are passing | ||
an unbound method with Python3 (which has no support for unbound methods) | ||
In case of a class, its ``__init__`` method is considered. | ||
For methods the ``self`` parameter is not included. | ||
""" | ||
cache = getattr(func, "__dict__", {}) | ||
try: | ||
|
@@ -581,7 +595,7 @@ def varnames(func): | |
try: | ||
func = func.__init__ | ||
except AttributeError: | ||
return () | ||
return (), () | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should update the docs for this function given the new return value. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. i just noticed something dreadful, since this is a public name, this is a breaking change would warrant a major incompatible release There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. as an example, if we had used this in pytest and didn't do vendoring, this would be a release that breaks pytest There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @RonnyPfannschmidt you got a point, we should increase the version to In addition, should we make this method private and remove it from the public API? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Oh that's a good point. tox doesn't use it as well, neither does devpi using a local search (BB doesn't seem to have a search feature). I think we can safely rename it to an internal function to avoid confusion then. IMHO no need to provide a wrapper for it since nobody outside
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @RonnyPfannschmidt if you agree then I won't bother There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. i do agree on practicality vs purity grounds There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @RonnyPfannschmidt so do you still want the wrapper or no? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @tgoodlet its not needed, i meant that i agree with @nicoddemus that on grounds of probably controlled set of users and pre 1.0 we can just make it private without going the extra mile |
||
elif not inspect.isroutine(func): # callable object? | ||
try: | ||
func = getattr(func, '__call__', func) | ||
|
@@ -591,10 +605,14 @@ def varnames(func): | |
try: # func MUST be a function or method here or we won't parse any args | ||
spec = inspect.getargspec(func) | ||
except TypeError: | ||
return () | ||
return (), () | ||
|
||
args, defaults = spec.args, spec.defaults | ||
args = args[:-len(defaults)] if defaults else args | ||
args, defaults = tuple(spec.args), spec.defaults | ||
if defaults: | ||
index = -len(defaults) | ||
args, defaults = args[:index], tuple(args[index:]) | ||
else: | ||
defaults = () | ||
|
||
# strip any implicit instance arg | ||
if args: | ||
|
@@ -605,10 +623,10 @@ def varnames(func): | |
|
||
assert "self" not in args # best naming practises check? | ||
try: | ||
cache["_varnames"] = args | ||
cache["_varnames"] = args, defaults | ||
except TypeError: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I know this code was here before already, but I'm curious in which situations can There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @nicoddemus yeah I never really understood the weird "storing arg names in the function's Maybe @hpk42 can comment? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @tgoodlet i remember that this optimization safes a few hops, and going for a cache on the object is very practical as it indeed ties object lifetime and cache lifetime There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @RonnyPfannschmidt huh that's an interesting point. I've always understood that function lifetimes last from module import until interpreter exit unless you explicitly delete references to them. Also consider That being said @hpk42 had it there originally and I see no strong reason to remove it. Oh and if we did end up wanting to use a global cache we could just use There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @nicoddemus btw do you think this is worth making a separate issue? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Perhaps writing a series of banchmarks so we could have a safe suite to test optimizations would be something worth exploring. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @nicoddemus agreed that's what #37 is for ;) Maybe I'll hack on that next in an effort to rally some faith! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @tgoodlet for an weak dictionary there is a need to handle some things differently, unless im mistaken there will be a need to decompose to method objects, and it will create a new magical global managing lifetimes, personally i believe its much more safe to bind lifetimes to the objects directly There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @RonnyPfannschmidt I'm not sure it will matter but maybe there's something I'm missing. I do think we should leave it for now. If you think I should open discussion about then I'll make a separate issue :) |
||
pass | ||
return tuple(args) | ||
return args, defaults | ||
|
||
|
||
class _HookRelay(object): | ||
|
@@ -627,6 +645,8 @@ def __init__(self, name, hook_execute, specmodule_or_class=None, spec_opts=None) | |
self._wrappers = [] | ||
self._nonwrappers = [] | ||
self._hookexec = hook_execute | ||
self.argnames = None | ||
self.kwargnames = None | ||
if specmodule_or_class is not None: | ||
assert spec_opts is not None | ||
self.set_specification(specmodule_or_class, spec_opts) | ||
|
@@ -638,7 +658,8 @@ def set_specification(self, specmodule_or_class, spec_opts): | |
assert not self.has_spec() | ||
self._specmodule_or_class = specmodule_or_class | ||
specfunc = getattr(specmodule_or_class, self.name) | ||
argnames = varnames(specfunc) | ||
# get spec arg signature | ||
argnames, self.kwargnames = varnames(specfunc) | ||
self.argnames = ["__multicall__"] + list(argnames) | ||
self.spec_opts = spec_opts | ||
if spec_opts.get("historic"): | ||
|
@@ -658,6 +679,8 @@ def remove(wrappers): | |
raise ValueError("plugin %r not found" % (plugin,)) | ||
|
||
def _add_hookimpl(self, hookimpl): | ||
"""A an implementation to the callback chain. | ||
""" | ||
if hookimpl.hookwrapper: | ||
methods = self._wrappers | ||
else: | ||
|
@@ -679,6 +702,15 @@ def __repr__(self): | |
|
||
def __call__(self, **kwargs): | ||
assert not self.is_historic() | ||
if self.argnames: | ||
notincall = set(self.argnames) - set(['__multicall__']) - set( | ||
kwargs.keys()) | ||
if notincall: | ||
warnings.warn( | ||
"Argument(s) {0} which are declared in the hookspec " | ||
"can not be found in this hook call" | ||
.format(tuple(notincall)) | ||
) | ||
return self._hookexec(self, self._nonwrappers + self._wrappers, kwargs) | ||
|
||
def call_historic(self, proc=None, kwargs=None): | ||
|
@@ -708,6 +740,8 @@ def call_extra(self, methods, kwargs): | |
self._nonwrappers, self._wrappers = old | ||
|
||
def _maybe_apply_history(self, method): | ||
"""Apply call history to a new hookimpl if it is marked as historic. | ||
""" | ||
if self.is_historic(): | ||
for kwargs, proc in self._call_history: | ||
res = self._hookexec(self, [method], kwargs) | ||
|
@@ -718,21 +752,13 @@ def _maybe_apply_history(self, method): | |
class HookImpl(object): | ||
def __init__(self, plugin, plugin_name, function, hook_impl_opts): | ||
self.function = function | ||
self.argnames = varnames(self.function) | ||
self.argnames, self.kwargnames = varnames(self.function) | ||
self.plugin = plugin | ||
self.opts = hook_impl_opts | ||
self.plugin_name = plugin_name | ||
self.__dict__.update(hook_impl_opts) | ||
|
||
|
||
class PluginValidationError(Exception): | ||
""" plugin failed validation. """ | ||
|
||
|
||
class HookCallError(Exception): | ||
""" Hook was called wrongly. """ | ||
|
||
|
||
if hasattr(inspect, 'signature'): | ||
def _formatdef(func): | ||
return "%s%s" % ( | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -13,16 +13,16 @@ class B(object): | |
def __call__(self, z): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would it be possible to test the generated warnings? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes good call 👍 |
||
pass | ||
|
||
assert varnames(f) == ("x",) | ||
assert varnames(A().f) == ('y',) | ||
assert varnames(B()) == ('z',) | ||
assert varnames(f) == (("x",), ()) | ||
assert varnames(A().f) == (('y',), ()) | ||
assert varnames(B()) == (('z',), ()) | ||
|
||
|
||
def test_varnames_default(): | ||
def f(x, y=3): | ||
pass | ||
|
||
assert varnames(f) == ("x",) | ||
assert varnames(f) == (("x",), ("y",)) | ||
|
||
|
||
def test_varnames_class(): | ||
|
@@ -40,10 +40,10 @@ def __init__(self, x): | |
class F(object): | ||
pass | ||
|
||
assert varnames(C) == ("x",) | ||
assert varnames(D) == () | ||
assert varnames(E) == ("x",) | ||
assert varnames(F) == () | ||
assert varnames(C) == (("x",), ()) | ||
assert varnames(D) == ((), ()) | ||
assert varnames(E) == (("x",), ()) | ||
assert varnames(F) == ((), ()) | ||
|
||
|
||
def test_formatdef(): | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was under the impression we should always use the term "arguments" or "keyword arguments" when conveying information to users, given that for all purposes hookimpls should always match the argument names of hookspecs, but position is not of importance. At least that's how pluggy and pytest hooks work in my mind.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@nicoddemus the only thing is if we're going to support actual keyword arguments as in #43, then shouldn't we distinguish?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Isn't #43 about supporting default values?
Let's see if we are using the same nomenclature first. Considering this function:
foobar(1, 2, 10)
foobar(y=2, x=1, z=10)
foobar(1, 2) # z = 4
As far as I understand, hooks are always called using keyword arguments so order is not an issue, that's why my comment was referring to "Positional arguments" as confusing.
Note that I'm talking about the caller here, not the definition. The definition might for example force the caller to call a function using only keywords:
(Python 3 only of course)
AFAIU,
pluggy
always calls using keyword arguments, so specs are implicitly defined as:And that issue is completely separated from default values.
Am I missing something?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@nicoddemus no you're definitions are absolutely correct.
Sorry when I said "keyword" I meant to say "default".
So yes my concern is about being explicit with regard to
hookimpl
tohookspec
mismatches regarding default arguments which is really not in the scope of this PR (it's coupled here because originally #43 and this were one PR).I'll revert and thanks for clarifying.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh OK that explains it. 😄
Thanks!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@nicoddemus alright fixed. Let me know if there's anything else otherwise I'll squash the history.