Skip to content

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

Merged
merged 22 commits into from
May 3, 2024
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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": {}})
Copy link
Contributor

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 an updated_props key?

Copy link
Contributor Author

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.

)
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(
Copy link
Contributor

@emilykl emilykl Apr 30, 2024

Choose a reason for hiding this comment

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

This exposes a weird value for the "updating" part:
Screen Shot 2024-04-30 at 5 47 28 PM

Not crucial to fix as part of this PR but would be nice if it's an easy fix.

Copy link
Contributor Author

@T4rk1n T4rk1n May 1, 2024

Choose a reason for hiding this comment

The 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}"
Copy link
Contributor

Choose a reason for hiding this comment

The 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
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):
Copy link
Contributor

Choose a reason for hiding this comment

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

Is there a reason why the function signature for set_props isn't structured as 3 inputs: component_id, prop_name, value?

That to me seems more consistent with the rest of the Dash API.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It is the same signature as the frontend set_props, we considered set_props("id", prop=value) but decided to stay with same API for frontend and backend.

Copy link
Collaborator

Choose a reason for hiding this comment

The 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)
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():
Copy link
Contributor

Choose a reason for hiding this comment

The 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) + ".."

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