Skip to content

Commit

Permalink
Merge pull request #2414 from plotly/patch-update
Browse files Browse the repository at this point in the history
Add Patch callbacks
  • Loading branch information
T4rk1n committed Mar 1, 2023
2 parents d20161d + 2949b7b commit 8aec8d4
Show file tree
Hide file tree
Showing 14 changed files with 1,073 additions and 16 deletions.
4 changes: 2 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions dash/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -38,6 +38,6 @@
no_update,
page_container,
)

from ._patch import Patch # noqa: F401,E402

ctx = callback_context
12 changes: 10 additions & 2 deletions dash/_callback.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
to_json,
coerce_to_list,
AttributeDict,
clean_property_name,
)

from . import _validate
Expand Down Expand Up @@ -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"],
Expand Down Expand Up @@ -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
Expand Down
142 changes: 142 additions & 0 deletions dash/_patch.py
Original file line number Diff line number Diff line change
@@ -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,
}
10 changes: 9 additions & 1 deletion dash/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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) + ".."
Expand Down Expand Up @@ -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]
42 changes: 40 additions & 2 deletions dash/_validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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"
)
Expand Down Expand Up @@ -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)
29 changes: 27 additions & 2 deletions dash/dash-renderer/src/actions/callbacks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import {
pluck,
values,
toPairs,
zip
zip,
assocPath
} from 'ramda';

import {STATUS, JWT_EXPIRED_MESSAGE} from '../constants/constants';
Expand Down Expand Up @@ -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<IBlockedCallback[]>(
CallbackActionType.AddBlocked
Expand Down Expand Up @@ -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,
Expand All @@ -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) {
Expand Down
3 changes: 2 additions & 1 deletion dash/dash-renderer/src/actions/dependencies.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Loading

0 comments on commit 8aec8d4

Please sign in to comment.