Skip to content

Commit bee2154

Browse files
authored
Merge branch 'dev' into patch-sideupdates
2 parents c42d732 + bac3f36 commit bee2154

File tree

4 files changed

+131
-1
lines changed

4 files changed

+131
-1
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ This project adheres to [Semantic Versioning](https://semver.org/).
99
- [#3403](https://github.com/plotly/dash/pull/3403) Add app_context to get_app, allowing to get the current app in routes.
1010
- [#3407](https://github.com/plotly/dash/pull/3407) Add `hidden` to callback arguments, hiding the callback from appearing in the devtool callback graph.
1111
- [#3424](https://github.com/plotly/dash/pull/3424) Adds support for `Patch` on clientside callbacks class `dash_clientside.Patch`, as well as supporting side updates, eg: (Running, SetProps).
12+
- [#3347](https://github.com/plotly/dash/pull/3347) Added 'api_endpoint' to `callback` to expose api endpoints at the provided path for use to be executed directly without dash.
1213

1314
## Fixed
1415
- [#3395](https://github.com/plotly/dash/pull/3395) Fix Components added through set_props() cannot trigger related callback functions. Fix [#3316](https://github.com/plotly/dash/issues/3316)

dash/_callback.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ def _invoke_callback(func, *args, **kwargs): # used to mark the frame for the d
6262
GLOBAL_CALLBACK_LIST = []
6363
GLOBAL_CALLBACK_MAP = {}
6464
GLOBAL_INLINE_SCRIPTS = []
65+
GLOBAL_API_PATHS = {}
6566

6667

6768
# pylint: disable=too-many-locals,too-many-arguments
@@ -77,6 +78,7 @@ def callback(
7778
cache_args_to_ignore: Optional[list] = None,
7879
cache_ignore_triggered=True,
7980
on_error: Optional[Callable[[Exception], Any]] = None,
81+
api_endpoint: Optional[str] = None,
8082
optional: Optional[bool] = False,
8183
hidden: Optional[bool] = False,
8284
**_kwargs,
@@ -165,6 +167,14 @@ def callback(
165167
Mark all dependencies as not required on the initial layout checks.
166168
:param hidden:
167169
Hide the callback from the devtools callbacks tab.
170+
:param api_endpoint:
171+
If provided, the callback will be available at the given API endpoint.
172+
This allows you to call the callback directly through HTTP requests
173+
instead of through the Dash front-end. The endpoint should be a string
174+
that starts with a forward slash (e.g. `/my_callback`).
175+
The endpoint is relative to the Dash app's base URL.
176+
Note that the endpoint will not appear in the list of registered
177+
callbacks in the Dash devtools.
168178
"""
169179

170180
background_spec = None
@@ -219,6 +229,7 @@ def callback(
219229
manager=manager,
220230
running=running,
221231
on_error=on_error,
232+
api_endpoint=api_endpoint,
222233
optional=optional,
223234
hidden=hidden,
224235
)
@@ -587,7 +598,11 @@ def _prepare_response(
587598

588599
# pylint: disable=too-many-branches,too-many-statements
589600
def register_callback(
590-
callback_list, callback_map, config_prevent_initial_callbacks, *_args, **_kwargs
601+
callback_list,
602+
callback_map,
603+
config_prevent_initial_callbacks,
604+
*_args,
605+
**_kwargs,
591606
):
592607
(
593608
output,
@@ -642,6 +657,10 @@ def register_callback(
642657

643658
# pylint: disable=too-many-locals
644659
def wrap_func(func):
660+
if _kwargs.get("api_endpoint"):
661+
api_endpoint = _kwargs.get("api_endpoint")
662+
GLOBAL_API_PATHS[api_endpoint] = func
663+
645664
if background is None:
646665
background_key = None
647666
else:

dash/dash.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -569,6 +569,7 @@ def __init__( # pylint: disable=too-many-statements
569569
self.callback_map = {}
570570
# same deps as a list to catch duplicate outputs, and to send to the front end
571571
self._callback_list = []
572+
self.callback_api_paths = {}
572573

573574
# list of inline scripts
574575
self._inline_scripts = []
@@ -783,6 +784,54 @@ def _setup_routes(self):
783784
# catch-all for front-end routes, used by dcc.Location
784785
self._add_url("<path:path>", self.index)
785786

787+
def setup_apis(self):
788+
"""
789+
Register API endpoints for all callbacks defined using `dash.callback`.
790+
791+
This method must be called after all callbacks are registered and before the app is served.
792+
It ensures that all callback API routes are available for the Dash app to function correctly.
793+
794+
Typical usage:
795+
app = Dash(__name__)
796+
# Register callbacks here
797+
app.setup_apis()
798+
app.run()
799+
800+
If not called, callback endpoints will not be available and the app will not function as expected.
801+
"""
802+
for k in list(_callback.GLOBAL_API_PATHS):
803+
if k in self.callback_api_paths:
804+
raise DuplicateCallback(
805+
f"The callback `{k}` provided with `dash.callback` was already "
806+
"assigned with `app.callback`."
807+
)
808+
self.callback_api_paths[k] = _callback.GLOBAL_API_PATHS.pop(k)
809+
810+
def make_parse_body(func):
811+
def _parse_body():
812+
if flask.request.is_json:
813+
data = flask.request.get_json()
814+
return flask.jsonify(func(**data))
815+
return flask.jsonify({})
816+
817+
return _parse_body
818+
819+
def make_parse_body_async(func):
820+
async def _parse_body_async():
821+
if flask.request.is_json:
822+
data = flask.request.get_json()
823+
result = await func(**data)
824+
return flask.jsonify(result)
825+
return flask.jsonify({})
826+
827+
return _parse_body_async
828+
829+
for path, func in self.callback_api_paths.items():
830+
if asyncio.iscoroutinefunction(func):
831+
self._add_url(path, make_parse_body_async(func), ["POST"])
832+
else:
833+
self._add_url(path, make_parse_body(func), ["POST"])
834+
786835
def _setup_plotlyjs(self):
787836
# pylint: disable=import-outside-toplevel
788837
from plotly.offline import get_plotlyjs_version
@@ -1364,6 +1413,7 @@ def callback(self, *_args, **_kwargs) -> Callable[..., Any]:
13641413
config_prevent_initial_callbacks=self.config.prevent_initial_callbacks,
13651414
callback_list=self._callback_list,
13661415
callback_map=self.callback_map,
1416+
callback_api_paths=self.callback_api_paths,
13671417
**_kwargs,
13681418
)
13691419

@@ -1516,6 +1566,7 @@ def dispatch(self):
15161566
def _setup_server(self):
15171567
if self._got_first_request["setup_server"]:
15181568
return
1569+
15191570
self._got_first_request["setup_server"] = True
15201571

15211572
# Apply _force_eager_loading overrides from modules
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
from dash import (
2+
Dash,
3+
Input,
4+
Output,
5+
html,
6+
ctx,
7+
)
8+
import requests
9+
import json
10+
from flask import jsonify
11+
12+
test_string = (
13+
'{"step_0": "Data fetched - 1", "step_1": "Data fetched - 1", "step_2": "Data fetched - 1", '
14+
'"step_3": "Data fetched - 1", "step_4": "Data fetched - 1"}'
15+
)
16+
17+
18+
def test_apib001_api_callback(dash_duo):
19+
20+
app = Dash(__name__)
21+
app.layout = html.Div(
22+
[
23+
html.Button("Slow Callback", id="slow-btn"),
24+
html.Div(id="slow-output"),
25+
]
26+
)
27+
28+
def get_data(n_clicks):
29+
# Simulate an async data fetch
30+
return f"Data fetched - {n_clicks}"
31+
32+
@app.callback(
33+
Output("slow-output", "children"),
34+
Input("slow-btn", "n_clicks"),
35+
prevent_initial_call=True,
36+
api_endpoint="/api/slow_callback", # Example API path for the slow callback
37+
)
38+
def slow_callback(n_clicks):
39+
data = {}
40+
for i in range(5):
41+
data[f"step_{i}"] = get_data(n_clicks)
42+
ret = f"{json.dumps(data)}"
43+
if ctx:
44+
return ret
45+
return jsonify(ret)
46+
47+
app.setup_apis()
48+
49+
dash_duo.start_server(app)
50+
51+
dash_duo.wait_for_element("#slow-btn").click()
52+
dash_duo.wait_for_text_to_equal("#slow-output", test_string)
53+
r = requests.post(
54+
dash_duo.server_url + "/api/slow_callback",
55+
json={"n_clicks": 1},
56+
headers={"Content-Type": "application/json"},
57+
)
58+
assert r.status_code == 200
59+
assert r.json() == test_string

0 commit comments

Comments
 (0)