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 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 e066e9a682..3d63d4b21e 100644 --- a/dash/_callback.py +++ b/dash/_callback.py @@ -10,6 +10,7 @@ Output, ) from .exceptions import ( + InvalidCallbackReturnValue, PreventUpdate, WildcardInLongCallback, MissingLongCallbackManagerError, @@ -226,6 +227,7 @@ def insert_callback( manager=None, running=None, dynamic_creator=False, + no_output=False, ): if prevent_initial_call is None: prevent_initial_call = config_prevent_initial_callbacks @@ -234,7 +236,7 @@ def insert_callback( output, prevent_initial_call, config_prevent_initial_callbacks ) - callback_id = create_callback_id(output, inputs) + callback_id = create_callback_id(output, inputs, no_output) callback_spec = { "output": callback_id, "inputs": [c.to_dict() for c in inputs], @@ -248,6 +250,7 @@ def insert_callback( "interval": long["interval"], }, "dynamic_creator": dynamic_creator, + "no_output": no_output, } if running: callback_spec["running"] = running @@ -262,6 +265,7 @@ def insert_callback( "raw_inputs": inputs, "manager": manager, "allow_dynamic_callbacks": dynamic_creator, + "no_output": no_output, } callback_list.append(callback_spec) @@ -283,10 +287,12 @@ def register_callback( # pylint: disable=R0914 # Insert callback with scalar (non-multi) Output insert_output = output multi = False + has_output = True else: # Insert callback as multi Output insert_output = flatten_grouping(output) multi = True + has_output = len(output) > 0 long = _kwargs.get("long") manager = _kwargs.get("manager") @@ -315,6 +321,7 @@ def register_callback( # pylint: disable=R0914 manager=manager, dynamic_creator=allow_dynamic_callbacks, running=running, + no_output=not has_output, ) # pylint: disable=too-many-locals @@ -331,9 +338,12 @@ def wrap_func(func): def add_context(*args, **kwargs): output_spec = kwargs.pop("outputs_list") app_callback_manager = kwargs.pop("long_callback_manager", None) - callback_ctx = kwargs.pop("callback_context", {}) + callback_ctx = kwargs.pop( + "callback_context", AttributeDict({"updated_props": {}}) + ) callback_manager = long and long.get("manager", app_callback_manager) - _validate.validate_output_spec(insert_output, output_spec, Output) + if has_output: + _validate.validate_output_spec(insert_output, output_spec, Output) context_value.set(callback_ctx) @@ -342,6 +352,7 @@ def add_context(*args, **kwargs): ) response = {"multi": True} + has_update = False if long is not None: if not callback_manager: @@ -443,6 +454,10 @@ def add_context(*args, **kwargs): NoUpdate() if NoUpdate.is_no_update(r) else r for r in output_value ] + updated_props = callback_manager.get_updated_props(cache_key) + if len(updated_props) > 0: + response["sideUpdate"] = updated_props + has_update = True if output_value is callback_manager.UNDEFINED: return to_json(response) @@ -452,35 +467,49 @@ def add_context(*args, **kwargs): if NoUpdate.is_no_update(output_value): raise PreventUpdate - if not multi: - output_value, output_spec = [output_value], [output_spec] - flat_output_values = output_value - else: - if isinstance(output_value, (list, tuple)): - # For multi-output, allow top-level collection to be - # list or tuple - output_value = list(output_value) - - # Flatten grouping and validate grouping structure - flat_output_values = flatten_grouping(output_value, output) + component_ids = collections.defaultdict(dict) - _validate.validate_multi_return( - output_spec, flat_output_values, callback_id - ) + if has_output: + if not multi: + output_value, output_spec = [output_value], [output_spec] + flat_output_values = output_value + else: + if isinstance(output_value, (list, tuple)): + # For multi-output, allow top-level collection to be + # list or tuple + output_value = list(output_value) + + # Flatten grouping and validate grouping structure + flat_output_values = flatten_grouping(output_value, output) + + _validate.validate_multi_return( + output_spec, flat_output_values, callback_id + ) - component_ids = collections.defaultdict(dict) - has_update = False - for val, spec in zip(flat_output_values, output_spec): - if isinstance(val, NoUpdate): - continue - for vali, speci in ( - zip(val, spec) if isinstance(spec, list) else [[val, spec]] - ): - if not isinstance(vali, NoUpdate): - has_update = True - id_str = stringify_id(speci["id"]) - prop = clean_property_name(speci["property"]) - component_ids[id_str][prop] = vali + for val, spec in zip(flat_output_values, output_spec): + if isinstance(val, NoUpdate): + continue + for vali, speci in ( + zip(val, spec) if isinstance(spec, list) else [[val, spec]] + ): + if not isinstance(vali, NoUpdate): + has_update = True + id_str = stringify_id(speci["id"]) + prop = clean_property_name(speci["property"]) + component_ids[id_str][prop] = vali + else: + if output_value is not None: + raise InvalidCallbackReturnValue( + 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 diff --git a/dash/_callback_context.py b/dash/_callback_context.py index 7638d0a860..aaa721369e 100644 --- a/dash/_callback_context.py +++ b/dash/_callback_context.py @@ -2,11 +2,12 @@ import warnings import json import contextvars +import typing import flask from . import exceptions -from ._utils import AttributeDict +from ._utils import AttributeDict, stringify_id context_value = contextvars.ContextVar("callback_context") @@ -247,5 +248,18 @@ def using_outputs_grouping(self): def timing_information(self): return getattr(flask.g, "timing_information", {}) + @has_context + def set_props(self, component_id: typing.Union[str, dict], props: dict): + ctx_value = _get_context_value() + _id = stringify_id(component_id) + ctx_value.updated_props[_id] = props + callback_context = CallbackContext() + + +def set_props(component_id: typing.Union[str, dict], props: dict): + """ + 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 234dea31a3..38ff0c39ac 100644 --- a/dash/_utils.py +++ b/dash/_utils.py @@ -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) + ".." @@ -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_ diff --git a/dash/dash-renderer/src/actions/callbacks.ts b/dash/dash-renderer/src/actions/callbacks.ts index 68320568e5..20961199ff 100644 --- a/dash/dash-renderer/src/actions/callbacks.ts +++ b/dash/dash-renderer/src/actions/callbacks.ts @@ -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)); }); } @@ -345,7 +365,6 @@ function handleServerside( hooks: any, config: any, payload: any, - paths: any, long: LongCallbackInfo | undefined, additionalArgs: [string, string, boolean?][] | undefined, getState: any, @@ -365,7 +384,7 @@ function handleServerside( let moreArgs = additionalArgs; if (running) { - sideUpdate(running.running, dispatch, paths); + sideUpdate(running.running, dispatch); runningOff = running.runningOff; } @@ -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); } }; @@ -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; @@ -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); } } } @@ -713,7 +732,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/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 62fcb19d20..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 { @@ -103,4 +104,5 @@ export type CallbackResponseData = { running?: CallbackResponse; runningOff?: CallbackResponse; cancel?: ICallbackProperty[]; + sideUpdate?: any; }; diff --git a/dash/dash.py b/dash/dash.py index 9309762c9e..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() @@ -1280,7 +1281,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 @@ -1312,6 +1313,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 @@ -1340,14 +1347,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/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} diff --git a/dash/long_callback/_proxy_set_props.py b/dash/long_callback/_proxy_set_props.py new file mode 100644 index 0000000000..52f0af70a6 --- /dev/null +++ b/dash/long_callback/_proxy_set_props.py @@ -0,0 +1,12 @@ +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 + + 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 651656c004..6167828c60 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): diff --git a/tests/integration/callbacks/test_arbitrary_callbacks.py b/tests/integration/callbacks/test_arbitrary_callbacks.py new file mode 100644 index 0000000000..4441c44add --- /dev/null +++ b/tests/integration/callbacks/test_arbitrary_callbacks.py @@ -0,0 +1,167 @@ +import time +from multiprocessing import Value + +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() + + 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"), + ] + ) + + @app.callback( + Input("no-output", "n_clicks"), + prevent_initial_call=True, + ) + def no_output1(_): + set_props("secondary-output", {"children": "no-output"}) + + @app.callback( + Input("no-output2", "n_clicks"), + prevent_initial_call=True, + ) + 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() + 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") + + 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="") + + 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") + + +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", + ) 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:"], - ], ], ) 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..bef39d3eed --- /dev/null +++ b/tests/integration/long_callback/test_basic_long_callback017.py @@ -0,0 +1,18 @@ +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) + + 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")