diff --git a/pyproject.toml b/pyproject.toml index fc9804508..5725bce3f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -153,17 +153,16 @@ serve = [ [tool.hatch.envs.python] extra-dependencies = [ "reactpy[all]", - "ruff", - "toml", - "mypy==1.8", + "pyright", "types-toml", "types-click", "types-requests", + "types-lxml", + "jsonpointer", ] [tool.hatch.envs.python.scripts] -# TODO: Replace mypy with pyright -type_check = ["mypy --strict src/reactpy"] +type_check = ["pyright src/reactpy"] ############################ # >>> Hatch JS Scripts <<< # @@ -218,12 +217,8 @@ publish_client = [ # >>> Generic Tools <<< # ######################### -[tool.mypy] -incremental = false -ignore_missing_imports = true -warn_unused_configs = true -warn_redundant_casts = true -warn_unused_ignores = true +[tool.pyright] +reportIncompatibleVariableOverride = false [tool.coverage.run] source_pkgs = ["reactpy"] diff --git a/src/reactpy/_console/ast_utils.py b/src/reactpy/_console/ast_utils.py index 220751119..c0ce6b224 100644 --- a/src/reactpy/_console/ast_utils.py +++ b/src/reactpy/_console/ast_utils.py @@ -1,3 +1,4 @@ +# pyright: reportAttributeAccessIssue=false from __future__ import annotations import ast diff --git a/src/reactpy/_warnings.py b/src/reactpy/_warnings.py index dc6d2fa1f..6a515e0a6 100644 --- a/src/reactpy/_warnings.py +++ b/src/reactpy/_warnings.py @@ -2,7 +2,7 @@ from functools import wraps from inspect import currentframe from types import FrameType -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from warnings import warn as _warn @@ -13,7 +13,7 @@ def warn(*args: Any, **kwargs: Any) -> Any: if TYPE_CHECKING: - warn = _warn + warn = cast(Any, _warn) def _frame_depth_in_module() -> int: diff --git a/src/reactpy/config.py b/src/reactpy/config.py index be6ceb3da..993e6d8b4 100644 --- a/src/reactpy/config.py +++ b/src/reactpy/config.py @@ -42,13 +42,17 @@ def boolean(value: str | bool | int) -> bool: - :data:`REACTPY_CHECK_JSON_ATTRS` """ -REACTPY_CHECK_VDOM_SPEC = Option("REACTPY_CHECK_VDOM_SPEC", parent=REACTPY_DEBUG) +REACTPY_CHECK_VDOM_SPEC = Option( + "REACTPY_CHECK_VDOM_SPEC", parent=REACTPY_DEBUG, validator=boolean +) """Checks which ensure VDOM is rendered to spec For more info on the VDOM spec, see here: :ref:`VDOM JSON Schema` """ -REACTPY_CHECK_JSON_ATTRS = Option("REACTPY_CHECK_JSON_ATTRS", parent=REACTPY_DEBUG) +REACTPY_CHECK_JSON_ATTRS = Option( + "REACTPY_CHECK_JSON_ATTRS", parent=REACTPY_DEBUG, validator=boolean +) """Checks that all VDOM attributes are JSON serializable The VDOM spec is not able to enforce this on its own since attributes could anything. diff --git a/src/reactpy/core/hooks.py b/src/reactpy/core/hooks.py index 8420ba1fe..8adc2a9e9 100644 --- a/src/reactpy/core/hooks.py +++ b/src/reactpy/core/hooks.py @@ -66,7 +66,9 @@ def use_state(initial_value: _Type | Callable[[], _Type]) -> State[_Type]: A tuple containing the current state and a function to update it. """ current_state = _use_const(lambda: _CurrentState(initial_value)) - return State(current_state.value, current_state.dispatch) + + # FIXME: Not sure why this type hint is not being inferred correctly when using pyright + return State(current_state.value, current_state.dispatch) # type: ignore class _CurrentState(Generic[_Type]): @@ -84,10 +86,7 @@ def __init__( hook = current_hook() def dispatch(new: _Type | Callable[[_Type], _Type]) -> None: - if callable(new): - next_value = new(self.value) - else: - next_value = new + next_value = new(self.value) if callable(new) else new # type: ignore if not strictly_equal(next_value, self.value): self.value = next_value hook.schedule_render() @@ -338,9 +337,9 @@ def use_connection() -> Connection[Any]: return conn -def use_scope() -> asgi_types.HTTPScope | asgi_types.WebSocketScope: +def use_scope() -> dict[str, Any] | asgi_types.HTTPScope | asgi_types.WebSocketScope: """Get the current :class:`~reactpy.types.Connection`'s scope.""" - return use_connection().scope # type: ignore + return use_connection().scope def use_location() -> Location: @@ -511,8 +510,6 @@ def use_memo( else: changed = False - setup: Callable[[Callable[[], _Type]], _Type] - if changed: def setup(function: Callable[[], _Type]) -> _Type: @@ -524,10 +521,7 @@ def setup(function: Callable[[], _Type]) -> _Type: def setup(function: Callable[[], _Type]) -> _Type: return memo.value - if function is not None: - return setup(function) - else: - return setup + return setup(function) if function is not None else setup class _Memo(Generic[_Type]): diff --git a/src/reactpy/core/layout.py b/src/reactpy/core/layout.py index 309644b24..5115120de 100644 --- a/src/reactpy/core/layout.py +++ b/src/reactpy/core/layout.py @@ -14,6 +14,7 @@ from collections.abc import Sequence from contextlib import AsyncExitStack from logging import getLogger +from types import TracebackType from typing import ( Any, Callable, @@ -56,13 +57,13 @@ class Layout: """Responsible for "rendering" components. That is, turning them into VDOM.""" __slots__: tuple[str, ...] = ( - "root", "_event_handlers", - "_rendering_queue", + "_model_states_by_life_cycle_state_id", "_render_tasks", "_render_tasks_ready", + "_rendering_queue", "_root_life_cycle_state_id", - "_model_states_by_life_cycle_state_id", + "root", ) if not hasattr(abc.ABC, "__weakref__"): # nocov @@ -80,17 +81,17 @@ async def __aenter__(self) -> Layout: self._event_handlers: EventHandlerDict = {} self._render_tasks: set[Task[LayoutUpdateMessage]] = set() self._render_tasks_ready: Semaphore = Semaphore(0) - self._rendering_queue: _ThreadSafeQueue[_LifeCycleStateId] = _ThreadSafeQueue() root_model_state = _new_root_model_state(self.root, self._schedule_render_task) - self._root_life_cycle_state_id = root_id = root_model_state.life_cycle_state.id self._model_states_by_life_cycle_state_id = {root_id: root_model_state} self._schedule_render_task(root_id) return self - async def __aexit__(self, *exc: object) -> None: + async def __aexit__( + self, exc_type: type[Exception], exc_value: Exception, traceback: TracebackType + ) -> None: root_csid = self._root_life_cycle_state_id root_model_state = self._model_states_by_life_cycle_state_id[root_csid] @@ -109,7 +110,7 @@ async def __aexit__(self, *exc: object) -> None: del self._root_life_cycle_state_id del self._model_states_by_life_cycle_state_id - async def deliver(self, event: LayoutEventMessage) -> None: + async def deliver(self, event: LayoutEventMessage | dict[str, Any]) -> None: """Dispatch an event to the targeted handler""" # It is possible for an element in the frontend to produce an event # associated with a backend model that has been deleted. We only handle @@ -217,7 +218,7 @@ async def _render_component( parent.children_by_key[key] = new_state # need to add this model to parent's children without mutating parent model old_parent_model = parent.model.current - old_parent_children = old_parent_model["children"] + old_parent_children = old_parent_model.setdefault("children", []) parent.model.current = { **old_parent_model, "children": [ @@ -318,8 +319,11 @@ async def _render_model_children( new_state: _ModelState, raw_children: Any, ) -> None: - if not isinstance(raw_children, (list, tuple)): - raw_children = [raw_children] + if not isinstance(raw_children, list): + if isinstance(raw_children, tuple): + raw_children = list(raw_children) + else: + raw_children = [raw_children] if old_state is None: if raw_children: @@ -609,7 +613,7 @@ def __init__( parent: _ModelState | None, index: int, key: Any, - model: Ref[VdomJson], + model: Ref[VdomJson | dict[str, Any]], patch_path: str, children_by_key: dict[Key, _ModelState], targets_by_event: dict[str, str], @@ -656,7 +660,7 @@ def parent(self) -> _ModelState: return parent def append_child(self, child: Any) -> None: - self.model.current["children"].append(child) + self.model.current.setdefault("children", []).append(child) def __repr__(self) -> str: # nocov return f"ModelState({ {s: getattr(self, s, None) for s in self.__slots__} })" diff --git a/src/reactpy/core/serve.py b/src/reactpy/core/serve.py index 40a5761cf..03006a0c6 100644 --- a/src/reactpy/core/serve.py +++ b/src/reactpy/core/serve.py @@ -2,7 +2,7 @@ from collections.abc import Awaitable from logging import getLogger -from typing import Callable +from typing import Any, Callable from warnings import warn from anyio import create_task_group @@ -14,10 +14,10 @@ logger = getLogger(__name__) -SendCoroutine = Callable[[LayoutUpdateMessage], Awaitable[None]] +SendCoroutine = Callable[[LayoutUpdateMessage | dict[str, Any]], Awaitable[None]] """Send model patches given by a dispatcher""" -RecvCoroutine = Callable[[], Awaitable[LayoutEventMessage]] +RecvCoroutine = Callable[[], Awaitable[LayoutEventMessage | dict[str, Any]]] """Called by a dispatcher to return a :class:`reactpy.core.layout.LayoutEventMessage` The event will then trigger an :class:`reactpy.core.proto.EventHandlerType` in a layout. @@ -35,7 +35,9 @@ class Stop(BaseException): async def serve_layout( - layout: LayoutType[LayoutUpdateMessage, LayoutEventMessage], + layout: LayoutType[ + LayoutUpdateMessage | dict[str, Any], LayoutEventMessage | dict[str, Any] + ], send: SendCoroutine, recv: RecvCoroutine, ) -> None: @@ -55,7 +57,10 @@ async def serve_layout( async def _single_outgoing_loop( - layout: LayoutType[LayoutUpdateMessage, LayoutEventMessage], send: SendCoroutine + layout: LayoutType[ + LayoutUpdateMessage | dict[str, Any], LayoutEventMessage | dict[str, Any] + ], + send: SendCoroutine, ) -> None: while True: update = await layout.render() @@ -74,7 +79,9 @@ async def _single_outgoing_loop( async def _single_incoming_loop( task_group: TaskGroup, - layout: LayoutType[LayoutUpdateMessage, LayoutEventMessage], + layout: LayoutType[ + LayoutUpdateMessage | dict[str, Any], LayoutEventMessage | dict[str, Any] + ], recv: RecvCoroutine, ) -> None: while True: diff --git a/src/reactpy/core/vdom.py b/src/reactpy/core/vdom.py index 77b173f8f..0e6e825a4 100644 --- a/src/reactpy/core/vdom.py +++ b/src/reactpy/core/vdom.py @@ -3,7 +3,7 @@ import json from collections.abc import Mapping, Sequence from functools import wraps -from typing import Any, Protocol, cast, overload +from typing import Any, Callable, Protocol, cast from fastjsonschema import compile as compile_json_schema @@ -92,7 +92,7 @@ # we can't add a docstring to this because Sphinx doesn't know how to find its source -_COMPILED_VDOM_VALIDATOR = compile_json_schema(VDOM_JSON_SCHEMA) +_COMPILED_VDOM_VALIDATOR: Callable = compile_json_schema(VDOM_JSON_SCHEMA) # type: ignore def validate_vdom_json(value: Any) -> VdomJson: @@ -124,19 +124,7 @@ def is_vdom(value: Any) -> bool: ) -@overload -def vdom(tag: str, *children: VdomChildren) -> VdomDict: ... - - -@overload -def vdom(tag: str, attributes: VdomAttributes, *children: VdomChildren) -> VdomDict: ... - - -def vdom( - tag: str, - *attributes_and_children: Any, - **kwargs: Any, -) -> VdomDict: +def vdom(tag: str, *attributes_and_children: VdomAttributes | VdomChildren) -> VdomDict: """A helper function for creating VDOM elements. Parameters: @@ -157,33 +145,6 @@ def vdom( (subject to change) specifies javascript that, when evaluated returns a React component. """ - if kwargs: # nocov - if "key" in kwargs: - if attributes_and_children: - maybe_attributes, *children = attributes_and_children - if _is_attributes(maybe_attributes): - attributes_and_children = ( - {**maybe_attributes, "key": kwargs.pop("key")}, - *children, - ) - else: - attributes_and_children = ( - {"key": kwargs.pop("key")}, - maybe_attributes, - *children, - ) - else: - attributes_and_children = ({"key": kwargs.pop("key")},) - warn( - "An element's 'key' must be declared in an attribute dict instead " - "of as a keyword argument. This will error in a future version.", - DeprecationWarning, - ) - - if kwargs: - msg = f"Extra keyword arguments {kwargs}" - raise ValueError(msg) - model: VdomDict = {"tagName": tag} if not attributes_and_children: diff --git a/src/reactpy/executors/asgi/middleware.py b/src/reactpy/executors/asgi/middleware.py index 58dcdc8c6..54b8df511 100644 --- a/src/reactpy/executors/asgi/middleware.py +++ b/src/reactpy/executors/asgi/middleware.py @@ -12,7 +12,6 @@ import orjson from asgi_tools import ResponseText, ResponseWebSocket -from asgiref import typing as asgi_types from asgiref.compatibility import guarantee_single_callable from servestatic import ServeStaticASGI from typing_extensions import Unpack @@ -23,10 +22,18 @@ from reactpy.core.serve import serve_layout from reactpy.executors.asgi.types import ( AsgiApp, - AsgiHttpApp, - AsgiLifespanApp, - AsgiWebsocketApp, + AsgiHttpReceive, + AsgiHttpScope, + AsgiHttpSend, + AsgiReceive, + AsgiScope, + AsgiSend, + AsgiV3App, + AsgiV3HttpApp, + AsgiV3LifespanApp, + AsgiV3WebsocketApp, AsgiWebsocketReceive, + AsgiWebsocketScope, AsgiWebsocketSend, ) from reactpy.executors.utils import check_path, import_components, process_settings @@ -42,7 +49,7 @@ class ReactPyMiddleware: def __init__( self, - app: asgi_types.ASGIApplication, + app: AsgiApp, root_components: Iterable[str], **settings: Unpack[ReactPyConfig], ) -> None: @@ -80,12 +87,12 @@ def __init__( ) # User defined ASGI apps - self.extra_http_routes: dict[str, AsgiHttpApp] = {} - self.extra_ws_routes: dict[str, AsgiWebsocketApp] = {} - self.extra_lifespan_app: AsgiLifespanApp | None = None + self.extra_http_routes: dict[str, AsgiV3HttpApp] = {} + self.extra_ws_routes: dict[str, AsgiV3WebsocketApp] = {} + self.extra_lifespan_app: AsgiV3LifespanApp | None = None # Component attributes - self.asgi_app: asgi_types.ASGI3Application = guarantee_single_callable(app) # type: ignore + self.asgi_app: AsgiV3App = guarantee_single_callable(app) # type: ignore self.root_components = import_components(root_components) # Directory attributes @@ -98,10 +105,7 @@ def __init__( self.web_modules_app = WebModuleApp(parent=self) async def __call__( - self, - scope: asgi_types.Scope, - receive: asgi_types.ASGIReceiveCallable, - send: asgi_types.ASGISendCallable, + self, scope: AsgiScope, receive: AsgiReceive, send: AsgiSend ) -> None: """The ASGI entrypoint that determines whether ReactPy should route the request to ourselves or to the user application.""" @@ -125,16 +129,16 @@ async def __call__( # Serve the user's application await self.asgi_app(scope, receive, send) - def match_dispatch_path(self, scope: asgi_types.WebSocketScope) -> bool: + def match_dispatch_path(self, scope: AsgiWebsocketScope) -> bool: return bool(re.match(self.dispatcher_pattern, scope["path"])) - def match_static_path(self, scope: asgi_types.HTTPScope) -> bool: + def match_static_path(self, scope: AsgiHttpScope) -> bool: return scope["path"].startswith(self.static_path) - def match_web_modules_path(self, scope: asgi_types.HTTPScope) -> bool: + def match_web_modules_path(self, scope: AsgiHttpScope) -> bool: return scope["path"].startswith(self.web_modules_path) - def match_extra_paths(self, scope: asgi_types.Scope) -> AsgiApp | None: + def match_extra_paths(self, scope: AsgiScope) -> AsgiApp | None: # Custom defined routes are unused by default to encourage users to handle # routing within their ASGI framework of choice. return None @@ -146,13 +150,13 @@ class ComponentDispatchApp: async def __call__( self, - scope: asgi_types.WebSocketScope, - receive: asgi_types.ASGIReceiveCallable, - send: asgi_types.ASGISendCallable, + scope: AsgiWebsocketScope, + receive: AsgiWebsocketReceive, + send: AsgiWebsocketSend, ) -> None: """ASGI app for rendering ReactPy Python components.""" # Start a loop that handles ASGI websocket events - async with ReactPyWebsocket(scope, receive, send, parent=self.parent) as ws: # type: ignore + async with ReactPyWebsocket(scope, receive, send, parent=self.parent) as ws: while True: # Wait for the webserver to notify us of a new event event: dict[str, Any] = await ws.receive(raw=True) # type: ignore @@ -175,7 +179,7 @@ async def __call__( class ReactPyWebsocket(ResponseWebSocket): def __init__( self, - scope: asgi_types.WebSocketScope, + scope: AsgiWebsocketScope, receive: AsgiWebsocketReceive, send: AsgiWebsocketSend, parent: ReactPyMiddleware, @@ -231,7 +235,7 @@ async def run_dispatcher(self) -> None: await serve_layout( Layout(ConnectionContext(component(), value=connection)), self.send_json, - self.rendering_queue.get, # type: ignore + self.rendering_queue.get, ) # Manually log exceptions since this function is running in a separate asyncio task. @@ -250,10 +254,7 @@ class StaticFileApp: _static_file_server: ServeStaticASGI | None = None async def __call__( - self, - scope: asgi_types.HTTPScope, - receive: asgi_types.ASGIReceiveCallable, - send: asgi_types.ASGISendCallable, + self, scope: AsgiHttpScope, receive: AsgiHttpReceive, send: AsgiHttpSend ) -> None: """ASGI app for ReactPy static files.""" if not self._static_file_server: @@ -272,10 +273,7 @@ class WebModuleApp: _static_file_server: ServeStaticASGI | None = None async def __call__( - self, - scope: asgi_types.HTTPScope, - receive: asgi_types.ASGIReceiveCallable, - send: asgi_types.ASGISendCallable, + self, scope: AsgiHttpScope, receive: AsgiHttpReceive, send: AsgiHttpSend ) -> None: """ASGI app for ReactPy web modules.""" if not self._static_file_server: @@ -291,10 +289,7 @@ async def __call__( class Error404App: async def __call__( - self, - scope: asgi_types.HTTPScope, - receive: asgi_types.ASGIReceiveCallable, - send: asgi_types.ASGISendCallable, + self, scope: AsgiScope, receive: AsgiReceive, send: AsgiSend ) -> None: response = ResponseText("Resource not found on this server.", status_code=404) await response(scope, receive, send) # type: ignore diff --git a/src/reactpy/executors/asgi/pyscript.py b/src/reactpy/executors/asgi/pyscript.py index af2b6fafd..79ccfb2ad 100644 --- a/src/reactpy/executors/asgi/pyscript.py +++ b/src/reactpy/executors/asgi/pyscript.py @@ -9,12 +9,12 @@ from pathlib import Path from typing import Any -from asgiref.typing import WebSocketScope from typing_extensions import Unpack from reactpy import html from reactpy.executors.asgi.middleware import ReactPyMiddleware from reactpy.executors.asgi.standalone import ReactPy, ReactPyApp +from reactpy.executors.asgi.types import AsgiWebsocketScope from reactpy.executors.utils import vdom_head_to_html from reactpy.pyscript.utils import pyscript_component_html, pyscript_setup_html from reactpy.types import ReactPyConfig, VdomDict @@ -79,7 +79,9 @@ def __init__( self.html_head = html_head or html.head() self.html_lang = html_lang - def match_dispatch_path(self, scope: WebSocketScope) -> bool: # pragma: no cover + def match_dispatch_path( + self, scope: AsgiWebsocketScope + ) -> bool: # pragma: no cover """We do not use a WebSocket dispatcher for Client-Side Rendering (CSR).""" return False diff --git a/src/reactpy/executors/asgi/standalone.py b/src/reactpy/executors/asgi/standalone.py index 41fb050ff..56c7f6367 100644 --- a/src/reactpy/executors/asgi/standalone.py +++ b/src/reactpy/executors/asgi/standalone.py @@ -9,16 +9,19 @@ from typing import Callable, Literal, cast, overload from asgi_tools import ResponseHTML -from asgiref import typing as asgi_types from typing_extensions import Unpack from reactpy import html from reactpy.executors.asgi.middleware import ReactPyMiddleware from reactpy.executors.asgi.types import ( AsgiApp, - AsgiHttpApp, - AsgiLifespanApp, - AsgiWebsocketApp, + AsgiReceive, + AsgiScope, + AsgiSend, + AsgiV3HttpApp, + AsgiV3LifespanApp, + AsgiV3WebsocketApp, + AsgiWebsocketScope, ) from reactpy.executors.utils import server_side_component_html, vdom_head_to_html from reactpy.pyscript.utils import pyscript_setup_html @@ -77,20 +80,21 @@ def __init__( pyscript_head_vdom["tagName"] = "" self.html_head["children"].append(pyscript_head_vdom) # type: ignore - def match_dispatch_path(self, scope: asgi_types.WebSocketScope) -> bool: + def match_dispatch_path(self, scope: AsgiWebsocketScope) -> bool: """Method override to remove `dotted_path` from the dispatcher URL.""" return str(scope["path"]) == self.dispatcher_path - def match_extra_paths(self, scope: asgi_types.Scope) -> AsgiApp | None: + def match_extra_paths(self, scope: AsgiScope) -> AsgiApp | None: """Method override to match user-provided HTTP/Websocket routes.""" if scope["type"] == "lifespan": return self.extra_lifespan_app + routing_dictionary = {} if scope["type"] == "http": routing_dictionary = self.extra_http_routes.items() if scope["type"] == "websocket": - routing_dictionary = self.extra_ws_routes.items() # type: ignore + routing_dictionary = self.extra_ws_routes.items() return next( ( @@ -106,22 +110,22 @@ def route( self, path: str, type: Literal["http"] = "http", - ) -> Callable[[AsgiHttpApp | str], AsgiApp]: ... + ) -> Callable[[AsgiV3HttpApp | str], AsgiApp]: ... @overload def route( self, path: str, type: Literal["websocket"], - ) -> Callable[[AsgiWebsocketApp | str], AsgiApp]: ... + ) -> Callable[[AsgiV3WebsocketApp | str], AsgiApp]: ... def route( self, path: str, type: Literal["http", "websocket"] = "http", ) -> ( - Callable[[AsgiHttpApp | str], AsgiApp] - | Callable[[AsgiWebsocketApp | str], AsgiApp] + Callable[[AsgiV3HttpApp | str], AsgiApp] + | Callable[[AsgiV3WebsocketApp | str], AsgiApp] ): """Interface that allows user to define their own HTTP/Websocket routes within the current ReactPy application. @@ -142,15 +146,15 @@ def decorator( asgi_app: AsgiApp = import_dotted_path(app) if isinstance(app, str) else app if type == "http": - self.extra_http_routes[re_path] = cast(AsgiHttpApp, asgi_app) + self.extra_http_routes[re_path] = cast(AsgiV3HttpApp, asgi_app) elif type == "websocket": - self.extra_ws_routes[re_path] = cast(AsgiWebsocketApp, asgi_app) + self.extra_ws_routes[re_path] = cast(AsgiV3WebsocketApp, asgi_app) return asgi_app return decorator - def lifespan(self, app: AsgiLifespanApp | str) -> None: + def lifespan(self, app: AsgiV3LifespanApp | str) -> None: """Interface that allows user to define their own lifespan app within the current ReactPy application. @@ -176,10 +180,7 @@ class ReactPyApp: _last_modified = "" async def __call__( - self, - scope: asgi_types.Scope, - receive: asgi_types.ASGIReceiveCallable, - send: asgi_types.ASGISendCallable, + self, scope: AsgiScope, receive: AsgiReceive, send: AsgiSend ) -> None: if scope["type"] != "http": # pragma: no cover if scope["type"] != "lifespan": diff --git a/src/reactpy/executors/asgi/types.py b/src/reactpy/executors/asgi/types.py index bff5e0ca7..82a87d4f8 100644 --- a/src/reactpy/executors/asgi/types.py +++ b/src/reactpy/executors/asgi/types.py @@ -2,76 +2,108 @@ from __future__ import annotations -from collections.abc import Awaitable -from typing import Callable, Union +from collections.abc import Awaitable, MutableMapping +from typing import Any, Callable, Protocol from asgiref import typing as asgi_types -AsgiHttpReceive = Callable[ - [], - Awaitable[asgi_types.HTTPRequestEvent | asgi_types.HTTPDisconnectEvent], -] +# Type hints for `receive` within `asgi_app(scope, receive, send)` +AsgiReceive = Callable[[], Awaitable[dict[str, Any] | MutableMapping[str, Any]]] +AsgiHttpReceive = ( + Callable[ + [], Awaitable[asgi_types.HTTPRequestEvent | asgi_types.HTTPDisconnectEvent] + ] + | AsgiReceive +) +AsgiWebsocketReceive = ( + Callable[ + [], + Awaitable[ + asgi_types.WebSocketConnectEvent + | asgi_types.WebSocketDisconnectEvent + | asgi_types.WebSocketReceiveEvent + ], + ] + | AsgiReceive +) +AsgiLifespanReceive = ( + Callable[ + [], + Awaitable[asgi_types.LifespanStartupEvent | asgi_types.LifespanShutdownEvent], + ] + | AsgiReceive +) -AsgiHttpSend = Callable[ - [ - asgi_types.HTTPResponseStartEvent - | asgi_types.HTTPResponseBodyEvent - | asgi_types.HTTPResponseTrailersEvent - | asgi_types.HTTPServerPushEvent - | asgi_types.HTTPDisconnectEvent - ], - Awaitable[None], -] +# Type hints for `send` within `asgi_app(scope, receive, send)` +AsgiSend = Callable[[dict[str, Any] | MutableMapping[str, Any]], Awaitable[None]] +AsgiHttpSend = ( + Callable[ + [ + asgi_types.HTTPResponseStartEvent + | asgi_types.HTTPResponseBodyEvent + | asgi_types.HTTPResponseTrailersEvent + | asgi_types.HTTPServerPushEvent + | asgi_types.HTTPDisconnectEvent + ], + Awaitable[None], + ] + | AsgiSend +) +AsgiWebsocketSend = ( + Callable[ + [ + asgi_types.WebSocketAcceptEvent + | asgi_types.WebSocketSendEvent + | asgi_types.WebSocketResponseStartEvent + | asgi_types.WebSocketResponseBodyEvent + | asgi_types.WebSocketCloseEvent + ], + Awaitable[None], + ] + | AsgiSend +) +AsgiLifespanSend = ( + Callable[ + [ + asgi_types.LifespanStartupCompleteEvent + | asgi_types.LifespanStartupFailedEvent + | asgi_types.LifespanShutdownCompleteEvent + | asgi_types.LifespanShutdownFailedEvent + ], + Awaitable[None], + ] + | AsgiSend +) -AsgiWebsocketReceive = Callable[ - [], - Awaitable[ - asgi_types.WebSocketConnectEvent - | asgi_types.WebSocketDisconnectEvent - | asgi_types.WebSocketReceiveEvent - ], -] +# Type hints for `scope` within `asgi_app(scope, receive, send)` +AsgiScope = dict[str, Any] | MutableMapping[str, Any] +AsgiHttpScope = asgi_types.HTTPScope | AsgiScope +AsgiWebsocketScope = asgi_types.WebSocketScope | AsgiScope +AsgiLifespanScope = asgi_types.LifespanScope | AsgiScope -AsgiWebsocketSend = Callable[ - [ - asgi_types.WebSocketAcceptEvent - | asgi_types.WebSocketSendEvent - | asgi_types.WebSocketResponseStartEvent - | asgi_types.WebSocketResponseBodyEvent - | asgi_types.WebSocketCloseEvent - ], - Awaitable[None], -] -AsgiLifespanReceive = Callable[ - [], - Awaitable[asgi_types.LifespanStartupEvent | asgi_types.LifespanShutdownEvent], +# Type hints for the ASGI app interface +AsgiV3App = Callable[[AsgiScope, AsgiReceive, AsgiSend], Awaitable[None]] +AsgiV3HttpApp = Callable[ + [AsgiHttpScope, AsgiHttpReceive, AsgiHttpSend], Awaitable[None] ] - -AsgiLifespanSend = Callable[ - [ - asgi_types.LifespanStartupCompleteEvent - | asgi_types.LifespanStartupFailedEvent - | asgi_types.LifespanShutdownCompleteEvent - | asgi_types.LifespanShutdownFailedEvent - ], - Awaitable[None], +AsgiV3WebsocketApp = Callable[ + [AsgiWebsocketScope, AsgiWebsocketReceive, AsgiWebsocketSend], Awaitable[None] ] - -AsgiHttpApp = Callable[ - [asgi_types.HTTPScope, AsgiHttpReceive, AsgiHttpSend], - Awaitable[None], +AsgiV3LifespanApp = Callable[ + [AsgiLifespanScope, AsgiLifespanReceive, AsgiLifespanSend], Awaitable[None] ] -AsgiWebsocketApp = Callable[ - [asgi_types.WebSocketScope, AsgiWebsocketReceive, AsgiWebsocketSend], - Awaitable[None], -] -AsgiLifespanApp = Callable[ - [asgi_types.LifespanScope, AsgiLifespanReceive, AsgiLifespanSend], - Awaitable[None], -] +class AsgiV2Protocol(Protocol): + """The ASGI 2.0 protocol for ASGI applications. Type hints for parameters are not provided since + type checkers tend to be too strict with protocol method types matching up perfectly.""" + + def __init__(self, scope: Any) -> None: ... + + async def __call__(self, receive: Any, send: Any) -> None: ... -AsgiApp = Union[AsgiHttpApp, AsgiWebsocketApp, AsgiLifespanApp] +AsgiV2App = type[AsgiV2Protocol] +AsgiApp = AsgiV3App | AsgiV2App +"""The type hint for any ASGI application. This was written to be as generic as possible to avoid type checking issues.""" diff --git a/src/reactpy/testing/backend.py b/src/reactpy/testing/backend.py index a16196b8e..f41563489 100644 --- a/src/reactpy/testing/backend.py +++ b/src/reactpy/testing/backend.py @@ -4,7 +4,7 @@ import logging from contextlib import AsyncExitStack from types import TracebackType -from typing import TYPE_CHECKING, Any, Callable +from typing import Any, Callable from urllib.parse import urlencode, urlunparse import uvicorn @@ -14,6 +14,7 @@ from reactpy.core.hooks import use_callback, use_effect, use_state from reactpy.executors.asgi.middleware import ReactPyMiddleware from reactpy.executors.asgi.standalone import ReactPy +from reactpy.executors.asgi.types import AsgiApp from reactpy.testing.logs import ( LogAssertionError, capture_reactpy_logs, @@ -23,9 +24,6 @@ from reactpy.types import ComponentConstructor, ReactPyConfig from reactpy.utils import Ref -if TYPE_CHECKING: - from asgiref import typing as asgi_types - class BackendFixture: """A test fixture for running a server and imperatively displaying views @@ -45,7 +43,7 @@ class BackendFixture: def __init__( self, - app: asgi_types.ASGIApplication | None = None, + app: AsgiApp | None = None, host: str = "127.0.0.1", port: int | None = None, timeout: float | None = None, diff --git a/src/reactpy/testing/common.py b/src/reactpy/testing/common.py index 6921bb8da..a71277747 100644 --- a/src/reactpy/testing/common.py +++ b/src/reactpy/testing/common.py @@ -5,7 +5,7 @@ import os import shutil import time -from collections.abc import Awaitable +from collections.abc import Awaitable, Coroutine from functools import wraps from typing import Any, Callable, Generic, TypeVar, cast from uuid import uuid4 @@ -51,11 +51,12 @@ def __init__( coro: Callable[_P, Awaitable[_R]] if not inspect.iscoroutinefunction(function): - async def coro(*args: _P.args, **kwargs: _P.kwargs) -> _R: + async def async_func(*args: _P.args, **kwargs: _P.kwargs) -> _R: return cast(_R, function(*args, **kwargs)) + coro = async_func else: - coro = cast(Callable[_P, Awaitable[_R]], function) + coro = cast(Callable[_P, Coroutine[Any, Any, _R]], function) self._func = coro self._args = args self._kwargs = kwargs diff --git a/src/reactpy/types.py b/src/reactpy/types.py index 89e7c4458..483f139e5 100644 --- a/src/reactpy/types.py +++ b/src/reactpy/types.py @@ -15,14 +15,12 @@ NamedTuple, Protocol, TypeVar, - overload, runtime_checkable, ) from typing_extensions import TypeAlias, TypedDict CarrierType = TypeVar("CarrierType") - _Type = TypeVar("_Type") @@ -71,14 +69,19 @@ def render(self) -> VdomDict | ComponentType | str | None: class LayoutType(Protocol[_Render_co, _Event_contra]): """Renders and delivers, updates to views and events to handlers, respectively""" - async def render(self) -> _Render_co: - """Render an update to a view""" + async def render( + self, + ) -> _Render_co: ... # Render an update to a view - async def deliver(self, event: _Event_contra) -> None: - """Relay an event to its respective handler""" + async def deliver( + self, event: _Event_contra + ) -> None: ... # Relay an event to its respective handler - async def __aenter__(self) -> LayoutType[_Render_co, _Event_contra]: - """Prepare the layout for its first render""" + async def __aenter__( + self, + ) -> LayoutType[ + _Render_co, _Event_contra + ]: ... # Prepare the layout for its first render async def __aexit__( self, @@ -191,15 +194,6 @@ class EventHandlerType(Protocol): class VdomDictConstructor(Protocol): """Standard function for constructing a :class:`VdomDict`""" - @overload - def __call__( - self, attributes: VdomAttributes, *children: VdomChildren - ) -> VdomDict: ... - - @overload - def __call__(self, *children: VdomChildren) -> VdomDict: ... - - @overload def __call__( self, *attributes_and_children: VdomAttributes | VdomChildren ) -> VdomDict: ... @@ -212,7 +206,7 @@ class LayoutUpdateMessage(TypedDict): """The type of message""" path: str """JSON Pointer path to the model element being updated""" - model: VdomJson + model: VdomJson | dict[str, Any] """The model to assign at the given JSON Pointer path""" @@ -245,8 +239,7 @@ class ContextProviderType(ComponentType, Protocol[_Type]): """The context type""" @property - def value(self) -> _Type: - "Current context value" + def value(self) -> _Type: ... # Current context value @dataclass diff --git a/src/reactpy/utils.py b/src/reactpy/utils.py index a7fcda926..a8f3fd60f 100644 --- a/src/reactpy/utils.py +++ b/src/reactpy/utils.py @@ -74,7 +74,7 @@ def vdom_to_html(vdom: VdomDict) -> str: """ temp_root = etree.Element("__temp__") _add_vdom_to_etree(temp_root, vdom) - html = cast(bytes, tostring(temp_root)).decode() + html = cast(bytes, tostring(temp_root)).decode() # type: ignore # strip out temp root <__temp__> element return html[10:-11] @@ -145,7 +145,7 @@ def _etree_to_vdom( children = _generate_vdom_children(node, transforms) # Convert the lxml node to a VDOM dict - el = make_vdom(node.tag, dict(node.items()), *children) + el = make_vdom(str(node.tag), dict(node.items()), *children) # Perform any necessary mutations on the VDOM attributes to meet VDOM spec _mutate_vdom(el) @@ -268,7 +268,7 @@ def del_html_head_body_transform(vdom: VdomDict) -> VdomDict: The VDOM dictionary to transform. """ if vdom["tagName"] in {"html", "body", "head"}: - return {"tagName": "", "children": vdom["children"]} + return {"tagName": "", "children": vdom.setdefault("children", [])} return vdom