Skip to content

Commit e9e42d2

Browse files
authored
Merge pull request #1952 from AnnMarieW/triggered_ids
Improved `callback_context`
2 parents 7202d53 + 2c16563 commit e9e42d2

File tree

9 files changed

+271
-10
lines changed

9 files changed

+271
-10
lines changed

CHANGELOG.md

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

77
### Added
8+
- [#1952](https://github.com/plotly/dash/pull/1952) Improved callback_context
9+
- Closes [#1818](https://github.com/plotly/dash/issues/1818) Closes [#1054](https://github.com/plotly/dash/issues/1054)
10+
- adds `dash.ctx`, a more concise name for `dash.callback_context`
11+
- adds `ctx.triggered_prop_ids`, a dictionary of the component ids and props that triggered the callback.
12+
- adds `ctx.triggered_id`, the `id` of the component that triggered the callback.
13+
- adds `ctx.args_grouping`, a dict of the inputs used with flexible callback signatures.
814

915
- [#2009](https://github.com/plotly/dash/pull/2009) Add support for Promises within Client-side callbacks as requested in [#1364](https://github.com/plotly/dash/pull/1364).
1016

dash/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,4 @@
2626
get_relative_path,
2727
strip_relative_path,
2828
)
29+
ctx = callback_context

dash/_callback_context.py

Lines changed: 146 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import functools
22
import warnings
3-
3+
import json
4+
from copy import deepcopy
45
import flask
56

67
from . import exceptions
8+
from ._utils import stringify_id, AttributeDict
79

810

911
def has_context(func):
@@ -46,16 +48,158 @@ def states(self):
4648
@property
4749
@has_context
4850
def triggered(self):
51+
"""
52+
Returns a list of all the Input props that changed and caused the callback to execute. It is empty when the
53+
callback is called on initial load, unless an Input prop got its value from another initial callback.
54+
Callbacks triggered by user actions typically have one item in triggered, unless the same action changes
55+
two props at once or the callback has several Input props that are all modified by another callback based on
56+
a single user action.
57+
58+
Example: To get the id of the component that triggered the callback:
59+
`component_id = ctx.triggered[0]['prop_id'].split('.')[0]`
60+
61+
Example: To detect initial call, empty triggered is not really empty, it's falsy so that you can use:
62+
`if ctx.triggered:`
63+
"""
4964
# For backward compatibility: previously `triggered` always had a
5065
# value - to avoid breaking existing apps, add a dummy item but
5166
# make the list still look falsy. So `if ctx.triggered` will make it
5267
# look empty, but you can still do `triggered[0]["prop_id"].split(".")`
5368
return getattr(flask.g, "triggered_inputs", []) or falsy_triggered
5469

70+
@property
71+
@has_context
72+
def triggered_prop_ids(self):
73+
"""
74+
Returns a dictionary of all the Input props that changed and caused the callback to execute. It is empty when
75+
the callback is called on initial load, unless an Input prop got its value from another initial callback.
76+
Callbacks triggered by user actions typically have one item in triggered, unless the same action changes
77+
two props at once or the callback has several Input props that are all modified by another callback based
78+
on a single user action.
79+
80+
triggered_prop_ids (dict):
81+
- keys (str) : the triggered "prop_id" composed of "component_id.component_property"
82+
- values (str or dict): the id of the component that triggered the callback. Will be the dict id for pattern matching callbacks
83+
84+
Example - regular callback
85+
{"btn-1.n_clicks": "btn-1"}
86+
87+
Example - pattern matching callbacks:
88+
{'{"index":0,"type":"filter-dropdown"}.value': {"index":0,"type":"filter-dropdown"}}
89+
90+
Example usage:
91+
`if "btn-1.n_clicks" in ctx.triggered_prop_ids:
92+
do_something()`
93+
"""
94+
triggered = getattr(flask.g, "triggered_inputs", [])
95+
ids = AttributeDict({})
96+
for item in triggered:
97+
component_id, _, _ = item["prop_id"].rpartition(".")
98+
ids[item["prop_id"]] = component_id
99+
if component_id.startswith("{"):
100+
ids[item["prop_id"]] = AttributeDict(json.loads(component_id))
101+
return ids
102+
103+
@property
104+
@has_context
105+
def triggered_id(self):
106+
"""
107+
Returns the component id (str or dict) of the Input component that triggered the callback.
108+
109+
Note - use `triggered_prop_ids` if you need both the component id and the prop that triggered the callback or if
110+
multiple Inputs triggered the callback.
111+
112+
Example usage:
113+
`if "btn-1" == ctx.triggered_id:
114+
do_something()`
115+
116+
"""
117+
component_id = None
118+
if self.triggered:
119+
prop_id = self.triggered_prop_ids.first()
120+
component_id = self.triggered_prop_ids[prop_id]
121+
return component_id
122+
55123
@property
56124
@has_context
57125
def args_grouping(self):
58-
return getattr(flask.g, "args_grouping", [])
126+
"""
127+
args_grouping is a dict of the inputs used with flexible callback signatures. The keys are the variable names
128+
and the values are dictionaries containing:
129+
- “id”: (string or dict) the component id. If it’s a pattern matching id, it will be a dict.
130+
- “id_str”: (str) for pattern matching ids, it’s the strigified dict id with no white spaces.
131+
- “property”: (str) The component property used in the callback.
132+
- “value”: the value of the component property at the time the callback was fired.
133+
- “triggered”: (bool)Whether this input triggered the callback.
134+
135+
Example usage:
136+
@app.callback(
137+
Output("container", "children"),
138+
inputs=dict(btn1=Input("btn-1", "n_clicks"), btn2=Input("btn-2", "n_clicks")),
139+
)
140+
def display(btn1, btn2):
141+
c = ctx.args_grouping
142+
if c.btn1.triggered:
143+
return f"Button 1 clicked {btn1} times"
144+
elif c.btn2.triggered:
145+
return f"Button 2 clicked {btn2} times"
146+
else:
147+
return "No clicks yet"
148+
149+
"""
150+
triggered = getattr(flask.g, "triggered_inputs", [])
151+
triggered = [item["prop_id"] for item in triggered]
152+
grouping = getattr(flask.g, "args_grouping", {})
153+
154+
def update_args_grouping(g):
155+
if isinstance(g, dict) and "id" in g:
156+
str_id = stringify_id(g["id"])
157+
prop_id = f"{str_id}.{g['property']}"
158+
159+
new_values = {
160+
"value": g.get("value"),
161+
"str_id": str_id,
162+
"triggered": prop_id in triggered,
163+
"id": AttributeDict(g["id"])
164+
if isinstance(g["id"], dict)
165+
else g["id"],
166+
}
167+
g.update(new_values)
168+
169+
def recursive_update(g):
170+
if isinstance(g, (tuple, list)):
171+
for i in g:
172+
update_args_grouping(i)
173+
recursive_update(i)
174+
if isinstance(g, dict):
175+
for i in g.values():
176+
update_args_grouping(i)
177+
recursive_update(i)
178+
179+
recursive_update(grouping)
180+
181+
return grouping
182+
183+
# todo not sure whether we need this, but it removes a level of nesting so
184+
# you don't need to use `.value` to get the value.
185+
@property
186+
@has_context
187+
def args_grouping_values(self):
188+
grouping = getattr(flask.g, "args_grouping", {})
189+
grouping = deepcopy(grouping)
190+
191+
def recursive_update(g):
192+
if isinstance(g, (tuple, list)):
193+
for i in g:
194+
recursive_update(i)
195+
if isinstance(g, dict):
196+
for k, v in g.items():
197+
if isinstance(v, dict) and "id" in v:
198+
g[k] = v["value"]
199+
recursive_update(v)
200+
201+
recursive_update(grouping)
202+
return grouping
59203

60204
@property
61205
@has_context

dash/_grouping.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
1515
"""
1616
from dash.exceptions import InvalidCallbackReturnValue
17+
from ._utils import AttributeDict
1718

1819

1920
def flatten_grouping(grouping, schema=None):
@@ -123,14 +124,14 @@ def map_grouping(fn, grouping):
123124
return [map_grouping(fn, g) for g in grouping]
124125

125126
if isinstance(grouping, dict):
126-
return {k: map_grouping(fn, g) for k, g in grouping.items()}
127+
return AttributeDict({k: map_grouping(fn, g) for k, g in grouping.items()})
127128

128129
return fn(grouping)
129130

130131

131132
def make_grouping_by_key(schema, source, default=None):
132133
"""
133-
Create a grouping from a schema by ujsing the schema's scalar values to look up
134+
Create a grouping from a schema by using the schema's scalar values to look up
134135
items in the provided source object.
135136
136137
:param schema: A grouping of potential keys in source

dash/_utils.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,8 @@ def first(self, *names):
119119
value = self.get(name)
120120
if value:
121121
return value
122+
if not names:
123+
return next(iter(self), {})
122124

123125

124126
def create_callback_id(output):
@@ -152,7 +154,7 @@ def stringify_id(id_):
152154

153155

154156
def inputs_to_dict(inputs_list):
155-
inputs = {}
157+
inputs = AttributeDict()
156158
for i in inputs_list:
157159
inputsi = i if isinstance(i, list) else [i]
158160
for ii in inputsi:
@@ -161,6 +163,16 @@ def inputs_to_dict(inputs_list):
161163
return inputs
162164

163165

166+
def convert_to_AttributeDict(nested_list):
167+
new_dict = []
168+
for i in nested_list:
169+
if isinstance(i, dict):
170+
new_dict.append(AttributeDict(i))
171+
else:
172+
new_dict.append([AttributeDict(ii) for ii in i])
173+
return new_dict
174+
175+
164176
def inputs_to_vals(inputs):
165177
return [
166178
[ii.get("value") for ii in i] if isinstance(i, list) else i.get("value")

dash/_validate.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,11 @@ def validate_and_group_input_args(flat_args, arg_index_grouping):
134134
if isinstance(arg_index_grouping, dict):
135135
func_args = []
136136
func_kwargs = args_grouping
137+
for key in func_kwargs:
138+
if not key.isidentifier():
139+
raise exceptions.CallbackException(
140+
f"{key} is not a valid Python variable name"
141+
)
137142
elif isinstance(arg_index_grouping, (tuple, list)):
138143
func_args = list(args_grouping)
139144
func_kwargs = {}

dash/dash.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
patch_collections_abc,
5050
split_callback_id,
5151
to_json,
52+
convert_to_AttributeDict,
5253
gen_salt,
5354
)
5455
from . import _callback
@@ -1297,6 +1298,7 @@ def callback(_triggers, user_store_data, user_callback_args):
12971298

12981299
def dispatch(self):
12991300
body = flask.request.get_json()
1301+
13001302
flask.g.inputs_list = inputs = body.get( # pylint: disable=assigning-non-slot
13011303
"inputs", []
13021304
)
@@ -1331,9 +1333,12 @@ def dispatch(self):
13311333
# Add args_grouping
13321334
inputs_state_indices = cb["inputs_state_indices"]
13331335
inputs_state = inputs + state
1336+
inputs_state = convert_to_AttributeDict(inputs_state)
1337+
13341338
args_grouping = map_grouping(
13351339
lambda ind: inputs_state[ind], inputs_state_indices
13361340
)
1341+
13371342
flask.g.args_grouping = args_grouping # pylint: disable=assigning-non-slot
13381343
flask.g.using_args_grouping = ( # pylint: disable=assigning-non-slot
13391344
not isinstance(inputs_state_indices, int)

tests/integration/callbacks/test_callback_context.py

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import operator
33
import pytest
44

5-
from dash import Dash, Input, Output, html, dcc, callback_context
5+
from dash import Dash, ALL, Input, Output, html, dcc, callback_context, ctx
66

77
from dash.exceptions import PreventUpdate, MissingCallbackContextException
88
import dash.testing.wait as wait
@@ -330,3 +330,59 @@ def update_results(n1, n2, nsum):
330330
assert len(keys1) == 2
331331
assert "sum-number.value" in keys1
332332
assert "input-number-2.value" in keys1
333+
334+
335+
def test_cbcx007_triggered_id(dash_duo):
336+
app = Dash(__name__)
337+
338+
btns = ["btn-{}".format(x) for x in range(1, 6)]
339+
340+
app.layout = html.Div(
341+
[html.Div([html.Button(btn, id=btn) for btn in btns]), html.Div(id="output")]
342+
)
343+
344+
@app.callback(Output("output", "children"), [Input(x, "n_clicks") for x in btns])
345+
def on_click(*args):
346+
if not ctx.triggered:
347+
raise PreventUpdate
348+
for btn in btns:
349+
if btn in ctx.triggered_prop_ids.values():
350+
assert btn == ctx.triggered_id
351+
return f"Just clicked {btn}"
352+
353+
dash_duo.start_server(app)
354+
355+
for i in range(1, 5):
356+
for btn in btns:
357+
dash_duo.find_element("#" + btn).click()
358+
dash_duo.wait_for_text_to_equal("#output", f"Just clicked {btn}")
359+
360+
361+
def test_cbcx008_triggered_id_pmc(dash_duo):
362+
363+
app = Dash()
364+
app.layout = html.Div(
365+
[
366+
html.Button("Click me", id={"type": "btn", "index": "myindex"}),
367+
html.Div(id="output"),
368+
]
369+
)
370+
371+
@app.callback(
372+
Output("output", "children"), Input({"type": "btn", "index": ALL}, "n_clicks")
373+
)
374+
def func(n_clicks):
375+
if ctx.triggered:
376+
triggered_id, dict_id = next(iter(ctx.triggered_prop_ids.items()))
377+
378+
assert dict_id == ctx.triggered_id
379+
380+
if dict_id == {"type": "btn", "index": "myindex"}:
381+
return dict_id["index"]
382+
383+
dash_duo.start_server(app)
384+
385+
dash_duo.find_element(
386+
'#\\{\\"index\\"\\:\\"myindex\\"\\,\\"type\\"\\:\\"btn\\"\\}'
387+
).click()
388+
dash_duo.wait_for_text_to_equal("#output", "myindex")

0 commit comments

Comments
 (0)