Skip to content

Commit

Permalink
Add bind function and associated reactive API (#460)
Browse files Browse the repository at this point in the history
  • Loading branch information
philippjfr authored Sep 23, 2023
1 parent 833b4a8 commit b3c6508
Show file tree
Hide file tree
Showing 11 changed files with 2,625 additions and 120 deletions.
812 changes: 812 additions & 0 deletions doc/user_guide/Reactive_Expressions.ipynb

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions doc/user_guide/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ This user guide provides detailed information about how to use Param, assuming y
- [Parameters](./Parameters): Using parameters (Class vs. instance parameters, setting defaults, etc.)
- [Parameter Types](./Parameter_Types): Predefined Parameter classes available for your use
- [Dependencies and Watchers](./Dependencies_and_Watchers): Expressing relationships between parameters and parameters or code, and triggering events
- [Reactive Expressions](./Reactive_Expressions): How to write expressions and functions that automatically re-evaluate when their parameter inputs change.
- [Serialization and Persistence](./Serialization_and_Persistence): Saving the state of a Parameterized object to a text, script, or pickle file
- [Outputs](./Outputs): Output types and connecting output to Parameter inputs
- [Logging and Warnings](./Logging_and_Warnings): Logging, messaging, warning, and raising errors on Parameterized objects
Expand All @@ -24,6 +25,7 @@ Simplifying Codebases <Simplifying_Codebases>
Parameters <Parameters>
Parameter Types <Parameter_Types>
Dependencies and Watchers <Dependencies_and_Watchers>
Reactive Expressions <Reactive_Expressions>
Serialization and Persistence <Serialization_and_Persistence>
Outputs <Outputs>
Logging and Warnings <Logging_and_Warnings>
Expand Down
13 changes: 8 additions & 5 deletions param/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,13 @@

from . import version # noqa: api import

from .parameterized import ( Undefined,
from .depends import depends # noqa: api import
from .parameterized import (
Parameterized, Parameter, String, ParameterizedFunction, ParamOverrides,
descendents, get_logger, instance_descriptor, dt_types,
_int_types)

from .parameterized import (batch_watch, depends, output, script_repr, # noqa: api import
Undefined, descendents, get_logger, instance_descriptor, dt_types,
_int_types
)
from .parameterized import (batch_watch, output, script_repr, # noqa: api import
discard_events, edit_constant, instance_descriptor)
from .parameterized import shared_parameters # noqa: api import
from .parameterized import logging_level # noqa: api import
Expand Down Expand Up @@ -3221,3 +3222,5 @@ def exceptions_summarized():
import sys
etype, value, tb = sys.exc_info()
print(f"{etype.__name__}: {value}", file=sys.stderr)

from .reactive import bind, reactive # noqa: api import
63 changes: 63 additions & 0 deletions param/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import traceback
import warnings

from collections import defaultdict
from textwrap import dedent
from threading import get_ident
from collections import abc
Expand Down Expand Up @@ -186,3 +187,65 @@ def _dict_update(dictionary, **kwargs):
d = dictionary.copy()
d.update(kwargs)
return d


def full_groupby(l, key=lambda x: x):
"""
Groupby implementation which does not require a prior sort
"""
d = defaultdict(list)
for item in l:
d[key(item)].append(item)
return d.items()


def iscoroutinefunction(function):
"""
Whether the function is an asynchronous coroutine function.
"""
if not hasattr(inspect, 'iscoroutinefunction'):
return False
import asyncio
try:
return (
inspect.isasyncgenfunction(function) or
asyncio.iscoroutinefunction(function)
)
except AttributeError:
return False


def flatten(line):
"""
Flatten an arbitrarily nested sequence.
Inspired by: pd.core.common.flatten
Parameters
----------
line : sequence
The sequence to flatten
Notes
-----
This only flattens list, tuple, and dict sequences.
Returns
-------
flattened : generator
"""
for element in line:
if any(isinstance(element, tp) for tp in (list, tuple, dict)):
yield from flatten(element)
else:
yield element


def accept_arguments(f):
"""
Decorator for decorators that accept arguments
"""
@functools.wraps(f)
def _f(*args, **kwargs):
return lambda actual_f: f(actual_f, *args, **kwargs)
return _f
205 changes: 205 additions & 0 deletions param/depends.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import weakref

from collections import defaultdict
from functools import wraps

from .parameterized import (
Parameter, Parameterized, ParameterizedMetaclass
)
from ._utils import accept_arguments, iscoroutinefunction

# Hooks to apply to depends and bind arguments to turn them into valid parameters

_reactive_display_objs = weakref.WeakSet()
_display_accessors = {}
_dependency_transforms = []

def register_display_accessor(name, accessor, force=False):
if name in _display_accessors and not force:
raise KeyError(
'Display accessor {name!r} already registered. Override it '
'by setting force=True or unregister the existing accessor first.')
_display_accessors[name] = accessor
for fn in _reactive_display_objs:
setattr(fn, name, accessor(fn))

def unregister_display_accessor(name):
if name not in _display_accessors:
raise KeyError('No such display accessor: {name!r}')
del _display_accessors[name]
for fn in _reactive_display_objs:
delattr(fn, name)

def register_depends_transform(transform):
"""
Appends a transform to extract potential parameter dependencies
from an object.
Arguments
---------
transform: Callable[Any, Any]
"""
return _dependency_transforms.append(transform)

def transform_dependency(arg):
"""
Transforms arguments for depends and bind functions applying any
registered dependency transforms. This is useful for adding
handling for depending on objects that are not simple Parameters or
functions with dependency definitions.
"""
for transform in _dependency_transforms:
if isinstance(arg, Parameter) or hasattr(arg, '_dinfo'):
break
arg = transform(arg)
return arg

def eval_function_with_deps(function):
"""Evaluates a function after resolving its dependencies.
Calls and returns a function after resolving any dependencies
stored on the _dinfo attribute and passing the resolved values
as arguments.
"""
args, kwargs = (), {}
if hasattr(function, '_dinfo'):
arg_deps = function._dinfo['dependencies']
kw_deps = function._dinfo.get('kw', {})
if kw_deps or any(isinstance(d, Parameter) for d in arg_deps):
args = (getattr(dep.owner, dep.name) for dep in arg_deps)
kwargs = {n: getattr(dep.owner, dep.name) for n, dep in kw_deps.items()}
return function(*args, **kwargs)

def resolve_value(value):
"""
Resolves the current value of a dynamic reference.
"""
if isinstance(value, (list, tuple)):
return type(value)(resolve_value(v) for v in value)
elif isinstance(value, dict):
return type(value)((k, resolve_value(v)) for k, v in value)
elif isinstance(value, slice):
return slice(
resolve_value(value.start),
resolve_value(value.stop),
resolve_value(value.step)
)
value = transform_dependency(value)
if hasattr(value, '_dinfo'):
value = eval_function_with_deps(value)
elif isinstance(value, Parameter):
value = getattr(value.owner, value.name)
return value

def resolve_ref(reference):
"""
Resolves all parameters a dynamic reference depends on.
"""
if isinstance(reference, (list, tuple, set)):
return [r for v in reference for r in resolve_ref(v)]
elif isinstance(reference, dict):
return [r for v in reference.values() for r in resolve_ref(v)]
elif isinstance(reference, slice):
return [r for v in (reference.start, reference.stop, reference.step) for r in resolve_ref(v)]
reference = transform_dependency(reference)
if hasattr(reference, '_dinfo'):
dinfo = getattr(reference, '_dinfo', {})
args = list(dinfo.get('dependencies', []))
kwargs = list(dinfo.get('kw', {}).values())
refs = []
for arg in (args + kwargs):
refs.extend(resolve_ref(arg))
return refs
elif isinstance(reference, Parameter):
return [reference]
return []

@accept_arguments
def depends(func, *dependencies, watch=False, on_init=False, **kw):
"""Annotates a function or Parameterized method to express its dependencies.
The specified dependencies can be either be Parameter instances or if a
method is supplied they can be defined as strings referring to Parameters
of the class, or Parameters of subobjects (Parameterized objects that are
values of this object's parameters). Dependencies can either be on
Parameter values, or on other metadata about the Parameter.
Parameters
----------
watch : bool, optional
Whether to invoke the function/method when the dependency is updated,
by default False
on_init : bool, optional
Whether to invoke the function/method when the instance is created,
by default False
"""
dependencies, kw = (
tuple(transform_dependency(arg) for arg in dependencies),
{key: transform_dependency(arg) for key, arg in kw.items()}
)

if iscoroutinefunction(func):
@wraps(func)
async def _depends(*args, **kw):
return await func(*args, **kw)
else:
@wraps(func)
def _depends(*args, **kw):
return func(*args, **kw)

deps = list(dependencies)+list(kw.values())
string_specs = False
for dep in deps:
if isinstance(dep, str):
string_specs = True
elif hasattr(dep, '_dinfo'):
pass
elif not isinstance(dep, Parameter):
raise ValueError('The depends decorator only accepts string '
'types referencing a parameter or parameter '
'instances, found %s type instead.' %
type(dep).__name__)
elif not (isinstance(dep.owner, Parameterized) or
(isinstance(dep.owner, ParameterizedMetaclass))):
owner = 'None' if dep.owner is None else '%s class' % type(dep.owner).__name__
raise ValueError('Parameters supplied to the depends decorator, '
'must be bound to a Parameterized class or '
'instance, not %s.' % owner)

if (any(isinstance(dep, Parameter) for dep in deps) and
any(isinstance(dep, str) for dep in deps)):
raise ValueError('Dependencies must either be defined as strings '
'referencing parameters on the class defining '
'the decorated method or as parameter instances. '
'Mixing of string specs and parameter instances '
'is not supported.')
elif string_specs and kw:
raise AssertionError('Supplying keywords to the decorated method '
'or function is not supported when referencing '
'parameters by name.')

if not string_specs and watch: # string_specs case handled elsewhere (later), in Parameterized.__init__
if iscoroutinefunction(func):
async def cb(*events):
args = (getattr(dep.owner, dep.name) for dep in dependencies)
dep_kwargs = {n: getattr(dep.owner, dep.name) for n, dep in kw.items()}
await func(*args, **dep_kwargs)
else:
def cb(*events):
args = (getattr(dep.owner, dep.name) for dep in dependencies)
dep_kwargs = {n: getattr(dep.owner, dep.name) for n, dep in kw.items()}
return func(*args, **dep_kwargs)

grouped = defaultdict(list)
for dep in deps:
grouped[id(dep.owner)].append(dep)
for group in grouped.values():
group[0].owner.param.watch(cb, [dep.name for dep in group])

_dinfo = getattr(func, '_dinfo', {})
_dinfo.update({'dependencies': dependencies,
'kw': kw, 'watch': watch, 'on_init': on_init})

_depends._dinfo = _dinfo

return _depends
37 changes: 35 additions & 2 deletions param/ipython.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,16 @@
exist in the active namespace.
"""

__author__ = "Jean-Luc Stevens"

import re
import itertools
import textwrap
import uuid

import param

from param.depends import depends, register_display_accessor, resolve_ref
from param.reactive import reactive


# Whether to generate warnings when misformatted docstrings are found
WARN_MISFORMATTED_DOCSTRINGS = False
Expand Down Expand Up @@ -348,3 +351,33 @@ def params(self, parameter_s='', namespaces=None):
if not _loaded:
_loaded = True
ip.register_magics(ParamMagics)


class IPythonDisplay:
"""
Reactive display handler that updates the output.
"""

enabled = True

def __init__(self, reactive):
self._reactive = reactive

def __call__(self):
if isinstance(self._reactive, reactive):
cb = self._reactive._callback
@depends(*self._reactive._params, watch=True)
def update_handle(*args, **kwargs):
handle.update(cb())
else:
cb = self._reactive
@depends(*resolve_ref(cb), watch=True)
def update_handle(*args, **kwargs):
handle.update(cb())
try:
handle = display(cb(), display_id=uuid.uuid4().hex) # noqa
except TypeError:
raise NotImplementedError


register_display_accessor('_ipython_display_', IPythonDisplay)
Loading

0 comments on commit b3c6508

Please sign in to comment.