Skip to content
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

Merged
merged 5 commits into from
Feb 25, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 63 additions & 37 deletions pluggy.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import sys
import inspect
import warnings

__version__ = '0.5.0'

Expand All @@ -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.

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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"
Copy link
Member

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.

Copy link
Contributor Author

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?

Copy link
Member

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:

def foobar(x, y, z=4):
    pass  
  • positional arguments: caller passes by position so order matters: foobar(1, 2, 10)
  • keyword arguments: caller passes using keyword syntax so order does not matter: foobar(y=2, x=1, z=10)
  • default arguments: caller does not need to pass them on: 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:

def foobar(*, x, y, z=4):
    pass

(Python 3 only of course)

AFAIU, pluggy always calls using keyword arguments, so specs are implicitly defined as:

def pytest_deselectitems(*, items, session):
    pass

And that issue is completely separated from default values.

Am I missing something?

Copy link
Contributor Author

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 to hookspec 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.

Copy link
Member

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!

Copy link
Contributor Author

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.

"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
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -581,7 +595,7 @@ def varnames(func):
try:
func = func.__init__
except AttributeError:
return ()
return (), ()
Copy link
Member

Choose a reason for hiding this comment

The 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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Copy link
Member

Choose a reason for hiding this comment

The 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

Copy link
Member

Choose a reason for hiding this comment

The 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

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@RonnyPfannschmidt you got a point, we should increase the version to 0.5.0 and mention this change in the CHANGELOG.

In addition, should we make this method private and remove it from the public API?

Copy link
Member

@nicoddemus nicoddemus Feb 24, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Afaik this isn't "really" part of the public API due to the contents of pluggy.all?

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 pluggy uses it as far as we know, and in case someone complains and makes a good case for it we can reintroduce it and promote to the public API.

0.X is the period of time we can break things if we think that's better. 😉

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@RonnyPfannschmidt if you agree then I won't bother

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i do agree on practicality vs purity grounds

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@RonnyPfannschmidt so do you still want the wrapper or no?

Copy link
Member

Choose a reason for hiding this comment

The 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)
Expand All @@ -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:
Expand All @@ -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:
Copy link
Member

Choose a reason for hiding this comment

The 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 cache["_varnames"] raise a TypeError.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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 dict" in the first place. Can't we just have a single global cache which hashes by object id?

Maybe @hpk42 can comment?

Copy link
Member

Choose a reason for hiding this comment

The 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

Copy link
Contributor Author

@goodboy goodboy Feb 16, 2017

Choose a reason for hiding this comment

The 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 varnames() is only normally called once at PluginManager.register() time (really only once for each hookspec or hookimpl) so I might even go so far as to say this whole cache idea is a pre-mature optimization.

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 weakref.WeakValueDictionary and get the same gc behavior no?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nicoddemus btw do you think this is worth making a separate issue?

Copy link
Member

Choose a reason for hiding this comment

The 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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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!

Copy link
Member

Choose a reason for hiding this comment

The 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

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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):
Expand All @@ -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)
Expand All @@ -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"):
Expand All @@ -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:
Expand All @@ -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):
Expand Down Expand Up @@ -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)
Expand All @@ -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" % (
Expand Down
31 changes: 31 additions & 0 deletions testing/test_details.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import warnings
from pluggy import PluginManager, HookimplMarker, HookspecMarker


Expand Down Expand Up @@ -62,3 +63,33 @@ class Module(object):
# register() would raise an error
pm.register(module, 'donttouch')
assert pm.get_plugin('donttouch') is module


def test_warning_on_call_vs_hookspec_arg_mismatch():
"""Verify that is a hook is called with less arguments then defined in the
spec that a warning is emitted.
"""
class Spec:
@hookspec
def myhook(self, arg1, arg2):
pass

class Plugin:
@hookimpl
def myhook(self, arg1):
pass

pm = PluginManager(hookspec.project_name)
pm.register(Plugin())
pm.add_hookspecs(Spec())

with warnings.catch_warnings(record=True) as warns:
warnings.simplefilter('always')

# calling should trigger a warning
pm.hook.myhook(arg1=1)

assert len(warns) == 1
warning = warns[-1]
assert issubclass(warning.category, Warning)
assert "Argument(s) ('arg2',)" in str(warning.message)
16 changes: 8 additions & 8 deletions testing/test_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,16 @@ class B(object):
def __call__(self, z):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be possible to test the generated warnings?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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():
Expand All @@ -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():
Expand Down