Skip to content

Commit a5a9e4e

Browse files
authored
Merge pull request #2822 from plotly/feat/global-set-props
Support Arbitrary callbacks
2 parents 9a4a479 + d367a6e commit a5a9e4e

19 files changed

+492
-105
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,14 @@ This project adheres to [Semantic Versioning](https://semver.org/).
1616
- `target_components` specifies components/props triggering the loading spinner
1717
- `custom_spinner` enables using a custom component for loading messages instead of built-in spinners
1818
- `display` overrides the loading status with options for "show," "hide," or "auto"
19+
- [#2822](https://github.com/plotly/dash/pull/2822) Support no output callbacks. Fixes [#1549](https://github.com/plotly/dash/issues/1549)
20+
- [#2822](https://github.com/plotly/dash/pull/2822) Add global set_props. Fixes [#2803](https://github.com/plotly/dash/issues/2803)
1921

2022
## Fixed
2123

2224
- [#2362](https://github.com/plotly/dash/pull/2362) Global namespace not polluted any more when loading clientside callbacks.
2325
- [#2833](https://github.com/plotly/dash/pull/2833) Allow data url in link props. Fixes [#2764](https://github.com/plotly/dash/issues/2764)
26+
- [#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)
2427

2528
## [2.16.1] - 2024-03-06
2629

dash/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from . import html # noqa: F401,E402
1919
from . import dash_table # noqa: F401,E402
2020
from .version import __version__ # noqa: F401,E402
21-
from ._callback_context import callback_context # noqa: F401,E402
21+
from ._callback_context import callback_context, set_props # noqa: F401,E402
2222
from ._callback import callback, clientside_callback # noqa: F401,E402
2323
from ._get_app import get_app # noqa: F401,E402
2424
from ._get_paths import ( # noqa: F401,E402

dash/_callback.py

Lines changed: 59 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
Output,
1111
)
1212
from .exceptions import (
13+
InvalidCallbackReturnValue,
1314
PreventUpdate,
1415
WildcardInLongCallback,
1516
MissingLongCallbackManagerError,
@@ -226,6 +227,7 @@ def insert_callback(
226227
manager=None,
227228
running=None,
228229
dynamic_creator=False,
230+
no_output=False,
229231
):
230232
if prevent_initial_call is None:
231233
prevent_initial_call = config_prevent_initial_callbacks
@@ -234,7 +236,7 @@ def insert_callback(
234236
output, prevent_initial_call, config_prevent_initial_callbacks
235237
)
236238

237-
callback_id = create_callback_id(output, inputs)
239+
callback_id = create_callback_id(output, inputs, no_output)
238240
callback_spec = {
239241
"output": callback_id,
240242
"inputs": [c.to_dict() for c in inputs],
@@ -248,6 +250,7 @@ def insert_callback(
248250
"interval": long["interval"],
249251
},
250252
"dynamic_creator": dynamic_creator,
253+
"no_output": no_output,
251254
}
252255
if running:
253256
callback_spec["running"] = running
@@ -262,6 +265,7 @@ def insert_callback(
262265
"raw_inputs": inputs,
263266
"manager": manager,
264267
"allow_dynamic_callbacks": dynamic_creator,
268+
"no_output": no_output,
265269
}
266270
callback_list.append(callback_spec)
267271

@@ -283,10 +287,12 @@ def register_callback( # pylint: disable=R0914
283287
# Insert callback with scalar (non-multi) Output
284288
insert_output = output
285289
multi = False
290+
has_output = True
286291
else:
287292
# Insert callback as multi Output
288293
insert_output = flatten_grouping(output)
289294
multi = True
295+
has_output = len(output) > 0
290296

291297
long = _kwargs.get("long")
292298
manager = _kwargs.get("manager")
@@ -315,6 +321,7 @@ def register_callback( # pylint: disable=R0914
315321
manager=manager,
316322
dynamic_creator=allow_dynamic_callbacks,
317323
running=running,
324+
no_output=not has_output,
318325
)
319326

320327
# pylint: disable=too-many-locals
@@ -331,9 +338,12 @@ def wrap_func(func):
331338
def add_context(*args, **kwargs):
332339
output_spec = kwargs.pop("outputs_list")
333340
app_callback_manager = kwargs.pop("long_callback_manager", None)
334-
callback_ctx = kwargs.pop("callback_context", {})
341+
callback_ctx = kwargs.pop(
342+
"callback_context", AttributeDict({"updated_props": {}})
343+
)
335344
callback_manager = long and long.get("manager", app_callback_manager)
336-
_validate.validate_output_spec(insert_output, output_spec, Output)
345+
if has_output:
346+
_validate.validate_output_spec(insert_output, output_spec, Output)
337347

338348
context_value.set(callback_ctx)
339349

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

344354
response = {"multi": True}
355+
has_update = False
345356

346357
if long is not None:
347358
if not callback_manager:
@@ -443,6 +454,10 @@ def add_context(*args, **kwargs):
443454
NoUpdate() if NoUpdate.is_no_update(r) else r
444455
for r in output_value
445456
]
457+
updated_props = callback_manager.get_updated_props(cache_key)
458+
if len(updated_props) > 0:
459+
response["sideUpdate"] = updated_props
460+
has_update = True
446461

447462
if output_value is callback_manager.UNDEFINED:
448463
return to_json(response)
@@ -452,35 +467,49 @@ def add_context(*args, **kwargs):
452467
if NoUpdate.is_no_update(output_value):
453468
raise PreventUpdate
454469

455-
if not multi:
456-
output_value, output_spec = [output_value], [output_spec]
457-
flat_output_values = output_value
458-
else:
459-
if isinstance(output_value, (list, tuple)):
460-
# For multi-output, allow top-level collection to be
461-
# list or tuple
462-
output_value = list(output_value)
463-
464-
# Flatten grouping and validate grouping structure
465-
flat_output_values = flatten_grouping(output_value, output)
470+
component_ids = collections.defaultdict(dict)
466471

467-
_validate.validate_multi_return(
468-
output_spec, flat_output_values, callback_id
469-
)
472+
if has_output:
473+
if not multi:
474+
output_value, output_spec = [output_value], [output_spec]
475+
flat_output_values = output_value
476+
else:
477+
if isinstance(output_value, (list, tuple)):
478+
# For multi-output, allow top-level collection to be
479+
# list or tuple
480+
output_value = list(output_value)
481+
482+
# Flatten grouping and validate grouping structure
483+
flat_output_values = flatten_grouping(output_value, output)
484+
485+
_validate.validate_multi_return(
486+
output_spec, flat_output_values, callback_id
487+
)
470488

471-
component_ids = collections.defaultdict(dict)
472-
has_update = False
473-
for val, spec in zip(flat_output_values, output_spec):
474-
if isinstance(val, NoUpdate):
475-
continue
476-
for vali, speci in (
477-
zip(val, spec) if isinstance(spec, list) else [[val, spec]]
478-
):
479-
if not isinstance(vali, NoUpdate):
480-
has_update = True
481-
id_str = stringify_id(speci["id"])
482-
prop = clean_property_name(speci["property"])
483-
component_ids[id_str][prop] = vali
489+
for val, spec in zip(flat_output_values, output_spec):
490+
if isinstance(val, NoUpdate):
491+
continue
492+
for vali, speci in (
493+
zip(val, spec) if isinstance(spec, list) else [[val, spec]]
494+
):
495+
if not isinstance(vali, NoUpdate):
496+
has_update = True
497+
id_str = stringify_id(speci["id"])
498+
prop = clean_property_name(speci["property"])
499+
component_ids[id_str][prop] = vali
500+
else:
501+
if output_value is not None:
502+
raise InvalidCallbackReturnValue(
503+
f"No-output callback received return value: {output_value}"
504+
)
505+
output_value = []
506+
flat_output_values = []
507+
508+
if not long:
509+
side_update = dict(callback_ctx.updated_props)
510+
if len(side_update) > 0:
511+
has_update = True
512+
response["sideUpdate"] = side_update
484513

485514
if not has_update:
486515
raise PreventUpdate

dash/_callback_context.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@
22
import warnings
33
import json
44
import contextvars
5+
import typing
56

67
import flask
78

89
from . import exceptions
9-
from ._utils import AttributeDict
10+
from ._utils import AttributeDict, stringify_id
1011

1112

1213
context_value = contextvars.ContextVar("callback_context")
@@ -247,5 +248,18 @@ def using_outputs_grouping(self):
247248
def timing_information(self):
248249
return getattr(flask.g, "timing_information", {})
249250

251+
@has_context
252+
def set_props(self, component_id: typing.Union[str, dict], props: dict):
253+
ctx_value = _get_context_value()
254+
_id = stringify_id(component_id)
255+
ctx_value.updated_props[_id] = props
256+
250257

251258
callback_context = CallbackContext()
259+
260+
261+
def set_props(component_id: typing.Union[str, dict], props: dict):
262+
"""
263+
Set the props for a component not included in the callback outputs.
264+
"""
265+
callback_context.set_props(component_id, props)

dash/_utils.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -131,24 +131,31 @@ def first(self, *names):
131131
return next(iter(self), {})
132132

133133

134-
def create_callback_id(output, inputs):
134+
def create_callback_id(output, inputs, no_output=False):
135135
# A single dot within a dict id key or value is OK
136136
# but in case of multiple dots together escape each dot
137137
# with `\` so we don't mistake it for multi-outputs
138138
hashed_inputs = None
139139

140+
def _hash_inputs():
141+
return hashlib.sha256(
142+
".".join(str(x) for x in inputs).encode("utf-8")
143+
).hexdigest()
144+
140145
def _concat(x):
141146
nonlocal hashed_inputs
142147
_id = x.component_id_str().replace(".", "\\.") + "." + x.component_property
143148
if x.allow_duplicate:
144149
if not hashed_inputs:
145-
hashed_inputs = hashlib.sha256(
146-
".".join(str(x) for x in inputs).encode("utf-8")
147-
).hexdigest()
150+
hashed_inputs = _hash_inputs()
148151
# Actually adds on the property part.
149152
_id += f"@{hashed_inputs}"
150153
return _id
151154

155+
if no_output:
156+
# No output will hash the inputs.
157+
return _hash_inputs()
158+
152159
if isinstance(output, (list, tuple)):
153160
return ".." + "...".join(_concat(x) for x in output) + ".."
154161

@@ -167,8 +174,12 @@ def split_callback_id(callback_id):
167174

168175

169176
def stringify_id(id_):
177+
def _json(k, v):
178+
vstr = v.to_json() if hasattr(v, "to_json") else json.dumps(v)
179+
return f"{json.dumps(k)}:{vstr}"
180+
170181
if isinstance(id_, dict):
171-
return json.dumps(id_, sort_keys=True, separators=(",", ":"))
182+
return "{" + ",".join(_json(k, id_[k]) for k in sorted(id_)) + "}"
172183
return id_
173184

174185

dash/dash-renderer/src/actions/callbacks.ts

Lines changed: 37 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -324,19 +324,39 @@ async function handleClientside(
324324
return result;
325325
}
326326

327-
function sideUpdate(outputs: any, dispatch: any, paths: any) {
328-
toPairs(outputs).forEach(([id, value]) => {
329-
const [componentId, propName] = id.split('.');
330-
const componentPath = paths.strs[componentId];
327+
function updateComponent(component_id: any, props: any) {
328+
return function (dispatch: any, getState: any) {
329+
const paths = getState().paths;
330+
const componentPath = getPath(paths, component_id);
331331
dispatch(
332332
updateProps({
333-
props: {[propName]: value},
333+
props,
334334
itempath: componentPath
335335
})
336336
);
337-
dispatch(
338-
notifyObservers({id: componentId, props: {[propName]: value}})
339-
);
337+
dispatch(notifyObservers({id: component_id, props}));
338+
};
339+
}
340+
341+
function sideUpdate(outputs: any, dispatch: any) {
342+
toPairs(outputs).forEach(([id, value]) => {
343+
let componentId = id,
344+
propName;
345+
346+
if (id.startsWith('{')) {
347+
const index = id.lastIndexOf('}');
348+
if (index + 2 < id.length) {
349+
propName = id.substring(index + 2);
350+
componentId = JSON.parse(id.substring(0, index + 1));
351+
} else {
352+
componentId = JSON.parse(id);
353+
}
354+
} else if (id.includes('.')) {
355+
[componentId, propName] = id.split('.');
356+
}
357+
358+
const props = propName ? {[propName]: value} : value;
359+
dispatch(updateComponent(componentId, props));
340360
});
341361
}
342362

@@ -345,7 +365,6 @@ function handleServerside(
345365
hooks: any,
346366
config: any,
347367
payload: any,
348-
paths: any,
349368
long: LongCallbackInfo | undefined,
350369
additionalArgs: [string, string, boolean?][] | undefined,
351370
getState: any,
@@ -365,7 +384,7 @@ function handleServerside(
365384
let moreArgs = additionalArgs;
366385

367386
if (running) {
368-
sideUpdate(running.running, dispatch, paths);
387+
sideUpdate(running.running, dispatch);
369388
runningOff = running.runningOff;
370389
}
371390

@@ -475,10 +494,10 @@ function handleServerside(
475494
dispatch(removeCallbackJob({jobId: job}));
476495
}
477496
if (runningOff) {
478-
sideUpdate(runningOff, dispatch, paths);
497+
sideUpdate(runningOff, dispatch);
479498
}
480499
if (progressDefault) {
481-
sideUpdate(progressDefault, dispatch, paths);
500+
sideUpdate(progressDefault, dispatch);
482501
}
483502
};
484503

@@ -500,8 +519,12 @@ function handleServerside(
500519
job = data.job;
501520
}
502521

522+
if (data.sideUpdate) {
523+
sideUpdate(data.sideUpdate, dispatch);
524+
}
525+
503526
if (data.progress) {
504-
sideUpdate(data.progress, dispatch, paths);
527+
sideUpdate(data.progress, dispatch);
505528
}
506529
if (!progressDefault && data.progressDefault) {
507530
progressDefault = data.progressDefault;
@@ -696,11 +719,7 @@ export function executeCallback(
696719
if (inter.length) {
697720
additionalArgs.push(['cancelJob', job.jobId]);
698721
if (job.progressDefault) {
699-
sideUpdate(
700-
job.progressDefault,
701-
dispatch,
702-
paths
703-
);
722+
sideUpdate(job.progressDefault, dispatch);
704723
}
705724
}
706725
}
@@ -713,7 +732,6 @@ export function executeCallback(
713732
hooks,
714733
newConfig,
715734
payload,
716-
paths,
717735
long,
718736
additionalArgs.length ? additionalArgs : undefined,
719737
getState,

0 commit comments

Comments
 (0)