From 3488bbecacd50676cd512b1c7cdc3b4e4764acb6 Mon Sep 17 00:00:00 2001 From: philippe Date: Fri, 29 Mar 2024 14:04:16 -0400 Subject: [PATCH 01/21] Support Arbitrary callbacks - Allow no output callback - Add global set_props - Fix side update pattern ids --- .pylintrc | 2 +- dash/__init__.py | 2 +- dash/_callback.py | 56 +++++++++++++------ dash/_callback_context.py | 16 ++++++ dash/_utils.py | 8 ++- dash/dash-renderer/src/actions/callbacks.ts | 54 +++++++++++------- .../dash-renderer/src/actions/dependencies.js | 53 ++++++++++++------ dash/dash-renderer/src/types/callbacks.ts | 1 + dash/dash.py | 31 +++++++--- dash/long_callback/_proxy_set_props.py | 7 +++ dash/long_callback/managers/__init__.py | 7 +++ dash/long_callback/managers/celery_manager.py | 17 ++++++ .../managers/diskcache_manager.py | 15 +++++ 13 files changed, 204 insertions(+), 65 deletions(-) create mode 100644 dash/long_callback/_proxy_set_props.py diff --git a/.pylintrc b/.pylintrc index 554f953b8e..db7fac560e 100644 --- a/.pylintrc +++ b/.pylintrc @@ -426,7 +426,7 @@ max-public-methods=40 max-returns=6 # Maximum number of statements in function / method body -max-statements=50 +max-statements=75 # Minimum number of public methods for a class (see R0903). min-public-methods=2 diff --git a/dash/__init__.py b/dash/__init__.py index f46d6f77e1..8f740e35ba 100644 --- a/dash/__init__.py +++ b/dash/__init__.py @@ -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 diff --git a/dash/_callback.py b/dash/_callback.py index d2fa15742d..7b6f305038 100644 --- a/dash/_callback.py +++ b/dash/_callback.py @@ -226,6 +226,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 +235,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 +249,7 @@ def insert_callback( "interval": long["interval"], }, "dynamic_creator": dynamic_creator, + "no_output": no_output, } if running: callback_spec["running"] = running @@ -262,6 +264,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 +286,12 @@ def register_callback( # pylint: disable=R0914 # Insert callback with scalar (non-multi) Output insert_output = output multi = False + no_output = False else: # Insert callback as multi Output insert_output = flatten_grouping(output) multi = True + no_output = len(output) == 0 long = _kwargs.get("long") manager = _kwargs.get("manager") @@ -315,6 +320,7 @@ def register_callback( # pylint: disable=R0914 manager=manager, dynamic_creator=allow_dynamic_callbacks, running=running, + no_output=no_output, ) # pylint: disable=too-many-locals @@ -333,7 +339,8 @@ def add_context(*args, **kwargs): app_callback_manager = kwargs.pop("long_callback_manager", None) callback_ctx = kwargs.pop("callback_context", {}) callback_manager = long and long.get("manager", app_callback_manager) - _validate.validate_output_spec(insert_output, output_spec, Output) + if not no_output: + _validate.validate_output_spec(insert_output, output_spec, Output) context_value.set(callback_ctx) @@ -443,6 +450,9 @@ 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 if output_value is callback_manager.UNDEFINED: return to_json(response) @@ -452,7 +462,10 @@ def add_context(*args, **kwargs): if NoUpdate.is_no_update(output_value): raise PreventUpdate - if not multi: + if no_output: + output_value = [] + flat_output_values = [] + elif not multi: output_value, output_spec = [output_value], [output_spec] flat_output_values = output_value else: @@ -464,23 +477,30 @@ def add_context(*args, **kwargs): # 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 + if not no_output: + _validate.validate_multi_return( + output_spec, flat_output_values, callback_id + ) + + 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 + + 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 diff --git a/dash/_callback_context.py b/dash/_callback_context.py index 7638d0a860..db82d5aaee 100644 --- a/dash/_callback_context.py +++ b/dash/_callback_context.py @@ -2,6 +2,7 @@ import warnings import json import contextvars +import typing import flask @@ -247,5 +248,20 @@ 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() + if isinstance(component_id, dict): + ctx_value.updated_props[json.dumps(component_id)] = props + else: + ctx_value.updated_props[component_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) diff --git a/dash/_utils.py b/dash/_utils.py index 5b3b1d62e1..648c27e040 100644 --- a/dash/_utils.py +++ b/dash/_utils.py @@ -131,7 +131,7 @@ 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 @@ -149,6 +149,12 @@ def _concat(x): _id += f"@{hashed_inputs}" return _id + if no_output: + # No output will hash the inputs. + return hashlib.sha256( + ".".join(str(x) for x in inputs).encode("utf-8") + ).hexdigest() + if isinstance(output, (list, tuple)): return ".." + "...".join(_concat(x) for x in output) + ".." diff --git a/dash/dash-renderer/src/actions/callbacks.ts b/dash/dash-renderer/src/actions/callbacks.ts index 68320568e5..845fcc7eee 100644 --- a/dash/dash-renderer/src/actions/callbacks.ts +++ b/dash/dash-renderer/src/actions/callbacks.ts @@ -324,19 +324,37 @@ 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, propName; + if (id.includes('.')) { + [componentId, propName] = id.split('.'); + if (componentId.startsWith('{')) { + componentId = JSON.parse(componentId); + } + dispatch(updateComponent(componentId, {[propName]: value})); + } else { + if (id.startsWith('{')) { + componentId = JSON.parse(id); + } else { + componentId = id; + } + dispatch(updateComponent(componentId, value)); + } }); } @@ -345,7 +363,6 @@ function handleServerside( hooks: any, config: any, payload: any, - paths: any, long: LongCallbackInfo | undefined, additionalArgs: [string, string, boolean?][] | undefined, getState: any, @@ -365,7 +382,7 @@ function handleServerside( let moreArgs = additionalArgs; if (running) { - sideUpdate(running.running, dispatch, paths); + sideUpdate(running.running, dispatch); runningOff = running.runningOff; } @@ -475,10 +492,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); } }; @@ -500,8 +517,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; @@ -696,11 +717,7 @@ export function executeCallback( if (inter.length) { additionalArgs.push(['cancelJob', job.jobId]); if (job.progressDefault) { - sideUpdate( - job.progressDefault, - dispatch, - paths - ); + sideUpdate(job.progressDefault, dispatch); } } } @@ -713,7 +730,6 @@ export function executeCallback( hooks, newConfig, payload, - paths, long, additionalArgs.length ? additionalArgs : undefined, getState, diff --git a/dash/dash-renderer/src/actions/dependencies.js b/dash/dash-renderer/src/actions/dependencies.js index 34f7a90f59..223b3f8c4c 100644 --- a/dash/dash-renderer/src/actions/dependencies.js +++ b/dash/dash-renderer/src/actions/dependencies.js @@ -191,10 +191,6 @@ function validateDependencies(parsedDependencies, dispatchError) { let hasOutputs = true; if (outputs.length === 1 && !outputs[0].id && !outputs[0].property) { hasOutputs = false; - dispatchError('A callback is missing Outputs', [ - 'Please provide an output for this callback:', - JSON.stringify(dep, null, 2) - ]); } const head = @@ -238,8 +234,22 @@ function validateDependencies(parsedDependencies, dispatchError) { }); }); - findDuplicateOutputs(outputs, head, dispatchError, outStrs, outObjs); - findMismatchedWildcards(outputs, inputs, state, head, dispatchError); + if (hasOutputs) { + findDuplicateOutputs( + outputs, + head, + dispatchError, + outStrs, + outObjs + ); + findMismatchedWildcards( + outputs, + inputs, + state, + head, + dispatchError + ); + } }); } @@ -382,7 +392,9 @@ function checkInOutOverlap(out, inputs) { } function findMismatchedWildcards(outputs, inputs, state, head, dispatchError) { - const {matchKeys: out0MatchKeys} = findWildcardKeys(outputs[0].id); + const {matchKeys: out0MatchKeys} = findWildcardKeys( + outputs.length ? outputs[0].id : undefined + ); outputs.forEach((out, i) => { if (i && !equals(findWildcardKeys(out.id).matchKeys, out0MatchKeys)) { dispatchError('Mismatched `MATCH` wildcards across `Output`s', [ @@ -605,12 +617,21 @@ export function computeGraphs(dependencies, dispatchError) { const fixIds = map(evolve({id: parseIfWildcard})); const parsedDependencies = map(dep => { - const {output} = dep; + const {output, no_output} = dep; const out = evolve({inputs: fixIds, state: fixIds}, dep); - out.outputs = map( - outi => assoc('out', true, splitIdAndProp(outi)), - isMultiOutputProp(output) ? parseMultipleOutputs(output) : [output] - ); + if (no_output) { + // No output case + out.outputs = []; + out.noOutput = true; + } else { + out.outputs = map( + outi => assoc('out', true, splitIdAndProp(outi)), + isMultiOutputProp(output) + ? parseMultipleOutputs(output) + : [output] + ); + } + return out; }, dependencies); @@ -809,7 +830,9 @@ export function computeGraphs(dependencies, dispatchError) { // Also collect MATCH keys in the output (all outputs must share these) // and ALL keys in the first output (need not be shared but we'll use // the first output for calculations) for later convenience. - const {matchKeys} = findWildcardKeys(outputs[0].id); + const {matchKeys} = findWildcardKeys( + outputs.length ? outputs[0].id : undefined + ); const firstSingleOutput = findIndex(o => !isMultiValued(o.id), outputs); const finalDependency = mergeRight( {matchKeys, firstSingleOutput, outputs}, @@ -1091,9 +1114,7 @@ export function addAllResolvedFromOutputs(resolve, paths, matches) { } } else { const cb = makeResolvedCallback(callback, resolve, ''); - if (flatten(cb.getOutputs(paths)).length) { - matches.push(cb); - } + matches.push(cb); } }; } diff --git a/dash/dash-renderer/src/types/callbacks.ts b/dash/dash-renderer/src/types/callbacks.ts index 62fcb19d20..fe8fd03ba7 100644 --- a/dash/dash-renderer/src/types/callbacks.ts +++ b/dash/dash-renderer/src/types/callbacks.ts @@ -103,4 +103,5 @@ export type CallbackResponseData = { running?: CallbackResponse; runningOff?: CallbackResponse; cancel?: ICallbackProperty[]; + sideUpdate?: any; }; diff --git a/dash/dash.py b/dash/dash.py index 1876eecd15..8b42d41bbb 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -1273,7 +1273,7 @@ def dispatch(self): "state", [] ) output = body["output"] - outputs_list = body.get("outputs") or split_callback_id(output) + outputs_list = body.get("outputs") g.outputs_list = outputs_list # pylint: disable=assigning-non-slot g.input_values = ( # pylint: disable=assigning-non-slot @@ -1305,6 +1305,12 @@ def dispatch(self): inputs_state = inputs + state inputs_state = convert_to_AttributeDict(inputs_state) + if cb.get("no_output"): + outputs_list = [] + elif not outputs_list: + # FIXME Old renderer support? + split_callback_id(output) + # update args_grouping attributes for s in inputs_state: # check for pattern matching: list of inputs or state @@ -1333,14 +1339,21 @@ def dispatch(self): else: flat_outputs = outputs_list - outputs_grouping = map_grouping( - lambda ind: flat_outputs[ind], outputs_indices - ) - g.outputs_grouping = outputs_grouping # pylint: disable=assigning-non-slot - g.using_outputs_grouping = ( # pylint: disable=assigning-non-slot - not isinstance(outputs_indices, int) - and outputs_indices != list(range(grouping_len(outputs_indices))) - ) + if len(flat_outputs) > 0: + outputs_grouping = map_grouping( + lambda ind: flat_outputs[ind], outputs_indices + ) + g.outputs_grouping = ( + outputs_grouping # pylint: disable=assigning-non-slot + ) + g.using_outputs_grouping = ( # pylint: disable=assigning-non-slot + not isinstance(outputs_indices, int) + and outputs_indices != list(range(grouping_len(outputs_indices))) + ) + else: + g.outputs_grouping = [] + g.using_outputs_grouping = [] + g.updated_props = {} except KeyError as missing_callback_function: msg = f"Callback function not found for output '{output}', perhaps you forgot to prepend the '@'?" diff --git a/dash/long_callback/_proxy_set_props.py b/dash/long_callback/_proxy_set_props.py new file mode 100644 index 0000000000..3327305f3f --- /dev/null +++ b/dash/long_callback/_proxy_set_props.py @@ -0,0 +1,7 @@ +class ProxySetProps(dict): + def __init__(self, on_change): + super().__init__() + self.on_change = on_change + + def __setitem__(self, key, value): + self.on_change(key, value) diff --git a/dash/long_callback/managers/__init__.py b/dash/long_callback/managers/__init__.py index cf5dcd2182..bda99a1cde 100644 --- a/dash/long_callback/managers/__init__.py +++ b/dash/long_callback/managers/__init__.py @@ -51,6 +51,9 @@ def result_ready(self, key): def get_result(self, key, job): raise NotImplementedError + def get_updated_props(self, key): + raise NotImplementedError + def build_cache_key(self, fn, args, cache_args_to_ignore): fn_source = inspect.getsource(fn) @@ -98,6 +101,10 @@ def register_func(fn, progress, callback_id): def _make_progress_key(key): return key + "-progress" + @staticmethod + def _make_set_props_key(key): + return f"{key}-set_props" + @staticmethod def hash_function(fn, callback_id=""): fn_source = inspect.getsource(fn) diff --git a/dash/long_callback/managers/celery_manager.py b/dash/long_callback/managers/celery_manager.py index 5090d42178..01fadf4f8d 100644 --- a/dash/long_callback/managers/celery_manager.py +++ b/dash/long_callback/managers/celery_manager.py @@ -7,6 +7,7 @@ from dash._callback_context import context_value from dash._utils import AttributeDict from dash.exceptions import PreventUpdate +from dash.long_callback._proxy_set_props import ProxySetProps from dash.long_callback.managers import BaseLongCallbackManager @@ -124,6 +125,15 @@ def get_result(self, key, job): self.terminate_job(job) return result + def get_updated_props(self, key): + updated_props = self.handle.backend.get(self._make_set_props_key(key)) + if updated_props is None: + return {} + + self.clear_cache_entry(key) + + return json.loads(updated_props) + def _make_job_fn(fn, celery_app, progress, key): cache = celery_app.backend @@ -138,11 +148,18 @@ def _set_progress(progress_value): maybe_progress = [_set_progress] if progress else [] + def _set_props(_id, props): + cache.set( + f"{result_key}-set_props", + json.dumps({_id: props}, cls=PlotlyJSONEncoder), + ) + ctx = copy_context() def run(): c = AttributeDict(**context) c.ignore_register_page = False + c.updated_props = ProxySetProps(_set_props) context_value.set(c) try: if isinstance(user_callback_args, dict): diff --git a/dash/long_callback/managers/diskcache_manager.py b/dash/long_callback/managers/diskcache_manager.py index 22fb7bd5c5..a106700f82 100644 --- a/dash/long_callback/managers/diskcache_manager.py +++ b/dash/long_callback/managers/diskcache_manager.py @@ -2,6 +2,7 @@ from contextvars import copy_context from . import BaseLongCallbackManager +from .._proxy_set_props import ProxySetProps from ..._callback_context import context_value from ..._utils import AttributeDict from ...exceptions import PreventUpdate @@ -155,6 +156,16 @@ def get_result(self, key, job): self.terminate_job(job) return result + def get_updated_props(self, key): + set_props_key = self._make_set_props_key(key) + result = self.handle.get(set_props_key, self.UNDEFINED) + if result is self.UNDEFINED: + return {} + + self.clear_cache_entry(set_props_key) + + return result + def _make_job_fn(fn, cache, progress): def job_fn(result_key, progress_key, user_callback_args, context): @@ -166,11 +177,15 @@ def _set_progress(progress_value): maybe_progress = [_set_progress] if progress else [] + def _set_props(_id, props): + cache.set(f"{result_key}-set_props", {_id: props}) + ctx = copy_context() def run(): c = AttributeDict(**context) c.ignore_register_page = False + c.updated_props = ProxySetProps(_set_props) context_value.set(c) try: if isinstance(user_callback_args, dict): From 6623750d6800b7193d24c9f98382f296bed47083 Mon Sep 17 00:00:00 2001 From: philippe Date: Tue, 2 Apr 2024 13:03:18 -0400 Subject: [PATCH 02/21] Fix dvcv001 --- tests/integration/devtools/test_callback_validation.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/integration/devtools/test_callback_validation.py b/tests/integration/devtools/test_callback_validation.py index 0e758a6889..59b874012e 100644 --- a/tests/integration/devtools/test_callback_validation.py +++ b/tests/integration/devtools/test_callback_validation.py @@ -70,10 +70,6 @@ def x(): dash_duo, [ ["A callback is missing Inputs", ["there are no `Input` elements."]], - [ - "A callback is missing Outputs", - ["Please provide an output for this callback:"], - ], ], ) From 2f840220a8fd004853efdfbc3a7d94a12474a35f Mon Sep 17 00:00:00 2001 From: philippe Date: Tue, 2 Apr 2024 13:26:18 -0400 Subject: [PATCH 03/21] Fix test_grouped_callbacks. --- dash/_callback.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/dash/_callback.py b/dash/_callback.py index 7b6f305038..758c8ac0cc 100644 --- a/dash/_callback.py +++ b/dash/_callback.py @@ -337,7 +337,9 @@ 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) if not no_output: _validate.validate_output_spec(insert_output, output_spec, Output) From fb60ce0a6aa3ad4e9f2efd8b5f70600998649526 Mon Sep 17 00:00:00 2001 From: philippe Date: Tue, 9 Apr 2024 11:14:24 -0400 Subject: [PATCH 04/21] Add test arbitrary callbacks. --- .../callbacks/test_arbitrary_callbacks.py | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 tests/integration/callbacks/test_arbitrary_callbacks.py diff --git a/tests/integration/callbacks/test_arbitrary_callbacks.py b/tests/integration/callbacks/test_arbitrary_callbacks.py new file mode 100644 index 0000000000..f5c4612eaa --- /dev/null +++ b/tests/integration/callbacks/test_arbitrary_callbacks.py @@ -0,0 +1,123 @@ +from dash import Dash, Input, Output, html, set_props, register_page + + +def test_arb001_global_set_props(dash_duo): + app = Dash() + app.layout = html.Div( + [ + html.Div(id="output"), + html.Div(id="secondary-output"), + html.Button("click", id="clicker"), + ] + ) + + @app.callback( + Output("output", "children"), + Input("clicker", "n_clicks"), + prevent_initial_call=True, + ) + def on_click(n_clicks): + set_props("secondary-output", {"children": "secondary"}) + return f"Clicked {n_clicks} times" + + dash_duo.start_server(app) + + dash_duo.wait_for_element("#clicker").click() + dash_duo.wait_for_text_to_equal("#output", "Clicked 1 times") + dash_duo.wait_for_text_to_equal("#secondary-output", "secondary") + + +def test_arb002_no_output_callbacks(dash_duo): + app = Dash() + + app.layout = html.Div( + [ + html.Div(id="secondary-output"), + html.Button("no-output", id="no-output"), + html.Button("no-output2", id="no-output2"), + ] + ) + + @app.callback( + Input("no-output", "n_clicks"), + prevent_initial_call=True, + ) + def no_output(_): + set_props("secondary-output", {"children": "no-output"}) + + @app.callback( + Input("no-output2", "n_clicks"), + prevent_initial_call=True, + ) + def no_output(_): + set_props("secondary-output", {"children": "no-output2"}) + + dash_duo.start_server(app) + + dash_duo.wait_for_element("#no-output").click() + dash_duo.wait_for_text_to_equal("#secondary-output", "no-output") + + dash_duo.wait_for_element("#no-output2").click() + dash_duo.wait_for_text_to_equal("#secondary-output", "no-output2") + + +def test_arb003_arbitrary_pages(dash_duo): + app = Dash(use_pages=True, pages_folder="") + + register_page( + "page", + "/", + layout=html.Div( + [ + html.Div(id="secondary-output"), + html.Button("no-output", id="no-output"), + html.Button("no-output2", id="no-output2"), + ] + ), + ) + + @app.callback( + Input("no-output", "n_clicks"), + prevent_initial_call=True, + ) + def no_output(_): + set_props("secondary-output", {"children": "no-output"}) + + @app.callback( + Input("no-output2", "n_clicks"), + prevent_initial_call=True, + ) + def no_output(_): + set_props("secondary-output", {"children": "no-output2"}) + + dash_duo.start_server(app) + + dash_duo.wait_for_element("#no-output").click() + dash_duo.wait_for_text_to_equal("#secondary-output", "no-output") + + dash_duo.wait_for_element("#no-output2").click() + dash_duo.wait_for_text_to_equal("#secondary-output", "no-output2") + + +def test_arb004_wildcard_set_props(dash_duo): + app = Dash() + app.layout = html.Div( + [ + html.Button("click", id="click"), + html.Div(html.Div(id={"id": "output", "index": 0}), id="output"), + ] + ) + + @app.callback( + Input("click", "n_clicks"), + prevent_initial_call=True, + ) + def on_click(n_clicks): + set_props( + {"id": "output", "index": 0}, {"children": f"Clicked {n_clicks} times"} + ) + + dash_duo.start_server(app) + + dash_duo.wait_for_element("#click").click() + dash_duo.wait_for_text_to_equal("#output", "Clicked 1 times") From a9f757cab68026017b04460092c803ccd533e626 Mon Sep 17 00:00:00 2001 From: philippe Date: Wed, 10 Apr 2024 11:08:32 -0400 Subject: [PATCH 05/21] Add test arbitrary callbacks for background callbacks. --- dash/_callback.py | 3 +- .../long_callback/app_arbitrary.py | 48 +++++++++++++++++++ .../test_basic_long_callback017.py | 19 ++++++++ 3 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 tests/integration/long_callback/app_arbitrary.py create mode 100644 tests/integration/long_callback/test_basic_long_callback017.py diff --git a/dash/_callback.py b/dash/_callback.py index 758c8ac0cc..d0d39ef3d8 100644 --- a/dash/_callback.py +++ b/dash/_callback.py @@ -351,6 +351,7 @@ def add_context(*args, **kwargs): ) response = {"multi": True} + has_update = False if long is not None: if not callback_manager: @@ -455,6 +456,7 @@ def add_context(*args, **kwargs): 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) @@ -480,7 +482,6 @@ def add_context(*args, **kwargs): flat_output_values = flatten_grouping(output_value, output) component_ids = collections.defaultdict(dict) - has_update = False if not no_output: _validate.validate_multi_return( output_spec, flat_output_values, callback_id diff --git a/tests/integration/long_callback/app_arbitrary.py b/tests/integration/long_callback/app_arbitrary.py new file mode 100644 index 0000000000..e09f08de34 --- /dev/null +++ b/tests/integration/long_callback/app_arbitrary.py @@ -0,0 +1,48 @@ +from dash import Dash, Input, Output, html, callback, set_props +import time + +from tests.integration.long_callback.utils import get_long_callback_manager + +long_callback_manager = get_long_callback_manager() +handle = long_callback_manager.handle + +app = Dash(__name__, long_callback_manager=long_callback_manager) +app.test_lock = lock = long_callback_manager.test_lock + +app.layout = html.Div( + [ + html.Button("start", id="start"), + html.Div(id="secondary"), + html.Div(id="no-output"), + html.Div("initial", id="output"), + html.Button("start-no-output", id="start-no-output"), + ] +) + + +@callback( + Output("output", "children"), + Input("start", "n_clicks"), + prevent_initial_call=True, + background=True, +) +def on_click(_): + set_props("secondary", {"children": "first"}) + time.sleep(2) + set_props("secondary", {"children": "second"}) + return "completed" + + +@callback( + Input("start-no-output", "n_clicks"), + prevent_initial_call=True, + background=True, +) +def on_click(_): + set_props("no-output", {"children": "started"}) + time.sleep(2) + set_props("no-output", {"children": "completed"}) + + +if __name__ == "__main__": + app.run_server(debug=True) diff --git a/tests/integration/long_callback/test_basic_long_callback017.py b/tests/integration/long_callback/test_basic_long_callback017.py new file mode 100644 index 0000000000..599d22f132 --- /dev/null +++ b/tests/integration/long_callback/test_basic_long_callback017.py @@ -0,0 +1,19 @@ +from tests.integration.long_callback.utils import setup_long_callback_app + + +def test_lcbc017_long_callback_set_props(dash_duo, manager): + with setup_long_callback_app(manager, "app_arbitrary") as app: + dash_duo.start_server(app) + + with app.test_lock: + dash_duo.find_element("#start").click() + + dash_duo.wait_for_text_to_equal("#secondary", "first") + dash_duo.wait_for_text_to_equal("#output", "initial") + dash_duo.wait_for_text_to_equal("#secondary", "second") + dash_duo.wait_for_text_to_equal("#output", "completed") + + dash_duo.find_element("#start-no-output").click() + + dash_duo.wait_for_text_to_equal("#no-output", "started") + dash_duo.wait_for_text_to_equal("#no-output", "completed") From d6564d85f44f179e0fe8a9c79d85e22a681c3f4f Mon Sep 17 00:00:00 2001 From: philippe Date: Wed, 10 Apr 2024 11:41:41 -0400 Subject: [PATCH 06/21] Remove lock from test. --- tests/integration/long_callback/test_basic_long_callback017.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/integration/long_callback/test_basic_long_callback017.py b/tests/integration/long_callback/test_basic_long_callback017.py index 599d22f132..bef39d3eed 100644 --- a/tests/integration/long_callback/test_basic_long_callback017.py +++ b/tests/integration/long_callback/test_basic_long_callback017.py @@ -5,8 +5,7 @@ def test_lcbc017_long_callback_set_props(dash_duo, manager): with setup_long_callback_app(manager, "app_arbitrary") as app: dash_duo.start_server(app) - with app.test_lock: - dash_duo.find_element("#start").click() + dash_duo.find_element("#start").click() dash_duo.wait_for_text_to_equal("#secondary", "first") dash_duo.wait_for_text_to_equal("#output", "initial") From 00baa48e4c09196d11730e720a810f2a0290c08f Mon Sep 17 00:00:00 2001 From: philippe Date: Tue, 23 Apr 2024 09:03:50 -0400 Subject: [PATCH 07/21] reuse hashing --- dash/_utils.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/dash/_utils.py b/dash/_utils.py index bb388d65b6..c2577a8a09 100644 --- a/dash/_utils.py +++ b/dash/_utils.py @@ -137,23 +137,24 @@ def create_callback_id(output, inputs, no_output=False): # 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 hashlib.sha256( - ".".join(str(x) for x in inputs).encode("utf-8") - ).hexdigest() + return _hash_inputs if isinstance(output, (list, tuple)): return ".." + "...".join(_concat(x) for x in output) + ".." From d57134b1bd10415dda482d0a98be66a7814b3fb9 Mon Sep 17 00:00:00 2001 From: philippe Date: Tue, 23 Apr 2024 09:37:21 -0400 Subject: [PATCH 08/21] reuse hashing --- dash/_utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dash/_utils.py b/dash/_utils.py index c2577a8a09..4d67cb39dd 100644 --- a/dash/_utils.py +++ b/dash/_utils.py @@ -147,14 +147,14 @@ def _concat(x): _id = x.component_id_str().replace(".", "\\.") + "." + x.component_property if x.allow_duplicate: if not hashed_inputs: - hashed_inputs = _hash_inputs + 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 + return _hash_inputs() if isinstance(output, (list, tuple)): return ".." + "...".join(_concat(x) for x in output) + ".." From f1a92a399710c80450d5c11a0f0a657d20de6b1c Mon Sep 17 00:00:00 2001 From: philippe Date: Tue, 23 Apr 2024 10:23:57 -0400 Subject: [PATCH 09/21] Improve sideUpdate --- dash/dash-renderer/src/actions/callbacks.ts | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/dash/dash-renderer/src/actions/callbacks.ts b/dash/dash-renderer/src/actions/callbacks.ts index 845fcc7eee..bb55997c84 100644 --- a/dash/dash-renderer/src/actions/callbacks.ts +++ b/dash/dash-renderer/src/actions/callbacks.ts @@ -343,18 +343,16 @@ function sideUpdate(outputs: any, dispatch: any) { let componentId, propName; if (id.includes('.')) { [componentId, propName] = id.split('.'); - if (componentId.startsWith('{')) { - componentId = JSON.parse(componentId); - } - dispatch(updateComponent(componentId, {[propName]: value})); } else { - if (id.startsWith('{')) { - componentId = JSON.parse(id); - } else { - componentId = id; - } - dispatch(updateComponent(componentId, value)); + componentId = id; + } + + if (componentId.startsWith('{')) { + componentId = JSON.parse(componentId); } + + const props = propName ? {[propName]: value} : value; + dispatch(updateComponent(componentId, props)); }); } From b1a0529ca47c3c71a94fde07aa9bb6f57a7f1a26 Mon Sep 17 00:00:00 2001 From: philippe Date: Tue, 23 Apr 2024 11:35:20 -0400 Subject: [PATCH 10/21] Add error when returning value in no output callbacks. --- dash/_callback.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/dash/_callback.py b/dash/_callback.py index 9424c031ac..35e57528ab 100644 --- a/dash/_callback.py +++ b/dash/_callback.py @@ -10,6 +10,7 @@ Output, ) from .exceptions import ( + InvalidCallbackReturnValue, PreventUpdate, WildcardInLongCallback, MissingLongCallbackManagerError, @@ -467,6 +468,10 @@ def add_context(*args, **kwargs): raise PreventUpdate if no_output: + if output_value is not None: + raise InvalidCallbackReturnValue( + f"No output callback received return value: {output_value}" + ) output_value = [] flat_output_values = [] elif not multi: From dfdb6056496443462435d9aa0590c42135cd0d69 Mon Sep 17 00:00:00 2001 From: philippe Date: Tue, 23 Apr 2024 14:40:26 -0400 Subject: [PATCH 11/21] Add ProxySetProps docstring --- dash/long_callback/_proxy_set_props.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/dash/long_callback/_proxy_set_props.py b/dash/long_callback/_proxy_set_props.py index 3327305f3f..52f0af70a6 100644 --- a/dash/long_callback/_proxy_set_props.py +++ b/dash/long_callback/_proxy_set_props.py @@ -1,4 +1,9 @@ class ProxySetProps(dict): + """ + Defer dictionary item setter to run a custom function on change. + Used by background callback manager to save the `set_props` data. + """ + def __init__(self, on_change): super().__init__() self.on_change = on_change From 8891a71546b1e5689869b6bc7eb4e8d3f54200f5 Mon Sep 17 00:00:00 2001 From: philippe Date: Wed, 24 Apr 2024 11:18:33 -0400 Subject: [PATCH 12/21] use stringigfy_id --- dash/_callback_context.py | 8 +++----- dash/dependencies.py | 16 ++-------------- 2 files changed, 5 insertions(+), 19 deletions(-) diff --git a/dash/_callback_context.py b/dash/_callback_context.py index db82d5aaee..aaa721369e 100644 --- a/dash/_callback_context.py +++ b/dash/_callback_context.py @@ -7,7 +7,7 @@ import flask from . import exceptions -from ._utils import AttributeDict +from ._utils import AttributeDict, stringify_id context_value = contextvars.ContextVar("callback_context") @@ -251,10 +251,8 @@ def timing_information(self): @has_context def set_props(self, component_id: typing.Union[str, dict], props: dict): ctx_value = _get_context_value() - if isinstance(component_id, dict): - ctx_value.updated_props[json.dumps(component_id)] = props - else: - ctx_value.updated_props[component_id] = props + _id = stringify_id(component_id) + ctx_value.updated_props[_id] = props callback_context = CallbackContext() diff --git a/dash/dependencies.py b/dash/dependencies.py index 2e2ce322c7..819d134546 100644 --- a/dash/dependencies.py +++ b/dash/dependencies.py @@ -1,8 +1,8 @@ -import json from dash.development.base_component import Component from ._validate import validate_callback from ._grouping import flatten_grouping, make_grouping_by_index +from ._utils import stringify_id class _Wildcard: # pylint: disable=too-few-public-methods @@ -44,19 +44,7 @@ def __repr__(self): return f"<{self.__class__.__name__} `{self}`>" def component_id_str(self): - i = self.component_id - - def _dump(v): - return json.dumps(v, sort_keys=True, separators=(",", ":")) - - 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(i, dict): - return "{" + ",".join(_json(k, i[k]) for k in sorted(i)) + "}" - - return i + return stringify_id(self.component_id) def to_dict(self): return {"id": self.component_id_str(), "property": self.component_property} From 939c0100adcb62e6624e39bf465c9f421a28c580 Mon Sep 17 00:00:00 2001 From: philippe Date: Wed, 24 Apr 2024 11:25:51 -0400 Subject: [PATCH 13/21] rollback pylintrc change --- .pylintrc | 2 +- dash/dash.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.pylintrc b/.pylintrc index db7fac560e..554f953b8e 100644 --- a/.pylintrc +++ b/.pylintrc @@ -426,7 +426,7 @@ max-public-methods=40 max-returns=6 # Maximum number of statements in function / method body -max-statements=75 +max-statements=50 # Minimum number of public methods for a class (see R0903). min-public-methods=2 diff --git a/dash/dash.py b/dash/dash.py index 4ca11c0fed..b17ef80c96 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -1266,6 +1266,7 @@ def long_callback( **_kwargs, ) + # pylint: disable=R0915 def dispatch(self): body = flask.request.get_json() From b12c4817653d4399fd083709352768e6ecdf1b36 Mon Sep 17 00:00:00 2001 From: philippe Date: Wed, 24 Apr 2024 11:55:54 -0400 Subject: [PATCH 14/21] fix stringify_id --- dash/_utils.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/dash/_utils.py b/dash/_utils.py index 4d67cb39dd..38ff0c39ac 100644 --- a/dash/_utils.py +++ b/dash/_utils.py @@ -174,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_ From dfaf5d1005bebc6bbdc6b6c4a123e6789e3e9758 Mon Sep 17 00:00:00 2001 From: philippe Date: Wed, 24 Apr 2024 16:36:36 -0400 Subject: [PATCH 15/21] Fix case where the dict id contains a dot --- dash/dash-renderer/src/actions/callbacks.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/dash/dash-renderer/src/actions/callbacks.ts b/dash/dash-renderer/src/actions/callbacks.ts index bb55997c84..20961199ff 100644 --- a/dash/dash-renderer/src/actions/callbacks.ts +++ b/dash/dash-renderer/src/actions/callbacks.ts @@ -340,15 +340,19 @@ function updateComponent(component_id: any, props: any) { function sideUpdate(outputs: any, dispatch: any) { toPairs(outputs).forEach(([id, value]) => { - let componentId, propName; - if (id.includes('.')) { + 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('.'); - } else { - componentId = id; - } - - if (componentId.startsWith('{')) { - componentId = JSON.parse(componentId); } const props = propName ? {[propName]: value} : value; From 63604214ff1d50116d161591a429988b1fad784d Mon Sep 17 00:00:00 2001 From: philippe Date: Thu, 25 Apr 2024 14:21:10 -0400 Subject: [PATCH 16/21] Refactor no_output variable to has_output and less branching --- dash/_callback.py | 35 ++++++++++++++++++----------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/dash/_callback.py b/dash/_callback.py index 35e57528ab..35a3168096 100644 --- a/dash/_callback.py +++ b/dash/_callback.py @@ -287,12 +287,12 @@ def register_callback( # pylint: disable=R0914 # Insert callback with scalar (non-multi) Output insert_output = output multi = False - no_output = False + has_output = True else: # Insert callback as multi Output insert_output = flatten_grouping(output) multi = True - no_output = len(output) == 0 + has_output = len(output) > 0 long = _kwargs.get("long") manager = _kwargs.get("manager") @@ -321,7 +321,7 @@ def register_callback( # pylint: disable=R0914 manager=manager, dynamic_creator=allow_dynamic_callbacks, running=running, - no_output=no_output, + no_output=not has_output, ) # pylint: disable=too-many-locals @@ -342,7 +342,7 @@ def add_context(*args, **kwargs): "callback_context", AttributeDict({"updated_props": {}}) ) callback_manager = long and long.get("manager", app_callback_manager) - if not no_output: + if has_output: _validate.validate_output_spec(insert_output, output_spec, Output) context_value.set(callback_ctx) @@ -467,27 +467,28 @@ def add_context(*args, **kwargs): if NoUpdate.is_no_update(output_value): raise PreventUpdate - if no_output: + component_ids = collections.defaultdict(dict) + + if not has_output: if output_value is not None: raise InvalidCallbackReturnValue( f"No output callback received return value: {output_value}" ) output_value = [] flat_output_values = [] - elif 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) + 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) - # Flatten grouping and validate grouping structure - flat_output_values = flatten_grouping(output_value, output) - - component_ids = collections.defaultdict(dict) - if not no_output: _validate.validate_multi_return( output_spec, flat_output_values, callback_id ) From 2b29699492730278842b3ec24ff4c96d3e8ce9d0 Mon Sep 17 00:00:00 2001 From: philippe Date: Thu, 25 Apr 2024 14:53:06 -0400 Subject: [PATCH 17/21] Update changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2c04f9e3b..531ba7da8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 From a1e461f829cf99e9f571177663edd19a90ecc56d Mon Sep 17 00:00:00 2001 From: philippe Date: Thu, 25 Apr 2024 14:54:56 -0400 Subject: [PATCH 18/21] Add no output counter test --- .../callbacks/test_arbitrary_callbacks.py | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/tests/integration/callbacks/test_arbitrary_callbacks.py b/tests/integration/callbacks/test_arbitrary_callbacks.py index f5c4612eaa..18269ecae9 100644 --- a/tests/integration/callbacks/test_arbitrary_callbacks.py +++ b/tests/integration/callbacks/test_arbitrary_callbacks.py @@ -1,3 +1,6 @@ +import time +from multiprocessing import Value + from dash import Dash, Input, Output, html, set_props, register_page @@ -30,11 +33,14 @@ def on_click(n_clicks): def test_arb002_no_output_callbacks(dash_duo): app = Dash() + counter = Value("i", 0) + app.layout = html.Div( [ html.Div(id="secondary-output"), html.Button("no-output", id="no-output"), html.Button("no-output2", id="no-output2"), + html.Button("no-output3", id="no-output3"), ] ) @@ -42,16 +48,24 @@ def test_arb002_no_output_callbacks(dash_duo): Input("no-output", "n_clicks"), prevent_initial_call=True, ) - def no_output(_): + def no_output1(_): set_props("secondary-output", {"children": "no-output"}) @app.callback( Input("no-output2", "n_clicks"), prevent_initial_call=True, ) - def no_output(_): + def no_output2(_): set_props("secondary-output", {"children": "no-output2"}) + @app.callback( + Input("no-output3", "n_clicks"), + prevent_initial_call=True, + ) + def no_output3(_): + with counter.get_lock(): + counter.value += 1 + dash_duo.start_server(app) dash_duo.wait_for_element("#no-output").click() @@ -60,6 +74,12 @@ def no_output(_): dash_duo.wait_for_element("#no-output2").click() dash_duo.wait_for_text_to_equal("#secondary-output", "no-output2") + dash_duo.wait_for_element("#no-output3").click() + + time.sleep(1) + with counter.get_lock(): + assert counter.value == 1 + def test_arb003_arbitrary_pages(dash_duo): app = Dash(use_pages=True, pages_folder="") From ad73c298bda0b39c964440d7c679b7102a5698cc Mon Sep 17 00:00:00 2001 From: philippe Date: Thu, 25 Apr 2024 15:21:32 -0400 Subject: [PATCH 19/21] reverse condition --- dash/_callback.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/dash/_callback.py b/dash/_callback.py index 35a3168096..b5d471aa5d 100644 --- a/dash/_callback.py +++ b/dash/_callback.py @@ -469,14 +469,7 @@ def add_context(*args, **kwargs): component_ids = collections.defaultdict(dict) - if not has_output: - if output_value is not None: - raise InvalidCallbackReturnValue( - f"No output callback received return value: {output_value}" - ) - output_value = [] - flat_output_values = [] - else: + if has_output: if not multi: output_value, output_spec = [output_value], [output_spec] flat_output_values = output_value @@ -504,6 +497,13 @@ def add_context(*args, **kwargs): 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) From 33f6d742d3be80a3b3e41d37cf6c53c93af74a63 Mon Sep 17 00:00:00 2001 From: philippe Date: Wed, 1 May 2024 15:40:03 -0400 Subject: [PATCH 20/21] Better error message on no output callbacks exceptions --- .../src/observers/executedCallbacks.ts | 19 ++++++++++----- dash/dash-renderer/src/types/callbacks.ts | 1 + .../callbacks/test_arbitrary_callbacks.py | 24 +++++++++++++++++++ 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/dash/dash-renderer/src/observers/executedCallbacks.ts b/dash/dash-renderer/src/observers/executedCallbacks.ts index 76d78a3c06..1249252704 100644 --- a/dash/dash-renderer/src/observers/executedCallbacks.ts +++ b/dash/dash-renderer/src/observers/executedCallbacks.ts @@ -302,12 +302,19 @@ const observer: IStoreObserverDefinition = { } if (error !== undefined) { - const outputs = payload - ? map(combineIdAndProp, flatten([payload.outputs])).join( - ', ' - ) - : output; - let message = `Callback error updating ${outputs}`; + let message; + if (cb.callback.no_output) { + const inpts = keys(cb.changedPropIds).join(', '); + message = `Callback error with no output from input ${inpts}`; + } else { + const outputs = payload + ? map( + combineIdAndProp, + flatten([payload.outputs]) + ).join(', ') + : output; + message = `Callback error updating ${outputs}`; + } if (clientside_function) { const {namespace: ns, function_name: fn} = clientside_function; diff --git a/dash/dash-renderer/src/types/callbacks.ts b/dash/dash-renderer/src/types/callbacks.ts index fe8fd03ba7..33942b0281 100644 --- a/dash/dash-renderer/src/types/callbacks.ts +++ b/dash/dash-renderer/src/types/callbacks.ts @@ -14,6 +14,7 @@ export interface ICallbackDefinition { long?: LongCallbackInfo; dynamic_creator?: boolean; running: any; + no_output?: boolean; } export interface ICallbackProperty { diff --git a/tests/integration/callbacks/test_arbitrary_callbacks.py b/tests/integration/callbacks/test_arbitrary_callbacks.py index 18269ecae9..4441c44add 100644 --- a/tests/integration/callbacks/test_arbitrary_callbacks.py +++ b/tests/integration/callbacks/test_arbitrary_callbacks.py @@ -141,3 +141,27 @@ def on_click(n_clicks): dash_duo.wait_for_element("#click").click() dash_duo.wait_for_text_to_equal("#output", "Clicked 1 times") + + +def test_arb005_no_output_error(dash_duo): + app = Dash() + + app.layout = html.Div([html.Button("start", id="start")]) + + @app.callback(Input("start", "n_clicks"), prevent_initial_call=True) + def on_click(clicked): + return f"clicked {clicked}" + + dash_duo.start_server( + app, + debug=True, + use_reloader=False, + use_debugger=True, + dev_tools_hot_reload=False, + ) + + dash_duo.wait_for_element("#start").click() + dash_duo.wait_for_text_to_equal( + ".dash-fe-error__title", + "Callback error with no output from input start.n_clicks", + ) From d367a6e115f67f7b4cbc401b2749155efbdd1f7d Mon Sep 17 00:00:00 2001 From: philippe Date: Wed, 1 May 2024 16:01:51 -0400 Subject: [PATCH 21/21] No output -> No-output --- dash/_callback.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dash/_callback.py b/dash/_callback.py index b5d471aa5d..3d63d4b21e 100644 --- a/dash/_callback.py +++ b/dash/_callback.py @@ -500,7 +500,7 @@ def add_context(*args, **kwargs): else: if output_value is not None: raise InvalidCallbackReturnValue( - f"No output callback received return value: {output_value}" + f"No-output callback received return value: {output_value}" ) output_value = [] flat_output_values = []