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

Automatically handle different signatures in Callable #1260

Merged
merged 24 commits into from Apr 10, 2017
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
80176f1
Added core.util.argspec utility
jlstevens Apr 10, 2017
bbbcf80
Added Callable.argspec property
jlstevens Apr 10, 2017
ce4b284
Callable now promotes posargs to kwargs if possible
jlstevens Apr 10, 2017
edb2606
Added assertContains method to MockLoggingHandler
jlstevens Apr 10, 2017
2be3cbb
Added 18 Callable unit tests in tests/testcallable.py
jlstevens Apr 10, 2017
51a1016
Fixed typo in docstring of argspec utility
jlstevens Apr 10, 2017
a60ec5c
Fixed py3 handling of ParameterizedFunction.__call__ in argspec
jlstevens Apr 10, 2017
c05d369
Added Callable tests of parameterized function instances
jlstevens Apr 10, 2017
c74f3ea
Added validate_dynamic_argspec utility to core.util
jlstevens Apr 10, 2017
3f3655d
Validated callback signature in DynamicMap constructor
jlstevens Apr 10, 2017
25c91b6
Generalized how DynamicMap._execute_callback invokes Callable
jlstevens Apr 10, 2017
50022df
Computing *args and **kwargs outside of memoization context manager
jlstevens Apr 10, 2017
3d5be47
Fixed invalid unit test definitions in testdynamic.py
jlstevens Apr 10, 2017
d5f060e
validate_dynamic_argspec now handles positional stream arguments
jlstevens Apr 10, 2017
32b3811
validate_dynamic_argspec now supports streams as positional args
jlstevens Apr 10, 2017
d5063f9
Fixed bug in validate_dynamic_argspec utility
jlstevens Apr 10, 2017
f5efe4e
validate_dynamic_argspec now only raises KeyError exceptions
jlstevens Apr 10, 2017
0fae1e5
Added 10 unit tests for different allowed Callable signatures
jlstevens Apr 10, 2017
09fec6d
Added unit test for when kdim posargs have different names
jlstevens Apr 10, 2017
ebc8ffa
Improved validation in validate_dynamic_argspec utility
jlstevens Apr 10, 2017
fccdab4
Formatting fixes to comments
jlstevens Apr 10, 2017
5ec7a2b
Added unit tests for invalid call conditions
jlstevens Apr 10, 2017
111e333
Unmatched positional kdims must be at the start of the signature
jlstevens Apr 10, 2017
f5c370c
Fixed exception message
jlstevens Apr 10, 2017
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
20 changes: 20 additions & 0 deletions holoviews/core/spaces.py
Expand Up @@ -442,6 +442,10 @@ def __init__(self, callable, **params):
super(Callable, self).__init__(callable=callable, **params)
self._memoized = {}

@property
def argspec(self):
return util.argspec(self.callable)

def __call__(self, *args, **kwargs):
inputs = [i for i in self.inputs if isinstance(i, DynamicMap)]
streams = []
Expand All @@ -456,6 +460,22 @@ def __call__(self, *args, **kwargs):
if memoize and hashed_key in self._memoized:
return self._memoized[hashed_key]

if self.argspec.varargs is not None:
# Missing information on positional argument names, cannot promote to keywords
pass
elif len(args) != 0: # Turn positional arguments into keyword arguments
pos_kwargs = {k:v for k,v in zip(self.argspec.args, args)}
ignored = range(len(self.argspec.args),len(args))
if len(ignored):
self.warning('Ignoring extra positional argument %s'
% ', '.join('%s' % i for i in ignored))
clashes = set(pos_kwargs.keys()) & set(kwargs.keys())
if clashes:
self.warning('Positional arguments %r overriden by keywords'
% list(clashes))
args, kwargs = (), dict(pos_kwargs, **kwargs)


ret = self.callable(*args, **kwargs)
if hashed_key is not None:
self._memoized = {hashed_key : ret}
Expand Down
29 changes: 29 additions & 0 deletions holoviews/core/util.py
@@ -1,10 +1,12 @@
import os, sys, warnings, operator
import numbers
import inspect
import itertools
import string, fnmatch
import unicodedata
import datetime as dt
from collections import defaultdict, Counter
from functools import partial

import numpy as np
import param
Expand Down Expand Up @@ -99,6 +101,33 @@ def deephash(obj):
generator_types = (izip, xrange, types.GeneratorType)



def argspec(callable_obj):
"""
Returns an ArgSpec object for functions, staticmethods, instance
methods, classmethods and partials.

Note that the args list for instance and class methods are those as
seen by the user. In other words, the first argument with is
Copy link
Member

Choose a reason for hiding this comment

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

s/with/which?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks. Fixed in 51a1016

conventionally called 'self' or 'cls' is omitted in these cases.
"""
if inspect.isfunction(callable_obj): # functions and staticmethods
return inspect.getargspec(callable_obj)
elif isinstance(callable_obj, partial): # partials
arglen = len(callable_obj.args)
spec = inspect.getargspec(callable_obj.func)
args = [arg for arg in spec.args[arglen:] if arg not in callable_obj.keywords]
elif inspect.ismethod(callable_obj): # instance and class methods
spec = inspect.getargspec(callable_obj)
args = spec.args[1:]
else: # callable objects
return argspec(callable_obj.__call__)

return inspect.ArgSpec(args = args,
varargs = spec.varargs,
keywords = spec.keywords,
defaults = spec.defaults)

def process_ellipses(obj, key, vdim_selection=False):
"""
Helper function to pad a __getitem__ key with the right number of
Expand Down
24 changes: 19 additions & 5 deletions tests/__init__.py
Expand Up @@ -27,6 +27,8 @@ class MockLoggingHandler(logging.Handler):
def __init__(self, *args, **kwargs):
self.messages = {'DEBUG': [], 'INFO': [], 'WARNING': [],
'ERROR': [], 'CRITICAL': [], 'VERBOSE':[]}
self.param_methods = {'WARNING':'param.warning()', 'INFO':'param.message()',
'VERBOSE':'param.verbose()', 'DEBUG':'param.debug()'}
super(MockLoggingHandler, self).__init__(*args, **kwargs)

def emit(self, record):
Expand Down Expand Up @@ -54,18 +56,30 @@ def assertEndsWith(self, level, substring):
Assert that the last line captured at the given level ends with
a particular substring.
"""
methods = {'WARNING':'param.warning()', 'INFO':'param.message()',
'VERBOSE':'param.verbose()', 'DEBUG':'param.debug()'}
msg='\n\n{method}: {last_line}\ndoes not end with:\n{substring}'
last_line = self.tail(level, n=1)
if len(last_line) == 0:
raise AssertionError('Missing {method} output: {repr(substring)}'.format(
method=methods[level], substring=repr(substring)))
raise AssertionError('Missing {method} output: {substring}'.format(
method=self.param_methods[level], substring=repr(substring)))
if not last_line[0].endswith(substring):
raise AssertionError(msg.format(method=methods[level],
raise AssertionError(msg.format(method=self.param_methods[level],
last_line=repr(last_line[0]),
substring=repr(substring)))

def assertContains(self, level, substring):
"""
Assert that the last line captured at the given level contains a
particular substring.
"""
msg='\n\n{method}: {last_line}\ndoes not contain:\n{substring}'
last_line = self.tail(level, n=1)
if len(last_line) == 0:
raise AssertionError('Missing {method} output: {substring}'.format(
method=self.param_methods[level], substring=repr(substring)))
if substring not in last_line[0]:
raise AssertionError(msg.format(method=self.param_methods[level],
last_line=repr(last_line[0]),
substring=repr(substring)))


class LoggingComparisonTestCase(ComparisonTestCase):
Expand Down
119 changes: 119 additions & 0 deletions tests/testcallable.py
@@ -0,0 +1,119 @@
# -*- coding: utf-8 -*-
"""
Unit tests of the Callable object that wraps user callbacks
"""
import param
from holoviews.element.comparison import ComparisonTestCase
from holoviews.core.spaces import Callable
from functools import partial

from . import LoggingComparisonTestCase

class CallableClass(object):

def __call__(self, *testargs):
return sum(testargs)


class ParamFunc(param.ParameterizedFunction):

a = param.Integer(default=1)
b = param.Number(default=1)

def __call__(self, **params):
p = param.ParamOverrides(self, params)
return p.a * p.b


class TestSimpleCallableInvocation(LoggingComparisonTestCase):

def setUp(self):
super(TestSimpleCallableInvocation, self).setUp()

def test_callable_fn(self):
def callback(x): return x
self.assertEqual(Callable(callback)(3), 3)

def test_callable_lambda(self):
self.assertEqual(Callable(lambda x,y: x+y)(3,5), 8)

def test_callable_lambda_extras(self):
substr = "Ignoring extra positional argument"
self.assertEqual(Callable(lambda x,y: x+y)(3,5,10), 8)
self.log_handler.assertContains('WARNING', substr)

def test_callable_lambda_extras_kwargs(self):
substr = "['x'] overriden by keywords"
self.assertEqual(Callable(lambda x,y: x+y)(3,5,x=10), 15)
self.log_handler.assertEndsWith('WARNING', substr)

def test_callable_partial(self):
self.assertEqual(Callable(partial(lambda x,y: x+y,x=4))(5), 9)

def test_callable_class(self):
self.assertEqual(Callable(CallableClass())(1,2,3,4), 10)

def test_callable_paramfunc(self):
self.assertEqual(Callable(ParamFunc)(a=3,b=5), 15)


class TestCallableArgspec(ComparisonTestCase):

def test_callable_fn_argspec(self):
def callback(x): return x
self.assertEqual(Callable(callback).argspec.args, ['x'])
self.assertEqual(Callable(callback).argspec.keywords, None)

def test_callable_lambda_argspec(self):
self.assertEqual(Callable(lambda x,y: x+y).argspec.args, ['x','y'])
self.assertEqual(Callable(lambda x,y: x+y).argspec.keywords, None)

def test_callable_partial_argspec(self):
self.assertEqual(Callable(partial(lambda x,y: x+y,x=4)).argspec.args, ['y'])
self.assertEqual(Callable(partial(lambda x,y: x+y,x=4)).argspec.keywords, None)

def test_callable_class_argspec(self):
self.assertEqual(Callable(CallableClass()).argspec.args, [])
self.assertEqual(Callable(CallableClass()).argspec.keywords, None)
self.assertEqual(Callable(CallableClass()).argspec.varargs, 'testargs')

def test_callable_paramfunc_argspec(self):
self.assertEqual(Callable(ParamFunc).argspec.args, [])
self.assertEqual(Callable(ParamFunc).argspec.keywords, 'params')
self.assertEqual(Callable(ParamFunc).argspec.varargs, None)


class TestKwargCallableInvocation(ComparisonTestCase):
"""
Test invocation of Callable with kwargs, even for callbacks with
positional arguments.
"""

def test_callable_fn(self):
def callback(x): return x
self.assertEqual(Callable(callback)(x=3), 3)

def test_callable_lambda(self):
self.assertEqual(Callable(lambda x,y: x+y)(x=3,y=5), 8)

def test_callable_partial(self):
self.assertEqual(Callable(partial(lambda x,y: x+y,x=4))(y=5), 9)

def test_callable_paramfunc(self):
self.assertEqual(Callable(ParamFunc)(a=3,b=5), 15)


class TestMixedCallableInvocation(ComparisonTestCase):
"""
Test mixed invocation of Callable with kwargs.
"""

def test_callable_mixed_1(self):
def mixed_example(a,b, c=10, d=20):
return a+b+c+d
self.assertEqual(Callable(mixed_example)(a=3,b=5), 38)

def test_callable_mixed_2(self):
def mixed_example(a,b, c=10, d=20):
return a+b+c+d
self.assertEqual(Callable(mixed_example)(3,5,5), 33)