From dd9a6efaedede91cec9eea9b15ab38dcba7896b6 Mon Sep 17 00:00:00 2001 From: "Brian E. Granger" Date: Sat, 1 Feb 2014 16:06:40 -0800 Subject: [PATCH] Utter interact insanity. This involves a bunch of really complicated logic to handle the different ways that function parameters can be processed in Python. Most importantly, this includes support for *args in interact. --- IPython/html/widgets/__init__.py | 2 +- IPython/html/widgets/interaction.py | 194 ++++++++++++------ IPython/html/widgets/tests/__init__.py | 0 .../html/widgets/tests/test_interaction.py | 22 ++ 4 files changed, 154 insertions(+), 64 deletions(-) create mode 100644 IPython/html/widgets/tests/__init__.py create mode 100644 IPython/html/widgets/tests/test_interaction.py diff --git a/IPython/html/widgets/__init__.py b/IPython/html/widgets/__init__.py index b62a61aed..1425d77b9 100644 --- a/IPython/html/widgets/__init__.py +++ b/IPython/html/widgets/__init__.py @@ -9,4 +9,4 @@ from .widget_int import IntTextWidget, BoundedIntTextWidget, IntSliderWidget, In from .widget_selection import RadioButtonsWidget, ToggleButtonsWidget, DropdownWidget, SelectWidget from .widget_selectioncontainer import TabWidget, AccordionWidget from .widget_string import HTMLWidget, LatexWidget, TextWidget, TextareaWidget -from .interaction import interact, interactive +from .interaction import interact, interactive, annotate diff --git a/IPython/html/widgets/interaction.py b/IPython/html/widgets/interaction.py index 8d60d111f..2faa9af96 100644 --- a/IPython/html/widgets/interaction.py +++ b/IPython/html/widgets/interaction.py @@ -1,5 +1,4 @@ -"""Interact with functions using widgets. -""" +"""Interact with functions using widgets.""" #----------------------------------------------------------------------------- # Copyright (c) 2013, the IPython Development Team. @@ -13,10 +12,13 @@ # Imports #----------------------------------------------------------------------------- +from __future__ import print_function + try: # Python >= 3.3 from inspect import signature, Parameter except ImportError: from IPython.utils.signatures import signature, Parameter +from inspect import getcallargs from IPython.html.widgets import (Widget, TextWidget, FloatSliderWidget, IntSliderWidget, CheckboxWidget, DropdownWidget, @@ -30,6 +32,7 @@ from IPython.utils.py3compat import string_types, unicode_type def _matches(o, pattern): + """Match a pattern of types in a sequence.""" if not len(o) == len(pattern): return False comps = zip(o,pattern) @@ -49,9 +52,9 @@ def _get_min_max_value(min, max, value): elif value == 0: min, max, value = 0, 1, 0 elif isinstance(value, float): - min, max = -value, 3.0*value + min, max = (-value, 3.0*value) if value > 0 else (3.0*value, -value) elif isinstance(value, int): - min, max = -value, 3*value + min, max = (-value, 3*value) if value > 0 else (3*value, -value) else: raise TypeError('expected a number, got: %r' % value) else: @@ -67,8 +70,6 @@ def _widget_abbrev_single_value(o): values = o.values() w = DropdownWidget(value=values[0], values=values, labels=labels) return w - # Special case float and int == 0.0 - # get_range(value): elif isinstance(o, bool): return CheckboxWidget(value=o) elif isinstance(o, float): @@ -77,6 +78,8 @@ def _widget_abbrev_single_value(o): elif isinstance(o, int): min, max, value = _get_min_max_value(None, None, o) return IntSliderWidget(value=o, min=min, max=max) + else: + return None def _widget_abbrev(o): """Make widgets from abbreviations: single values, lists or tuples.""" @@ -99,92 +102,157 @@ def _widget_abbrev(o): elif all(isinstance(x, string_types) for x in o): return DropdownWidget(value=unicode_type(o[0]), values=[unicode_type(k) for k in o]) - else: return _widget_abbrev_single_value(o) -def _widget_or_abbrev(value): - if isinstance(value, Widget): - return value +def _widget_from_abbrev(abbrev): + """Build a Widget intstance given an abbreviation or Widget.""" + if isinstance(abbrev, Widget): + return abbrev - widget = _widget_abbrev(value) + widget = _widget_abbrev(abbrev) if widget is None: - raise ValueError("%r cannot be transformed to a Widget" % value) + raise ValueError("%r cannot be transformed to a Widget" % abbrev) return widget -def _widget_for_param(param, kwargs): - """Get a widget for a parameter. - - We look for, in this order: - - keyword arguments passed to interact[ive]() that match the parameter name. - - function annotations - - default values - - Returns an instance of Widget, or None if nothing suitable is found. - - Raises ValueError if the kwargs or annotation value cannot be made into - a widget. - """ - if param.name in kwargs: - return _widget_or_abbrev(kwargs.pop(param.name)) - - if param.annotation is not Parameter.empty: - return _widget_or_abbrev(param.annotation) - - if param.default is not Parameter.empty: - # Returns None if it's not suitable - return _widget_abbrev_single_value(param.default) - - return None +def _yield_abbreviations_for_parameter(param, args, kwargs): + """Get an abbreviation for a function parameter.""" + # print(param, args, kwargs) + name = param.name + kind = param.kind + ann = param.annotation + default = param.default + empty = Parameter.empty + if kind == Parameter.POSITIONAL_ONLY: + if args: + yield name, args.pop(0), False + elif ann is not empty: + yield name, ann, False + else: + yield None, None, None + elif kind == Parameter.POSITIONAL_OR_KEYWORD: + if name in kwargs: + yield name, kwargs.pop(name), True + elif args: + yield name, args.pop(0), False + elif ann is not empty: + if default is empty: + yield name, ann, False + else: + yield name, ann, True + elif default is not empty: + yield name, default, True + else: + yield None, None, None + elif kind == Parameter.VAR_POSITIONAL: + # In this case name=args or something and we don't actually know the names. + for item in args[::]: + args.pop(0) + yield '', item, False + elif kind == Parameter.KEYWORD_ONLY: + if name in kwargs: + yield name, kwargs.pop(name), True + elif ann is not empty: + yield name, ann, True + elif default is not empty: + yield name, default, True + else: + yield None, None, None + elif kind == Parameter.VAR_KEYWORD: + # In this case name=kwargs and we yield the items in kwargs with their keys. + for k, v in kwargs.copy().items(): + kwargs.pop(k) + yield k, v, True -def interactive(f, **kwargs): - """Build a group of widgets for setting the inputs to a function.""" - +def _find_abbreviations(f, args, kwargs): + """Find the abbreviations for a function and args/kwargs passed to interact.""" + new_args = [] + new_kwargs = [] + for param in signature(f).parameters.values(): + for name, value, kw in _yield_abbreviations_for_parameter(param, args, kwargs): + if value is None: + raise ValueError('cannot find widget or abbreviation for argument: {!r}'.format(name)) + if kw: + new_kwargs.append((name, value)) + else: + new_args.append((name, value)) + return new_args, new_kwargs + +def _widgets_from_abbreviations(seq): + """Given a sequence of (name, abbrev) tuples, return a sequence of Widgets.""" + result = [] + for name, abbrev in seq: + widget = _widget_from_abbrev(abbrev) + widget.description = name + result.append(widget) + return result + +def interactive(f, *args, **kwargs): + """Build a group of widgets to interact with a function.""" co = kwargs.pop('clear_output', True) - # First convert all args to Widget instances - widgets = [] + args_widgets = [] + kwargs_widgets = [] container = ContainerWidget() container.result = None + container.args = [] container.kwargs = dict() - - # Extract parameters from the function signature - for param in signature(f).parameters.values(): - param_widget = _widget_for_param(param, kwargs) - if param_widget is not None: - param_widget.description = param.name - widgets.append(param_widget) - - # Extra parameters from keyword args - we assume f takes **kwargs - for name, value in sorted(kwargs.items(), key = lambda x: x[0]): - widget = _widget_or_abbrev(value) - widget.description = name - widgets.append(widget) - + # We need this to be a list as we iteratively pop elements off it + args = list(args) + kwargs = kwargs.copy() + + new_args, new_kwargs = _find_abbreviations(f, args, kwargs) + # Before we proceed, let's make sure that the user has passed a set of args+kwargs + # that will lead to a valid call of the function. This protects against unspecified + # and doubly-specified arguments. + getcallargs(f, *[v for n,v in new_args], **{n:v for n,v in new_kwargs}) + # Now build the widgets from the abbreviations. + args_widgets.extend(_widgets_from_abbreviations(new_args)) + kwargs_widgets.extend(_widgets_from_abbreviations(new_kwargs)) + kwargs_widgets.extend(_widgets_from_abbreviations(sorted(kwargs.items(), key = lambda x: x[0]))) + # This has to be done as an assignment, not using container.children.append, # so that traitlets notices the update. - container.children = widgets + container.children = args_widgets + kwargs_widgets # Build the callback def call_f(name, old, new): - actual_kwargs = {} - for widget in widgets: + container.args = [] + for widget in args_widgets: + value = widget.value + container.args.append(value) + for widget in kwargs_widgets: value = widget.value container.kwargs[widget.description] = value - actual_kwargs[widget.description] = value if co: clear_output(wait=True) - container.result = f(**actual_kwargs) + container.result = f(*container.args, **container.kwargs) # Wire up the widgets - for widget in widgets: + for widget in args_widgets: + widget.on_trait_change(call_f, 'value') + for widget in kwargs_widgets: widget.on_trait_change(call_f, 'value') container.on_displayed(lambda _: call_f(None, None, None)) return container -def interact(f, **kwargs): +def interact(f, *args, **kwargs): """Interact with a function using widgets.""" - w = interactive(f, **kwargs) + w = interactive(f, *args, **kwargs) f.widget = w display(w) + +def annotate(**kwargs): + """Python 3 compatible function annotation for Python 2.""" + if not kwargs: + raise ValueError('annotations must be provided as keyword arguments') + def dec(f): + if hasattr(f, '__annotations__'): + for k, v in kwargs.items(): + f.__annotations__[k] = v + else: + f.__annotations__ = kwargs + return f + return dec + diff --git a/IPython/html/widgets/tests/__init__.py b/IPython/html/widgets/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/IPython/html/widgets/tests/test_interaction.py b/IPython/html/widgets/tests/test_interaction.py new file mode 100644 index 000000000..d2aab8a7b --- /dev/null +++ b/IPython/html/widgets/tests/test_interaction.py @@ -0,0 +1,22 @@ +"""Test interact and interactive.""" + +#----------------------------------------------------------------------------- +# Copyright (C) 2014 The IPython Development Team +# +# Distributed under the terms of the BSD License. The full license is in +# the file COPYING, distributed as part of this software. +#----------------------------------------------------------------------------- + +#----------------------------------------------------------------------------- +# Imports +#----------------------------------------------------------------------------- + +import nose.tools as nt + +from IPython.html.widgets import interact, interactive +from IPython.html.widgets import interaction + +#----------------------------------------------------------------------------- +# Test functions +#----------------------------------------------------------------------------- +