Skip to content

Commit 8c4b5e6

Browse files
authored
Merge pull request #3817 from i-murray/fix/background-callback-queryparams-serialisation
Fix background callback context serialisation for non-dict request args
2 parents ffd8e96 + 48adf42 commit 8c4b5e6

3 files changed

Lines changed: 167 additions & 0 deletions

File tree

CHANGELOG.md

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

77
### Fixed
8+
- [#3817](https://github.com/plotly/dash/pull/3817) Fix background callback context serialisation for non-dict request args on the FastAPI and Quart backends. Fixes [#3816](https://github.com/plotly/dash/issues/3816).
89
- [#3805](https://github.com/plotly/dash/pull/3805) Fix FastAPI POST routes deadlock caused by middleware consuming request body. Fixes [#3801](https://github.com/plotly/dash/issues/3801).
910
- [#3813](https://github.com/plotly/dash/pull/3813) Fix websockets using incorrect path when deployed behind a proxy
1011

dash/_callback.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,10 @@ def _setup_background_callback(
429429
ctx_value.pop("background_callback_manager")
430430
ctx_value.pop("dash_response")
431431

432+
args_value = ctx_value.get("args")
433+
if args_value is not None and not isinstance(args_value, dict):
434+
ctx_value["args"] = dict(args_value)
435+
432436
job = callback_manager.call_job_fn(
433437
cache_key,
434438
job_fn,
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import json
2+
from unittest import mock
3+
4+
import pytest
5+
6+
from dash import _callback
7+
from dash._callback import _setup_background_callback, context_value
8+
from dash._utils import AttributeDict
9+
10+
11+
class FakeMultiDict:
12+
"""Minimal multi-dict standing in for a backend query-params object.
13+
14+
It mimics ``starlette.datastructures.QueryParams`` for these tests: it is
15+
convertible to a ``dict`` and exposes ``getlist``, but is **not** a ``dict``
16+
subclass and is **not** JSON serialisable.
17+
18+
Parameters
19+
----------
20+
items : list of tuple
21+
The ``(key, value)`` pairs held by the multi-dict.
22+
"""
23+
24+
def __init__(self, items):
25+
self._items = list(items)
26+
27+
def getlist(self, key):
28+
"""Return all values stored under ``key``."""
29+
return [value for stored_key, value in self._items if stored_key == key]
30+
31+
def keys(self):
32+
"""Return the keys, enabling ``dict(self)`` coercion."""
33+
return [key for key, _ in self._items]
34+
35+
def __getitem__(self, key):
36+
for stored_key, value in self._items:
37+
if stored_key == key:
38+
return value
39+
raise KeyError(key)
40+
41+
42+
def _make_context(args_value):
43+
"""Build a minimal callback context containing ``args``.
44+
45+
Parameters
46+
----------
47+
args_value : Any
48+
The value to store under the ``args`` key.
49+
50+
Returns
51+
-------
52+
AttributeDict
53+
A context with the keys ``_setup_background_callback`` expects to find
54+
and pop.
55+
"""
56+
return AttributeDict(
57+
args=args_value,
58+
background_callback_manager=object(),
59+
dash_response=object(),
60+
)
61+
62+
63+
class _CapturingManager:
64+
"""Background callback manager stub that captures the dispatched context."""
65+
66+
def __init__(self):
67+
self.captured_context = None
68+
self.func_registry = mock.Mock()
69+
self.func_registry.get.return_value = object()
70+
71+
def build_cache_key(self, *_args, **_kwargs):
72+
"""Return a deterministic cache key."""
73+
return "cache-key"
74+
75+
def call_job_fn(self, _cache_key, _job_fn, _func_args, context):
76+
"""Capture the context that would be dispatched to the worker."""
77+
self.captured_context = context
78+
return "job-id"
79+
80+
81+
@pytest.fixture(name="patched_app")
82+
def fixture_patched_app():
83+
"""Patch ``get_app`` so ``_get_callback_manager`` can resolve an adapter."""
84+
adapter = mock.Mock()
85+
adapter.args.getlist.return_value = []
86+
app = mock.Mock()
87+
app.backend.request_adapter.return_value = adapter
88+
with mock.patch.object(_callback, "get_app", return_value=app):
89+
yield app
90+
91+
92+
def _run_setup(manager, args_value):
93+
"""Run ``_setup_background_callback`` with ``args_value`` on the context.
94+
95+
Parameters
96+
----------
97+
manager : _CapturingManager
98+
The manager whose ``call_job_fn`` captures the dispatched context.
99+
args_value : Any
100+
The value to place under the context ``args`` key.
101+
"""
102+
token = context_value.set(_make_context(args_value))
103+
try:
104+
_setup_background_callback(
105+
kwargs={},
106+
background={"manager": manager},
107+
background_key="bg-key",
108+
func=lambda: None,
109+
func_args=[],
110+
func_kwargs={},
111+
callback_ctx=AttributeDict(),
112+
)
113+
finally:
114+
context_value.reset(token)
115+
116+
117+
def test_non_dict_args_coerced_to_serialisable_dict(patched_app):
118+
"""A non-dict ``args`` is coerced to a JSON-serialisable ``dict``."""
119+
manager = _CapturingManager()
120+
args_value = FakeMultiDict([("foo", "bar"), ("baz", "qux")])
121+
122+
_run_setup(manager, args_value)
123+
124+
dispatched_args = manager.captured_context["args"]
125+
assert isinstance(dispatched_args, dict)
126+
assert dispatched_args == {"foo": "bar", "baz": "qux"}
127+
# Must not raise; the worker dispatch relies on this being serialisable.
128+
json.dumps(dispatched_args)
129+
130+
131+
def test_dict_args_left_unchanged(patched_app):
132+
"""An ``args`` value that is already a ``dict`` is preserved as-is."""
133+
manager = _CapturingManager()
134+
args_value = {"foo": "bar"}
135+
136+
_run_setup(manager, args_value)
137+
138+
dispatched_args = manager.captured_context["args"]
139+
assert dispatched_args == {"foo": "bar"}
140+
json.dumps(dispatched_args)
141+
142+
143+
def test_none_args_left_as_none(patched_app):
144+
"""A missing/``None`` ``args`` value does not raise and stays ``None``."""
145+
manager = _CapturingManager()
146+
147+
_run_setup(manager, None)
148+
149+
assert manager.captured_context["args"] is None
150+
151+
152+
def test_starlette_query_params_coerced(patched_app):
153+
"""A real ``starlette`` ``QueryParams`` is coerced to a serialisable dict."""
154+
query_params = pytest.importorskip("starlette.datastructures").QueryParams
155+
156+
manager = _CapturingManager()
157+
_run_setup(manager, query_params("foo=bar&baz=qux"))
158+
159+
dispatched_args = manager.captured_context["args"]
160+
assert isinstance(dispatched_args, dict)
161+
assert dispatched_args == {"foo": "bar", "baz": "qux"}
162+
json.dumps(dispatched_args)

0 commit comments

Comments
 (0)