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

Long callback refactor #2039

Merged
merged 41 commits into from
Jun 30, 2022
Merged
Show file tree
Hide file tree
Changes from 39 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
bfbd59e
Handle long callback errors.
T4rk1n Apr 26, 2022
dbacfcc
:hankey: Add test long callback error.
T4rk1n Apr 26, 2022
ac59e59
Add lock to diskcache
T4rk1n May 6, 2022
e2d3d5e
Fix test long callback error.
T4rk1n May 6, 2022
c3855ef
Merge branch 'dev' into long-callback-errors
T4rk1n May 6, 2022
fb4cef9
Handle no update in celery long callbacks.
T4rk1n May 6, 2022
fa94a40
Use diskcache lock
T4rk1n May 9, 2022
7688306
Stricter no_update check.
T4rk1n May 9, 2022
20c72b9
Handle no update in multi output.
T4rk1n May 9, 2022
2f9fe06
Add long callback test lock.
T4rk1n May 9, 2022
9ed5a3f
Merge branch 'dev' into long-callback-errors
T4rk1n May 17, 2022
9cab5b4
Clean up no_update.
T4rk1n May 17, 2022
9509213
Merge branch 'dev' into long-callback-errors
T4rk1n Jun 10, 2022
3a207ce
Merge branch 'dev' into long-callback-errors
T4rk1n Jun 13, 2022
2b22ff3
Replace long callback interval with request polling handled in renderer.
T4rk1n Jun 13, 2022
c5f6fff
Fix test_grouped_callbacks
T4rk1n Jun 13, 2022
fc9a77b
Fix cbva002
T4rk1n Jun 14, 2022
40bd173
Fix celery cancel.
T4rk1n Jun 14, 2022
28a6d77
Fix callback_map
T4rk1n Jun 14, 2022
1c1c7a2
Update callback docstrings.
T4rk1n Jun 15, 2022
55455f9
Add test short interval.
T4rk1n Jun 15, 2022
cc9b09b
Add error if no manager.
T4rk1n Jun 15, 2022
0501fd9
Fix error message assert
T4rk1n Jun 15, 2022
43e42ea
Remove leftover _NoUpdate.
T4rk1n Jun 20, 2022
de5ee34
Update dash/dash.py
T4rk1n Jun 20, 2022
08356f0
Hide arguments.
T4rk1n Jun 20, 2022
edd7fd6
Add test side update.
T4rk1n Jun 21, 2022
2fd56e9
Redux devtools ignore reloadRequest actions.
T4rk1n Jun 21, 2022
da2b01f
Long callbacks side update to trigger other callbacks.
T4rk1n Jun 21, 2022
bfc3c8f
Add test long callback pattern matching.
T4rk1n Jun 21, 2022
077733c
Add circular check for long callbacks side outputs.
T4rk1n Jun 21, 2022
638dacf
Add test long callback context.
T4rk1n Jun 21, 2022
9df3082
Support callback context in long callbacks.
T4rk1n Jun 21, 2022
ccb53b9
Proper callback_context, replace flask.g context with contextvars.
T4rk1n Jun 22, 2022
8525c73
Fix cancel.
T4rk1n Jun 23, 2022
34bd81d
Back to flask.g for timing_information.
T4rk1n Jun 23, 2022
b2ac6ce
Merge branch 'dev' into long-callback-errors
T4rk1n Jun 23, 2022
02c15dc
Manage previous outdated running jobs.
T4rk1n Jun 28, 2022
a003f5a
Merge branch 'dev' into long-callback-errors
T4rk1n Jun 29, 2022
529ec8e
Lock selenium <=4.2.0
T4rk1n Jun 30, 2022
2fb1cfa
Update changelog.
T4rk1n Jun 30, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
310 changes: 298 additions & 12 deletions dash/_callback.py

Large diffs are not rendered by default.

41 changes: 28 additions & 13 deletions dash/_callback_context.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
import functools
import warnings
import json
import contextvars

import flask

from . import exceptions
from ._utils import AttributeDict


context_value = contextvars.ContextVar("callback_context")
context_value.set({})


def has_context(func):
@functools.wraps(func)
def assert_context(*args, **kwargs):
if not flask.has_request_context():
if not context_value.get():
raise exceptions.MissingCallbackContextException(
f"dash.callback_context.{getattr(func, '__name__')} is only available from a callback!"
)
Expand All @@ -19,6 +25,10 @@ def assert_context(*args, **kwargs):
return assert_context


def _get_context_value():
return context_value.get()


class FalsyList(list):
def __bool__(self):
# for Python 3
Expand All @@ -37,12 +47,12 @@ class CallbackContext:
@property
@has_context
def inputs(self):
return getattr(flask.g, "input_values", {})
return getattr(_get_context_value(), "input_values", {})

@property
@has_context
def states(self):
return getattr(flask.g, "state_values", {})
return getattr(_get_context_value(), "state_values", {})

@property
@has_context
Expand All @@ -64,7 +74,7 @@ def triggered(self):
# value - to avoid breaking existing apps, add a dummy item but
# make the list still look falsy. So `if ctx.triggered` will make it
# look empty, but you can still do `triggered[0]["prop_id"].split(".")`
return getattr(flask.g, "triggered_inputs", []) or falsy_triggered
return getattr(_get_context_value(), "triggered_inputs", []) or falsy_triggered

@property
@has_context
Expand All @@ -90,7 +100,7 @@ def triggered_prop_ids(self):
`if "btn-1.n_clicks" in ctx.triggered_prop_ids:
do_something()`
"""
triggered = getattr(flask.g, "triggered_inputs", [])
triggered = getattr(_get_context_value(), "triggered_inputs", [])
ids = AttributeDict({})
for item in triggered:
component_id, _, _ = item["prop_id"].rpartition(".")
Expand Down Expand Up @@ -146,12 +156,12 @@ def display(btn1, btn2):
return "No clicks yet"

"""
return getattr(flask.g, "args_grouping", [])
return getattr(_get_context_value(), "args_grouping", [])

@property
@has_context
def outputs_grouping(self):
return getattr(flask.g, "outputs_grouping", [])
return getattr(_get_context_value(), "outputs_grouping", [])

@property
@has_context
Expand All @@ -162,7 +172,7 @@ def outputs_list(self):
DeprecationWarning,
)

return getattr(flask.g, "outputs_list", [])
return getattr(_get_context_value(), "outputs_list", [])

@property
@has_context
Expand All @@ -173,7 +183,7 @@ def inputs_list(self):
DeprecationWarning,
)

return getattr(flask.g, "inputs_list", [])
return getattr(_get_context_value(), "inputs_list", [])

@property
@has_context
Expand All @@ -183,12 +193,12 @@ def states_list(self):
"states_list is deprecated, use args_grouping instead",
DeprecationWarning,
)
return getattr(flask.g, "states_list", [])
return getattr(_get_context_value(), "states_list", [])

@property
@has_context
def response(self):
return getattr(flask.g, "dash_response")
return getattr(_get_context_value(), "dash_response")

@staticmethod
@has_context
Expand Down Expand Up @@ -221,7 +231,7 @@ def using_args_grouping(self):
Return True if this callback is using dictionary or nested groupings for
Input/State dependencies, or if Input and State dependencies are interleaved
"""
return getattr(flask.g, "using_args_grouping", [])
return getattr(_get_context_value(), "using_args_grouping", [])

@property
@has_context
Expand All @@ -230,7 +240,12 @@ def using_outputs_grouping(self):
Return True if this callback is using dictionary or nested groupings for
Output dependencies.
"""
return getattr(flask.g, "using_outputs_grouping", [])
return getattr(_get_context_value(), "using_outputs_grouping", [])

@property
@has_context
def timing_information(self):
return getattr(flask.g, "timing_information", {})


callback_context = CallbackContext()
6 changes: 6 additions & 0 deletions dash/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,3 +217,9 @@ def gen_salt(chars):
return "".join(
secrets.choice(string.ascii_letters + string.digits) for _ in range(chars)
)


def coerce_to_list(obj):
if not isinstance(obj, (list, tuple)):
return [obj]
return obj
34 changes: 33 additions & 1 deletion dash/_validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
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
from ._utils import patch_collections_abc, stringify_id, to_json, coerce_to_list


def validate_callback(outputs, inputs, state, extra_args, types):
Expand Down Expand Up @@ -479,3 +479,35 @@ def validate_module_name(module):
"The first attribute of dash.register_page() must be a string or '__name__'"
)
return module


def validate_long_callbacks(callback_map):
# Validate that long callback side output & inputs are not circular
# If circular, triggering a long callback would result in a fatal server/computer crash.
all_outputs = set()
input_indexed = {}
for callback in callback_map.values():
out = coerce_to_list(callback["output"])
all_outputs.update(out)
for o in out:
input_indexed.setdefault(o, set())
input_indexed[o].update(coerce_to_list(callback["raw_inputs"]))

for callback in (x for x in callback_map.values() if x.get("long")):
long_info = callback["long"]
progress = long_info.get("progress", [])
running = long_info.get("running", [])

long_inputs = coerce_to_list(callback["raw_inputs"])
outputs = set([x[0] for x in running] + progress)
circular = [
x
for x in set(k for k, v in input_indexed.items() if v.intersection(outputs))
if x in long_inputs
]

if circular:
raise exceptions.LongCallbackError(
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."
)
Loading