Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 2 additions & 1 deletion .ai/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ The backend system uses an abstract interface:
- `FastAPIRequestAdapter` - Uses context variable for current request
- `FastAPIResponseAdapter` - Uses Starlette responses
- `DashMiddleware` - Consolidated ASGI middleware for request handling
- Runs `before_request` hooks on every HTTP route (user routes, assets, mounted apps) and the WebSocket handshake; a non-None hook result is sent as the response, like Flask. Only Dash's own routes get their JSON body pre-read, timing recorded and Dash's error handling. Quart registers the same hooks as `before_websocket` too.
- Runs with uvicorn, supports hot reload
- Built-in GZip compression

Expand Down Expand Up @@ -596,7 +597,7 @@ cache_manager = DiskcacheManager(
4. **Completion**: When job finishes, poll returns final result
5. **Cleanup**: Results cleared from cache (unless `cache_by` specified)

Cache key is SHA256 hash of: function source + arguments + triggered inputs + cache_by values.
Cache key is SHA256 hash of: callback key + function source + arguments + triggered inputs + cache_by values. Without `cache_by` a random nonce is added, so every run gets its own key: the job runs with the requester's context, so two users with equal inputs must never share a result. Diskcache writes those uncached entries with a one day expiry (`UNCACHED_RESULT_EXPIRE`) so results nobody polls for do not pile up; Celery's result backend already expires them.

### Key Files

Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ This project adheres to [Semantic Versioning](https://semver.org/).
- [#3986](https://github.com/plotly/dash/pull/3986) Adjust `_run_before_hooks` in the `fastapi` backend to honor a response returned by a `before_request` function, matching the `flask` backend's behavior.

### Fixed
- [#4075](https://github.com/plotly/dash/pull/4075) `fastapi` backend: run `before_request` hooks on every route of the Dash app (assets, hook routes, `api_endpoint`, pages, when mounted) and send a non-None hook result as the response, as on Flask. `fastapi` and `quart` also run them on the WebSocket handshake.
- [#4075](https://github.com/plotly/dash/pull/4075) Background callbacks without `cache_by` no longer share a result between requests with equal inputs (unread Diskcache results now expire after a day, or after `expire` when set, even with `cache_by`), and callbacks with the same source no longer share a cache key. `build_cache_key` takes a new `callback_key` argument.
- [#4075](https://github.com/plotly/dash/pull/4075) `dcc.Location(refresh=True).href`, `html.Area.href` and `html.Base.href` now block `javascript:` URLs like `html.A`.
- [#4075](https://github.com/plotly/dash/pull/4075) Pages `path_template` matching no longer errors or backtracks badly on paths with a newline.
- [#4075](https://github.com/plotly/dash/pull/4075) Add `configure_mcp_server(cache_by=..., cache_maxsize=...)` so apps whose layout or callbacks depend on the user can keep the MCP server's cached tool descriptions and initial values per user (128 users by default, `None` for no limit).
- [#4027](https://github.com/plotly/dash/pull/4027) Support `async def` hook routes (`dash.hooks.route`) on all backends, stop hook routes from leaking the app context to the caller, and let hook routes and MCP read the request on the `fastapi` backend.
- [#4053](https://github.com/plotly/dash/pull/4053) Fix callbacks being registered twice under a reloader (e.g. `uvicorn --reload`) when `Dash()` is given an explicit `name` or is built by an app factory called from the main module. Fixes [#3912](https://github.com/plotly/dash/issues/3912).
- [#3980](https://github.com/plotly/dash/pull/3980) Fix the three `before_request` hooks (`Dash._setup_server` and the pages `router_sync` / `router_async`) publishing their "already done" guard flag before the setup work behind it had run. Under a multi-threaded WSGI worker such as `gunicorn -k gthread` (or under an ASGI worker for the async router), a second request arriving mid-setup could observe the flag already set, skip setup, then read `registered_paths` / `callback_map` / the pages router callback while they were still being registered - causing the first burst of component bundle requests after a restart to 500 with `Error loading dependency. "<lib>" is not a registered library`, or the pages router to hit `DuplicateCallback` when two workers raced past the guard. Each hook body now runs under a lock (`threading.Lock` for the two sync hooks, an `asyncio.Lock` bound to the running loop for the async router) and only publishes the flag after all work completes. Fixes [#3971](https://github.com/plotly/dash/issues/3971).
Expand Down
11 changes: 11 additions & 0 deletions components/dash-core-components/src/components/Location.react.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,17 @@ export default class Location extends Component {
// Prop has changed?
if (refresh === true) {
// Refresh the page?
if (
fieldName === 'href' &&
window.dash_clientside.clean_url(propVal) !== propVal
) {
setProps({
_dash_error: new Error(
`Dangerous link detected: ${propVal}`
),
});
return false;
}
window.location[fieldName] = propVal;
} else if (this.props[fieldName] !== propVal) {
// If this prop has changed, need to setProps
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,9 @@ const customCodesForComponents = {
iframe: createXSSProtection('src'),
object: createXSSProtection('data'),
embed: createXSSProtection('src'),
button: createXSSProtection('formAction')
button: createXSSProtection('formAction'),
area: createXSSProtection('href'),
base: createXSSProtection('href')
}

function generateComponent(Component, element, attributes) {
Expand Down
1 change: 1 addition & 0 deletions dash/_callback.py
Original file line number Diff line number Diff line change
Expand Up @@ -588,6 +588,7 @@ def _setup_background_callback(
None
if cache_ignore_triggered
else callback_ctx.get("triggered_inputs", []),
callback_key=background_key,
)
job_fn = callback_manager.func_registry.get(background_key)
ctx_value = AttributeDict(**context_value.get())
Expand Down
22 changes: 13 additions & 9 deletions dash/_pages.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import collections
import functools
import importlib
import importlib.util # to make the type checker happy
import os
Expand Down Expand Up @@ -129,23 +130,26 @@
returns **{"asset_id": "a100"}
"""

# parse variable definitions e.g. <var_name> from template
# and create pattern to match
wildcard_pattern = re.sub("<.*?>", "*", path_template)
var_pattern = re.sub("<.*?>", "(.*)", path_template)

# check that static sections of the pathname match the template
# fnmatch compiles to a pattern that cannot backtrack, so it gates out
# non-matching paths cheaply before the capturing match below runs.
if not fnmatch(pathname, wildcard_pattern):
return None

# parse variable names e.g. var_name from template
match = _path_template_regex(path_template).fullmatch(pathname)
if match is None:
return None

var_names = re.findall("<(.*?)>", path_template)
return dict(zip(var_names, match.groups()))

# parse variables from path
variables = re.findall(var_pattern, pathname)
variables = variables[0] if isinstance(variables[0], tuple) else variables

return dict(zip(var_names, variables))
@functools.lru_cache(maxsize=None)
def _path_template_regex(path_template):
static_parts = re.split("<.*?>", path_template)

Check warning on line 150 in dash/_pages.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this use of a reluctant quantifier with "[^>]*".

See more on https://sonarcloud.io/project/issues?id=plotly_dash&issues=AaFaLtEAq5XP3Zb2Ak_j&open=AaFaLtEAq5XP3Zb2Ak_j&pullRequest=4075
# DOTALL to agree with fnmatch, whose * also matches newlines.
return re.compile("(.*)".join(re.escape(p) for p in static_parts), re.DOTALL)


def _set_redirect(redirect_from, path):
Expand Down
123 changes: 99 additions & 24 deletions dash/backends/_fastapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import asyncio
import concurrent.futures
import json
import logging
import queue
from typing import TYPE_CHECKING, Any, Callable, Dict, List
import sys
Expand All @@ -23,7 +24,9 @@
from fastapi.responses import JSONResponse, RedirectResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from starlette.responses import Response as StarletteResponse
from starlette.datastructures import MutableHeaders
from starlette.datastructures import Headers, MutableHeaders
from starlette.exceptions import HTTPException
from starlette.requests import HTTPConnection
from starlette.types import ASGIApp, Scope, Receive, Send
from starlette.websockets import WebSocket, WebSocketDisconnect
import uvicorn
Expand Down Expand Up @@ -76,6 +79,8 @@
if TYPE_CHECKING: # pragma: no cover - typing only
from dash import Dash

logger = logging.getLogger(__name__)


def _run_subprocess(args, env):
proc = subprocess.Popen(args, env=env) # pylint: disable=R1732
Expand Down Expand Up @@ -162,6 +167,29 @@
return replay


def _hook_response(result: Any) -> StarletteResponse:
"""Turn a before_request hook's return value into a response, following the
Flask/Quart contract: a response, a body, ``(body, status)``,
``(body, headers)`` or ``(body, status, headers)``."""
if isinstance(result, StarletteResponse):
return result
status, headers = 200, None
if isinstance(result, tuple):
if len(result) == 3:
result, status, headers = result
elif isinstance(result[1], (dict, Headers, list, tuple)):
result, headers = result
else:
result, status = result
if isinstance(status, str):
status = int(status.split(" ", 1)[0])
if isinstance(headers, (list, tuple)):
headers = dict(headers)
if isinstance(result, (dict, list)):
return JSONResponse(content=result, status_code=status, headers=headers)
return Response(content=result, status_code=status, headers=headers)


class DashMiddleware: # pylint: disable=too-few-public-methods
"""Consolidated middleware for all Dash/FastAPI integration needs."""

Expand Down Expand Up @@ -209,15 +237,15 @@
}
return body

async def _run_before_hooks(self) -> None:
"""Run all before-request hooks."""
async def _run_before_hooks(self) -> Any:
"""Run the before-request hooks, returning the first non-None result."""
for func in self.before_request_funcs:
if inspect.iscoroutinefunction(func):
result = await func()
else:
result = func()
result = func()
if inspect.isawaitable(result):
result = await result
if result is not None:
return result
return None

async def _run_after_hooks(self) -> None:
"""Run after-request hook if configured."""
Expand Down Expand Up @@ -245,6 +273,12 @@
"""Handle exceptions during request processing."""
if isinstance(error, PreventUpdate):
response = Response(status_code=204)
elif isinstance(error, HTTPException):
response = JSONResponse(
status_code=error.status_code,
content={"detail": error.detail},
headers=error.headers,
)
elif self.dash_server.error_handling_mode in ["raise", "prune"]:
tb = self.dash_server._get_traceback(None, error) # pylint: disable=W0212
response = Response(content=tb, media_type="text/html", status_code=500)
Expand All @@ -258,7 +292,7 @@
)
await response(scope, receive, send)

async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:

Check failure on line 295 in dash/backends/_fastapi.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 16 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=plotly_dash&issues=AaFaLs6Iq5XP3Zb2Ak_h&open=AaFaLs6Iq5XP3Zb2Ak_h&pullRequest=4075
# Handle lifespan events (startup/shutdown)

if scope["type"] == "lifespan":
Expand All @@ -281,32 +315,39 @@
await self.app(scope, _receive_with_shutdown, send)
return

# Non-HTTP/WebSocket scopes pass through
if scope["type"] != "http":
await self.app(scope, receive, send)
if scope["type"] == "websocket":
await self._gate_websocket(scope, receive, send)
return

# Non-Dash routes pass through to avoid consuming body stream.
# Routes registered through Dash (hook routes, MCP) are Dash routes too.
path = scope["path"]
prefix = self.dash_app.config.routes_pathname_prefix
dash_prefix = prefix.rstrip("/") + "/_dash-"
if (
not path.startswith(dash_prefix)
and path != prefix
and path != prefix.rstrip("/")
and path not in self.dash_app.routes
):
if scope["type"] != "http":
await self.app(scope, receive, send)
return

# HTTP request handling
# before_request hooks run for every route, like Flask and Quart. Only
# Dash's own routes get their JSON body parsed and timing recorded, so
# user routes keep their raw body stream.
is_dash_route = self._is_dash_route(scope)
request = Request(scope, receive=receive)
token = set_current_request(request)

try:
body = await self._setup_timing(request)
await self._run_before_hooks()
body = None
if is_dash_route:
body = await self._setup_timing(request)
else:
request.state.json_body = None

denied = await self._run_before_hooks()
if denied is not None:
await _hook_response(denied)(scope, receive, send)
return

if not is_dash_route:
# User routes keep their own error handling.
reset_current_request(token)
token = None
await self.app(scope, receive, send)
return

if body is not None:
# The body stream is consumed, replay it for handlers reading it.
Expand All @@ -318,8 +359,42 @@

except Exception as e: # pylint: disable=W0718
await self._handle_error(e, scope, receive, send)
finally:
if token is not None:
reset_current_request(token)

def _is_dash_route(self, scope: Scope) -> bool:
path = scope["path"]
# When mounted under a parent app the path still carries the mount
# prefix, which Starlette also reports as root_path.
root_path = scope.get("root_path", "")
if root_path and (path == root_path or path.startswith(root_path + "/")):
path = path[len(root_path) :] or "/"
prefix = self.dash_app.config.routes_pathname_prefix
return (
path.startswith(prefix.rstrip("/") + "/_dash-")
or path in (prefix, prefix.rstrip("/"))
or path in self.dash_app.routes
)

async def _gate_websocket(self, scope: Scope, receive: Receive, send: Send):
"""Run the before_request hooks on the WebSocket handshake so the
WebSocket callback transport has the same access check as HTTP."""
connection = HTTPConnection(scope)
connection.state.json_body = None
token = set_current_request(connection)
try:
denied = await self._run_before_hooks()
except Exception: # pylint: disable=broad-exception-caught
logger.exception("before_request hook failed, rejecting WebSocket")
denied = True
Comment thread
KoolADE85 marked this conversation as resolved.
finally:
reset_current_request(token)
if denied is not None:
# Closing before accept makes the server reject the handshake (403).
await send({"type": "websocket.close", "code": 1008})
return
await self.app(scope, receive, send)


class FastAPIDashServer(BaseDashServer[FastAPI]):
Expand Down
13 changes: 11 additions & 2 deletions dash/backends/_quart.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
Blueprint,
g as quart_g,
has_request_context,
has_websocket_context,
redirect,
websocket,
)
Expand Down Expand Up @@ -253,6 +254,9 @@ async def catchall(

def before_request(self, func: _t.Callable[[], _t.Any]):
self.server.before_request(func)
# Also gate the WebSocket handshake, so the WebSocket callback
# transport gets the same access check as HTTP.
self.server.before_websocket(func)

def after_request(self, func: _t.Callable[[], _t.Any]):
@self.server.after_request
Expand Down Expand Up @@ -820,13 +824,18 @@ async def websocket_handler(): # pylint: disable=too-many-branches

class QuartRequestAdapter(RequestAdapter):
def __init__(self) -> None:
self._request = request # type: ignore[assignment]
# Inside a before_websocket hook there is no request, only the websocket.
self._request = ( # type: ignore[assignment]
websocket
if not has_request_context() and has_websocket_context()
else request
)
if self._request is None:
raise RuntimeError("Quart not installed; cannot access request context")

@property
def context(self):
if not has_request_context():
if not (has_request_context() or has_websocket_context()):
raise RuntimeError("No active request in context")
return quart_g

Expand Down
13 changes: 11 additions & 2 deletions dash/background_callback/managers/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from abc import ABC
import inspect
import hashlib
import secrets
from typing import Any, List


Expand Down Expand Up @@ -69,7 +70,9 @@
"""
raise NotImplementedError

def build_cache_key(self, fn, args, cache_args_to_ignore, triggered):
def build_cache_key(
self, fn, args, cache_args_to_ignore, triggered, callback_key=None
):
try:
fn_source = inspect.getsource(fn)
fn_str = fn_source
Expand All @@ -87,13 +90,19 @@
arg for i, arg in enumerate(args) if i not in cache_args_to_ignore
]

hash_dict = dict(args=args, fn_source=fn_str, triggered=triggered)
hash_dict = dict(

Check warning on line 93 in dash/background_callback/managers/__init__.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this constructor call with a literal.

See more on https://sonarcloud.io/project/issues?id=plotly_dash&issues=AaFaLs-wq5XP3Zb2Ak_i&open=AaFaLs-wq5XP3Zb2Ak_i&pullRequest=4075
args=args, fn_source=fn_str, triggered=triggered, callback=callback_key
)

if self.cache_by is not None:
# Caching enabled
for i, cache_item in enumerate(self.cache_by):
# Call cache function
hash_dict[f"cache_key_{i}"] = cache_item()
else:
# Without caching the job runs in the requester's context, so two
# requests with equal inputs must never share a result.
hash_dict["nonce"] = secrets.token_hex(16)
Comment thread
KoolADE85 marked this conversation as resolved.

return hashlib.sha256(str(hash_dict).encode("utf-8")).hexdigest()

Expand Down
Loading
Loading