From 54f224b5b7c5490f782810c4b20b53a14a3625eb Mon Sep 17 00:00:00 2001 From: philippe Date: Wed, 25 Jan 2023 15:49:46 -0500 Subject: [PATCH 01/30] Add patch API to partially update props from callbacks. --- dash/__init__.py | 5 +- dash/_callback.py | 4 +- dash/_patch.py | 117 ++++++++++++++++ dash/_utils.py | 10 +- dash/_validate.py | 13 +- dash/dash-renderer/src/actions/callbacks.ts | 28 +++- .../dash-renderer/src/actions/dependencies.js | 3 +- dash/dash-renderer/src/actions/patch.ts | 125 ++++++++++++++++++ dash/dependencies.py | 6 + 9 files changed, 302 insertions(+), 9 deletions(-) create mode 100644 dash/_patch.py create mode 100644 dash/dash-renderer/src/actions/patch.ts diff --git a/dash/__init__.py b/dash/__init__.py index 6a755c7a4e..b4cb75c4cd 100644 --- a/dash/__init__.py +++ b/dash/__init__.py @@ -4,7 +4,8 @@ __plotly_dash = True from .dependencies import ( # noqa: F401,E402 Input, # noqa: F401,E402 - Output, # noqa: F401,E402 + Output, # noqa: F401,E402, + PatchOutput, # noqa: F401,E402 State, # noqa: F401,E402 ClientsideFunction, # noqa: F401,E402 MATCH, # noqa: F401,E402 @@ -38,6 +39,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..612bc83c7c 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 @@ -469,7 +470,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..d9baf751e0 --- /dev/null +++ b/dash/_patch.py @@ -0,0 +1,117 @@ +def _operation(name, location, **kwargs): + return {"operation": name, "location": location, "params": dict(**kwargs)} + + +_noop = object() + + +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): + 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): + if value is _noop: + # The += set themselves. + return + self._operations.append( + _operation( + "Assign", + self._location + [key], + value=value, + ) + ) + + def __delitem__(self, key): + self._operations.append(_operation("Delete", self._location + [key])) + + def __add__(self, other): + self._operations.append(_operation("Add", self._location, value=other)) + return _noop + + def __iadd__(self, other): + self._operations.append(_operation("Add", self._location, value=other)) + return _noop + + def __sub__(self, other): + self._operations.append(_operation("Sub", self._location, value=other)) + return _noop + + def __isub__(self, other): + self._operations.append(_operation("Sub", self._location, value=other)) + return _noop + + def __mul__(self, other): + self._operations.append(_operation("Mul", self._location, value=other)) + return _noop + + def __imul__(self, other): + self._operations.append(_operation("Mul", self._location, value=other)) + return _noop + + def __truediv__(self, other): + self._operations.append(_operation("Div", self._location, value=other)) + return _noop + + def __itruediv__(self, other): + self._operations.append(_operation("Div", self._location, value=other)) + return _noop + + def append(self, item): + self._operations.append(_operation("Append", self._location, value=item)) + + def prepend(self, item): + self._operations.append(_operation("Prepend", self._location, value=item)) + + def extend(self, item): + if not isinstance(item, list): + raise TypeError(f"{item} should be a list") + self._operations.append(_operation("Extend", self._location, value=item)) + + def merge(self, item): + if not isinstance(item, dict): + raise TypeError(f"{item} should be a dictionary") + self._operations.append(_operation("Merge", self._location, value=item)) + + 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..680d07b813 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.is_patch: + # 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..02b1018ac8 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" ) diff --git a/dash/dash-renderer/src/actions/callbacks.ts b/dash/dash-renderer/src/actions/callbacks.ts index 63f4f7b633..e57b4f5592 100644 --- a/dash/dash-renderer/src/actions/callbacks.ts +++ b/dash/dash-renderer/src/actions/callbacks.ts @@ -10,7 +10,9 @@ import { pluck, values, toPairs, - zip + zip, + assocPath, + includes } from 'ramda'; import {STATUS, JWT_EXPIRED_MESSAGE} from '../constants/constants'; @@ -39,6 +41,8 @@ import {createAction, Action} from 'redux-actions'; import {addHttpHeaders} from '../actions'; import {notifyObservers, updateProps} from './index'; import {CallbackJobPayload} from '../reducers/callbackJobs'; +import {handlePatch} from './patch'; +import {getPath} from './paths'; export const addBlockedCallbacks = createAction( CallbackActionType.AddBlocked @@ -683,7 +687,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, @@ -699,6 +703,26 @@ export function executeCallback( dispatch(addHttpHeaders(newHeaders)); } + outputs.forEach((out: any) => { + if (includes('@', out.property)) { + const propName = out.property.split('@')[0]; + const outputPath = getPath(paths, out.id); + const previousValue = path( + outputPath.concat(['props', propName]), + layout + ); + const dataPath = [out.id, propName]; + data = assocPath( + dataPath, + handlePatch( + previousValue, + path(dataPath, data) + ), + data + ); + } + }); + return {data, payload}; } catch (res: any) { lastError = res; 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..decaeb2dd4 --- /dev/null +++ b/dash/dash-renderer/src/actions/patch.ts @@ -0,0 +1,125 @@ +import { + append, + assocPath, + concat, + dissocPath, + has, + insert, + path, + prepend +} from 'ramda'; + +type PatchOperation = { + operation: string; + location: LocationIndex[]; + params: any; +}; + +type LocationIndex = string | number; +type PatchHandler = (previous: any, patchUpdate: PatchOperation) => any; + +function isPatch(obj: any): boolean { + return has('__dash_patch_update', obj); +} + +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) => { + return insert( + patchOperation.params.index, + patchOperation.params.data, + 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 + ); + } +}; + +export function handlePatch(previousValue: T, patchValue: any): T { + if (!isPatch(patchValue)) { + return patchValue; + } + let reducedValue = previousValue; + + for (let i = 0; i < patchValue.operations.length; i++) { + const patch = patchValue.operations[i]; + const handler = patchHandlers[patch.operation]; + if (!handler) { + throw new Error(`Invalid Operation ${patch.operation}`); + } + reducedValue = handler(reducedValue, patch); + } + + return reducedValue; +} diff --git a/dash/dependencies.py b/dash/dependencies.py index 93f278b92c..babb31b732 100644 --- a/dash/dependencies.py +++ b/dash/dependencies.py @@ -27,6 +27,8 @@ def to_json(self): class DashDependency: # pylint: disable=too-few-public-methods + is_patch = False + def __init__(self, component_id, component_property): if isinstance(component_id, Component): @@ -124,6 +126,10 @@ class Output(DashDependency): # pylint: disable=too-few-public-methods allowed_wildcards = (MATCH, ALL) +class PatchOutput(Output): + is_patch = True + + class Input(DashDependency): # pylint: disable=too-few-public-methods """Input of callback: trigger an update when it is updated.""" From 967d20b357f071072fc1f50b30ee148eb31081c0 Mon Sep 17 00:00:00 2001 From: philippe Date: Wed, 25 Jan 2023 15:51:49 -0500 Subject: [PATCH 02/30] Add unit tests for patch API. --- tests/unit/test_patch.py | 204 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 tests/unit/test_patch.py diff --git a/tests/unit/test_patch.py b/tests/unit/test_patch.py new file mode 100644 index 0000000000..ee0a4b4838 --- /dev/null +++ b/tests/unit/test_patch.py @@ -0,0 +1,204 @@ +import json + +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"] = "item" + + data = patch_to_dict(p) + + assert data["operations"][0] == { + "operation": "Assign", + "location": ["item"], + "params": {"value": "item"}, + } + + +def test_pat002_patch_assign_attr(): + p = Patch() + p.item = "item" + + data = patch_to_dict(p) + + assert data["operations"][0] == { + "operation": "Assign", + "location": ["item"], + "params": {"value": "item"}, + } + + +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.merge({"merge": "merged"}) + data = patch_to_dict(p) + + assert data["operations"][0] == { + "operation": "Merge", + "location": [], + "params": {"value": {"merge": "merged"}}, + } + + +def test_pat011_patch_add(): + p = Patch() + p.added = p.added + 1 + p.plusplus += 1 + + data = patch_to_dict(p) + + assert data["operations"][0] == { + "operation": "Add", + "location": ["added"], + "params": {"value": 1}, + } + assert data["operations"][1] == { + "operation": "Add", + "location": ["plusplus"], + "params": {"value": 1}, + } + + +def test_pat012_patch_sub(): + p = Patch() + _ = p.sub - 1 + p.minusless -= 1 + + data = patch_to_dict(p) + + assert data["operations"][0] == { + "operation": "Sub", + "location": ["sub"], + "params": {"value": 1}, + } + assert data["operations"][1] == { + "operation": "Sub", + "location": ["minusless"], + "params": {"value": 1}, + } + + +def test_pat013_patch_mul(): + p = Patch() + _ = p.mul * 2 + p.mulby *= 2 + + data = patch_to_dict(p) + + assert data["operations"][0] == { + "operation": "Mul", + "location": ["mul"], + "params": {"value": 2}, + } + assert data["operations"][1] == { + "operation": "Mul", + "location": ["mulby"], + "params": {"value": 2}, + } + + +def test_pat014_patch_div(): + p = Patch() + _ = p.div / 2 + p.divby /= 2 + + data = patch_to_dict(p) + + assert data["operations"][0] == { + "operation": "Div", + "location": ["div"], + "params": {"value": 2}, + } + assert data["operations"][1] == { + "operation": "Div", + "location": ["divby"], + "params": {"value": 2}, + } From f9c29b17818d068b6f4d3fdb48c760115548418f Mon Sep 17 00:00:00 2001 From: philippe Date: Thu, 26 Jan 2023 15:49:57 -0500 Subject: [PATCH 03/30] Remove PatchOutput, add allow_duplicate argument to Output. --- dash/__init__.py | 1 - dash/_utils.py | 2 +- dash/dash-renderer/src/actions/callbacks.ts | 35 ++++++++++----------- dash/dependencies.py | 8 ++--- 4 files changed, 21 insertions(+), 25 deletions(-) diff --git a/dash/__init__.py b/dash/__init__.py index b4cb75c4cd..871da672d0 100644 --- a/dash/__init__.py +++ b/dash/__init__.py @@ -5,7 +5,6 @@ from .dependencies import ( # noqa: F401,E402 Input, # noqa: F401,E402 Output, # noqa: F401,E402, - PatchOutput, # noqa: F401,E402 State, # noqa: F401,E402 ClientsideFunction, # noqa: F401,E402 MATCH, # noqa: F401,E402 diff --git a/dash/_utils.py b/dash/_utils.py index 680d07b813..fab6219bcd 100644 --- a/dash/_utils.py +++ b/dash/_utils.py @@ -129,7 +129,7 @@ def create_callback_id(output): # with `\` so we don't mistake it for multi-outputs def _concat(x): _id = x.component_id_str().replace(".", "\\.") + "." + x.component_property - if x.is_patch: + if x.allow_duplicate: # Actually adds on the property part. _id += f"@{uuid.uuid4().hex}" return _id diff --git a/dash/dash-renderer/src/actions/callbacks.ts b/dash/dash-renderer/src/actions/callbacks.ts index e57b4f5592..c238042af8 100644 --- a/dash/dash-renderer/src/actions/callbacks.ts +++ b/dash/dash-renderer/src/actions/callbacks.ts @@ -11,8 +11,7 @@ import { values, toPairs, zip, - assocPath, - includes + assocPath } from 'ramda'; import {STATUS, JWT_EXPIRED_MESSAGE} from '../constants/constants'; @@ -704,23 +703,21 @@ export function executeCallback( } outputs.forEach((out: any) => { - if (includes('@', out.property)) { - const propName = out.property.split('@')[0]; - const outputPath = getPath(paths, out.id); - const previousValue = path( - outputPath.concat(['props', propName]), - layout - ); - const dataPath = [out.id, propName]; - data = assocPath( - dataPath, - handlePatch( - previousValue, - path(dataPath, data) - ), - data - ); - } + const propName = out.property.split('@')[0]; + const outputPath = getPath(paths, out.id); + const previousValue = path( + outputPath.concat(['props', propName]), + layout + ); + const dataPath = [out.id, propName]; + data = assocPath( + dataPath, + handlePatch( + previousValue, + path(dataPath, data) + ), + data + ); }); return {data, payload}; diff --git a/dash/dependencies.py b/dash/dependencies.py index babb31b732..a8a05d2213 100644 --- a/dash/dependencies.py +++ b/dash/dependencies.py @@ -27,7 +27,6 @@ def to_json(self): class DashDependency: # pylint: disable=too-few-public-methods - is_patch = False def __init__(self, component_id, component_property): @@ -37,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}" @@ -125,9 +125,9 @@ class Output(DashDependency): # pylint: disable=too-few-public-methods allowed_wildcards = (MATCH, ALL) - -class PatchOutput(Output): - is_patch = True + 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 From f77cef251655904cf4160a57472ff73c2462790f Mon Sep 17 00:00:00 2001 From: philippe Date: Wed, 1 Feb 2023 12:35:35 -0500 Subject: [PATCH 04/30] Fix DAG with allow_duplicate. --- .../error/CallbackGraph/CallbackGraphContainer.react.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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..064b2b177b 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'; From 5930963ccb1bd8af69cf81de2204017ffa17a833 Mon Sep 17 00:00:00 2001 From: philippe Date: Wed, 1 Feb 2023 13:58:02 -0500 Subject: [PATCH 05/30] Handle PMC patch outputs. --- dash/dash-renderer/src/actions/callbacks.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/dash/dash-renderer/src/actions/callbacks.ts b/dash/dash-renderer/src/actions/callbacks.ts index c238042af8..bfe8f0bea5 100644 --- a/dash/dash-renderer/src/actions/callbacks.ts +++ b/dash/dash-renderer/src/actions/callbacks.ts @@ -701,15 +701,14 @@ export function executeCallback( if (newHeaders) { dispatch(addHttpHeaders(newHeaders)); } - - outputs.forEach((out: any) => { + flatten(outputs).forEach((out: any) => { const propName = out.property.split('@')[0]; const outputPath = getPath(paths, out.id); const previousValue = path( outputPath.concat(['props', propName]), layout ); - const dataPath = [out.id, propName]; + const dataPath = [stringifyId(out.id), propName]; data = assocPath( dataPath, handlePatch( From 8a90e237d74f7e6d8a387ea803398794d2d6a29e Mon Sep 17 00:00:00 2001 From: philippe Date: Wed, 1 Feb 2023 14:38:58 -0500 Subject: [PATCH 06/30] Fix patch handling logic. --- dash/dash-renderer/src/actions/callbacks.ts | 25 ++++++++++++--------- dash/dash-renderer/src/actions/patch.ts | 5 +---- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/dash/dash-renderer/src/actions/callbacks.ts b/dash/dash-renderer/src/actions/callbacks.ts index bfe8f0bea5..a2395b5380 100644 --- a/dash/dash-renderer/src/actions/callbacks.ts +++ b/dash/dash-renderer/src/actions/callbacks.ts @@ -40,7 +40,7 @@ import {createAction, Action} from 'redux-actions'; import {addHttpHeaders} from '../actions'; import {notifyObservers, updateProps} from './index'; import {CallbackJobPayload} from '../reducers/callbackJobs'; -import {handlePatch} from './patch'; +import {handlePatch, isPatch} from './patch'; import {getPath} from './paths'; export const addBlockedCallbacks = createAction( @@ -701,22 +701,27 @@ 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]), - layout + currentLayout ); const dataPath = [stringifyId(out.id), propName]; - data = assocPath( - dataPath, - handlePatch( - previousValue, - path(dataPath, data) - ), - data - ); + const outputValue = path(dataPath, data); + if ( + previousValue !== undefined && + isPatch(outputValue) + ) { + data = assocPath( + dataPath, + handlePatch(previousValue, outputValue), + data + ); + } }); return {data, payload}; diff --git a/dash/dash-renderer/src/actions/patch.ts b/dash/dash-renderer/src/actions/patch.ts index decaeb2dd4..096de9c0d6 100644 --- a/dash/dash-renderer/src/actions/patch.ts +++ b/dash/dash-renderer/src/actions/patch.ts @@ -18,7 +18,7 @@ type PatchOperation = { type LocationIndex = string | number; type PatchHandler = (previous: any, patchUpdate: PatchOperation) => any; -function isPatch(obj: any): boolean { +export function isPatch(obj: any): boolean { return has('__dash_patch_update', obj); } @@ -107,9 +107,6 @@ const patchHandlers: {[k: string]: PatchHandler} = { }; export function handlePatch(previousValue: T, patchValue: any): T { - if (!isPatch(patchValue)) { - return patchValue; - } let reducedValue = previousValue; for (let i = 0; i < patchValue.operations.length; i++) { From 7fbc630d00da5dd62e1e6e0e48273288843e3650 Mon Sep 17 00:00:00 2001 From: philippe Date: Thu, 2 Feb 2023 10:38:28 -0500 Subject: [PATCH 07/30] Validate patch on undefined. --- dash/dash-renderer/src/actions/callbacks.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/dash/dash-renderer/src/actions/callbacks.ts b/dash/dash-renderer/src/actions/callbacks.ts index a2395b5380..9064aad175 100644 --- a/dash/dash-renderer/src/actions/callbacks.ts +++ b/dash/dash-renderer/src/actions/callbacks.ts @@ -712,10 +712,10 @@ export function executeCallback( ); const dataPath = [stringifyId(out.id), propName]; const outputValue = path(dataPath, data); - if ( - previousValue !== undefined && - isPatch(outputValue) - ) { + if (isPatch(outputValue)) { + if (previousValue === undefined) { + throw new Error('Cannot patch undefined'); + } data = assocPath( dataPath, handlePatch(previousValue, outputValue), From 4ceb35d3ba02ba5f70a0a54e5086bbd63f6ff053 Mon Sep 17 00:00:00 2001 From: philippe Date: Mon, 6 Feb 2023 10:44:36 -0500 Subject: [PATCH 08/30] Add integration tests for patch callbacks. --- tests/integration/test_patch.py | 249 ++++++++++++++++++++++++++++++++ 1 file changed, 249 insertions(+) create mode 100644 tests/integration/test_patch.py diff --git a/tests/integration/test_patch.py b/tests/integration/test_patch.py new file mode 100644 index 0000000000..9dbd533849 --- /dev/null +++ b/tests/integration/test_patch.py @@ -0,0 +1,249 @@ +import json + +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="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"), + 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.merge({"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 + + dash_duo.start_server(app) + + 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 + + +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") From 5ba249c1392d6657e524590eba3eec58daa4a999 Mon Sep 17 00:00:00 2001 From: philippe Date: Wed, 15 Feb 2023 09:58:32 -0500 Subject: [PATCH 09/30] Clean DAG output id infobox. --- .../CallbackGraphContainer.react.js | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) 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 064b2b177b..ce2c4b95a3 100644 --- a/dash/dash-renderer/src/components/error/CallbackGraph/CallbackGraphContainer.react.js +++ b/dash/dash-renderer/src/components/error/CallbackGraph/CallbackGraphContainer.react.js @@ -158,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; @@ -327,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 { From 88cdad47a97b3758637592e836319b610c990b32 Mon Sep 17 00:00:00 2001 From: philippe Date: Mon, 20 Feb 2023 11:32:38 -0500 Subject: [PATCH 10/30] Validate allow_duplicate with prevent_initial_call. --- dash/_callback.py | 8 +++++++- dash/_validate.py | 29 ++++++++++++++++++++++++++++ tests/unit/library/test_validate.py | 30 +++++++++++++++++++++++++++-- 3 files changed, 64 insertions(+), 3 deletions(-) diff --git a/dash/_callback.py b/dash/_callback.py index 612bc83c7c..7e099e0de1 100644 --- a/dash/_callback.py +++ b/dash/_callback.py @@ -241,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"], diff --git a/dash/_validate.py b/dash/_validate.py index 02b1018ac8..24136aeed3 100644 --- a/dash/_validate.py +++ b/dash/_validate.py @@ -521,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/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 + ) From 0c4529125961295415c409703a17fc1705597793 Mon Sep 17 00:00:00 2001 From: philippe Date: Tue, 28 Feb 2023 10:55:11 -0500 Subject: [PATCH 11/30] Add patch insert. --- dash/_patch.py | 7 ++++- dash/dash-renderer/src/actions/patch.ts | 11 +++++--- tests/integration/test_patch.py | 34 +++++++++++++++++++++++++ tests/unit/test_patch.py | 12 +++++++++ 4 files changed, 60 insertions(+), 4 deletions(-) diff --git a/dash/_patch.py b/dash/_patch.py index d9baf751e0..639338423a 100644 --- a/dash/_patch.py +++ b/dash/_patch.py @@ -100,9 +100,14 @@ def append(self, item): def prepend(self, item): self._operations.append(_operation("Prepend", self._location, value=item)) + def insert(self, index, item): + self._operations.append( + _operation("Insert", self._location, value=item, index=index) + ) + def extend(self, item): if not isinstance(item, list): - raise TypeError(f"{item} should be a list") + raise TypeError(f"{item} should be a list or tuple") self._operations.append(_operation("Extend", self._location, value=item)) def merge(self, item): diff --git a/dash/dash-renderer/src/actions/patch.ts b/dash/dash-renderer/src/actions/patch.ts index 096de9c0d6..c4820d307c 100644 --- a/dash/dash-renderer/src/actions/patch.ts +++ b/dash/dash-renderer/src/actions/patch.ts @@ -50,9 +50,14 @@ const patchHandlers: {[k: string]: PatchHandler} = { return dissocPath(patchOperation.location, previous); }, Insert: (previous, patchOperation) => { - return insert( - patchOperation.params.index, - patchOperation.params.data, + const prev: any = path(patchOperation.location, previous); + return assocPath( + patchOperation.location, + insert( + patchOperation.params.index, + patchOperation.params.value, + prev + ), previous ); }, diff --git a/tests/integration/test_patch.py b/tests/integration/test_patch.py index 9dbd533849..12a6739339 100644 --- a/tests/integration/test_patch.py +++ b/tests/integration/test_patch.py @@ -27,6 +27,13 @@ def test_pch001_patch_operations(dash_duo): 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"), @@ -134,8 +141,23 @@ def on_click(_): 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 + 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) @@ -179,6 +201,18 @@ def get_output(): 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", + ] + def test_pch002_patch_app_pmc_callbacks(dash_duo): app = Dash(__name__) diff --git a/tests/unit/test_patch.py b/tests/unit/test_patch.py index ee0a4b4838..b3c2db00c8 100644 --- a/tests/unit/test_patch.py +++ b/tests/unit/test_patch.py @@ -202,3 +202,15 @@ def test_pat014_patch_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"}, + } From ea3fb07836e19720812c7e826415b9013a70e648 Mon Sep 17 00:00:00 2001 From: philippe Date: Tue, 28 Feb 2023 10:57:51 -0500 Subject: [PATCH 12/30] list or tuple --- dash/_patch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dash/_patch.py b/dash/_patch.py index 639338423a..0c6c4169eb 100644 --- a/dash/_patch.py +++ b/dash/_patch.py @@ -106,7 +106,7 @@ def insert(self, index, item): ) def extend(self, item): - if not isinstance(item, 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)) From aefeb78b575c2fe992a646bf506cf8c85c57c2a7 Mon Sep 17 00:00:00 2001 From: philippe Date: Tue, 28 Feb 2023 11:50:24 -0500 Subject: [PATCH 13/30] Error on patch with slice index. --- dash/_patch.py | 7 +++++++ tests/unit/test_patch.py | 12 ++++++++++++ 2 files changed, 19 insertions(+) diff --git a/dash/_patch.py b/dash/_patch.py index 0c6c4169eb..d7572f5fc9 100644 --- a/dash/_patch.py +++ b/dash/_patch.py @@ -5,6 +5,11 @@ def _operation(name, location, **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 @@ -26,6 +31,7 @@ def __init__(self, location=None, parent=None): self._operations = [] def __getitem__(self, item): + validate_slice(item) return Patch(location=self._location + [item], parent=self) def __getattr__(self, item): @@ -48,6 +54,7 @@ def __delattr__(self, item): self.__delitem__(item) def __setitem__(self, key, value): + validate_slice(key) if value is _noop: # The += set themselves. return diff --git a/tests/unit/test_patch.py b/tests/unit/test_patch.py index b3c2db00c8..ce242db91d 100644 --- a/tests/unit/test_patch.py +++ b/tests/unit/test_patch.py @@ -1,5 +1,7 @@ import json +import pytest + from dash import Patch from dash._utils import to_json @@ -214,3 +216,13 @@ def test_pat015_patch_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" From 3f9723cd03fae7a3121db238c1a097db8e551a17 Mon Sep 17 00:00:00 2001 From: philippe Date: Tue, 28 Feb 2023 13:50:13 -0500 Subject: [PATCH 14/30] Validate slice on deletion. --- dash/_patch.py | 1 + tests/unit/test_patch.py | 3 +++ 2 files changed, 4 insertions(+) diff --git a/dash/_patch.py b/dash/_patch.py index d7572f5fc9..1fc1fd4ce5 100644 --- a/dash/_patch.py +++ b/dash/_patch.py @@ -67,6 +67,7 @@ def __setitem__(self, key, value): ) def __delitem__(self, key): + validate_slice(key) self._operations.append(_operation("Delete", self._location + [key])) def __add__(self, other): diff --git a/tests/unit/test_patch.py b/tests/unit/test_patch.py index ce242db91d..6decb74a55 100644 --- a/tests/unit/test_patch.py +++ b/tests/unit/test_patch.py @@ -226,3 +226,6 @@ def test_pat016_patch_slice(): with pytest.raises(TypeError): p[2:3]["nested"] = "nest-slice" + + with pytest.raises(TypeError): + del p[1:] From 0c99886e08b6f9f97eaf9aa111b8fcd729c8d637 Mon Sep 17 00:00:00 2001 From: philippe Date: Tue, 28 Feb 2023 14:53:48 -0500 Subject: [PATCH 15/30] Handle negative index in location. --- dash/dash-renderer/src/actions/patch.ts | 17 +++++++++++++++++ tests/integration/test_patch.py | 17 +++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/dash/dash-renderer/src/actions/patch.ts b/dash/dash-renderer/src/actions/patch.ts index c4820d307c..1df668619b 100644 --- a/dash/dash-renderer/src/actions/patch.ts +++ b/dash/dash-renderer/src/actions/patch.ts @@ -5,6 +5,7 @@ import { dissocPath, has, insert, + is, path, prepend } from 'ramda'; @@ -22,6 +23,21 @@ export function isPatch(obj: any): boolean { return has('__dash_patch_update', obj); } +function getLocationPath(location: LocationIndex[], obj: any) { + const current = []; + + for (let i = 0; i < location.length; i++) { + let value = location[i]; + if (is(Number, value) && value < 0) { + const previous: any = path(current, obj); + value = previous.length - value; + } + current.push(value); + } + + return current; +} + const patchHandlers: {[k: string]: PatchHandler} = { Assign: (previous, patchOperation) => { const {params, location} = patchOperation; @@ -116,6 +132,7 @@ export function handlePatch(previousValue: T, patchValue: any): T { 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}`); diff --git a/tests/integration/test_patch.py b/tests/integration/test_patch.py index 12a6739339..8e7e42561a 100644 --- a/tests/integration/test_patch.py +++ b/tests/integration/test_patch.py @@ -1,5 +1,7 @@ import json +from selenium.webdriver import Keys + from dash import Dash, html, dcc, Input, Output, State, ALL, Patch @@ -213,6 +215,21 @@ def get_output(): "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", + "Extend", + "Inserted with negative index", + ] + def test_pch002_patch_app_pmc_callbacks(dash_duo): app = Dash(__name__) From 280e9486b07653dae4a8169156a4192e47aac809 Mon Sep 17 00:00:00 2001 From: philippe Date: Tue, 28 Feb 2023 16:03:30 -0500 Subject: [PATCH 16/30] Fix negative location index + support insert. --- dash/dash-renderer/src/actions/patch.ts | 15 +++++++++------ tests/integration/test_patch.py | 2 +- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/dash/dash-renderer/src/actions/patch.ts b/dash/dash-renderer/src/actions/patch.ts index 1df668619b..db60b89cf0 100644 --- a/dash/dash-renderer/src/actions/patch.ts +++ b/dash/dash-renderer/src/actions/patch.ts @@ -23,15 +23,18 @@ 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++) { - let value = location[i]; - if (is(Number, value) && value < 0) { - const previous: any = path(current, obj); - value = previous.length - value; - } + const value = getLocationIndex(location[i], path(current, obj)); current.push(value); } @@ -70,7 +73,7 @@ const patchHandlers: {[k: string]: PatchHandler} = { return assocPath( patchOperation.location, insert( - patchOperation.params.index, + getLocationIndex(patchOperation.params.index, prev), patchOperation.params.value, prev ), diff --git a/tests/integration/test_patch.py b/tests/integration/test_patch.py index 8e7e42561a..9a0e9188ce 100644 --- a/tests/integration/test_patch.py +++ b/tests/integration/test_patch.py @@ -226,8 +226,8 @@ def get_output(): "Inserted", "initial", "Append", - "Extend", "Inserted with negative index", + "Extend", ] From 5b59c1c7da832d13ef156d9460f30c08b53ca787 Mon Sep 17 00:00:00 2001 From: philippe Date: Tue, 28 Feb 2023 16:08:31 -0500 Subject: [PATCH 17/30] Test array index deletion. --- tests/integration/test_patch.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/integration/test_patch.py b/tests/integration/test_patch.py index 9a0e9188ce..cf8406f76a 100644 --- a/tests/integration/test_patch.py +++ b/tests/integration/test_patch.py @@ -49,6 +49,7 @@ def test_pch001_patch_operations(dash_duo): ] ), html.Button("Delete", id="delete-btn"), + html.Button("Delete index", id="delete-index"), dcc.Store( data={ "value": "unset", @@ -156,6 +157,18 @@ def on_insert(_, value, index): 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 + dash_duo.start_server(app) assert dash_duo.get_logs() == [] @@ -230,6 +243,14 @@ def get_output(): "Extend", ] + dash_duo.find_element("#delete-index").click() + assert get_output().get("array") == [ + "Prepend", + "initial", + "Append", + "Extend", + ] + def test_pch002_patch_app_pmc_callbacks(dash_duo): app = Dash(__name__) From 5e6c3a67b094b355a1f7d43a03f9e77704d38952 Mon Sep 17 00:00:00 2001 From: philippe Date: Tue, 28 Feb 2023 16:13:07 -0500 Subject: [PATCH 18/30] Remove regular +-*/ operations. --- dash/_patch.py | 16 ---------------- tests/unit/test_patch.py | 24 ------------------------ 2 files changed, 40 deletions(-) diff --git a/dash/_patch.py b/dash/_patch.py index 1fc1fd4ce5..a6bff7a7db 100644 --- a/dash/_patch.py +++ b/dash/_patch.py @@ -70,34 +70,18 @@ def __delitem__(self, key): validate_slice(key) self._operations.append(_operation("Delete", self._location + [key])) - def __add__(self, other): - self._operations.append(_operation("Add", self._location, value=other)) - return _noop - def __iadd__(self, other): self._operations.append(_operation("Add", self._location, value=other)) return _noop - def __sub__(self, other): - self._operations.append(_operation("Sub", self._location, value=other)) - return _noop - def __isub__(self, other): self._operations.append(_operation("Sub", self._location, value=other)) return _noop - def __mul__(self, other): - self._operations.append(_operation("Mul", self._location, value=other)) - return _noop - def __imul__(self, other): self._operations.append(_operation("Mul", self._location, value=other)) return _noop - def __truediv__(self, other): - self._operations.append(_operation("Div", self._location, value=other)) - return _noop - def __itruediv__(self, other): self._operations.append(_operation("Div", self._location, value=other)) return _noop diff --git a/tests/unit/test_patch.py b/tests/unit/test_patch.py index 6decb74a55..32ccf65a0a 100644 --- a/tests/unit/test_patch.py +++ b/tests/unit/test_patch.py @@ -132,17 +132,11 @@ def test_pat010_patch_merge(): def test_pat011_patch_add(): p = Patch() - p.added = p.added + 1 p.plusplus += 1 data = patch_to_dict(p) assert data["operations"][0] == { - "operation": "Add", - "location": ["added"], - "params": {"value": 1}, - } - assert data["operations"][1] == { "operation": "Add", "location": ["plusplus"], "params": {"value": 1}, @@ -151,17 +145,11 @@ def test_pat011_patch_add(): def test_pat012_patch_sub(): p = Patch() - _ = p.sub - 1 p.minusless -= 1 data = patch_to_dict(p) assert data["operations"][0] == { - "operation": "Sub", - "location": ["sub"], - "params": {"value": 1}, - } - assert data["operations"][1] == { "operation": "Sub", "location": ["minusless"], "params": {"value": 1}, @@ -170,17 +158,11 @@ def test_pat012_patch_sub(): def test_pat013_patch_mul(): p = Patch() - _ = p.mul * 2 p.mulby *= 2 data = patch_to_dict(p) assert data["operations"][0] == { - "operation": "Mul", - "location": ["mul"], - "params": {"value": 2}, - } - assert data["operations"][1] == { "operation": "Mul", "location": ["mulby"], "params": {"value": 2}, @@ -189,17 +171,11 @@ def test_pat013_patch_mul(): def test_pat014_patch_div(): p = Patch() - _ = p.div / 2 p.divby /= 2 data = patch_to_dict(p) assert data["operations"][0] == { - "operation": "Div", - "location": ["div"], - "params": {"value": 2}, - } - assert data["operations"][1] == { "operation": "Div", "location": ["divby"], "params": {"value": 2}, From a7cb1d86c2fa5b53dfae840559a077cd9c30ae2b Mon Sep 17 00:00:00 2001 From: philippe Date: Tue, 28 Feb 2023 16:17:25 -0500 Subject: [PATCH 19/30] iadd to extend lists & merge dicts --- dash/_patch.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/dash/_patch.py b/dash/_patch.py index a6bff7a7db..ae3dcd98e9 100644 --- a/dash/_patch.py +++ b/dash/_patch.py @@ -71,7 +71,12 @@ def __delitem__(self, key): self._operations.append(_operation("Delete", self._location + [key])) def __iadd__(self, other): - self._operations.append(_operation("Add", self._location, value=other)) + if isinstance(other, (list, tuple)): + self.extend(other) + elif isinstance(other, dict): + self.merge(other) + else: + self._operations.append(_operation("Add", self._location, value=other)) return _noop def __isub__(self, other): From e1270800ff1f6fecc29ef6003ad4f488fee37541 Mon Sep 17 00:00:00 2001 From: philippe Date: Tue, 28 Feb 2023 17:00:43 -0500 Subject: [PATCH 20/30] Add clear operation. --- dash/_patch.py | 3 +++ dash/dash-renderer/src/actions/patch.ts | 5 +++++ tests/integration/test_patch.py | 15 +++++++++++++++ tests/unit/test_patch.py | 8 ++++++++ 4 files changed, 31 insertions(+) diff --git a/dash/_patch.py b/dash/_patch.py index ae3dcd98e9..cce4463c97 100644 --- a/dash/_patch.py +++ b/dash/_patch.py @@ -102,6 +102,9 @@ def insert(self, index, item): _operation("Insert", self._location, value=item, index=index) ) + def clear(self): + self._operations.append(_operation("Clear", self._location)) + def extend(self, item): if not isinstance(item, (list, tuple)): raise TypeError(f"{item} should be a list or tuple") diff --git a/dash/dash-renderer/src/actions/patch.ts b/dash/dash-renderer/src/actions/patch.ts index db60b89cf0..ab0a2d8b0f 100644 --- a/dash/dash-renderer/src/actions/patch.ts +++ b/dash/dash-renderer/src/actions/patch.ts @@ -3,6 +3,7 @@ import { assocPath, concat, dissocPath, + empty, has, insert, is, @@ -127,6 +128,10 @@ const patchHandlers: {[k: string]: PatchHandler} = { prev / patchOperation.params.value, previous ); + }, + Clear: (previous, patchOperation) => { + const prev: any = path(patchOperation.location, previous); + return assocPath(patchOperation.location, empty(prev), previous); } }; diff --git a/tests/integration/test_patch.py b/tests/integration/test_patch.py index cf8406f76a..70d3fff47d 100644 --- a/tests/integration/test_patch.py +++ b/tests/integration/test_patch.py @@ -50,6 +50,7 @@ def test_pch001_patch_operations(dash_duo): ), html.Button("Delete", id="delete-btn"), html.Button("Delete index", id="delete-index"), + html.Button("Clear", id="clear-btn"), dcc.Store( data={ "value": "unset", @@ -169,6 +170,17 @@ def on_click(_): 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 + dash_duo.start_server(app) assert dash_duo.get_logs() == [] @@ -251,6 +263,9 @@ def get_output(): "Extend", ] + dash_duo.find_element("#clear-btn").click() + assert get_output()["array"] == [] + def test_pch002_patch_app_pmc_callbacks(dash_duo): app = Dash(__name__) diff --git a/tests/unit/test_patch.py b/tests/unit/test_patch.py index 32ccf65a0a..84fcea301a 100644 --- a/tests/unit/test_patch.py +++ b/tests/unit/test_patch.py @@ -205,3 +205,11 @@ def test_pat016_patch_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": {}} From 1e5acb7a80bf48639c9050e0f036050839c10ada Mon Sep 17 00:00:00 2001 From: philippe Date: Tue, 28 Feb 2023 17:05:25 -0500 Subject: [PATCH 21/30] add ior to merge. --- dash/_patch.py | 6 ++++-- tests/unit/test_patch.py | 7 +++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/dash/_patch.py b/dash/_patch.py index cce4463c97..cad6001853 100644 --- a/dash/_patch.py +++ b/dash/_patch.py @@ -73,8 +73,6 @@ def __delitem__(self, key): def __iadd__(self, other): if isinstance(other, (list, tuple)): self.extend(other) - elif isinstance(other, dict): - self.merge(other) else: self._operations.append(_operation("Add", self._location, value=other)) return _noop @@ -91,6 +89,10 @@ def __itruediv__(self, other): self._operations.append(_operation("Div", self._location, value=other)) return _noop + def __ior__(self, other): + self.merge(other) + return _noop + def append(self, item): self._operations.append(_operation("Append", self._location, value=item)) diff --git a/tests/unit/test_patch.py b/tests/unit/test_patch.py index 84fcea301a..168b407145 100644 --- a/tests/unit/test_patch.py +++ b/tests/unit/test_patch.py @@ -121,6 +121,8 @@ def test_pat009_patch_extend(): def test_pat010_patch_merge(): p = Patch() p.merge({"merge": "merged"}) + + p["merge"] |= {"ior": "iored"} data = patch_to_dict(p) assert data["operations"][0] == { @@ -128,6 +130,11 @@ def test_pat010_patch_merge(): "location": [], "params": {"value": {"merge": "merged"}}, } + assert data["operations"][1] == { + "operation": "Merge", + "location": ["merge"], + "params": {"value": {"ior": "iored"}}, + } def test_pat011_patch_add(): From 9f55b3d1e67746882b7f07e2f1573e2a83a4c025 Mon Sep 17 00:00:00 2001 From: philippe Date: Wed, 1 Mar 2023 09:24:19 -0500 Subject: [PATCH 22/30] Fix selenium keys import. --- tests/integration/test_patch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/test_patch.py b/tests/integration/test_patch.py index 70d3fff47d..d56a89081c 100644 --- a/tests/integration/test_patch.py +++ b/tests/integration/test_patch.py @@ -1,6 +1,6 @@ import json -from selenium.webdriver import Keys +from selenium.webdriver.common.keys import Keys from dash import Dash, html, dcc, Input, Output, State, ALL, Patch From c5d4dd6a874a10dfb873e7793891fd6b4011ef33 Mon Sep 17 00:00:00 2001 From: philippe Date: Wed, 1 Mar 2023 09:40:57 -0500 Subject: [PATCH 23/30] Add reverse --- dash/_patch.py | 3 +++ dash/dash-renderer/src/actions/patch.ts | 7 ++++++- tests/integration/test_patch.py | 20 ++++++++++++++++++++ tests/unit/test_patch.py | 12 ++++++++++++ 4 files changed, 41 insertions(+), 1 deletion(-) diff --git a/dash/_patch.py b/dash/_patch.py index cad6001853..94b4ac34f6 100644 --- a/dash/_patch.py +++ b/dash/_patch.py @@ -107,6 +107,9 @@ def insert(self, index, item): def clear(self): self._operations.append(_operation("Clear", self._location)) + def reverse(self): + self._operations.append(_operation("Reverse", self._location)) + def extend(self, item): if not isinstance(item, (list, tuple)): raise TypeError(f"{item} should be a list or tuple") diff --git a/dash/dash-renderer/src/actions/patch.ts b/dash/dash-renderer/src/actions/patch.ts index ab0a2d8b0f..ec909be1f5 100644 --- a/dash/dash-renderer/src/actions/patch.ts +++ b/dash/dash-renderer/src/actions/patch.ts @@ -8,7 +8,8 @@ import { insert, is, path, - prepend + prepend, + reverse } from 'ramda'; type PatchOperation = { @@ -132,6 +133,10 @@ const patchHandlers: {[k: string]: PatchHandler} = { 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); } }; diff --git a/tests/integration/test_patch.py b/tests/integration/test_patch.py index d56a89081c..be41a4286d 100644 --- a/tests/integration/test_patch.py +++ b/tests/integration/test_patch.py @@ -51,6 +51,7 @@ def test_pch001_patch_operations(dash_duo): 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"), dcc.Store( data={ "value": "unset", @@ -181,6 +182,17 @@ def on_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 + dash_duo.start_server(app) assert dash_duo.get_logs() == [] @@ -263,6 +275,14 @@ def get_output(): "Extend", ] + dash_duo.find_element("#reverse-btn").click() + assert get_output().get("array") == [ + "Extend", + "Append", + "initial", + "Prepend", + ] + dash_duo.find_element("#clear-btn").click() assert get_output()["array"] == [] diff --git a/tests/unit/test_patch.py b/tests/unit/test_patch.py index 168b407145..e22b30f2b6 100644 --- a/tests/unit/test_patch.py +++ b/tests/unit/test_patch.py @@ -220,3 +220,15 @@ def test_pat017_patch_clear(): 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": {}, + } From 9ccc456a9528963095f3debc87536be63e1ff772 Mon Sep 17 00:00:00 2001 From: philippe Date: Wed, 1 Mar 2023 09:43:57 -0500 Subject: [PATCH 24/30] item->assigned --- tests/unit/test_patch.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/unit/test_patch.py b/tests/unit/test_patch.py index e22b30f2b6..c4615d38bb 100644 --- a/tests/unit/test_patch.py +++ b/tests/unit/test_patch.py @@ -12,27 +12,27 @@ def patch_to_dict(p): def test_pat001_patch_assign_item(): p = Patch() - p["item"] = "item" + p["item"] = "assigned" data = patch_to_dict(p) assert data["operations"][0] == { "operation": "Assign", "location": ["item"], - "params": {"value": "item"}, + "params": {"value": "assigned"}, } def test_pat002_patch_assign_attr(): p = Patch() - p.item = "item" + p.item = "assigned" data = patch_to_dict(p) assert data["operations"][0] == { "operation": "Assign", "location": ["item"], - "params": {"value": "item"}, + "params": {"value": "assigned"}, } From d4d88043d5a4da752b7324e0ef864552cb563482 Mon Sep 17 00:00:00 2001 From: philippe Date: Wed, 1 Mar 2023 10:39:48 -0500 Subject: [PATCH 25/30] Rename merge to update --- dash/_patch.py | 12 +++++++----- tests/integration/test_patch.py | 2 +- tests/unit/test_patch.py | 2 +- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/dash/_patch.py b/dash/_patch.py index 94b4ac34f6..3a4ef281ea 100644 --- a/dash/_patch.py +++ b/dash/_patch.py @@ -90,7 +90,7 @@ def __itruediv__(self, other): return _noop def __ior__(self, other): - self.merge(other) + self.update(E=other) return _noop def append(self, item): @@ -115,10 +115,12 @@ def extend(self, item): raise TypeError(f"{item} should be a list or tuple") self._operations.append(_operation("Extend", self._location, value=item)) - def merge(self, item): - if not isinstance(item, dict): - raise TypeError(f"{item} should be a dictionary") - self._operations.append(_operation("Merge", self._location, value=item)) + def update(self, E=None, **F): + if E is not None: + value = E + else: + value = dict(F) + self._operations.append(_operation("Merge", self._location, value=value)) def to_plotly_json(self): return { diff --git a/tests/integration/test_patch.py b/tests/integration/test_patch.py index be41a4286d..997cf2c7e8 100644 --- a/tests/integration/test_patch.py +++ b/tests/integration/test_patch.py @@ -131,7 +131,7 @@ def on_click(_, value): ) def on_click(_, value): p = Patch() - p.merge({"merged": value}) + p.update({"merged": value}) p.n_clicks += 1 return p diff --git a/tests/unit/test_patch.py b/tests/unit/test_patch.py index c4615d38bb..dbdfab34e0 100644 --- a/tests/unit/test_patch.py +++ b/tests/unit/test_patch.py @@ -120,7 +120,7 @@ def test_pat009_patch_extend(): def test_pat010_patch_merge(): p = Patch() - p.merge({"merge": "merged"}) + p.update({"merge": "merged"}) p["merge"] |= {"ior": "iored"} data = patch_to_dict(p) From 1fc6b46d75d15b7d34593789ff81ab69fef0448d Mon Sep 17 00:00:00 2001 From: philippe Date: Wed, 1 Mar 2023 10:59:22 -0500 Subject: [PATCH 26/30] Add remove. --- dash/_patch.py | 4 ++++ dash/dash-renderer/src/actions/patch.ts | 11 +++++++++++ tests/integration/test_patch.py | 18 ++++++++++++++++++ tests/unit/test_patch.py | 12 ++++++++++++ 4 files changed, 45 insertions(+) diff --git a/dash/_patch.py b/dash/_patch.py index 3a4ef281ea..7d67140c26 100644 --- a/dash/_patch.py +++ b/dash/_patch.py @@ -115,6 +115,10 @@ def extend(self, item): 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): if E is not None: value = E diff --git a/dash/dash-renderer/src/actions/patch.ts b/dash/dash-renderer/src/actions/patch.ts index ec909be1f5..fac5b59ba3 100644 --- a/dash/dash-renderer/src/actions/patch.ts +++ b/dash/dash-renderer/src/actions/patch.ts @@ -4,6 +4,7 @@ import { concat, dissocPath, empty, + equals, has, insert, is, @@ -137,6 +138,16 @@ const patchHandlers: {[k: string]: PatchHandler} = { 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 + ); } }; diff --git a/tests/integration/test_patch.py b/tests/integration/test_patch.py index 997cf2c7e8..7beb867215 100644 --- a/tests/integration/test_patch.py +++ b/tests/integration/test_patch.py @@ -52,6 +52,7 @@ def test_pch001_patch_operations(dash_duo): 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", @@ -193,6 +194,16 @@ def on_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() == [] @@ -283,6 +294,13 @@ def get_output(): "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"] == [] diff --git a/tests/unit/test_patch.py b/tests/unit/test_patch.py index dbdfab34e0..c6f1ca22d4 100644 --- a/tests/unit/test_patch.py +++ b/tests/unit/test_patch.py @@ -232,3 +232,15 @@ def test_pat018_patch_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"}, + } From 9d4ae67189ae2e678826f0e16f9e30225c6d6352 Mon Sep 17 00:00:00 2001 From: philippe Date: Wed, 1 Mar 2023 11:22:23 -0500 Subject: [PATCH 27/30] Add docstrings to patch methods. --- dash/_patch.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/dash/_patch.py b/dash/_patch.py index 7d67140c26..f993002807 100644 --- a/dash/_patch.py +++ b/dash/_patch.py @@ -94,23 +94,29 @@ def __ior__(self, 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)) @@ -120,6 +126,7 @@ def remove(self, item): self._operations.append(_operation("Remove", self._location, value=item)) def update(self, E=None, **F): + """Merge a dict or keyword arguments with another dictionary""" if E is not None: value = E else: From d3767455aace60b9f0c15fd8c43401fbb9920cf8 Mon Sep 17 00:00:00 2001 From: Philippe Duval Date: Wed, 1 Mar 2023 13:41:12 -0500 Subject: [PATCH 28/30] Update dash/_patch.py Co-authored-by: Alex Johnson --- dash/_patch.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/dash/_patch.py b/dash/_patch.py index f993002807..c24b5c6978 100644 --- a/dash/_patch.py +++ b/dash/_patch.py @@ -127,10 +127,8 @@ def remove(self, item): def update(self, E=None, **F): """Merge a dict or keyword arguments with another dictionary""" - if E is not None: - value = E - else: - value = dict(F) + value = E or {} + value.update(F) self._operations.append(_operation("Merge", self._location, value=value)) def to_plotly_json(self): From 3fae39e73219a445017006f100d7f53c213a7313 Mon Sep 17 00:00:00 2001 From: philippe Date: Wed, 1 Mar 2023 13:48:03 -0500 Subject: [PATCH 29/30] Add sort stub --- dash/_patch.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/dash/_patch.py b/dash/_patch.py index c24b5c6978..aba5d4f4e8 100644 --- a/dash/_patch.py +++ b/dash/_patch.py @@ -131,6 +131,10 @@ def update(self, E=None, **F): 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", From 2949b7b549c4105beb1a01c5ff1dc704383dacdb Mon Sep 17 00:00:00 2001 From: philippe Date: Wed, 1 Mar 2023 16:45:28 -0500 Subject: [PATCH 30/30] Update changelog --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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