diff --git a/.ai/ARCHITECTURE.md b/.ai/ARCHITECTURE.md index bdd27d6367..1ad587e2c4 100644 --- a/.ai/ARCHITECTURE.md +++ b/.ai/ARCHITECTURE.md @@ -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 @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f53980dee..072445db8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. "" 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). diff --git a/components/dash-core-components/src/components/Location.react.js b/components/dash-core-components/src/components/Location.react.js index 27815db741..cfd4a55868 100644 --- a/components/dash-core-components/src/components/Location.react.js +++ b/components/dash-core-components/src/components/Location.react.js @@ -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 diff --git a/components/dash-html-components/scripts/generate-components.js b/components/dash-html-components/scripts/generate-components.js index 9a4472804b..634a7554ab 100644 --- a/components/dash-html-components/scripts/generate-components.js +++ b/components/dash-html-components/scripts/generate-components.js @@ -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) { diff --git a/dash/_callback.py b/dash/_callback.py index 91624c04eb..4191e85e7d 100644 --- a/dash/_callback.py +++ b/dash/_callback.py @@ -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()) diff --git a/dash/_pages.py b/dash/_pages.py index b63d37c669..22b7cecb4a 100644 --- a/dash/_pages.py +++ b/dash/_pages.py @@ -1,4 +1,5 @@ import collections +import functools import importlib import importlib.util # to make the type checker happy import os @@ -129,23 +130,26 @@ def _parse_path_variables(pathname, path_template): returns **{"asset_id": "a100"} """ - # parse variable definitions e.g. 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) + # 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): diff --git a/dash/backends/_fastapi.py b/dash/backends/_fastapi.py index 5e72f65570..ec1514a405 100644 --- a/dash/backends/_fastapi.py +++ b/dash/backends/_fastapi.py @@ -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 @@ -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 @@ -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 @@ -162,6 +167,29 @@ async def replay(): 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.""" @@ -209,15 +237,15 @@ async def _setup_timing(self, request: Request) -> bytes | None: } 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.""" @@ -245,6 +273,12 @@ async def _handle_error( """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) @@ -281,32 +315,39 @@ async def _receive_with_shutdown(): 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. @@ -318,8 +359,42 @@ async def _receive_with_shutdown(): 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 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]): diff --git a/dash/backends/_quart.py b/dash/backends/_quart.py index 242e9f8e5f..ac58e1acf5 100644 --- a/dash/backends/_quart.py +++ b/dash/backends/_quart.py @@ -28,6 +28,7 @@ Blueprint, g as quart_g, has_request_context, + has_websocket_context, redirect, websocket, ) @@ -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 @@ -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 diff --git a/dash/background_callback/managers/__init__.py b/dash/background_callback/managers/__init__.py index f7faa1fbb5..9b9723c2c2 100644 --- a/dash/background_callback/managers/__init__.py +++ b/dash/background_callback/managers/__init__.py @@ -1,6 +1,7 @@ from abc import ABC import inspect import hashlib +import secrets from typing import Any, List @@ -69,7 +70,9 @@ def get_or_create_signing_secret(self, generate): """ 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 @@ -87,13 +90,19 @@ def build_cache_key(self, fn, args, cache_args_to_ignore, triggered): 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( + 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) return hashlib.sha256(str(hash_dict).encode("utf-8")).hexdigest() diff --git a/dash/background_callback/managers/diskcache_manager.py b/dash/background_callback/managers/diskcache_manager.py index 80170af284..6312d20696 100644 --- a/dash/background_callback/managers/diskcache_manager.py +++ b/dash/background_callback/managers/diskcache_manager.py @@ -17,6 +17,10 @@ class DiskcacheManager(BaseBackgroundCallbackManager): """Manage the background execution of callbacks with subprocesses and a diskcache result backend.""" + # Without cache_by each run has its own result key, so a result nobody polls + # for (the page was closed or refreshed first) is never read and deleted. + UNCACHED_RESULT_EXPIRE = 24 * 60 * 60 + def __init__(self, cache=None, cache_by=None, expire=None): """ Background callback manager that runs callback logic in a subprocess and stores @@ -140,9 +144,13 @@ def call_job_fn(self, key, job_fn, args, context): from multiprocess import Process # type: ignore # pylint: disable-next=not-callable + expire = self.expire + if expire is None and self.cache_by is None: + expire = self.UNCACHED_RESULT_EXPIRE process = Process( target=job_fn, args=(key, self._make_progress_key(key), args, context), + kwargs={"expire": expire}, ) process.start() return process.pid @@ -217,12 +225,12 @@ def get_updated_props(self, key): # pylint: disable-next=too-many-statements def _make_job_fn(fn, cache, progress): # pylint: disable-next=too-many-statements - def job_fn(result_key, progress_key, user_callback_args, context): + def job_fn(result_key, progress_key, user_callback_args, context, expire=None): def _set_progress(progress_value): if not isinstance(progress_value, (list, tuple)): progress_value = [progress_value] - cache.set(progress_key, progress_value) + cache.set(progress_key, progress_value, expire=expire) maybe_progress = [_set_progress] if progress else [] @@ -231,7 +239,7 @@ def _set_props(_id, props): old = cache.get(key, {}) old_props = old.get(_id, {}) old[_id] = {**old_props, **props} - cache.set(key, old) + cache.set(key, old, expire=expire) ctx = copy_context() @@ -251,7 +259,9 @@ def run(): user_callback_output = fn(*maybe_progress, user_callback_args) except PreventUpdate: errored = True - cache.set(result_key, {"_dash_no_update": "_dash_no_update"}) + cache.set( + result_key, {"_dash_no_update": "_dash_no_update"}, expire=expire + ) except Exception as err: # pylint: disable=broad-except errored = True cache.set( @@ -262,10 +272,11 @@ def run(): "tb": traceback.format_exc(), } }, + expire=expire, ) if not errored: - cache.set(result_key, user_callback_output) + cache.set(result_key, user_callback_output, expire=expire) async def async_run(): c = AttributeDict(**context) @@ -287,7 +298,9 @@ async def async_run(): user_callback_output = await fn(*maybe_progress, user_callback_args) except PreventUpdate: errored = True - cache.set(result_key, {"_dash_no_update": "_dash_no_update"}) + cache.set( + result_key, {"_dash_no_update": "_dash_no_update"}, expire=expire + ) except Exception as err: # pylint: disable=broad-except errored = True cache.set( @@ -298,13 +311,14 @@ async def async_run(): "tb": traceback.format_exc(), } }, + expire=expire, ) if not errored: if asyncio.iscoroutine(user_callback_output): user_callback_output = await user_callback_output try: - cache.set(result_key, user_callback_output) + cache.set(result_key, user_callback_output, expire=expire) except Exception as err: # pylint: disable=broad-except print(f"Diskcache manager couldn't save output: {err}") diff --git a/dash/dash.py b/dash/dash.py index 0b37862157..8c3bf625ef 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -11,6 +11,7 @@ from importlib import metadata import pkgutil import threading +from contextvars import ContextVar import re import logging import mimetypes @@ -689,7 +690,13 @@ def __init__( # pylint: disable=too-many-statements, too-many-branches self._callback_list: list = [] self.callback_api_paths: dict = {} self.mcp_decorated_functions: dict = {} - self.mcp_callback_map: Any = None + # The collection serving the current MCP request, picked from + # _mcp_callback_maps by the configured cache_by key. + self._mcp_callback_map: ContextVar = ContextVar( + "mcp_callback_map", default=None + ) + self._mcp_callback_maps: collections.OrderedDict = collections.OrderedDict() + self._mcp_callback_maps_lock = threading.Lock() # list of inline scripts self._inline_scripts: list = [] @@ -998,6 +1005,14 @@ def _setup_plotlyjs(self): ) self._plotlyjs_url = url + @property + def mcp_callback_map(self) -> Any: + return self._mcp_callback_map.get() + + @mcp_callback_map.setter + def mcp_callback_map(self, value: Any): + self._mcp_callback_map.set(value) + @property def layout(self) -> Any: return self._layout diff --git a/dash/mcp/_configure.py b/dash/mcp/_configure.py index 2844483a64..698f5b1d3e 100644 --- a/dash/mcp/_configure.py +++ b/dash/mcp/_configure.py @@ -6,7 +6,7 @@ from __future__ import annotations -from typing import Optional +from typing import Any, Callable, Dict, List, Optional, Union from dash import get_app from dash.exceptions import AppNotFoundError @@ -32,14 +32,19 @@ _PAGE_RESOURCES = {PagesResource, PageLayoutResource} _LAYOUT_TOOLS = {GetDashComponentTool} -_DEFAULT_CONFIG = { +_DEFAULT_CONFIG: Dict[str, Any] = { "include_layout": True, "include_callbacks": True, "include_clientside_callbacks": True, "include_pages": True, "expose_callback_docstrings": False, + "cache_by": [], + "cache_maxsize": 128, } -_current_config = dict(_DEFAULT_CONFIG) +_current_config: Dict[str, Any] = dict(_DEFAULT_CONFIG) + +# None is a meaningful cache_maxsize (no limit), so "not passed" needs its own value. +_UNSET: Any = object() def configure_mcp_server( @@ -49,6 +54,8 @@ def configure_mcp_server( include_clientside_callbacks: Optional[bool] = None, include_pages: Optional[bool] = None, expose_callback_docstrings: Optional[bool] = None, + cache_by: Optional[Union[Callable[[], object], List[Callable[[], object]]]] = None, + cache_maxsize: Optional[int] = _UNSET, ) -> None: """ Configure which content the Dash MCP server exposes. @@ -69,6 +76,17 @@ def configure_mcp_server( ``dash://page-layout/{path}``. Defaults to ``True``. :param expose_callback_docstrings: Include callback docstrings in tool descriptions. Defaults to ``False``. + :param cache_by: Zero-argument functions, called during each MCP request, + whose return values key the server's cache of tool descriptions and + initial values (computed by walking the layout and running callbacks). + By default one cache is shared by every MCP client. If your layout or + callbacks depend on who is asking, pass a function that returns the + user or session id (a string or int) so each user gets their own + values, the same way ``cache_by`` works for background callbacks. + :param cache_maxsize: How many ``cache_by`` keys (for example users) keep + a cache at once; the least recently used is dropped past that. + Defaults to ``128``. ``None`` keeps every key for the life of the + process. Example — expose only ``@mcp_enabled``-decorated functions:: @@ -93,10 +111,13 @@ def configure_mcp_server( "include_clientside_callbacks": include_clientside_callbacks, "include_pages": include_pages, "expose_callback_docstrings": expose_callback_docstrings, + "cache_by": [cache_by] if callable(cache_by) else cache_by, } _current_config.update( {key: value for key, value in passed.items() if value is not None} ) + if cache_maxsize is not _UNSET: + _current_config["cache_maxsize"] = cache_maxsize CallbackTools.callbacks_mcp_enabled_by_default = _current_config[ "include_callbacks" @@ -125,9 +146,12 @@ def configure_mcp_server( tool for tool in _ALL_MCP_TOOL_PROVIDERS if tool not in excluded_tools ] - # Invalidate the cached callback map so it is rebuilt with the new config. + # Invalidate the cached callback maps so they are rebuilt with the new config. # No app yet (configured before `Dash()`) means there is no cache to clear. try: - get_app().mcp_callback_map = None + app = get_app() except AppNotFoundError: - pass + return + app.mcp_callback_map = None + with app._mcp_callback_maps_lock: # pylint: disable=protected-access + app._mcp_callback_maps.clear() # pylint: disable=protected-access diff --git a/dash/mcp/_server.py b/dash/mcp/_server.py index fdbf9bc81a..c314c8d5d4 100644 --- a/dash/mcp/_server.py +++ b/dash/mcp/_server.py @@ -26,6 +26,7 @@ ) from dash import get_app +from dash.mcp import _configure from dash.mcp._decorator import MCP_DECORATED_FUNCTIONS from dash.mcp.primitives import ( call_tool, @@ -259,10 +260,52 @@ def _process_mcp_message(data: dict[str, Any]) -> dict[str, Any] | None: request_id: str | int = _id if isinstance(_id, (str, int)) else "" app = get_app() - if app.mcp_callback_map is None: - app.mcp_callback_map = CallbackAdapterCollection(app) - app.mcp_decorated_functions = dict(MCP_DECORATED_FUNCTIONS) + app.mcp_decorated_functions = dict(MCP_DECORATED_FUNCTIONS) + try: + collection = _callback_map_for_request(app) + except Exception as e: # pylint: disable=broad-exception-caught + # Never fall back to a shared cache: it could serve another user's values. + logger.error("MCP cache_by failed: %s", e, exc_info=True) + return JSONRPCError( + jsonrpc="2.0", + id=request_id, + error=ErrorData(code=-32603, message=f"cache_by failed: {e}"), + ).model_dump(exclude_none=True) + # pylint: disable-next=protected-access + token = app._mcp_callback_map.set(collection) + try: + return _dispatch_mcp_message(method, params, request_id) + finally: + app._mcp_callback_map.reset(token) # pylint: disable=protected-access + +def _callback_map_for_request(app: Dash) -> CallbackAdapterCollection: + """Return the cached adapter collection for this request's cache_by key. + + The adapters lazily cache values computed in the request that first reads + them, so requests only share a collection when their cache_by values match. + """ + # pylint: disable-next=protected-access + cache_by = _configure._current_config["cache_by"] or [] + cache_key = str([fn() for fn in cache_by]) + # pylint: disable=protected-access + with app._mcp_callback_maps_lock: + maps = app._mcp_callback_maps + collection = maps.get(cache_key) + if collection is None: + collection = CallbackAdapterCollection(app) + maps[cache_key] = collection + maxsize = _configure._current_config["cache_maxsize"] + if maxsize is not None and len(maps) > maxsize: + maps.popitem(last=False) + else: + maps.move_to_end(cache_key) + return collection + + +def _dispatch_mcp_message( + method: str, params: dict[str, Any], request_id: str | int +) -> dict[str, Any] | None: mcp_methods = { "initialize": _handle_initialize, "tools/list": list_tools, diff --git a/tests/backend_tests/test_fastapi_before_request.py b/tests/backend_tests/test_fastapi_before_request.py new file mode 100644 index 0000000000..b094bbcd7f --- /dev/null +++ b/tests/backend_tests/test_fastapi_before_request.py @@ -0,0 +1,189 @@ +"""before_request hooks on the FastAPI backend gate every route, as on Flask +and Quart.""" +import pytest + +from dash import Dash, Input, Output, _get_app, hooks, html + +pytest.importorskip("fastapi") + + +@pytest.fixture(autouse=True) +def _fresh_app_context(): + # Lifespan resolves the app through get_app(); a context left by an earlier + # test would win over the app built here. + _get_app.app_context.set(None) + + +ROUTES = [ + ("GET", ""), + ("GET", "_dash-layout"), + ("GET", "_dash-dependencies"), + ("GET", "_reload-hash"), + ("GET", "healthz"), + ("GET", "secret-export"), + ("GET", "assets/load.css"), + ("POST", "api/echo"), + ("GET", "some/page"), +] + + +@pytest.fixture +def secret_route(): + @hooks.route(name="secret-export") + def secret_export(): + return "route secret" + + yield + hooks._ns["routes"] = [] + + +def _make_app(deny, assets): + (assets / "load.css").write_text("body {}") + app = Dash( + __name__, + backend="fastapi", + health_endpoint="healthz", + assets_folder=str(assets), + ) + app.layout = html.Div([html.Div("layout", id="o"), html.Button(id="b")]) + + @app.callback( + Output("o", "children"), Input("b", "n_clicks"), api_endpoint="/api/echo" + ) + def echo(n_clicks=None): + return "api" + + app.setup_apis() + + def check_auth(): + if app.backend.request_adapter().headers.get("authorization") != "good": + return deny() + return None + + app.backend.before_request(check_auth) + return app + + +def _deny_with_response(): + from starlette.responses import Response + + return Response("denied", status_code=401) + + +def _deny_with_tuple(): + return ("denied", 401) + + +def _deny_with_raise(): + from fastapi import HTTPException + + raise HTTPException(status_code=401, detail="denied") + + +@pytest.mark.parametrize( + "deny", [_deny_with_response, _deny_with_tuple, _deny_with_raise] +) +def test_fabr001_hook_gates_every_route(secret_route, deny, tmp_path): + from starlette.testclient import TestClient + + with TestClient(_make_app(deny, tmp_path).server) as client: + for method, route in ROUTES: + kwargs = {"json": {"n_clicks": 1}} if method == "POST" else {} + denied = client.request(method, f"/{route}", **kwargs) + assert denied.status_code == 401, f"{method} /{route} was not gated" + allowed = client.request( + method, f"/{route}", headers={"authorization": "good"}, **kwargs + ) + assert allowed.status_code == 200, f"{method} /{route}" + + +def test_fabr002_hook_gates_mounted_app(): + from fastapi import FastAPI + from starlette.testclient import TestClient + + app = Dash( + __name__, + backend="fastapi", + requests_pathname_prefix="/dash/", + routes_pathname_prefix="/", + ) + app.layout = html.Div("layout") + app.backend.before_request( + lambda: None + if app.backend.request_adapter().headers.get("authorization") == "good" + else ("denied", 401) + ) + parent = FastAPI() + parent.mount("/dash", app.server) + + with TestClient(parent) as client: + for route in ("_dash-layout", "_dash-dependencies", ""): + assert client.get(f"/dash/{route}").status_code == 401 + ok = client.get(f"/dash/{route}", headers={"authorization": "good"}) + assert ok.status_code == 200 + + +def test_fabr003_hook_gates_websocket_handshake(): + from starlette.testclient import TestClient + from starlette.websockets import WebSocketDisconnect + + app = Dash(__name__, backend="fastapi", websocket_callbacks=True) + app.layout = html.Div("layout") + app.backend.before_request( + lambda: None + if app.backend.request_adapter().headers.get("authorization") == "good" + else ("denied", 401) + ) + origin = {"origin": "http://testserver"} + + with TestClient(app.server) as client: + with pytest.raises(WebSocketDisconnect): + with client.websocket_connect("/_dash-ws-callback", headers=origin): + pass + with client.websocket_connect( + "/_dash-ws-callback", headers={**origin, "authorization": "good"} + ): + pass + + +@pytest.mark.parametrize( + "returned, status", + [ + (("blocked", {"X-Blocked": "1"}), 200), + (("blocked", [("X-Blocked", "1")]), 200), + (("blocked", "403 FORBIDDEN", {"X-Blocked": "1"}), 403), + ], +) +def test_fabr004_hook_returns_flask_style_tuples(returned, status): + from starlette.testclient import TestClient + + app = Dash(__name__, backend="fastapi") + app.layout = html.Div("layout") + app.backend.before_request(lambda: returned) + + with TestClient(app.server) as client: + r = client.get("/_dash-layout") + assert r.status_code == status + assert r.text == "blocked" + assert r.headers["X-Blocked"] == "1" + + +def test_fabr005_failing_hook_logs_and_rejects_websocket(caplog): + from starlette.testclient import TestClient + from starlette.websockets import WebSocketDisconnect + + app = Dash(__name__, backend="fastapi", websocket_callbacks=True) + app.layout = html.Div("layout") + + def broken(): + raise ValueError("hook bug") + + app.backend.before_request(broken) + + with TestClient(app.server) as client: + with pytest.raises(WebSocketDisconnect): + with client.websocket_connect( + "/_dash-ws-callback", headers={"origin": "http://testserver"} + ): + pass + assert "hook bug" in caplog.text diff --git a/tests/backend_tests/test_quart_before_request.py b/tests/backend_tests/test_quart_before_request.py new file mode 100644 index 0000000000..a8ae31fdb7 --- /dev/null +++ b/tests/backend_tests/test_quart_before_request.py @@ -0,0 +1,37 @@ +"""before_request hooks on the Quart backend also gate the WebSocket handshake.""" +import asyncio + +import pytest + +from dash import Dash, html + +pytest.importorskip("quart") + + +def test_qbr001_hook_gates_websocket_handshake(): + from quart.testing import WebsocketResponseError + + app = Dash(__name__, backend="quart", websocket_callbacks=True) + app.layout = html.Div("layout") + app.backend.before_request( + lambda: None + if app.backend.request_adapter().headers.get("authorization") == "good" + else ("denied", 401) + ) + origin = {"origin": "http://localhost"} + + async def connect(headers): + client = app.server.test_client() + try: + async with client.websocket("/_dash-ws-callback", headers=headers) as ws: + # The test client only surfaces a rejected handshake on receive. + try: + await asyncio.wait_for(ws.receive(), 1) + except asyncio.TimeoutError: + pass + return "accepted" + except WebsocketResponseError as err: + return err.response.status_code + + assert asyncio.run(connect(origin)) == 401 + assert asyncio.run(connect({**origin, "authorization": "good"})) == "accepted" diff --git a/tests/integration/mcp/test_mcp_request_isolation.py b/tests/integration/mcp/test_mcp_request_isolation.py new file mode 100644 index 0000000000..4d3e723ecd --- /dev/null +++ b/tests/integration/mcp/test_mcp_request_isolation.py @@ -0,0 +1,167 @@ +"""The MCP server caches tool descriptions and initial values. With +``cache_by`` keyed on the user, values computed for one user are not served to +another.""" + +import base64 +import json + +import flask +import pytest + +from dash import Dash, Input, Output, dcc, html +from dash.mcp import configure_mcp_server + +USERS = {"alice": "pw-a", "bob": "pw-b"} +SECRETS = {"alice": "alice-secret-111", "bob": "bob-secret-222"} + + +def _auth_header(user): + token = base64.b64encode(f"{user}:{USERS[user]}".encode()).decode() + return {"Authorization": f"Basic {token}", "Content-Type": "application/json"} + + +def _add_basic_auth(app): + @app.server.before_request + def basic_auth(): + auth = flask.request.authorization + if not auth or USERS.get(auth.username) != auth.password: + return flask.Response("auth required", 401) + flask.g.user = auth.username + return None + + +def _rpc(client, user, method, params=None): + r = client.post( + "/_mcp", + headers=_auth_header(user), + data=json.dumps( + {"jsonrpc": "2.0", "id": 1, "method": method, "params": params or {}} + ), + ) + assert r.status_code == 200 + return json.dumps(r.get_json()) + + +def test_mcpi001_callback_initial_output_is_per_user(): + app = Dash(__name__, enable_mcp=True) + configure_mcp_server(cache_by=lambda: flask.g.user) + app.layout = html.Div( + [ + dcc.Location(id="url"), + html.Div(id="profile"), + dcc.Input(id="note", value="hi"), + html.Div(id="summary"), + ] + ) + _add_basic_auth(app) + + @app.callback(Output("profile", "children"), Input("url", "pathname")) + def load_profile(_pathname): + return f"record: {SECRETS[flask.g.user]}" + + @app.callback( + Output("summary", "children"), + Input("profile", "children"), + Input("note", "value"), + ) + def summarize(profile, note): + return f"{profile} / {note}" + + client = app.server.test_client() + get_profile = { + "name": "get_dash_component", + "arguments": {"component_id": "profile"}, + } + + alice = _rpc(client, "alice", "tools/call", get_profile) + assert SECRETS["alice"] in alice + + bob = _rpc(client, "bob", "tools/call", get_profile) + assert SECRETS["alice"] not in bob + assert SECRETS["bob"] in bob + + bob_tools = _rpc(client, "bob", "tools/list") + assert SECRETS["alice"] not in bob_tools + + +def test_mcpi002_layout_function_values_are_per_user(): + app = Dash(__name__, enable_mcp=True, suppress_callback_exceptions=True) + configure_mcp_server(cache_by=[lambda: flask.g.user]) + + def serve_layout(): + user = getattr(flask.g, "user", None) if flask.has_app_context() else None + return html.Div( + [dcc.Input(id="apikey", value=SECRETS.get(user, "")), html.Div(id="out")] + ) + + app.layout = serve_layout + _add_basic_auth(app) + + @app.callback(Output("out", "children"), Input("apikey", "value")) + def use_key(apikey): + return "ok" if apikey else "no key" + + client = app.server.test_client() + assert SECRETS["alice"] in _rpc(client, "alice", "tools/list") + + bob_tools = _rpc(client, "bob", "tools/list") + assert SECRETS["alice"] not in bob_tools + assert SECRETS["bob"] in bob_tools + + +def test_mcpi003_cache_is_reused_across_requests(): + app = Dash(__name__, enable_mcp=True) + app.layout = html.Div([dcc.Input(id="in", value="x"), html.Div(id="out")]) + _add_basic_auth(app) + runs = [] + + @app.callback(Output("out", "children"), Input("in", "value")) + def echo(value): + runs.append(value) + return value + + client = app.server.test_client() + get_out = {"name": "get_dash_component", "arguments": {"component_id": "out"}} + _rpc(client, "alice", "tools/call", get_out) + _rpc(client, "bob", "tools/call", get_out) + _rpc(client, "alice", "tools/list") + assert len(runs) == 1 + + +def test_mcpi004_failing_cache_by_returns_error(): + app = Dash(__name__, enable_mcp=True) + app.layout = html.Div([dcc.Input(id="in", value="x"), html.Div(id="out")]) + _add_basic_auth(app) + + def broken(): + raise KeyError("no user") + + configure_mcp_server(cache_by=broken) + + @app.callback(Output("out", "children"), Input("in", "value")) + def echo(value): + return value + + body = json.loads(_rpc(app.server.test_client(), "alice", "tools/list")) + assert body["error"]["code"] == -32603 + assert "result" not in body + + +@pytest.mark.parametrize("maxsize, expected_runs", [(1, 3), (None, 2)]) +def test_mcpi005_cache_maxsize(maxsize, expected_runs): + app = Dash(__name__, enable_mcp=True) + app.layout = html.Div([dcc.Input(id="in", value="x"), html.Div(id="out")]) + _add_basic_auth(app) + configure_mcp_server(cache_by=lambda: flask.g.user, cache_maxsize=maxsize) + runs = [] + + @app.callback(Output("out", "children"), Input("in", "value")) + def echo(value): + runs.append(value) + return value + + client = app.server.test_client() + get_out = {"name": "get_dash_component", "arguments": {"component_id": "out"}} + for user in ("alice", "bob", "alice"): + _rpc(client, user, "tools/call", get_out) + assert len(runs) == expected_runs diff --git a/tests/integration/multi_page/test_pages_path_template.py b/tests/integration/multi_page/test_pages_path_template.py new file mode 100644 index 0000000000..202600db0a --- /dev/null +++ b/tests/integration/multi_page/test_pages_path_template.py @@ -0,0 +1,64 @@ +import time + +import dash +from dash import Dash, html + + +def _make_app(): + app = Dash(__name__, use_pages=True, pages_folder="") + dash.register_page("home", path="/", layout=html.Div("home")) + dash.register_page( + "asset", + path_template="/asset//view", + layout=lambda asset_id=None, **_: html.Div(f"asset:{asset_id}", id="asset-out"), + ) + dash.register_page( + "cmp", + path_template="/cmp////view", + layout=lambda a=None, b=None, c=None, **_: html.Div(f"cmp:{a},{b},{c}"), + ) + app.layout = html.Div(dash.page_container) + return app + + +def _route(client, pathname): + body = { + "output": ".._pages_content.children..._pages_store.data..", + "outputs": [ + {"id": "_pages_content", "property": "children"}, + {"id": "_pages_store", "property": "data"}, + ], + "inputs": [ + {"id": "_pages_location", "property": "pathname", "value": pathname}, + {"id": "_pages_location", "property": "search", "value": ""}, + ], + "changedPropIds": ["_pages_location.pathname"], + "state": [], + } + start = time.time() + r = client.post("/_dash-update-component", json=body) + return r, time.time() - start + + +def test_pgpt001_path_template_variables(dash_duo): + app = _make_app() + dash_duo.start_server(app) + dash_duo.wait_for_page(f"{dash_duo.server_url}/asset/a1/view") + dash_duo.wait_for_text_to_equal("#asset-out", "asset:a1") + assert dash_duo.get_logs() == [] + + +def test_pgpt002_newline_pathname_is_rejected_fast(): + app = _make_app() + client = app.server.test_client() + client.get("/") + + r, _ = _route(client, "/asset/a\nb/view") + assert r.status_code == 200 + + r, elapsed = _route(client, "/cmp/" + "/" * 2000 + "\n/view") + assert r.status_code == 200 + assert elapsed < 1 + + r, _ = _route(client, "/cmp/x/y/z/view") + assert "cmp:x,y,z" in r.get_data(as_text=True) diff --git a/tests/integration/security/test_background_isolation.py b/tests/integration/security/test_background_isolation.py new file mode 100644 index 0000000000..421af8c867 --- /dev/null +++ b/tests/integration/security/test_background_isolation.py @@ -0,0 +1,149 @@ +import json +import re +import tempfile +import time + +import pytest + +from dash import Dash, DiskcacheManager, Input, Output, callback_context, dcc, html + +diskcache = pytest.importorskip("diskcache") + + +def _end_id(client): + index = client.get("/").get_data(as_text=True) + config = re.search(r'id="_dash-config"[^>]*>(.*?)', index, re.S) + return json.loads(config.group(1))["end_id"] + + +def _body(name): + return { + "output": f"out-{name}.children", + "outputs": {"id": f"out-{name}", "property": "children"}, + "inputs": [{"id": f"in-{name}", "property": "value", "value": "go"}], + "changedPropIds": [f"in-{name}.value"], + "state": [], + } + + +def _start(client, end_id, body): + return client.post( + "/_dash-update-component", json=body, query_string={"endId": end_id} + ).get_json() + + +def _poll(client, end_id, body, handles): + query = {"endId": end_id, "cacheKey": handles["cacheKey"], "job": handles["job"]} + r = client.post("/_dash-update-component", json=body, query_string=query) + return r.get_data(as_text=True) + + +def _poll_until_done(client, end_id, body, handles): + for _ in range(50): + out = _poll(client, end_id, body, handles) + if '"response"' in out: + return out + time.sleep(0.2) + raise AssertionError("background callback never finished") + + +def test_bgiso001_equal_inputs_do_not_share_results(): + manager = DiskcacheManager(diskcache.Cache(tempfile.mkdtemp())) + app = Dash(__name__, background_callback_manager=manager) + app.layout = html.Div( + [dcc.Input(id="in-report", value="go"), html.Div(id="out-report")] + ) + + @app.callback( + Output("out-report", "children"), + Input("in-report", "value"), + background=True, + prevent_initial_call=True, + ) + def report(_value): + return f"report for {callback_context.cookies.get('user')}" + + alice = app.server.test_client() + alice.set_cookie("user", "alice") + mallory = app.server.test_client() + mallory.set_cookie("user", "mallory") + alice_end, mallory_end = _end_id(alice), _end_id(mallory) + body = _body("report") + + alice_handles = _start(alice, alice_end, body) + time.sleep(1.5) + + mallory_handles = _start(mallory, mallory_end, body) + assert mallory_handles["cacheKey"] != alice_handles["cacheKey"] + assert "alice" not in _poll_until_done(mallory, mallory_end, body, mallory_handles) + + assert "report for alice" in _poll_until_done(alice, alice_end, body, alice_handles) + + +def test_bgiso002_callbacks_from_one_factory_do_not_share_results(): + manager = DiskcacheManager( + diskcache.Cache(tempfile.mkdtemp()), cache_by=[lambda: "fixed"] + ) + app = Dash(__name__, background_callback_manager=manager) + tables = {"public": "public stats", "admin": "admin payroll"} + app.layout = html.Div( + [ + dcc.Input(id="in-public", value="go"), + html.Div(id="out-public"), + dcc.Input(id="in-admin", value="go"), + html.Div(id="out-admin"), + ] + ) + + def make(name): + @app.callback( + Output(f"out-{name}", "children"), + Input(f"in-{name}", "value"), + background=True, + prevent_initial_call=True, + ) + def render(_value): + return tables[name] + + make("admin") + make("public") + + client = app.server.test_client() + end_id = _end_id(client) + + admin = _start(client, end_id, _body("admin")) + assert "admin payroll" in _poll_until_done(client, end_id, _body("admin"), admin) + + public = _start(client, end_id, _body("public")) + assert public["cacheKey"] != admin["cacheKey"] + out = _poll_until_done(client, end_id, _body("public"), public) + assert "public stats" in out + assert "admin payroll" not in out + + +def test_bgiso003_unread_results_expire_without_cache_by(): + manager = DiskcacheManager(diskcache.Cache(tempfile.mkdtemp())) + app = Dash(__name__, background_callback_manager=manager) + app.layout = html.Div([dcc.Input(id="in-x", value="go"), html.Div(id="out-x")]) + + @app.callback( + Output("out-x", "children"), + Input("in-x", "value"), + background=True, + prevent_initial_call=True, + ) + def slow(value): + return value + + client = app.server.test_client() + handles = _start(client, _end_id(client), _body("x")) + raw_key = handles["cacheKey"].split("~")[0] + + # Nobody polls (the page was closed): the stored result must still expire. + for _ in range(50): + _, expire_time = manager.handle.get(raw_key, expire_time=True) + if expire_time is not None: + break + time.sleep(0.2) + remaining = expire_time - time.time() + assert 0 < remaining <= manager.UNCACHED_RESULT_EXPIRE diff --git a/tests/integration/security/test_xss.py b/tests/integration/security/test_xss.py index fe1341d860..0f877a5370 100644 --- a/tests/integration/security/test_xss.py +++ b/tests/integration/security/test_xss.py @@ -1,4 +1,5 @@ -from dash import Dash, html, dcc +from dash import Dash, Input, Output, ctx, dcc, html +from dash.testing.wait import until def test_xss001_banned_protocols(dash_duo): @@ -91,3 +92,68 @@ def test_xss003_data_allowed(dash_duo): element = dash_duo.find_element("#image") assert element.get_attribute("src") != "about:blank" assert dash_duo.get_logs() == [] + + +def test_xss004_area_href(dash_duo): + app = Dash() + app.layout = html.MapEl( + html.Area(href="javascript:alert(1)", id="area", shape="default"), + name="map", + ) + + dash_duo.start_server(app) + dash_duo.wait_for_element("#area") + assert dash_duo.find_element("#area").get_attribute("href") == "about:blank" + + +def test_xss005_location_refresh_href(dash_duo): + app = Dash() + app.layout = html.Div( + [ + dcc.Location(id="url", refresh=True), + html.Button("bad", id="bad"), + html.Button("good", id="good"), + ] + ) + + @app.callback( + Output("url", "href"), + Input("bad", "n_clicks"), + Input("good", "n_clicks"), + prevent_initial_call=True, + ) + def navigate(_bad, _good): + if ctx.triggered_id == "bad": + return "javascript:void(window.xss_ran = true)" + return "/next" + + dash_duo.start_server( + app, + debug=True, + use_reloader=False, + use_debugger=True, + dev_tools_hot_reload=False, + ) + dash_duo.wait_for_element("#bad").click() + dash_duo.wait_for_text_to_equal( + ".dash-fe-error__title", + "Dangerous link detected: javascript:void(window.xss_ran = true)", + ) + assert dash_duo.driver.execute_script("return window.xss_ran") is None + + dash_duo.find_element("#good").click() + until(lambda: dash_duo.driver.current_url.endswith("/next"), timeout=5) + + +def test_xss006_base_href(dash_duo): + app = Dash() + app.layout = html.Div( + [ + html.Div("ready", id="ready"), + html.Base(href="javascript:alert(1)", id="base"), + ] + ) + + dash_duo.start_server(app) + dash_duo.wait_for_text_to_equal("#ready", "ready") + assert dash_duo.find_element("#base").get_attribute("href") == "about:blank" diff --git a/tests/unit/test_async_background_callback_job_fn.py b/tests/unit/test_async_background_callback_job_fn.py index de93c9c5ed..04ffa8d8e7 100644 --- a/tests/unit/test_async_background_callback_job_fn.py +++ b/tests/unit/test_async_background_callback_job_fn.py @@ -25,7 +25,7 @@ class FakeCache: def __init__(self): self.store = {} - def set(self, key, value): + def set(self, key, value, expire=None): # pylint: disable=unused-argument self.store[key] = value def get(self, key, default=None):