Skip to content

Commit

Permalink
Merge pull request #2822 from plotly/feat/global-set-props
Browse files Browse the repository at this point in the history
Support Arbitrary callbacks
  • Loading branch information
T4rk1n authored May 3, 2024
2 parents 9a4a479 + d367a6e commit a5a9e4e
Show file tree
Hide file tree
Showing 19 changed files with 492 additions and 105 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,14 @@ This project adheres to [Semantic Versioning](https://semver.org/).
- `target_components` specifies components/props triggering the loading spinner
- `custom_spinner` enables using a custom component for loading messages instead of built-in spinners
- `display` overrides the loading status with options for "show," "hide," or "auto"
- [#2822](https://github.com/plotly/dash/pull/2822) Support no output callbacks. Fixes [#1549](https://github.com/plotly/dash/issues/1549)
- [#2822](https://github.com/plotly/dash/pull/2822) Add global set_props. Fixes [#2803](https://github.com/plotly/dash/issues/2803)

## Fixed

- [#2362](https://github.com/plotly/dash/pull/2362) Global namespace not polluted any more when loading clientside callbacks.
- [#2833](https://github.com/plotly/dash/pull/2833) Allow data url in link props. Fixes [#2764](https://github.com/plotly/dash/issues/2764)
- [#2822](https://github.com/plotly/dash/pull/2822) Fix side update (running/progress/cancel) dict ids. Fixes [#2111](https://github.com/plotly/dash/issues/2111)

## [2.16.1] - 2024-03-06

Expand Down
2 changes: 1 addition & 1 deletion dash/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from . import html # noqa: F401,E402
from . import dash_table # noqa: F401,E402
from .version import __version__ # noqa: F401,E402
from ._callback_context import callback_context # noqa: F401,E402
from ._callback_context import callback_context, set_props # noqa: F401,E402
from ._callback import callback, clientside_callback # noqa: F401,E402
from ._get_app import get_app # noqa: F401,E402
from ._get_paths import ( # noqa: F401,E402
Expand Down
89 changes: 59 additions & 30 deletions dash/_callback.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
Output,
)
from .exceptions import (
InvalidCallbackReturnValue,
PreventUpdate,
WildcardInLongCallback,
MissingLongCallbackManagerError,
Expand Down Expand Up @@ -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
Expand All @@ -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],
Expand All @@ -248,6 +250,7 @@ def insert_callback(
"interval": long["interval"],
},
"dynamic_creator": dynamic_creator,
"no_output": no_output,
}
if running:
callback_spec["running"] = running
Expand All @@ -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)

Expand All @@ -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")
Expand Down Expand Up @@ -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
Expand All @@ -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)

context_value.set(callback_ctx)

Expand All @@ -342,6 +352,7 @@ def add_context(*args, **kwargs):
)

response = {"multi": True}
has_update = False

if long is not None:
if not callback_manager:
Expand Down Expand Up @@ -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)
Expand All @@ -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(
f"No-output callback received return value: {output_value}"
)
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
Expand Down
16 changes: 15 additions & 1 deletion dash/_callback_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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):
"""
Set the props for a component not included in the callback outputs.
"""
callback_context.set_props(component_id, props)
21 changes: 16 additions & 5 deletions dash/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
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) + ".."

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


Expand Down
56 changes: 37 additions & 19 deletions dash/dash-renderer/src/actions/callbacks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,19 +324,39 @@ async function handleClientside(
return result;
}

function sideUpdate(outputs: any, dispatch: any, paths: any) {
toPairs(outputs).forEach(([id, value]) => {
const [componentId, propName] = id.split('.');
const componentPath = paths.strs[componentId];
function updateComponent(component_id: any, props: any) {
return function (dispatch: any, getState: any) {
const paths = getState().paths;
const componentPath = getPath(paths, component_id);
dispatch(
updateProps({
props: {[propName]: value},
props,
itempath: componentPath
})
);
dispatch(
notifyObservers({id: componentId, props: {[propName]: value}})
);
dispatch(notifyObservers({id: component_id, props}));
};
}

function sideUpdate(outputs: any, dispatch: any) {
toPairs(outputs).forEach(([id, value]) => {
let componentId = id,
propName;

if (id.startsWith('{')) {
const index = id.lastIndexOf('}');
if (index + 2 < id.length) {
propName = id.substring(index + 2);
componentId = JSON.parse(id.substring(0, index + 1));
} else {
componentId = JSON.parse(id);
}
} else if (id.includes('.')) {
[componentId, propName] = id.split('.');
}

const props = propName ? {[propName]: value} : value;
dispatch(updateComponent(componentId, props));
});
}

Expand All @@ -345,7 +365,6 @@ function handleServerside(
hooks: any,
config: any,
payload: any,
paths: any,
long: LongCallbackInfo | undefined,
additionalArgs: [string, string, boolean?][] | undefined,
getState: any,
Expand All @@ -365,7 +384,7 @@ function handleServerside(
let moreArgs = additionalArgs;

if (running) {
sideUpdate(running.running, dispatch, paths);
sideUpdate(running.running, dispatch);
runningOff = running.runningOff;
}

Expand Down Expand Up @@ -475,10 +494,10 @@ function handleServerside(
dispatch(removeCallbackJob({jobId: job}));
}
if (runningOff) {
sideUpdate(runningOff, dispatch, paths);
sideUpdate(runningOff, dispatch);
}
if (progressDefault) {
sideUpdate(progressDefault, dispatch, paths);
sideUpdate(progressDefault, dispatch);
}
};

Expand All @@ -500,8 +519,12 @@ function handleServerside(
job = data.job;
}

if (data.sideUpdate) {
sideUpdate(data.sideUpdate, dispatch);
}

if (data.progress) {
sideUpdate(data.progress, dispatch, paths);
sideUpdate(data.progress, dispatch);
}
if (!progressDefault && data.progressDefault) {
progressDefault = data.progressDefault;
Expand Down Expand Up @@ -696,11 +719,7 @@ export function executeCallback(
if (inter.length) {
additionalArgs.push(['cancelJob', job.jobId]);
if (job.progressDefault) {
sideUpdate(
job.progressDefault,
dispatch,
paths
);
sideUpdate(job.progressDefault, dispatch);
}
}
}
Expand All @@ -713,7 +732,6 @@ export function executeCallback(
hooks,
newConfig,
payload,
paths,
long,
additionalArgs.length ? additionalArgs : undefined,
getState,
Expand Down
Loading

0 comments on commit a5a9e4e

Please sign in to comment.