From 232024663239a799ce9c5994dcdad30003f70f73 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Thu, 19 Mar 2026 01:01:25 +0500 Subject: [PATCH 01/12] feat: add chunked file upload support Streaming Upload API (rx.upload_files_chunk) Implement chunked/streaming file uploads to handle large files without loading them entirely into memory. Moves upload handling logic from app.py to event.py, adds chunked upload JS helpers, and updates the upload component to support the new upload_files_chunk API. Includes unit and integration tests for chunked upload, cancel, and streaming. --- pyi_hashes.json | 4 +- reflex/.templates/web/utils/helpers/upload.js | 188 ++++-- reflex/.templates/web/utils/state.js | 18 +- reflex/__init__.py | 4 + reflex/app.py | 226 +------- reflex/components/core/upload.py | 23 +- reflex/constants/event.py | 1 + reflex/event.py | 334 ++++++++++- reflex/uploads.py | 537 ++++++++++++++++++ tests/integration/test_upload.py | 211 +++++++ tests/units/components/core/test_upload.py | 121 +++- tests/units/states/upload.py | 51 ++ tests/units/test_app.py | 220 ++++++- 13 files changed, 1631 insertions(+), 307 deletions(-) create mode 100644 reflex/uploads.py diff --git a/pyi_hashes.json b/pyi_hashes.json index ec9bbff8850..a23f3696548 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -1,5 +1,5 @@ { - "reflex/__init__.pyi": "0a3ae880e256b9fd3b960e12a2cb51a7", + "reflex/__init__.pyi": "70485139882c5c114c121445a24c7b28", "reflex/components/__init__.pyi": "ac05995852baa81062ba3d18fbc489fb", "reflex/components/base/__init__.pyi": "16e47bf19e0d62835a605baa3d039c5a", "reflex/components/base/app_wrap.pyi": "22e94feaa9fe675bcae51c412f5b67f1", @@ -19,7 +19,7 @@ "reflex/components/core/helmet.pyi": "43f8497c8fafe51e29dca1dd535d143a", "reflex/components/core/html.pyi": "86eb9d4c1bb4807547b2950d9a32e9fd", "reflex/components/core/sticky.pyi": "cb763b986a9b0654d1a3f33440dfcf60", - "reflex/components/core/upload.pyi": "6dc28804a6dddf903e31162e87c1b023", + "reflex/components/core/upload.pyi": "58c023b9149635894331528bf29eaf13", "reflex/components/core/window_events.pyi": "af33ccec866b9540ee7fbec6dbfbd151", "reflex/components/datadisplay/__init__.pyi": "52755871369acbfd3a96b46b9a11d32e", "reflex/components/datadisplay/code.pyi": "b86769987ef4d1cbdddb461be88539fd", diff --git a/reflex/.templates/web/utils/helpers/upload.js b/reflex/.templates/web/utils/helpers/upload.js index 6bbfc746ed6..5732f0668b8 100644 --- a/reflex/.templates/web/utils/helpers/upload.js +++ b/reflex/.templates/web/utils/helpers/upload.js @@ -1,47 +1,11 @@ import JSON5 from "json5"; import env from "$/env.json"; -/** - * Upload files to the server. - * - * @param state The state to apply the delta to. - * @param handler The handler to use. - * @param upload_id The upload id to use. - * @param on_upload_progress The function to call on upload progress. - * @param socket the websocket connection - * @param extra_headers Extra headers to send with the request. - * @param refs The refs object to store the abort controller in. - * @param getBackendURL Function to get the backend URL. - * @param getToken Function to get the Reflex token. - * - * @returns The response from posting to the UPLOADURL endpoint. - */ -export const uploadFiles = async ( - handler, - files, - upload_id, - on_upload_progress, - extra_headers, - socket, - refs, - getBackendURL, - getToken, -) => { - // return if there's no file to upload - if (files === undefined || files.length === 0) { - return false; - } - - const upload_ref_name = `__upload_controllers_${upload_id}`; - - if (refs[upload_ref_name]) { - console.log("Upload already in progress for ", upload_id); - return false; - } - +const trackUploadResponse = (socket) => { // Track how many partial updates have been processed for this upload. let resp_idx = 0; - const eventHandler = (progressEvent) => { + + return (progressEvent) => { const event_callbacks = socket._callbacks.$event; // Whenever called, responseText will contain the entire response so far. const chunks = progressEvent.event.target.responseText.trim().split("\n"); @@ -73,22 +37,32 @@ export const uploadFiles = async ( } }); }; +}; - const controller = new AbortController(); - const formdata = new FormData(); +const sendUploadRequest = async ({ + handler, + upload_id, + on_upload_progress, + extra_headers, + refs, + getToken, + formdata, + url, + responseHandler, +}) => { + const upload_ref_name = `__upload_controllers_${upload_id}`; - // Add the token and handler to the file name. - files.forEach((file) => { - formdata.append("files", file, file.path || file.name); - }); + if (refs[upload_ref_name]) { + return false; + } + + const controller = new AbortController(); - // Send the file to the server. refs[upload_ref_name] = controller; return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); - // Set up event handlers xhr.onload = function () { if (xhr.status >= 200 && xhr.status < 300) { resolve({ @@ -112,42 +86,36 @@ export const uploadFiles = async ( reject(new Error("Upload aborted")); }; - // Handle upload progress if (on_upload_progress) { xhr.upload.onprogress = function (event) { if (event.lengthComputable) { - const progressEvent = { + on_upload_progress({ loaded: event.loaded, total: event.total, progress: event.loaded / event.total, - }; - on_upload_progress(progressEvent); + }); } }; } - // Handle download progress with streaming response parsing - xhr.onprogress = function (event) { - if (eventHandler) { - const progressEvent = { + if (responseHandler) { + xhr.onprogress = function (event) { + responseHandler({ event: { target: { responseText: xhr.responseText, }, }, progress: event.lengthComputable ? event.loaded / event.total : 0, - }; - eventHandler(progressEvent); - } - }; + }); + }; + } - // Handle abort controller controller.signal.addEventListener("abort", () => { xhr.abort(); }); - // Configure and send request - xhr.open("POST", getBackendURL(env.UPLOAD)); + xhr.open("POST", url); xhr.setRequestHeader("Reflex-Client-Token", getToken()); xhr.setRequestHeader("Reflex-Event-Handler", handler); for (const [key, value] of Object.entries(extra_headers || {})) { @@ -168,3 +136,99 @@ export const uploadFiles = async ( delete refs[upload_ref_name]; }); }; + +/** + * Upload files to the server. + * + * @param handler The handler to use. + * @param upload_id The upload id to use. + * @param on_upload_progress The function to call on upload progress. + * @param extra_headers Extra headers to send with the request. + * @param socket The websocket connection. + * @param refs The refs object to store the abort controller in. + * @param getBackendURL Function to get the backend URL. + * @param getToken Function to get the Reflex token. + * + * @returns The response from posting to the upload endpoint. + */ +export const uploadFiles = async ( + handler, + files, + upload_id, + on_upload_progress, + extra_headers, + socket, + refs, + getBackendURL, + getToken, +) => { + if (files === undefined || files.length === 0) { + return false; + } + + const formdata = new FormData(); + + files.forEach((file) => { + formdata.append("files", file, file.path || file.name); + }); + + return sendUploadRequest({ + handler, + upload_id, + on_upload_progress, + extra_headers, + refs, + getToken, + formdata, + url: getBackendURL(env.UPLOAD), + responseHandler: trackUploadResponse(socket), + }); +}; + +/** + * Upload files to the streaming chunk endpoint. + * + * @param handler The handler to use. + * @param files The files to upload. + * @param upload_id The upload id to use. + * @param on_upload_progress The function to call on upload progress. + * @param extra_headers Extra headers to send with the request. + * @param _socket The websocket connection. + * @param refs The refs object to store the abort controller in. + * @param getBackendURL Function to get the backend URL. + * @param getToken Function to get the Reflex token. + * + * @returns The response from posting to the chunk upload endpoint. + */ +export const uploadFilesChunk = async ( + handler, + files, + upload_id, + on_upload_progress, + extra_headers, + _socket, + refs, + getBackendURL, + getToken, +) => { + if (files === undefined || files.length === 0) { + return false; + } + + const formdata = new FormData(); + + files.forEach((file) => { + formdata.append("files", file, file.path || file.name); + }); + + return sendUploadRequest({ + handler, + upload_id, + on_upload_progress, + extra_headers, + refs, + getToken, + formdata, + url: getBackendURL(env.UPLOAD_CHUNK), + }); +}; diff --git a/reflex/.templates/web/utils/state.js b/reflex/.templates/web/utils/state.js index 9e937ed62cd..f54b05a7523 100644 --- a/reflex/.templates/web/utils/state.js +++ b/reflex/.templates/web/utils/state.js @@ -20,7 +20,7 @@ import { } from "$/utils/context"; import debounce from "$/utils/helpers/debounce"; import throttle from "$/utils/helpers/throttle"; -import { uploadFiles } from "$/utils/helpers/upload"; +import { uploadFiles, uploadFilesChunk } from "$/utils/helpers/upload"; // Endpoint URLs. const EVENTURL = env.EVENT; @@ -418,11 +418,15 @@ export const applyEvent = async (event, socket, navigate, params) => { */ export const applyRestEvent = async (event, socket, navigate, params) => { let eventSent = false; - if (event.handler === "uploadFiles") { - if (event.payload.files === undefined || event.payload.files.length === 0) { + if (event.handler === "uploadFiles" || event.handler === "uploadFilesChunk") { + const filePayloadKey = event.payload.upload_param_name || "files"; + const uploadFilesPayload = + event.payload.files ?? event.payload[filePayloadKey]; + + if (uploadFilesPayload === undefined || uploadFilesPayload.length === 0) { // Submit the event over the websocket to trigger the event handler. return await applyEvent( - ReflexEvent(event.name, { files: [] }), + ReflexEvent(event.name, { [filePayloadKey]: [] }), socket, navigate, params, @@ -430,9 +434,11 @@ export const applyRestEvent = async (event, socket, navigate, params) => { } // Start upload, but do not wait for it, which would block other events. - uploadFiles( + const uploadFn = + event.handler === "uploadFilesChunk" ? uploadFilesChunk : uploadFiles; + uploadFn( event.name, - event.payload.files, + uploadFilesPayload, event.payload.upload_id, event.payload.on_upload_progress, event.payload.extra_headers, diff --git a/reflex/__init__.py b/reflex/__init__.py index 066df110f02..6342acfa723 100644 --- a/reflex/__init__.py +++ b/reflex/__init__.py @@ -301,6 +301,8 @@ "event", "EventChain", "EventHandler", + "UploadChunk", + "UploadChunkIterator", "call_script", "call_function", "run_script", @@ -320,6 +322,7 @@ "set_value", "stop_propagation", "upload_files", + "upload_files_chunk", "window_alert", ], "istate.storage": [ @@ -348,6 +351,7 @@ _SUBMODULES: set[str] = { "components", "app", + "uploads", "style", "admin", "base", diff --git a/reflex/app.py b/reflex/app.py index 54682543a7d..abebff64f3e 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -18,7 +18,6 @@ from collections.abc import ( AsyncGenerator, AsyncIterator, - Awaitable, Callable, Coroutine, Mapping, @@ -29,18 +28,15 @@ from pathlib import Path from timeit import default_timer as timer from types import SimpleNamespace -from typing import TYPE_CHECKING, Any, BinaryIO, ParamSpec, get_args, get_type_hints +from typing import TYPE_CHECKING, Any, ParamSpec from rich.progress import MofNCompleteColumn, Progress, TimeElapsedColumn from socketio import ASGIApp as EngineIOApp from socketio import AsyncNamespace, AsyncServer from starlette.applications import Starlette -from starlette.datastructures import Headers -from starlette.datastructures import UploadFile as StarletteUploadFile -from starlette.exceptions import HTTPException from starlette.middleware import cors -from starlette.requests import ClientDisconnect, Request -from starlette.responses import JSONResponse, Response, StreamingResponse +from starlette.requests import Request +from starlette.responses import JSONResponse, Response from starlette.staticfiles import StaticFiles from reflex import constants @@ -101,6 +97,8 @@ all_base_state_classes, code_uses_state_contexts, ) +from reflex.uploads import UploadFile as UploadFile +from reflex.uploads import upload, upload_chunk from reflex.utils import ( codespaces, console, @@ -110,7 +108,6 @@ js_runtimes, path_ops, prerequisites, - types, ) from reflex.utils.exec import ( get_compile_context, @@ -245,46 +242,6 @@ def default_error_boundary(*children: Component, **props) -> Component: ) -@dataclasses.dataclass(frozen=True) -class UploadFile(StarletteUploadFile): - """A file uploaded to the server. - - Args: - file: The standard Python file object (non-async). - filename: The original file name. - size: The size of the file in bytes. - headers: The headers of the request. - """ - - file: BinaryIO - - path: Path | None = dataclasses.field(default=None) - - size: int | None = dataclasses.field(default=None) - - headers: Headers = dataclasses.field(default_factory=Headers) - - @property - def filename(self) -> str | None: - """Get the name of the uploaded file. - - Returns: - The name of the uploaded file. - """ - return self.name - - @property - def name(self) -> str | None: - """Get the name of the uploaded file. - - Returns: - The name of the uploaded file. - """ - if self.path: - return self.path.name - return None - - @dataclasses.dataclass( frozen=True, ) @@ -706,6 +663,11 @@ def _add_optional_endpoints(self): upload(self), methods=["POST"], ) + self._api.add_route( + str(constants.Endpoint.UPLOAD_CHUNK), + upload_chunk(self), + methods=["POST"], + ) # To access uploaded files. self._api.mount( @@ -1891,174 +1853,6 @@ async def health(_request: Request) -> JSONResponse: return JSONResponse(content=health_status, status_code=status_code) -class _UploadStreamingResponse(StreamingResponse): - """Streaming response that always releases upload form resources.""" - - _on_finish: Callable[[], Awaitable[None]] - - def __init__( - self, - *args: Any, - on_finish: Callable[[], Awaitable[None]], - **kwargs: Any, - ) -> None: - super().__init__(*args, **kwargs) - self._on_finish = on_finish - - async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: - try: - await super().__call__(scope, receive, send) - finally: - await self._on_finish() - - -def upload(app: App): - """Upload a file. - - Args: - app: The app to upload the file for. - - Returns: - The upload function. - """ - - async def upload_file(request: Request): - """Upload a file. - - Args: - request: The Starlette request object. - - Returns: - StreamingResponse yielding newline-delimited JSON of StateUpdate - emitted by the upload handler. - - Raises: - UploadValueError: if there are no args with supported annotation. - UploadTypeError: if a background task is used as the handler. - HTTPException: when the request does not include token / handler headers. - """ - from reflex.utils.exceptions import UploadTypeError, UploadValueError - - # Get the files from the request. - try: - form_data = await request.form() - except ClientDisconnect: - return Response() # user cancelled - - form_data_closed = False - - async def _close_form_data() -> None: - """Close the parsed form data exactly once.""" - nonlocal form_data_closed - if form_data_closed: - return - form_data_closed = True - await form_data.close() - - async def _create_upload_event() -> Event: - """Create an upload event using the live Starlette temp files. - - Returns: - The upload event backed by the original temp files. - """ - files = form_data.getlist("files") - if not files: - msg = "No files were uploaded." - raise UploadValueError(msg) - - token = request.headers.get("reflex-client-token") - handler = request.headers.get("reflex-event-handler") - - if not token or not handler: - raise HTTPException( - status_code=400, - detail="Missing reflex-client-token or reflex-event-handler header.", - ) - - # Get the state for the session. - substate_token = _substate_key(token, handler.rpartition(".")[0]) - state = await app.state_manager.get_state(substate_token) - - handler_upload_param = () - - _current_state, event_handler = state._get_event_handler(handler) - - if event_handler.is_background: - msg = f"@rx.event(background=True) is not supported for upload handler `{handler}`." - raise UploadTypeError(msg) - func = event_handler.fn - if isinstance(func, functools.partial): - func = func.func - for k, v in get_type_hints(func).items(): - if types.is_generic_alias(v) and types._issubclass( - get_args(v)[0], - UploadFile, - ): - handler_upload_param = (k, v) - break - - if not handler_upload_param: - msg = ( - f"`{handler}` handler should have a parameter annotated as " - "list[rx.UploadFile]" - ) - raise UploadValueError(msg) - - # Keep the parsed form data alive until the upload event finishes so - # the underlying Starlette temp files remain available to the handler. - file_uploads = [] - for file in files: - if not isinstance(file, StarletteUploadFile): - raise UploadValueError( - "Uploaded file is not an UploadFile." + str(file) - ) - file_uploads.append( - UploadFile( - file=file.file, - path=Path(file.filename.lstrip("/")) if file.filename else None, - size=file.size, - headers=file.headers, - ) - ) - - return Event( - token=token, - name=handler, - payload={handler_upload_param[0]: file_uploads}, - ) - - event: Event | None = None - try: - event = await _create_upload_event() - finally: - if event is None: - await _close_form_data() - - async def _ndjson_updates(): - """Process the upload event, generating ndjson updates. - - Yields: - Each state update as JSON followed by a new line. - """ - # Process the event. - async with app.state_manager.modify_state_with_links( - event.substate_token - ) as state: - async for update in state._process(event): - # Postprocess the event. - update = await app._postprocess(state, event, update) - yield update.json() + "\n" - - # Stream updates to client - return _UploadStreamingResponse( - _ndjson_updates(), - media_type="application/x-ndjson", - on_finish=_close_form_data, - ) - - return upload_file - - class EventNamespace(AsyncNamespace): """The event namespace.""" diff --git a/reflex/components/core/upload.py b/reflex/components/core/upload.py index 670112fad33..cc423a4459e 100644 --- a/reflex/components/core/upload.py +++ b/reflex/components/core/upload.py @@ -27,6 +27,7 @@ EventChain, EventHandler, EventSpec, + UploadChunkIterator, call_event_fn, call_event_handler, parse_args_spec, @@ -172,6 +173,10 @@ def get_upload_url(file_path: str | Var[str]) -> Var[str]: _on_drop_spec = passthrough_event_spec(list[UploadFile]) +_on_drop_args_spec = ( + _on_drop_spec, + passthrough_event_spec(UploadChunkIterator), +) def _default_drop_rejected(rejected_files: ArrayVar[list[dict[str, Any]]]) -> EventSpec: @@ -212,10 +217,10 @@ class GhostUpload(Fragment): """A ghost upload component.""" # Fired when files are dropped. - on_drop: EventHandler[_on_drop_spec] + on_drop: EventHandler[_on_drop_args_spec] # Fired when dropped files do not meet the specified criteria. - on_drop_rejected: EventHandler[_on_drop_spec] + on_drop_rejected: EventHandler[_on_drop_args_spec] class Upload(MemoizationLeaf): @@ -258,10 +263,10 @@ class Upload(MemoizationLeaf): is_used: ClassVar[bool] = False # Fired when files are dropped. - on_drop: EventHandler[_on_drop_spec] + on_drop: EventHandler[_on_drop_args_spec] # Fired when dropped files do not meet the specified criteria. - on_drop_rejected: EventHandler[_on_drop_spec] + on_drop_rejected: EventHandler[_on_drop_args_spec] # Style rules to apply when actively dragging. drag_active_style: Style | None = field(default=None, is_javascript_property=False) @@ -310,11 +315,15 @@ def create(cls, *children, **props) -> Component: if isinstance(event, EventHandler): event = event(upload_files(upload_id)) if isinstance(event, EventSpec): - # Call the lambda to get the event chain. - event = call_event_handler(event, _on_drop_spec) + if event.client_handler_name not in { + "uploadFiles", + "uploadFilesChunk", + }: + # Call the lambda to get the event chain. + event = call_event_handler(event, _on_drop_args_spec) elif isinstance(event, Callable): # Call the lambda to get the event chain. - event = call_event_fn(event, _on_drop_spec) + event = call_event_fn(event, _on_drop_args_spec) if isinstance(event, EventSpec): # Update the provided args for direct use with on_drop. event = event.with_args( diff --git a/reflex/constants/event.py b/reflex/constants/event.py index 6a0f71ec161..9fb5305c20a 100644 --- a/reflex/constants/event.py +++ b/reflex/constants/event.py @@ -10,6 +10,7 @@ class Endpoint(Enum): PING = "ping" EVENT = "_event" UPLOAD = "_upload" + UPLOAD_CHUNK = "_upload_chunk" AUTH_CODESPACE = "auth-codespace" HEALTH = "_health" ALL_ROUTES = "_all_routes" diff --git a/reflex/event.py b/reflex/event.py index ff75e3bd3cb..57c06b05cc0 100644 --- a/reflex/event.py +++ b/reflex/event.py @@ -1,11 +1,13 @@ """Define event classes to connect the frontend and backend.""" +import asyncio import dataclasses import inspect import sys import types from base64 import b64encode -from collections.abc import Callable, Mapping, Sequence +from collections import deque +from collections.abc import AsyncIterator, Callable, Mapping, Sequence from functools import lru_cache, partial from typing import ( TYPE_CHECKING, @@ -92,6 +94,238 @@ def substate_token(self) -> str: EVENT_ACTIONS_MARKER = "_rx_event_actions" +@dataclasses.dataclass( + init=True, + frozen=True, +) +class UploadChunk: + """A chunk of uploaded file data.""" + + filename: str + offset: int + content_type: str + data: bytes + + +class UploadChunkIterator(AsyncIterator[UploadChunk]): + """An async iterator over uploaded file chunks.""" + + def __init__(self, *, maxsize: int = 8): + """Initialize the iterator. + + Args: + maxsize: Maximum number of chunks to buffer before blocking producers. + """ + self._maxsize = maxsize + self._chunks: deque[UploadChunk] = deque() + self._condition = asyncio.Condition() + self._closed = False + self._error: Exception | None = None + self._consumer_task: asyncio.Task[Any] | None = None + + def __aiter__(self) -> Self: + """Return the iterator itself. + + Returns: + The upload chunk iterator. + """ + return self + + async def __anext__(self) -> UploadChunk: + """Yield the next available upload chunk. + + Returns: + The next upload chunk. + + Raises: + _error: Any error forwarded from the upload producer. + StopAsyncIteration: When all chunks have been consumed. + """ + async with self._condition: + while not self._chunks and not self._closed: + await self._condition.wait() + + if self._chunks: + chunk = self._chunks.popleft() + self._condition.notify_all() + return chunk + + if self._error is not None: + raise self._error + raise StopAsyncIteration + + def set_consumer_task(self, task: asyncio.Task[Any]) -> None: + """Track the task consuming this iterator. + + Args: + task: The background task consuming upload chunks. + """ + self._consumer_task = task + task.add_done_callback(self._wake_waiters) + + async def push(self, chunk: UploadChunk) -> None: + """Push a new chunk into the iterator. + + Args: + chunk: The chunk to push. + + Raises: + RuntimeError: If the iterator is already closed or the consumer exited early. + """ + async with self._condition: + while len(self._chunks) >= self._maxsize and not self._closed: + self._raise_if_consumer_finished() + await self._condition.wait() + + if self._closed: + msg = "Upload chunk iterator is closed." + raise RuntimeError(msg) + + self._raise_if_consumer_finished() + self._chunks.append(chunk) + self._condition.notify_all() + + async def finish(self) -> None: + """Mark the iterator as complete.""" + async with self._condition: + if self._closed: + return + self._closed = True + self._condition.notify_all() + + async def fail(self, error: Exception) -> None: + """Mark the iterator as failed. + + Args: + error: The error to raise from the iterator. + """ + async with self._condition: + if self._closed: + return + self._closed = True + self._error = error + self._condition.notify_all() + + def _raise_if_consumer_finished(self) -> None: + """Raise if the consumer task exited before draining the iterator. + + Raises: + RuntimeError: If the consumer task completed before draining the iterator. + """ + if self._consumer_task is None or not self._consumer_task.done(): + return + + try: + task_exc = self._consumer_task.exception() + except asyncio.CancelledError as err: + task_exc = err + + msg = "Upload handler returned before consuming all upload chunks." + if task_exc is not None: + raise RuntimeError(msg) from task_exc + raise RuntimeError(msg) + + def _wake_waiters(self, task: asyncio.Task[Any]) -> None: + """Wake any producers or consumers blocked on the iterator condition. + + Args: + task: The completed consumer task. + """ + task.get_loop().create_task(self._notify_waiters()) + + async def _notify_waiters(self) -> None: + """Notify tasks waiting on the iterator condition.""" + async with self._condition: + self._condition.notify_all() + + +def _handler_name(handler: "EventHandler") -> str: + """Get a stable fully qualified handler name for errors. + + Args: + handler: The handler to name. + + Returns: + The fully qualified handler name. + """ + if handler.state_full_name: + return f"{handler.state_full_name}.{handler.fn.__name__}" + return handler.fn.__qualname__ + + +def resolve_upload_handler_param(handler: "EventHandler") -> tuple[str, Any]: + """Validate and resolve the UploadFile list parameter for a handler. + + Args: + handler: The event handler to inspect. + + Returns: + The parameter name and annotation for the upload file argument. + + Raises: + UploadTypeError: If the handler is a background task. + UploadValueError: If the handler does not accept ``list[rx.UploadFile]``. + """ + from reflex.app import UploadFile + from reflex.utils.exceptions import UploadTypeError, UploadValueError + + handler_name = _handler_name(handler) + if handler.is_background: + msg = ( + f"@rx.event(background=True) is not supported for upload handler " + f"`{handler_name}`." + ) + raise UploadTypeError(msg) + + func = handler.fn.func if isinstance(handler.fn, partial) else handler.fn + for name, annotation in get_type_hints(func).items(): + if name == "return" or get_origin(annotation) is not list: + continue + args = get_args(annotation) + if len(args) == 1 and typehint_issubclass(args[0], UploadFile): + return name, annotation + + msg = ( + f"`{handler_name}` handler should have a parameter annotated as " + "list[rx.UploadFile]" + ) + raise UploadValueError(msg) + + +def resolve_upload_chunk_handler_param(handler: "EventHandler") -> tuple[str, type]: + """Validate and resolve the UploadChunkIterator parameter for a handler. + + Args: + handler: The event handler to inspect. + + Returns: + The parameter name and annotation for the iterator argument. + + Raises: + UploadTypeError: If the handler is not a background task. + UploadValueError: If the handler does not accept an UploadChunkIterator. + """ + from reflex.utils.exceptions import UploadTypeError, UploadValueError + + handler_name = _handler_name(handler) + if not handler.is_background: + msg = f"@rx.event(background=True) is required for upload_files_chunk handler `{handler_name}`." + raise UploadTypeError(msg) + + func = handler.fn.func if isinstance(handler.fn, partial) else handler.fn + for name, annotation in get_type_hints(func).items(): + if name == "return": + continue + if annotation is UploadChunkIterator: + return name, annotation + + msg = ( + f"`{handler_name}` handler should have a parameter annotated as " + "rx.UploadChunkIterator" + ) + raise UploadValueError(msg) + + @dataclasses.dataclass( init=True, frozen=True, @@ -282,7 +516,7 @@ def __call__(self, *args: Any, **kwargs: Any) -> "EventSpec": values = [] for arg in [*args, *kwargs.values()]: # Special case for file uploads. - if isinstance(arg, FileUpload): + if isinstance(arg, (FileUpload, UploadFilesChunk)): return arg.as_event_spec(handler=self) # Otherwise, convert to JSON. @@ -858,14 +1092,22 @@ def on_upload_progress_args_spec(_prog: Var[dict[str, int | float | bool]]): """ return [_prog] - def as_event_spec(self, handler: EventHandler) -> EventSpec: - """Get the EventSpec for the file upload. + def _as_event_spec( + self, + handler: EventHandler, + *, + client_handler_name: str, + upload_param_name: str, + ) -> EventSpec: + """Create an upload EventSpec. Args: handler: The event handler. + client_handler_name: The client handler name. + upload_param_name: The upload argument name in the event handler. Returns: - The event spec for the handler. + The upload EventSpec. Raises: ValueError: If the on_upload_progress is not a valid event handler. @@ -876,14 +1118,19 @@ def as_event_spec(self, handler: EventHandler) -> EventSpec: ) upload_id = self.upload_id if self.upload_id is not None else DEFAULT_UPLOAD_ID + upload_files_var = Var( + _js_expr="filesById", + _var_type=dict[str, Any], + _var_data=VarData.merge(upload_files_context_var_data), + ).to(ObjectVar)[LiteralVar.create(upload_id)] spec_args = [ ( Var(_js_expr="files"), - Var( - _js_expr="filesById", - _var_type=dict[str, Any], - _var_data=VarData.merge(upload_files_context_var_data), - ).to(ObjectVar)[LiteralVar.create(upload_id)], + upload_files_var, + ), + ( + Var(_js_expr="upload_param_name"), + LiteralVar.create(upload_param_name), ), ( Var(_js_expr="upload_id"), @@ -896,6 +1143,14 @@ def as_event_spec(self, handler: EventHandler) -> EventSpec: ), ), ] + if upload_param_name != "files": + spec_args.insert( + 1, + ( + Var(_js_expr=upload_param_name), + upload_files_var, + ), + ) if self.on_upload_progress is not None: on_upload_progress = self.on_upload_progress if isinstance(on_upload_progress, EventHandler): @@ -931,16 +1186,65 @@ def as_event_spec(self, handler: EventHandler) -> EventSpec: ) return EventSpec( handler=handler, - client_handler_name="uploadFiles", + client_handler_name=client_handler_name, args=tuple(spec_args), event_actions=handler.event_actions.copy(), ) + def as_event_spec(self, handler: EventHandler) -> EventSpec: + """Get the EventSpec for the file upload. + + Args: + handler: The event handler. + + Returns: + The event spec for the handler. + """ + from reflex.utils.exceptions import UploadValueError + + try: + upload_param_name, _annotation = resolve_upload_handler_param(handler) + except UploadValueError: + upload_param_name = "files" + return self._as_event_spec( + handler, + client_handler_name="uploadFiles", + upload_param_name=upload_param_name, + ) + # Alias for rx.upload_files upload_files = FileUpload +@dataclasses.dataclass( + init=True, + frozen=True, +) +class UploadFilesChunk(FileUpload): + """Class to represent a streaming file upload.""" + + def as_event_spec(self, handler: EventHandler) -> EventSpec: + """Get the EventSpec for the streaming file upload. + + Args: + handler: The event handler. + + Returns: + The event spec for the handler. + """ + upload_param_name, _annotation = resolve_upload_chunk_handler_param(handler) + return self._as_event_spec( + handler, + client_handler_name="uploadFilesChunk", + upload_param_name=upload_param_name, + ) + + +# Alias for rx.upload_files_chunk +upload_files_chunk = UploadFilesChunk + + # Special server-side events. def server_side(name: str, sig: inspect.Signature, **kwargs) -> EventSpec: """A server-side event. @@ -2303,6 +2607,9 @@ class EventNamespace: # File Upload FileUpload = FileUpload + UploadChunk = UploadChunk + UploadChunkIterator = UploadChunkIterator + UploadFilesChunk = UploadFilesChunk # Type Aliases EventType = EventType @@ -2316,10 +2623,15 @@ class EventNamespace: _EVENT_FIELDS = _EVENT_FIELDS FORM_DATA = FORM_DATA upload_files = upload_files + upload_files_chunk = upload_files_chunk stop_propagation = stop_propagation prevent_default = prevent_default # Private/Internal Functions + resolve_upload_handler_param = staticmethod(resolve_upload_handler_param) + resolve_upload_chunk_handler_param = staticmethod( + resolve_upload_chunk_handler_param + ) _values_returned_from_event = staticmethod(_values_returned_from_event) _check_event_args_subclass_of_callback = staticmethod( _check_event_args_subclass_of_callback diff --git a/reflex/uploads.py b/reflex/uploads.py new file mode 100644 index 00000000000..386dca4302d --- /dev/null +++ b/reflex/uploads.py @@ -0,0 +1,537 @@ +"""Backend upload helpers and routes for Reflex apps.""" + +from __future__ import annotations + +import asyncio +import contextlib +import dataclasses +from collections.abc import AsyncGenerator, Awaitable, Callable +from pathlib import Path +from typing import TYPE_CHECKING, Any, BinaryIO, cast + +from python_multipart.multipart import MultipartParser, parse_options_header +from starlette.datastructures import Headers +from starlette.datastructures import UploadFile as StarletteUploadFile +from starlette.exceptions import HTTPException +from starlette.formparsers import MultiPartException, _user_safe_decode +from starlette.requests import ClientDisconnect, Request +from starlette.responses import JSONResponse, Response, StreamingResponse + +from reflex import constants +from reflex.event import ( + Event, + EventHandler, + UploadChunk, + UploadChunkIterator, + resolve_upload_chunk_handler_param, + resolve_upload_handler_param, +) +from reflex.state import BaseState, RouterData, _substate_key +from reflex.utils import exceptions +from reflex.utils.types import Receive, Scope, Send + +if TYPE_CHECKING: + from reflex.app import App + + +@dataclasses.dataclass(frozen=True) +class UploadFile(StarletteUploadFile): + """A file uploaded to the server. + + Args: + file: The standard Python file object (non-async). + filename: The original file name. + size: The size of the file in bytes. + headers: The headers of the request. + """ + + file: BinaryIO + + path: Path | None = dataclasses.field(default=None) + + size: int | None = dataclasses.field(default=None) + + headers: Headers = dataclasses.field(default_factory=Headers) + + @property + def filename(self) -> str | None: + """Get the name of the uploaded file. + + Returns: + The name of the uploaded file. + """ + return self.name + + @property + def name(self) -> str | None: + """Get the name of the uploaded file. + + Returns: + The name of the uploaded file. + """ + if self.path: + return self.path.name + return None + + +@dataclasses.dataclass +class _UploadChunkPart: + """Track the current multipart file part for upload streaming.""" + + content_disposition: bytes | None = None + field_name: str = "" + filename: str | None = None + content_type: str = "" + item_headers: list[tuple[bytes, bytes]] = dataclasses.field(default_factory=list) + offset: int = 0 + bytes_emitted: int = 0 + is_upload_chunk: bool = False + + +class _UploadChunkMultipartParser: + """Streaming multipart parser for streamed upload files.""" + + def __init__( + self, + headers: Headers, + stream: AsyncGenerator[bytes, None], + chunk_iter: UploadChunkIterator, + ) -> None: + self.headers = headers + self.stream = stream + self.chunk_iter = chunk_iter + self._charset = "" + self._current_partial_header_name = b"" + self._current_partial_header_value = b"" + self._current_part = _UploadChunkPart() + self._chunks_to_emit: list[UploadChunk] = [] + self._seen_upload_chunk = False + self._part_count = 0 + self._emitted_chunk_count = 0 + self._emitted_bytes = 0 + self._stream_chunk_count = 0 + + def on_part_begin(self) -> None: + """Reset parser state for a new multipart part.""" + self._current_part = _UploadChunkPart() + + def on_part_data(self, data: bytes, start: int, end: int) -> None: + """Record streamed chunk data for the current part.""" + if ( + not self._current_part.is_upload_chunk + or self._current_part.filename is None + ): + return + + message_bytes = data[start:end] + self._chunks_to_emit.append( + UploadChunk( + filename=self._current_part.filename, + offset=self._current_part.offset + self._current_part.bytes_emitted, + content_type=self._current_part.content_type, + data=message_bytes, + ) + ) + self._current_part.bytes_emitted += len(message_bytes) + self._emitted_chunk_count += 1 + self._emitted_bytes += len(message_bytes) + + def on_part_end(self) -> None: + """Emit a zero-byte chunk for empty file parts.""" + if ( + self._current_part.is_upload_chunk + and self._current_part.filename is not None + and self._current_part.bytes_emitted == 0 + ): + self._chunks_to_emit.append( + UploadChunk( + filename=self._current_part.filename, + offset=self._current_part.offset, + content_type=self._current_part.content_type, + data=b"", + ) + ) + self._emitted_chunk_count += 1 + + def on_header_field(self, data: bytes, start: int, end: int) -> None: + """Accumulate multipart header field bytes.""" + self._current_partial_header_name += data[start:end] + + def on_header_value(self, data: bytes, start: int, end: int) -> None: + """Accumulate multipart header value bytes.""" + self._current_partial_header_value += data[start:end] + + def on_header_end(self) -> None: + """Store the completed multipart header.""" + field = self._current_partial_header_name.lower() + if field == b"content-disposition": + self._current_part.content_disposition = self._current_partial_header_value + self._current_part.item_headers.append(( + field, + self._current_partial_header_value, + )) + self._current_partial_header_name = b"" + self._current_partial_header_value = b"" + + def on_headers_finished(self) -> None: + """Parse upload metadata from multipart headers.""" + disposition, options = parse_options_header( + self._current_part.content_disposition + ) + if disposition != b"form-data": + msg = "Invalid upload chunk disposition." + raise MultiPartException(msg) + + try: + field_name = _user_safe_decode(options[b"name"], self._charset) + except KeyError as err: + msg = 'The Content-Disposition header field "name" must be provided.' + raise MultiPartException(msg) from err + + try: + filename = _user_safe_decode(options[b"filename"], self._charset) + except KeyError: + # Ignore non-file form fields entirely. + return + filename = Path(filename.lstrip("/")).name + + content_type = "" + for header_name, header_value in self._current_part.item_headers: + if header_name == b"content-type": + content_type = _user_safe_decode(header_value, self._charset) + break + + self._current_part.field_name = field_name + self._current_part.filename = filename + self._current_part.content_type = content_type + self._current_part.offset = 0 + self._current_part.bytes_emitted = 0 + self._current_part.is_upload_chunk = True + self._seen_upload_chunk = True + self._part_count += 1 + + def on_end(self) -> None: + """Finalize parser callbacks.""" + + async def _flush_emitted_chunks(self) -> None: + """Push parsed upload chunks into the handler iterator.""" + while self._chunks_to_emit: + await self.chunk_iter.push(self._chunks_to_emit.pop(0)) + + async def parse(self) -> None: + """Parse the incoming request stream and push chunks to the iterator. + + Raises: + MultiPartException: If the request is not valid multipart upload data. + RuntimeError: If the upload handler exits before consuming all chunks. + """ + _, params = parse_options_header(self.headers["Content-Type"]) + charset = params.get(b"charset", "utf-8") + if isinstance(charset, bytes): + charset = charset.decode("latin-1") + self._charset = charset + + try: + boundary = params[b"boundary"] + except KeyError as err: + msg = "Missing boundary in multipart." + raise MultiPartException(msg) from err + + callbacks = { + "on_part_begin": self.on_part_begin, + "on_part_data": self.on_part_data, + "on_part_end": self.on_part_end, + "on_header_field": self.on_header_field, + "on_header_value": self.on_header_value, + "on_header_end": self.on_header_end, + "on_headers_finished": self.on_headers_finished, + "on_end": self.on_end, + } + parser = MultipartParser(boundary, cast(Any, callbacks)) + + async for chunk in self.stream: + self._stream_chunk_count += 1 + parser.write(chunk) + await self._flush_emitted_chunks() + + parser.finalize() + await self._flush_emitted_chunks() + + if not self._seen_upload_chunk: + msg = "No file chunks were uploaded." + raise MultiPartException(msg) + + +class _UploadStreamingResponse(StreamingResponse): + """Streaming response that always releases upload form resources.""" + + _on_finish: Callable[[], Awaitable[None]] + + def __init__( + self, + *args: Any, + on_finish: Callable[[], Awaitable[None]], + **kwargs: Any, + ) -> None: + super().__init__(*args, **kwargs) + self._on_finish = on_finish + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + try: + await super().__call__(scope, receive, send) + finally: + await self._on_finish() + + +def _require_upload_headers(request: Request) -> tuple[str, str]: + """Extract the required upload headers from a request. + + Args: + request: The incoming request. + + Returns: + The client token and event handler name. + + Raises: + HTTPException: If the upload headers are missing. + """ + token = request.headers.get("reflex-client-token") + handler = request.headers.get("reflex-event-handler") + + if not token or not handler: + raise HTTPException( + status_code=400, + detail="Missing reflex-client-token or reflex-event-handler header.", + ) + + return token, handler + + +async def _get_upload_runtime_handler( + app: App, + token: str, + handler_name: str, +) -> tuple[BaseState, EventHandler]: + """Resolve the runtime state and event handler for an upload request. + + Args: + app: The Reflex app. + token: The client token. + handler_name: The fully qualified event handler name. + + Returns: + The root state instance and resolved event handler. + """ + substate_token = _substate_key(token, handler_name.rpartition(".")[0]) + state = await app.state_manager.get_state(substate_token) + _current_state, event_handler = state._get_event_handler(handler_name) + return state, event_handler + + +def _seed_upload_router_data(state: BaseState, token: str) -> None: + """Ensure upload-launched handlers have the client token in router state. + + Background upload handlers use ``StateProxy`` which derives its mutable-state + token from ``self.router.session.client_token``. Upload requests do not flow + through the normal websocket event pipeline, so we seed the token here. + + Args: + state: The root state instance. + token: The client token from the upload request. + """ + router_data = dict(state.router_data) + if router_data.get(constants.RouteVar.CLIENT_TOKEN) == token: + return + + router_data[constants.RouteVar.CLIENT_TOKEN] = token + state.router_data = router_data + state.router = RouterData.from_router_data(router_data) + + +def upload(app: App): + """Upload a file. + + Args: + app: The app to upload the file for. + + Returns: + The upload function. + """ + + async def upload_file(request: Request): + """Upload a file. + + Args: + request: The Starlette request object. + + Returns: + StreamingResponse yielding newline-delimited JSON of StateUpdate + emitted by the upload handler. + + Raises: + UploadValueError: if there are no args with supported annotation. + UploadTypeError: if a background task is used as the handler. + HTTPException: when the request does not include token / handler headers. + """ + from reflex.utils.exceptions import UploadValueError + + try: + form_data = await request.form() + except ClientDisconnect: + return Response() + + form_data_closed = False + + async def _close_form_data() -> None: + """Close the parsed form data exactly once.""" + nonlocal form_data_closed + if form_data_closed: + return + form_data_closed = True + await form_data.close() + + async def _create_upload_event() -> Event: + """Create an upload event using the live Starlette temp files. + + Returns: + The upload event backed by the original temp files. + """ + files = form_data.getlist("files") + if not files: + msg = "No files were uploaded." + raise UploadValueError(msg) + + token, handler = _require_upload_headers(request) + + _state, event_handler = await _get_upload_runtime_handler( + app, token, handler + ) + handler_upload_param = resolve_upload_handler_param(event_handler) + + file_uploads = [] + for file in files: + if not isinstance(file, StarletteUploadFile): + raise UploadValueError( + "Uploaded file is not an UploadFile." + str(file) + ) + file_uploads.append( + UploadFile( + file=file.file, + path=Path(file.filename.lstrip("/")) if file.filename else None, + size=file.size, + headers=file.headers, + ) + ) + + return Event( + token=token, + name=handler, + payload={handler_upload_param[0]: file_uploads}, + ) + + event: Event | None = None + try: + event = await _create_upload_event() + finally: + if event is None: + await _close_form_data() + + async def _ndjson_updates(): + """Process the upload event, generating ndjson updates. + + Yields: + Each state update as JSON followed by a new line. + """ + async with app.state_manager.modify_state_with_links( + event.substate_token + ) as state: + async for update in state._process(event): + update = await app._postprocess(state, event, update) + yield update.json() + "\n" + + return _UploadStreamingResponse( + _ndjson_updates(), + media_type="application/x-ndjson", + on_finish=_close_form_data, + ) + + return upload_file + + +def upload_chunk(app: App): + """Upload file chunks to a background event handler. + + Args: + app: The app to upload the file for. + + Returns: + The streaming upload function. + """ + + async def upload_file_chunk(request: Request): + """Upload file chunks without buffering the full file in memory. + + Args: + request: The Starlette request object. + + Returns: + A response indicating whether the upload stream was accepted. + + Raises: + UploadTypeError: If the handler is not a background event. + UploadValueError: If the handler signature is invalid. + HTTPException: If the request is missing required headers. + """ + token, handler_name = _require_upload_headers(request) + try: + _state, event_handler = await _get_upload_runtime_handler( + app, token, handler_name + ) + handler_upload_param = resolve_upload_chunk_handler_param(event_handler) + except (exceptions.UploadTypeError, RuntimeError, ValueError) as err: + return JSONResponse({"detail": str(err)}, status_code=400) + + chunk_iter = UploadChunkIterator(maxsize=8) + event = Event( + token=token, + name=handler_name, + payload={handler_upload_param[0]: chunk_iter}, + ) + + async with app.state_manager.modify_state_with_links( + event.substate_token, + event=event, + ) as state: + _seed_upload_router_data(state, token) + task = app._process_background(state, event) + + if task is None: + msg = f"@rx.event(background=True) is required for upload_files_chunk handler `{handler_name}`." + return JSONResponse({"detail": msg}, status_code=400) + + chunk_iter.set_consumer_task(task) + + parser = _UploadChunkMultipartParser( + request.headers, + request.stream(), + chunk_iter, + ) + + try: + await parser.parse() + except ClientDisconnect: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + return Response() + except (MultiPartException, RuntimeError, ValueError) as err: + await chunk_iter.fail(err) + return JSONResponse({"detail": str(err)}, status_code=400) + + try: + await chunk_iter.finish() + except RuntimeError as err: + return JSONResponse({"detail": str(err)}, status_code=400) + return Response(status_code=202) + + return upload_file_chunk diff --git a/tests/integration/test_upload.py b/tests/integration/test_upload.py index ed4a5456cd1..9af85805a1f 100644 --- a/tests/integration/test_upload.py +++ b/tests/integration/test_upload.py @@ -6,11 +6,13 @@ import time from collections.abc import Generator from pathlib import Path +from typing import Any, cast from urllib.parse import urlsplit import pytest from selenium.webdriver.common.by import By +import reflex as rx from reflex.constants.event import Endpoint from reflex.testing import AppHarness, WebDriver @@ -27,9 +29,12 @@ class UploadState(rx.State): _file_data: dict[str, str] = {} event_order: rx.Field[list[str]] = rx.field([]) progress_dicts: rx.Field[list[dict]] = rx.field([]) + stream_progress_dicts: rx.Field[list[dict]] = rx.field([]) disabled: rx.Field[bool] = rx.field(False) large_data: rx.Field[str] = rx.field("") quaternary_names: rx.Field[list[str]] = rx.field([]) + stream_chunk_records: rx.Field[list[str]] = rx.field([]) + stream_completed_files: rx.Field[list[str]] = rx.field([]) @rx.event async def handle_upload(self, files: list[rx.UploadFile]): @@ -57,6 +62,11 @@ def chain_event(self): self.large_data = "" self.event_order.append("chain_event") + @rx.event + def stream_upload_progress(self, progress): + assert progress + self.stream_progress_dicts.append(progress) + @rx.event async def handle_upload_tertiary(self, files: list[rx.UploadFile]): for file in files: @@ -68,6 +78,35 @@ async def handle_upload_tertiary(self, files: list[rx.UploadFile]): async def handle_upload_quaternary(self, files: list[rx.UploadFile]): self.quaternary_names = [file.name for file in files if file.name] + @rx.event(background=True) + async def handle_upload_stream(self, chunk_iter: rx.UploadChunkIterator): + upload_dir = rx.get_upload_dir() / "streaming" + file_handles: dict[str, Any] = {} + + try: + async for chunk in chunk_iter: + path = upload_dir / chunk.filename + path.parent.mkdir(parents=True, exist_ok=True) + + fh = file_handles.get(chunk.filename) + if fh is None: + fh = path.open("r+b") if path.exists() else path.open("wb") + file_handles[chunk.filename] = fh + + fh.seek(chunk.offset) + fh.write(chunk.data) + + async with self: + self.stream_chunk_records.append( + f"{chunk.filename}:{chunk.offset}:{len(chunk.data)}" + ) + finally: + for fh in file_handles.values(): + fh.close() + + async with self: + self.stream_completed_files = sorted(file_handles) + @rx.event def do_download(self): return rx.download(rx.get_upload_url("test.txt")) @@ -188,6 +227,44 @@ def index(): UploadState.quaternary_names.to_string(), id="quaternary_files", ), + rx.heading("Streaming Upload"), + rx.upload.root( + rx.vstack( + rx.button("Select File"), + rx.text("Drag and drop files here or click to select files"), + ), + id="streaming", + ), + rx.button( + "Upload", + on_click=UploadState.handle_upload_stream( + rx.upload_files_chunk( # pyright: ignore [reportArgumentType] + upload_id="streaming", + on_upload_progress=UploadState.stream_upload_progress, + ) + ), + id="upload_button_streaming", + ), + rx.box( + rx.foreach( + rx.selected_files("streaming"), + lambda f: rx.text(f, as_="p"), + ), + id="selected_files_streaming", + ), + rx.button( + "Cancel", + on_click=rx.cancel_upload("streaming"), + id="cancel_button_streaming", + ), + rx.text( + UploadState.stream_chunk_records.to_string(), + id="stream_chunk_records", + ), + rx.text( + UploadState.stream_completed_files.to_string(), + id="stream_completed_files", + ), rx.text(UploadState.event_order.to_string(), id="event-order"), ) @@ -487,6 +564,140 @@ async def _progress_dicts(): target_file.unlink() +@pytest.mark.asyncio +async def test_upload_chunk_file(tmp_path, upload_file: AppHarness, driver: WebDriver): + """Submit a streaming upload and check that chunks are processed incrementally.""" + assert upload_file.app_instance is not None + token = poll_for_token(driver, upload_file) + state_name = upload_file.get_state_name("_upload_state") + state_full_name = upload_file.get_full_state_name(["_upload_state"]) + substate_token = f"{token}_{state_full_name}" + + upload_box = driver.find_elements(By.XPATH, "//input[@type='file']")[4] + upload_button = driver.find_element(By.ID, "upload_button_streaming") + selected_files = driver.find_element(By.ID, "selected_files_streaming") + chunk_records_display = driver.find_element(By.ID, "stream_chunk_records") + completed_files_display = driver.find_element(By.ID, "stream_completed_files") + + exp_files = { + "stream1.txt": "ABCD" * 262_144, + "stream2.txt": "WXYZ" * 262_144, + } + for exp_name, exp_contents in exp_files.items(): + target_file = tmp_path / exp_name + target_file.write_text(exp_contents) + upload_box.send_keys(str(target_file)) + + await asyncio.sleep(0.2) + + assert [Path(name).name for name in selected_files.text.split("\n")] == [ + Path(name).name for name in exp_files + ] + + upload_button.click() + + AppHarness.expect(lambda: "stream1.txt" in chunk_records_display.text) + + async def _stream_completed(): + state = await upload_file.get_state(substate_token) + return ( + len( + state.substates[state_name].stream_completed_files # pyright: ignore[reportAttributeAccessIssue] + ) + == 2 + ) + + await AppHarness._poll_for_async(_stream_completed) + + state = await upload_file.get_state(substate_token) + substate = cast(Any, state.substates[state_name]) + chunk_records = substate.stream_chunk_records + + assert len(chunk_records) > 2 + assert {Path(record.split(":")[0]).name for record in chunk_records} == { + "stream1.txt", + "stream2.txt", + } + assert substate.stream_completed_files == ["stream1.txt", "stream2.txt"] + + AppHarness.expect( + lambda: ( + "stream1.txt" in completed_files_display.text + and "stream2.txt" in completed_files_display.text + ) + ) + + for exp_name, exp_contents in exp_files.items(): + assert ( + rx.get_upload_dir() / "streaming" / exp_name + ).read_text() == exp_contents + + +@pytest.mark.asyncio +async def test_cancel_upload_chunk( + tmp_path, + upload_file: AppHarness, + driver: WebDriver, +): + """Submit a large streaming upload and cancel it.""" + assert upload_file.app_instance is not None + driver.execute_cdp_cmd("Network.enable", {}) + driver.execute_cdp_cmd( + "Network.emulateNetworkConditions", + { + "offline": False, + "downloadThroughput": 1024 * 1024 / 8, # 1 Mbps + "uploadThroughput": 1024 * 1024 / 8, # 1 Mbps + "latency": 200, # 200ms + }, + ) + token = poll_for_token(driver, upload_file) + state_name = upload_file.get_state_name("_upload_state") + state_full_name = upload_file.get_full_state_name(["_upload_state"]) + substate_token = f"{token}_{state_full_name}" + + upload_box = driver.find_elements(By.XPATH, "//input[@type='file']")[4] + upload_button = driver.find_element(By.ID, "upload_button_streaming") + cancel_button = driver.find_element(By.ID, "cancel_button_streaming") + + exp_name = "cancel_stream.txt" + target_file = tmp_path / exp_name + with target_file.open("wb") as f: + f.seek(2 * 1024 * 1024) + f.write(b"0") + + upload_box.send_keys(str(target_file)) + upload_button.click() + await asyncio.sleep(1) + cancel_button.click() + + await asyncio.sleep(12) + + async def _stream_progress_dicts(): + state = await upload_file.get_state(substate_token) + return ( + state.substates[state_name].stream_progress_dicts # pyright: ignore[reportAttributeAccessIssue] + ) + + assert await AppHarness._poll_for_async(_stream_progress_dicts) + + for progress in await _stream_progress_dicts(): + assert progress["progress"] != 1 + + state = await upload_file.get_state(substate_token) + substate = cast(Any, state.substates[state_name]) + assert substate.stream_completed_files == [] + assert substate.stream_chunk_records + + partial_path = rx.get_upload_dir() / "streaming" / exp_name + assert partial_path.exists() + assert partial_path.stat().st_size < target_file.stat().st_size + + target_file.unlink() + if partial_path.exists(): + partial_path.unlink() + + def test_upload_download_file( tmp_path, upload_file: AppHarness, diff --git a/tests/units/components/core/test_upload.py b/tests/units/components/core/test_upload.py index 3b03362d6e4..b9dfb4bb976 100644 --- a/tests/units/components/core/test_upload.py +++ b/tests/units/components/core/test_upload.py @@ -1,5 +1,8 @@ -from typing import Any +from typing import Any, cast +import pytest + +import reflex as rx from reflex import event from reflex.components.core.upload import ( StyledUpload, @@ -9,7 +12,7 @@ cancel_upload, get_upload_url, ) -from reflex.event import EventSpec +from reflex.event import EventChain, EventHandler, EventSpec from reflex.state import State from reflex.vars.base import LiteralVar, Var @@ -33,6 +36,31 @@ def not_drop_handler(self, not_files: Any): not_files: The files dropped. """ + @event + async def upload_alias_handler(self, uploads: list[rx.UploadFile]): + """Handle uploaded files with a non-default parameter name.""" + + +class StreamingUploadStateTest(State): + """Test state for streaming uploads.""" + + @event(background=True) + async def chunk_drop_handler(self, chunk_iter: rx.UploadChunkIterator): + """Handle streamed upload chunks.""" + + @event(background=True) + async def chunk_upload_alias_handler(self, stream: rx.UploadChunkIterator): + """Handle streamed upload chunks with a non-default parameter name.""" + + async def chunk_drop_handler_not_background( + self, chunk_iter: rx.UploadChunkIterator + ): + """Invalid handler used to validate background-task requirement.""" + + @event(background=True) + async def chunk_drop_handler_missing_annotation(self, chunk_iter): + """Invalid handler missing the UploadChunkIterator annotation.""" + def test_cancel_upload(): spec = cancel_upload("foo_id") @@ -44,10 +72,54 @@ def test_get_upload_url(): assert isinstance(url, Var) +def test_upload_files_chunk_export(): + chunk = rx.UploadChunk( + filename="foo.txt", + offset=0, + content_type="text/plain", + data=b"hello", + ) + + assert chunk.filename == "foo.txt" + assert isinstance(rx.UploadChunkIterator(), rx.UploadChunkIterator) + assert callable(rx.upload_files_chunk) + + def test__on_drop_spec(): assert isinstance(_on_drop_spec(LiteralVar.create([])), tuple) +def test_upload_files_chunk_requires_background(): + with pytest.raises(TypeError) as err: + event.resolve_upload_chunk_handler_param( + cast( + EventHandler, StreamingUploadStateTest.chunk_drop_handler_not_background + ) + ) + + assert ( + err.value.args[0] + == "@rx.event(background=True) is required for upload_files_chunk handler " + f"`{StreamingUploadStateTest.get_full_name()}.chunk_drop_handler_not_background`." + ) + + +def test_upload_files_chunk_requires_iterator_annotation(): + with pytest.raises(ValueError) as err: + event.resolve_upload_chunk_handler_param( + cast( + EventHandler, + StreamingUploadStateTest.chunk_drop_handler_missing_annotation, + ) + ) + + assert ( + err.value.args[0] + == f"`{StreamingUploadStateTest.get_full_name()}.chunk_drop_handler_missing_annotation` " + "handler should have a parameter annotated as rx.UploadChunkIterator" + ) + + def test_upload_create(): up_comp_1 = Upload.create() assert isinstance(up_comp_1, Upload) @@ -83,6 +155,51 @@ def test_upload_create(): assert isinstance(up_comp_4, Upload) assert up_comp_4.is_used + # reset is_used + Upload.is_used = False + + up_comp_5 = Upload.create( + id="foo_id", + on_drop=StreamingUploadStateTest.chunk_drop_handler( + cast(Any, rx.upload_files_chunk(upload_id="foo_id")) + ), + ) + assert isinstance(up_comp_5, Upload) + assert up_comp_5.is_used + + up_comp_6 = Upload.create( + id="foo_id", + on_drop=StreamingUploadStateTest.chunk_upload_alias_handler( + cast(Any, rx.upload_files_chunk(upload_id="foo_id")) + ), + ) + assert isinstance(up_comp_6, Upload) + assert up_comp_6.is_used + + +def test_upload_button_handlers_allow_custom_param_names(): + legacy_button = rx.button( + "Upload", + on_click=UploadStateTest.upload_alias_handler( + cast(Any, rx.upload_files(upload_id="foo_id")) + ), + ) + legacy_chain = cast(EventChain, legacy_button.event_triggers["on_click"]) + legacy_event = cast(EventSpec, legacy_chain.events[0]) + legacy_arg_names = [arg[0]._js_expr for arg in legacy_event.args] + assert legacy_arg_names[:3] == ["files", "uploads", "upload_param_name"] + + chunk_button = rx.button( + "Upload", + on_click=StreamingUploadStateTest.chunk_upload_alias_handler( + cast(Any, rx.upload_files_chunk(upload_id="foo_id")) + ), + ) + chunk_chain = cast(EventChain, chunk_button.event_triggers["on_click"]) + chunk_event = cast(EventSpec, chunk_chain.events[0]) + chunk_arg_names = [arg[0]._js_expr for arg in chunk_event.args] + assert chunk_arg_names[:3] == ["files", "stream", "upload_param_name"] + def test_styled_upload_create(): styled_up_comp_1 = StyledUpload.create() diff --git a/tests/units/states/upload.py b/tests/units/states/upload.py index 6c732796a73..d53304a3be6 100644 --- a/tests/units/states/upload.py +++ b/tests/units/states/upload.py @@ -1,6 +1,7 @@ """Test states for upload-related tests.""" from pathlib import Path +from typing import BinaryIO import reflex as rx from reflex.state import BaseState, State @@ -78,6 +79,56 @@ class FileUploadState(_FileUploadMixin, State): """The base state for uploading a file.""" +class _ChunkUploadMixin(BaseState, mixin=True): + """Common fields and handlers for chunk upload tests.""" + + chunk_records: list[str] + completed_files: list[str] + _tmp_path: Path = Path() + + @rx.event(background=True) + async def chunk_handle_upload(self, chunk_iter: rx.UploadChunkIterator): + """Handle a chunked upload in the background.""" + file_handles: dict[str, BinaryIO] = {} + + try: + async for chunk in chunk_iter: + outfile = self._tmp_path / chunk.filename + outfile.parent.mkdir(parents=True, exist_ok=True) + + fh = file_handles.get(chunk.filename) + if fh is None: + fh = outfile.open("r+b") if outfile.exists() else outfile.open("wb") + file_handles[chunk.filename] = fh + + fh.seek(chunk.offset) + fh.write(chunk.data) + + async with self: + self.chunk_records.append( + f"{chunk.filename}:{chunk.offset}:{len(chunk.data)}:{chunk.content_type}" + ) + finally: + for fh in file_handles.values(): + fh.close() + + async with self: + self.completed_files = sorted(file_handles) + + async def chunk_handle_upload_not_background( + self, chunk_iter: rx.UploadChunkIterator + ): + """Invalid streaming upload handler used for compile-time validation tests.""" + + @rx.event(background=True) + async def chunk_handle_upload_missing_annotation(self, chunk_iter): + """Invalid streaming upload handler missing the iterator annotation.""" + + +class ChunkUploadState(_ChunkUploadMixin, State): + """The base state for streaming chunk uploads.""" + + class FileStateBase1(State): """The base state for a child FileUploadState.""" diff --git a/tests/units/test_app.py b/tests/units/test_app.py index 25c71c0d17e..54163b227e8 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -10,13 +10,14 @@ from contextlib import nullcontext as does_not_raise from importlib.util import find_spec from pathlib import Path +from types import SimpleNamespace from typing import TYPE_CHECKING, Any, ClassVar from unittest.mock import AsyncMock import pytest from pytest_mock import MockerFixture from starlette.applications import Starlette -from starlette.datastructures import FormData, UploadFile +from starlette.datastructures import FormData, Headers, UploadFile from starlette.responses import StreamingResponse import reflex as rx @@ -27,6 +28,7 @@ default_overlay_component, process, upload, + upload_chunk, ) from reflex.components import Component from reflex.components.base.bare import Bare @@ -57,6 +59,7 @@ from .states import GenState from .states.upload import ( ChildFileUploadState, + ChunkUploadState, FileStateBase1, FileUploadState, GrandChildFileUploadState, @@ -1271,6 +1274,221 @@ async def form(): # noqa: RUF029 await app.state_manager.close() +def _build_chunk_upload_multipart_body( + boundary: str, + parts: list[tuple[str, str, str, bytes]], +) -> bytes: + """Build a multipart upload body for chunk upload tests. + + Args: + boundary: The multipart boundary string. + parts: Tuples of field name, filename, content type, and payload. + + Returns: + The encoded multipart body bytes. + """ + body = bytearray() + for field_name, filename, content_type, data in parts: + body.extend(f"--{boundary}\r\n".encode()) + body.extend( + ( + f'Content-Disposition: form-data; name="{field_name}"; ' + f'filename="{filename}"\r\n' + ).encode() + ) + body.extend(f"Content-Type: {content_type}\r\n\r\n".encode()) + body.extend(data) + body.extend(b"\r\n") + body.extend(f"--{boundary}--\r\n".encode()) + return bytes(body) + + +def _make_chunk_upload_request( + token: str, + handler_name: str, + body: bytes, + *, + content_type: str, + stream_chunk_size: int = 17, +): + """Create a mocked request for the chunk upload endpoint. + + Returns: + A mocked Starlette request object. + """ + request_mock = unittest.mock.Mock() + request_mock.headers = Headers({ + "content-type": content_type, + "reflex-client-token": token, + "reflex-event-handler": handler_name, + }) + request_mock.query_params = {} + + async def stream(): + for index in range(0, len(body), stream_chunk_size): + yield body[index : index + stream_chunk_size] + yield b"" + await asyncio.sleep(0) + + request_mock.stream = stream + return request_mock + + +async def _drain_background_tasks(app: App): + """Wait for all background tasks associated with an app. + + Returns: + The gathered background task results. + """ + tasks = tuple(app._background_tasks) + if tasks: + return await asyncio.gather(*tasks, return_exceptions=True) + return [] + + +@pytest.mark.asyncio +async def test_upload_chunk_streams_chunks(tmp_path, token: str, mocker: MockerFixture): + """Test streaming upload chunks through the background upload endpoint.""" + mocker.patch( + "reflex.state.State.class_subclasses", + {ChunkUploadState}, + ) + app = App() + mocker.patch( + "reflex.utils.prerequisites.get_and_validate_app", + return_value=SimpleNamespace(app=app), + ) + app.event_namespace.emit_update = AsyncMock() # pyright: ignore [reportOptionalMemberAccess] + + async with app.modify_state(_substate_key(token, ChunkUploadState)) as root_state: + substate = root_state.get_substate(ChunkUploadState.get_full_name().split(".")) + substate._tmp_path = tmp_path + substate.chunk_records = [] + substate.completed_files = [] + + upload_fn = upload_chunk(app) + boundary = "chunk-upload-boundary" + response = await upload_fn( + _make_chunk_upload_request( + token, + f"{ChunkUploadState.get_full_name()}.chunk_handle_upload", + _build_chunk_upload_multipart_body( + boundary, + [ + ("files", "alpha.txt", "text/plain", b"abcde"), + ("files", "beta.txt", "text/plain", b"12345"), + ], + ), + content_type=f"multipart/form-data; boundary={boundary}", + stream_chunk_size=1, + ) + ) + assert response.status_code == 202 + + task_results = await _drain_background_tasks(app) + assert task_results == [None] + + state = await app.state_manager.get_state(_substate_key(token, ChunkUploadState)) + substate = ( + state + if isinstance(state, ChunkUploadState) + else state.get_substate(ChunkUploadState.get_full_name().split(".")) + ) + assert isinstance(substate, ChunkUploadState) + parsed_chunk_records = [ + (filename, int(offset), int(size), content_type) + for filename, offset, size, content_type in ( + record.rsplit(":", 3) for record in substate.chunk_records + ) + ] + assert len(parsed_chunk_records) >= 4 + assert {filename for filename, *_ in parsed_chunk_records} == { + "alpha.txt", + "beta.txt", + } + assert all( + content_type == "text/plain" for *_, content_type in parsed_chunk_records + ) + assert ( + sum( + size + for filename, _offset, size, _content_type in parsed_chunk_records + if filename == "alpha.txt" + ) + == 5 + ) + assert ( + sum( + size + for filename, _offset, size, _content_type in parsed_chunk_records + if filename == "beta.txt" + ) + == 5 + ) + assert parsed_chunk_records[0][0] == "alpha.txt" + assert parsed_chunk_records[-1][0] == "beta.txt" + assert substate.completed_files == ["alpha.txt", "beta.txt"] + assert (tmp_path / "alpha.txt").read_bytes() == b"abcde" + assert (tmp_path / "beta.txt").read_bytes() == b"12345" + assert app.event_namespace.emit_update.await_count >= 1 # pyright: ignore [reportOptionalMemberAccess] + assert not app._background_tasks + + await app.state_manager.close() + + +@pytest.mark.asyncio +async def test_upload_chunk_invalid_offset_returns_400( + token: str, + mocker: MockerFixture, +): + """Test that malformed chunk metadata fails the upload request.""" + mocker.patch( + "reflex.state.State.class_subclasses", + {ChunkUploadState}, + ) + app = App() + mocker.patch( + "reflex.utils.prerequisites.get_and_validate_app", + return_value=SimpleNamespace(app=app), + ) + app.event_namespace.emit_update = AsyncMock() # pyright: ignore [reportOptionalMemberAccess] + + async with app.modify_state(_substate_key(token, ChunkUploadState)) as root_state: + substate = root_state.get_substate(ChunkUploadState.get_full_name().split(".")) + substate.chunk_records = [] + substate.completed_files = [] + + upload_fn = upload_chunk(app) + response = await upload_fn( + _make_chunk_upload_request( + token, + f"{ChunkUploadState.get_full_name()}.chunk_handle_upload", + b"abc", + content_type="text/plain", + ) + ) + + assert response.status_code == 400 + assert json.loads(bytes(response.body).decode()) == { + "detail": "Missing boundary in multipart." + } + + await _drain_background_tasks(app) + + state = await app.state_manager.get_state(_substate_key(token, ChunkUploadState)) + substate = ( + state + if isinstance(state, ChunkUploadState) + else state.get_substate(ChunkUploadState.get_full_name().split(".")) + ) + assert isinstance(substate, ChunkUploadState) + assert substate.chunk_records == [] + assert substate.completed_files == [] + assert not app._background_tasks + + await app.state_manager.close() + + class DynamicState(BaseState): """State class for testing dynamic route var. From 63eeb440d7f5117eaf77f793051506ebbb2efdb6 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Thu, 19 Mar 2026 01:24:32 +0500 Subject: [PATCH 02/12] test: redis oplock --- tests/units/test_app.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/units/test_app.py b/tests/units/test_app.py index 54163b227e8..630362f2b9d 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -1341,9 +1341,12 @@ async def _drain_background_tasks(app: App): The gathered background task results. """ tasks = tuple(app._background_tasks) - if tasks: - return await asyncio.gather(*tasks, return_exceptions=True) - return [] + results = await asyncio.gather(*tasks, return_exceptions=True) if tasks else [] + if environment.REFLEX_OPLOCK_ENABLED.get(): + # Redis oplocks can keep completed background-task writes in the local + # lease cache until the manager is closed. + await app.state_manager.close() + return results @pytest.mark.asyncio From 5ccdd5e16cd1112bb7abb4c61b9483bde9bac489 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Thu, 19 Mar 2026 01:53:07 +0500 Subject: [PATCH 03/12] fix: rename uploads to upload --- pyi_hashes.json | 4 ++-- reflex/__init__.py | 2 +- reflex/app.py | 4 ++-- reflex/components/core/upload.py | 2 +- reflex/{uploads.py => upload.py} | 0 reflex/utils/pyi_generator.py | 1 + 6 files changed, 7 insertions(+), 6 deletions(-) rename reflex/{uploads.py => upload.py} (100%) diff --git a/pyi_hashes.json b/pyi_hashes.json index a23f3696548..26d4168d9ff 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -1,5 +1,5 @@ { - "reflex/__init__.pyi": "70485139882c5c114c121445a24c7b28", + "reflex/__init__.pyi": "5fa247402bc6edcfd80498d77aee6ebf", "reflex/components/__init__.pyi": "ac05995852baa81062ba3d18fbc489fb", "reflex/components/base/__init__.pyi": "16e47bf19e0d62835a605baa3d039c5a", "reflex/components/base/app_wrap.pyi": "22e94feaa9fe675bcae51c412f5b67f1", @@ -19,7 +19,7 @@ "reflex/components/core/helmet.pyi": "43f8497c8fafe51e29dca1dd535d143a", "reflex/components/core/html.pyi": "86eb9d4c1bb4807547b2950d9a32e9fd", "reflex/components/core/sticky.pyi": "cb763b986a9b0654d1a3f33440dfcf60", - "reflex/components/core/upload.pyi": "58c023b9149635894331528bf29eaf13", + "reflex/components/core/upload.pyi": "fb63525ca5533e00ad47e9d42ff34a83", "reflex/components/core/window_events.pyi": "af33ccec866b9540ee7fbec6dbfbd151", "reflex/components/datadisplay/__init__.pyi": "52755871369acbfd3a96b46b9a11d32e", "reflex/components/datadisplay/code.pyi": "b86769987ef4d1cbdddb461be88539fd", diff --git a/reflex/__init__.py b/reflex/__init__.py index 6342acfa723..2496466bf7f 100644 --- a/reflex/__init__.py +++ b/reflex/__init__.py @@ -351,7 +351,7 @@ _SUBMODULES: set[str] = { "components", "app", - "uploads", + "upload", "style", "admin", "base", diff --git a/reflex/app.py b/reflex/app.py index abebff64f3e..7b52a8563b8 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -97,8 +97,8 @@ all_base_state_classes, code_uses_state_contexts, ) -from reflex.uploads import UploadFile as UploadFile -from reflex.uploads import upload, upload_chunk +from reflex.upload import UploadFile as UploadFile +from reflex.upload import upload, upload_chunk from reflex.utils import ( codespaces, console, diff --git a/reflex/components/core/upload.py b/reflex/components/core/upload.py index cc423a4459e..d8b324ac9f2 100644 --- a/reflex/components/core/upload.py +++ b/reflex/components/core/upload.py @@ -6,7 +6,6 @@ from pathlib import Path from typing import Any, ClassVar -from reflex.app import UploadFile from reflex.components.base.fragment import Fragment from reflex.components.component import ( Component, @@ -36,6 +35,7 @@ upload_files, ) from reflex.style import Style +from reflex.upload import UploadFile from reflex.utils import format from reflex.utils.imports import ImportVar from reflex.vars import VarData diff --git a/reflex/uploads.py b/reflex/upload.py similarity index 100% rename from reflex/uploads.py rename to reflex/upload.py diff --git a/reflex/utils/pyi_generator.py b/reflex/utils/pyi_generator.py index 3dae7ff4c62..a1ac27563b0 100644 --- a/reflex/utils/pyi_generator.py +++ b/reflex/utils/pyi_generator.py @@ -93,6 +93,7 @@ "PointerEventInfo", ], "reflex.style": ["Style"], + "reflex.upload": ["UploadFile"], "reflex.vars.base": ["Var"], } From ac3f2c2c5701016f6c2a4ddae092bb0de610a38b Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Thu, 19 Mar 2026 02:21:03 +0500 Subject: [PATCH 04/12] refactor: consolidate upload logic and remove chunked upload JS path Move upload helpers from reflex/upload.py to reflex/_upload.py, unify the frontend to use a single uploadFiles function instead of separate uploadFiles/uploadFilesChunk paths, and normalize upload payload keys server-side in state.py instead of branching in the JS client. --- pyi_hashes.json | 4 +- reflex/.templates/web/utils/helpers/upload.js | 188 ++++------ reflex/.templates/web/utils/state.js | 18 +- reflex/__init__.py | 1 - reflex/{upload.py => _upload.py} | 325 +++++++++++------- reflex/app.py | 4 +- reflex/components/core/upload.py | 2 +- reflex/event.py | 2 +- reflex/state.py | 49 +++ reflex/utils/pyi_generator.py | 2 +- tests/units/components/core/test_upload.py | 8 +- tests/units/test_app.py | 81 ++++- 12 files changed, 407 insertions(+), 277 deletions(-) rename reflex/{upload.py => _upload.py} (68%) diff --git a/pyi_hashes.json b/pyi_hashes.json index 26d4168d9ff..d7135bfe7eb 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -1,5 +1,5 @@ { - "reflex/__init__.pyi": "5fa247402bc6edcfd80498d77aee6ebf", + "reflex/__init__.pyi": "276759cf35be6503c710e2203405adb6", "reflex/components/__init__.pyi": "ac05995852baa81062ba3d18fbc489fb", "reflex/components/base/__init__.pyi": "16e47bf19e0d62835a605baa3d039c5a", "reflex/components/base/app_wrap.pyi": "22e94feaa9fe675bcae51c412f5b67f1", @@ -19,7 +19,7 @@ "reflex/components/core/helmet.pyi": "43f8497c8fafe51e29dca1dd535d143a", "reflex/components/core/html.pyi": "86eb9d4c1bb4807547b2950d9a32e9fd", "reflex/components/core/sticky.pyi": "cb763b986a9b0654d1a3f33440dfcf60", - "reflex/components/core/upload.pyi": "fb63525ca5533e00ad47e9d42ff34a83", + "reflex/components/core/upload.pyi": "b9f253873aec3559d82e98e97ab42074", "reflex/components/core/window_events.pyi": "af33ccec866b9540ee7fbec6dbfbd151", "reflex/components/datadisplay/__init__.pyi": "52755871369acbfd3a96b46b9a11d32e", "reflex/components/datadisplay/code.pyi": "b86769987ef4d1cbdddb461be88539fd", diff --git a/reflex/.templates/web/utils/helpers/upload.js b/reflex/.templates/web/utils/helpers/upload.js index 5732f0668b8..6bbfc746ed6 100644 --- a/reflex/.templates/web/utils/helpers/upload.js +++ b/reflex/.templates/web/utils/helpers/upload.js @@ -1,11 +1,47 @@ import JSON5 from "json5"; import env from "$/env.json"; -const trackUploadResponse = (socket) => { +/** + * Upload files to the server. + * + * @param state The state to apply the delta to. + * @param handler The handler to use. + * @param upload_id The upload id to use. + * @param on_upload_progress The function to call on upload progress. + * @param socket the websocket connection + * @param extra_headers Extra headers to send with the request. + * @param refs The refs object to store the abort controller in. + * @param getBackendURL Function to get the backend URL. + * @param getToken Function to get the Reflex token. + * + * @returns The response from posting to the UPLOADURL endpoint. + */ +export const uploadFiles = async ( + handler, + files, + upload_id, + on_upload_progress, + extra_headers, + socket, + refs, + getBackendURL, + getToken, +) => { + // return if there's no file to upload + if (files === undefined || files.length === 0) { + return false; + } + + const upload_ref_name = `__upload_controllers_${upload_id}`; + + if (refs[upload_ref_name]) { + console.log("Upload already in progress for ", upload_id); + return false; + } + // Track how many partial updates have been processed for this upload. let resp_idx = 0; - - return (progressEvent) => { + const eventHandler = (progressEvent) => { const event_callbacks = socket._callbacks.$event; // Whenever called, responseText will contain the entire response so far. const chunks = progressEvent.event.target.responseText.trim().split("\n"); @@ -37,32 +73,22 @@ const trackUploadResponse = (socket) => { } }); }; -}; - -const sendUploadRequest = async ({ - handler, - upload_id, - on_upload_progress, - extra_headers, - refs, - getToken, - formdata, - url, - responseHandler, -}) => { - const upload_ref_name = `__upload_controllers_${upload_id}`; - - if (refs[upload_ref_name]) { - return false; - } const controller = new AbortController(); + const formdata = new FormData(); + // Add the token and handler to the file name. + files.forEach((file) => { + formdata.append("files", file, file.path || file.name); + }); + + // Send the file to the server. refs[upload_ref_name] = controller; return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); + // Set up event handlers xhr.onload = function () { if (xhr.status >= 200 && xhr.status < 300) { resolve({ @@ -86,36 +112,42 @@ const sendUploadRequest = async ({ reject(new Error("Upload aborted")); }; + // Handle upload progress if (on_upload_progress) { xhr.upload.onprogress = function (event) { if (event.lengthComputable) { - on_upload_progress({ + const progressEvent = { loaded: event.loaded, total: event.total, progress: event.loaded / event.total, - }); + }; + on_upload_progress(progressEvent); } }; } - if (responseHandler) { - xhr.onprogress = function (event) { - responseHandler({ + // Handle download progress with streaming response parsing + xhr.onprogress = function (event) { + if (eventHandler) { + const progressEvent = { event: { target: { responseText: xhr.responseText, }, }, progress: event.lengthComputable ? event.loaded / event.total : 0, - }); - }; - } + }; + eventHandler(progressEvent); + } + }; + // Handle abort controller controller.signal.addEventListener("abort", () => { xhr.abort(); }); - xhr.open("POST", url); + // Configure and send request + xhr.open("POST", getBackendURL(env.UPLOAD)); xhr.setRequestHeader("Reflex-Client-Token", getToken()); xhr.setRequestHeader("Reflex-Event-Handler", handler); for (const [key, value] of Object.entries(extra_headers || {})) { @@ -136,99 +168,3 @@ const sendUploadRequest = async ({ delete refs[upload_ref_name]; }); }; - -/** - * Upload files to the server. - * - * @param handler The handler to use. - * @param upload_id The upload id to use. - * @param on_upload_progress The function to call on upload progress. - * @param extra_headers Extra headers to send with the request. - * @param socket The websocket connection. - * @param refs The refs object to store the abort controller in. - * @param getBackendURL Function to get the backend URL. - * @param getToken Function to get the Reflex token. - * - * @returns The response from posting to the upload endpoint. - */ -export const uploadFiles = async ( - handler, - files, - upload_id, - on_upload_progress, - extra_headers, - socket, - refs, - getBackendURL, - getToken, -) => { - if (files === undefined || files.length === 0) { - return false; - } - - const formdata = new FormData(); - - files.forEach((file) => { - formdata.append("files", file, file.path || file.name); - }); - - return sendUploadRequest({ - handler, - upload_id, - on_upload_progress, - extra_headers, - refs, - getToken, - formdata, - url: getBackendURL(env.UPLOAD), - responseHandler: trackUploadResponse(socket), - }); -}; - -/** - * Upload files to the streaming chunk endpoint. - * - * @param handler The handler to use. - * @param files The files to upload. - * @param upload_id The upload id to use. - * @param on_upload_progress The function to call on upload progress. - * @param extra_headers Extra headers to send with the request. - * @param _socket The websocket connection. - * @param refs The refs object to store the abort controller in. - * @param getBackendURL Function to get the backend URL. - * @param getToken Function to get the Reflex token. - * - * @returns The response from posting to the chunk upload endpoint. - */ -export const uploadFilesChunk = async ( - handler, - files, - upload_id, - on_upload_progress, - extra_headers, - _socket, - refs, - getBackendURL, - getToken, -) => { - if (files === undefined || files.length === 0) { - return false; - } - - const formdata = new FormData(); - - files.forEach((file) => { - formdata.append("files", file, file.path || file.name); - }); - - return sendUploadRequest({ - handler, - upload_id, - on_upload_progress, - extra_headers, - refs, - getToken, - formdata, - url: getBackendURL(env.UPLOAD_CHUNK), - }); -}; diff --git a/reflex/.templates/web/utils/state.js b/reflex/.templates/web/utils/state.js index f54b05a7523..9e937ed62cd 100644 --- a/reflex/.templates/web/utils/state.js +++ b/reflex/.templates/web/utils/state.js @@ -20,7 +20,7 @@ import { } from "$/utils/context"; import debounce from "$/utils/helpers/debounce"; import throttle from "$/utils/helpers/throttle"; -import { uploadFiles, uploadFilesChunk } from "$/utils/helpers/upload"; +import { uploadFiles } from "$/utils/helpers/upload"; // Endpoint URLs. const EVENTURL = env.EVENT; @@ -418,15 +418,11 @@ export const applyEvent = async (event, socket, navigate, params) => { */ export const applyRestEvent = async (event, socket, navigate, params) => { let eventSent = false; - if (event.handler === "uploadFiles" || event.handler === "uploadFilesChunk") { - const filePayloadKey = event.payload.upload_param_name || "files"; - const uploadFilesPayload = - event.payload.files ?? event.payload[filePayloadKey]; - - if (uploadFilesPayload === undefined || uploadFilesPayload.length === 0) { + if (event.handler === "uploadFiles") { + if (event.payload.files === undefined || event.payload.files.length === 0) { // Submit the event over the websocket to trigger the event handler. return await applyEvent( - ReflexEvent(event.name, { [filePayloadKey]: [] }), + ReflexEvent(event.name, { files: [] }), socket, navigate, params, @@ -434,11 +430,9 @@ export const applyRestEvent = async (event, socket, navigate, params) => { } // Start upload, but do not wait for it, which would block other events. - const uploadFn = - event.handler === "uploadFilesChunk" ? uploadFilesChunk : uploadFiles; - uploadFn( + uploadFiles( event.name, - uploadFilesPayload, + event.payload.files, event.payload.upload_id, event.payload.on_upload_progress, event.payload.extra_headers, diff --git a/reflex/__init__.py b/reflex/__init__.py index 2496466bf7f..e3c832ced19 100644 --- a/reflex/__init__.py +++ b/reflex/__init__.py @@ -351,7 +351,6 @@ _SUBMODULES: set[str] = { "components", "app", - "upload", "style", "admin", "base", diff --git a/reflex/upload.py b/reflex/_upload.py similarity index 68% rename from reflex/upload.py rename to reflex/_upload.py index 386dca4302d..39968f169f9 100644 --- a/reflex/upload.py +++ b/reflex/_upload.py @@ -26,7 +26,7 @@ resolve_upload_chunk_handler_param, resolve_upload_handler_param, ) -from reflex.state import BaseState, RouterData, _substate_key +from reflex.state import BaseState, RouterData, StateUpdate, _substate_key from reflex.utils import exceptions from reflex.utils.types import Receive, Scope, Send @@ -348,8 +348,175 @@ def _seed_upload_router_data(state: BaseState, token: str) -> None: state.router = RouterData.from_router_data(router_data) +async def _upload_buffered_file( + request: Request, + app: App, + *, + token: str, + handler_name: str, + handler_upload_param: tuple[str, Any], +) -> Response: + """Handle buffered uploads on the standard upload endpoint. + + Returns: + A streaming response for the buffered upload. + """ + from reflex.utils.exceptions import UploadValueError + + try: + form_data = await request.form() + except ClientDisconnect: + return Response() + + form_data_closed = False + + async def _close_form_data() -> None: + """Close the parsed form data exactly once.""" + nonlocal form_data_closed + if form_data_closed: + return + form_data_closed = True + await form_data.close() + + def _create_upload_event() -> Event: + """Create an upload event using the live Starlette temp files. + + Returns: + The upload event backed by the parsed files. + """ + files = form_data.getlist("files") + if not files: + msg = "No files were uploaded." + raise UploadValueError(msg) + + file_uploads = [] + for file in files: + if not isinstance(file, StarletteUploadFile): + raise UploadValueError( + "Uploaded file is not an UploadFile." + str(file) + ) + file_uploads.append( + UploadFile( + file=file.file, + path=Path(file.filename.lstrip("/")) if file.filename else None, + size=file.size, + headers=file.headers, + ) + ) + + return Event( + token=token, + name=handler_name, + payload={handler_upload_param[0]: file_uploads}, + ) + + event: Event | None = None + try: + event = _create_upload_event() + finally: + if event is None: + await _close_form_data() + + if event is None: + msg = "Upload event was not created." + raise RuntimeError(msg) + + async def _ndjson_updates(): + """Process the upload event, generating ndjson updates. + + Yields: + Each state update as newline-delimited JSON. + """ + async with app.state_manager.modify_state_with_links( + event.substate_token + ) as state: + async for update in state._process(event): + update = await app._postprocess(state, event, update) + yield update.json() + "\n" + + return _UploadStreamingResponse( + _ndjson_updates(), + media_type="application/x-ndjson", + on_finish=_close_form_data, + ) + + +def _background_upload_accepted_response() -> StreamingResponse: + """Return a minimal ndjson response for background upload dispatch.""" + + def _accepted_updates(): + yield StateUpdate(final=True).json() + "\n" + + return StreamingResponse( + _accepted_updates(), + media_type="application/x-ndjson", + status_code=202, + ) + + +async def _upload_chunk_file( + request: Request, + app: App, + *, + token: str, + handler_name: str, + handler_upload_param: tuple[str, Any], + acknowledge_on_upload_endpoint: bool, +) -> Response: + """Handle a streaming upload request. + + Returns: + The streaming upload response. + """ + chunk_iter = UploadChunkIterator(maxsize=8) + event = Event( + token=token, + name=handler_name, + payload={handler_upload_param[0]: chunk_iter}, + ) + + async with app.state_manager.modify_state_with_links( + event.substate_token, + event=event, + ) as state: + _seed_upload_router_data(state, token) + task = app._process_background(state, event) + + if task is None: + msg = f"@rx.event(background=True) is required for upload_files_chunk handler `{handler_name}`." + return JSONResponse({"detail": msg}, status_code=400) + + chunk_iter.set_consumer_task(task) + + parser = _UploadChunkMultipartParser( + request.headers, + request.stream(), + chunk_iter, + ) + + try: + await parser.parse() + except ClientDisconnect: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + return Response() + except (MultiPartException, RuntimeError, ValueError) as err: + await chunk_iter.fail(err) + return JSONResponse({"detail": str(err)}, status_code=400) + + try: + await chunk_iter.finish() + except RuntimeError as err: + return JSONResponse({"detail": str(err)}, status_code=400) + + if acknowledge_on_upload_endpoint: + return _background_upload_accepted_response() + return Response(status_code=202) + + def upload(app: App): - """Upload a file. + """Upload files, dispatching to buffered or streaming handling. Args: app: The app to upload the file for. @@ -365,94 +532,40 @@ async def upload_file(request: Request): request: The Starlette request object. Returns: - StreamingResponse yielding newline-delimited JSON of StateUpdate - emitted by the upload handler. + The upload response. Raises: - UploadValueError: if there are no args with supported annotation. - UploadTypeError: if a background task is used as the handler. + UploadValueError: If the handler does not have a supported annotation. + UploadTypeError: If a non-streaming upload is wired to a background task. HTTPException: when the request does not include token / handler headers. """ - from reflex.utils.exceptions import UploadValueError - - try: - form_data = await request.form() - except ClientDisconnect: - return Response() - - form_data_closed = False - - async def _close_form_data() -> None: - """Close the parsed form data exactly once.""" - nonlocal form_data_closed - if form_data_closed: - return - form_data_closed = True - await form_data.close() - - async def _create_upload_event() -> Event: - """Create an upload event using the live Starlette temp files. - - Returns: - The upload event backed by the original temp files. - """ - files = form_data.getlist("files") - if not files: - msg = "No files were uploaded." - raise UploadValueError(msg) - - token, handler = _require_upload_headers(request) + token, handler_name = _require_upload_headers(request) + _state, event_handler = await _get_upload_runtime_handler( + app, token, handler_name + ) - _state, event_handler = await _get_upload_runtime_handler( - app, token, handler - ) - handler_upload_param = resolve_upload_handler_param(event_handler) - - file_uploads = [] - for file in files: - if not isinstance(file, StarletteUploadFile): - raise UploadValueError( - "Uploaded file is not an UploadFile." + str(file) - ) - file_uploads.append( - UploadFile( - file=file.file, - path=Path(file.filename.lstrip("/")) if file.filename else None, - size=file.size, - headers=file.headers, - ) + if event_handler.is_background: + try: + handler_upload_param = resolve_upload_chunk_handler_param(event_handler) + except exceptions.UploadValueError: + handler_upload_param = None + else: + return await _upload_chunk_file( + request, + app, + token=token, + handler_name=handler_name, + handler_upload_param=handler_upload_param, + acknowledge_on_upload_endpoint=True, ) - return Event( - token=token, - name=handler, - payload={handler_upload_param[0]: file_uploads}, - ) - - event: Event | None = None - try: - event = await _create_upload_event() - finally: - if event is None: - await _close_form_data() - - async def _ndjson_updates(): - """Process the upload event, generating ndjson updates. - - Yields: - Each state update as JSON followed by a new line. - """ - async with app.state_manager.modify_state_with_links( - event.substate_token - ) as state: - async for update in state._process(event): - update = await app._postprocess(state, event, update) - yield update.json() + "\n" - - return _UploadStreamingResponse( - _ndjson_updates(), - media_type="application/x-ndjson", - on_finish=_close_form_data, + handler_upload_param = resolve_upload_handler_param(event_handler) + return await _upload_buffered_file( + request, + app, + token=token, + handler_name=handler_name, + handler_upload_param=handler_upload_param, ) return upload_file @@ -491,47 +604,13 @@ async def upload_file_chunk(request: Request): except (exceptions.UploadTypeError, RuntimeError, ValueError) as err: return JSONResponse({"detail": str(err)}, status_code=400) - chunk_iter = UploadChunkIterator(maxsize=8) - event = Event( + return await _upload_chunk_file( + request, + app, token=token, - name=handler_name, - payload={handler_upload_param[0]: chunk_iter}, - ) - - async with app.state_manager.modify_state_with_links( - event.substate_token, - event=event, - ) as state: - _seed_upload_router_data(state, token) - task = app._process_background(state, event) - - if task is None: - msg = f"@rx.event(background=True) is required for upload_files_chunk handler `{handler_name}`." - return JSONResponse({"detail": msg}, status_code=400) - - chunk_iter.set_consumer_task(task) - - parser = _UploadChunkMultipartParser( - request.headers, - request.stream(), - chunk_iter, + handler_name=handler_name, + handler_upload_param=handler_upload_param, + acknowledge_on_upload_endpoint=False, ) - try: - await parser.parse() - except ClientDisconnect: - task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await task - return Response() - except (MultiPartException, RuntimeError, ValueError) as err: - await chunk_iter.fail(err) - return JSONResponse({"detail": str(err)}, status_code=400) - - try: - await chunk_iter.finish() - except RuntimeError as err: - return JSONResponse({"detail": str(err)}, status_code=400) - return Response(status_code=202) - return upload_file_chunk diff --git a/reflex/app.py b/reflex/app.py index 7b52a8563b8..c976b40a3fe 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -40,6 +40,8 @@ from starlette.staticfiles import StaticFiles from reflex import constants +from reflex._upload import UploadFile as UploadFile +from reflex._upload import upload, upload_chunk from reflex.admin import AdminDash from reflex.app_mixins import AppMixin, LifespanMixin, MiddlewareMixin from reflex.compiler import compiler @@ -97,8 +99,6 @@ all_base_state_classes, code_uses_state_contexts, ) -from reflex.upload import UploadFile as UploadFile -from reflex.upload import upload, upload_chunk from reflex.utils import ( codespaces, console, diff --git a/reflex/components/core/upload.py b/reflex/components/core/upload.py index d8b324ac9f2..2f05781d16b 100644 --- a/reflex/components/core/upload.py +++ b/reflex/components/core/upload.py @@ -6,6 +6,7 @@ from pathlib import Path from typing import Any, ClassVar +from reflex._upload import UploadFile from reflex.components.base.fragment import Fragment from reflex.components.component import ( Component, @@ -35,7 +36,6 @@ upload_files, ) from reflex.style import Style -from reflex.upload import UploadFile from reflex.utils import format from reflex.utils.imports import ImportVar from reflex.vars import VarData diff --git a/reflex/event.py b/reflex/event.py index 57c06b05cc0..0fa3ab97956 100644 --- a/reflex/event.py +++ b/reflex/event.py @@ -1236,7 +1236,7 @@ def as_event_spec(self, handler: EventHandler) -> EventSpec: upload_param_name, _annotation = resolve_upload_chunk_handler_param(handler) return self._as_event_spec( handler, - client_handler_name="uploadFilesChunk", + client_handler_name="uploadFiles", upload_param_name=upload_param_name, ) diff --git a/reflex/state.py b/reflex/state.py index 49c6703088b..1301644b31d 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -140,6 +140,53 @@ async def _no_chain_background_task_gen(*args, **kwargs): # noqa: RUF029 raise TypeError(msg) +async def _normalize_upload_payload( + handler: EventHandler, + payload: builtins.dict[str, Any], +) -> None: + """Normalize upload payload keys before invoking a handler. + + The frontend always uses the ``files`` key when it falls back to queueing an + empty upload event over the websocket. Server-side upload endpoints already + resolve the real handler parameter name for non-empty uploads, so this keeps + empty uploads working without extra frontend branching. + + Args: + handler: The event handler receiving the payload. + payload: The event payload to normalize in place. + + Raises: + ValueError: If a chunk upload handler is invoked outside the upload endpoint. + """ + if "files" not in payload: + return + + try: + upload_param_name, _annotation = event.resolve_upload_handler_param(handler) + except (TypeError, ValueError): + pass + else: + if upload_param_name != "files" and upload_param_name not in payload: + payload[upload_param_name] = payload.pop("files") + return + + try: + upload_param_name, _annotation = event.resolve_upload_chunk_handler_param( + handler + ) + except (TypeError, ValueError): + return + + upload_value = payload.pop("files") + if upload_value != []: + msg = "Upload chunk handlers must be invoked through the upload endpoint." + raise ValueError(msg) + + chunk_iter = event.UploadChunkIterator(maxsize=1) + await chunk_iter.finish() + payload[upload_param_name] = chunk_iter + + def _substate_key( token: str, state_cls_or_name: BaseState | type[BaseState] | str | Sequence[str], @@ -1895,6 +1942,8 @@ async def _process_event( except Exception: type_hints = {} + await _normalize_upload_payload(handler, payload) + for arg, value in list(payload.items()): hinted_args = type_hints.get(arg, Any) if hinted_args is Any: diff --git a/reflex/utils/pyi_generator.py b/reflex/utils/pyi_generator.py index a1ac27563b0..5adb91f1519 100644 --- a/reflex/utils/pyi_generator.py +++ b/reflex/utils/pyi_generator.py @@ -93,7 +93,7 @@ "PointerEventInfo", ], "reflex.style": ["Style"], - "reflex.upload": ["UploadFile"], + "reflex._upload": ["UploadFile"], "reflex.vars.base": ["Var"], } diff --git a/tests/units/components/core/test_upload.py b/tests/units/components/core/test_upload.py index b9dfb4bb976..334022cd250 100644 --- a/tests/units/components/core/test_upload.py +++ b/tests/units/components/core/test_upload.py @@ -161,7 +161,7 @@ def test_upload_create(): up_comp_5 = Upload.create( id="foo_id", on_drop=StreamingUploadStateTest.chunk_drop_handler( - cast(Any, rx.upload_files_chunk(upload_id="foo_id")) + rx.upload_files_chunk(upload_id="foo_id") # pyright: ignore[reportArgumentType] ), ) assert isinstance(up_comp_5, Upload) @@ -170,7 +170,7 @@ def test_upload_create(): up_comp_6 = Upload.create( id="foo_id", on_drop=StreamingUploadStateTest.chunk_upload_alias_handler( - cast(Any, rx.upload_files_chunk(upload_id="foo_id")) + rx.upload_files_chunk(upload_id="foo_id") # pyright: ignore[reportArgumentType] ), ) assert isinstance(up_comp_6, Upload) @@ -187,17 +187,19 @@ def test_upload_button_handlers_allow_custom_param_names(): legacy_chain = cast(EventChain, legacy_button.event_triggers["on_click"]) legacy_event = cast(EventSpec, legacy_chain.events[0]) legacy_arg_names = [arg[0]._js_expr for arg in legacy_event.args] + assert legacy_event.client_handler_name == "uploadFiles" assert legacy_arg_names[:3] == ["files", "uploads", "upload_param_name"] chunk_button = rx.button( "Upload", on_click=StreamingUploadStateTest.chunk_upload_alias_handler( - cast(Any, rx.upload_files_chunk(upload_id="foo_id")) + rx.upload_files_chunk(upload_id="foo_id") # pyright: ignore[reportArgumentType] ), ) chunk_chain = cast(EventChain, chunk_button.event_triggers["on_click"]) chunk_event = cast(EventSpec, chunk_chain.events[0]) chunk_arg_names = [arg[0]._js_expr for arg in chunk_event.args] + assert chunk_event.client_handler_name == "uploadFiles" assert chunk_arg_names[:3] == ["files", "stream", "upload_param_name"] diff --git a/tests/units/test_app.py b/tests/units/test_app.py index 630362f2b9d..740070bca4f 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -1092,7 +1092,7 @@ async def test_upload_file_closes_form_on_event_creation_cancellation( token: str, mocker: MockerFixture, ): - """Test that cancellation during upload event creation closes form data.""" + """Test that cancellation before form parsing leaves form data untouched.""" mocker.patch( "reflex.state.State.class_subclasses", {FileUploadState}, @@ -1125,8 +1125,8 @@ async def cancelled_get_state(*_args, **_kwargs): with pytest.raises(asyncio.CancelledError): await upload_fn(request_mock) - assert form_close.await_count == 1 - assert file1.file.closed + assert form_close.await_count == 0 + assert not file1.file.closed await app.state_manager.close() @@ -1388,8 +1388,9 @@ async def test_upload_chunk_streams_chunks(tmp_path, token: str, mocker: MockerF ) assert response.status_code == 202 - task_results = await _drain_background_tasks(app) - assert task_results == [None] + if app._background_tasks: + task_results = await _drain_background_tasks(app) + assert task_results == [None] state = await app.state_manager.get_state(_substate_key(token, ChunkUploadState)) substate = ( @@ -1439,6 +1440,76 @@ async def test_upload_chunk_streams_chunks(tmp_path, token: str, mocker: MockerF await app.state_manager.close() +@pytest.mark.asyncio +async def test_upload_dispatches_chunk_handlers_on_upload_endpoint( + tmp_path, + token: str, + mocker: MockerFixture, +): + """Test that the standard upload endpoint dispatches chunk handlers.""" + mocker.patch( + "reflex.state.State.class_subclasses", + {ChunkUploadState}, + ) + app = App() + mocker.patch( + "reflex.utils.prerequisites.get_and_validate_app", + return_value=SimpleNamespace(app=app), + ) + app.event_namespace.emit_update = AsyncMock() # pyright: ignore [reportOptionalMemberAccess] + + async with app.modify_state(_substate_key(token, ChunkUploadState)) as root_state: + substate = root_state.get_substate(ChunkUploadState.get_full_name().split(".")) + substate._tmp_path = tmp_path + substate.chunk_records = [] + substate.completed_files = [] + + upload_fn = upload(app) + boundary = "chunk-upload-on-upload-endpoint-boundary" + response = await upload_fn( + _make_chunk_upload_request( + token, + f"{ChunkUploadState.get_full_name()}.chunk_handle_upload", + _build_chunk_upload_multipart_body( + boundary, + [ + ("files", "alpha.txt", "text/plain", b"abcde"), + ("files", "beta.txt", "text/plain", b"12345"), + ], + ), + content_type=f"multipart/form-data; boundary={boundary}", + stream_chunk_size=1, + ) + ) + + assert isinstance(response, StreamingResponse) + assert response.status_code == 202 + + updates = [] + async for state_update in response.body_iterator: + updates.append(json.loads(str(state_update))) + assert updates == [{"delta": {}, "events": [], "final": True}] + + if app._background_tasks: + task_results = await _drain_background_tasks(app) + assert task_results == [None] + + state = await app.state_manager.get_state(_substate_key(token, ChunkUploadState)) + substate = ( + state + if isinstance(state, ChunkUploadState) + else state.get_substate(ChunkUploadState.get_full_name().split(".")) + ) + assert isinstance(substate, ChunkUploadState) + assert substate.completed_files == ["alpha.txt", "beta.txt"] + assert (tmp_path / "alpha.txt").read_bytes() == b"abcde" + assert (tmp_path / "beta.txt").read_bytes() == b"12345" + assert app.event_namespace.emit_update.await_count >= 1 # pyright: ignore [reportOptionalMemberAccess] + assert not app._background_tasks + + await app.state_manager.close() + + @pytest.mark.asyncio async def test_upload_chunk_invalid_offset_returns_400( token: str, From ee66b399db93646f8edd399c9197de3bdde13575 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Thu, 19 Mar 2026 02:28:55 +0500 Subject: [PATCH 05/12] test" removed test and fix tests --- tests/units/components/core/test_upload.py | 13 ------------- tests/units/test_app.py | 10 ++++------ 2 files changed, 4 insertions(+), 19 deletions(-) diff --git a/tests/units/components/core/test_upload.py b/tests/units/components/core/test_upload.py index 334022cd250..ddeed335c59 100644 --- a/tests/units/components/core/test_upload.py +++ b/tests/units/components/core/test_upload.py @@ -72,19 +72,6 @@ def test_get_upload_url(): assert isinstance(url, Var) -def test_upload_files_chunk_export(): - chunk = rx.UploadChunk( - filename="foo.txt", - offset=0, - content_type="text/plain", - data=b"hello", - ) - - assert chunk.filename == "foo.txt" - assert isinstance(rx.UploadChunkIterator(), rx.UploadChunkIterator) - assert callable(rx.upload_files_chunk) - - def test__on_drop_spec(): assert isinstance(_on_drop_spec(LiteralVar.create([])), tuple) diff --git a/tests/units/test_app.py b/tests/units/test_app.py index 740070bca4f..913dd2e2238 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -1388,9 +1388,8 @@ async def test_upload_chunk_streams_chunks(tmp_path, token: str, mocker: MockerF ) assert response.status_code == 202 - if app._background_tasks: - task_results = await _drain_background_tasks(app) - assert task_results == [None] + task_results = await _drain_background_tasks(app) + assert all(result is None for result in task_results) state = await app.state_manager.get_state(_substate_key(token, ChunkUploadState)) substate = ( @@ -1490,9 +1489,8 @@ async def test_upload_dispatches_chunk_handlers_on_upload_endpoint( updates.append(json.loads(str(state_update))) assert updates == [{"delta": {}, "events": [], "final": True}] - if app._background_tasks: - task_results = await _drain_background_tasks(app) - assert task_results == [None] + task_results = await _drain_background_tasks(app) + assert all(result is None for result in task_results) state = await app.state_manager.get_state(_substate_key(token, ChunkUploadState)) substate = ( From a00d5abcb5505dad295e118fbf43f55ba412a4d9 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Thu, 19 Mar 2026 02:46:44 +0500 Subject: [PATCH 06/12] refactor: cleanup --- pyi_hashes.json | 2 +- reflex/_upload.py | 52 ++---------------- reflex/app.py | 7 +-- reflex/components/core/upload.py | 10 ++-- reflex/constants/event.py | 1 - reflex/event.py | 7 +-- tests/units/test_app.py | 93 ++++++-------------------------- 7 files changed, 31 insertions(+), 141 deletions(-) diff --git a/pyi_hashes.json b/pyi_hashes.json index d7135bfe7eb..cfedb823784 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -19,7 +19,7 @@ "reflex/components/core/helmet.pyi": "43f8497c8fafe51e29dca1dd535d143a", "reflex/components/core/html.pyi": "86eb9d4c1bb4807547b2950d9a32e9fd", "reflex/components/core/sticky.pyi": "cb763b986a9b0654d1a3f33440dfcf60", - "reflex/components/core/upload.pyi": "b9f253873aec3559d82e98e97ab42074", + "reflex/components/core/upload.pyi": "3f1405e4d76ace532e2259c39fb0b86f", "reflex/components/core/window_events.pyi": "af33ccec866b9540ee7fbec6dbfbd151", "reflex/components/datadisplay/__init__.pyi": "52755871369acbfd3a96b46b9a11d32e", "reflex/components/datadisplay/code.pyi": "b86769987ef4d1cbdddb461be88539fd", diff --git a/reflex/_upload.py b/reflex/_upload.py index 39968f169f9..dba50ac9d70 100644 --- a/reflex/_upload.py +++ b/reflex/_upload.py @@ -5,6 +5,7 @@ import asyncio import contextlib import dataclasses +from collections import deque from collections.abc import AsyncGenerator, Awaitable, Callable from pathlib import Path from typing import TYPE_CHECKING, Any, BinaryIO, cast @@ -104,7 +105,7 @@ def __init__( self._current_partial_header_name = b"" self._current_partial_header_value = b"" self._current_part = _UploadChunkPart() - self._chunks_to_emit: list[UploadChunk] = [] + self._chunks_to_emit: deque[UploadChunk] = deque() self._seen_upload_chunk = False self._part_count = 0 self._emitted_chunk_count = 0 @@ -216,7 +217,7 @@ def on_end(self) -> None: async def _flush_emitted_chunks(self) -> None: """Push parsed upload chunks into the handler iterator.""" while self._chunks_to_emit: - await self.chunk_iter.push(self._chunks_to_emit.pop(0)) + await self.chunk_iter.push(self._chunks_to_emit.popleft()) async def parse(self) -> None: """Parse the incoming request stream and push chunks to the iterator. @@ -548,7 +549,7 @@ async def upload_file(request: Request): try: handler_upload_param = resolve_upload_chunk_handler_param(event_handler) except exceptions.UploadValueError: - handler_upload_param = None + pass else: return await _upload_chunk_file( request, @@ -569,48 +570,3 @@ async def upload_file(request: Request): ) return upload_file - - -def upload_chunk(app: App): - """Upload file chunks to a background event handler. - - Args: - app: The app to upload the file for. - - Returns: - The streaming upload function. - """ - - async def upload_file_chunk(request: Request): - """Upload file chunks without buffering the full file in memory. - - Args: - request: The Starlette request object. - - Returns: - A response indicating whether the upload stream was accepted. - - Raises: - UploadTypeError: If the handler is not a background event. - UploadValueError: If the handler signature is invalid. - HTTPException: If the request is missing required headers. - """ - token, handler_name = _require_upload_headers(request) - try: - _state, event_handler = await _get_upload_runtime_handler( - app, token, handler_name - ) - handler_upload_param = resolve_upload_chunk_handler_param(event_handler) - except (exceptions.UploadTypeError, RuntimeError, ValueError) as err: - return JSONResponse({"detail": str(err)}, status_code=400) - - return await _upload_chunk_file( - request, - app, - token=token, - handler_name=handler_name, - handler_upload_param=handler_upload_param, - acknowledge_on_upload_endpoint=False, - ) - - return upload_file_chunk diff --git a/reflex/app.py b/reflex/app.py index c976b40a3fe..5cf2736baae 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -41,7 +41,7 @@ from reflex import constants from reflex._upload import UploadFile as UploadFile -from reflex._upload import upload, upload_chunk +from reflex._upload import upload from reflex.admin import AdminDash from reflex.app_mixins import AppMixin, LifespanMixin, MiddlewareMixin from reflex.compiler import compiler @@ -663,11 +663,6 @@ def _add_optional_endpoints(self): upload(self), methods=["POST"], ) - self._api.add_route( - str(constants.Endpoint.UPLOAD_CHUNK), - upload_chunk(self), - methods=["POST"], - ) # To access uploaded files. self._api.mount( diff --git a/reflex/components/core/upload.py b/reflex/components/core/upload.py index 2f05781d16b..e7cab1ccc9b 100644 --- a/reflex/components/core/upload.py +++ b/reflex/components/core/upload.py @@ -177,6 +177,7 @@ def get_upload_url(file_path: str | Var[str]) -> Var[str]: _on_drop_spec, passthrough_event_spec(UploadChunkIterator), ) +_UPLOAD_FILES_CLIENT_HANDLER = "uploadFiles" def _default_drop_rejected(rejected_files: ArrayVar[list[dict[str, Any]]]) -> EventSpec: @@ -220,7 +221,7 @@ class GhostUpload(Fragment): on_drop: EventHandler[_on_drop_args_spec] # Fired when dropped files do not meet the specified criteria. - on_drop_rejected: EventHandler[_on_drop_args_spec] + on_drop_rejected: EventHandler[_on_drop_spec] class Upload(MemoizationLeaf): @@ -266,7 +267,7 @@ class Upload(MemoizationLeaf): on_drop: EventHandler[_on_drop_args_spec] # Fired when dropped files do not meet the specified criteria. - on_drop_rejected: EventHandler[_on_drop_args_spec] + on_drop_rejected: EventHandler[_on_drop_spec] # Style rules to apply when actively dragging. drag_active_style: Style | None = field(default=None, is_javascript_property=False) @@ -315,10 +316,7 @@ def create(cls, *children, **props) -> Component: if isinstance(event, EventHandler): event = event(upload_files(upload_id)) if isinstance(event, EventSpec): - if event.client_handler_name not in { - "uploadFiles", - "uploadFilesChunk", - }: + if event.client_handler_name != _UPLOAD_FILES_CLIENT_HANDLER: # Call the lambda to get the event chain. event = call_event_handler(event, _on_drop_args_spec) elif isinstance(event, Callable): diff --git a/reflex/constants/event.py b/reflex/constants/event.py index 9fb5305c20a..6a0f71ec161 100644 --- a/reflex/constants/event.py +++ b/reflex/constants/event.py @@ -10,7 +10,6 @@ class Endpoint(Enum): PING = "ping" EVENT = "_event" UPLOAD = "_upload" - UPLOAD_CHUNK = "_upload_chunk" AUTH_CODESPACE = "auth-codespace" HEALTH = "_health" ALL_ROUTES = "_all_routes" diff --git a/reflex/event.py b/reflex/event.py index 0fa3ab97956..47b3f0ed611 100644 --- a/reflex/event.py +++ b/reflex/event.py @@ -92,6 +92,7 @@ def substate_token(self) -> str: BACKGROUND_TASK_MARKER = "_reflex_background_task" EVENT_ACTIONS_MARKER = "_rx_event_actions" +UPLOAD_FILES_CLIENT_HANDLER = "uploadFiles" @dataclasses.dataclass( @@ -266,7 +267,7 @@ def resolve_upload_handler_param(handler: "EventHandler") -> tuple[str, Any]: UploadTypeError: If the handler is a background task. UploadValueError: If the handler does not accept ``list[rx.UploadFile]``. """ - from reflex.app import UploadFile + from reflex._upload import UploadFile from reflex.utils.exceptions import UploadTypeError, UploadValueError handler_name = _handler_name(handler) @@ -1208,7 +1209,7 @@ def as_event_spec(self, handler: EventHandler) -> EventSpec: upload_param_name = "files" return self._as_event_spec( handler, - client_handler_name="uploadFiles", + client_handler_name=UPLOAD_FILES_CLIENT_HANDLER, upload_param_name=upload_param_name, ) @@ -1236,7 +1237,7 @@ def as_event_spec(self, handler: EventHandler) -> EventSpec: upload_param_name, _annotation = resolve_upload_chunk_handler_param(handler) return self._as_event_spec( handler, - client_handler_name="uploadFiles", + client_handler_name=UPLOAD_FILES_CLIENT_HANDLER, upload_param_name=upload_param_name, ) diff --git a/tests/units/test_app.py b/tests/units/test_app.py index 913dd2e2238..40bd8c0327d 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -28,7 +28,6 @@ default_overlay_component, process, upload, - upload_chunk, ) from reflex.components import Component from reflex.components.base.bare import Bare @@ -1350,8 +1349,12 @@ async def _drain_background_tasks(app: App): @pytest.mark.asyncio -async def test_upload_chunk_streams_chunks(tmp_path, token: str, mocker: MockerFixture): - """Test streaming upload chunks through the background upload endpoint.""" +async def test_upload_dispatches_chunk_handlers_on_upload_endpoint( + tmp_path, + token: str, + mocker: MockerFixture, +): + """Test that the standard upload endpoint dispatches chunk handlers.""" mocker.patch( "reflex.state.State.class_subclasses", {ChunkUploadState}, @@ -1369,8 +1372,8 @@ async def test_upload_chunk_streams_chunks(tmp_path, token: str, mocker: MockerF substate.chunk_records = [] substate.completed_files = [] - upload_fn = upload_chunk(app) - boundary = "chunk-upload-boundary" + upload_fn = upload(app) + boundary = "chunk-upload-on-upload-endpoint-boundary" response = await upload_fn( _make_chunk_upload_request( token, @@ -1386,8 +1389,15 @@ async def test_upload_chunk_streams_chunks(tmp_path, token: str, mocker: MockerF stream_chunk_size=1, ) ) + + assert isinstance(response, StreamingResponse) assert response.status_code == 202 + updates = [] + async for state_update in response.body_iterator: + updates.append(json.loads(str(state_update))) + assert updates == [{"delta": {}, "events": [], "final": True}] + task_results = await _drain_background_tasks(app) assert all(result is None for result in task_results) @@ -1439,81 +1449,12 @@ async def test_upload_chunk_streams_chunks(tmp_path, token: str, mocker: MockerF await app.state_manager.close() -@pytest.mark.asyncio -async def test_upload_dispatches_chunk_handlers_on_upload_endpoint( - tmp_path, - token: str, - mocker: MockerFixture, -): - """Test that the standard upload endpoint dispatches chunk handlers.""" - mocker.patch( - "reflex.state.State.class_subclasses", - {ChunkUploadState}, - ) - app = App() - mocker.patch( - "reflex.utils.prerequisites.get_and_validate_app", - return_value=SimpleNamespace(app=app), - ) - app.event_namespace.emit_update = AsyncMock() # pyright: ignore [reportOptionalMemberAccess] - - async with app.modify_state(_substate_key(token, ChunkUploadState)) as root_state: - substate = root_state.get_substate(ChunkUploadState.get_full_name().split(".")) - substate._tmp_path = tmp_path - substate.chunk_records = [] - substate.completed_files = [] - - upload_fn = upload(app) - boundary = "chunk-upload-on-upload-endpoint-boundary" - response = await upload_fn( - _make_chunk_upload_request( - token, - f"{ChunkUploadState.get_full_name()}.chunk_handle_upload", - _build_chunk_upload_multipart_body( - boundary, - [ - ("files", "alpha.txt", "text/plain", b"abcde"), - ("files", "beta.txt", "text/plain", b"12345"), - ], - ), - content_type=f"multipart/form-data; boundary={boundary}", - stream_chunk_size=1, - ) - ) - - assert isinstance(response, StreamingResponse) - assert response.status_code == 202 - - updates = [] - async for state_update in response.body_iterator: - updates.append(json.loads(str(state_update))) - assert updates == [{"delta": {}, "events": [], "final": True}] - - task_results = await _drain_background_tasks(app) - assert all(result is None for result in task_results) - - state = await app.state_manager.get_state(_substate_key(token, ChunkUploadState)) - substate = ( - state - if isinstance(state, ChunkUploadState) - else state.get_substate(ChunkUploadState.get_full_name().split(".")) - ) - assert isinstance(substate, ChunkUploadState) - assert substate.completed_files == ["alpha.txt", "beta.txt"] - assert (tmp_path / "alpha.txt").read_bytes() == b"abcde" - assert (tmp_path / "beta.txt").read_bytes() == b"12345" - assert app.event_namespace.emit_update.await_count >= 1 # pyright: ignore [reportOptionalMemberAccess] - assert not app._background_tasks - - await app.state_manager.close() - - @pytest.mark.asyncio async def test_upload_chunk_invalid_offset_returns_400( token: str, mocker: MockerFixture, ): - """Test that malformed chunk metadata fails the upload request.""" + """Test that malformed chunk metadata fails the standard upload request.""" mocker.patch( "reflex.state.State.class_subclasses", {ChunkUploadState}, @@ -1530,7 +1471,7 @@ async def test_upload_chunk_invalid_offset_returns_400( substate.chunk_records = [] substate.completed_files = [] - upload_fn = upload_chunk(app) + upload_fn = upload(app) response = await upload_fn( _make_chunk_upload_request( token, From 3a4cf3c748576524d4d085b2b50e19c506b9db7a Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Thu, 19 Mar 2026 02:56:41 +0500 Subject: [PATCH 07/12] refactor: pyi hashes --- pyi_hashes.json | 210 ++++++++++++++++++++++++------------------------ 1 file changed, 105 insertions(+), 105 deletions(-) diff --git a/pyi_hashes.json b/pyi_hashes.json index cfedb823784..0b95e6655f4 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -2,121 +2,121 @@ "reflex/__init__.pyi": "276759cf35be6503c710e2203405adb6", "reflex/components/__init__.pyi": "ac05995852baa81062ba3d18fbc489fb", "reflex/components/base/__init__.pyi": "16e47bf19e0d62835a605baa3d039c5a", - "reflex/components/base/app_wrap.pyi": "22e94feaa9fe675bcae51c412f5b67f1", - "reflex/components/base/body.pyi": "e8ab029a730824bab6d4211203609e6a", - "reflex/components/base/document.pyi": "311c53c90a60587a82e760103758a3cf", - "reflex/components/base/error_boundary.pyi": "a678cceea014cb16048647257cd24ba6", - "reflex/components/base/fragment.pyi": "745f1be02c23a0b25d7c52d7423ec76a", - "reflex/components/base/link.pyi": "0bc1d26ee29d8864aed14a12991bd47d", - "reflex/components/base/meta.pyi": "129aecf65ab53f756c4d1cbe1d0b188d", - "reflex/components/base/script.pyi": "e5f506d1d0d6712cb9e597a781eb3941", - "reflex/components/base/strict_mode.pyi": "6b72e16caadf7158ab744a0ab751b010", + "reflex/components/base/app_wrap.pyi": "926061c72ef91977b3c8ad8ef61b2fc7", + "reflex/components/base/body.pyi": "3567e2cedcda8af51a50de586d1148b6", + "reflex/components/base/document.pyi": "b2f596ab56313e4095da49275b416c84", + "reflex/components/base/error_boundary.pyi": "068e7218be8c3d448ffa389d64baa39a", + "reflex/components/base/fragment.pyi": "f90b36a2fc68a48800b4ff3d2829b9e4", + "reflex/components/base/link.pyi": "f4a9382730a19ad8f00724ca8f099681", + "reflex/components/base/meta.pyi": "893a710f8b02adf6e82a00de4d0919d0", + "reflex/components/base/script.pyi": "58079b0c3ba85a9696d4736495fd8ccf", + "reflex/components/base/strict_mode.pyi": "4f50d728c10882e5d2c065ff23fe9409", "reflex/components/core/__init__.pyi": "007170b97e58bdf28b2aee381d91c0c7", - "reflex/components/core/auto_scroll.pyi": "18068d22aca7244a08cd0c5a897c0950", - "reflex/components/core/banner.pyi": "fd93e7a92961de8524718ad32135c37c", - "reflex/components/core/clipboard.pyi": "a844eb927d9bc2a43f5e88161b258539", - "reflex/components/core/debounce.pyi": "055da7aa890f44fb4d48bd5978f1a874", - "reflex/components/core/helmet.pyi": "43f8497c8fafe51e29dca1dd535d143a", - "reflex/components/core/html.pyi": "86eb9d4c1bb4807547b2950d9a32e9fd", - "reflex/components/core/sticky.pyi": "cb763b986a9b0654d1a3f33440dfcf60", + "reflex/components/core/auto_scroll.pyi": "8a308e6b2bff05d8ebb3c7438b256766", + "reflex/components/core/banner.pyi": "9732eb647f18c348fd3fda01c1440c65", + "reflex/components/core/clipboard.pyi": "f8b6abb2f9f5adba04151614f5381770", + "reflex/components/core/debounce.pyi": "47ea18b7e4f6e4173344771b0351091f", + "reflex/components/core/helmet.pyi": "7aa6b4edc854489707adb268ae8bff91", + "reflex/components/core/html.pyi": "ef8fc8eca23d060272412de894b73f93", + "reflex/components/core/sticky.pyi": "e37a540debf6b97889bd9111ea2bf364", "reflex/components/core/upload.pyi": "3f1405e4d76ace532e2259c39fb0b86f", - "reflex/components/core/window_events.pyi": "af33ccec866b9540ee7fbec6dbfbd151", + "reflex/components/core/window_events.pyi": "a5df53a9698285e1f3a3cfddfb9dfaea", "reflex/components/datadisplay/__init__.pyi": "52755871369acbfd3a96b46b9a11d32e", - "reflex/components/datadisplay/code.pyi": "b86769987ef4d1cbdddb461be88539fd", - "reflex/components/datadisplay/dataeditor.pyi": "f8c1e816c9f22f4a7429f812214407f2", - "reflex/components/datadisplay/shiki_code_block.pyi": "1d53e75b6be0d3385a342e7b3011babd", + "reflex/components/datadisplay/code.pyi": "f91b4872ee1bf7257741972c2c515869", + "reflex/components/datadisplay/dataeditor.pyi": "b48fc74a32a8b4a11446e1175985b6e3", + "reflex/components/datadisplay/shiki_code_block.pyi": "313ce931c1df079aa6d156ff2cc3e1e6", "reflex/components/el/__init__.pyi": "0adfd001a926a2a40aee94f6fa725ecc", - "reflex/components/el/element.pyi": "c5974a92fbc310e42d0f6cfdd13472f4", + "reflex/components/el/element.pyi": "eb4a6307334bd5b0fa842cd8760b9f2d", "reflex/components/el/elements/__init__.pyi": "29512d7a6b29c6dc5ff68d3b31f26528", - "reflex/components/el/elements/base.pyi": "3f74c7ea573ea29b055b0cd48b040d2c", - "reflex/components/el/elements/forms.pyi": "8b6bb2fbaf4bad828b076e2f7c8444d0", - "reflex/components/el/elements/inline.pyi": "3549cd6ad45217aa6387800911b641c3", - "reflex/components/el/elements/media.pyi": "9b97220aa99783d402b6e278c4069043", - "reflex/components/el/elements/metadata.pyi": "24448004b7aa07f1225028a85bd49fef", - "reflex/components/el/elements/other.pyi": "0c4d5d0b955d8596bf6cf4a48d7decdb", - "reflex/components/el/elements/scripts.pyi": "d33df9f21f7e838376b2b5024beef7c9", - "reflex/components/el/elements/sectioning.pyi": "3c5a7e4caa9c25da0ae788f02466eac4", - "reflex/components/el/elements/tables.pyi": "686eb70ea7d8c4dafb0cc5c284e76184", - "reflex/components/el/elements/typography.pyi": "684e83dde887dba12badd0fb75c87c04", - "reflex/components/gridjs/datatable.pyi": "98a7e1b3f3b60cafcdfcd8879750ee42", - "reflex/components/lucide/icon.pyi": "dcb8773ef162f3ec5759efe11374cf5e", - "reflex/components/markdown/markdown.pyi": "dd74e8e9665b2a813ff799a7aa190b44", - "reflex/components/moment/moment.pyi": "e1952f1c2c82cef85d91e970d1be64ab", - "reflex/components/plotly/plotly.pyi": "4311a0aae2abcc9226abb6a273f96372", + "reflex/components/el/elements/base.pyi": "d7ca6baef5ab9b7210df3cbf17e75130", + "reflex/components/el/elements/forms.pyi": "bd10646610bccd41ca021119ad00c587", + "reflex/components/el/elements/inline.pyi": "dd26811a187044eea3cff93eea0c37d5", + "reflex/components/el/elements/media.pyi": "f9e7b7437e1e29f84434f0e989d01802", + "reflex/components/el/elements/metadata.pyi": "7b851fe57a8cc25dfbb97671b4be4b95", + "reflex/components/el/elements/other.pyi": "8dd65b7eeb0a610da92a86adbf5668cf", + "reflex/components/el/elements/scripts.pyi": "043e64a4d86f07fadf669fa08178fb9e", + "reflex/components/el/elements/sectioning.pyi": "f3c8078ab19ebb989285185874550992", + "reflex/components/el/elements/tables.pyi": "c28f6ba4a9bd1e5e5df09ddae984d5a4", + "reflex/components/el/elements/typography.pyi": "5bd0c2d6da7dad53125e66f9c0a01086", + "reflex/components/gridjs/datatable.pyi": "f7ef10e8c98e9390819ba844bfe2a203", + "reflex/components/lucide/icon.pyi": "6c5f33175257010f47e9209f9bbd1177", + "reflex/components/markdown/markdown.pyi": "710072cc8f2d6df281d871e3f0c03b91", + "reflex/components/moment/moment.pyi": "64b756800a3b4a731c0cf83da9585ad3", + "reflex/components/plotly/plotly.pyi": "c016835a480f7b2afd23e75a0a82c477", "reflex/components/radix/__init__.pyi": "5d8e3579912473e563676bfc71f29191", "reflex/components/radix/primitives/__init__.pyi": "01c388fe7a1f5426a16676404344edf6", - "reflex/components/radix/primitives/accordion.pyi": "19484eca0ad53f538f5db04c09921738", - "reflex/components/radix/primitives/base.pyi": "9ef34884fb6028dc017df5e2db639c81", - "reflex/components/radix/primitives/dialog.pyi": "9ee73362bb59619c482b6b0d07033f37", - "reflex/components/radix/primitives/drawer.pyi": "921e45dfaf5b9131ef27c561c3acca2e", - "reflex/components/radix/primitives/form.pyi": "78055e820703c98c3b838aa889566365", - "reflex/components/radix/primitives/progress.pyi": "c917952d57ddb3e138a40c4005120d5e", - "reflex/components/radix/primitives/slider.pyi": "4ff06f0025d47f166132909b09ab96f8", + "reflex/components/radix/primitives/accordion.pyi": "a81af77072d1aedec710897bdad22c9a", + "reflex/components/radix/primitives/base.pyi": "fa1539190f969b6b68455e6414cd6e7f", + "reflex/components/radix/primitives/dialog.pyi": "987363d0d09b2651263b768665599042", + "reflex/components/radix/primitives/drawer.pyi": "47835cf5394348af2ca66b05069eb904", + "reflex/components/radix/primitives/form.pyi": "8fb89c217583d1c977d8b5b3469b5e74", + "reflex/components/radix/primitives/progress.pyi": "79e9c888b000eaf53cdcbef99b737aff", + "reflex/components/radix/primitives/slider.pyi": "021d0310b62dec4e328bf1db2fbf470b", "reflex/components/radix/themes/__init__.pyi": "582b4a7ead62b2ae8605e17fa084c063", - "reflex/components/radix/themes/base.pyi": "3e1ccd5ce5fef0b2898025193ee3d069", - "reflex/components/radix/themes/color_mode.pyi": "dda570583355d8c0d8f607be457ba7a1", + "reflex/components/radix/themes/base.pyi": "d343c09e67ce7f01c970cc48584f0427", + "reflex/components/radix/themes/color_mode.pyi": "918826f0cc0b2b21c8e37ef3d637c245", "reflex/components/radix/themes/components/__init__.pyi": "efa279ee05479d7bb8a64d49da808d03", - "reflex/components/radix/themes/components/alert_dialog.pyi": "eed422fcc1ff5ccf3dbf6934699bd0b1", - "reflex/components/radix/themes/components/aspect_ratio.pyi": "71de4160d79840561c48b570197a4152", - "reflex/components/radix/themes/components/avatar.pyi": "e40c2f0fda6d2c028d83681a27f3fb96", - "reflex/components/radix/themes/components/badge.pyi": "58fd1a9c5d2f8762e2a0370311731ff5", - "reflex/components/radix/themes/components/button.pyi": "50f0b08ad5d1d1054ab537152f0f5c43", - "reflex/components/radix/themes/components/callout.pyi": "547f2570ffbd10db36b745566e9f1b17", - "reflex/components/radix/themes/components/card.pyi": "f7adb83f7b001a11bdd7fd6791fb3ffb", - "reflex/components/radix/themes/components/checkbox.pyi": "8eabb6887a5d0849a43e086a284814c2", - "reflex/components/radix/themes/components/checkbox_cards.pyi": "1d567fd04b4425abd5cc5aad10108aa9", - "reflex/components/radix/themes/components/checkbox_group.pyi": "8638582a623036f8893a3fa6080f2672", - "reflex/components/radix/themes/components/context_menu.pyi": "b9499d8bdd2c5565621fea5fe7d7a25a", - "reflex/components/radix/themes/components/data_list.pyi": "6f8d9c582e084c23966b992158193b72", - "reflex/components/radix/themes/components/dialog.pyi": "d2615f1a68c80ff930444d054b598c13", - "reflex/components/radix/themes/components/dropdown_menu.pyi": "43f8770c9adf93c73398d68f79048424", - "reflex/components/radix/themes/components/hover_card.pyi": "a96f4433237f9994decf935deff9f269", - "reflex/components/radix/themes/components/icon_button.pyi": "e930911d8ecbe61e5447e61c76a28ab6", - "reflex/components/radix/themes/components/inset.pyi": "bd7a2186b553bd4c86d83ff50c784066", - "reflex/components/radix/themes/components/popover.pyi": "91f8edefeb232cc6d48690b1838144c2", - "reflex/components/radix/themes/components/progress.pyi": "0e59587d5b3c8fe0d0067587f144e5b0", - "reflex/components/radix/themes/components/radio.pyi": "f375aa5ac746679618ea7dad257e3224", - "reflex/components/radix/themes/components/radio_cards.pyi": "9dc34a1ce2a1924eb1f41438ef84e80b", - "reflex/components/radix/themes/components/radio_group.pyi": "173254cf91908bcf6aa4fa21a747e2cf", - "reflex/components/radix/themes/components/scroll_area.pyi": "2e3539b0f6895dda127ee96e9864dbf9", - "reflex/components/radix/themes/components/segmented_control.pyi": "1776f1ad936bae402007802b1ee98906", - "reflex/components/radix/themes/components/select.pyi": "2c7aee592972ff5f05da08154aa981c8", - "reflex/components/radix/themes/components/separator.pyi": "79e550cc10ee455f35d75d0e236fedd2", - "reflex/components/radix/themes/components/skeleton.pyi": "a25d3ceb56f99f736ea463579845c454", - "reflex/components/radix/themes/components/slider.pyi": "305a34c14ca8656ca9267e4c31aaa388", - "reflex/components/radix/themes/components/spinner.pyi": "b7e689e7d75635e379242fd113a1ea9a", - "reflex/components/radix/themes/components/switch.pyi": "f1ba948750a74126cda990e89a3ec7ef", - "reflex/components/radix/themes/components/table.pyi": "eefbbd1904deae3d166fcad28b20fd4a", - "reflex/components/radix/themes/components/tabs.pyi": "a533d2509a6798fe0ab7275b0152519d", - "reflex/components/radix/themes/components/text_area.pyi": "4af55e5d18a5b9d56717bf31b23ea543", - "reflex/components/radix/themes/components/text_field.pyi": "232618b744076db98d861ea1b9eb3192", - "reflex/components/radix/themes/components/tooltip.pyi": "2b8366200ce92ec4784ca3ec4152e676", + "reflex/components/radix/themes/components/alert_dialog.pyi": "2990c4b9afb9fa0adf8d88472c026d9b", + "reflex/components/radix/themes/components/aspect_ratio.pyi": "4148382648761b481569b03d9b91d590", + "reflex/components/radix/themes/components/avatar.pyi": "f7bc6d5f6bbcfc38898a178a468ae47a", + "reflex/components/radix/themes/components/badge.pyi": "d58011a96520b27fddc6d9dfc1853688", + "reflex/components/radix/themes/components/button.pyi": "6a6852e236917bff2f45475cb9711ad5", + "reflex/components/radix/themes/components/callout.pyi": "3c0e430f5acd54de0a26a18f46464891", + "reflex/components/radix/themes/components/card.pyi": "b99dc57897348a46315d41949151b591", + "reflex/components/radix/themes/components/checkbox.pyi": "fc899d4c981a255f769cf62ba0931d92", + "reflex/components/radix/themes/components/checkbox_cards.pyi": "bf3efaa118305c8fbdbe1725b8585c6e", + "reflex/components/radix/themes/components/checkbox_group.pyi": "4d576c211f2390107e6cdf8195451638", + "reflex/components/radix/themes/components/context_menu.pyi": "5ac2f6cbea722131ea69d428b96e9abe", + "reflex/components/radix/themes/components/data_list.pyi": "8454c12566a94a1a71714e697bf7a502", + "reflex/components/radix/themes/components/dialog.pyi": "117645359e6e4cebcc4e481d8ea5332c", + "reflex/components/radix/themes/components/dropdown_menu.pyi": "a4ff79fb9317c4008b5c2cba1d274c63", + "reflex/components/radix/themes/components/hover_card.pyi": "2d71bcdd84289aa6337deecceeb91165", + "reflex/components/radix/themes/components/icon_button.pyi": "83b01c06b2b8472dfec757d51bf08910", + "reflex/components/radix/themes/components/inset.pyi": "23213c4039076de324d433ed81ffe8e1", + "reflex/components/radix/themes/components/popover.pyi": "8f8a453124793552b15e43781ba34371", + "reflex/components/radix/themes/components/progress.pyi": "ba913efd725e800ab6c8a8dcb2ff0721", + "reflex/components/radix/themes/components/radio.pyi": "818f036e91254c238937789fa0a3e285", + "reflex/components/radix/themes/components/radio_cards.pyi": "3c9590282e1f70d07f1877cb495f331b", + "reflex/components/radix/themes/components/radio_group.pyi": "fd2617e25912330dfce34d2a930c332c", + "reflex/components/radix/themes/components/scroll_area.pyi": "1422653b0e5993e6284d36dbdd6f933e", + "reflex/components/radix/themes/components/segmented_control.pyi": "eff0372c4bb967097696fc3c8a1c418e", + "reflex/components/radix/themes/components/select.pyi": "dfc8df33937f48786d44faab0c979da1", + "reflex/components/radix/themes/components/separator.pyi": "49ded0ad13cb3f3a4b5c4d1024f4872d", + "reflex/components/radix/themes/components/skeleton.pyi": "8ae7c097295f62d1af6c5aaa8952a83b", + "reflex/components/radix/themes/components/slider.pyi": "358523911f95baf8aca4803c3b189e6e", + "reflex/components/radix/themes/components/spinner.pyi": "d72e097c7c5642b2c2871356f4e250f4", + "reflex/components/radix/themes/components/switch.pyi": "55728f3e01c299272af30263b9d10734", + "reflex/components/radix/themes/components/table.pyi": "b0ce734f5562e339bdb4b276c2f3380a", + "reflex/components/radix/themes/components/tabs.pyi": "cb4c4f0f87aced09365e4f243ec2a20a", + "reflex/components/radix/themes/components/text_area.pyi": "f492cbc2a5be8d5c898eba1a8e324a23", + "reflex/components/radix/themes/components/text_field.pyi": "15daa83f8e2ac815c9f727825da113f2", + "reflex/components/radix/themes/components/tooltip.pyi": "edb05c77ba6a0150533d110f354445a0", "reflex/components/radix/themes/layout/__init__.pyi": "73eefc509a49215b1797b5b5d28d035e", - "reflex/components/radix/themes/layout/base.pyi": "5be31d7dadd23ab544e53762423d123e", - "reflex/components/radix/themes/layout/box.pyi": "dbaed1c50c668805fc7b71d22f878254", - "reflex/components/radix/themes/layout/center.pyi": "17323694217e8ad7611adb683f8d96ce", - "reflex/components/radix/themes/layout/container.pyi": "24222fd7ffa2dc05f709eab6c7b9643c", - "reflex/components/radix/themes/layout/flex.pyi": "0307e9dbe6a5784140121d77c8f67a86", - "reflex/components/radix/themes/layout/grid.pyi": "95c9edb8bdd4e39dc1bd6bc2a8ca0933", - "reflex/components/radix/themes/layout/list.pyi": "049ecf827ef0ba8de2d76dbf7b1c562c", - "reflex/components/radix/themes/layout/section.pyi": "a51952b9b5c8227aa3024373dedcad5d", - "reflex/components/radix/themes/layout/spacer.pyi": "c35accf0f2f742c90a23675ff1fb960d", - "reflex/components/radix/themes/layout/stack.pyi": "271d3315c6196356d3ced759520d4e7d", + "reflex/components/radix/themes/layout/base.pyi": "6750a055d74a0182e7a146f7dd4052b6", + "reflex/components/radix/themes/layout/box.pyi": "39f08f36b9a9f2b943fa7c4ba48f064b", + "reflex/components/radix/themes/layout/center.pyi": "9dc515c2668125710316aeca9875c9e4", + "reflex/components/radix/themes/layout/container.pyi": "6aeef75af435e0526d094a09755b9df4", + "reflex/components/radix/themes/layout/flex.pyi": "771d93df7db8f13d3096143ca781d472", + "reflex/components/radix/themes/layout/grid.pyi": "949137a31d46f7af86a289799098baa7", + "reflex/components/radix/themes/layout/list.pyi": "459a5e14b9f7b87970820ccb09b23895", + "reflex/components/radix/themes/layout/section.pyi": "151f88fce10376ff070ace65a79239de", + "reflex/components/radix/themes/layout/spacer.pyi": "34cb3b632511cf4dc77366d10a37aa25", + "reflex/components/radix/themes/layout/stack.pyi": "c0b528751b75cc9b2692f07e1b1aebbe", "reflex/components/radix/themes/typography/__init__.pyi": "b8ef970530397e9984004961f3aaee62", - "reflex/components/radix/themes/typography/blockquote.pyi": "080c71899532f5dbf4cf143e7a5ad3bf", - "reflex/components/radix/themes/typography/code.pyi": "7ffe785d55979cf8ff97ea040f3e2b64", - "reflex/components/radix/themes/typography/heading.pyi": "0ebb38915cd0521fd59c569e04d288bb", - "reflex/components/radix/themes/typography/link.pyi": "e88c5d880a54548b6808c097ac62505b", - "reflex/components/radix/themes/typography/text.pyi": "50f9ca15a941e4b77ddd12e77aa3c03e", - "reflex/components/react_player/audio.pyi": "0e1690ff1f1f39bc748278d292238350", - "reflex/components/react_player/react_player.pyi": "5ccd373b94ed1d3934ae6afc46bd6fe4", - "reflex/components/react_player/video.pyi": "998671c06103d797c554d9278eb3b2a0", - "reflex/components/react_router/dom.pyi": "3042fa630b7e26a7378fe045d7fbf4af", + "reflex/components/radix/themes/typography/blockquote.pyi": "31577adbad26e6e64a49fb98656cf3b8", + "reflex/components/radix/themes/typography/code.pyi": "484df673404329d8e683cb5005991a2f", + "reflex/components/radix/themes/typography/heading.pyi": "0c7a7e020a9de1e332e03221089a2492", + "reflex/components/radix/themes/typography/link.pyi": "4ef0cfc3af352491ec5c69292080b524", + "reflex/components/radix/themes/typography/text.pyi": "ea87614808ec3401699c984766b78b9f", + "reflex/components/react_player/audio.pyi": "c8ff18b4cd97c0f6ffe7d32d2778c218", + "reflex/components/react_player/react_player.pyi": "09a311a52824212e7a58c39c33292956", + "reflex/components/react_player/video.pyi": "14f0c4f3fccaec8522ca2529246ca79e", + "reflex/components/react_router/dom.pyi": "4fc6377eb4c021d6c98da32b0c8bce3e", "reflex/components/recharts/__init__.pyi": "6ee7f1ca2c0912f389ba6f3251a74d99", - "reflex/components/recharts/cartesian.pyi": "d138261ab8259d5208c2f028b9f708bd", - "reflex/components/recharts/charts.pyi": "013036b9c00ad85a570efdb813c1bc40", - "reflex/components/recharts/general.pyi": "d87ff9b85b2a204be01753690df4fb11", - "reflex/components/recharts/polar.pyi": "b8b1a3e996e066facdf4f8c9eb363137", - "reflex/components/recharts/recharts.pyi": "d5c9fc57a03b419748f0408c23319eee", - "reflex/components/sonner/toast.pyi": "3c27bad1aaeb5183eaa6a41e77e8d7f0" + "reflex/components/recharts/cartesian.pyi": "122d02df5c7df4058d365127f89bbfaf", + "reflex/components/recharts/charts.pyi": "954ee81a3b43083b58d77b9f203a711d", + "reflex/components/recharts/general.pyi": "4fcc577a1d3710cb9d5afdc003287cca", + "reflex/components/recharts/polar.pyi": "6517fdc29454dab017a7d133b02338c3", + "reflex/components/recharts/recharts.pyi": "fa2e46af8847543768a3f12b41227aca", + "reflex/components/sonner/toast.pyi": "6f443739c1bccf3191b89b35ae843ecc" } From ba6a28ea6e4ba67c33c07eb6fde122a34cbdf44a Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Thu, 19 Mar 2026 13:17:20 +0500 Subject: [PATCH 08/12] refactor: fix ast generation and remove from global import --- pyi_hashes.json | 212 +++++++++++++++++----------------- reflex/utils/pyi_generator.py | 47 +++++++- 2 files changed, 149 insertions(+), 110 deletions(-) diff --git a/pyi_hashes.json b/pyi_hashes.json index 0b95e6655f4..a20deff60e2 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -2,121 +2,121 @@ "reflex/__init__.pyi": "276759cf35be6503c710e2203405adb6", "reflex/components/__init__.pyi": "ac05995852baa81062ba3d18fbc489fb", "reflex/components/base/__init__.pyi": "16e47bf19e0d62835a605baa3d039c5a", - "reflex/components/base/app_wrap.pyi": "926061c72ef91977b3c8ad8ef61b2fc7", - "reflex/components/base/body.pyi": "3567e2cedcda8af51a50de586d1148b6", - "reflex/components/base/document.pyi": "b2f596ab56313e4095da49275b416c84", - "reflex/components/base/error_boundary.pyi": "068e7218be8c3d448ffa389d64baa39a", - "reflex/components/base/fragment.pyi": "f90b36a2fc68a48800b4ff3d2829b9e4", - "reflex/components/base/link.pyi": "f4a9382730a19ad8f00724ca8f099681", - "reflex/components/base/meta.pyi": "893a710f8b02adf6e82a00de4d0919d0", - "reflex/components/base/script.pyi": "58079b0c3ba85a9696d4736495fd8ccf", - "reflex/components/base/strict_mode.pyi": "4f50d728c10882e5d2c065ff23fe9409", + "reflex/components/base/app_wrap.pyi": "22e94feaa9fe675bcae51c412f5b67f1", + "reflex/components/base/body.pyi": "e8ab029a730824bab6d4211203609e6a", + "reflex/components/base/document.pyi": "311c53c90a60587a82e760103758a3cf", + "reflex/components/base/error_boundary.pyi": "a678cceea014cb16048647257cd24ba6", + "reflex/components/base/fragment.pyi": "745f1be02c23a0b25d7c52d7423ec76a", + "reflex/components/base/link.pyi": "0bc1d26ee29d8864aed14a12991bd47d", + "reflex/components/base/meta.pyi": "129aecf65ab53f756c4d1cbe1d0b188d", + "reflex/components/base/script.pyi": "e5f506d1d0d6712cb9e597a781eb3941", + "reflex/components/base/strict_mode.pyi": "6b72e16caadf7158ab744a0ab751b010", "reflex/components/core/__init__.pyi": "007170b97e58bdf28b2aee381d91c0c7", - "reflex/components/core/auto_scroll.pyi": "8a308e6b2bff05d8ebb3c7438b256766", - "reflex/components/core/banner.pyi": "9732eb647f18c348fd3fda01c1440c65", - "reflex/components/core/clipboard.pyi": "f8b6abb2f9f5adba04151614f5381770", - "reflex/components/core/debounce.pyi": "47ea18b7e4f6e4173344771b0351091f", - "reflex/components/core/helmet.pyi": "7aa6b4edc854489707adb268ae8bff91", - "reflex/components/core/html.pyi": "ef8fc8eca23d060272412de894b73f93", - "reflex/components/core/sticky.pyi": "e37a540debf6b97889bd9111ea2bf364", - "reflex/components/core/upload.pyi": "3f1405e4d76ace532e2259c39fb0b86f", - "reflex/components/core/window_events.pyi": "a5df53a9698285e1f3a3cfddfb9dfaea", + "reflex/components/core/auto_scroll.pyi": "18068d22aca7244a08cd0c5a897c0950", + "reflex/components/core/banner.pyi": "fd93e7a92961de8524718ad32135c37c", + "reflex/components/core/clipboard.pyi": "a844eb927d9bc2a43f5e88161b258539", + "reflex/components/core/debounce.pyi": "055da7aa890f44fb4d48bd5978f1a874", + "reflex/components/core/helmet.pyi": "43f8497c8fafe51e29dca1dd535d143a", + "reflex/components/core/html.pyi": "86eb9d4c1bb4807547b2950d9a32e9fd", + "reflex/components/core/sticky.pyi": "cb763b986a9b0654d1a3f33440dfcf60", + "reflex/components/core/upload.pyi": "e448223ad0d8c2370e8a147727399b5d", + "reflex/components/core/window_events.pyi": "af33ccec866b9540ee7fbec6dbfbd151", "reflex/components/datadisplay/__init__.pyi": "52755871369acbfd3a96b46b9a11d32e", - "reflex/components/datadisplay/code.pyi": "f91b4872ee1bf7257741972c2c515869", - "reflex/components/datadisplay/dataeditor.pyi": "b48fc74a32a8b4a11446e1175985b6e3", - "reflex/components/datadisplay/shiki_code_block.pyi": "313ce931c1df079aa6d156ff2cc3e1e6", + "reflex/components/datadisplay/code.pyi": "b86769987ef4d1cbdddb461be88539fd", + "reflex/components/datadisplay/dataeditor.pyi": "f8c1e816c9f22f4a7429f812214407f2", + "reflex/components/datadisplay/shiki_code_block.pyi": "1d53e75b6be0d3385a342e7b3011babd", "reflex/components/el/__init__.pyi": "0adfd001a926a2a40aee94f6fa725ecc", - "reflex/components/el/element.pyi": "eb4a6307334bd5b0fa842cd8760b9f2d", + "reflex/components/el/element.pyi": "c5974a92fbc310e42d0f6cfdd13472f4", "reflex/components/el/elements/__init__.pyi": "29512d7a6b29c6dc5ff68d3b31f26528", - "reflex/components/el/elements/base.pyi": "d7ca6baef5ab9b7210df3cbf17e75130", - "reflex/components/el/elements/forms.pyi": "bd10646610bccd41ca021119ad00c587", - "reflex/components/el/elements/inline.pyi": "dd26811a187044eea3cff93eea0c37d5", - "reflex/components/el/elements/media.pyi": "f9e7b7437e1e29f84434f0e989d01802", - "reflex/components/el/elements/metadata.pyi": "7b851fe57a8cc25dfbb97671b4be4b95", - "reflex/components/el/elements/other.pyi": "8dd65b7eeb0a610da92a86adbf5668cf", - "reflex/components/el/elements/scripts.pyi": "043e64a4d86f07fadf669fa08178fb9e", - "reflex/components/el/elements/sectioning.pyi": "f3c8078ab19ebb989285185874550992", - "reflex/components/el/elements/tables.pyi": "c28f6ba4a9bd1e5e5df09ddae984d5a4", - "reflex/components/el/elements/typography.pyi": "5bd0c2d6da7dad53125e66f9c0a01086", - "reflex/components/gridjs/datatable.pyi": "f7ef10e8c98e9390819ba844bfe2a203", - "reflex/components/lucide/icon.pyi": "6c5f33175257010f47e9209f9bbd1177", - "reflex/components/markdown/markdown.pyi": "710072cc8f2d6df281d871e3f0c03b91", - "reflex/components/moment/moment.pyi": "64b756800a3b4a731c0cf83da9585ad3", - "reflex/components/plotly/plotly.pyi": "c016835a480f7b2afd23e75a0a82c477", + "reflex/components/el/elements/base.pyi": "3f74c7ea573ea29b055b0cd48b040d2c", + "reflex/components/el/elements/forms.pyi": "8b6bb2fbaf4bad828b076e2f7c8444d0", + "reflex/components/el/elements/inline.pyi": "3549cd6ad45217aa6387800911b641c3", + "reflex/components/el/elements/media.pyi": "9b97220aa99783d402b6e278c4069043", + "reflex/components/el/elements/metadata.pyi": "24448004b7aa07f1225028a85bd49fef", + "reflex/components/el/elements/other.pyi": "0c4d5d0b955d8596bf6cf4a48d7decdb", + "reflex/components/el/elements/scripts.pyi": "d33df9f21f7e838376b2b5024beef7c9", + "reflex/components/el/elements/sectioning.pyi": "3c5a7e4caa9c25da0ae788f02466eac4", + "reflex/components/el/elements/tables.pyi": "686eb70ea7d8c4dafb0cc5c284e76184", + "reflex/components/el/elements/typography.pyi": "684e83dde887dba12badd0fb75c87c04", + "reflex/components/gridjs/datatable.pyi": "98a7e1b3f3b60cafcdfcd8879750ee42", + "reflex/components/lucide/icon.pyi": "dcb8773ef162f3ec5759efe11374cf5e", + "reflex/components/markdown/markdown.pyi": "dd74e8e9665b2a813ff799a7aa190b44", + "reflex/components/moment/moment.pyi": "e1952f1c2c82cef85d91e970d1be64ab", + "reflex/components/plotly/plotly.pyi": "4311a0aae2abcc9226abb6a273f96372", "reflex/components/radix/__init__.pyi": "5d8e3579912473e563676bfc71f29191", "reflex/components/radix/primitives/__init__.pyi": "01c388fe7a1f5426a16676404344edf6", - "reflex/components/radix/primitives/accordion.pyi": "a81af77072d1aedec710897bdad22c9a", - "reflex/components/radix/primitives/base.pyi": "fa1539190f969b6b68455e6414cd6e7f", - "reflex/components/radix/primitives/dialog.pyi": "987363d0d09b2651263b768665599042", - "reflex/components/radix/primitives/drawer.pyi": "47835cf5394348af2ca66b05069eb904", - "reflex/components/radix/primitives/form.pyi": "8fb89c217583d1c977d8b5b3469b5e74", - "reflex/components/radix/primitives/progress.pyi": "79e9c888b000eaf53cdcbef99b737aff", - "reflex/components/radix/primitives/slider.pyi": "021d0310b62dec4e328bf1db2fbf470b", + "reflex/components/radix/primitives/accordion.pyi": "19484eca0ad53f538f5db04c09921738", + "reflex/components/radix/primitives/base.pyi": "9ef34884fb6028dc017df5e2db639c81", + "reflex/components/radix/primitives/dialog.pyi": "9ee73362bb59619c482b6b0d07033f37", + "reflex/components/radix/primitives/drawer.pyi": "921e45dfaf5b9131ef27c561c3acca2e", + "reflex/components/radix/primitives/form.pyi": "78055e820703c98c3b838aa889566365", + "reflex/components/radix/primitives/progress.pyi": "c917952d57ddb3e138a40c4005120d5e", + "reflex/components/radix/primitives/slider.pyi": "4ff06f0025d47f166132909b09ab96f8", "reflex/components/radix/themes/__init__.pyi": "582b4a7ead62b2ae8605e17fa084c063", - "reflex/components/radix/themes/base.pyi": "d343c09e67ce7f01c970cc48584f0427", - "reflex/components/radix/themes/color_mode.pyi": "918826f0cc0b2b21c8e37ef3d637c245", + "reflex/components/radix/themes/base.pyi": "3e1ccd5ce5fef0b2898025193ee3d069", + "reflex/components/radix/themes/color_mode.pyi": "dda570583355d8c0d8f607be457ba7a1", "reflex/components/radix/themes/components/__init__.pyi": "efa279ee05479d7bb8a64d49da808d03", - "reflex/components/radix/themes/components/alert_dialog.pyi": "2990c4b9afb9fa0adf8d88472c026d9b", - "reflex/components/radix/themes/components/aspect_ratio.pyi": "4148382648761b481569b03d9b91d590", - "reflex/components/radix/themes/components/avatar.pyi": "f7bc6d5f6bbcfc38898a178a468ae47a", - "reflex/components/radix/themes/components/badge.pyi": "d58011a96520b27fddc6d9dfc1853688", - "reflex/components/radix/themes/components/button.pyi": "6a6852e236917bff2f45475cb9711ad5", - "reflex/components/radix/themes/components/callout.pyi": "3c0e430f5acd54de0a26a18f46464891", - "reflex/components/radix/themes/components/card.pyi": "b99dc57897348a46315d41949151b591", - "reflex/components/radix/themes/components/checkbox.pyi": "fc899d4c981a255f769cf62ba0931d92", - "reflex/components/radix/themes/components/checkbox_cards.pyi": "bf3efaa118305c8fbdbe1725b8585c6e", - "reflex/components/radix/themes/components/checkbox_group.pyi": "4d576c211f2390107e6cdf8195451638", - "reflex/components/radix/themes/components/context_menu.pyi": "5ac2f6cbea722131ea69d428b96e9abe", - "reflex/components/radix/themes/components/data_list.pyi": "8454c12566a94a1a71714e697bf7a502", - "reflex/components/radix/themes/components/dialog.pyi": "117645359e6e4cebcc4e481d8ea5332c", - "reflex/components/radix/themes/components/dropdown_menu.pyi": "a4ff79fb9317c4008b5c2cba1d274c63", - "reflex/components/radix/themes/components/hover_card.pyi": "2d71bcdd84289aa6337deecceeb91165", - "reflex/components/radix/themes/components/icon_button.pyi": "83b01c06b2b8472dfec757d51bf08910", - "reflex/components/radix/themes/components/inset.pyi": "23213c4039076de324d433ed81ffe8e1", - "reflex/components/radix/themes/components/popover.pyi": "8f8a453124793552b15e43781ba34371", - "reflex/components/radix/themes/components/progress.pyi": "ba913efd725e800ab6c8a8dcb2ff0721", - "reflex/components/radix/themes/components/radio.pyi": "818f036e91254c238937789fa0a3e285", - "reflex/components/radix/themes/components/radio_cards.pyi": "3c9590282e1f70d07f1877cb495f331b", - "reflex/components/radix/themes/components/radio_group.pyi": "fd2617e25912330dfce34d2a930c332c", - "reflex/components/radix/themes/components/scroll_area.pyi": "1422653b0e5993e6284d36dbdd6f933e", - "reflex/components/radix/themes/components/segmented_control.pyi": "eff0372c4bb967097696fc3c8a1c418e", - "reflex/components/radix/themes/components/select.pyi": "dfc8df33937f48786d44faab0c979da1", - "reflex/components/radix/themes/components/separator.pyi": "49ded0ad13cb3f3a4b5c4d1024f4872d", - "reflex/components/radix/themes/components/skeleton.pyi": "8ae7c097295f62d1af6c5aaa8952a83b", - "reflex/components/radix/themes/components/slider.pyi": "358523911f95baf8aca4803c3b189e6e", - "reflex/components/radix/themes/components/spinner.pyi": "d72e097c7c5642b2c2871356f4e250f4", - "reflex/components/radix/themes/components/switch.pyi": "55728f3e01c299272af30263b9d10734", - "reflex/components/radix/themes/components/table.pyi": "b0ce734f5562e339bdb4b276c2f3380a", - "reflex/components/radix/themes/components/tabs.pyi": "cb4c4f0f87aced09365e4f243ec2a20a", - "reflex/components/radix/themes/components/text_area.pyi": "f492cbc2a5be8d5c898eba1a8e324a23", - "reflex/components/radix/themes/components/text_field.pyi": "15daa83f8e2ac815c9f727825da113f2", - "reflex/components/radix/themes/components/tooltip.pyi": "edb05c77ba6a0150533d110f354445a0", + "reflex/components/radix/themes/components/alert_dialog.pyi": "eed422fcc1ff5ccf3dbf6934699bd0b1", + "reflex/components/radix/themes/components/aspect_ratio.pyi": "71de4160d79840561c48b570197a4152", + "reflex/components/radix/themes/components/avatar.pyi": "e40c2f0fda6d2c028d83681a27f3fb96", + "reflex/components/radix/themes/components/badge.pyi": "58fd1a9c5d2f8762e2a0370311731ff5", + "reflex/components/radix/themes/components/button.pyi": "50f0b08ad5d1d1054ab537152f0f5c43", + "reflex/components/radix/themes/components/callout.pyi": "547f2570ffbd10db36b745566e9f1b17", + "reflex/components/radix/themes/components/card.pyi": "f7adb83f7b001a11bdd7fd6791fb3ffb", + "reflex/components/radix/themes/components/checkbox.pyi": "8eabb6887a5d0849a43e086a284814c2", + "reflex/components/radix/themes/components/checkbox_cards.pyi": "1d567fd04b4425abd5cc5aad10108aa9", + "reflex/components/radix/themes/components/checkbox_group.pyi": "8638582a623036f8893a3fa6080f2672", + "reflex/components/radix/themes/components/context_menu.pyi": "b9499d8bdd2c5565621fea5fe7d7a25a", + "reflex/components/radix/themes/components/data_list.pyi": "6f8d9c582e084c23966b992158193b72", + "reflex/components/radix/themes/components/dialog.pyi": "d2615f1a68c80ff930444d054b598c13", + "reflex/components/radix/themes/components/dropdown_menu.pyi": "43f8770c9adf93c73398d68f79048424", + "reflex/components/radix/themes/components/hover_card.pyi": "a96f4433237f9994decf935deff9f269", + "reflex/components/radix/themes/components/icon_button.pyi": "e930911d8ecbe61e5447e61c76a28ab6", + "reflex/components/radix/themes/components/inset.pyi": "bd7a2186b553bd4c86d83ff50c784066", + "reflex/components/radix/themes/components/popover.pyi": "91f8edefeb232cc6d48690b1838144c2", + "reflex/components/radix/themes/components/progress.pyi": "0e59587d5b3c8fe0d0067587f144e5b0", + "reflex/components/radix/themes/components/radio.pyi": "f375aa5ac746679618ea7dad257e3224", + "reflex/components/radix/themes/components/radio_cards.pyi": "9dc34a1ce2a1924eb1f41438ef84e80b", + "reflex/components/radix/themes/components/radio_group.pyi": "173254cf91908bcf6aa4fa21a747e2cf", + "reflex/components/radix/themes/components/scroll_area.pyi": "2e3539b0f6895dda127ee96e9864dbf9", + "reflex/components/radix/themes/components/segmented_control.pyi": "1776f1ad936bae402007802b1ee98906", + "reflex/components/radix/themes/components/select.pyi": "2c7aee592972ff5f05da08154aa981c8", + "reflex/components/radix/themes/components/separator.pyi": "79e550cc10ee455f35d75d0e236fedd2", + "reflex/components/radix/themes/components/skeleton.pyi": "a25d3ceb56f99f736ea463579845c454", + "reflex/components/radix/themes/components/slider.pyi": "305a34c14ca8656ca9267e4c31aaa388", + "reflex/components/radix/themes/components/spinner.pyi": "b7e689e7d75635e379242fd113a1ea9a", + "reflex/components/radix/themes/components/switch.pyi": "f1ba948750a74126cda990e89a3ec7ef", + "reflex/components/radix/themes/components/table.pyi": "eefbbd1904deae3d166fcad28b20fd4a", + "reflex/components/radix/themes/components/tabs.pyi": "a533d2509a6798fe0ab7275b0152519d", + "reflex/components/radix/themes/components/text_area.pyi": "4af55e5d18a5b9d56717bf31b23ea543", + "reflex/components/radix/themes/components/text_field.pyi": "232618b744076db98d861ea1b9eb3192", + "reflex/components/radix/themes/components/tooltip.pyi": "2b8366200ce92ec4784ca3ec4152e676", "reflex/components/radix/themes/layout/__init__.pyi": "73eefc509a49215b1797b5b5d28d035e", - "reflex/components/radix/themes/layout/base.pyi": "6750a055d74a0182e7a146f7dd4052b6", - "reflex/components/radix/themes/layout/box.pyi": "39f08f36b9a9f2b943fa7c4ba48f064b", - "reflex/components/radix/themes/layout/center.pyi": "9dc515c2668125710316aeca9875c9e4", - "reflex/components/radix/themes/layout/container.pyi": "6aeef75af435e0526d094a09755b9df4", - "reflex/components/radix/themes/layout/flex.pyi": "771d93df7db8f13d3096143ca781d472", - "reflex/components/radix/themes/layout/grid.pyi": "949137a31d46f7af86a289799098baa7", - "reflex/components/radix/themes/layout/list.pyi": "459a5e14b9f7b87970820ccb09b23895", - "reflex/components/radix/themes/layout/section.pyi": "151f88fce10376ff070ace65a79239de", - "reflex/components/radix/themes/layout/spacer.pyi": "34cb3b632511cf4dc77366d10a37aa25", - "reflex/components/radix/themes/layout/stack.pyi": "c0b528751b75cc9b2692f07e1b1aebbe", + "reflex/components/radix/themes/layout/base.pyi": "5be31d7dadd23ab544e53762423d123e", + "reflex/components/radix/themes/layout/box.pyi": "dbaed1c50c668805fc7b71d22f878254", + "reflex/components/radix/themes/layout/center.pyi": "17323694217e8ad7611adb683f8d96ce", + "reflex/components/radix/themes/layout/container.pyi": "24222fd7ffa2dc05f709eab6c7b9643c", + "reflex/components/radix/themes/layout/flex.pyi": "0307e9dbe6a5784140121d77c8f67a86", + "reflex/components/radix/themes/layout/grid.pyi": "95c9edb8bdd4e39dc1bd6bc2a8ca0933", + "reflex/components/radix/themes/layout/list.pyi": "049ecf827ef0ba8de2d76dbf7b1c562c", + "reflex/components/radix/themes/layout/section.pyi": "a51952b9b5c8227aa3024373dedcad5d", + "reflex/components/radix/themes/layout/spacer.pyi": "c35accf0f2f742c90a23675ff1fb960d", + "reflex/components/radix/themes/layout/stack.pyi": "271d3315c6196356d3ced759520d4e7d", "reflex/components/radix/themes/typography/__init__.pyi": "b8ef970530397e9984004961f3aaee62", - "reflex/components/radix/themes/typography/blockquote.pyi": "31577adbad26e6e64a49fb98656cf3b8", - "reflex/components/radix/themes/typography/code.pyi": "484df673404329d8e683cb5005991a2f", - "reflex/components/radix/themes/typography/heading.pyi": "0c7a7e020a9de1e332e03221089a2492", - "reflex/components/radix/themes/typography/link.pyi": "4ef0cfc3af352491ec5c69292080b524", - "reflex/components/radix/themes/typography/text.pyi": "ea87614808ec3401699c984766b78b9f", - "reflex/components/react_player/audio.pyi": "c8ff18b4cd97c0f6ffe7d32d2778c218", - "reflex/components/react_player/react_player.pyi": "09a311a52824212e7a58c39c33292956", - "reflex/components/react_player/video.pyi": "14f0c4f3fccaec8522ca2529246ca79e", - "reflex/components/react_router/dom.pyi": "4fc6377eb4c021d6c98da32b0c8bce3e", + "reflex/components/radix/themes/typography/blockquote.pyi": "080c71899532f5dbf4cf143e7a5ad3bf", + "reflex/components/radix/themes/typography/code.pyi": "7ffe785d55979cf8ff97ea040f3e2b64", + "reflex/components/radix/themes/typography/heading.pyi": "0ebb38915cd0521fd59c569e04d288bb", + "reflex/components/radix/themes/typography/link.pyi": "e88c5d880a54548b6808c097ac62505b", + "reflex/components/radix/themes/typography/text.pyi": "50f9ca15a941e4b77ddd12e77aa3c03e", + "reflex/components/react_player/audio.pyi": "0e1690ff1f1f39bc748278d292238350", + "reflex/components/react_player/react_player.pyi": "5ccd373b94ed1d3934ae6afc46bd6fe4", + "reflex/components/react_player/video.pyi": "998671c06103d797c554d9278eb3b2a0", + "reflex/components/react_router/dom.pyi": "3042fa630b7e26a7378fe045d7fbf4af", "reflex/components/recharts/__init__.pyi": "6ee7f1ca2c0912f389ba6f3251a74d99", - "reflex/components/recharts/cartesian.pyi": "122d02df5c7df4058d365127f89bbfaf", - "reflex/components/recharts/charts.pyi": "954ee81a3b43083b58d77b9f203a711d", - "reflex/components/recharts/general.pyi": "4fcc577a1d3710cb9d5afdc003287cca", - "reflex/components/recharts/polar.pyi": "6517fdc29454dab017a7d133b02338c3", - "reflex/components/recharts/recharts.pyi": "fa2e46af8847543768a3f12b41227aca", - "reflex/components/sonner/toast.pyi": "6f443739c1bccf3191b89b35ae843ecc" + "reflex/components/recharts/cartesian.pyi": "d138261ab8259d5208c2f028b9f708bd", + "reflex/components/recharts/charts.pyi": "013036b9c00ad85a570efdb813c1bc40", + "reflex/components/recharts/general.pyi": "d87ff9b85b2a204be01753690df4fb11", + "reflex/components/recharts/polar.pyi": "b8b1a3e996e066facdf4f8c9eb363137", + "reflex/components/recharts/recharts.pyi": "d5c9fc57a03b419748f0408c23319eee", + "reflex/components/sonner/toast.pyi": "3c27bad1aaeb5183eaa6a41e77e8d7f0" } diff --git a/reflex/utils/pyi_generator.py b/reflex/utils/pyi_generator.py index 5adb91f1519..93a7bae6b18 100644 --- a/reflex/utils/pyi_generator.py +++ b/reflex/utils/pyi_generator.py @@ -93,7 +93,6 @@ "PointerEventInfo", ], "reflex.style": ["Style"], - "reflex._upload": ["UploadFile"], "reflex.vars.base": ["Var"], } @@ -537,13 +536,48 @@ def _extract_class_props_as_ast_nodes( return kwargs -def type_to_ast(typ: Any, cls: type) -> ast.expr: +def _get_visible_type_name( + typ: Any, type_hint_globals: Mapping[str, Any] | None +) -> str | None: + """Get a visible identifier for a type in the current module. + + Args: + typ: The type annotation to resolve. + type_hint_globals: The globals visible in the current module. + + Returns: + The visible identifier if one exists, otherwise None. + """ + if type_hint_globals is None: + return None + + type_name = getattr(typ, "__name__", None) + if ( + type_name is not None + and type_name in type_hint_globals + and type_hint_globals[type_name] is typ + ): + return type_name + + for name, value in type_hint_globals.items(): + if name.isidentifier() and value is typ: + return name + + return None + + +def type_to_ast( + typ: Any, + cls: type, + type_hint_globals: Mapping[str, Any] | None = None, +) -> ast.expr: """Converts any type annotation into its AST representation. Handles nested generic types, unions, etc. Args: typ: The type annotation to convert. cls: The class where the type annotation is used. + type_hint_globals: The globals visible where the annotation is used. Returns: The AST representation of the type annotation. @@ -574,6 +608,8 @@ def type_to_ast(typ: Any, cls: type) -> ast.expr: if all(a == b for a, b in zipped) and len(typ_parts) == len(cls_parts): return ast.Name(id=typ.__name__) + if visible_name := _get_visible_type_name(typ, type_hint_globals): + return ast.Name(id=visible_name) if ( typ.__module__ in DEFAULT_IMPORTS and typ.__name__ in DEFAULT_IMPORTS[typ.__module__] @@ -596,7 +632,7 @@ def type_to_ast(typ: Any, cls: type) -> ast.expr: return ast.Name(id=base_name) # Convert all type arguments recursively - arg_nodes = [type_to_ast(arg, cls) for arg in args] + arg_nodes = [type_to_ast(arg, cls, type_hint_globals) for arg in args] # Special case for single-argument types (like list[T] or Optional[T]) if len(arg_nodes) == 1: @@ -695,7 +731,10 @@ def figure_out_return_type(annotation: Any): ] # Convert each argument type to its AST representation - type_args = [type_to_ast(arg, cls=clz) for arg in arguments_without_var] + type_args = [ + type_to_ast(arg, cls=clz, type_hint_globals=type_hint_globals) + for arg in arguments_without_var + ] # Get all prefixes of the type arguments all_count_args_type = [ From 3f3729081c24292917a2ce87cf156c7654c6aaaf Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Fri, 20 Mar 2026 11:46:18 +0500 Subject: [PATCH 09/12] refactor: consolidate upload internals and allow empty file uploads through upload endpoint Move UploadChunk and UploadChunkIterator from reflex.event to reflex._upload, use lazy imports to break circular dependencies, and remove early-return guards for empty file lists. Empty uploads now flow through the normal upload path instead of being short-circuited on the frontend or normalized via websocket fallback (_normalize_upload_payload removed). Adds tests for empty buffered and chunk uploads with aliased handler parameters. --- pyi_hashes.json | 2 +- reflex/.templates/web/utils/helpers/upload.js | 5 +- reflex/.templates/web/utils/state.js | 10 - reflex/_upload.py | 231 ++++++++++++++---- reflex/components/core/upload.py | 3 +- reflex/event.py | 156 +----------- reflex/state.py | 49 ---- tests/units/states/upload.py | 14 ++ tests/units/test_app.py | 108 ++++++++ 9 files changed, 322 insertions(+), 256 deletions(-) diff --git a/pyi_hashes.json b/pyi_hashes.json index a0f6ca27409..f4c7a7d3608 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -19,7 +19,7 @@ "reflex/components/core/helmet.pyi": "43f8497c8fafe51e29dca1dd535d143a", "reflex/components/core/html.pyi": "86eb9d4c1bb4807547b2950d9a32e9fd", "reflex/components/core/sticky.pyi": "cb763b986a9b0654d1a3f33440dfcf60", - "reflex/components/core/upload.pyi": "e448223ad0d8c2370e8a147727399b5d", + "reflex/components/core/upload.pyi": "68c0c3aed3456812c6dde2fee80be877", "reflex/components/core/window_events.pyi": "af33ccec866b9540ee7fbec6dbfbd151", "reflex/components/datadisplay/__init__.pyi": "52755871369acbfd3a96b46b9a11d32e", "reflex/components/datadisplay/code.pyi": "b86769987ef4d1cbdddb461be88539fd", diff --git a/reflex/.templates/web/utils/helpers/upload.js b/reflex/.templates/web/utils/helpers/upload.js index 6bbfc746ed6..6d3d146c6c5 100644 --- a/reflex/.templates/web/utils/helpers/upload.js +++ b/reflex/.templates/web/utils/helpers/upload.js @@ -27,10 +27,7 @@ export const uploadFiles = async ( getBackendURL, getToken, ) => { - // return if there's no file to upload - if (files === undefined || files.length === 0) { - return false; - } + files = files ?? []; const upload_ref_name = `__upload_controllers_${upload_id}`; diff --git a/reflex/.templates/web/utils/state.js b/reflex/.templates/web/utils/state.js index 9e937ed62cd..56eba97d4fe 100644 --- a/reflex/.templates/web/utils/state.js +++ b/reflex/.templates/web/utils/state.js @@ -419,16 +419,6 @@ export const applyEvent = async (event, socket, navigate, params) => { export const applyRestEvent = async (event, socket, navigate, params) => { let eventSent = false; if (event.handler === "uploadFiles") { - if (event.payload.files === undefined || event.payload.files.length === 0) { - // Submit the event over the websocket to trigger the event handler. - return await applyEvent( - ReflexEvent(event.name, { files: [] }), - socket, - navigate, - params, - ); - } - // Start upload, but do not wait for it, which would block other events. uploadFiles( event.name, diff --git a/reflex/_upload.py b/reflex/_upload.py index 81e6c742a3b..1e7ca21537c 100644 --- a/reflex/_upload.py +++ b/reflex/_upload.py @@ -6,7 +6,7 @@ import contextlib import dataclasses from collections import deque -from collections.abc import AsyncGenerator, Awaitable, Callable +from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable from pathlib import Path from typing import TYPE_CHECKING, Any, BinaryIO, cast @@ -17,22 +17,16 @@ from starlette.formparsers import MultiPartException, _user_safe_decode from starlette.requests import ClientDisconnect, Request from starlette.responses import JSONResponse, Response, StreamingResponse +from typing_extensions import Self from reflex import constants -from reflex.event import ( - Event, - EventHandler, - UploadChunk, - UploadChunkIterator, - resolve_upload_chunk_handler_param, - resolve_upload_handler_param, -) -from reflex.state import BaseState, RouterData, StateUpdate, _substate_key from reflex.utils import exceptions -from reflex.utils.types import Receive, Scope, Send if TYPE_CHECKING: from reflex.app import App + from reflex.event import EventHandler + from reflex.state import BaseState + from reflex.utils.types import Receive, Scope, Send @dataclasses.dataclass(frozen=True) @@ -75,7 +69,158 @@ def name(self) -> str | None: return None -@dataclasses.dataclass +@dataclasses.dataclass(frozen=True, kw_only=True, slots=True) +class UploadChunk: + """A chunk of uploaded file data.""" + + filename: str + offset: int + content_type: str + data: bytes + + +class UploadChunkIterator(AsyncIterator[UploadChunk]): + """An async iterator over uploaded file chunks.""" + + __slots__ = ( + "_chunks", + "_closed", + "_condition", + "_consumer_task", + "_error", + "_maxsize", + ) + + def __init__(self, *, maxsize: int = 8): + """Initialize the iterator. + + Args: + maxsize: Maximum number of chunks to buffer before blocking producers. + """ + self._maxsize = maxsize + self._chunks: deque[UploadChunk] = deque() + self._condition = asyncio.Condition() + self._closed = False + self._error: Exception | None = None + self._consumer_task: asyncio.Task[Any] | None = None + + def __aiter__(self) -> Self: + """Return the iterator itself. + + Returns: + The upload chunk iterator. + """ + return self + + async def __anext__(self) -> UploadChunk: + """Yield the next available upload chunk. + + Returns: + The next upload chunk. + + Raises: + _error: Any error forwarded from the upload producer. + StopAsyncIteration: When all chunks have been consumed. + """ + async with self._condition: + while not self._chunks and not self._closed: + await self._condition.wait() + + if self._chunks: + chunk = self._chunks.popleft() + self._condition.notify_all() + return chunk + + if self._error is not None: + raise self._error + raise StopAsyncIteration + + def set_consumer_task(self, task: asyncio.Task[Any]) -> None: + """Track the task consuming this iterator. + + Args: + task: The background task consuming upload chunks. + """ + self._consumer_task = task + task.add_done_callback(self._wake_waiters) + + async def push(self, chunk: UploadChunk) -> None: + """Push a new chunk into the iterator. + + Args: + chunk: The chunk to push. + + Raises: + RuntimeError: If the iterator is already closed or the consumer exited early. + """ + async with self._condition: + while len(self._chunks) >= self._maxsize and not self._closed: + self._raise_if_consumer_finished() + await self._condition.wait() + + if self._closed: + msg = "Upload chunk iterator is closed." + raise RuntimeError(msg) + + self._raise_if_consumer_finished() + self._chunks.append(chunk) + self._condition.notify_all() + + async def finish(self) -> None: + """Mark the iterator as complete.""" + async with self._condition: + if self._closed: + return + self._closed = True + self._condition.notify_all() + + async def fail(self, error: Exception) -> None: + """Mark the iterator as failed. + + Args: + error: The error to raise from the iterator. + """ + async with self._condition: + if self._closed: + return + self._closed = True + self._error = error + self._condition.notify_all() + + def _raise_if_consumer_finished(self) -> None: + """Raise if the consumer task exited before draining the iterator. + + Raises: + RuntimeError: If the consumer task completed before draining the iterator. + """ + if self._consumer_task is None or not self._consumer_task.done(): + return + + try: + task_exc = self._consumer_task.exception() + except asyncio.CancelledError as err: + task_exc = err + + msg = "Upload handler returned before consuming all upload chunks." + if task_exc is not None: + raise RuntimeError(msg) from task_exc + raise RuntimeError(msg) + + def _wake_waiters(self, task: asyncio.Task[Any]) -> None: + """Wake any producers or consumers blocked on the iterator condition. + + Args: + task: The completed consumer task. + """ + task.get_loop().create_task(self._notify_waiters()) + + async def _notify_waiters(self) -> None: + """Notify tasks waiting on the iterator condition.""" + async with self._condition: + self._condition.notify_all() + + +@dataclasses.dataclass(kw_only=True, slots=True) class _UploadChunkPart: """Track the current multipart file part for upload streaming.""" @@ -89,28 +234,25 @@ class _UploadChunkPart: is_upload_chunk: bool = False +@dataclasses.dataclass(kw_only=True, slots=True) class _UploadChunkMultipartParser: """Streaming multipart parser for streamed upload files.""" - def __init__( - self, - headers: Headers, - stream: AsyncGenerator[bytes, None], - chunk_iter: UploadChunkIterator, - ) -> None: - self.headers = headers - self.stream = stream - self.chunk_iter = chunk_iter - self._charset = "" - self._current_partial_header_name = b"" - self._current_partial_header_value = b"" - self._current_part = _UploadChunkPart() - self._chunks_to_emit: deque[UploadChunk] = deque() - self._seen_upload_chunk = False - self._part_count = 0 - self._emitted_chunk_count = 0 - self._emitted_bytes = 0 - self._stream_chunk_count = 0 + headers: Headers + stream: AsyncGenerator[bytes, None] + chunk_iter: UploadChunkIterator + _charset: str = "" + _current_partial_header_name: bytes = b"" + _current_partial_header_value: bytes = b"" + _current_part: _UploadChunkPart = dataclasses.field( + default_factory=_UploadChunkPart + ) + _chunks_to_emit: deque[UploadChunk] = dataclasses.field(default_factory=deque) + _seen_upload_chunk: bool = False + _part_count: int = 0 + _emitted_chunk_count: int = 0 + _emitted_bytes: int = 0 + _stream_chunk_count: int = 0 def on_part_begin(self) -> None: """Reset parser state for a new multipart part.""" @@ -258,10 +400,6 @@ async def parse(self) -> None: parser.finalize() await self._flush_emitted_chunks() - if not self._seen_upload_chunk: - msg = "No file chunks were uploaded." - raise MultiPartException(msg) - class _UploadStreamingResponse(StreamingResponse): """Streaming response that always releases upload form resources.""" @@ -323,6 +461,8 @@ async def _get_upload_runtime_handler( Returns: The root state instance and resolved event handler. """ + from reflex.state import _substate_key + substate_token = _substate_key(token, handler_name.rpartition(".")[0]) state = await app.state_manager.get_state(substate_token) _current_state, event_handler = state._get_event_handler(handler_name) @@ -340,6 +480,8 @@ def _seed_upload_router_data(state: BaseState, token: str) -> None: state: The root state instance. token: The client token from the upload request. """ + from reflex.state import RouterData + router_data = dict(state.router_data) if router_data.get(constants.RouteVar.CLIENT_TOKEN) == token: return @@ -362,6 +504,7 @@ async def _upload_buffered_file( Returns: A streaming response for the buffered upload. """ + from reflex.event import Event from reflex.utils.exceptions import UploadValueError try: @@ -386,10 +529,6 @@ def _create_upload_event() -> Event: The upload event backed by the parsed files. """ files = form_data.getlist("files") - if not files: - msg = "No files were uploaded." - raise UploadValueError(msg) - file_uploads = [] for file in files: if not isinstance(file, StarletteUploadFile): @@ -444,6 +583,7 @@ async def _ndjson_updates(): def _background_upload_accepted_response() -> StreamingResponse: """Return a minimal ndjson response for background upload dispatch.""" + from reflex.state import StateUpdate def _accepted_updates(): yield StateUpdate(final=True).json() + "\n" @@ -469,6 +609,8 @@ async def _upload_chunk_file( Returns: The streaming upload response. """ + from reflex.event import Event + chunk_iter = UploadChunkIterator(maxsize=8) event = Event( token=token, @@ -490,9 +632,9 @@ async def _upload_chunk_file( chunk_iter.set_consumer_task(task) parser = _UploadChunkMultipartParser( - request.headers, - request.stream(), - chunk_iter, + headers=request.headers, + stream=request.stream(), + chunk_iter=chunk_iter, ) try: @@ -540,6 +682,11 @@ async def upload_file(request: Request): UploadTypeError: If a non-streaming upload is wired to a background task. HTTPException: when the request does not include token / handler headers. """ + from reflex.event import ( + resolve_upload_chunk_handler_param, + resolve_upload_handler_param, + ) + token, handler_name = _require_upload_headers(request) _state, event_handler = await _get_upload_runtime_handler( app, token, handler_name diff --git a/reflex/components/core/upload.py b/reflex/components/core/upload.py index e7cab1ccc9b..98fd8cca1a0 100644 --- a/reflex/components/core/upload.py +++ b/reflex/components/core/upload.py @@ -6,7 +6,7 @@ from pathlib import Path from typing import Any, ClassVar -from reflex._upload import UploadFile +from reflex._upload import UploadChunkIterator, UploadFile from reflex.components.base.fragment import Fragment from reflex.components.component import ( Component, @@ -27,7 +27,6 @@ EventChain, EventHandler, EventSpec, - UploadChunkIterator, call_event_fn, call_event_handler, parse_args_spec, diff --git a/reflex/event.py b/reflex/event.py index 5022b55b75c..569960719c8 100644 --- a/reflex/event.py +++ b/reflex/event.py @@ -1,14 +1,13 @@ """Define event classes to connect the frontend and backend.""" -import asyncio import dataclasses import inspect import sys import types from base64 import b64encode -from collections import deque -from collections.abc import AsyncIterator, Callable, Mapping, Sequence +from collections.abc import Callable, Mapping, Sequence from functools import lru_cache, partial +from importlib import import_module from typing import ( TYPE_CHECKING, Annotated, @@ -95,151 +94,6 @@ def substate_token(self) -> str: UPLOAD_FILES_CLIENT_HANDLER = "uploadFiles" -@dataclasses.dataclass( - init=True, - frozen=True, -) -class UploadChunk: - """A chunk of uploaded file data.""" - - filename: str - offset: int - content_type: str - data: bytes - - -class UploadChunkIterator(AsyncIterator[UploadChunk]): - """An async iterator over uploaded file chunks.""" - - def __init__(self, *, maxsize: int = 8): - """Initialize the iterator. - - Args: - maxsize: Maximum number of chunks to buffer before blocking producers. - """ - self._maxsize = maxsize - self._chunks: deque[UploadChunk] = deque() - self._condition = asyncio.Condition() - self._closed = False - self._error: Exception | None = None - self._consumer_task: asyncio.Task[Any] | None = None - - def __aiter__(self) -> Self: - """Return the iterator itself. - - Returns: - The upload chunk iterator. - """ - return self - - async def __anext__(self) -> UploadChunk: - """Yield the next available upload chunk. - - Returns: - The next upload chunk. - - Raises: - _error: Any error forwarded from the upload producer. - StopAsyncIteration: When all chunks have been consumed. - """ - async with self._condition: - while not self._chunks and not self._closed: - await self._condition.wait() - - if self._chunks: - chunk = self._chunks.popleft() - self._condition.notify_all() - return chunk - - if self._error is not None: - raise self._error - raise StopAsyncIteration - - def set_consumer_task(self, task: asyncio.Task[Any]) -> None: - """Track the task consuming this iterator. - - Args: - task: The background task consuming upload chunks. - """ - self._consumer_task = task - task.add_done_callback(self._wake_waiters) - - async def push(self, chunk: UploadChunk) -> None: - """Push a new chunk into the iterator. - - Args: - chunk: The chunk to push. - - Raises: - RuntimeError: If the iterator is already closed or the consumer exited early. - """ - async with self._condition: - while len(self._chunks) >= self._maxsize and not self._closed: - self._raise_if_consumer_finished() - await self._condition.wait() - - if self._closed: - msg = "Upload chunk iterator is closed." - raise RuntimeError(msg) - - self._raise_if_consumer_finished() - self._chunks.append(chunk) - self._condition.notify_all() - - async def finish(self) -> None: - """Mark the iterator as complete.""" - async with self._condition: - if self._closed: - return - self._closed = True - self._condition.notify_all() - - async def fail(self, error: Exception) -> None: - """Mark the iterator as failed. - - Args: - error: The error to raise from the iterator. - """ - async with self._condition: - if self._closed: - return - self._closed = True - self._error = error - self._condition.notify_all() - - def _raise_if_consumer_finished(self) -> None: - """Raise if the consumer task exited before draining the iterator. - - Raises: - RuntimeError: If the consumer task completed before draining the iterator. - """ - if self._consumer_task is None or not self._consumer_task.done(): - return - - try: - task_exc = self._consumer_task.exception() - except asyncio.CancelledError as err: - task_exc = err - - msg = "Upload handler returned before consuming all upload chunks." - if task_exc is not None: - raise RuntimeError(msg) from task_exc - raise RuntimeError(msg) - - def _wake_waiters(self, task: asyncio.Task[Any]) -> None: - """Wake any producers or consumers blocked on the iterator condition. - - Args: - task: The completed consumer task. - """ - task.get_loop().create_task(self._notify_waiters()) - - async def _notify_waiters(self) -> None: - """Notify tasks waiting on the iterator condition.""" - async with self._condition: - self._condition.notify_all() - - def _handler_name(handler: "EventHandler") -> str: """Get a stable fully qualified handler name for errors. @@ -306,6 +160,7 @@ def resolve_upload_chunk_handler_param(handler: "EventHandler") -> tuple[str, ty UploadTypeError: If the handler is not a background task. UploadValueError: If the handler does not accept an UploadChunkIterator. """ + from reflex._upload import UploadChunkIterator from reflex.utils.exceptions import UploadTypeError, UploadValueError handler_name = _handler_name(handler) @@ -1246,6 +1101,11 @@ def as_event_spec(self, handler: EventHandler) -> EventSpec: upload_files_chunk = UploadFilesChunk +_upload_module = import_module("reflex._upload") +UploadChunk = _upload_module.UploadChunk +UploadChunkIterator = _upload_module.UploadChunkIterator + + # Special server-side events. def server_side(name: str, sig: inspect.Signature, **kwargs) -> EventSpec: """A server-side event. diff --git a/reflex/state.py b/reflex/state.py index e470e5e938a..d198029a0b0 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -140,53 +140,6 @@ async def _no_chain_background_task_gen(*args, **kwargs): # noqa: RUF029 raise TypeError(msg) -async def _normalize_upload_payload( - handler: EventHandler, - payload: builtins.dict[str, Any], -) -> None: - """Normalize upload payload keys before invoking a handler. - - The frontend always uses the ``files`` key when it falls back to queueing an - empty upload event over the websocket. Server-side upload endpoints already - resolve the real handler parameter name for non-empty uploads, so this keeps - empty uploads working without extra frontend branching. - - Args: - handler: The event handler receiving the payload. - payload: The event payload to normalize in place. - - Raises: - ValueError: If a chunk upload handler is invoked outside the upload endpoint. - """ - if "files" not in payload: - return - - try: - upload_param_name, _annotation = event.resolve_upload_handler_param(handler) - except (TypeError, ValueError): - pass - else: - if upload_param_name != "files" and upload_param_name not in payload: - payload[upload_param_name] = payload.pop("files") - return - - try: - upload_param_name, _annotation = event.resolve_upload_chunk_handler_param( - handler - ) - except (TypeError, ValueError): - return - - upload_value = payload.pop("files") - if upload_value != []: - msg = "Upload chunk handlers must be invoked through the upload endpoint." - raise ValueError(msg) - - chunk_iter = event.UploadChunkIterator(maxsize=1) - await chunk_iter.finish() - payload[upload_param_name] = chunk_iter - - def _substate_key( token: str, state_cls_or_name: BaseState | type[BaseState] | str | Sequence[str], @@ -1942,8 +1895,6 @@ async def _process_event( except Exception: type_hints = {} - await _normalize_upload_payload(handler, payload) - for arg, value in list(payload.items()): hinted_args = type_hints.get(arg, Any) if hinted_args is Any: diff --git a/tests/units/states/upload.py b/tests/units/states/upload.py index d53304a3be6..1ec847f808a 100644 --- a/tests/units/states/upload.py +++ b/tests/units/states/upload.py @@ -66,6 +66,10 @@ async def multi_handle_upload(self, files: list[rx.UploadFile]): self.img_list.append(file.name) yield + async def upload_alias_handler(self, uploads: list[rx.UploadFile]): + """Handle uploaded files with a non-default parameter name.""" + self.img_list = [f"count:{len(uploads)}"] + @rx.event(background=True) async def bg_upload(self, files: list[rx.UploadFile]): """Background task cannot be upload handler. @@ -124,6 +128,16 @@ async def chunk_handle_upload_not_background( async def chunk_handle_upload_missing_annotation(self, chunk_iter): """Invalid streaming upload handler missing the iterator annotation.""" + @rx.event(background=True) + async def chunk_handle_upload_alias(self, stream: rx.UploadChunkIterator): + """Handle streamed upload chunks with a non-default parameter name.""" + chunk_count = 0 + async for _chunk in stream: + chunk_count += 1 + + async with self: + self.completed_files = [f"chunks:{chunk_count}"] + class ChunkUploadState(_ChunkUploadMixin, State): """The base state for streaming chunk uploads.""" diff --git a/tests/units/test_app.py b/tests/units/test_app.py index 40bd8c0327d..ec645f3815b 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -1086,6 +1086,56 @@ async def send(message): # noqa: RUF029 await app.state_manager.close() +@pytest.mark.asyncio +async def test_upload_empty_buffered_request_dispatches_alias_handler( + token: str, + mocker: MockerFixture, +): + """Test that empty uploads still dispatch buffered alias handlers.""" + mocker.patch( + "reflex.state.State.class_subclasses", + {FileUploadState}, + ) + app = App() + app.event_namespace.emit = AsyncMock() # pyright: ignore [reportOptionalMemberAccess] + + async with app.modify_state(_substate_key(token, FileUploadState)) as root_state: + substate = root_state.get_substate(FileUploadState.get_full_name().split(".")) + substate.img_list = [] + + request_mock = unittest.mock.Mock() + request_mock.headers = { + "reflex-client-token": token, + "reflex-event-handler": f"{FileUploadState.get_full_name()}.upload_alias_handler", + } + + async def form(): # noqa: RUF029 + return FormData() + + request_mock.form = form + + upload_fn = upload(app) + streaming_response = await upload_fn(request_mock) + assert isinstance(streaming_response, StreamingResponse) + + updates = [] + async for state_update in streaming_response.body_iterator: + updates.append(json.loads(str(state_update))) + + assert updates[-1]["final"] + + state = await app.state_manager.get_state(_substate_key(token, FileUploadState)) + substate = ( + state + if isinstance(state, FileUploadState) + else state.get_substate(FileUploadState.get_full_name().split(".")) + ) + assert isinstance(substate, FileUploadState) + assert substate.img_list == ["count:0"] + + await app.state_manager.close() + + @pytest.mark.asyncio async def test_upload_file_closes_form_on_event_creation_cancellation( token: str, @@ -1449,6 +1499,64 @@ async def test_upload_dispatches_chunk_handlers_on_upload_endpoint( await app.state_manager.close() +@pytest.mark.asyncio +async def test_upload_empty_chunk_request_dispatches_alias_handler( + token: str, + mocker: MockerFixture, +): + """Test that empty uploads still dispatch chunk alias handlers.""" + mocker.patch( + "reflex.state.State.class_subclasses", + {ChunkUploadState}, + ) + app = App() + mocker.patch( + "reflex.utils.prerequisites.get_and_validate_app", + return_value=SimpleNamespace(app=app), + ) + app.event_namespace.emit_update = AsyncMock() # pyright: ignore [reportOptionalMemberAccess] + + async with app.modify_state(_substate_key(token, ChunkUploadState)) as root_state: + substate = root_state.get_substate(ChunkUploadState.get_full_name().split(".")) + substate.chunk_records = [] + substate.completed_files = [] + + upload_fn = upload(app) + boundary = "chunk-upload-empty-alias-boundary" + response = await upload_fn( + _make_chunk_upload_request( + token, + f"{ChunkUploadState.get_full_name()}.chunk_handle_upload_alias", + _build_chunk_upload_multipart_body(boundary, []), + content_type=f"multipart/form-data; boundary={boundary}", + ) + ) + + assert isinstance(response, StreamingResponse) + assert response.status_code == 202 + + updates = [] + async for state_update in response.body_iterator: + updates.append(json.loads(str(state_update))) + assert updates == [{"delta": {}, "events": [], "final": True}] + + task_results = await _drain_background_tasks(app) + assert all(result is None for result in task_results) + + state = await app.state_manager.get_state(_substate_key(token, ChunkUploadState)) + substate = ( + state + if isinstance(state, ChunkUploadState) + else state.get_substate(ChunkUploadState.get_full_name().split(".")) + ) + assert isinstance(substate, ChunkUploadState) + assert substate.chunk_records == [] + assert substate.completed_files == ["chunks:0"] + assert not app._background_tasks + + await app.state_manager.close() + + @pytest.mark.asyncio async def test_upload_chunk_invalid_offset_returns_400( token: str, From 611bd6f32913239bb4df26ce5fc61d45154fa0f4 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Fri, 20 Mar 2026 13:01:30 +0500 Subject: [PATCH 10/12] test: oplock test failing fix. --- tests/units/test_app.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/units/test_app.py b/tests/units/test_app.py index ec645f3815b..97dec39eb51 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -1124,6 +1124,9 @@ async def form(): # noqa: RUF029 assert updates[-1]["final"] + if environment.REFLEX_OPLOCK_ENABLED.get(): + await app.state_manager.close() + state = await app.state_manager.get_state(_substate_key(token, FileUploadState)) substate = ( state From c4e6690ff097f6488166d10924438a5ba33e2beb Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Sat, 21 Mar 2026 00:43:29 +0500 Subject: [PATCH 11/12] refactor: move UploadChunk exports from event.py to _upload module Re-export UploadChunk and UploadChunkIterator directly from reflex._upload instead of re-importing them through reflex.event, removing the eager import_module call at module load time. --- pyi_hashes.json | 2 +- reflex/__init__.py | 6 ++++-- reflex/event.py | 8 -------- 3 files changed, 5 insertions(+), 11 deletions(-) diff --git a/pyi_hashes.json b/pyi_hashes.json index f4c7a7d3608..d595a3ded49 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -1,5 +1,5 @@ { - "reflex/__init__.pyi": "276759cf35be6503c710e2203405adb6", + "reflex/__init__.pyi": "a0266c47111e9af7f340186013c7a31e", "reflex/components/__init__.pyi": "ac05995852baa81062ba3d18fbc489fb", "reflex/components/base/__init__.pyi": "16e47bf19e0d62835a605baa3d039c5a", "reflex/components/base/app_wrap.pyi": "22e94feaa9fe675bcae51c412f5b67f1", diff --git a/reflex/__init__.py b/reflex/__init__.py index e3c832ced19..29841ffd86a 100644 --- a/reflex/__init__.py +++ b/reflex/__init__.py @@ -297,12 +297,14 @@ "config": ["Config", "DBConfig"], "constants": ["Env"], "constants.colors": ["Color"], + "_upload": [ + "UploadChunk", + "UploadChunkIterator", + ], "event": [ "event", "EventChain", "EventHandler", - "UploadChunk", - "UploadChunkIterator", "call_script", "call_function", "run_script", diff --git a/reflex/event.py b/reflex/event.py index 569960719c8..a09cd121a7d 100644 --- a/reflex/event.py +++ b/reflex/event.py @@ -7,7 +7,6 @@ from base64 import b64encode from collections.abc import Callable, Mapping, Sequence from functools import lru_cache, partial -from importlib import import_module from typing import ( TYPE_CHECKING, Annotated, @@ -1101,11 +1100,6 @@ def as_event_spec(self, handler: EventHandler) -> EventSpec: upload_files_chunk = UploadFilesChunk -_upload_module = import_module("reflex._upload") -UploadChunk = _upload_module.UploadChunk -UploadChunkIterator = _upload_module.UploadChunkIterator - - # Special server-side events. def server_side(name: str, sig: inspect.Signature, **kwargs) -> EventSpec: """A server-side event. @@ -2468,8 +2462,6 @@ class EventNamespace: # File Upload FileUpload = FileUpload - UploadChunk = UploadChunk - UploadChunkIterator = UploadChunkIterator UploadFilesChunk = UploadFilesChunk # Type Aliases From bd93e2392fb266263a216eb47122b02d41300db8 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Wed, 25 Mar 2026 14:49:04 +0500 Subject: [PATCH 12/12] updated hashes --- pyi_hashes.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyi_hashes.json b/pyi_hashes.json index 834264d4990..60bf946e01e 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -19,7 +19,7 @@ "reflex/components/core/helmet.pyi": "cb5ac1be02c6f82fcc78ba74651be593", "reflex/components/core/html.pyi": "4ebe946f3fc097fc2e31dddf7040ec1c", "reflex/components/core/sticky.pyi": "cb763b986a9b0654d1a3f33440dfcf60", - "reflex/components/core/upload.pyi": "c90782be1b63276b428bce3fd4ce0af2", + "reflex/components/core/upload.pyi": "ca9f7424f3b74b1b56f5c819e8654eeb", "reflex/components/core/window_events.pyi": "e7af4bf5341c4afaf60c4a534660f68f", "reflex/components/datadisplay/__init__.pyi": "52755871369acbfd3a96b46b9a11d32e", "reflex/components/datadisplay/code.pyi": "1d123d19ef08f085422f3023540e7bb1",