Skip to content

Commit 8aec8d4

Browse files
authored
Merge pull request #2414 from plotly/patch-update
Add Patch callbacks
2 parents d20161d + 2949b7b commit 8aec8d4

File tree

14 files changed

+1073
-16
lines changed

14 files changed

+1073
-16
lines changed

CHANGELOG.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@ This project adheres to [Semantic Versioning](https://semver.org/).
66

77
## Added
88

9-
-### Added
10-
119
- [#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.
1210
- [#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)
1311
- [#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)
1412
- [#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.
1513
- [#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.
14+
- [#2414](https://github.com/plotly/dash/pull/2414) Add `dash.Patch`for partial update Output props without transferring the previous value in a State.
15+
- [#2414](https://github.com/plotly/dash/pull/2414) Add `allow_duplicate` to `Output` arguments allowing duplicate callbacks to target the same prop.
1616

1717
## Fixed
1818

dash/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
__plotly_dash = True
55
from .dependencies import ( # noqa: F401,E402
66
Input, # noqa: F401,E402
7-
Output, # noqa: F401,E402
7+
Output, # noqa: F401,E402,
88
State, # noqa: F401,E402
99
ClientsideFunction, # noqa: F401,E402
1010
MATCH, # noqa: F401,E402
@@ -38,6 +38,6 @@
3838
no_update,
3939
page_container,
4040
)
41-
41+
from ._patch import Patch # noqa: F401,E402
4242

4343
ctx = callback_context

dash/_callback.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
to_json,
2828
coerce_to_list,
2929
AttributeDict,
30+
clean_property_name,
3031
)
3132

3233
from . import _validate
@@ -240,13 +241,19 @@ def insert_callback(
240241
if prevent_initial_call is None:
241242
prevent_initial_call = config_prevent_initial_callbacks
242243

244+
_validate.validate_duplicate_output(
245+
output, prevent_initial_call, config_prevent_initial_callbacks
246+
)
247+
243248
callback_id = create_callback_id(output)
244249
callback_spec = {
245250
"output": callback_id,
246251
"inputs": [c.to_dict() for c in inputs],
247252
"state": [c.to_dict() for c in state],
248253
"clientside_function": None,
249-
"prevent_initial_call": prevent_initial_call,
254+
# prevent_initial_call can be a string "initial_duplicates"
255+
# which should not prevent the initial call.
256+
"prevent_initial_call": prevent_initial_call is True,
250257
"long": long
251258
and {
252259
"interval": long["interval"],
@@ -469,7 +476,8 @@ def add_context(*args, **kwargs):
469476
if not isinstance(vali, NoUpdate):
470477
has_update = True
471478
id_str = stringify_id(speci["id"])
472-
component_ids[id_str][speci["property"]] = vali
479+
prop = clean_property_name(speci["property"])
480+
component_ids[id_str][prop] = vali
473481

474482
if not has_update:
475483
raise PreventUpdate

dash/_patch.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
def _operation(name, location, **kwargs):
2+
return {"operation": name, "location": location, "params": dict(**kwargs)}
3+
4+
5+
_noop = object()
6+
7+
8+
def validate_slice(obj):
9+
if isinstance(obj, slice):
10+
raise TypeError("a slice is not a valid index for patch")
11+
12+
13+
class Patch:
14+
"""
15+
Patch a callback output value
16+
17+
Act like a proxy of the output prop value on the frontend.
18+
19+
Supported prop types: Dictionaries and lists.
20+
"""
21+
22+
def __init__(self, location=None, parent=None):
23+
if location is not None:
24+
self._location = location
25+
else:
26+
# pylint: disable=consider-using-ternary
27+
self._location = (parent and parent._location) or []
28+
if parent is not None:
29+
self._operations = parent._operations
30+
else:
31+
self._operations = []
32+
33+
def __getitem__(self, item):
34+
validate_slice(item)
35+
return Patch(location=self._location + [item], parent=self)
36+
37+
def __getattr__(self, item):
38+
if item == "tolist":
39+
# to_json fix
40+
raise AttributeError
41+
if item == "_location":
42+
return self._location
43+
if item == "_operations":
44+
return self._operations
45+
return self.__getitem__(item)
46+
47+
def __setattr__(self, key, value):
48+
if key in ("_location", "_operations"):
49+
self.__dict__[key] = value
50+
else:
51+
self.__setitem__(key, value)
52+
53+
def __delattr__(self, item):
54+
self.__delitem__(item)
55+
56+
def __setitem__(self, key, value):
57+
validate_slice(key)
58+
if value is _noop:
59+
# The += set themselves.
60+
return
61+
self._operations.append(
62+
_operation(
63+
"Assign",
64+
self._location + [key],
65+
value=value,
66+
)
67+
)
68+
69+
def __delitem__(self, key):
70+
validate_slice(key)
71+
self._operations.append(_operation("Delete", self._location + [key]))
72+
73+
def __iadd__(self, other):
74+
if isinstance(other, (list, tuple)):
75+
self.extend(other)
76+
else:
77+
self._operations.append(_operation("Add", self._location, value=other))
78+
return _noop
79+
80+
def __isub__(self, other):
81+
self._operations.append(_operation("Sub", self._location, value=other))
82+
return _noop
83+
84+
def __imul__(self, other):
85+
self._operations.append(_operation("Mul", self._location, value=other))
86+
return _noop
87+
88+
def __itruediv__(self, other):
89+
self._operations.append(_operation("Div", self._location, value=other))
90+
return _noop
91+
92+
def __ior__(self, other):
93+
self.update(E=other)
94+
return _noop
95+
96+
def append(self, item):
97+
"""Add the item to the end of a list"""
98+
self._operations.append(_operation("Append", self._location, value=item))
99+
100+
def prepend(self, item):
101+
"""Add the item to the start of a list"""
102+
self._operations.append(_operation("Prepend", self._location, value=item))
103+
104+
def insert(self, index, item):
105+
"""Add the item at the index of a list"""
106+
self._operations.append(
107+
_operation("Insert", self._location, value=item, index=index)
108+
)
109+
110+
def clear(self):
111+
"""Remove all items in a list"""
112+
self._operations.append(_operation("Clear", self._location))
113+
114+
def reverse(self):
115+
"""Reversal of the order of items in a list"""
116+
self._operations.append(_operation("Reverse", self._location))
117+
118+
def extend(self, item):
119+
"""Add all the items to the end of a list"""
120+
if not isinstance(item, (list, tuple)):
121+
raise TypeError(f"{item} should be a list or tuple")
122+
self._operations.append(_operation("Extend", self._location, value=item))
123+
124+
def remove(self, item):
125+
"""filter the item out of a list on the frontend"""
126+
self._operations.append(_operation("Remove", self._location, value=item))
127+
128+
def update(self, E=None, **F):
129+
"""Merge a dict or keyword arguments with another dictionary"""
130+
value = E or {}
131+
value.update(F)
132+
self._operations.append(_operation("Merge", self._location, value=value))
133+
134+
# pylint: disable=no-self-use
135+
def sort(self):
136+
raise KeyError("sort is reserved for future use, use brackets to access this key on your object")
137+
138+
def to_plotly_json(self):
139+
return {
140+
"__dash_patch_update": "__dash_patch_update",
141+
"operations": self._operations,
142+
}

dash/_utils.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,11 @@ def create_callback_id(output):
128128
# but in case of multiple dots together escape each dot
129129
# with `\` so we don't mistake it for multi-outputs
130130
def _concat(x):
131-
return x.component_id_str().replace(".", "\\.") + "." + x.component_property
131+
_id = x.component_id_str().replace(".", "\\.") + "." + x.component_property
132+
if x.allow_duplicate:
133+
# Actually adds on the property part.
134+
_id += f"@{uuid.uuid4().hex}"
135+
return _id
132136

133137
if isinstance(output, (list, tuple)):
134138
return ".." + "...".join(_concat(x) for x in output) + ".."
@@ -247,3 +251,7 @@ def coerce_to_list(obj):
247251
if not isinstance(obj, (list, tuple)):
248252
return [obj]
249253
return obj
254+
255+
256+
def clean_property_name(name: str):
257+
return name.split("@")[0]

dash/_validate.py

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,13 @@
77
from ._grouping import grouping_len, map_grouping
88
from .development.base_component import Component
99
from . import exceptions
10-
from ._utils import patch_collections_abc, stringify_id, to_json, coerce_to_list
10+
from ._utils import (
11+
patch_collections_abc,
12+
stringify_id,
13+
to_json,
14+
coerce_to_list,
15+
clean_property_name,
16+
)
1117
from .exceptions import PageError
1218

1319

@@ -123,7 +129,10 @@ def validate_output_spec(output, output_spec, Output):
123129
for outi, speci in zip(output, output_spec):
124130
speci_list = speci if isinstance(speci, (list, tuple)) else [speci]
125131
for specij in speci_list:
126-
if not Output(specij["id"], specij["property"]) == outi:
132+
if (
133+
not Output(specij["id"], clean_property_name(specij["property"]))
134+
== outi
135+
):
127136
raise exceptions.CallbackException(
128137
"Output does not match callback definition"
129138
)
@@ -512,3 +521,32 @@ def validate_long_callbacks(callback_map):
512521
f"Long callback circular error!\n{circular} is used as input for a long callback"
513522
f" but also used as output from an input that is updated with progress or running argument."
514523
)
524+
525+
526+
def validate_duplicate_output(
527+
output, prevent_initial_call, config_prevent_initial_call
528+
):
529+
530+
if "initial_duplicate" in (prevent_initial_call, config_prevent_initial_call):
531+
return
532+
533+
def _valid(out):
534+
if (
535+
out.allow_duplicate
536+
and not prevent_initial_call
537+
and not config_prevent_initial_call
538+
):
539+
raise exceptions.DuplicateCallback(
540+
"allow_duplicate requires prevent_initial_call to be True. The order of the call is not"
541+
" guaranteed to be the same on every page load. "
542+
"To enable duplicate callback with initial call, set prevent_initial_call='initial_duplicate' "
543+
" or globally in the config prevent_initial_callbacks='initial_duplicate'"
544+
)
545+
546+
if isinstance(output, (list, tuple)):
547+
for o in output:
548+
_valid(o)
549+
550+
return
551+
552+
_valid(output)

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

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ import {
1010
pluck,
1111
values,
1212
toPairs,
13-
zip
13+
zip,
14+
assocPath
1415
} from 'ramda';
1516

1617
import {STATUS, JWT_EXPIRED_MESSAGE} from '../constants/constants';
@@ -39,6 +40,8 @@ import {createAction, Action} from 'redux-actions';
3940
import {addHttpHeaders} from '../actions';
4041
import {notifyObservers, updateProps} from './index';
4142
import {CallbackJobPayload} from '../reducers/callbackJobs';
43+
import {handlePatch, isPatch} from './patch';
44+
import {getPath} from './paths';
4245

4346
export const addBlockedCallbacks = createAction<IBlockedCallback[]>(
4447
CallbackActionType.AddBlocked
@@ -683,7 +686,7 @@ export function executeCallback(
683686

684687
for (let retry = 0; retry <= MAX_AUTH_RETRIES; retry++) {
685688
try {
686-
const data = await handleServerside(
689+
let data = await handleServerside(
687690
dispatch,
688691
hooks,
689692
newConfig,
@@ -698,6 +701,28 @@ export function executeCallback(
698701
if (newHeaders) {
699702
dispatch(addHttpHeaders(newHeaders));
700703
}
704+
// Layout may have changed.
705+
const currentLayout = getState().layout;
706+
flatten(outputs).forEach((out: any) => {
707+
const propName = out.property.split('@')[0];
708+
const outputPath = getPath(paths, out.id);
709+
const previousValue = path(
710+
outputPath.concat(['props', propName]),
711+
currentLayout
712+
);
713+
const dataPath = [stringifyId(out.id), propName];
714+
const outputValue = path(dataPath, data);
715+
if (isPatch(outputValue)) {
716+
if (previousValue === undefined) {
717+
throw new Error('Cannot patch undefined');
718+
}
719+
data = assocPath(
720+
dataPath,
721+
handlePatch(previousValue, outputValue),
722+
data
723+
);
724+
}
725+
});
701726

702727
return {data, payload};
703728
} catch (res: any) {

dash/dash-renderer/src/actions/dependencies.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -485,7 +485,8 @@ export function validateCallbacksToLayout(state_, dispatchError) {
485485
]);
486486
}
487487

488-
function validateProp(id, idPath, prop, cls, callbacks) {
488+
function validateProp(id, idPath, rawProp, cls, callbacks) {
489+
const prop = rawProp.split('@')[0];
489490
const component = path(idPath, layout);
490491
const element = Registry.resolve(component);
491492

0 commit comments

Comments
 (0)