From 47efec594f29f98659912e9c7a2f8fa3fdadf018 Mon Sep 17 00:00:00 2001 From: BSd3v <82055130+BSd3v@users.noreply.github.com> Date: Tue, 29 Apr 2025 15:57:05 -0400 Subject: [PATCH 1/7] adds `allow_optional` to State and Input to place no value as the placeholders --- dash/dash-renderer/src/actions/callbacks.ts | 38 ++++++++++++--------- dash/dependencies.py | 28 ++++++++++++--- 2 files changed, 44 insertions(+), 22 deletions(-) diff --git a/dash/dash-renderer/src/actions/callbacks.ts b/dash/dash-renderer/src/actions/callbacks.ts index 82c7211446..2e38083067 100644 --- a/dash/dash-renderer/src/actions/callbacks.ts +++ b/dash/dash-renderer/src/actions/callbacks.ts @@ -122,22 +122,27 @@ function unwrapIfNotMulti( if (idProps.length !== 1) { if (!idProps.length) { - const isStr = typeof spec.id === 'string'; - msg = - 'A nonexistent object was used in an `' + - depType + - '` of a Dash callback. The id of this object is ' + - (isStr - ? '`' + spec.id + '`' - : JSON.stringify(spec.id) + - (anyVals ? ' with MATCH values ' + anyVals : '')) + - ' and the property is `' + - spec.property + - (isStr - ? '`. The string ids in the current layout are: [' + - keys(paths.strs).join(', ') + - ']' - : '`. The wildcard ids currently available are logged above.'); + if (spec.allow_optional) { + idProps = [{...spec, value: null}] + msg = '' + } else { + const isStr = typeof spec.id === 'string'; + msg = + 'A nonexistent object was used in an `' + + depType + + '` of a Dash callback. The id of this object is ' + + (isStr + ? '`' + spec.id + '`' + : JSON.stringify(spec.id) + + (anyVals ? ' with MATCH values ' + anyVals : '')) + + ' and the property is `' + + spec.property + + (isStr + ? '`. The string ids in the current layout are: [' + + keys(paths.strs).join(', ') + + ']' + : '`. The wildcard ids currently available are logged above.'); + } } else { msg = 'Multiple objects were found for an `' + @@ -203,7 +208,6 @@ function fillVals( // That's a real problem, so throw the first message as an error. refErr(errors, paths); } - return inputVals; } diff --git a/dash/dependencies.py b/dash/dependencies.py index 9fcd058b9d..de046215d2 100644 --- a/dash/dependencies.py +++ b/dash/dependencies.py @@ -35,6 +35,7 @@ class DashDependency: # pylint: disable=too-few-public-methods allow_duplicate: bool component_property: str allowed_wildcards: Sequence[_Wildcard] + allow_optional: bool def __init__(self, component_id: ComponentIdType, component_property: str): @@ -45,6 +46,7 @@ def __init__(self, component_id: ComponentIdType, component_property: str): self.component_property = component_property self.allow_duplicate = False + self.allow_optional = False def __str__(self): return f"{self.component_id_str()}.{self.component_property}" @@ -56,7 +58,8 @@ def component_id_str(self) -> str: return stringify_id(self.component_id) def to_dict(self) -> dict: - return {"id": self.component_id_str(), "property": self.component_property} + specs = {"id": self.component_id_str(), "property": self.component_property} + return {**specs, 'allow_optional': True} if self.allow_optional else specs def __eq__(self, other): """ @@ -133,14 +136,29 @@ def __init__( class Input(DashDependency): # pylint: disable=too-few-public-methods """Input of callback: trigger an update when it is updated.""" - - allowed_wildcards = (MATCH, ALL, ALLSMALLER) + def __init__( + self, + component_id: ComponentIdType, + component_property: str, + allow_optional: bool = False, + ): + super().__init__(component_id, component_property) + self.allow_optional = allow_optional + self.allowed_wildcards = (MATCH, ALL, ALLSMALLER) class State(DashDependency): # pylint: disable=too-few-public-methods """Use the value of a State in a callback but don't trigger updates.""" - - allowed_wildcards = (MATCH, ALL, ALLSMALLER) + def __init__( + self, + component_id: ComponentIdType, + component_property: str, + allow_optional: bool = False, + ): + super().__init__(component_id, component_property) + self.allow_optional = allow_optional + self.allowed_wildcards = (MATCH, ALL, ALLSMALLER) + # allowed_wildcards = (MATCH, ALL, ALLSMALLER) class ClientsideFunction: # pylint: disable=too-few-public-methods From 6445159cb1510a54ccc367eaac84819298a3563f Mon Sep 17 00:00:00 2001 From: BSd3v <82055130+BSd3v@users.noreply.github.com> Date: Tue, 29 Apr 2025 16:43:31 -0400 Subject: [PATCH 2/7] adding test for `allow_optional` --- .../callbacks/test_callback_optional.py | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 tests/integration/callbacks/test_callback_optional.py diff --git a/tests/integration/callbacks/test_callback_optional.py b/tests/integration/callbacks/test_callback_optional.py new file mode 100644 index 0000000000..c9a1e3beff --- /dev/null +++ b/tests/integration/callbacks/test_callback_optional.py @@ -0,0 +1,101 @@ +from dash import * + +def test_cbop001_optional_input(dash_duo): + app = Dash(suppress_callback_exceptions=True) + + app.layout = html.Div( + [ + html.Button(id='button1', children='Button 1'), + html.Div(id='button-container'), + html.Div(id='test-out'), + html.Div(id='test-out2') + ] + ) + + @app.callback( + Output('button-container', 'children'), + Input('button1', 'n_clicks'), + State('button-container', 'children'), + prevent_initial_call=True + ) + def _(_, c): + if not c: + return html.Button(id='button2', children='Button 2') + return no_update + + @app.callback( + Output('test-out', 'children'), + Input('button1', 'n_clicks'), + Input('button2', 'n_clicks', allow_optional=True), + prevent_inital_call=True + ) + def display(n, n2): + return f"{n} - {n2}" + + dash_duo.start_server(app) + dash_duo.wait_for_text_to_equal('#button1', 'Button 1') + assert dash_duo.get_logs() == [] + dash_duo.wait_for_text_to_equal('#test-out', 'None - None') + dash_duo.find_element("#button1").click() + dash_duo.wait_for_text_to_equal('#test-out', '1 - None') + + dash_duo.find_element("#button2").click() + dash_duo.wait_for_text_to_equal('#test-out', '1 - 1') + assert dash_duo.get_logs() == [] + + + +def test_cbop002_optional_state(dash_duo): + app = Dash(suppress_callback_exceptions=True) + + app.layout = html.Div( + [ + html.Button(id='button1', children='Button 1'), + html.Div(id='button-container'), + html.Div(id='test-out'), + html.Div(id='test-out2') + ] + ) + + @app.callback( + Output('button-container', 'children'), + Input('button1', 'n_clicks'), + State('button-container', 'children'), + prevent_initial_call=True + ) + def _(_, c): + if not c: + return html.Button(id='button2', children='Button 2') + return no_update + + @app.callback( + Output('test-out', 'children'), + Input('button1', 'n_clicks'), + State('button2', 'n_clicks', allow_optional=True), + prevent_inital_call=True + ) + def display(n, n2): + return f"{n} - {n2}" + + @app.callback( + Output('test-out2', 'children'), + Input('button2', 'n_clicks', allow_optional=True) + ) + def test(n): + if n: + return n + return no_update + + dash_duo.start_server(app) + dash_duo.wait_for_text_to_equal('#button1', 'Button 1') + assert dash_duo.get_logs() == [] + dash_duo.wait_for_text_to_equal('#test-out', 'None - None') + dash_duo.find_element("#button1").click() + dash_duo.wait_for_text_to_equal('#test-out', '1 - None') + + dash_duo.find_element("#button2").click() + dash_duo.wait_for_text_to_equal('#test-out2', '1') + dash_duo.wait_for_text_to_equal('#test-out', '1 - None') + dash_duo.find_element("#button1").click() + dash_duo.wait_for_text_to_equal('#test-out', '2 - 1') + assert dash_duo.get_logs() == [] From 1321683ead31cb5839a084baaca90a0ceb5e68cb Mon Sep 17 00:00:00 2001 From: BSd3v <82055130+BSd3v@users.noreply.github.com> Date: Tue, 29 Apr 2025 17:04:43 -0400 Subject: [PATCH 3/7] fixing for lint --- dash/dash-renderer/src/actions/callbacks.ts | 4 +- dash/dependencies.py | 5 +- .../callbacks/test_callback_optional.py | 78 +++++++++---------- 3 files changed, 45 insertions(+), 42 deletions(-) diff --git a/dash/dash-renderer/src/actions/callbacks.ts b/dash/dash-renderer/src/actions/callbacks.ts index 2e38083067..eae5682cb4 100644 --- a/dash/dash-renderer/src/actions/callbacks.ts +++ b/dash/dash-renderer/src/actions/callbacks.ts @@ -123,8 +123,8 @@ function unwrapIfNotMulti( if (idProps.length !== 1) { if (!idProps.length) { if (spec.allow_optional) { - idProps = [{...spec, value: null}] - msg = '' + idProps = [{...spec, value: null}]; + msg = ''; } else { const isStr = typeof spec.id === 'string'; msg = diff --git a/dash/dependencies.py b/dash/dependencies.py index de046215d2..3bfe0e2790 100644 --- a/dash/dependencies.py +++ b/dash/dependencies.py @@ -59,7 +59,7 @@ def component_id_str(self) -> str: def to_dict(self) -> dict: specs = {"id": self.component_id_str(), "property": self.component_property} - return {**specs, 'allow_optional': True} if self.allow_optional else specs + return {**specs, "allow_optional": True} if self.allow_optional else specs def __eq__(self, other): """ @@ -136,6 +136,7 @@ def __init__( class Input(DashDependency): # pylint: disable=too-few-public-methods """Input of callback: trigger an update when it is updated.""" + def __init__( self, component_id: ComponentIdType, @@ -149,6 +150,7 @@ def __init__( class State(DashDependency): # pylint: disable=too-few-public-methods """Use the value of a State in a callback but don't trigger updates.""" + def __init__( self, component_id: ComponentIdType, @@ -158,6 +160,7 @@ def __init__( super().__init__(component_id, component_property) self.allow_optional = allow_optional self.allowed_wildcards = (MATCH, ALL, ALLSMALLER) + # allowed_wildcards = (MATCH, ALL, ALLSMALLER) diff --git a/tests/integration/callbacks/test_callback_optional.py b/tests/integration/callbacks/test_callback_optional.py index c9a1e3beff..a51d1eeeba 100644 --- a/tests/integration/callbacks/test_callback_optional.py +++ b/tests/integration/callbacks/test_callback_optional.py @@ -1,85 +1,85 @@ from dash import * + def test_cbop001_optional_input(dash_duo): app = Dash(suppress_callback_exceptions=True) app.layout = html.Div( [ - html.Button(id='button1', children='Button 1'), - html.Div(id='button-container'), - html.Div(id='test-out'), - html.Div(id='test-out2') + html.Button(id="button1", children="Button 1"), + html.Div(id="button-container"), + html.Div(id="test-out"), + html.Div(id="test-out2"), ] ) @app.callback( - Output('button-container', 'children'), - Input('button1', 'n_clicks'), - State('button-container', 'children'), - prevent_initial_call=True + Output("button-container", "children"), + Input("button1", "n_clicks"), + State("button-container", "children"), + prevent_initial_call=True, ) def _(_, c): if not c: - return html.Button(id='button2', children='Button 2') + return html.Button(id="button2", children="Button 2") return no_update @app.callback( - Output('test-out', 'children'), - Input('button1', 'n_clicks'), - Input('button2', 'n_clicks', allow_optional=True), - prevent_inital_call=True + Output("test-out", "children"), + Input("button1", "n_clicks"), + Input("button2", "n_clicks", allow_optional=True), + prevent_inital_call=True, ) def display(n, n2): return f"{n} - {n2}" dash_duo.start_server(app) - dash_duo.wait_for_text_to_equal('#button1', 'Button 1') + dash_duo.wait_for_text_to_equal("#button1", "Button 1") assert dash_duo.get_logs() == [] - dash_duo.wait_for_text_to_equal('#test-out', 'None - None') + dash_duo.wait_for_text_to_equal("#test-out", "None - None") dash_duo.find_element("#button1").click() - dash_duo.wait_for_text_to_equal('#test-out', '1 - None') + dash_duo.wait_for_text_to_equal("#test-out", "1 - None") dash_duo.find_element("#button2").click() - dash_duo.wait_for_text_to_equal('#test-out', '1 - 1') + dash_duo.wait_for_text_to_equal("#test-out", "1 - 1") assert dash_duo.get_logs() == [] - def test_cbop002_optional_state(dash_duo): app = Dash(suppress_callback_exceptions=True) app.layout = html.Div( [ - html.Button(id='button1', children='Button 1'), - html.Div(id='button-container'), - html.Div(id='test-out'), - html.Div(id='test-out2') + html.Button(id="button1", children="Button 1"), + html.Div(id="button-container"), + html.Div(id="test-out"), + html.Div(id="test-out2"), ] ) @app.callback( - Output('button-container', 'children'), - Input('button1', 'n_clicks'), - State('button-container', 'children'), - prevent_initial_call=True + Output("button-container", "children"), + Input("button1", "n_clicks"), + State("button-container", "children"), + prevent_initial_call=True, ) def _(_, c): if not c: - return html.Button(id='button2', children='Button 2') + return html.Button(id="button2", children="Button 2") return no_update @app.callback( - Output('test-out', 'children'), - Input('button1', 'n_clicks'), - State('button2', 'n_clicks', allow_optional=True), - prevent_inital_call=True + Output("test-out", "children"), + Input("button1", "n_clicks"), + State("button2", "n_clicks", allow_optional=True), + prevent_inital_call=True, ) def display(n, n2): return f"{n} - {n2}" @app.callback( - Output('test-out2', 'children'), - Input('button2', 'n_clicks', allow_optional=True) + Output("test-out2", "children"), + Input("button2", "n_clicks", allow_optional=True), ) def test(n): if n: @@ -87,15 +87,15 @@ def test(n): return no_update dash_duo.start_server(app) - dash_duo.wait_for_text_to_equal('#button1', 'Button 1') + dash_duo.wait_for_text_to_equal("#button1", "Button 1") assert dash_duo.get_logs() == [] - dash_duo.wait_for_text_to_equal('#test-out', 'None - None') + dash_duo.wait_for_text_to_equal("#test-out", "None - None") dash_duo.find_element("#button1").click() - dash_duo.wait_for_text_to_equal('#test-out', '1 - None') + dash_duo.wait_for_text_to_equal("#test-out", "1 - None") dash_duo.find_element("#button2").click() - dash_duo.wait_for_text_to_equal('#test-out2', '1') - dash_duo.wait_for_text_to_equal('#test-out', '1 - None') + dash_duo.wait_for_text_to_equal("#test-out2", "1") + dash_duo.wait_for_text_to_equal("#test-out", "1 - None") dash_duo.find_element("#button1").click() - dash_duo.wait_for_text_to_equal('#test-out', '2 - 1') + dash_duo.wait_for_text_to_equal("#test-out", "2 - 1") assert dash_duo.get_logs() == [] From fe2d51403d0c406368a31183c80fa615a42ce84d Mon Sep 17 00:00:00 2001 From: BSd3v <82055130+BSd3v@users.noreply.github.com> Date: Tue, 29 Apr 2025 17:18:18 -0400 Subject: [PATCH 4/7] fixing for lint --- tests/integration/callbacks/test_callback_optional.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/callbacks/test_callback_optional.py b/tests/integration/callbacks/test_callback_optional.py index a51d1eeeba..73cc969055 100644 --- a/tests/integration/callbacks/test_callback_optional.py +++ b/tests/integration/callbacks/test_callback_optional.py @@ -1,4 +1,4 @@ -from dash import * +from dash import Dash, html, Output, Input, State, no_update def test_cbop001_optional_input(dash_duo): From 873394ccac7c5a1754626231f27cb4209fa64c5f Mon Sep 17 00:00:00 2001 From: BSd3v <82055130+BSd3v@users.noreply.github.com> Date: Thu, 1 May 2025 13:06:16 -0400 Subject: [PATCH 5/7] adjustments for feedback --- dash/dependencies.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dash/dependencies.py b/dash/dependencies.py index 3bfe0e2790..c994924f4b 100644 --- a/dash/dependencies.py +++ b/dash/dependencies.py @@ -145,7 +145,8 @@ def __init__( ): super().__init__(component_id, component_property) self.allow_optional = allow_optional - self.allowed_wildcards = (MATCH, ALL, ALLSMALLER) + + allowed_wildcards = (MATCH, ALL, ALLSMALLER) class State(DashDependency): # pylint: disable=too-few-public-methods @@ -159,9 +160,8 @@ def __init__( ): super().__init__(component_id, component_property) self.allow_optional = allow_optional - self.allowed_wildcards = (MATCH, ALL, ALLSMALLER) - # allowed_wildcards = (MATCH, ALL, ALLSMALLER) + allowed_wildcards = (MATCH, ALL, ALLSMALLER) class ClientsideFunction: # pylint: disable=too-few-public-methods From 94710e2e6f3de002e5340597e449a5082de1a83e Mon Sep 17 00:00:00 2001 From: BSd3v <82055130+BSd3v@users.noreply.github.com> Date: Thu, 1 May 2025 13:27:03 -0400 Subject: [PATCH 6/7] fixing for lint --- dash/dependencies.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dash/dependencies.py b/dash/dependencies.py index c994924f4b..fd8c782de5 100644 --- a/dash/dependencies.py +++ b/dash/dependencies.py @@ -145,7 +145,7 @@ def __init__( ): super().__init__(component_id, component_property) self.allow_optional = allow_optional - + allowed_wildcards = (MATCH, ALL, ALLSMALLER) From 900da07850fbca66d5928c02bb34353297b0fac8 Mon Sep 17 00:00:00 2001 From: BSd3v <82055130+BSd3v@users.noreply.github.com> Date: Mon, 5 May 2025 09:41:17 -0400 Subject: [PATCH 7/7] adjustments specs to dict --- CHANGELOG.md | 3 +++ dash/dependencies.py | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed2ea067fc..f6dba82d31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## Fixed - [#3279](https://github.com/plotly/dash/pull/3279) Fix an issue where persisted values were incorrectly pruned when updated via callback. Now, callback returned values are correctly stored in the persistence storage. Fix [#2678](https://github.com/plotly/dash/issues/2678) +## Added +- [#3294](https://github.com/plotly/dash/pull/3294) Added the ability to pass `allow_optional` to Input and State to allow callbacks to work even if these components are not in the dash layout. + ## [3.0.4] - 2025-04-24 ## Fixed diff --git a/dash/dependencies.py b/dash/dependencies.py index fd8c782de5..ae74a52858 100644 --- a/dash/dependencies.py +++ b/dash/dependencies.py @@ -59,7 +59,9 @@ def component_id_str(self) -> str: def to_dict(self) -> dict: specs = {"id": self.component_id_str(), "property": self.component_property} - return {**specs, "allow_optional": True} if self.allow_optional else specs + if self.allow_optional: + specs["allow_optional"] = True + return specs def __eq__(self, other): """