From 57106f1307c2015a16fa0357f53e76f5a6224765 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Wed, 14 Dec 2022 14:31:56 -0800 Subject: [PATCH 1/3] initial work on file uploads --- .gitignore | 1 - src/idom/__init__.py | 2 +- src/idom/backend/common/__init__.py | 0 .../{_common.py => common/implementations.py} | 0 src/idom/backend/common/messages.py | 118 ++++++++++++ src/idom/backend/common/types.py | 176 ++++++++++++++++++ src/idom/backend/{ => common}/utils.py | 0 src/idom/backend/default.py | 4 +- src/idom/backend/flask.py | 4 +- src/idom/backend/hooks.py | 2 +- src/idom/backend/sanic.py | 4 +- src/idom/backend/starlette.py | 4 +- src/idom/backend/tornado.py | 4 +- src/idom/backend/types.py | 78 -------- src/idom/testing/backend.py | 4 +- src/idom/types.py | 2 +- tests/test_backend/test__common.py | 5 +- tests/test_backend/test_all.py | 6 +- tests/test_backend/test_utils.py | 4 +- tests/test_client.py | 2 +- 20 files changed, 319 insertions(+), 101 deletions(-) create mode 100644 src/idom/backend/common/__init__.py rename src/idom/backend/{_common.py => common/implementations.py} (100%) create mode 100644 src/idom/backend/common/messages.py create mode 100644 src/idom/backend/common/types.py rename src/idom/backend/{ => common}/utils.py (100%) delete mode 100644 src/idom/backend/types.py diff --git a/.gitignore b/.gitignore index 50febabbe..1f937894d 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,3 @@ pip-wheel-metadata # --- JS --- node_modules - diff --git a/src/idom/__init__.py b/src/idom/__init__.py index f092c326a..f82e0d309 100644 --- a/src/idom/__init__.py +++ b/src/idom/__init__.py @@ -1,6 +1,6 @@ from . import backend, config, html, logging, sample, svg, types, web +from .backend.common.utils import run from .backend.hooks import use_connection, use_location, use_scope -from .backend.utils import run from .core import hooks from .core.component import component from .core.events import event diff --git a/src/idom/backend/common/__init__.py b/src/idom/backend/common/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/idom/backend/_common.py b/src/idom/backend/common/implementations.py similarity index 100% rename from src/idom/backend/_common.py rename to src/idom/backend/common/implementations.py diff --git a/src/idom/backend/common/messages.py b/src/idom/backend/common/messages.py new file mode 100644 index 000000000..0a4e05058 --- /dev/null +++ b/src/idom/backend/common/messages.py @@ -0,0 +1,118 @@ +from asyncio import FIRST_COMPLETED, Event, Queue, create_task, wait +from typing import AsyncIterator + +from idom.backend.common.types import ( + ByteStream, + ClientMessageType, + FileUploadMessage, + ServerMessageType, +) + + +class MessageHandler: + def __init__(self, send, recv): + self._send = send + self._recv = recv + + async def send(self, message: ServerMessageType) -> None: + ... + + async def recv(self, message: ClientMessageType) -> None: + ... + + +class FileUploadHandler: + def __init__( + self, + max_chunk_size: int | None, + max_queue_size: int | None, + completion_timeout: float, + message_timeout: float, + ) -> None: + self._max_chunk_size = max_chunk_size + self._max_queue_size = max_queue_size + self._streams: dict[str, ByteStream] = {} + + def get_stream(self, name: str, create_if_missing: bool = True) -> ByteStream: + try: + return self._streams[name] + except KeyError: + if not create_if_missing: + raise + + stream = self._streams[name] = ByteStream( + self._max_chunk_size, self._max_queue_size + ) + return stream + + async def handle(self, message: FileUploadMessage) -> None: + ... + + +class ByteStream: + def __init__( + self, + max_chunk_size: int | None = None, + max_queue_size: int | None = None, + default_timeout: float | None = None, + ) -> None: + self._queue: Queue[bytes] = Queue(max_queue_size or 0) + self._max_chunk_size = max_chunk_size + self._closed = Event() + self._default_timeout = default_timeout + + async def put(self, data: bytes, timeout: float) -> None: + if self._max_chunk_size and len(data) > self._max_chunk_size: + raise RuntimeError(f"Max chunk size of {self._max_chunk_size} exceeded") + + timeout = timeout if timeout is not None else self._default_timeout + + put_task = create_task(self._queue.put(data)) + closed_task = create_task(self._closed.wait()) + await wait( + (put_task, closed_task), + timeout=timeout, + return_when=FIRST_COMPLETED, + ) + + if put_task.done(): + return await put_task.result() + elif closed_task.done(): + return None + else: + raise TimeoutError(f"No data after {timeout} seconds") + + async def get(self, timeout: float | None = None) -> bytes | None: + timeout = timeout if timeout is not None else self._default_timeout + + get_task = create_task(self._queue.get()) + closed_task = create_task(self._closed.wait()) + await wait( + (get_task, closed_task), + timeout=timeout, + return_when=FIRST_COMPLETED, + ) + + if get_task.done(): + return await get_task.result() + elif closed_task.done(): + return None + else: + raise TimeoutError(f"No data after {timeout} seconds") + + async def iter(self, timeout: float) -> AsyncIterator[bytes]: + while True: + value = await self.get(timeout) + if value is None: + return + yield value + + def close(self) -> None: + self._closed.set() + + def is_closed(self): + return self._closed + + +class ClosedBytesStream(Exception): + """Raised when an action is performed on a closed ByteStream""" diff --git a/src/idom/backend/common/types.py b/src/idom/backend/common/types.py new file mode 100644 index 000000000..5714a91d4 --- /dev/null +++ b/src/idom/backend/common/types.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +import asyncio +from asyncio import TimeoutError, create_task +from dataclasses import dataclass +from typing import ( + Any, + AsyncIterator, + Callable, + Generic, + MutableMapping, + TypedDict, + TypeVar, + Union, + get_type_hints, +) + +from typing_extensions import Literal, Protocol, TypeGuard, runtime_checkable + +from idom.core.types import RootComponentConstructor + + +_App = TypeVar("_App") + + +@runtime_checkable +class BackendImplementation(Protocol[_App]): + """Common interface for built-in web server/framework integrations""" + + Options: Callable[..., Any] + """A constructor for options passed to :meth:`BackendImplementation.configure`""" + + def configure( + self, + app: _App, + component: RootComponentConstructor, + options: Any | None = None, + ) -> None: + """Configure the given app instance to display the given component""" + + def create_development_app(self) -> _App: + """Create an application instance for development purposes""" + + async def serve_development_app( + self, + app: _App, + host: str, + port: int, + started: asyncio.Event | None = None, + ) -> None: + """Run an application using a development server""" + + +_Carrier = TypeVar("_Carrier") + + +@dataclass +class Connection(Generic[_Carrier]): + """Represents a connection with a client""" + + scope: MutableMapping[str, Any] + """An ASGI scope or WSGI environment dictionary""" + + location: Location + """The current location (URL)""" + + carrier: _Carrier + """How the connection is mediated. For example, a request or websocket. + + This typically depends on the backend implementation. + """ + + +@dataclass +class Location: + """Represents the current location (URL) + + Analogous to, but not necessarily identical to, the client-side + ``document.location`` object. + """ + + pathname: str + """the path of the URL for the location""" + + search: str + """A search or query string - a '?' followed by the parameters of the URL. + + If there are no search parameters this should be an empty string + """ + + +_Type = TypeVar("_Type") + + +def _make_message_type_guard( + msg_type: type[_Type], +) -> Callable[[Any], TypeGuard[_Type]]: + annotations = get_type_hints(msg_type) + type_anno_args = getattr(annotations["type"], "__args__") + assert ( + isinstance(type_anno_args, tuple) + and len(type_anno_args) == 1 + and isinstance(type_anno_args[0], str) + ) + expected_type = type_anno_args[0] + + def type_guard(value: _Type) -> TypeGuard[_Type]: + assert isinstance(value, dict) + return value["type"] == expected_type + + type_guard.__doc__ = f"Check wheter the given value is a {expected_type!r} message" + + return type_guard + + +ServerMessageType = Union[ + "ServerHandshakeMessage", + "LayoutUpdateMessage", +] + +ClientMessageType = Union[ + "ClientHandshakeMessage", + "LayoutEventMessage", + "FileUploadMessage", +] + +ServerHandshakeMessage = TypedDict( + "ServerHandshakeMessage", + { + "type": Literal["server-handshake"], + "version": str, + }, +) +is_server_handshake_message = _make_message_type_guard(ServerHandshakeMessage) + +ClientHandshakeMessage = TypedDict( + "ClientHandshakeMessage", + { + "type": Literal["client-handshake"], + "version": str, + }, +) +is_client_handshake_message = _make_message_type_guard(ClientHandshakeMessage) + +LayoutUpdateMessage = TypedDict( + "LayoutUpdateMessage", + { + "type": Literal["layout-update"], + "data": "list[Any]", + "files": "list[str]", + }, +) +is_layout_udpate_message = _make_message_type_guard(LayoutUpdateMessage) + +LayoutEventMessage = TypedDict( + "LayoutEventMessage", + { + "type": Literal["layout-event"], + "data": "list[Any]", + "files": "list[str]", + }, +) +is_layout_event_message = _make_message_type_guard(LayoutEventMessage) + +FileUploadMessage = TypedDict( + "FileUploadMessage", + { + "type": Literal["file-upload"], + "file": str, + "data": bytes, + "bytes-chunk-size": int, + "bytes-sent": int, + "bytes-remaining": int, + }, +) +is_file_upload_message = _make_message_type_guard(FileUploadMessage) diff --git a/src/idom/backend/utils.py b/src/idom/backend/common/utils.py similarity index 100% rename from src/idom/backend/utils.py rename to src/idom/backend/common/utils.py diff --git a/src/idom/backend/default.py b/src/idom/backend/default.py index c874f50ab..ebcb91ac4 100644 --- a/src/idom/backend/default.py +++ b/src/idom/backend/default.py @@ -5,8 +5,8 @@ from idom.types import RootComponentConstructor -from .types import BackendImplementation -from .utils import all_implementations +from .common.types import BackendImplementation +from .common.utils import all_implementations def configure( diff --git a/src/idom/backend/flask.py b/src/idom/backend/flask.py index 95c054b83..da9c27520 100644 --- a/src/idom/backend/flask.py +++ b/src/idom/backend/flask.py @@ -25,7 +25,7 @@ from werkzeug.serving import BaseWSGIServer, make_server import idom -from idom.backend._common import ( +from idom.backend.common.implementations import ( ASSETS_PATH, MODULES_PATH, PATH_PREFIX, @@ -35,9 +35,9 @@ safe_client_build_dir_path, safe_web_modules_dir_path, ) +from idom.backend.common.types import Connection, Location from idom.backend.hooks import ConnectionContext from idom.backend.hooks import use_connection as _use_connection -from idom.backend.types import Connection, Location from idom.core.layout import LayoutEvent, LayoutUpdate from idom.core.serve import serve_json_patch from idom.core.types import ComponentType, RootComponentConstructor diff --git a/src/idom/backend/hooks.py b/src/idom/backend/hooks.py index c5b5d7c9a..772f83151 100644 --- a/src/idom/backend/hooks.py +++ b/src/idom/backend/hooks.py @@ -4,7 +4,7 @@ from idom.core.hooks import Context, create_context, use_context -from .types import Connection, Location +from .common.types import Connection, Location # backend implementations should establish this context at the root of an app diff --git a/src/idom/backend/sanic.py b/src/idom/backend/sanic.py index fda9d214f..ed77583ea 100644 --- a/src/idom/backend/sanic.py +++ b/src/idom/backend/sanic.py @@ -13,7 +13,7 @@ from sanic.server.websockets.connection import WebSocketConnection from sanic_cors import CORS -from idom.backend.types import Connection, Location +from idom.backend.common.types import Connection, Location from idom.core.layout import Layout, LayoutEvent from idom.core.serve import ( RecvCoroutine, @@ -24,7 +24,7 @@ ) from idom.core.types import RootComponentConstructor -from ._common import ( +from .common.implementations import ( ASSETS_PATH, MODULES_PATH, PATH_PREFIX, diff --git a/src/idom/backend/starlette.py b/src/idom/backend/starlette.py index 21d5200af..1c1a659aa 100644 --- a/src/idom/backend/starlette.py +++ b/src/idom/backend/starlette.py @@ -13,8 +13,8 @@ from starlette.staticfiles import StaticFiles from starlette.websockets import WebSocket, WebSocketDisconnect +from idom.backend.common.types import Connection, Location from idom.backend.hooks import ConnectionContext -from idom.backend.types import Connection, Location from idom.config import IDOM_WEB_MODULES_DIR from idom.core.layout import Layout, LayoutEvent from idom.core.serve import ( @@ -25,7 +25,7 @@ ) from idom.core.types import RootComponentConstructor -from ._common import ( +from .common.implementations import ( ASSETS_PATH, CLIENT_BUILD_DIR, MODULES_PATH, diff --git a/src/idom/backend/tornado.py b/src/idom/backend/tornado.py index a9a112ffc..7803c0007 100644 --- a/src/idom/backend/tornado.py +++ b/src/idom/backend/tornado.py @@ -15,13 +15,13 @@ from tornado.websocket import WebSocketHandler from tornado.wsgi import WSGIContainer -from idom.backend.types import Connection, Location +from idom.backend.common.types import Connection, Location from idom.config import IDOM_WEB_MODULES_DIR from idom.core.layout import Layout, LayoutEvent from idom.core.serve import VdomJsonPatch, serve_json_patch from idom.core.types import ComponentConstructor -from ._common import ( +from .common.implementations import ( ASSETS_PATH, CLIENT_BUILD_DIR, MODULES_PATH, diff --git a/src/idom/backend/types.py b/src/idom/backend/types.py deleted file mode 100644 index 2e7826fae..000000000 --- a/src/idom/backend/types.py +++ /dev/null @@ -1,78 +0,0 @@ -from __future__ import annotations - -import asyncio -from dataclasses import dataclass -from typing import Any, Callable, Generic, MutableMapping, TypeVar - -from typing_extensions import Protocol, runtime_checkable - -from idom.core.types import RootComponentConstructor - - -_App = TypeVar("_App") - - -@runtime_checkable -class BackendImplementation(Protocol[_App]): - """Common interface for built-in web server/framework integrations""" - - Options: Callable[..., Any] - """A constructor for options passed to :meth:`BackendImplementation.configure`""" - - def configure( - self, - app: _App, - component: RootComponentConstructor, - options: Any | None = None, - ) -> None: - """Configure the given app instance to display the given component""" - - def create_development_app(self) -> _App: - """Create an application instance for development purposes""" - - async def serve_development_app( - self, - app: _App, - host: str, - port: int, - started: asyncio.Event | None = None, - ) -> None: - """Run an application using a development server""" - - -_Carrier = TypeVar("_Carrier") - - -@dataclass -class Connection(Generic[_Carrier]): - """Represents a connection with a client""" - - scope: MutableMapping[str, Any] - """An ASGI scope or WSGI environment dictionary""" - - location: Location - """The current location (URL)""" - - carrier: _Carrier - """How the connection is mediated. For example, a request or websocket. - - This typically depends on the backend implementation. - """ - - -@dataclass -class Location: - """Represents the current location (URL) - - Analogous to, but not necessarily identical to, the client-side - ``document.location`` object. - """ - - pathname: str - """the path of the URL for the location""" - - search: str - """A search or query string - a '?' followed by the parameters of the URL. - - If there are no search parameters this should be an empty string - """ diff --git a/src/idom/testing/backend.py b/src/idom/testing/backend.py index 3376f8439..0e475fded 100644 --- a/src/idom/testing/backend.py +++ b/src/idom/testing/backend.py @@ -8,8 +8,8 @@ from urllib.parse import urlencode, urlunparse from idom.backend import default as default_server -from idom.backend.types import BackendImplementation -from idom.backend.utils import find_available_port +from idom.backend.common.types import BackendImplementation +from idom.backend.common.utils import find_available_port from idom.widgets import hotswap from .logs import LogAssertionError, capture_idom_logs, list_logged_exceptions diff --git a/src/idom/types.py b/src/idom/types.py index 73ffef03b..d1b0cf8e3 100644 --- a/src/idom/types.py +++ b/src/idom/types.py @@ -4,7 +4,7 @@ - :mod:`idom.backend.types` """ -from .backend.types import BackendImplementation, Connection, Location +from .backend.common.types import BackendImplementation, Connection, Location from .core.component import Component from .core.hooks import Context from .core.types import ( diff --git a/tests/test_backend/test__common.py b/tests/test_backend/test__common.py index e575625a2..06e8da10c 100644 --- a/tests/test_backend/test__common.py +++ b/tests/test_backend/test__common.py @@ -1,7 +1,10 @@ import pytest from idom import html -from idom.backend._common import traversal_safe_path, vdom_head_elements_to_html +from idom.backend.common.implementations import ( + traversal_safe_path, + vdom_head_elements_to_html, +) @pytest.mark.parametrize( diff --git a/tests/test_backend/test_all.py b/tests/test_backend/test_all.py index 98036cb16..2f84af5ed 100644 --- a/tests/test_backend/test_all.py +++ b/tests/test_backend/test_all.py @@ -5,9 +5,9 @@ import idom from idom import html from idom.backend import default as default_implementation -from idom.backend._common import PATH_PREFIX -from idom.backend.types import BackendImplementation, Connection, Location -from idom.backend.utils import all_implementations +from idom.backend.common.implementations import PATH_PREFIX +from idom.backend.common.types import BackendImplementation, Connection, Location +from idom.backend.common.utils import all_implementations from idom.testing import BackendFixture, DisplayFixture, poll diff --git a/tests/test_backend/test_utils.py b/tests/test_backend/test_utils.py index c3cb13613..95c813391 100644 --- a/tests/test_backend/test_utils.py +++ b/tests/test_backend/test_utils.py @@ -6,8 +6,8 @@ from playwright.async_api import Page from idom.backend import flask as flask_implementation -from idom.backend.utils import find_available_port -from idom.backend.utils import run as sync_run +from idom.backend.common.utils import find_available_port +from idom.backend.common.utils import run as sync_run from idom.sample import SampleApp as SampleApp diff --git a/tests/test_client.py b/tests/test_client.py index 0e48e3390..dbe8c79c6 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -5,7 +5,7 @@ from playwright.async_api import Browser import idom -from idom.backend.utils import find_available_port +from idom.backend.common.utils import find_available_port from idom.testing import BackendFixture, DisplayFixture from tests.tooling.common import DEFAULT_TYPE_DELAY From 9b12583163bc0d90c81f1e873fee0ab83e76f1f7 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Wed, 14 Dec 2022 20:27:18 -0800 Subject: [PATCH 2/3] more work --- .../idom-client-react/src/json-patch.js | 48 ++--- .../packages/idom-client-react/src/mount.js | 20 +- src/idom/__init__.py | 2 +- .../common/{implementations.py => impl.py} | 0 src/idom/backend/common/messages.py | 118 ------------ src/idom/backend/common/types.py | 104 +--------- src/idom/backend/flask.py | 4 +- src/idom/backend/sanic.py | 4 +- src/idom/backend/starlette.py | 22 +-- src/idom/backend/tornado.py | 4 +- src/idom/core/layout.py | 40 ++-- src/idom/core/serve.py | 93 --------- src/idom/core/server/__init__.py | 4 + src/idom/core/server/files.py | 177 ++++++++++++++++++ src/idom/core/server/serve.py | 87 +++++++++ src/idom/core/server/types.py | 95 ++++++++++ temp.py | 18 ++ tests/test_backend/test__common.py | 5 +- tests/test_backend/test_all.py | 2 +- tests/test_core/test_serve.py | 2 +- 20 files changed, 453 insertions(+), 396 deletions(-) rename src/idom/backend/common/{implementations.py => impl.py} (100%) delete mode 100644 src/idom/backend/common/messages.py delete mode 100644 src/idom/core/serve.py create mode 100644 src/idom/core/server/__init__.py create mode 100644 src/idom/core/server/files.py create mode 100644 src/idom/core/server/serve.py create mode 100644 src/idom/core/server/types.py create mode 100644 temp.py diff --git a/src/client/packages/idom-client-react/src/json-patch.js b/src/client/packages/idom-client-react/src/json-patch.js index 5323f11a9..4167e0f2f 100644 --- a/src/client/packages/idom-client-react/src/json-patch.js +++ b/src/client/packages/idom-client-react/src/json-patch.js @@ -1,42 +1,26 @@ import React from "react"; -import jsonpatch from "fast-json-patch"; export function useJsonPatchCallback(initial) { const doc = React.useRef(initial); const forceUpdate = useForceUpdate(); const applyPatch = React.useCallback( - (path, patch) => { + ({ path, new: newDoc }) => { if (!path) { - // We CANNOT mutate the part of the document because React checks some - // attributes of the model (e.g. model.attributes.style is checked for - // identity). - doc.current = applyNonMutativePatch( - doc.current, - patch, - false, - false, - true - ); + doc.current = newDoc; } else { - // We CAN mutate the document here though because we know that nothing above - // The patch `path` is changing. Thus, maintaining the identity for that section - // of the model is accurate. - applyMutativePatch(doc.current, [ - { - op: "replace", - path: path, - // We CANNOT mutate the part of the document where the actual patch is being - // applied. Instead we create a copy because React checks some attributes of - // the model (e.g. model.attributes.style is checked for identity). The part - // of the document above the `path` can be mutated though because we know it - // has not changed. - value: applyNonMutativePatch( - jsonpatch.getValueByPointer(doc.current, path), - patch - ), - }, - ]); + let value = doc.current; + const pathParts = path + .split("/") + .map((pathPart) => + startsWithNumber(pathPart) ? Number(pathPart) : pathPart + ); + const pathPrefix = pathParts.slice(0, -1); + const pathLast = pathParts[pathParts.length - 1]; + for (const pathPart in pathPrefix) { + value = value[pathPart]; + } + value[pathLast] = newDoc; } forceUpdate(); }, @@ -58,3 +42,7 @@ function useForceUpdate() { const [, updateState] = React.useState(); return React.useCallback(() => updateState({}), []); } + +function startsWithNumber(str) { + return /^\d/.test(str); +} diff --git a/src/client/packages/idom-client-react/src/mount.js b/src/client/packages/idom-client-react/src/mount.js index 926f2a8ae..6c9962722 100644 --- a/src/client/packages/idom-client-react/src/mount.js +++ b/src/client/packages/idom-client-react/src/mount.js @@ -38,6 +38,8 @@ function mountLayoutWithReconnectingWebSocket( socket.onopen = (event) => { console.info(`IDOM WebSocket connected.`); + socket.send(JSON.stringify({ type: "client-info", version: "0.0.1" })); + if (mountState.everMounted) { ReactDOM.unmountComponentAtNode(element); } @@ -46,13 +48,25 @@ function mountLayoutWithReconnectingWebSocket( mountLayout(element, { loadImportSource, saveUpdateHook: updateHookPromise.resolve, - sendEvent: (event) => socket.send(JSON.stringify(event)), + sendEvent: (event) => + socket.send(JSON.stringify({ type: "layout-event", data: event })), }); }; + const messageHandlers = { + "server-info": () => {}, + "layout-update": ({ data }) => + updateHookPromise.promise.then((update) => update(data)), + }; + socket.onmessage = (event) => { - const [pathPrefix, patch] = JSON.parse(event.data); - updateHookPromise.promise.then((update) => update(pathPrefix, patch)); + const msg = JSON.parse(event.data); + const handler = messageHandlers[msg["type"]]; + if (!handler) { + console.error(`Unknown message type '${msg["type"]}'`); + return; + } + handler(msg); }; socket.onclose = (event) => { diff --git a/src/idom/__init__.py b/src/idom/__init__.py index f82e0d309..dd9e1781c 100644 --- a/src/idom/__init__.py +++ b/src/idom/__init__.py @@ -16,7 +16,7 @@ use_state, ) from .core.layout import Layout -from .core.serve import Stop +from .core.server import Stop from .core.vdom import vdom from .utils import Ref, html_to_vdom, vdom_to_html from .widgets import hotswap diff --git a/src/idom/backend/common/implementations.py b/src/idom/backend/common/impl.py similarity index 100% rename from src/idom/backend/common/implementations.py rename to src/idom/backend/common/impl.py diff --git a/src/idom/backend/common/messages.py b/src/idom/backend/common/messages.py deleted file mode 100644 index 0a4e05058..000000000 --- a/src/idom/backend/common/messages.py +++ /dev/null @@ -1,118 +0,0 @@ -from asyncio import FIRST_COMPLETED, Event, Queue, create_task, wait -from typing import AsyncIterator - -from idom.backend.common.types import ( - ByteStream, - ClientMessageType, - FileUploadMessage, - ServerMessageType, -) - - -class MessageHandler: - def __init__(self, send, recv): - self._send = send - self._recv = recv - - async def send(self, message: ServerMessageType) -> None: - ... - - async def recv(self, message: ClientMessageType) -> None: - ... - - -class FileUploadHandler: - def __init__( - self, - max_chunk_size: int | None, - max_queue_size: int | None, - completion_timeout: float, - message_timeout: float, - ) -> None: - self._max_chunk_size = max_chunk_size - self._max_queue_size = max_queue_size - self._streams: dict[str, ByteStream] = {} - - def get_stream(self, name: str, create_if_missing: bool = True) -> ByteStream: - try: - return self._streams[name] - except KeyError: - if not create_if_missing: - raise - - stream = self._streams[name] = ByteStream( - self._max_chunk_size, self._max_queue_size - ) - return stream - - async def handle(self, message: FileUploadMessage) -> None: - ... - - -class ByteStream: - def __init__( - self, - max_chunk_size: int | None = None, - max_queue_size: int | None = None, - default_timeout: float | None = None, - ) -> None: - self._queue: Queue[bytes] = Queue(max_queue_size or 0) - self._max_chunk_size = max_chunk_size - self._closed = Event() - self._default_timeout = default_timeout - - async def put(self, data: bytes, timeout: float) -> None: - if self._max_chunk_size and len(data) > self._max_chunk_size: - raise RuntimeError(f"Max chunk size of {self._max_chunk_size} exceeded") - - timeout = timeout if timeout is not None else self._default_timeout - - put_task = create_task(self._queue.put(data)) - closed_task = create_task(self._closed.wait()) - await wait( - (put_task, closed_task), - timeout=timeout, - return_when=FIRST_COMPLETED, - ) - - if put_task.done(): - return await put_task.result() - elif closed_task.done(): - return None - else: - raise TimeoutError(f"No data after {timeout} seconds") - - async def get(self, timeout: float | None = None) -> bytes | None: - timeout = timeout if timeout is not None else self._default_timeout - - get_task = create_task(self._queue.get()) - closed_task = create_task(self._closed.wait()) - await wait( - (get_task, closed_task), - timeout=timeout, - return_when=FIRST_COMPLETED, - ) - - if get_task.done(): - return await get_task.result() - elif closed_task.done(): - return None - else: - raise TimeoutError(f"No data after {timeout} seconds") - - async def iter(self, timeout: float) -> AsyncIterator[bytes]: - while True: - value = await self.get(timeout) - if value is None: - return - yield value - - def close(self) -> None: - self._closed.set() - - def is_closed(self): - return self._closed - - -class ClosedBytesStream(Exception): - """Raised when an action is performed on a closed ByteStream""" diff --git a/src/idom/backend/common/types.py b/src/idom/backend/common/types.py index 5714a91d4..2e7826fae 100644 --- a/src/idom/backend/common/types.py +++ b/src/idom/backend/common/types.py @@ -1,21 +1,10 @@ from __future__ import annotations import asyncio -from asyncio import TimeoutError, create_task from dataclasses import dataclass -from typing import ( - Any, - AsyncIterator, - Callable, - Generic, - MutableMapping, - TypedDict, - TypeVar, - Union, - get_type_hints, -) - -from typing_extensions import Literal, Protocol, TypeGuard, runtime_checkable +from typing import Any, Callable, Generic, MutableMapping, TypeVar + +from typing_extensions import Protocol, runtime_checkable from idom.core.types import RootComponentConstructor @@ -87,90 +76,3 @@ class Location: If there are no search parameters this should be an empty string """ - - -_Type = TypeVar("_Type") - - -def _make_message_type_guard( - msg_type: type[_Type], -) -> Callable[[Any], TypeGuard[_Type]]: - annotations = get_type_hints(msg_type) - type_anno_args = getattr(annotations["type"], "__args__") - assert ( - isinstance(type_anno_args, tuple) - and len(type_anno_args) == 1 - and isinstance(type_anno_args[0], str) - ) - expected_type = type_anno_args[0] - - def type_guard(value: _Type) -> TypeGuard[_Type]: - assert isinstance(value, dict) - return value["type"] == expected_type - - type_guard.__doc__ = f"Check wheter the given value is a {expected_type!r} message" - - return type_guard - - -ServerMessageType = Union[ - "ServerHandshakeMessage", - "LayoutUpdateMessage", -] - -ClientMessageType = Union[ - "ClientHandshakeMessage", - "LayoutEventMessage", - "FileUploadMessage", -] - -ServerHandshakeMessage = TypedDict( - "ServerHandshakeMessage", - { - "type": Literal["server-handshake"], - "version": str, - }, -) -is_server_handshake_message = _make_message_type_guard(ServerHandshakeMessage) - -ClientHandshakeMessage = TypedDict( - "ClientHandshakeMessage", - { - "type": Literal["client-handshake"], - "version": str, - }, -) -is_client_handshake_message = _make_message_type_guard(ClientHandshakeMessage) - -LayoutUpdateMessage = TypedDict( - "LayoutUpdateMessage", - { - "type": Literal["layout-update"], - "data": "list[Any]", - "files": "list[str]", - }, -) -is_layout_udpate_message = _make_message_type_guard(LayoutUpdateMessage) - -LayoutEventMessage = TypedDict( - "LayoutEventMessage", - { - "type": Literal["layout-event"], - "data": "list[Any]", - "files": "list[str]", - }, -) -is_layout_event_message = _make_message_type_guard(LayoutEventMessage) - -FileUploadMessage = TypedDict( - "FileUploadMessage", - { - "type": Literal["file-upload"], - "file": str, - "data": bytes, - "bytes-chunk-size": int, - "bytes-sent": int, - "bytes-remaining": int, - }, -) -is_file_upload_message = _make_message_type_guard(FileUploadMessage) diff --git a/src/idom/backend/flask.py b/src/idom/backend/flask.py index da9c27520..7cd68e369 100644 --- a/src/idom/backend/flask.py +++ b/src/idom/backend/flask.py @@ -25,7 +25,7 @@ from werkzeug.serving import BaseWSGIServer, make_server import idom -from idom.backend.common.implementations import ( +from idom.backend.common.impl import ( ASSETS_PATH, MODULES_PATH, PATH_PREFIX, @@ -39,7 +39,7 @@ from idom.backend.hooks import ConnectionContext from idom.backend.hooks import use_connection as _use_connection from idom.core.layout import LayoutEvent, LayoutUpdate -from idom.core.serve import serve_json_patch +from idom.core.server import serve_json_patch from idom.core.types import ComponentType, RootComponentConstructor from idom.utils import Ref diff --git a/src/idom/backend/sanic.py b/src/idom/backend/sanic.py index ed77583ea..0c1980548 100644 --- a/src/idom/backend/sanic.py +++ b/src/idom/backend/sanic.py @@ -15,7 +15,7 @@ from idom.backend.common.types import Connection, Location from idom.core.layout import Layout, LayoutEvent -from idom.core.serve import ( +from idom.core.server import ( RecvCoroutine, SendCoroutine, Stop, @@ -24,7 +24,7 @@ ) from idom.core.types import RootComponentConstructor -from .common.implementations import ( +from .common.impl import ( ASSETS_PATH, MODULES_PATH, PATH_PREFIX, diff --git a/src/idom/backend/starlette.py b/src/idom/backend/starlette.py index 1c1a659aa..4e6d084a2 100644 --- a/src/idom/backend/starlette.py +++ b/src/idom/backend/starlette.py @@ -17,15 +17,11 @@ from idom.backend.hooks import ConnectionContext from idom.config import IDOM_WEB_MODULES_DIR from idom.core.layout import Layout, LayoutEvent -from idom.core.serve import ( - RecvCoroutine, - SendCoroutine, - VdomJsonPatch, - serve_json_patch, -) +from idom.core.server import RecvCoroutine, SendCoroutine +from idom.core.server import serve as serve_layout from idom.core.types import RootComponentConstructor -from .common.implementations import ( +from .common.impl import ( ASSETS_PATH, CLIENT_BUILD_DIR, MODULES_PATH, @@ -151,7 +147,9 @@ async def model_stream(socket: WebSocket) -> None: search = socket.scope["query_string"].decode() try: - await serve_json_patch( + await serve_layout( + send, + recv, Layout( ConnectionContext( constructor(), @@ -162,8 +160,6 @@ async def model_stream(socket: WebSocket) -> None: ), ) ), - send, - recv, ) except WebSocketDisconnect as error: logger.info(f"WebSocket disconnect: {error.code}") @@ -172,10 +168,10 @@ async def model_stream(socket: WebSocket) -> None: def _make_send_recv_callbacks( socket: WebSocket, ) -> Tuple[SendCoroutine, RecvCoroutine]: - async def sock_send(value: VdomJsonPatch) -> None: + async def sock_send(value: Any) -> None: await socket.send_text(json.dumps(value)) - async def sock_recv() -> LayoutEvent: - return LayoutEvent(**json.loads(await socket.receive_text())) + async def sock_recv() -> Any: + return json.loads(await socket.receive_text()) return sock_send, sock_recv diff --git a/src/idom/backend/tornado.py b/src/idom/backend/tornado.py index 7803c0007..0df4e1780 100644 --- a/src/idom/backend/tornado.py +++ b/src/idom/backend/tornado.py @@ -18,10 +18,10 @@ from idom.backend.common.types import Connection, Location from idom.config import IDOM_WEB_MODULES_DIR from idom.core.layout import Layout, LayoutEvent -from idom.core.serve import VdomJsonPatch, serve_json_patch +from idom.core.server import VdomJsonPatch, serve_json_patch from idom.core.types import ComponentConstructor -from .common.implementations import ( +from .common.impl import ( ASSETS_PATH, CLIENT_BUILD_DIR, MODULES_PATH, diff --git a/src/idom/core/layout.py b/src/idom/core/layout.py index bbc1848a5..9dde02240 100644 --- a/src/idom/core/layout.py +++ b/src/idom/core/layout.py @@ -17,6 +17,7 @@ Optional, Set, Tuple, + TypedDict, TypeVar, cast, ) @@ -24,36 +25,32 @@ from weakref import ref as weakref from idom.config import IDOM_CHECK_VDOM_SPEC, IDOM_DEBUG_MODE +from idom.core._event_proxy import _wrap_in_warning_event_proxies +from idom.core.hooks import LifeCycleHook +from idom.core.types import ComponentType, EventHandlerDict, VdomDict, VdomJson +from idom.core.vdom import validate_vdom_json from idom.utils import Ref -from ._event_proxy import _wrap_in_warning_event_proxies -from .hooks import LifeCycleHook -from .types import ComponentType, EventHandlerDict, VdomDict, VdomJson -from .vdom import validate_vdom_json - logger = getLogger(__name__) -class LayoutUpdate(NamedTuple): +class LayoutUpdate(TypedDict): """A change to a view as a result of a :meth:`Layout.render`""" path: str """A "/" delimited path to the element from the root of the layout""" - old: Optional[VdomJson] - """The old state of the layout""" - new: VdomJson """The new state of the layout""" -class LayoutEvent(NamedTuple): +class LayoutEvent(TypedDict): """An event that should be relayed to its handler by :meth:`Layout.deliver`""" target: str """The ID of the event handler.""" - data: List[Any] + data: list[Any] """A list of event data passed to the event handler.""" @@ -110,16 +107,16 @@ async def deliver(self, event: LayoutEvent) -> None: # associated with a backend model that has been deleted. We only handle # events if the element and the handler exist in the backend. Otherwise # we just ignore the event. - handler = self._event_handlers.get(event.target) + handler = self._event_handlers.get(event["target"]) if handler is not None: try: - await handler.function(_wrap_in_warning_event_proxies(event.data)) + await handler.function(_wrap_in_warning_event_proxies(event["data"])) except Exception: logger.exception(f"Failed to execute event handler {handler}") else: logger.info( - f"Ignored event - handler {event.target!r} does not exist or its component unmounted" + f"Ignored event - handler {event['target']!r} does not exist or its component unmounted" ) async def render(self) -> LayoutUpdate: @@ -148,17 +145,10 @@ def _create_layout_update(self, old_state: _ModelState) -> LayoutUpdate: with ExitStack() as exit_stack: self._render_component(exit_stack, old_state, new_state, component) - old_model: Optional[VdomJson] - try: - old_model = old_state.model.current - except AttributeError: - old_model = None - - return LayoutUpdate( - path=new_state.patch_path, - old=old_model, - new=new_state.model.current, - ) + return { + "path": new_state.patch_path, + "new": new_state.model.current, + } def _render_component( self, diff --git a/src/idom/core/serve.py b/src/idom/core/serve.py deleted file mode 100644 index 69071555f..000000000 --- a/src/idom/core/serve.py +++ /dev/null @@ -1,93 +0,0 @@ -from __future__ import annotations - -from asyncio import ensure_future -from asyncio.tasks import ensure_future -from logging import getLogger -from typing import Any, Awaitable, Callable, Dict, List, NamedTuple, cast - -from anyio import create_task_group -from jsonpatch import apply_patch - -from .layout import LayoutEvent, LayoutUpdate -from .types import LayoutType, VdomJson - - -logger = getLogger(__name__) - - -SendCoroutine = Callable[["VdomJsonPatch"], Awaitable[None]] -"""Send model patches given by a dispatcher""" - -RecvCoroutine = Callable[[], Awaitable[LayoutEvent]] -"""Called by a dispatcher to return a :class:`idom.core.layout.LayoutEvent` - -The event will then trigger an :class:`idom.core.proto.EventHandlerType` in a layout. -""" - - -class Stop(BaseException): - """Stop serving changes and events - - Raising this error will tell dispatchers to gracefully exit. Typically this is - called by code running inside a layout to tell it to stop rendering. - """ - - -async def serve_json_patch( - layout: LayoutType[LayoutUpdate, LayoutEvent], - send: SendCoroutine, - recv: RecvCoroutine, -) -> None: - """Run a dispatch loop for a single view instance""" - async with layout: - try: - async with create_task_group() as task_group: - task_group.start_soon(_single_outgoing_loop, layout, send) - task_group.start_soon(_single_incoming_loop, layout, recv) - except Stop: - logger.info("Stopped dispatch task") - - -async def render_json_patch(layout: LayoutType[LayoutUpdate, Any]) -> VdomJsonPatch: - """Render a class:`VdomJsonPatch` from a layout""" - return VdomJsonPatch.create_from(await layout.render()) - - -class VdomJsonPatch(NamedTuple): - """An object describing an update to a :class:`Layout` in the form of a JSON patch""" - - path: str - """The path where changes should be applied""" - - changes: List[Dict[str, Any]] - """A list of JSON patches to apply at the given path""" - - def apply_to(self, model: VdomJson) -> VdomJson: - """Return the model resulting from the changes in this update""" - return cast( - VdomJson, - apply_patch( - model, [{**c, "path": self.path + c["path"]} for c in self.changes] - ), - ) - - @classmethod - def create_from(cls, update: LayoutUpdate) -> VdomJsonPatch: - """Return a patch given an layout update""" - return cls(update.path, [{"op": "replace", "path": "", "value": update.new}]) - - -async def _single_outgoing_loop( - layout: LayoutType[LayoutUpdate, LayoutEvent], send: SendCoroutine -) -> None: - while True: - await send(await render_json_patch(layout)) - - -async def _single_incoming_loop( - layout: LayoutType[LayoutUpdate, LayoutEvent], recv: RecvCoroutine -) -> None: - while True: - # We need to fire and forget here so that we avoid waiting on the completion - # of this event handler before receiving and running the next one. - ensure_future(layout.deliver(await recv())) diff --git a/src/idom/core/server/__init__.py b/src/idom/core/server/__init__.py new file mode 100644 index 000000000..550ebd37f --- /dev/null +++ b/src/idom/core/server/__init__.py @@ -0,0 +1,4 @@ +from .serve import RecvCoroutine, SendCoroutine, Stop, serve + + +__all__ = ["serve", "Stop"] diff --git a/src/idom/core/server/files.py b/src/idom/core/server/files.py new file mode 100644 index 000000000..9baa24912 --- /dev/null +++ b/src/idom/core/server/files.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +from asyncio import ( + FIRST_COMPLETED, + Event, + Queue, + Semaphore, + Task, + create_task, + sleep, + wait, +) +from contextlib import AsyncExitStack +from dataclasses import dataclass +from logging import getLogger +from typing import AsyncIterator +from weakref import ReferenceType, finalize, ref + +from idom.backend.common.types import ByteStream, FileUploadMessage + + +logger = getLogger(__name__) + + +class ByteStreamFiles: + def __init__( + self, + max_chunk_size: int | None = None, + max_queue_size: int | None = None, + max_stream_count: int | None = None, + message_timeout: float | None = None, + completion_timeout: float | None = None, + ) -> None: + self._max_chunk_size = max_chunk_size + self._max_queue_size = max_queue_size + self._message_timeout = message_timeout + self._completion_timeout = completion_timeout + self._stream_count_semaphore = ( + Semaphore(max_stream_count) if max_stream_count else None + ) + self._streams: dict[str, _ByteStreamState] = {} + + def get(self, file: str) -> ByteStream: + """Get a byte stream that will be written to.""" + if file not in self._streams: + stream = ByteStream( + self._max_chunk_size, + self._max_queue_size, + self._message_timeout, + ) + self._create_stream_state(stream) + else: + stream = self._streams[file] + return stream + + async def handle(self, message: FileUploadMessage) -> None: + file = message["file"] + state = self._streams.get(file) + if state is None: + logger.info(f"No stream exists for {file!r}") + + stream = state.stream() + if stream is None: + logger.info(f"No stream exists for {file!r}") + + if self._stream_count_semaphore is not None: + await state.exit_stack.enter_async_context(self._stream_count_semaphore) + + await stream.put(message["data"]) + + if not message["bytes-remaining"]: + await self._clean_stream_state(file) + + def _create_stream_state(self, file: str, stream: ByteStream) -> None: + async def clean_on_timeout(): + await sleep(self._completion_timeout) + logger.warning( + f"File upload for {file!r} timed out " + f"after {self._completion_timeout} seconds." + ) + await self._clean_stream_state(file) + + self._streams[file] = _ByteStreamState( + stream=ref(stream), + exit_stack=AsyncExitStack(), + timeout_task=create_task(clean_on_timeout()), + ) + + finalize(stream, lambda: create_task(self._create_stream_state(file))) + + async def _clean_stream_state(self, file: str) -> None: + state = self._streams.pop(file, None) + if state is None: + return None + state.timeout_task.cancel() + await self._streams.pop(file).exit_stack.aclose() + + +@dataclass +class _ByteStreamState: + stream: ReferenceType[ByteStream] + exit_stack: AsyncExitStack + timeout_task: Task[None] + + +class ByteStream: + def __init__( + self, + max_chunk_size: int | None = None, + max_queue_size: int | None = None, + default_timeout: float | None = None, + ) -> None: + self._queue: Queue[bytes] = Queue(max_queue_size or 0) + self._max_chunk_size = max_chunk_size + self._closed = Event() + self._default_timeout = default_timeout + + async def put(self, data: bytes, timeout: float) -> None: + """Put data into the stream and raise ``RuntimeError`` if closed.""" + if self._closed.is_set(): + raise RuntimeError("Stream already closed.") + elif self._max_chunk_size and len(data) > self._max_chunk_size: + raise RuntimeError(f"Max chunk size of {self._max_chunk_size} exceeded") + + timeout = timeout if timeout is not None else self._default_timeout + + put_task = create_task(self._queue.put(data)) + closed_task = create_task(self._closed.wait()) + await wait( + (put_task, closed_task), + timeout=timeout, + return_when=FIRST_COMPLETED, + ) + + if put_task.done(): + return None + elif closed_task.done(): + raise RuntimeError("Stream closed while putting.") + else: + raise TimeoutError(f"No data after {timeout} seconds") + + async def get(self, timeout: float | None = None) -> bytes | None: + """Return bytes or None when the stream is closed.""" + if self._closed.is_set(): + return None + + timeout = timeout if timeout is not None else self._default_timeout + + get_task = create_task(self._queue.get()) + closed_task = create_task(self._closed.wait()) + await wait( + (get_task, closed_task), + timeout=timeout, + return_when=FIRST_COMPLETED, + ) + + if get_task.done(): + return await get_task.result() + elif closed_task.done(): + return None + else: + raise TimeoutError(f"No data after {timeout} seconds") + + async def iter(self, timeout: float) -> AsyncIterator[bytes]: + while True: + value = await self.get(timeout) + if value is None: + return + yield value + + def close(self) -> None: + """Close the stream""" + self._closed.set() + + def is_closed(self): + """Whether this stream is already closed""" + return self._closed diff --git a/src/idom/core/server/serve.py b/src/idom/core/server/serve.py new file mode 100644 index 000000000..876afe4ef --- /dev/null +++ b/src/idom/core/server/serve.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from asyncio import create_task +from logging import getLogger +from typing import Any, Awaitable, Callable + +from anyio import create_task_group + +from idom.core.layout import LayoutEvent, LayoutUpdate +from idom.core.server.types import ( + ClientInfoMessage, + ClientMessage, + FileHandler, + FileUploadMessage, + LayoutEventMessage, + ServerMessage, +) +from idom.core.types import LayoutType + + +logger = getLogger(__name__) +VERSION = "0.0.1" + +SendCoroutine = Callable[[ServerMessage], Awaitable[None]] +RecvCoroutine = Callable[[], Awaitable[ClientMessage]] +BuiltinLayout = LayoutType[LayoutUpdate, LayoutEvent] + + +async def serve( + send: SendCoroutine, + recv: RecvCoroutine, + layout: BuiltinLayout, + file_handler: FileHandler | None = None, +) -> None: + + async with layout: + try: + async with create_task_group() as task_group: + task_group.start_soon(_outgoing_loop, send, layout) + task_group.start_soon(_incoming_loop, recv, layout, file_handler) + except Stop: + logger.info("Stopped dispatch task") + + +class Stop(BaseException): + """Stop serving changes and events + + Raising this error will tell dispatchers to gracefully exit. Typically this is + called by code running inside a layout to tell it to stop rendering. + """ + + +async def _outgoing_loop(send: SendCoroutine, layout: BuiltinLayout) -> None: + await send({"type": "server-info", "version": VERSION}) + while True: + await send({"type": "layout-update", "data": await layout.render()}) + + +async def _incoming_loop( + recv: RecvCoroutine, + layout: BuiltinLayout, + file_handler: FileHandler | None, +) -> None: + async def handle_layout_event(message: LayoutEventMessage) -> None: + await layout.deliver(message["data"]) + + async def handle_client_info(message: ClientInfoMessage) -> None: + ... + + async def handle_file_upload(message: FileUploadMessage) -> None: + if file_handler: + await file_handler.handle(message) + + message_handlers: dict[str, Callable[[Any], Awaitable[None]]] = { + "layout-event": handle_layout_event, + "client-info": handle_client_info, + "file-upload": handle_file_upload, + } + + while True: + message = await recv() + handler = message_handlers.get(message["type"]) + if handler is None: + logger.error(f"Unknown message type {message['type']!r}") + # We need to fire and forget here so that we avoid waiting on the completion + # of this event handler before receiving and running the next one. + create_task(handler(message)) diff --git a/src/idom/core/server/types.py b/src/idom/core/server/types.py new file mode 100644 index 000000000..325359417 --- /dev/null +++ b/src/idom/core/server/types.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +from typing import Any, Callable, TypedDict, TypeVar, Union, get_type_hints + +from typing_extensions import Literal, Protocol, TypeGuard + +from idom.core.layout import LayoutEvent, LayoutUpdate + + +class FileHandler(Protocol): + async def handle(self, message: FileUploadMessage) -> None: + ... + + +_Type = TypeVar("_Type") + + +def _make_message_type_guard( + msg_type: type[_Type], +) -> Callable[[Any], TypeGuard[_Type]]: + annotations = get_type_hints(msg_type) + type_anno_args = getattr(annotations["type"], "__args__") + assert ( + isinstance(type_anno_args, tuple) + and len(type_anno_args) == 1 + and isinstance(type_anno_args[0], str) + ) + expected_type = type_anno_args[0] + + def type_guard(value: _Type) -> TypeGuard[_Type]: + assert isinstance(value, dict) + return value["type"] == expected_type + + type_guard.__doc__ = f"Check wheter the given value is a {expected_type!r} message" + + return type_guard + + +ServerMessage = "ServerInfoMessage" + +ClientMessage = Union[ + "ClientInfoMessage", + "LayoutEventMessage", + "FileUploadMessage", +] + +ServerInfoMessage = TypedDict( + "ServerInfoMessage", + { + "type": Literal["server-info"], + "version": str, + }, +) +is_server_info_message = _make_message_type_guard(ServerInfoMessage) + +ClientInfoMessage = TypedDict( + "ClientInfoMessage", + { + "type": Literal["client-info"], + "version": str, + }, +) +is_client_info_message = _make_message_type_guard(ClientInfoMessage) + + +LayoutUpdateMessage = TypedDict( + "LayoutUpdateMessage", + { + "type": Literal["layout-update"], + "data": LayoutUpdate, + }, +) +is_layout_udpate_message = _make_message_type_guard(LayoutUpdateMessage) + +LayoutEventMessage = TypedDict( + "LayoutEventMessage", + { + "type": Literal["layout-event"], + "data": LayoutEvent, + }, +) +is_layout_event_message = _make_message_type_guard(LayoutEventMessage) + +FileUploadMessage = TypedDict( + "FileUploadMessage", + { + "type": Literal["file-upload"], + "file": str, + "data": bytes, + "bytes-chunk-size": int, + "bytes-sent": int, + "bytes-remaining": int, + }, +) +is_file_upload_message = _make_message_type_guard(FileUploadMessage) diff --git a/temp.py b/temp.py new file mode 100644 index 000000000..e163ae961 --- /dev/null +++ b/temp.py @@ -0,0 +1,18 @@ +import asyncio + +from idom import component, html, run +from idom.backend.starlette import ( + configure, + create_development_app, + serve_development_app, +) + + +@component +def temp(): + return html.h1("asd") + + +app = create_development_app() +configure(app, temp) +asyncio.run(serve_development_app(app, "localhost", 8000)) diff --git a/tests/test_backend/test__common.py b/tests/test_backend/test__common.py index 06e8da10c..c08e230e8 100644 --- a/tests/test_backend/test__common.py +++ b/tests/test_backend/test__common.py @@ -1,10 +1,7 @@ import pytest from idom import html -from idom.backend.common.implementations import ( - traversal_safe_path, - vdom_head_elements_to_html, -) +from idom.backend.common.impl import traversal_safe_path, vdom_head_elements_to_html @pytest.mark.parametrize( diff --git a/tests/test_backend/test_all.py b/tests/test_backend/test_all.py index 2f84af5ed..e6028b99c 100644 --- a/tests/test_backend/test_all.py +++ b/tests/test_backend/test_all.py @@ -5,7 +5,7 @@ import idom from idom import html from idom.backend import default as default_implementation -from idom.backend.common.implementations import PATH_PREFIX +from idom.backend.common.impl import PATH_PREFIX from idom.backend.common.types import BackendImplementation, Connection, Location from idom.backend.common.utils import all_implementations from idom.testing import BackendFixture, DisplayFixture, poll diff --git a/tests/test_core/test_serve.py b/tests/test_core/test_serve.py index 8e3f05ded..b78bb7898 100644 --- a/tests/test_core/test_serve.py +++ b/tests/test_core/test_serve.py @@ -3,7 +3,7 @@ import idom from idom.core.layout import Layout, LayoutEvent, LayoutUpdate -from idom.core.serve import VdomJsonPatch, serve_json_patch +from idom.core.server import VdomJsonPatch, serve_json_patch from idom.testing import StaticEventHandler From 1271337b99c00300bb6767c372b432fdd2d8c1d4 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Wed, 14 Dec 2022 20:36:32 -0800 Subject: [PATCH 3/3] fix icon loading --- src/client/vite.config.js | 2 +- src/idom/backend/common/impl.py | 4 ++-- src/idom/backend/flask.py | 8 ++++---- src/idom/backend/sanic.py | 6 +++--- src/idom/backend/starlette.py | 6 +++--- src/idom/backend/tornado.py | 6 +++--- 6 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/client/vite.config.js b/src/client/vite.config.js index bbcb8ed43..22359a60f 100644 --- a/src/client/vite.config.js +++ b/src/client/vite.config.js @@ -8,5 +8,5 @@ export default defineConfig({ "react-dom": "preact/compat", }, }, - base: "/_idom", + base: "/_idom/client", }); diff --git a/src/idom/backend/common/impl.py b/src/idom/backend/common/impl.py index 90e2dea5b..5e871b46c 100644 --- a/src/idom/backend/common/impl.py +++ b/src/idom/backend/common/impl.py @@ -19,7 +19,7 @@ PATH_PREFIX = PurePosixPath("/_idom") MODULES_PATH = PATH_PREFIX / "modules" -ASSETS_PATH = PATH_PREFIX / "assets" +CLIENT_PATH = PATH_PREFIX / "client" STREAM_PATH = PATH_PREFIX / "stream" CLIENT_BUILD_DIR = Path(_idom_file_path).parent / "_client" @@ -115,7 +115,7 @@ class CommonOptions: html.link( { "rel": "icon", - "href": "_idom/assets/idom-logo-square-small.svg", + "href": "_idom/client/idom-logo-square-small.svg", "type": "image/svg+xml", } ), diff --git a/src/idom/backend/flask.py b/src/idom/backend/flask.py index 7cd68e369..3c5993038 100644 --- a/src/idom/backend/flask.py +++ b/src/idom/backend/flask.py @@ -26,7 +26,7 @@ import idom from idom.backend.common.impl import ( - ASSETS_PATH, + CLIENT_PATH, MODULES_PATH, PATH_PREFIX, STREAM_PATH, @@ -157,9 +157,9 @@ def _setup_common_routes( cors_params = cors_options if isinstance(cors_options, dict) else {} CORS(api_blueprint, **cors_params) - @api_blueprint.route(f"/{ASSETS_PATH.name}/") - def send_assets_dir(path: str = "") -> Any: - return send_file(safe_client_build_dir_path(f"assets/{path}")) + @api_blueprint.route(f"/{CLIENT_PATH.name}/") + def send_client_dir(path: str = "") -> Any: + return send_file(safe_client_build_dir_path(path)) @api_blueprint.route(f"/{MODULES_PATH.name}/") def send_modules_dir(path: str = "") -> Any: diff --git a/src/idom/backend/sanic.py b/src/idom/backend/sanic.py index 0c1980548..741c57e24 100644 --- a/src/idom/backend/sanic.py +++ b/src/idom/backend/sanic.py @@ -25,7 +25,7 @@ from idom.core.types import RootComponentConstructor from .common.impl import ( - ASSETS_PATH, + CLIENT_PATH, MODULES_PATH, PATH_PREFIX, STREAM_PATH, @@ -130,9 +130,9 @@ async def asset_files( path: str = "", ) -> response.HTTPResponse: path = urllib_parse.unquote(path) - return await response.file(safe_client_build_dir_path(f"assets/{path}")) + return await response.file(safe_client_build_dir_path(path)) - api_blueprint.add_route(asset_files, f"/{ASSETS_PATH.name}/") + api_blueprint.add_route(asset_files, f"/{CLIENT_PATH.name}/") async def web_module_files( request: request.Request, diff --git a/src/idom/backend/starlette.py b/src/idom/backend/starlette.py index 4e6d084a2..cb5017aa4 100644 --- a/src/idom/backend/starlette.py +++ b/src/idom/backend/starlette.py @@ -22,8 +22,8 @@ from idom.core.types import RootComponentConstructor from .common.impl import ( - ASSETS_PATH, CLIENT_BUILD_DIR, + CLIENT_PATH, MODULES_PATH, STREAM_PATH, CommonOptions, @@ -115,8 +115,8 @@ def _setup_common_routes(options: Options, app: Starlette) -> None: StaticFiles(directory=IDOM_WEB_MODULES_DIR.current, check_dir=False), ) app.mount( - str(ASSETS_PATH), - StaticFiles(directory=CLIENT_BUILD_DIR / "assets", check_dir=False), + str(CLIENT_PATH), + StaticFiles(directory=CLIENT_BUILD_DIR, check_dir=False), ) # register this last so it takes least priority index_route = _make_index_route(options) diff --git a/src/idom/backend/tornado.py b/src/idom/backend/tornado.py index 0df4e1780..699d892b1 100644 --- a/src/idom/backend/tornado.py +++ b/src/idom/backend/tornado.py @@ -22,8 +22,8 @@ from idom.core.types import ComponentConstructor from .common.impl import ( - ASSETS_PATH, CLIENT_BUILD_DIR, + CLIENT_PATH, MODULES_PATH, STREAM_PATH, CommonOptions, @@ -118,9 +118,9 @@ def _setup_common_routes(options: Options) -> _RouteHandlerSpecs: {"path": str(IDOM_WEB_MODULES_DIR.current)}, ), ( - rf"{ASSETS_PATH}/(.*)", + rf"{CLIENT_PATH}/(.*)", StaticFileHandler, - {"path": str(CLIENT_BUILD_DIR / "assets")}, + {"path": str(CLIENT_BUILD_DIR)}, ), ( r"/(.*)", pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy