Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support Arbitrary callbacks #2822

Merged
merged 22 commits into from May 3, 2024
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion .pylintrc
Expand Up @@ -426,7 +426,7 @@ max-public-methods=40
max-returns=6

# Maximum number of statements in function / method body
max-statements=50
max-statements=75
T4rk1n marked this conversation as resolved.
Show resolved Hide resolved

# Minimum number of public methods for a class (see R0903).
min-public-methods=2
Expand Down
2 changes: 1 addition & 1 deletion dash/__init__.py
Expand Up @@ -18,7 +18,7 @@
from . import html # noqa: F401,E402
from . import dash_table # noqa: F401,E402
from .version import __version__ # noqa: F401,E402
from ._callback_context import callback_context # noqa: F401,E402
from ._callback_context import callback_context, set_props # noqa: F401,E402
from ._callback import callback, clientside_callback # noqa: F401,E402
from ._get_app import get_app # noqa: F401,E402
from ._get_paths import ( # noqa: F401,E402
Expand Down
63 changes: 43 additions & 20 deletions dash/_callback.py
Expand Up @@ -226,6 +226,7 @@ def insert_callback(
manager=None,
running=None,
dynamic_creator=False,
no_output=False,
):
if prevent_initial_call is None:
prevent_initial_call = config_prevent_initial_callbacks
Expand All @@ -234,7 +235,7 @@ def insert_callback(
output, prevent_initial_call, config_prevent_initial_callbacks
)

callback_id = create_callback_id(output, inputs)
callback_id = create_callback_id(output, inputs, no_output)
callback_spec = {
"output": callback_id,
"inputs": [c.to_dict() for c in inputs],
Expand All @@ -248,6 +249,7 @@ def insert_callback(
"interval": long["interval"],
},
"dynamic_creator": dynamic_creator,
"no_output": no_output,
}
if running:
callback_spec["running"] = running
Expand All @@ -262,6 +264,7 @@ def insert_callback(
"raw_inputs": inputs,
"manager": manager,
"allow_dynamic_callbacks": dynamic_creator,
"no_output": no_output,
}
callback_list.append(callback_spec)

Expand All @@ -283,10 +286,12 @@ def register_callback( # pylint: disable=R0914
# Insert callback with scalar (non-multi) Output
insert_output = output
multi = False
no_output = False
else:
# Insert callback as multi Output
insert_output = flatten_grouping(output)
multi = True
no_output = len(output) == 0

long = _kwargs.get("long")
manager = _kwargs.get("manager")
Expand Down Expand Up @@ -315,6 +320,7 @@ def register_callback( # pylint: disable=R0914
manager=manager,
dynamic_creator=allow_dynamic_callbacks,
running=running,
no_output=no_output,
)

# pylint: disable=too-many-locals
Expand All @@ -331,9 +337,12 @@ def wrap_func(func):
def add_context(*args, **kwargs):
output_spec = kwargs.pop("outputs_list")
app_callback_manager = kwargs.pop("long_callback_manager", None)
callback_ctx = kwargs.pop("callback_context", {})
callback_ctx = kwargs.pop(
"callback_context", AttributeDict({"updated_props": {}})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does callback_ctx always need to contain an updated_props key?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I remember there is an error with some mocked tests.

)
callback_manager = long and long.get("manager", app_callback_manager)
_validate.validate_output_spec(insert_output, output_spec, Output)
if not no_output:
_validate.validate_output_spec(insert_output, output_spec, Output)
T4rk1n marked this conversation as resolved.
Show resolved Hide resolved

context_value.set(callback_ctx)

Expand All @@ -342,6 +351,7 @@ def add_context(*args, **kwargs):
)

response = {"multi": True}
has_update = False

if long is not None:
if not callback_manager:
Expand Down Expand Up @@ -443,6 +453,10 @@ def add_context(*args, **kwargs):
NoUpdate() if NoUpdate.is_no_update(r) else r
for r in output_value
]
updated_props = callback_manager.get_updated_props(cache_key)
if len(updated_props) > 0:
response["sideUpdate"] = updated_props
has_update = True

if output_value is callback_manager.UNDEFINED:
return to_json(response)
Expand All @@ -452,7 +466,10 @@ def add_context(*args, **kwargs):
if NoUpdate.is_no_update(output_value):
raise PreventUpdate

if not multi:
if no_output:
output_value = []
flat_output_values = []
elif not multi:
output_value, output_spec = [output_value], [output_spec]
flat_output_values = output_value
else:
Expand All @@ -464,23 +481,29 @@ def add_context(*args, **kwargs):
# Flatten grouping and validate grouping structure
flat_output_values = flatten_grouping(output_value, output)

_validate.validate_multi_return(
output_spec, flat_output_values, callback_id
)

component_ids = collections.defaultdict(dict)
has_update = False
for val, spec in zip(flat_output_values, output_spec):
if isinstance(val, NoUpdate):
continue
for vali, speci in (
zip(val, spec) if isinstance(spec, list) else [[val, spec]]
):
if not isinstance(vali, NoUpdate):
has_update = True
id_str = stringify_id(speci["id"])
prop = clean_property_name(speci["property"])
component_ids[id_str][prop] = vali
if not no_output:
syl-oh marked this conversation as resolved.
Show resolved Hide resolved
_validate.validate_multi_return(
output_spec, flat_output_values, callback_id
)

for val, spec in zip(flat_output_values, output_spec):
if isinstance(val, NoUpdate):
continue
for vali, speci in (
zip(val, spec) if isinstance(spec, list) else [[val, spec]]
):
if not isinstance(vali, NoUpdate):
has_update = True
id_str = stringify_id(speci["id"])
prop = clean_property_name(speci["property"])
component_ids[id_str][prop] = vali

if not long:
side_update = dict(callback_ctx.updated_props)
if len(side_update) > 0:
has_update = True
response["sideUpdate"] = side_update

if not has_update:
raise PreventUpdate
Expand Down
16 changes: 16 additions & 0 deletions dash/_callback_context.py
Expand Up @@ -2,6 +2,7 @@
import warnings
import json
import contextvars
import typing

import flask

Expand Down Expand Up @@ -247,5 +248,20 @@ def using_outputs_grouping(self):
def timing_information(self):
return getattr(flask.g, "timing_information", {})

@has_context
def set_props(self, component_id: typing.Union[str, dict], props: dict):
ctx_value = _get_context_value()
if isinstance(component_id, dict):
ctx_value.updated_props[json.dumps(component_id)] = props
T4rk1n marked this conversation as resolved.
Show resolved Hide resolved
else:
ctx_value.updated_props[component_id] = props


callback_context = CallbackContext()


def set_props(component_id: typing.Union[str, dict], props: dict):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason why the function signature for set_props isn't structured as 3 inputs: component_id, prop_name, value?

That to me seems more consistent with the rest of the Dash API.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is the same signature as the frontend set_props, we considered set_props("id", prop=value) but decided to stay with same API for frontend and backend.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's true, this pattern is a little more javascripty, but consistency is good and it could be convenient to be able to set multiple props of the same component in one call.

"""
Set the props for a component not included in the callback outputs.
"""
callback_context.set_props(component_id, props)
8 changes: 7 additions & 1 deletion dash/_utils.py
Expand Up @@ -131,7 +131,7 @@ def first(self, *names):
return next(iter(self), {})


def create_callback_id(output, inputs):
def create_callback_id(output, inputs, no_output=False):
# A single dot within a dict id key or value is OK
# but in case of multiple dots together escape each dot
# with `\` so we don't mistake it for multi-outputs
Expand All @@ -149,6 +149,12 @@ def _concat(x):
_id += f"@{hashed_inputs}"
return _id

if no_output:
# No output will hash the inputs.
return hashlib.sha256(
".".join(str(x) for x in inputs).encode("utf-8")
).hexdigest()
T4rk1n marked this conversation as resolved.
Show resolved Hide resolved

if isinstance(output, (list, tuple)):
return ".." + "...".join(_concat(x) for x in output) + ".."

Expand Down
54 changes: 35 additions & 19 deletions dash/dash-renderer/src/actions/callbacks.ts
Expand Up @@ -324,19 +324,37 @@ async function handleClientside(
return result;
}

function sideUpdate(outputs: any, dispatch: any, paths: any) {
toPairs(outputs).forEach(([id, value]) => {
const [componentId, propName] = id.split('.');
const componentPath = paths.strs[componentId];
function updateComponent(component_id: any, props: any) {
return function (dispatch: any, getState: any) {
const paths = getState().paths;
const componentPath = getPath(paths, component_id);
dispatch(
updateProps({
props: {[propName]: value},
props,
itempath: componentPath
})
);
dispatch(
notifyObservers({id: componentId, props: {[propName]: value}})
);
dispatch(notifyObservers({id: component_id, props}));
};
}

function sideUpdate(outputs: any, dispatch: any) {
toPairs(outputs).forEach(([id, value]) => {
let componentId, propName;
if (id.includes('.')) {
T4rk1n marked this conversation as resolved.
Show resolved Hide resolved
[componentId, propName] = id.split('.');
if (componentId.startsWith('{')) {
componentId = JSON.parse(componentId);
}
dispatch(updateComponent(componentId, {[propName]: value}));
} else {
if (id.startsWith('{')) {
componentId = JSON.parse(id);
} else {
componentId = id;
}
dispatch(updateComponent(componentId, value));
}
syl-oh marked this conversation as resolved.
Show resolved Hide resolved
});
}

Expand All @@ -345,7 +363,6 @@ function handleServerside(
hooks: any,
config: any,
payload: any,
paths: any,
long: LongCallbackInfo | undefined,
additionalArgs: [string, string, boolean?][] | undefined,
getState: any,
Expand All @@ -365,7 +382,7 @@ function handleServerside(
let moreArgs = additionalArgs;

if (running) {
sideUpdate(running.running, dispatch, paths);
sideUpdate(running.running, dispatch);
runningOff = running.runningOff;
}

Expand Down Expand Up @@ -475,10 +492,10 @@ function handleServerside(
dispatch(removeCallbackJob({jobId: job}));
}
if (runningOff) {
sideUpdate(runningOff, dispatch, paths);
sideUpdate(runningOff, dispatch);
}
if (progressDefault) {
sideUpdate(progressDefault, dispatch, paths);
sideUpdate(progressDefault, dispatch);
}
};

Expand All @@ -500,8 +517,12 @@ function handleServerside(
job = data.job;
}

if (data.sideUpdate) {
sideUpdate(data.sideUpdate, dispatch);
}

if (data.progress) {
sideUpdate(data.progress, dispatch, paths);
sideUpdate(data.progress, dispatch);
}
if (!progressDefault && data.progressDefault) {
progressDefault = data.progressDefault;
Expand Down Expand Up @@ -696,11 +717,7 @@ export function executeCallback(
if (inter.length) {
additionalArgs.push(['cancelJob', job.jobId]);
if (job.progressDefault) {
sideUpdate(
job.progressDefault,
dispatch,
paths
);
sideUpdate(job.progressDefault, dispatch);
}
}
}
Expand All @@ -713,7 +730,6 @@ export function executeCallback(
hooks,
newConfig,
payload,
paths,
long,
additionalArgs.length ? additionalArgs : undefined,
getState,
Expand Down