-
-
Notifications
You must be signed in to change notification settings - Fork 2.1k
Support Arbitrary callbacks #2822
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
Changes from 20 commits
3488bbe
6623750
2f84022
fb60ce0
a9f757c
d6564d8
cb84367
00baa48
d57134b
f1a92a3
b1a0529
dfdb605
8891a71
939c010
b12c481
dfaf5d1
6360421
2b29699
a1e461f
ad73c29
33f6d74
d367a6e
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 |
---|---|---|
|
@@ -10,6 +10,7 @@ | |
Output, | ||
) | ||
from .exceptions import ( | ||
InvalidCallbackReturnValue, | ||
PreventUpdate, | ||
WildcardInLongCallback, | ||
MissingLongCallbackManagerError, | ||
|
@@ -226,6 +227,7 @@ def insert_callback( | |
manager=None, | ||
running=None, | ||
dynamic_creator=False, | ||
no_output=False, | ||
): | ||
if prevent_initial_call is None: | ||
prevent_initial_call = config_prevent_initial_callbacks | ||
|
@@ -234,7 +236,7 @@ def insert_callback( | |
output, prevent_initial_call, config_prevent_initial_callbacks | ||
) | ||
|
||
callback_id = create_callback_id(output, inputs) | ||
callback_id = create_callback_id(output, inputs, no_output) | ||
callback_spec = { | ||
"output": callback_id, | ||
"inputs": [c.to_dict() for c in inputs], | ||
|
@@ -248,6 +250,7 @@ def insert_callback( | |
"interval": long["interval"], | ||
}, | ||
"dynamic_creator": dynamic_creator, | ||
"no_output": no_output, | ||
} | ||
if running: | ||
callback_spec["running"] = running | ||
|
@@ -262,6 +265,7 @@ def insert_callback( | |
"raw_inputs": inputs, | ||
"manager": manager, | ||
"allow_dynamic_callbacks": dynamic_creator, | ||
"no_output": no_output, | ||
} | ||
callback_list.append(callback_spec) | ||
|
||
|
@@ -283,10 +287,12 @@ def register_callback( # pylint: disable=R0914 | |
# Insert callback with scalar (non-multi) Output | ||
insert_output = output | ||
multi = False | ||
has_output = True | ||
else: | ||
# Insert callback as multi Output | ||
insert_output = flatten_grouping(output) | ||
multi = True | ||
has_output = len(output) > 0 | ||
|
||
long = _kwargs.get("long") | ||
manager = _kwargs.get("manager") | ||
|
@@ -315,6 +321,7 @@ def register_callback( # pylint: disable=R0914 | |
manager=manager, | ||
dynamic_creator=allow_dynamic_callbacks, | ||
running=running, | ||
no_output=not has_output, | ||
) | ||
|
||
# pylint: disable=too-many-locals | ||
|
@@ -331,9 +338,12 @@ def wrap_func(func): | |
def add_context(*args, **kwargs): | ||
output_spec = kwargs.pop("outputs_list") | ||
app_callback_manager = kwargs.pop("long_callback_manager", None) | ||
callback_ctx = kwargs.pop("callback_context", {}) | ||
callback_ctx = kwargs.pop( | ||
"callback_context", AttributeDict({"updated_props": {}}) | ||
) | ||
callback_manager = long and long.get("manager", app_callback_manager) | ||
_validate.validate_output_spec(insert_output, output_spec, Output) | ||
if has_output: | ||
_validate.validate_output_spec(insert_output, output_spec, Output) | ||
T4rk1n marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
context_value.set(callback_ctx) | ||
|
||
|
@@ -342,6 +352,7 @@ def add_context(*args, **kwargs): | |
) | ||
|
||
response = {"multi": True} | ||
has_update = False | ||
|
||
if long is not None: | ||
if not callback_manager: | ||
|
@@ -443,6 +454,10 @@ def add_context(*args, **kwargs): | |
NoUpdate() if NoUpdate.is_no_update(r) else r | ||
for r in output_value | ||
] | ||
updated_props = callback_manager.get_updated_props(cache_key) | ||
if len(updated_props) > 0: | ||
response["sideUpdate"] = updated_props | ||
has_update = True | ||
|
||
if output_value is callback_manager.UNDEFINED: | ||
return to_json(response) | ||
|
@@ -452,35 +467,49 @@ def add_context(*args, **kwargs): | |
if NoUpdate.is_no_update(output_value): | ||
raise PreventUpdate | ||
|
||
if not multi: | ||
output_value, output_spec = [output_value], [output_spec] | ||
flat_output_values = output_value | ||
else: | ||
if isinstance(output_value, (list, tuple)): | ||
# For multi-output, allow top-level collection to be | ||
# list or tuple | ||
output_value = list(output_value) | ||
|
||
# Flatten grouping and validate grouping structure | ||
flat_output_values = flatten_grouping(output_value, output) | ||
component_ids = collections.defaultdict(dict) | ||
|
||
_validate.validate_multi_return( | ||
output_spec, flat_output_values, callback_id | ||
) | ||
if has_output: | ||
if not multi: | ||
output_value, output_spec = [output_value], [output_spec] | ||
flat_output_values = output_value | ||
else: | ||
if isinstance(output_value, (list, tuple)): | ||
# For multi-output, allow top-level collection to be | ||
# list or tuple | ||
output_value = list(output_value) | ||
|
||
# Flatten grouping and validate grouping structure | ||
flat_output_values = flatten_grouping(output_value, output) | ||
|
||
_validate.validate_multi_return( | ||
output_spec, flat_output_values, callback_id | ||
) | ||
|
||
component_ids = collections.defaultdict(dict) | ||
has_update = False | ||
for val, spec in zip(flat_output_values, output_spec): | ||
if isinstance(val, NoUpdate): | ||
continue | ||
for vali, speci in ( | ||
zip(val, spec) if isinstance(spec, list) else [[val, spec]] | ||
): | ||
if not isinstance(vali, NoUpdate): | ||
has_update = True | ||
id_str = stringify_id(speci["id"]) | ||
prop = clean_property_name(speci["property"]) | ||
component_ids[id_str][prop] = vali | ||
for val, spec in zip(flat_output_values, output_spec): | ||
if isinstance(val, NoUpdate): | ||
continue | ||
for vali, speci in ( | ||
zip(val, spec) if isinstance(spec, list) else [[val, spec]] | ||
): | ||
if not isinstance(vali, NoUpdate): | ||
has_update = True | ||
id_str = stringify_id(speci["id"]) | ||
prop = clean_property_name(speci["property"]) | ||
component_ids[id_str][prop] = vali | ||
else: | ||
if output_value is not None: | ||
raise InvalidCallbackReturnValue( | ||
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. 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. Hmm, the hash part is gonna be for all no-output callback error, let's try to remove it. |
||
f"No output callback received return value: {output_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. nit: should be "No-output" rather than "No output" |
||
) | ||
output_value = [] | ||
flat_output_values = [] | ||
|
||
if not long: | ||
side_update = dict(callback_ctx.updated_props) | ||
if len(side_update) > 0: | ||
has_update = True | ||
response["sideUpdate"] = side_update | ||
|
||
if not has_update: | ||
raise PreventUpdate | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,11 +2,12 @@ | |
import warnings | ||
import json | ||
import contextvars | ||
import typing | ||
|
||
import flask | ||
|
||
from . import exceptions | ||
from ._utils import AttributeDict | ||
from ._utils import AttributeDict, stringify_id | ||
|
||
|
||
context_value = contextvars.ContextVar("callback_context") | ||
|
@@ -247,5 +248,18 @@ def using_outputs_grouping(self): | |
def timing_information(self): | ||
return getattr(flask.g, "timing_information", {}) | ||
|
||
@has_context | ||
def set_props(self, component_id: typing.Union[str, dict], props: dict): | ||
ctx_value = _get_context_value() | ||
_id = stringify_id(component_id) | ||
ctx_value.updated_props[_id] = props | ||
|
||
|
||
callback_context = CallbackContext() | ||
|
||
|
||
def set_props(component_id: typing.Union[str, dict], props: dict): | ||
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. Is there a reason why the function signature for That to me seems more consistent with the rest of the Dash 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. It is the same signature as the frontend 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. It's true, this pattern is a little more javascripty, but consistency is good and it could be convenient to be able to set multiple props of the same component in one call. |
||
""" | ||
Set the props for a component not included in the callback outputs. | ||
""" | ||
callback_context.set_props(component_id, props) |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -131,24 +131,31 @@ def first(self, *names): | |
return next(iter(self), {}) | ||
|
||
|
||
def create_callback_id(output, inputs): | ||
def create_callback_id(output, inputs, no_output=False): | ||
# A single dot within a dict id key or value is OK | ||
# but in case of multiple dots together escape each dot | ||
# with `\` so we don't mistake it for multi-outputs | ||
hashed_inputs = None | ||
|
||
def _hash_inputs(): | ||
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. 👍 |
||
return hashlib.sha256( | ||
".".join(str(x) for x in inputs).encode("utf-8") | ||
).hexdigest() | ||
|
||
def _concat(x): | ||
nonlocal hashed_inputs | ||
_id = x.component_id_str().replace(".", "\\.") + "." + x.component_property | ||
if x.allow_duplicate: | ||
if not hashed_inputs: | ||
hashed_inputs = hashlib.sha256( | ||
".".join(str(x) for x in inputs).encode("utf-8") | ||
).hexdigest() | ||
hashed_inputs = _hash_inputs() | ||
# Actually adds on the property part. | ||
_id += f"@{hashed_inputs}" | ||
return _id | ||
|
||
if no_output: | ||
# No output will hash the inputs. | ||
return _hash_inputs() | ||
|
||
if isinstance(output, (list, tuple)): | ||
return ".." + "...".join(_concat(x) for x in output) + ".." | ||
|
||
|
@@ -167,8 +174,12 @@ def split_callback_id(callback_id): | |
|
||
|
||
def stringify_id(id_): | ||
def _json(k, v): | ||
vstr = v.to_json() if hasattr(v, "to_json") else json.dumps(v) | ||
return f"{json.dumps(k)}:{vstr}" | ||
|
||
if isinstance(id_, dict): | ||
return json.dumps(id_, sort_keys=True, separators=(",", ":")) | ||
return "{" + ",".join(_json(k, id_[k]) for k in sorted(id_)) + "}" | ||
return id_ | ||
|
||
|
||
|
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.
Why does
callback_ctx
always need to contain anupdated_props
key?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 remember there is an error with some mocked tests.