diff --git a/CHANGELOG.md b/CHANGELOG.md index 49e4accbf2..81698b2cbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,13 +6,13 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## Added --### Added - - [#2068](https://github.com/plotly/dash/pull/2068) Added `refresh="callback-nav"` in `dcc.Location`. This allows for navigation without refreshing the page when url is updated in a callback. - [#2417](https://github.com/plotly/dash/pull/2417) Add wait_timeout property to customize the behavior of the default wait timeout used for by wait_for_page, fix [#1595](https://github.com/plotly/dash/issues/1595) - [#2417](https://github.com/plotly/dash/pull/2417) Add the element target text for wait_for_text* error message, fix [#945](https://github.com/plotly/dash/issues/945) - [#2425](https://github.com/plotly/dash/pull/2425) Add `add_log_handler=True` to Dash init, if you don't want a log stream handler at all. - [#2260](https://github.com/plotly/dash/pull/2260) Experimental support for React 18. The default is still React v16.14.0, but to use React 18 you can either set the environment variable `REACT_VERSION=18.2.0` before running your app, or inside the app call `dash._dash_renderer._set_react_version("18.2.0")`. THIS FEATURE IS EXPERIMENTAL. It has not been tested with component suites outside the Dash core, and we may add or remove available React versions in any future release. +- [#2414](https://github.com/plotly/dash/pull/2414) Add `dash.Patch`for partial update Output props without transferring the previous value in a State. +- [#2414](https://github.com/plotly/dash/pull/2414) Add `allow_duplicate` to `Output` arguments allowing duplicate callbacks to target the same prop. ## Fixed diff --git a/dash/__init__.py b/dash/__init__.py index 6a755c7a4e..871da672d0 100644 --- a/dash/__init__.py +++ b/dash/__init__.py @@ -4,7 +4,7 @@ __plotly_dash = True from .dependencies import ( # noqa: F401,E402 Input, # noqa: F401,E402 - Output, # noqa: F401,E402 + Output, # noqa: F401,E402, State, # noqa: F401,E402 ClientsideFunction, # noqa: F401,E402 MATCH, # noqa: F401,E402 @@ -38,6 +38,6 @@ no_update, page_container, ) - +from ._patch import Patch # noqa: F401,E402 ctx = callback_context diff --git a/dash/_callback.py b/dash/_callback.py index 9d58fe6edd..7e099e0de1 100644 --- a/dash/_callback.py +++ b/dash/_callback.py @@ -27,6 +27,7 @@ to_json, coerce_to_list, AttributeDict, + clean_property_name, ) from . import _validate @@ -240,13 +241,19 @@ def insert_callback( if prevent_initial_call is None: prevent_initial_call = config_prevent_initial_callbacks + _validate.validate_duplicate_output( + output, prevent_initial_call, config_prevent_initial_callbacks + ) + callback_id = create_callback_id(output) callback_spec = { "output": callback_id, "inputs": [c.to_dict() for c in inputs], "state": [c.to_dict() for c in state], "clientside_function": None, - "prevent_initial_call": prevent_initial_call, + # prevent_initial_call can be a string "initial_duplicates" + # which should not prevent the initial call. + "prevent_initial_call": prevent_initial_call is True, "long": long and { "interval": long["interval"], @@ -469,7 +476,8 @@ def add_context(*args, **kwargs): if not isinstance(vali, NoUpdate): has_update = True id_str = stringify_id(speci["id"]) - component_ids[id_str][speci["property"]] = vali + prop = clean_property_name(speci["property"]) + component_ids[id_str][prop] = vali if not has_update: raise PreventUpdate diff --git a/dash/_patch.py b/dash/_patch.py new file mode 100644 index 0000000000..aba5d4f4e8 --- /dev/null +++ b/dash/_patch.py @@ -0,0 +1,142 @@ +def _operation(name, location, **kwargs): + return {"operation": name, "location": location, "params": dict(**kwargs)} + + +_noop = object() + + +def validate_slice(obj): + if isinstance(obj, slice): + raise TypeError("a slice is not a valid index for patch") + + +class Patch: + """ + Patch a callback output value + + Act like a proxy of the output prop value on the frontend. + + Supported prop types: Dictionaries and lists. + """ + + def __init__(self, location=None, parent=None): + if location is not None: + self._location = location + else: + # pylint: disable=consider-using-ternary + self._location = (parent and parent._location) or [] + if parent is not None: + self._operations = parent._operations + else: + self._operations = [] + + def __getitem__(self, item): + validate_slice(item) + return Patch(location=self._location + [item], parent=self) + + def __getattr__(self, item): + if item == "tolist": + # to_json fix + raise AttributeError + if item == "_location": + return self._location + if item == "_operations": + return self._operations + return self.__getitem__(item) + + def __setattr__(self, key, value): + if key in ("_location", "_operations"): + self.__dict__[key] = value + else: + self.__setitem__(key, value) + + def __delattr__(self, item): + self.__delitem__(item) + + def __setitem__(self, key, value): + validate_slice(key) + if value is _noop: + # The += set themselves. + return + self._operations.append( + _operation( + "Assign", + self._location + [key], + value=value, + ) + ) + + def __delitem__(self, key): + validate_slice(key) + self._operations.append(_operation("Delete", self._location + [key])) + + def __iadd__(self, other): + if isinstance(other, (list, tuple)): + self.extend(other) + else: + self._operations.append(_operation("Add", self._location, value=other)) + return _noop + + def __isub__(self, other): + self._operations.append(_operation("Sub", self._location, value=other)) + return _noop + + def __imul__(self, other): + self._operations.append(_operation("Mul", self._location, value=other)) + return _noop + + def __itruediv__(self, other): + self._operations.append(_operation("Div", self._location, value=other)) + return _noop + + def __ior__(self, other): + self.update(E=other) + return _noop + + def append(self, item): + """Add the item to the end of a list""" + self._operations.append(_operation("Append", self._location, value=item)) + + def prepend(self, item): + """Add the item to the start of a list""" + self._operations.append(_operation("Prepend", self._location, value=item)) + + def insert(self, index, item): + """Add the item at the index of a list""" + self._operations.append( + _operation("Insert", self._location, value=item, index=index) + ) + + def clear(self): + """Remove all items in a list""" + self._operations.append(_operation("Clear", self._location)) + + def reverse(self): + """Reversal of the order of items in a list""" + self._operations.append(_operation("Reverse", self._location)) + + def extend(self, item): + """Add all the items to the end of a list""" + if not isinstance(item, (list, tuple)): + raise TypeError(f"{item} should be a list or tuple") + self._operations.append(_operation("Extend", self._location, value=item)) + + def remove(self, item): + """filter the item out of a list on the frontend""" + self._operations.append(_operation("Remove", self._location, value=item)) + + def update(self, E=None, **F): + """Merge a dict or keyword arguments with another dictionary""" + value = E or {} + value.update(F) + self._operations.append(_operation("Merge", self._location, value=value)) + + # pylint: disable=no-self-use + def sort(self): + raise KeyError("sort is reserved for future use, use brackets to access this key on your object") + + def to_plotly_json(self): + return { + "__dash_patch_update": "__dash_patch_update", + "operations": self._operations, + } diff --git a/dash/_utils.py b/dash/_utils.py index 52f0b88b2e..fab6219bcd 100644 --- a/dash/_utils.py +++ b/dash/_utils.py @@ -128,7 +128,11 @@ def create_callback_id(output): # but in case of multiple dots together escape each dot # with `\` so we don't mistake it for multi-outputs def _concat(x): - return x.component_id_str().replace(".", "\\.") + "." + x.component_property + _id = x.component_id_str().replace(".", "\\.") + "." + x.component_property + if x.allow_duplicate: + # Actually adds on the property part. + _id += f"@{uuid.uuid4().hex}" + return _id if isinstance(output, (list, tuple)): return ".." + "...".join(_concat(x) for x in output) + ".." @@ -247,3 +251,7 @@ def coerce_to_list(obj): if not isinstance(obj, (list, tuple)): return [obj] return obj + + +def clean_property_name(name: str): + return name.split("@")[0] diff --git a/dash/_validate.py b/dash/_validate.py index 8bee40594d..24136aeed3 100644 --- a/dash/_validate.py +++ b/dash/_validate.py @@ -7,7 +7,13 @@ from ._grouping import grouping_len, map_grouping from .development.base_component import Component from . import exceptions -from ._utils import patch_collections_abc, stringify_id, to_json, coerce_to_list +from ._utils import ( + patch_collections_abc, + stringify_id, + to_json, + coerce_to_list, + clean_property_name, +) from .exceptions import PageError @@ -123,7 +129,10 @@ def validate_output_spec(output, output_spec, Output): for outi, speci in zip(output, output_spec): speci_list = speci if isinstance(speci, (list, tuple)) else [speci] for specij in speci_list: - if not Output(specij["id"], specij["property"]) == outi: + if ( + not Output(specij["id"], clean_property_name(specij["property"])) + == outi + ): raise exceptions.CallbackException( "Output does not match callback definition" ) @@ -512,3 +521,32 @@ def validate_long_callbacks(callback_map): f"Long callback circular error!\n{circular} is used as input for a long callback" f" but also used as output from an input that is updated with progress or running argument." ) + + +def validate_duplicate_output( + output, prevent_initial_call, config_prevent_initial_call +): + + if "initial_duplicate" in (prevent_initial_call, config_prevent_initial_call): + return + + def _valid(out): + if ( + out.allow_duplicate + and not prevent_initial_call + and not config_prevent_initial_call + ): + raise exceptions.DuplicateCallback( + "allow_duplicate requires prevent_initial_call to be True. The order of the call is not" + " guaranteed to be the same on every page load. " + "To enable duplicate callback with initial call, set prevent_initial_call='initial_duplicate' " + " or globally in the config prevent_initial_callbacks='initial_duplicate'" + ) + + if isinstance(output, (list, tuple)): + for o in output: + _valid(o) + + return + + _valid(output) diff --git a/dash/dash-renderer/src/actions/callbacks.ts b/dash/dash-renderer/src/actions/callbacks.ts index 63f4f7b633..9064aad175 100644 --- a/dash/dash-renderer/src/actions/callbacks.ts +++ b/dash/dash-renderer/src/actions/callbacks.ts @@ -10,7 +10,8 @@ import { pluck, values, toPairs, - zip + zip, + assocPath } from 'ramda'; import {STATUS, JWT_EXPIRED_MESSAGE} from '../constants/constants'; @@ -39,6 +40,8 @@ import {createAction, Action} from 'redux-actions'; import {addHttpHeaders} from '../actions'; import {notifyObservers, updateProps} from './index'; import {CallbackJobPayload} from '../reducers/callbackJobs'; +import {handlePatch, isPatch} from './patch'; +import {getPath} from './paths'; export const addBlockedCallbacks = createAction( CallbackActionType.AddBlocked @@ -683,7 +686,7 @@ export function executeCallback( for (let retry = 0; retry <= MAX_AUTH_RETRIES; retry++) { try { - const data = await handleServerside( + let data = await handleServerside( dispatch, hooks, newConfig, @@ -698,6 +701,28 @@ export function executeCallback( if (newHeaders) { dispatch(addHttpHeaders(newHeaders)); } + // Layout may have changed. + const currentLayout = getState().layout; + flatten(outputs).forEach((out: any) => { + const propName = out.property.split('@')[0]; + const outputPath = getPath(paths, out.id); + const previousValue = path( + outputPath.concat(['props', propName]), + currentLayout + ); + const dataPath = [stringifyId(out.id), propName]; + const outputValue = path(dataPath, data); + if (isPatch(outputValue)) { + if (previousValue === undefined) { + throw new Error('Cannot patch undefined'); + } + data = assocPath( + dataPath, + handlePatch(previousValue, outputValue), + data + ); + } + }); return {data, payload}; } catch (res: any) { diff --git a/dash/dash-renderer/src/actions/dependencies.js b/dash/dash-renderer/src/actions/dependencies.js index 4e478e0e6d..d748dc4b0b 100644 --- a/dash/dash-renderer/src/actions/dependencies.js +++ b/dash/dash-renderer/src/actions/dependencies.js @@ -485,7 +485,8 @@ export function validateCallbacksToLayout(state_, dispatchError) { ]); } - function validateProp(id, idPath, prop, cls, callbacks) { + function validateProp(id, idPath, rawProp, cls, callbacks) { + const prop = rawProp.split('@')[0]; const component = path(idPath, layout); const element = Registry.resolve(component); diff --git a/dash/dash-renderer/src/actions/patch.ts b/dash/dash-renderer/src/actions/patch.ts new file mode 100644 index 0000000000..fac5b59ba3 --- /dev/null +++ b/dash/dash-renderer/src/actions/patch.ts @@ -0,0 +1,168 @@ +import { + append, + assocPath, + concat, + dissocPath, + empty, + equals, + has, + insert, + is, + path, + prepend, + reverse +} from 'ramda'; + +type PatchOperation = { + operation: string; + location: LocationIndex[]; + params: any; +}; + +type LocationIndex = string | number; +type PatchHandler = (previous: any, patchUpdate: PatchOperation) => any; + +export function isPatch(obj: any): boolean { + return has('__dash_patch_update', obj); +} + +function getLocationIndex(value: LocationIndex, previous: any) { + if (is(Number, value) && value < 0) { + return previous.length + value; + } + return value; +} + +function getLocationPath(location: LocationIndex[], obj: any) { + const current = []; + + for (let i = 0; i < location.length; i++) { + const value = getLocationIndex(location[i], path(current, obj)); + current.push(value); + } + + return current; +} + +const patchHandlers: {[k: string]: PatchHandler} = { + Assign: (previous, patchOperation) => { + const {params, location} = patchOperation; + return assocPath(location, params.value, previous); + }, + Merge: (previous, patchOperation) => { + const prev: any = path(patchOperation.location, previous); + return assocPath( + patchOperation.location, + { + ...prev, + ...patchOperation.params.value + }, + previous + ); + }, + Extend: (previous, patchOperation) => { + const prev: any = path(patchOperation.location, previous); + return assocPath( + patchOperation.location, + concat(prev, patchOperation.params.value), + previous + ); + }, + Delete: (previous, patchOperation) => { + return dissocPath(patchOperation.location, previous); + }, + Insert: (previous, patchOperation) => { + const prev: any = path(patchOperation.location, previous); + return assocPath( + patchOperation.location, + insert( + getLocationIndex(patchOperation.params.index, prev), + patchOperation.params.value, + prev + ), + previous + ); + }, + Append: (previous, patchOperation) => { + const prev: any = path(patchOperation.location, previous); + return assocPath( + patchOperation.location, + append(patchOperation.params.value, prev), + previous + ); + }, + Prepend: (previous, patchOperation) => { + const prev: any = path(patchOperation.location, previous); + return assocPath( + patchOperation.location, + prepend(patchOperation.params.value, prev), + previous + ); + }, + Add: (previous, patchOperation) => { + const prev: any = path(patchOperation.location, previous); + return assocPath( + patchOperation.location, + prev + patchOperation.params.value, + previous + ); + }, + Sub: (previous, patchOperation) => { + const prev: any = path(patchOperation.location, previous); + return assocPath( + patchOperation.location, + prev - patchOperation.params.value, + previous + ); + }, + Mul: (previous, patchOperation) => { + const prev: any = path(patchOperation.location, previous); + return assocPath( + patchOperation.location, + prev * patchOperation.params.value, + previous + ); + }, + Div: (previous, patchOperation) => { + const prev: any = path(patchOperation.location, previous); + return assocPath( + patchOperation.location, + prev / patchOperation.params.value, + previous + ); + }, + Clear: (previous, patchOperation) => { + const prev: any = path(patchOperation.location, previous); + return assocPath(patchOperation.location, empty(prev), previous); + }, + Reverse: (previous, patchOperation) => { + const prev: any = path(patchOperation.location, previous); + return assocPath(patchOperation.location, reverse(prev), previous); + }, + Remove: (previous, patchOperation) => { + const prev: any = path(patchOperation.location, previous); + return assocPath( + patchOperation.location, + prev.filter( + (item: any) => !equals(item, patchOperation.params.value) + ), + previous + ); + } +}; + +export function handlePatch(previousValue: T, patchValue: any): T { + let reducedValue = previousValue; + + for (let i = 0; i < patchValue.operations.length; i++) { + const patch = patchValue.operations[i]; + patch.location = getLocationPath(patch.location, reducedValue); + const handler = patchHandlers[patch.operation]; + if (!handler) { + throw new Error(`Invalid Operation ${patch.operation}`); + } + reducedValue = handler(reducedValue, patch); + } + + return reducedValue; +} diff --git a/dash/dash-renderer/src/components/error/CallbackGraph/CallbackGraphContainer.react.js b/dash/dash-renderer/src/components/error/CallbackGraph/CallbackGraphContainer.react.js index 86c06d2f21..ce2c4b95a3 100644 --- a/dash/dash-renderer/src/components/error/CallbackGraph/CallbackGraphContainer.react.js +++ b/dash/dash-renderer/src/components/error/CallbackGraph/CallbackGraphContainer.react.js @@ -31,7 +31,8 @@ function generateElements(graphs, profile, extraLinks) { const elements = []; const structure = {}; - function recordNode(id, property) { + function recordNode(id, rawProperty) { + const property = rawProperty.split('@')[0]; const idStr = stringifyId(id); const idType = typeof id === 'object' ? 'wildcard' : 'component'; @@ -157,6 +158,20 @@ function flattenInputs(inArray, final) { return final; } +function cleanOutputId(outputId) { + return outputId + .replace(/(^\.\.|\.\.$)/g, '') + .split('...') + .reduce( + (agg, next) => + agg.concat( + next.replace(/(.*\..*)(@.+)$/, (a, b) => b + ' (Duplicate)') + ), + [] + ) + .join('...'); +} + // len('__dash_callback__.') const cbPrefixLen = 18; @@ -326,7 +341,7 @@ function CallbackGraph() { // Remove uid and set profile. const callbackOutputId = data.id.slice(cbPrefixLen); - elementName = callbackOutputId.replace(/(^\.\.|\.\.$)/g, ''); + elementName = cleanOutputId(callbackOutputId); const cbProfile = profile.callbacks[callbackOutputId]; if (cbProfile) { const { diff --git a/dash/dependencies.py b/dash/dependencies.py index 93f278b92c..a8a05d2213 100644 --- a/dash/dependencies.py +++ b/dash/dependencies.py @@ -27,6 +27,7 @@ def to_json(self): class DashDependency: # pylint: disable=too-few-public-methods + def __init__(self, component_id, component_property): if isinstance(component_id, Component): @@ -35,6 +36,7 @@ def __init__(self, component_id, component_property): self.component_id = component_id self.component_property = component_property + self.allow_duplicate = False def __str__(self): return f"{self.component_id_str()}.{self.component_property}" @@ -123,6 +125,10 @@ class Output(DashDependency): # pylint: disable=too-few-public-methods allowed_wildcards = (MATCH, ALL) + def __init__(self, component_id, component_property, allow_duplicate=False): + super().__init__(component_id, component_property) + self.allow_duplicate = allow_duplicate + class Input(DashDependency): # pylint: disable=too-few-public-methods """Input of callback: trigger an update when it is updated.""" diff --git a/tests/integration/test_patch.py b/tests/integration/test_patch.py new file mode 100644 index 0000000000..7beb867215 --- /dev/null +++ b/tests/integration/test_patch.py @@ -0,0 +1,374 @@ +import json + +from selenium.webdriver.common.keys import Keys + +from dash import Dash, html, dcc, Input, Output, State, ALL, Patch + + +def test_pch001_patch_operations(dash_duo): + + app = Dash(__name__) + + app.layout = html.Div( + [ + html.Div( + [ + dcc.Input(id="set-value"), + html.Button("Set", id="set-btn"), + ] + ), + html.Div( + [ + dcc.Input(id="append-value"), + html.Button("Append", id="append-btn"), + ] + ), + html.Div( + [ + dcc.Input(id="prepend-value"), + html.Button("prepend", id="prepend-btn"), + ] + ), + html.Div( + [ + dcc.Input(id="insert-value"), + dcc.Input(id="insert-index", type="number", value=1), + html.Button("insert", id="insert-btn"), + ] + ), + html.Div( + [ + dcc.Input(id="extend-value"), + html.Button("extend", id="extend-btn"), + ] + ), + html.Div( + [ + dcc.Input(id="merge-value"), + html.Button("Merge", id="merge-btn"), + ] + ), + html.Button("Delete", id="delete-btn"), + html.Button("Delete index", id="delete-index"), + html.Button("Clear", id="clear-btn"), + html.Button("Reverse", id="reverse-btn"), + html.Button("Remove", id="remove-btn"), + dcc.Store( + data={ + "value": "unset", + "n_clicks": 0, + "array": ["initial"], + "delete": "Delete me", + }, + id="store", + ), + html.Div(id="store-content"), + ] + ) + + app.clientside_callback( + "function(value) {return JSON.stringify(value)}", + Output("store-content", "children"), + Input("store", "data"), + ) + + @app.callback( + Output("store", "data"), + Input("set-btn", "n_clicks"), + State("set-value", "value"), + prevent_initial_call=True, + ) + def on_click(_, value): + p = Patch() + p.value = value + p.n_clicks += 1 + + return p + + @app.callback( + Output("store", "data", allow_duplicate=True), + Input("append-btn", "n_clicks"), + State("append-value", "value"), + prevent_initial_call=True, + ) + def on_click(_, value): + p = Patch() + p.array.append(value) + p.n_clicks += 1 + + return p + + @app.callback( + Output("store", "data", allow_duplicate=True), + Input("prepend-btn", "n_clicks"), + State("prepend-value", "value"), + prevent_initial_call=True, + ) + def on_click(_, value): + p = Patch() + p.array.prepend(value) + p.n_clicks += 1 + + return p + + @app.callback( + Output("store", "data", allow_duplicate=True), + Input("extend-btn", "n_clicks"), + State("extend-value", "value"), + prevent_initial_call=True, + ) + def on_click(_, value): + p = Patch() + p.array.extend([value]) + p.n_clicks += 1 + + return p + + @app.callback( + Output("store", "data", allow_duplicate=True), + Input("merge-btn", "n_clicks"), + State("merge-value", "value"), + prevent_initial_call=True, + ) + def on_click(_, value): + p = Patch() + p.update({"merged": value}) + p.n_clicks += 1 + + return p + + @app.callback( + Output("store", "data", allow_duplicate=True), + Input("delete-btn", "n_clicks"), + prevent_initial_call=True, + ) + def on_click(_): + p = Patch() + del p.delete + return p + + @app.callback( + Output("store", "data", allow_duplicate=True), + Input("insert-btn", "n_clicks"), + State("insert-value", "value"), + State("insert-index", "value"), + prevent_initial_call=True, + ) + def on_insert(_, value, index): + p = Patch() + p.array.insert(index, value) + + return p + + @app.callback( + Output("store", "data", allow_duplicate=True), + Input("delete-index", "n_clicks"), + prevent_initial_call=True, + ) + def on_click(_): + p = Patch() + del p.array[1] + del p.array[-2] + + return p + + @app.callback( + Output("store", "data", allow_duplicate=True), + Input("clear-btn", "n_clicks"), + prevent_initial_call=True, + ) + def on_clear(_): + p = Patch() + p.array.clear() + + return p + + @app.callback( + Output("store", "data", allow_duplicate=True), + Input("reverse-btn", "n_clicks"), + prevent_initial_call=True, + ) + def on_reverse(_): + p = Patch() + p.array.reverse() + + return p + + @app.callback( + Output("store", "data", allow_duplicate=True), + Input("remove-btn", "n_clicks"), + prevent_initial_call=True, + ) + def on_remove(_): + p = Patch() + p.array.remove("initial") + return p + + dash_duo.start_server(app) + + assert dash_duo.get_logs() == [] + + def get_output(): + e = dash_duo.find_element("#store-content") + return json.loads(e.text) + + _input = dash_duo.find_element("#set-value") + _input.send_keys("Set Value") + dash_duo.find_element("#set-btn").click() + + assert get_output()["value"] == "Set Value" + + _input = dash_duo.find_element("#append-value") + _input.send_keys("Append") + dash_duo.find_element("#append-btn").click() + + assert get_output()["array"] == ["initial", "Append"] + + _input = dash_duo.find_element("#prepend-value") + _input.send_keys("Prepend") + dash_duo.find_element("#prepend-btn").click() + + assert get_output()["array"] == ["Prepend", "initial", "Append"] + + _input = dash_duo.find_element("#extend-value") + _input.send_keys("Extend") + dash_duo.find_element("#extend-btn").click() + + assert get_output()["array"] == ["Prepend", "initial", "Append", "Extend"] + + undef = object() + assert get_output().get("merge", undef) is undef + + _input = dash_duo.find_element("#merge-value") + _input.send_keys("Merged") + dash_duo.find_element("#merge-btn").click() + + assert get_output()["merged"] == "Merged" + + assert get_output()["delete"] == "Delete me" + + dash_duo.find_element("#delete-btn").click() + + assert get_output().get("delete", undef) is undef + + _input = dash_duo.find_element("#insert-value") + _input.send_keys("Inserted") + dash_duo.find_element("#insert-btn").click() + + assert get_output().get("array") == [ + "Prepend", + "Inserted", + "initial", + "Append", + "Extend", + ] + + _input.send_keys(" with negative index") + _input = dash_duo.find_element("#insert-index") + _input.send_keys(Keys.BACKSPACE) + _input.send_keys("-1") + dash_duo.find_element("#insert-btn").click() + + assert get_output().get("array") == [ + "Prepend", + "Inserted", + "initial", + "Append", + "Inserted with negative index", + "Extend", + ] + + dash_duo.find_element("#delete-index").click() + assert get_output().get("array") == [ + "Prepend", + "initial", + "Append", + "Extend", + ] + + dash_duo.find_element("#reverse-btn").click() + assert get_output().get("array") == [ + "Extend", + "Append", + "initial", + "Prepend", + ] + + dash_duo.find_element("#remove-btn").click() + assert get_output().get("array") == [ + "Extend", + "Append", + "Prepend", + ] + + dash_duo.find_element("#clear-btn").click() + assert get_output()["array"] == [] + + +def test_pch002_patch_app_pmc_callbacks(dash_duo): + app = Dash(__name__) + + app.layout = html.Div( + [ + html.Button("Click", id="click"), + html.Div(id={"type": "output", "index": 0}, className="first"), + html.Div(id={"type": "output", "index": 1}, className="second"), + ] + ) + + @app.callback( + Output({"type": "output", "index": ALL}, "children"), Input("click", "n_clicks") + ) + def on_click(n_clicks): + if n_clicks is None: + return ["Foo", "Bar"] + p1 = Patch() + p2 = Patch() + + p1.append("Bar") + p2.prepend("Foo") + + return [p1, p2] + + dash_duo.start_server(app) + + dash_duo.find_element("#click").click() + dash_duo.wait_for_text_to_equal(".first", "FooBar") + dash_duo.wait_for_text_to_equal(".second", "FooBar") + + +def test_pch003_patch_children(dash_duo): + + app = Dash(__name__) + app.layout = html.Div( + [ + html.Div( + [ + dcc.Input(value="", id="children-value"), + html.Button("Add", id="add-children"), + ] + ), + html.Div([html.Div("init", id="initial")], id="output"), + ] + ) + + @app.callback( + Output("output", "children"), + Input("add-children", "n_clicks"), + State("children-value", "value"), + prevent_initial_call=True, + ) + def on_click(_, value): + p = Patch() + p.append(html.Div(value, id=value)) + + return p + + dash_duo.start_server(app) + + _input = dash_duo.find_element("#children-value") + _input.send_keys("new-child") + dash_duo.find_element("#add-children").click() + + dash_duo.wait_for_text_to_equal("#new-child", "new-child") + dash_duo.wait_for_text_to_equal("#initial", "init") diff --git a/tests/unit/library/test_validate.py b/tests/unit/library/test_validate.py index db0f3da7fc..1842e42116 100644 --- a/tests/unit/library/test_validate.py +++ b/tests/unit/library/test_validate.py @@ -2,8 +2,8 @@ from dash import Output from dash.html import Div -from dash.exceptions import InvalidCallbackReturnValue -from dash._validate import fail_callback_output +from dash.exceptions import InvalidCallbackReturnValue, DuplicateCallback +from dash._validate import fail_callback_output, validate_duplicate_output @pytest.mark.parametrize( @@ -36,3 +36,29 @@ def test_ddvl001_fail_handler_fails_correctly(val): with pytest.raises(InvalidCallbackReturnValue): fail_callback_output(val, outputs) + + +@pytest.mark.parametrize( + "output, prevent_initial_call, config_prevent_initial_call, expect_error", + [ + (Output("a", "a", allow_duplicate=True), True, False, False), + (Output("a", "a", allow_duplicate=True), False, True, False), + (Output("a", "a", allow_duplicate=True), True, True, False), + (Output("a", "a", allow_duplicate=True), False, False, True), + (Output("a", "a", allow_duplicate=True), "initial_duplicate", False, False), + (Output("a", "a", allow_duplicate=True), False, "initial_duplicate", False), + (Output("a", "a"), False, False, False), + ], +) +def test_ddv002_allow_duplicate_validation( + output, prevent_initial_call, config_prevent_initial_call, expect_error +): + if expect_error: + with pytest.raises(DuplicateCallback): + validate_duplicate_output( + output, prevent_initial_call, config_prevent_initial_call + ) + else: + validate_duplicate_output( + output, prevent_initial_call, config_prevent_initial_call + ) diff --git a/tests/unit/test_patch.py b/tests/unit/test_patch.py new file mode 100644 index 0000000000..c6f1ca22d4 --- /dev/null +++ b/tests/unit/test_patch.py @@ -0,0 +1,246 @@ +import json + +import pytest + +from dash import Patch +from dash._utils import to_json + + +def patch_to_dict(p): + return json.loads(to_json(p)) + + +def test_pat001_patch_assign_item(): + p = Patch() + p["item"] = "assigned" + + data = patch_to_dict(p) + + assert data["operations"][0] == { + "operation": "Assign", + "location": ["item"], + "params": {"value": "assigned"}, + } + + +def test_pat002_patch_assign_attr(): + p = Patch() + p.item = "assigned" + + data = patch_to_dict(p) + + assert data["operations"][0] == { + "operation": "Assign", + "location": ["item"], + "params": {"value": "assigned"}, + } + + +def test_pat003_patch_multi_operations(): + p = Patch() + p.one = 1 + p.two = 2 + + data = patch_to_dict(p) + + assert len(data["operations"]) == 2 + assert data["operations"][0]["location"] == ["one"] + assert data["operations"][1]["location"] == ["two"] + + +def test_pat004_patch_nested_assign(): + p = Patch() + + p["nest_item"]["nested"]["deep"] = "deep" + p.nest_attr.nested.deep = "deep" + + data = patch_to_dict(p) + + assert data["operations"][0]["location"] == ["nest_item", "nested", "deep"] + assert data["operations"][1]["location"] == ["nest_attr", "nested", "deep"] + + +def test_pat005_patch_delete_item(): + p = Patch() + + del p["delete_me"] + + data = patch_to_dict(p) + + assert data["operations"][0]["operation"] == "Delete" + assert data["operations"][0]["location"] == ["delete_me"] + + +def test_pat006_patch_delete_attr(): + p = Patch() + + del p.delete_me + + data = patch_to_dict(p) + + assert data["operations"][0]["operation"] == "Delete" + assert data["operations"][0]["location"] == ["delete_me"] + + +def test_pat007_patch_append(): + p = Patch() + p.append("item") + data = patch_to_dict(p) + + assert data["operations"][0] == { + "operation": "Append", + "location": [], + "params": {"value": "item"}, + } + + +def test_pat008_patch_prepend(): + p = Patch() + p.prepend("item") + data = patch_to_dict(p) + + assert data["operations"][0] == { + "operation": "Prepend", + "location": [], + "params": {"value": "item"}, + } + + +def test_pat009_patch_extend(): + p = Patch() + p.extend(["extend"]) + data = patch_to_dict(p) + + assert data["operations"][0] == { + "operation": "Extend", + "location": [], + "params": {"value": ["extend"]}, + } + + +def test_pat010_patch_merge(): + p = Patch() + p.update({"merge": "merged"}) + + p["merge"] |= {"ior": "iored"} + data = patch_to_dict(p) + + assert data["operations"][0] == { + "operation": "Merge", + "location": [], + "params": {"value": {"merge": "merged"}}, + } + assert data["operations"][1] == { + "operation": "Merge", + "location": ["merge"], + "params": {"value": {"ior": "iored"}}, + } + + +def test_pat011_patch_add(): + p = Patch() + p.plusplus += 1 + + data = patch_to_dict(p) + + assert data["operations"][0] == { + "operation": "Add", + "location": ["plusplus"], + "params": {"value": 1}, + } + + +def test_pat012_patch_sub(): + p = Patch() + p.minusless -= 1 + + data = patch_to_dict(p) + + assert data["operations"][0] == { + "operation": "Sub", + "location": ["minusless"], + "params": {"value": 1}, + } + + +def test_pat013_patch_mul(): + p = Patch() + p.mulby *= 2 + + data = patch_to_dict(p) + + assert data["operations"][0] == { + "operation": "Mul", + "location": ["mulby"], + "params": {"value": 2}, + } + + +def test_pat014_patch_div(): + p = Patch() + p.divby /= 2 + + data = patch_to_dict(p) + + assert data["operations"][0] == { + "operation": "Div", + "location": ["divby"], + "params": {"value": 2}, + } + + +def test_pat015_patch_insert(): + p = Patch() + p.insert(1, "inserted") + + data = patch_to_dict(p) + assert data["operations"][0] == { + "operation": "Insert", + "location": [], + "params": {"index": 1, "value": "inserted"}, + } + + +def test_pat016_patch_slice(): + p = Patch() + + with pytest.raises(TypeError): + p[2::1] = "sliced" + + with pytest.raises(TypeError): + p[2:3]["nested"] = "nest-slice" + + with pytest.raises(TypeError): + del p[1:] + + +def test_pat017_patch_clear(): + p = Patch() + + p.clear() + data = patch_to_dict(p) + assert data["operations"][0] == {"operation": "Clear", "location": [], "params": {}} + + +def test_pat018_patch_reverse(): + p = Patch() + + p.reverse() + data = patch_to_dict(p) + assert data["operations"][0] == { + "operation": "Reverse", + "location": [], + "params": {}, + } + + +def test_pat019_patch_remove(): + p = Patch() + + p.remove("item") + data = patch_to_dict(p) + assert data["operations"][0] == { + "operation": "Remove", + "location": [], + "params": {"value": "item"}, + }