From 8a26c2c28f3605a23578fec021b399b8896db6b7 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sat, 5 Mar 2022 22:44:39 -0800 Subject: [PATCH 01/58] initial work to move away from run func --- src/idom/__init__.py | 3 +- src/idom/sample.py | 28 +------ src/idom/server/__init__.py | 8 -- src/idom/server/develop.py | 133 +++++++++++++++++++++++++++++++ src/idom/server/prefab.py | 151 ------------------------------------ src/idom/server/types.py | 43 ---------- src/idom/server/utils.py | 43 +--------- 7 files changed, 139 insertions(+), 270 deletions(-) create mode 100644 src/idom/server/develop.py delete mode 100644 src/idom/server/prefab.py delete mode 100644 src/idom/server/types.py diff --git a/src/idom/__init__.py b/src/idom/__init__.py index 8d250090e..61a9e7ad0 100644 --- a/src/idom/__init__.py +++ b/src/idom/__init__.py @@ -16,7 +16,7 @@ from .core.layout import Layout from .core.vdom import vdom from .sample import run_sample_app -from .server.prefab import run +from .server.develop import develop from .utils import Ref, html_to_vdom from .widgets import hotswap, multiview @@ -38,7 +38,6 @@ "multiview", "Ref", "run_sample_app", - "run", "Stop", "types", "use_callback", diff --git a/src/idom/sample.py b/src/idom/sample.py index b0844ada9..ac8133b19 100644 --- a/src/idom/sample.py +++ b/src/idom/sample.py @@ -3,17 +3,15 @@ import webbrowser from typing import Any -from idom.server.types import ServerType - from . import html from .core.component import component from .core.types import VdomDict -from .server.utils import find_available_port, find_builtin_server_type +from .server.develop import develop @component def App() -> VdomDict: - return html.div( + return html._( {"style": {"padding": "15px"}}, html.h1("Sample Application"), html.p( @@ -31,8 +29,7 @@ def run_sample_app( host: str = "127.0.0.1", port: int | None = None, open_browser: bool = False, - run_in_thread: bool | None = None, -) -> ServerType[Any]: +) -> None: """Run a sample application. Args: @@ -40,21 +37,4 @@ def run_sample_app( port: the port on the host to serve from open_browser: whether to open a browser window after starting the server """ - port = port or find_available_port(host) - server_type = find_builtin_server_type("PerClientStateServer") - server = server_type(App) - - run_in_thread = open_browser or run_in_thread - - if not run_in_thread: # pragma: no cover - server.run(host=host, port=port) - return server - - thread = server.run_in_thread(host=host, port=port) - server.wait_until_started(5) - - if open_browser: # pragma: no cover - webbrowser.open(f"http://{host}:{port}") - thread.join() - - return server + develop(App, None, host, port, open_browser=open_browser) diff --git a/src/idom/server/__init__.py b/src/idom/server/__init__.py index 0dfd40ace..e69de29bb 100644 --- a/src/idom/server/__init__.py +++ b/src/idom/server/__init__.py @@ -1,8 +0,0 @@ -from .prefab import hotswap_server, multiview_server, run - - -__all__ = [ - "hotswap_server", - "multiview_server", - "run", -] diff --git a/src/idom/server/develop.py b/src/idom/server/develop.py new file mode 100644 index 000000000..e2a319ce3 --- /dev/null +++ b/src/idom/server/develop.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import asyncio +import warnings +import webbrowser +from importlib import import_module +from typing import Any, Awaitable, TypeVar, runtime_checkable + +from typing_extensions import Protocol + +from idom.types import ComponentConstructor + +from .utils import find_available_port + + +SUPPORTED_PACKAGES = ( + "starlette", + "fastapi", + "sanic", + "flask", + "tornado", +) + + +def develop( + component: ComponentConstructor, + app: Any | None = None, + host: str = "127.0.0.1", + port: int | None = None, + open_browser: bool = True, +) -> None: + """Run a component with a development server""" + + warnings.warn( + "You are running a development server, be sure to change this before deploying in production!", + UserWarning, + stacklevel=2, + ) + + implementation = _get_implementation(app) + + if app is None: + app = implementation.create_development_app() + + implementation.configure_development_view(component) + + coros: list[Awaitable] = [] + + host = host + port = port or find_available_port(host) + server_did_start = asyncio.Event() + + coros.append( + implementation.serve_development_app( + app, + host=host, + port=port, + did_start=server_did_start, + ) + ) + + if open_browser: + coros.append(_open_browser_after_server(host, port, server_did_start)) + + asyncio.get_event_loop().run_forever(asyncio.gather(*coros)) + + +async def _open_browser_after_server( + host: str, + port: int, + server_did_start: asyncio.Event, +) -> None: + await server_did_start.wait() + webbrowser.open(f"http://{host}:{port}") + + +def _get_implementation(app: _App | None) -> _Implementation: + implementations = _all_implementations() + + if app is None: + return next(iter(implementations.values())) + + for cls in type(app).mro(): + if cls in implementations: + return implementations[cls] + else: + raise TypeError(f"No built-in and installed implementation supports {app}") + + +def _all_implementations() -> dict[type[Any], _Implementation]: + if not _INSTALLED_IMPLEMENTATIONS: + for name in SUPPORTED_PACKAGES: + try: + module = import_module(f"idom.server.{name}") + except ImportError: # pragma: no cover + continue + + if not isinstance(module, _Implementation): + raise TypeError(f"{module.__name__!r} is an invalid implementation") + + _INSTALLED_IMPLEMENTATIONS[module.SERVER_TYPE] = module + + if not _INSTALLED_IMPLEMENTATIONS: + raise RuntimeError("No built-in implementations are installed") + + return _INSTALLED_IMPLEMENTATIONS + + +_App = TypeVar("_App") + + +@runtime_checkable +class _Implementation(Protocol): + + APP_TYPE = type[Any] + + def create_development_app(self) -> Any: + ... + + def configure_development_view(self, component: ComponentConstructor) -> None: + ... + + async def serve_development_app( + self, + app: Any, + host: str, + port: int, + did_start: asyncio.Event, + ) -> None: + ... + + +_INSTALLED_IMPLEMENTATIONS: dict[type[Any], _Implementation[Any]] = {} diff --git a/src/idom/server/prefab.py b/src/idom/server/prefab.py deleted file mode 100644 index f264ce9ca..000000000 --- a/src/idom/server/prefab.py +++ /dev/null @@ -1,151 +0,0 @@ -import logging -from typing import Any, Dict, Optional, Tuple, TypeVar - -from idom.core.types import ComponentConstructor -from idom.widgets import MountFunc, MultiViewMount, hotswap, multiview - -from .types import ServerFactory, ServerType -from .utils import find_available_port, find_builtin_server_type - - -logger = logging.getLogger(__name__) - -_App = TypeVar("_App") -_Config = TypeVar("_Config") - - -def run( - component: ComponentConstructor, - server_type: Optional[ServerFactory[_App, _Config]] = None, - host: str = "127.0.0.1", - port: Optional[int] = None, - server_config: Optional[Any] = None, - run_kwargs: Optional[Dict[str, Any]] = None, - app: Optional[Any] = None, - daemon: bool = False, -) -> ServerType[_App]: - """A utility for quickly running a render server with minimal boilerplate - - Parameters: - component: - The root of the view. - server_type: - What server to run. Defaults to a builtin implementation if available. - host: - The host string. - port: - The port number. Defaults to a dynamically discovered available port. - server_config: - Options passed to configure the server. - run_kwargs: - Keyword arguments passed to the :meth:`~idom.server.proto.Server.run` - or :meth:`~idom.server.proto.Server.run_in_thread` methods of the server - depending on whether ``daemon`` is set or not. - app: - Register the server to an existing application and run that. - daemon: - Whether the server should be run in a daemon thread. - - Returns: - The server instance. This isn't really useful unless the server is spawned - as a daemon. Otherwise this function blocks until the server has stopped. - """ - if server_type is None: - server_type = find_builtin_server_type("PerClientStateServer") - if port is None: # pragma: no cover - port = find_available_port(host) - - server = server_type(component, server_config, app) - logger.info(f"Using {type(server).__name__}") - - run_server = server.run if not daemon else server.run_in_thread - run_server(host, port, **(run_kwargs or {})) - server.wait_until_started() - - return server - - -def multiview_server( - server_type: Optional[ServerFactory[_App, _Config]] = None, - host: str = "127.0.0.1", - port: Optional[int] = None, - server_config: Optional[_Config] = None, - run_kwargs: Optional[Dict[str, Any]] = None, - app: Optional[Any] = None, -) -> Tuple[MultiViewMount, ServerType[_App]]: - """Set up a server where views can be dynamically added. - - In other words this allows the user to work with IDOM in an imperative manner. Under - the hood this uses the :func:`idom.widgets.multiview` function to add the views on - the fly. - - Parameters: - server: The server type to start up as a daemon - host: The server hostname - port: The server port number - server_config: Value passed to :meth:`~idom.server.proto.ServerFactory` - run_kwargs: Keyword args passed to :meth:`~idom.server.proto.Server.run_in_thread` - app: Optionally provide a prexisting application to register to - - Returns: - The server instance and a function for adding views. See - :func:`idom.widgets.multiview` for details. - """ - mount, component = multiview() - - server = run( - component, - server_type, - host, - port, - server_config=server_config, - run_kwargs=run_kwargs, - daemon=True, - app=app, - ) - - return mount, server - - -def hotswap_server( - server_type: Optional[ServerFactory[_App, _Config]] = None, - host: str = "127.0.0.1", - port: Optional[int] = None, - server_config: Optional[_Config] = None, - run_kwargs: Optional[Dict[str, Any]] = None, - app: Optional[Any] = None, - sync_views: bool = False, -) -> Tuple[MountFunc, ServerType[_App]]: - """Set up a server where views can be dynamically swapped out. - - In other words this allows the user to work with IDOM in an imperative manner. Under - the hood this uses the :func:`idom.widgets.hotswap` function to swap the views on - the fly. - - Parameters: - server: The server type to start up as a daemon - host: The server hostname - port: The server port number - server_config: Value passed to :meth:`~idom.server.proto.ServerFactory` - run_kwargs: Keyword args passed to :meth:`~idom.server.proto.Server.run_in_thread` - app: Optionally provide a prexisting application to register to - sync_views: Whether to update all displays with newly mounted components - - Returns: - The server instance and a function for swapping views. See - :func:`idom.widgets.hotswap` for details. - """ - mount, component = hotswap(update_on_change=sync_views) - - server = run( - component, - server_type, - host, - port, - server_config=server_config, - run_kwargs=run_kwargs, - daemon=True, - app=app, - ) - - return mount, server diff --git a/src/idom/server/types.py b/src/idom/server/types.py deleted file mode 100644 index d17495664..000000000 --- a/src/idom/server/types.py +++ /dev/null @@ -1,43 +0,0 @@ -from __future__ import annotations - -from threading import Thread -from typing import Optional, TypeVar - -from typing_extensions import Protocol - -from idom.core.types import ComponentConstructor - - -_App = TypeVar("_App") -_Config = TypeVar("_Config", contravariant=True) - - -class ServerFactory(Protocol[_App, _Config]): - """Setup a :class:`Server`""" - - def __call__( - self, - constructor: ComponentConstructor, - config: Optional[_Config] = None, - app: Optional[_App] = None, - ) -> ServerType[_App]: - ... - - -class ServerType(Protocol[_App]): - """A thin wrapper around a web server that provides a common operational interface""" - - app: _App - """The server's underlying application""" - - def run(self, host: str, port: int) -> None: - """Start running the server""" - - def run_in_thread(self, host: str, port: int) -> Thread: - """Run the server in a thread""" - - def wait_until_started(self, timeout: Optional[float] = None) -> None: - """Block until the server is able to receive requests""" - - def stop(self, timeout: Optional[float] = None) -> None: - """Stop the running server""" diff --git a/src/idom/server/utils.py b/src/idom/server/utils.py index cb2c88a7c..b17484bc1 100644 --- a/src/idom/server/utils.py +++ b/src/idom/server/utils.py @@ -3,28 +3,17 @@ import time from contextlib import closing from functools import wraps -from importlib import import_module from pathlib import Path from threading import Event, Thread -from typing import Any, Callable, List, Optional +from typing import Any, Callable, Optional from typing_extensions import ParamSpec import idom -from .types import ServerFactory - CLIENT_BUILD_DIR = Path(idom.__file__).parent / "client" -_SUPPORTED_PACKAGES = [ - "sanic", - "fastapi", - "flask", - "tornado", - "starlette", -] - _FuncParams = ParamSpec("_FuncParams") @@ -66,36 +55,6 @@ def poll( time.sleep(frequency) -def find_builtin_server_type(type_name: str) -> ServerFactory[Any, Any]: - """Find first installed server implementation - - Raises: - :class:`RuntimeError` if one cannot be found - """ - installed_builtins: List[str] = [] - for name in _SUPPORTED_PACKAGES: - try: - import_module(name) - except ImportError: # pragma: no cover - continue - else: - builtin_module = import_module(f"idom.server.{name}") - installed_builtins.append(builtin_module.__name__) - try: - return getattr(builtin_module, type_name) # type: ignore - except AttributeError: # pragma: no cover - pass - else: # pragma: no cover - if not installed_builtins: - raise RuntimeError( - f"Found none of the following builtin server implementations {_SUPPORTED_PACKAGES}" - ) - else: - raise ImportError( - f"No server type {type_name!r} found in installed implementations {installed_builtins}" - ) - - def find_available_port( host: str, port_min: int = 8000, From 5ef939f9beecfc9dae4c7c68cb5f841ae69448fe Mon Sep 17 00:00:00 2001 From: rmorshea Date: Wed, 16 Mar 2022 22:42:02 -0700 Subject: [PATCH 02/58] initial work converting to new server interface --- src/idom/__init__.py | 3 +- src/idom/sample.py | 4 +- src/idom/server/any.py | 120 +++++++++++++++++ src/idom/server/develop.py | 133 ------------------- src/idom/server/fastapi.py | 55 +++----- src/idom/server/flask.py | 99 +++++++------- src/idom/server/sanic.py | 220 +++++++------------------------ src/idom/server/starlette.py | 247 ++++++++++------------------------- 8 files changed, 310 insertions(+), 571 deletions(-) create mode 100644 src/idom/server/any.py delete mode 100644 src/idom/server/develop.py diff --git a/src/idom/__init__.py b/src/idom/__init__.py index 61a9e7ad0..582c295d4 100644 --- a/src/idom/__init__.py +++ b/src/idom/__init__.py @@ -16,7 +16,7 @@ from .core.layout import Layout from .core.vdom import vdom from .sample import run_sample_app -from .server.develop import develop +from .server.any import run from .utils import Ref, html_to_vdom from .widgets import hotswap, multiview @@ -38,6 +38,7 @@ "multiview", "Ref", "run_sample_app", + "run", "Stop", "types", "use_callback", diff --git a/src/idom/sample.py b/src/idom/sample.py index ac8133b19..d907bf43b 100644 --- a/src/idom/sample.py +++ b/src/idom/sample.py @@ -6,7 +6,7 @@ from . import html from .core.component import component from .core.types import VdomDict -from .server.develop import develop +from .server.any import run @component @@ -37,4 +37,4 @@ def run_sample_app( port: the port on the host to serve from open_browser: whether to open a browser window after starting the server """ - develop(App, None, host, port, open_browser=open_browser) + run(App, host, port, open_browser=open_browser) diff --git a/src/idom/server/any.py b/src/idom/server/any.py new file mode 100644 index 000000000..fddc57770 --- /dev/null +++ b/src/idom/server/any.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +import asyncio +import warnings +import webbrowser +from importlib import import_module +from typing import Any, Awaitable, Iterator, TypeVar, runtime_checkable + +from typing_extensions import Protocol + +from idom.types import ComponentConstructor + +from .utils import find_available_port + + +SUPPORTED_PACKAGES = ( + "starlette", + "fastapi", + "sanic", + "flask", + "tornado", +) + + +def run( + component: ComponentConstructor, + host: str = "127.0.0.1", + port: int | None = None, + open_browser: bool = True, +) -> None: + """Run a component with a development server""" + + warnings.warn( + "You are running a development server, be sure to change this before deploying in production!", + UserWarning, + stacklevel=2, + ) + + try: + implementation = next(all_implementations()) + except StopIteration: + raise RuntimeError( # pragma: no cover + f"Found no built-in server implementation installed {SUPPORTED_PACKAGES}" + ) + + app = implementation.create_development_app() + implementation.configure(app, component) + + coros: list[Awaitable] = [] + + host = host + port = port or find_available_port(host) + started = asyncio.Event() + + coros.append(implementation.serve_development_app(app, host, port, started)) + + if open_browser: + + async def _open_browser_after_server() -> None: + await started.wait() + webbrowser.open(f"http://{host}:{port}") + + coros.append(_open_browser_after_server()) + + asyncio.get_event_loop().run_forever(asyncio.gather(*coros)) + + +def configure(app: Any, component: ComponentConstructor) -> None: + return get_implementation().configure(app, component) + + +def create_development_app() -> Any: + return get_implementation().create_development_app() + + +async def serve_development_app( + app: Any, host: str, port: int, started: asyncio.Event +) -> None: + return await get_implementation().serve_development_app(app, host, port, started) + + +def get_implementation() -> Implementation: + """Get the first available server implementation""" + try: + return next(all_implementations()) + except StopIteration: + raise RuntimeError("No built-in server implementation installed.") + + +def all_implementations() -> Iterator[Implementation]: + """Yield all available server implementations""" + for name in SUPPORTED_PACKAGES: + try: + module = import_module(f"idom.server.{name}") + except ImportError: # pragma: no cover + continue + + if not isinstance(module, Implementation): + raise TypeError(f"{module.__name__!r} is an invalid implementation") + + yield module + + +_App = TypeVar("_App") + + +@runtime_checkable +class Implementation(Protocol): + """Common interface for IDOM's builti-in server implementations""" + + def configure(self, app: _App, component: ComponentConstructor) -> 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: + """Run an application using a development server""" diff --git a/src/idom/server/develop.py b/src/idom/server/develop.py deleted file mode 100644 index e2a319ce3..000000000 --- a/src/idom/server/develop.py +++ /dev/null @@ -1,133 +0,0 @@ -from __future__ import annotations - -import asyncio -import warnings -import webbrowser -from importlib import import_module -from typing import Any, Awaitable, TypeVar, runtime_checkable - -from typing_extensions import Protocol - -from idom.types import ComponentConstructor - -from .utils import find_available_port - - -SUPPORTED_PACKAGES = ( - "starlette", - "fastapi", - "sanic", - "flask", - "tornado", -) - - -def develop( - component: ComponentConstructor, - app: Any | None = None, - host: str = "127.0.0.1", - port: int | None = None, - open_browser: bool = True, -) -> None: - """Run a component with a development server""" - - warnings.warn( - "You are running a development server, be sure to change this before deploying in production!", - UserWarning, - stacklevel=2, - ) - - implementation = _get_implementation(app) - - if app is None: - app = implementation.create_development_app() - - implementation.configure_development_view(component) - - coros: list[Awaitable] = [] - - host = host - port = port or find_available_port(host) - server_did_start = asyncio.Event() - - coros.append( - implementation.serve_development_app( - app, - host=host, - port=port, - did_start=server_did_start, - ) - ) - - if open_browser: - coros.append(_open_browser_after_server(host, port, server_did_start)) - - asyncio.get_event_loop().run_forever(asyncio.gather(*coros)) - - -async def _open_browser_after_server( - host: str, - port: int, - server_did_start: asyncio.Event, -) -> None: - await server_did_start.wait() - webbrowser.open(f"http://{host}:{port}") - - -def _get_implementation(app: _App | None) -> _Implementation: - implementations = _all_implementations() - - if app is None: - return next(iter(implementations.values())) - - for cls in type(app).mro(): - if cls in implementations: - return implementations[cls] - else: - raise TypeError(f"No built-in and installed implementation supports {app}") - - -def _all_implementations() -> dict[type[Any], _Implementation]: - if not _INSTALLED_IMPLEMENTATIONS: - for name in SUPPORTED_PACKAGES: - try: - module = import_module(f"idom.server.{name}") - except ImportError: # pragma: no cover - continue - - if not isinstance(module, _Implementation): - raise TypeError(f"{module.__name__!r} is an invalid implementation") - - _INSTALLED_IMPLEMENTATIONS[module.SERVER_TYPE] = module - - if not _INSTALLED_IMPLEMENTATIONS: - raise RuntimeError("No built-in implementations are installed") - - return _INSTALLED_IMPLEMENTATIONS - - -_App = TypeVar("_App") - - -@runtime_checkable -class _Implementation(Protocol): - - APP_TYPE = type[Any] - - def create_development_app(self) -> Any: - ... - - def configure_development_view(self, component: ComponentConstructor) -> None: - ... - - async def serve_development_app( - self, - app: Any, - host: str, - port: int, - did_start: asyncio.Event, - ) -> None: - ... - - -_INSTALLED_IMPLEMENTATIONS: dict[type[Any], _Implementation[Any]] = {} diff --git a/src/idom/server/fastapi.py b/src/idom/server/fastapi.py index 4dbb0e281..b6722bf88 100644 --- a/src/idom/server/fastapi.py +++ b/src/idom/server/fastapi.py @@ -1,54 +1,39 @@ -from typing import Optional +from __future__ import annotations from fastapi import FastAPI +from idom.config import IDOM_DEBUG_MODE from idom.core.types import ComponentConstructor from .starlette import ( - Config, - StarletteServer, + Options, _setup_common_routes, - _setup_config_and_app, - _setup_shared_view_dispatcher_route, + _setup_options, _setup_single_view_dispatcher_route, + serve_development_app, ) -def PerClientStateServer( - constructor: ComponentConstructor, - config: Optional[Config] = None, - app: Optional[FastAPI] = None, -) -> StarletteServer: - """Return a :class:`StarletteServer` where each client has its own state. - - Implements the :class:`~idom.server.proto.ServerFactory` protocol +__all__ = "configure", "serve_development_app", "create_development_app" - Parameters: - constructor: A component constructor - config: Options for configuring server behavior - app: An application instance (otherwise a default instance is created) - """ - config, app = _setup_config_and_app(config, app, FastAPI) - _setup_common_routes(config, app) - _setup_single_view_dispatcher_route(config["url_prefix"], app, constructor) - return StarletteServer(app) - -def SharedClientStateServer( +def configure( + app: FastAPI, constructor: ComponentConstructor, - config: Optional[Config] = None, - app: Optional[FastAPI] = None, -) -> StarletteServer: - """Return a :class:`StarletteServer` where each client shares state. - - Implements the :class:`~idom.server.proto.ServerFactory` protocol + options: Options | None = None, +) -> None: + """Prepare a :class:`FastAPI` server to serve the given component Parameters: + app: An application instance constructor: A component constructor config: Options for configuring server behavior - app: An application instance (otherwise a default instance is created) + """ - config, app = _setup_config_and_app(config, app, FastAPI) - _setup_common_routes(config, app) - _setup_shared_view_dispatcher_route(config["url_prefix"], app, constructor) - return StarletteServer(app) + options = _setup_options(options) + _setup_common_routes(options, app) + _setup_single_view_dispatcher_route(options["url_prefix"], app, constructor) + + +def create_development_app() -> FastAPI: + return FastAPI(debug=IDOM_DEBUG_MODE.current) diff --git a/src/idom/server/flask.py b/src/idom/server/flask.py index 667071808..ea99b2ae8 100644 --- a/src/idom/server/flask.py +++ b/src/idom/server/flask.py @@ -7,7 +7,7 @@ from queue import Queue as ThreadQueue from threading import Event as ThreadEvent from threading import Thread -from typing import Any, Callable, Dict, NamedTuple, Optional, Tuple, Union, cast +from typing import Any, Callable, Dict, NamedTuple, Optional, Union, cast from urllib.parse import parse_qs as parse_query_string from flask import Blueprint, Flask, redirect, request, send_from_directory, url_for @@ -30,7 +30,37 @@ logger = logging.getLogger(__name__) -class Config(TypedDict, total=False): +def configure( + app: Flask, component: ComponentConstructor, options: Options | None = None +) -> FlaskServer: + """Return a :class:`FlaskServer` where each client has its own state. + + Implements the :class:`~idom.server.proto.ServerFactory` protocol + + Parameters: + constructor: A component constructor + options: Options for configuring server behavior + app: An application instance (otherwise a default instance is created) + """ + options = _setup_options(options) + blueprint = Blueprint("idom", __name__, url_prefix=options["url_prefix"]) + _setup_common_routes(blueprint, options) + _setup_single_view_dispatcher_route(app, options, component) + app.register_blueprint(blueprint) + return FlaskServer(app) + + +def create_development_app() -> Flask: + return Flask(__name__) + + +async def serve_development_app( + app: Flask, host: str, port: int, started: asyncio.Event +) -> None: + ... + + +class Options(TypedDict, total=False): """Render server config for :class:`FlaskRenderServer`""" cors: Union[bool, Dict[str, Any]] @@ -55,28 +85,6 @@ class Config(TypedDict, total=False): """The URL prefix where IDOM resources will be served from""" -def PerClientStateServer( - constructor: ComponentConstructor, - config: Optional[Config] = None, - app: Optional[Flask] = None, -) -> FlaskServer: - """Return a :class:`FlaskServer` where each client has its own state. - - Implements the :class:`~idom.server.proto.ServerFactory` protocol - - Parameters: - constructor: A component constructor - config: Options for configuring server behavior - app: An application instance (otherwise a default instance is created) - """ - config, app = _setup_config_and_app(config, app) - blueprint = Blueprint("idom", __name__, url_prefix=config["url_prefix"]) - _setup_common_routes(blueprint, config) - _setup_single_view_dispatcher_route(app, config, constructor) - app.register_blueprint(blueprint) - return FlaskServer(app) - - class FlaskServer: """A thin wrapper for running a Flask application @@ -95,7 +103,7 @@ def server_did_start() -> None: def run(self, host: str, port: int, *args: Any, **kwargs: Any) -> None: if IDOM_DEBUG_MODE.current: - logging.basicConfig(level=logging.DEBUG) # pragma: no cover + logging.basicOptions(level=logging.DEBUG) # pragma: no cover logger.info(f"Running at http://{host}:{port}") self._wsgi_server = _StartCallbackWSGIServer( self._did_start.set, @@ -123,28 +131,23 @@ def stop(self, timeout: Optional[float] = 3.0) -> None: server.stop(timeout) -def _setup_config_and_app( - config: Optional[Config], app: Optional[Flask] -) -> Tuple[Config, Flask]: - return ( - { - "url_prefix": "", - "cors": False, - "serve_static_files": True, - "redirect_root_to_index": True, - **(config or {}), # type: ignore - }, - app or Flask(__name__), - ) - - -def _setup_common_routes(blueprint: Blueprint, config: Config) -> None: - cors_config = config["cors"] - if cors_config: # pragma: no cover - cors_params = cors_config if isinstance(cors_config, dict) else {} +def _setup_options(options: Options | None) -> Options: + return { + "url_prefix": "", + "cors": False, + "serve_static_files": True, + "redirect_root_to_index": True, + **(options or {}), # type: ignore + } + + +def _setup_common_routes(blueprint: Blueprint, options: Options) -> None: + cors_options = options["cors"] + if cors_options: # pragma: no cover + cors_params = cors_options if isinstance(cors_options, dict) else {} CORS(blueprint, **cors_params) - if config["serve_static_files"]: + if options["serve_static_files"]: @blueprint.route("/client/") def send_client_dir(path: str) -> Any: @@ -154,7 +157,7 @@ def send_client_dir(path: str) -> Any: def send_modules_dir(path: str) -> Any: return send_from_directory(str(IDOM_WEB_MODULES_DIR.current), path) - if config["redirect_root_to_index"]: + if options["redirect_root_to_index"]: @blueprint.route("/") def redirect_to_index() -> Any: @@ -168,11 +171,11 @@ def redirect_to_index() -> Any: def _setup_single_view_dispatcher_route( - app: Flask, config: Config, constructor: ComponentConstructor + app: Flask, options: Options, constructor: ComponentConstructor ) -> None: sockets = Sockets(app) - @sockets.route(_join_url_paths(config["url_prefix"], "/stream")) # type: ignore + @sockets.route(_join_url_paths(options["url_prefix"], "/stream")) # type: ignore def model_stream(ws: WebSocket) -> None: def send(value: Any) -> None: ws.send(json.dumps(value)) diff --git a/src/idom/server/sanic.py b/src/idom/server/sanic.py index 4845f1f6a..f6d594776 100644 --- a/src/idom/server/sanic.py +++ b/src/idom/server/sanic.py @@ -3,10 +3,7 @@ import asyncio import json import logging -from asyncio import Future -from asyncio.events import AbstractEventLoop -from threading import Event -from typing import Any, Dict, Optional, Tuple, Union +from typing import Any, Dict, Tuple, Union from mypy_extensions import TypedDict from sanic import Blueprint, Sanic, request, response @@ -17,24 +14,50 @@ from idom.core.dispatcher import ( RecvCoroutine, SendCoroutine, - SharedViewDispatcher, VdomJsonPatch, dispatch_single_view, - ensure_shared_view_dispatcher_future, ) from idom.core.layout import Layout, LayoutEvent from idom.core.types import ComponentConstructor -from .utils import CLIENT_BUILD_DIR, threaded, wait_on_event +from .utils import CLIENT_BUILD_DIR logger = logging.getLogger(__name__) -_SERVER_COUNT = 0 +def configure( + app: Sanic, component: ComponentConstructor, options: Options | None = None +) -> None: + """Configure an application instance to display the given component""" + options = _setup_options(options) + blueprint = Blueprint( + f"idom_dispatcher_{id(app)}", url_prefix=options["url_prefix"] + ) + _setup_common_routes(blueprint, options) + _setup_single_view_dispatcher_route(blueprint, component) + app.blueprint(blueprint) + + +def create_development_app() -> Sanic: + """Return a :class:`Sanic` app instance in debug mode""" + return Sanic("idom_development_app") + + +async def serve_development_app( + app: Sanic, host: str, port: int, started: asyncio.Event +) -> None: + """Run a development server for :mod:`sanic`""" + + @app.listener("after_server_start") + async def after_started(): + started.set() + + await app.create_server(host, port, debug=True) -class Config(TypedDict, total=False): - """Config for :class:`SanicRenderServer`""" + +class Options(TypedDict, total=False): + """Options for :class:`SanicRenderServer`""" cors: Union[bool, Dict[str, Any]] """Enable or configure Cross Origin Resource Sharing (CORS) @@ -52,143 +75,27 @@ class Config(TypedDict, total=False): """The URL prefix where IDOM resources will be served from""" -def PerClientStateServer( - constructor: ComponentConstructor, - config: Optional[Config] = None, - app: Optional[Sanic] = None, -) -> SanicServer: - """Return a :class:`SanicServer` where each client has its own state. - - Implements the :class:`~idom.server.proto.ServerFactory` protocol - - Parameters: - constructor: A component constructor - config: Options for configuring server behavior - app: An application instance (otherwise a default instance is created) - """ - config, app = _setup_config_and_app(config, app) - blueprint = Blueprint(f"idom_dispatcher_{id(app)}", url_prefix=config["url_prefix"]) - _setup_common_routes(blueprint, config) - _setup_single_view_dispatcher_route(blueprint, constructor) - app.blueprint(blueprint) - return SanicServer(app) - - -def SharedClientStateServer( - constructor: ComponentConstructor, - config: Optional[Config] = None, - app: Optional[Sanic] = None, -) -> SanicServer: - """Return a :class:`SanicServer` where each client shares state. - - Implements the :class:`~idom.server.proto.ServerFactory` protocol +def _setup_options(options: Options | None) -> Options: + return { + "cors": False, + "url_prefix": "", + "serve_static_files": True, + "redirect_root_to_index": True, + **(options or {}), # type: ignore + } - Parameters: - constructor: A component constructor - config: Options for configuring server behavior - app: An application instance (otherwise a default instance is created) - """ - config, app = _setup_config_and_app(config, app) - blueprint = Blueprint(f"idom_dispatcher_{id(app)}", url_prefix=config["url_prefix"]) - _setup_common_routes(blueprint, config) - _setup_shared_view_dispatcher_route(app, blueprint, constructor) - app.blueprint(blueprint) - return SanicServer(app) - -class SanicServer: - """A thin wrapper for running a Sanic application - - See :class:`idom.server.proto.Server` for more info - """ - - _loop: AbstractEventLoop - - def __init__(self, app: Sanic) -> None: - self.app = app - self._did_start = Event() - self._did_stop = Event() - app.register_listener(self._server_did_start, "after_server_start") - app.register_listener(self._server_did_stop, "after_server_stop") - - def run(self, host: str, port: int, *args: Any, **kwargs: Any) -> None: - self.app.run(host, port, *args, **kwargs) # pragma: no cover - - @threaded - def run_in_thread(self, host: str, port: int, *args: Any, **kwargs: Any) -> None: - loop = asyncio.get_event_loop() - - # what follows was copied from: - # https://github.com/sanic-org/sanic/blob/7028eae083b0da72d09111b9892ddcc00bce7df4/examples/run_async_advanced.py - - serv_coro = self.app.create_server( - host, port, *args, **kwargs, return_asyncio_server=True - ) - serv_task = asyncio.ensure_future(serv_coro, loop=loop) - server = loop.run_until_complete(serv_task) - server.after_start() - try: - loop.run_forever() - except KeyboardInterrupt: # pragma: no cover - loop.stop() - finally: - server.before_stop() - - # Wait for server to close - close_task = server.close() - loop.run_until_complete(close_task) - - # Complete all tasks on the loop - for connection in server.connections: - connection.close_if_idle() - server.after_stop() - - def wait_until_started(self, timeout: Optional[float] = 3.0) -> None: - wait_on_event(f"start {self.app}", self._did_start, timeout) - - def stop(self, timeout: Optional[float] = 3.0) -> None: - self._loop.call_soon_threadsafe(self.app.stop) - wait_on_event(f"stop {self.app}", self._did_stop, timeout) - - async def _server_did_start(self, app: Sanic, loop: AbstractEventLoop) -> None: - self._loop = loop - self._did_start.set() - - async def _server_did_stop(self, app: Sanic, loop: AbstractEventLoop) -> None: - self._did_stop.set() - - -def _setup_config_and_app( - config: Optional[Config], - app: Optional[Sanic], -) -> Tuple[Config, Sanic]: - if app is None: - global _SERVER_COUNT - _SERVER_COUNT += 1 - app = Sanic(f"{__name__}[{_SERVER_COUNT}]") - return ( - { - "cors": False, - "url_prefix": "", - "serve_static_files": True, - "redirect_root_to_index": True, - **(config or {}), # type: ignore - }, - app, - ) - - -def _setup_common_routes(blueprint: Blueprint, config: Config) -> None: - cors_config = config["cors"] - if cors_config: # pragma: no cover - cors_params = cors_config if isinstance(cors_config, dict) else {} +def _setup_common_routes(blueprint: Blueprint, options: Options) -> None: + cors_options = options["cors"] + if cors_options: # pragma: no cover + cors_params = cors_options if isinstance(cors_options, dict) else {} CORS(blueprint, **cors_params) - if config["serve_static_files"]: + if options["serve_static_files"]: blueprint.static("/client", str(CLIENT_BUILD_DIR)) blueprint.static("/modules", str(IDOM_WEB_MODULES_DIR.current)) - if config["redirect_root_to_index"]: + if options["redirect_root_to_index"]: @blueprint.route("/") # type: ignore def redirect_to_index( @@ -211,39 +118,6 @@ async def model_stream( await dispatch_single_view(Layout(constructor(**component_params)), send, recv) -def _setup_shared_view_dispatcher_route( - app: Sanic, blueprint: Blueprint, constructor: ComponentConstructor -) -> None: - dispatcher_future: Future[None] - dispatch_coroutine: SharedViewDispatcher - - async def activate_dispatcher(app: Sanic, loop: AbstractEventLoop) -> None: - nonlocal dispatcher_future - nonlocal dispatch_coroutine - dispatcher_future, dispatch_coroutine = ensure_shared_view_dispatcher_future( - Layout(constructor()) - ) - - async def deactivate_dispatcher(app: Sanic, loop: AbstractEventLoop) -> None: - logger.debug("Stopping dispatcher - server is shutting down") - dispatcher_future.cancel() - await asyncio.wait([dispatcher_future]) - - app.register_listener(activate_dispatcher, "before_server_start") - app.register_listener(deactivate_dispatcher, "before_server_stop") - - @blueprint.websocket("/stream") # type: ignore - async def model_stream( - request: request.Request, socket: WebSocketCommonProtocol - ) -> None: - if request.args: - raise ValueError( - "SharedClientState server does not support per-client view parameters" - ) - send, recv = _make_send_recv_callbacks(socket) - await dispatch_coroutine(send, recv) - - def _make_send_recv_callbacks( socket: WebSocketCommonProtocol, ) -> Tuple[SendCoroutine, RecvCoroutine]: diff --git a/src/idom/server/starlette.py b/src/idom/server/starlette.py index fe6c27f87..a2cdf67b3 100644 --- a/src/idom/server/starlette.py +++ b/src/idom/server/starlette.py @@ -3,10 +3,8 @@ import asyncio import json import logging -import sys -from asyncio import Future -from threading import Event, Thread, current_thread -from typing import Any, Dict, Optional, Tuple, TypeVar, Union +from asyncio import FIRST_EXCEPTION, CancelledError +from typing import Any, Dict, Tuple, Union from mypy_extensions import TypedDict from starlette.applications import Starlette @@ -17,188 +15,116 @@ from starlette.websockets import WebSocket, WebSocketDisconnect from uvicorn.config import Config as UvicornConfig from uvicorn.server import Server as UvicornServer -from uvicorn.supervisors.multiprocess import Multiprocess -from uvicorn.supervisors.statreload import StatReload as ChangeReload from idom.config import IDOM_DEBUG_MODE, IDOM_WEB_MODULES_DIR from idom.core.dispatcher import ( RecvCoroutine, SendCoroutine, - SharedViewDispatcher, VdomJsonPatch, dispatch_single_view, - ensure_shared_view_dispatcher_future, ) from idom.core.layout import Layout, LayoutEvent from idom.core.types import ComponentConstructor -from .utils import CLIENT_BUILD_DIR, poll, threaded +from .utils import CLIENT_BUILD_DIR logger = logging.getLogger(__name__) -_StarletteType = TypeVar("_StarletteType", bound=Starlette) - -class Config(TypedDict, total=False): - """Config for :class:`StarletteRenderServer`""" - - cors: Union[bool, Dict[str, Any]] - """Enable or configure Cross Origin Resource Sharing (CORS) - - For more information see docs for ``starlette.middleware.cors.CORSMiddleware`` - """ - - redirect_root_to_index: bool - """Whether to redirect the root URL (with prefix) to ``index.html``""" - - serve_static_files: bool - """Whether or not to serve static files (i.e. web modules)""" - - url_prefix: str - """The URL prefix where IDOM resources will be served from""" - - -def PerClientStateServer( +def configure( + app: Starlette, constructor: ComponentConstructor, - config: Optional[Config] = None, - app: Optional[Starlette] = None, -) -> StarletteServer: + options: Options | None = None, +) -> None: """Return a :class:`StarletteServer` where each client has its own state. Implements the :class:`~idom.server.proto.ServerFactory` protocol Parameters: + app: An application instance constructor: A component constructor - config: Options for configuring server behavior - app: An application instance (otherwise a default instance is created) + options: Options for configuring server behavior """ - config, app = _setup_config_and_app(config, app, Starlette) - _setup_common_routes(config, app) - _setup_single_view_dispatcher_route(config["url_prefix"], app, constructor) - return StarletteServer(app) + options, app = _setup_options(options) + _setup_common_routes(options, app) + _setup_single_view_dispatcher_route(options["url_prefix"], app, constructor) -def SharedClientStateServer( - constructor: ComponentConstructor, - config: Optional[Config] = None, - app: Optional[Starlette] = None, -) -> StarletteServer: - """Return a :class:`StarletteServer` where each client shares state. +def create_development_app() -> Starlette: + """Return a :class:`Starlette` app instance in debug mode""" + return Starlette(debug=True) - Implements the :class:`~idom.server.proto.ServerFactory` protocol - Parameters: - constructor: A component constructor - config: Options for configuring server behavior - app: An application instance (otherwise a default instance is created) - """ - config, app = _setup_config_and_app(config, app, Starlette) - _setup_common_routes(config, app) - _setup_shared_view_dispatcher_route(config["url_prefix"], app, constructor) - return StarletteServer(app) +async def serve_development_app( + app: Starlette, + host: str, + port: int, + started: asyncio.Event, +) -> None: + """Run a development server for starlette""" + server = UvicornServer(UvicornConfig(app, host=host, port=port, loop="asyncio")) + async def check_if_started(): + while not server.started: + await asyncio.sleep(0.2) + started.set() -class StarletteServer: - """A thin wrapper for running a Starlette application + _, pending = await asyncio.wait( + [server.serve(), check_if_started()], + return_when=FIRST_EXCEPTION, + ) - See :class:`idom.server.proto.Server` for more info - """ + for task in pending: + task.cancel() - _server: UvicornServer - _current_thread: Thread + try: + await asyncio.gather(*list(pending)) + except CancelledError: + pass - def __init__(self, app: Starlette) -> None: - self.app = app - self._did_stop = Event() - app.on_event("shutdown")(self._server_did_stop) - def run(self, host: str, port: int, *args: Any, **kwargs: Any) -> None: - self._current_thread = current_thread() +class Options(TypedDict, total=False): + """Optionsuration options for :class:`StarletteRenderServer`""" - self._server = server = UvicornServer( - UvicornConfig( - self.app, host=host, port=port, loop="asyncio", *args, **kwargs - ) - ) + cors: Union[bool, Dict[str, Any]] + """Enable or configure Cross Origin Resource Sharing (CORS) - # The following was copied from the uvicorn source with minimal modification. We - # shouldn't need to do this, but unfortunately there's no easy way to gain access to - # the server instance so you can stop it. - # BUG: https://github.com/encode/uvicorn/issues/742 - config = server.config - - if (config.reload or config.workers > 1) and not isinstance( - server.config.app, str - ): # pragma: no cover - logger = logging.getLogger("uvicorn.error") - logger.warning( - "You must pass the application as an import string to enable 'reload' or " - "'workers'." - ) - sys.exit(1) - - if config.should_reload: # pragma: no cover - sock = config.bind_socket() - supervisor = ChangeReload(config, target=server.run, sockets=[sock]) - supervisor.run() - elif config.workers > 1: # pragma: no cover - sock = config.bind_socket() - supervisor = Multiprocess(config, target=server.run, sockets=[sock]) - supervisor.run() - else: - import asyncio - - asyncio.set_event_loop(asyncio.new_event_loop()) - server.run() - - run_in_thread = threaded(run) - - def wait_until_started(self, timeout: Optional[float] = 3.0) -> None: - poll( - f"start {self.app}", - 0.01, - timeout, - lambda: hasattr(self, "_server") and self._server.started, - ) + For more information see docs for ``starlette.middleware.cors.CORSMiddleware`` + """ - def stop(self, timeout: Optional[float] = 3.0) -> None: - self._server.should_exit = True - self._did_stop.wait(timeout) - - async def _server_did_stop(self) -> None: - self._did_stop.set() - - -def _setup_config_and_app( - config: Optional[Config], - app: Optional[_StarletteType], - app_type: type[_StarletteType], -) -> Tuple[Config, _StarletteType]: - return ( - { - "cors": False, - "url_prefix": "", - "serve_static_files": True, - "redirect_root_to_index": True, - **(config or {}), # type: ignore - }, - app or app_type(debug=IDOM_DEBUG_MODE.current), - ) + redirect_root_to_index: bool + """Whether to redirect the root URL (with prefix) to ``index.html``""" + + serve_static_files: bool + """Whether or not to serve static files (i.e. web modules)""" + + url_prefix: str + """The URL prefix where IDOM resources will be served from""" -def _setup_common_routes(config: Config, app: Starlette) -> None: - cors_config = config["cors"] - if cors_config: # pragma: no cover +def _setup_options(options: Options | None) -> Options: + return { + "cors": False, + "url_prefix": "", + "serve_static_files": True, + "redirect_root_to_index": True, + **(options or {}), # type: ignore + } + + +def _setup_common_routes(options: Options, app: Starlette) -> None: + cors_options = options["cors"] + if cors_options: # pragma: no cover cors_params = ( - cors_config if isinstance(cors_config, dict) else {"allow_origins": ["*"]} + cors_options if isinstance(cors_options, dict) else {"allow_origins": ["*"]} ) app.add_middleware(CORSMiddleware, **cors_params) # This really should be added to the APIRouter, but there's a bug in Starlette # BUG: https://github.com/tiangolo/fastapi/issues/1469 - url_prefix = config["url_prefix"] - if config["serve_static_files"]: + url_prefix = options["url_prefix"] + if options["serve_static_files"]: app.mount( f"{url_prefix}/client", StaticFiles( @@ -218,7 +144,7 @@ def _setup_common_routes(config: Config, app: Starlette) -> None: name="idom_web_module_files", ) - if config["redirect_root_to_index"]: + if options["redirect_root_to_index"]: @app.route(f"{url_prefix}/") def redirect_to_index(request: Request) -> RedirectResponse: @@ -242,43 +168,6 @@ async def model_stream(socket: WebSocket) -> None: logger.info(f"WebSocket disconnect: {error.code}") -def _setup_shared_view_dispatcher_route( - url_prefix: str, app: Starlette, constructor: ComponentConstructor -) -> None: - dispatcher_future: Future[None] - dispatch_coroutine: SharedViewDispatcher - - @app.on_event("startup") - async def activate_dispatcher() -> None: - nonlocal dispatcher_future - nonlocal dispatch_coroutine - dispatcher_future, dispatch_coroutine = ensure_shared_view_dispatcher_future( - Layout(constructor()) - ) - - @app.on_event("shutdown") - async def deactivate_dispatcher() -> None: - logger.debug("Stopping dispatcher - server is shutting down") - dispatcher_future.cancel() - await asyncio.wait([dispatcher_future]) - - @app.websocket_route(f"{url_prefix}/stream") - async def model_stream(socket: WebSocket) -> None: - await socket.accept() - - if socket.query_params: - raise ValueError( - "SharedClientState server does not support per-client view parameters" - ) - - send, recv = _make_send_recv_callbacks(socket) - - try: - await dispatch_coroutine(send, recv) - except WebSocketDisconnect as error: - logger.info(f"WebSocket disconnect: {error.code}") - - def _make_send_recv_callbacks( socket: WebSocket, ) -> Tuple[SendCoroutine, RecvCoroutine]: From b1b8081f83a6b82abb1de8abc521d1bf8919a26b Mon Sep 17 00:00:00 2001 From: rmorshea Date: Thu, 17 Mar 2022 11:30:42 -0700 Subject: [PATCH 03/58] finish up flask conversion --- src/idom/server/any.py | 62 ++++++++++------------- src/idom/server/flask.py | 104 +++++++++++++-------------------------- src/idom/server/types.py | 25 ++++++++++ src/idom/server/utils.py | 47 ------------------ src/idom/types.py | 5 +- 5 files changed, 85 insertions(+), 158 deletions(-) create mode 100644 src/idom/server/types.py diff --git a/src/idom/server/any.py b/src/idom/server/any.py index fddc57770..bacc4c018 100644 --- a/src/idom/server/any.py +++ b/src/idom/server/any.py @@ -4,12 +4,11 @@ import warnings import webbrowser from importlib import import_module -from typing import Any, Awaitable, Iterator, TypeVar, runtime_checkable - -from typing_extensions import Protocol +from typing import Any, Awaitable, Iterator from idom.types import ComponentConstructor +from .types import ServerImplementation from .utils import find_available_port @@ -36,15 +35,8 @@ def run( stacklevel=2, ) - try: - implementation = next(all_implementations()) - except StopIteration: - raise RuntimeError( # pragma: no cover - f"Found no built-in server implementation installed {SUPPORTED_PACKAGES}" - ) - - app = implementation.create_development_app() - implementation.configure(app, component) + app = create_development_app() + configure(app, component) coros: list[Awaitable] = [] @@ -52,7 +44,7 @@ def run( port = port or find_available_port(host) started = asyncio.Event() - coros.append(implementation.serve_development_app(app, host, port, started)) + coros.append(serve_development_app(app, host, port, started)) if open_browser: @@ -66,28 +58,41 @@ async def _open_browser_after_server() -> None: def configure(app: Any, component: ComponentConstructor) -> None: + """Configure the given app instance to display the given component""" return get_implementation().configure(app, component) def create_development_app() -> Any: + """Create an application instance for development purposes""" return get_implementation().create_development_app() async def serve_development_app( app: Any, host: str, port: int, started: asyncio.Event ) -> None: + """Run an application using a development server""" return await get_implementation().serve_development_app(app, host, port, started) -def get_implementation() -> Implementation: +def get_implementation() -> ServerImplementation: """Get the first available server implementation""" + if _DEFAULT_IMPLEMENTATION is not None: + return _DEFAULT_IMPLEMENTATION + try: - return next(all_implementations()) + implementation = next(all_implementations()) except StopIteration: raise RuntimeError("No built-in server implementation installed.") + else: + global _DEFAULT_IMPLEMENTATION + _DEFAULT_IMPLEMENTATION = implementation + return implementation + +_DEFAULT_IMPLEMENTATION: ServerImplementation | None = None -def all_implementations() -> Iterator[Implementation]: + +def all_implementations() -> Iterator[ServerImplementation]: """Yield all available server implementations""" for name in SUPPORTED_PACKAGES: try: @@ -95,26 +100,9 @@ def all_implementations() -> Iterator[Implementation]: except ImportError: # pragma: no cover continue - if not isinstance(module, Implementation): - raise TypeError(f"{module.__name__!r} is an invalid implementation") + if not isinstance(module, ServerImplementation): + raise TypeError( # pragma: no cover + f"{module.__name__!r} is an invalid implementation" + ) yield module - - -_App = TypeVar("_App") - - -@runtime_checkable -class Implementation(Protocol): - """Common interface for IDOM's builti-in server implementations""" - - def configure(self, app: _App, component: ComponentConstructor) -> 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: - """Run an application using a development server""" diff --git a/src/idom/server/flask.py b/src/idom/server/flask.py index ea99b2ae8..950bdc89d 100644 --- a/src/idom/server/flask.py +++ b/src/idom/server/flask.py @@ -13,10 +13,9 @@ from flask import Blueprint, Flask, redirect, request, send_from_directory, url_for from flask_cors import CORS from flask_sockets import Sockets -from gevent import pywsgi -from geventwebsocket.handler import WebSocketHandler from geventwebsocket.websocket import WebSocket from typing_extensions import TypedDict +from werkzeug.serving import ThreadedWSGIServer import idom from idom.config import IDOM_DEBUG_MODE, IDOM_WEB_MODULES_DIR @@ -24,7 +23,7 @@ from idom.core.layout import LayoutEvent, LayoutUpdate from idom.core.types import ComponentConstructor, ComponentType -from .utils import CLIENT_BUILD_DIR, threaded, wait_on_event +from .utils import CLIENT_BUILD_DIR logger = logging.getLogger(__name__) @@ -32,7 +31,7 @@ def configure( app: Flask, component: ComponentConstructor, options: Options | None = None -) -> FlaskServer: +) -> None: """Return a :class:`FlaskServer` where each client has its own state. Implements the :class:`~idom.server.proto.ServerFactory` protocol @@ -47,17 +46,45 @@ def configure( _setup_common_routes(blueprint, options) _setup_single_view_dispatcher_route(app, options, component) app.register_blueprint(blueprint) - return FlaskServer(app) def create_development_app() -> Flask: + """Create an application instance for development purposes""" return Flask(__name__) async def serve_development_app( app: Flask, host: str, port: int, started: asyncio.Event ) -> None: - ... + """Run an application using a development server""" + loop = asyncio.get_event_loop() + + @app.before_first_request + def set_started(): + loop.call_soon_threadsafe(started.set) + + server = ThreadedWSGIServer(host, port, app) + + stopped = asyncio.Event() + + def run_server(): + try: + server.serve_forever() + finally: + loop.call_soon_threadsafe(stopped.set) + + thread = Thread(target=run_server, daemon=True) + + try: + await stopped.wait() + finally: + # we may have exitted because this task was cancelled + server.shutdown() + # the thread should eventually join + thread.join(timeout=3) + # just double check it happened + if thread.is_alive(): + raise RuntimeError("Failed to shutdown server.") class Options(TypedDict, total=False): @@ -85,52 +112,6 @@ class Options(TypedDict, total=False): """The URL prefix where IDOM resources will be served from""" -class FlaskServer: - """A thin wrapper for running a Flask application - - See :class:`idom.server.proto.Server` for more info - """ - - _wsgi_server: pywsgi.WSGIServer - - def __init__(self, app: Flask) -> None: - self.app = app - self._did_start = ThreadEvent() - - @app.before_first_request - def server_did_start() -> None: - self._did_start.set() - - def run(self, host: str, port: int, *args: Any, **kwargs: Any) -> None: - if IDOM_DEBUG_MODE.current: - logging.basicOptions(level=logging.DEBUG) # pragma: no cover - logger.info(f"Running at http://{host}:{port}") - self._wsgi_server = _StartCallbackWSGIServer( - self._did_start.set, - (host, port), - self.app, - *args, - handler_class=WebSocketHandler, - **kwargs, - ) - self._wsgi_server.serve_forever() - - run_in_thread = threaded(run) - - def wait_until_started(self, timeout: Optional[float] = 3.0) -> None: - wait_on_event(f"start {self.app}", self._did_start, timeout) - - def stop(self, timeout: Optional[float] = 3.0) -> None: - try: - server = self._wsgi_server - except AttributeError: # pragma: no cover - raise RuntimeError( - f"Application is not running or was not started by {self}" - ) - else: - server.stop(timeout) - - def _setup_options(options: Options | None) -> Options: return { "url_prefix": "", @@ -273,25 +254,6 @@ class _DispatcherThreadInfo(NamedTuple): async_recv_queue: "AsyncQueue[LayoutEvent]" -class _StartCallbackWSGIServer(pywsgi.WSGIServer): # type: ignore - def __init__( - self, before_first_request: Callable[[], None], *args: Any, **kwargs: Any - ) -> None: - self._before_first_request_callback = before_first_request - super().__init__(*args, **kwargs) - - def update_environ(self) -> None: - """ - Called before the first request is handled to fill in WSGI environment values. - - This includes getting the correct server name and port. - """ - super().update_environ() - # BUG: https://github.com/nedbat/coveragepy/issues/1012 - # Coverage isn't able to support concurrency coverage for both threading and gevent - self._before_first_request_callback() # pragma: no cover - - def _join_url_paths(*args: str) -> str: # urllib.parse.urljoin performs more logic than is needed. Thus we need a util func # to join paths as if they were POSIX paths. diff --git a/src/idom/server/types.py b/src/idom/server/types.py new file mode 100644 index 000000000..5463cc9d2 --- /dev/null +++ b/src/idom/server/types.py @@ -0,0 +1,25 @@ +import asyncio +from typing import Callable, TypeVar + +from typing_extensions import Protocol, runtime_checkable + +from idom.core.types import ComponentType + + +_App = TypeVar("_App") + + +@runtime_checkable +class ServerImplementation(Protocol): + """Common interface for IDOM's builti-in server implementations""" + + def configure(self, app: _App, component: Callable[[], ComponentType]) -> 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: + """Run an application using a development server""" diff --git a/src/idom/server/utils.py b/src/idom/server/utils.py index b17484bc1..3168cfeb5 100644 --- a/src/idom/server/utils.py +++ b/src/idom/server/utils.py @@ -1,13 +1,6 @@ -import asyncio import socket -import time from contextlib import closing -from functools import wraps from pathlib import Path -from threading import Event, Thread -from typing import Any, Callable, Optional - -from typing_extensions import ParamSpec import idom @@ -15,46 +8,6 @@ CLIENT_BUILD_DIR = Path(idom.__file__).parent / "client" -_FuncParams = ParamSpec("_FuncParams") - - -def threaded(function: Callable[_FuncParams, None]) -> Callable[_FuncParams, Thread]: - @wraps(function) - def wrapper(*args: Any, **kwargs: Any) -> Thread: - def target() -> None: - asyncio.set_event_loop(asyncio.new_event_loop()) - function(*args, **kwargs) - - thread = Thread(target=target, daemon=True) - thread.start() - - return thread - - return wrapper - - -def wait_on_event(description: str, event: Event, timeout: Optional[float]) -> None: - if not event.wait(timeout): - raise TimeoutError(f"Did not {description} within {timeout} seconds") - - -def poll( - description: str, - frequency: float, - timeout: Optional[float], - function: Callable[[], bool], -) -> None: - if timeout is not None: - expiry = time.time() + timeout - while not function(): - if time.time() > expiry: - raise TimeoutError(f"Did not {description} within {timeout} seconds") - time.sleep(frequency) - else: - while not function(): - time.sleep(frequency) - - def find_available_port( host: str, port_min: int = 8000, diff --git a/src/idom/types.py b/src/idom/types.py index e979ec8a0..ca4961732 100644 --- a/src/idom/types.py +++ b/src/idom/types.py @@ -21,7 +21,7 @@ VdomDict, VdomJson, ) -from .server.types import ServerFactory, ServerType +from .server.types import ServerImplementation __all__ = [ @@ -40,6 +40,5 @@ "VdomChildren", "VdomDict", "VdomJson", - "ServerFactory", - "ServerType", + "ServerImplementation", ] From c9fc66e1640f9986dcd846e8613e0ffbdacd2642 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Thu, 17 Mar 2022 12:28:13 -0700 Subject: [PATCH 04/58] convert tornado + begin reworking test utils --- src/idom/core/types.py | 3 ++ src/idom/server/any.py | 27 +++++----- src/idom/server/fastapi.py | 4 +- src/idom/server/flask.py | 6 +-- src/idom/server/sanic.py | 6 +-- src/idom/server/starlette.py | 8 +-- src/idom/server/tornado.py | 96 ++++++++++++++++++++---------------- src/idom/server/types.py | 4 +- src/idom/testing.py | 63 +++++++++++------------ src/idom/types.py | 2 + tests/conftest.py | 4 +- tests/test_client.py | 4 +- 12 files changed, 122 insertions(+), 105 deletions(-) diff --git a/src/idom/core/types.py b/src/idom/core/types.py index ffa9ba99a..3bc498181 100644 --- a/src/idom/core/types.py +++ b/src/idom/core/types.py @@ -21,6 +21,9 @@ ComponentConstructor = Callable[..., "ComponentType"] """Simple function returning a new component""" +RootComponentConstructor = Callable[[], "ComponentType"] +"""The root component should be constructed by a function accepting no arguments.""" + Key = Union[str, int] diff --git a/src/idom/server/any.py b/src/idom/server/any.py index bacc4c018..d7092bb71 100644 --- a/src/idom/server/any.py +++ b/src/idom/server/any.py @@ -1,12 +1,13 @@ from __future__ import annotations import asyncio +import sys import warnings import webbrowser from importlib import import_module from typing import Any, Awaitable, Iterator -from idom.types import ComponentConstructor +from idom.types import RootComponentConstructor from .types import ServerImplementation from .utils import find_available_port @@ -22,10 +23,11 @@ def run( - component: ComponentConstructor, + component: RootComponentConstructor, host: str = "127.0.0.1", port: int | None = None, open_browser: bool = True, + implementation: ServerImplementation = sys.modules[__name__], ) -> None: """Run a component with a development server""" @@ -35,8 +37,8 @@ def run( stacklevel=2, ) - app = create_development_app() - configure(app, component) + app = implementation.create_development_app() + implementation.configure(app, component) coros: list[Awaitable] = [] @@ -44,7 +46,7 @@ def run( port = port or find_available_port(host) started = asyncio.Event() - coros.append(serve_development_app(app, host, port, started)) + coros.append(implementation.serve_development_app(app, host, port, started)) if open_browser: @@ -57,25 +59,29 @@ async def _open_browser_after_server() -> None: asyncio.get_event_loop().run_forever(asyncio.gather(*coros)) -def configure(app: Any, component: ComponentConstructor) -> None: +def configure(app: Any, component: RootComponentConstructor) -> None: """Configure the given app instance to display the given component""" - return get_implementation().configure(app, component) + return _get_any_implementation().configure(app, component) def create_development_app() -> Any: """Create an application instance for development purposes""" - return get_implementation().create_development_app() + return _get_any_implementation().create_development_app() async def serve_development_app( app: Any, host: str, port: int, started: asyncio.Event ) -> None: """Run an application using a development server""" - return await get_implementation().serve_development_app(app, host, port, started) + return await _get_any_implementation().serve_development_app( + app, host, port, started + ) -def get_implementation() -> ServerImplementation: +def _get_any_implementation() -> ServerImplementation: """Get the first available server implementation""" + global _DEFAULT_IMPLEMENTATION + if _DEFAULT_IMPLEMENTATION is not None: return _DEFAULT_IMPLEMENTATION @@ -84,7 +90,6 @@ def get_implementation() -> ServerImplementation: except StopIteration: raise RuntimeError("No built-in server implementation installed.") else: - global _DEFAULT_IMPLEMENTATION _DEFAULT_IMPLEMENTATION = implementation return implementation diff --git a/src/idom/server/fastapi.py b/src/idom/server/fastapi.py index b6722bf88..5c9a3d80f 100644 --- a/src/idom/server/fastapi.py +++ b/src/idom/server/fastapi.py @@ -3,7 +3,7 @@ from fastapi import FastAPI from idom.config import IDOM_DEBUG_MODE -from idom.core.types import ComponentConstructor +from idom.core.types import RootComponentConstructor from .starlette import ( Options, @@ -19,7 +19,7 @@ def configure( app: FastAPI, - constructor: ComponentConstructor, + constructor: RootComponentConstructor, options: Options | None = None, ) -> None: """Prepare a :class:`FastAPI` server to serve the given component diff --git a/src/idom/server/flask.py b/src/idom/server/flask.py index 950bdc89d..69b8d6903 100644 --- a/src/idom/server/flask.py +++ b/src/idom/server/flask.py @@ -21,7 +21,7 @@ from idom.config import IDOM_DEBUG_MODE, IDOM_WEB_MODULES_DIR from idom.core.dispatcher import dispatch_single_view from idom.core.layout import LayoutEvent, LayoutUpdate -from idom.core.types import ComponentConstructor, ComponentType +from idom.core.types import ComponentType, RootComponentConstructor from .utils import CLIENT_BUILD_DIR @@ -30,7 +30,7 @@ def configure( - app: Flask, component: ComponentConstructor, options: Options | None = None + app: Flask, component: RootComponentConstructor, options: Options | None = None ) -> None: """Return a :class:`FlaskServer` where each client has its own state. @@ -152,7 +152,7 @@ def redirect_to_index() -> Any: def _setup_single_view_dispatcher_route( - app: Flask, options: Options, constructor: ComponentConstructor + app: Flask, options: Options, constructor: RootComponentConstructor ) -> None: sockets = Sockets(app) diff --git a/src/idom/server/sanic.py b/src/idom/server/sanic.py index f6d594776..90aa1186b 100644 --- a/src/idom/server/sanic.py +++ b/src/idom/server/sanic.py @@ -18,7 +18,7 @@ dispatch_single_view, ) from idom.core.layout import Layout, LayoutEvent -from idom.core.types import ComponentConstructor +from idom.core.types import RootComponentConstructor from .utils import CLIENT_BUILD_DIR @@ -27,7 +27,7 @@ def configure( - app: Sanic, component: ComponentConstructor, options: Options | None = None + app: Sanic, component: RootComponentConstructor, options: Options | None = None ) -> None: """Configure an application instance to display the given component""" options = _setup_options(options) @@ -107,7 +107,7 @@ def redirect_to_index( def _setup_single_view_dispatcher_route( - blueprint: Blueprint, constructor: ComponentConstructor + blueprint: Blueprint, constructor: RootComponentConstructor ) -> None: @blueprint.websocket("/stream") # type: ignore async def model_stream( diff --git a/src/idom/server/starlette.py b/src/idom/server/starlette.py index a2cdf67b3..27006dffb 100644 --- a/src/idom/server/starlette.py +++ b/src/idom/server/starlette.py @@ -24,7 +24,7 @@ dispatch_single_view, ) from idom.core.layout import Layout, LayoutEvent -from idom.core.types import ComponentConstructor +from idom.core.types import RootComponentConstructor from .utils import CLIENT_BUILD_DIR @@ -34,7 +34,7 @@ def configure( app: Starlette, - constructor: ComponentConstructor, + constructor: RootComponentConstructor, options: Options | None = None, ) -> None: """Return a :class:`StarletteServer` where each client has its own state. @@ -46,7 +46,7 @@ def configure( constructor: A component constructor options: Options for configuring server behavior """ - options, app = _setup_options(options) + options = _setup_options(options) _setup_common_routes(options, app) _setup_single_view_dispatcher_route(options["url_prefix"], app, constructor) @@ -154,7 +154,7 @@ def redirect_to_index(request: Request) -> RedirectResponse: def _setup_single_view_dispatcher_route( - url_prefix: str, app: Starlette, constructor: ComponentConstructor + url_prefix: str, app: Starlette, constructor: RootComponentConstructor ) -> None: @app.websocket_route(f"{url_prefix}/stream") async def model_stream(socket: WebSocket) -> None: diff --git a/src/idom/server/tornado.py b/src/idom/server/tornado.py index c1f8ae569..ea104f820 100644 --- a/src/idom/server/tornado.py +++ b/src/idom/server/tornado.py @@ -4,6 +4,7 @@ import json from asyncio import Queue as AsyncQueue from asyncio.futures import Future +from concurrent.futures import ThreadPoolExecutor from threading import Event as ThreadEvent from typing import Any, List, Optional, Tuple, Type, Union from urllib.parse import urljoin @@ -21,11 +22,45 @@ from .utils import CLIENT_BUILD_DIR, threaded, wait_on_event -_RouteHandlerSpecs = List[Tuple[str, Type[RequestHandler], Any]] +def configure( + app: Application, + component: ComponentConstructor, + options: Options | None = None, +) -> TornadoServer: + """Return a :class:`TornadoServer` where each client has its own state. + + Implements the :class:`~idom.server.proto.ServerFactory` protocol + + Parameters: + app: A tornado ``Application`` instance. + component: A root component constructor + options: Options for configuring how the component is mounted to the server. + """ + options = _setup_options(options) + _add_handler( + app, + options, + _setup_common_routes(options) + _setup_single_view_dispatcher_route(component), + ) + return TornadoServer(app) -class Config(TypedDict, total=False): - """Render server config for :class:`TornadoRenderServer` subclasses""" +def create_development_app() -> Application: + return Application(debug=True) + + +async def serve_development_app( + app: Application, host: str, port: int, started: asyncio.Event +) -> None: + loop = AsyncIOMainLoop() + loop.install() + app.listen(port, host) + loop.add_callback(lambda: loop.asyncio_loop.call_soon_threadsafe(started.set)) + await loop.run_in_executor(ThreadPoolExecutor()) + + +class Options(TypedDict, total=False): + """Render server options for :class:`TornadoRenderServer` subclasses""" redirect_root_to_index: bool """Whether to redirect the root URL (with prefix) to ``index.html``""" @@ -37,27 +72,7 @@ class Config(TypedDict, total=False): """The URL prefix where IDOM resources will be served from""" -def PerClientStateServer( - constructor: ComponentConstructor, - config: Optional[Config] = None, - app: Optional[Application] = None, -) -> TornadoServer: - """Return a :class:`TornadoServer` where each client has its own state. - - Implements the :class:`~idom.server.proto.ServerFactory` protocol - - Parameters: - constructor: A component constructor - config: Options for configuring server behavior - app: An application instance (otherwise a default instance is created) - """ - config, app = _setup_config_and_app(config, app) - _add_handler( - app, - config, - _setup_common_routes(config) + _setup_single_view_dispatcher_route(constructor), - ) - return TornadoServer(app) +_RouteHandlerSpecs = List[Tuple[str, Type[RequestHandler], Any]] class TornadoServer: @@ -105,23 +120,18 @@ def stop() -> None: wait_on_event(f"stop {self.app}", did_stop, timeout) -def _setup_config_and_app( - config: Optional[Config], app: Optional[Application] -) -> Tuple[Config, Application]: - return ( - { - "url_prefix": "", - "serve_static_files": True, - "redirect_root_to_index": True, - **(config or {}), # type: ignore - }, - app or Application(), - ) +def _setup_options(options: Options | None) -> Options: + return { + "url_prefix": "", + "serve_static_files": True, + "redirect_root_to_index": True, + **(options or {}), # type: ignore + } -def _setup_common_routes(config: Config) -> _RouteHandlerSpecs: +def _setup_common_routes(options: Options) -> _RouteHandlerSpecs: handlers: _RouteHandlerSpecs = [] - if config["serve_static_files"]: + if options["serve_static_files"]: handlers.append( ( r"/client/(.*)", @@ -136,16 +146,16 @@ def _setup_common_routes(config: Config) -> _RouteHandlerSpecs: {"path": str(IDOM_WEB_MODULES_DIR.current)}, ) ) - if config["redirect_root_to_index"]: + if options["redirect_root_to_index"]: handlers.append(("/", RedirectHandler, {"url": "./client/index.html"})) return handlers def _add_handler( - app: Application, config: Config, handlers: _RouteHandlerSpecs + app: Application, options: Options, handlers: _RouteHandlerSpecs ) -> None: prefixed_handlers: List[Any] = [ - (urljoin(config["url_prefix"], route_pattern),) + tuple(handler_info) + (urljoin(options["url_prefix"], route_pattern),) + tuple(handler_info) for route_pattern, *handler_info in handlers ] app.add_handlers(r".*", prefixed_handlers) @@ -157,13 +167,13 @@ def _setup_single_view_dispatcher_route( return [ ( "/stream", - PerClientStateModelStreamHandler, + ModelStreamHandler, {"component_constructor": constructor}, ) ] -class PerClientStateModelStreamHandler(WebSocketHandler): +class ModelStreamHandler(WebSocketHandler): """A web-socket handler that serves up a new model stream to each new client""" _dispatch_future: Future[None] diff --git a/src/idom/server/types.py b/src/idom/server/types.py index 5463cc9d2..11f34fe71 100644 --- a/src/idom/server/types.py +++ b/src/idom/server/types.py @@ -3,7 +3,7 @@ from typing_extensions import Protocol, runtime_checkable -from idom.core.types import ComponentType +from idom.core.types import RootComponentConstructor _App = TypeVar("_App") @@ -13,7 +13,7 @@ class ServerImplementation(Protocol): """Common interface for IDOM's builti-in server implementations""" - def configure(self, app: _App, component: Callable[[], ComponentType]) -> None: + def configure(self, app: _App, component: RootComponentConstructor) -> None: """Configure the given app instance to display the given component""" def create_development_app(self) -> _App: diff --git a/src/idom/testing.py b/src/idom/testing.py index e32086af1..c2ab4ae74 100644 --- a/src/idom/testing.py +++ b/src/idom/testing.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import logging import re import shutil @@ -33,9 +34,10 @@ from idom.config import IDOM_WEB_MODULES_DIR from idom.core.events import EventHandler, to_event_handler_function from idom.core.hooks import LifeCycleHook, current_hook -from idom.server.prefab import hotswap_server -from idom.server.types import ServerFactory, ServerType +from idom.server import any as any_server +from idom.server.types import ServerImplementation from idom.server.utils import find_available_port +from idom.widgets import MountFunc, hotswap from .log import ROOT_LOGGER @@ -63,45 +65,26 @@ def create_simple_selenium_web_driver( return driver -_Self = TypeVar("_Self", bound="ServerMountPoint[Any, Any]") -_Mount = TypeVar("_Mount") -_Server = TypeVar("_Server", bound=ServerType[Any]) -_App = TypeVar("_App") -_Config = TypeVar("_Config") +_Self = TypeVar("_Self", bound="ServerMountPoint") -class ServerMountPoint(Generic[_Mount, _Server]): +class ServerMountPoint: """A context manager for imperatively mounting views to a render server when testing""" - mount: _Mount - server: _Server - _log_handler: "_LogRecordCaptor" + _server_future: asyncio.Task def __init__( self, - server_type: Optional[ServerFactory[_App, _Config]] = None, + server_implementation: ServerImplementation = any_server, host: str = "127.0.0.1", port: Optional[int] = None, - server_config: Optional[_Config] = None, - run_kwargs: Optional[Dict[str, Any]] = None, - mount_and_server_constructor: "Callable[..., Tuple[_Mount, _Server]]" = hotswap_server, # type: ignore - app: Optional[_App] = None, - **other_options: Any, + update_on_mount: bool = False, ) -> None: + self.server_implementation = server_implementation self.host = host self.port = port or find_available_port(host, allow_reuse_waiting_ports=False) - self._mount_and_server_constructor: "Callable[[], Tuple[_Mount, _Server]]" = ( - lambda: mount_and_server_constructor( - server_type, - self.host, - self.port, - server_config, - run_kwargs, - app, - **other_options, - ) - ) + self._update_on_mount = update_on_mount @property def log_records(self) -> List[logging.LogRecord]: @@ -155,24 +138,38 @@ def list_logged_exceptions( found.append(error) return found - def __enter__(self: _Self) -> _Self: + async def __aenter__(self: _Self) -> _Self: + self.mount, root_component = hotswap(self._update_on_mount) + self._log_handler = _LogRecordCaptor() logging.getLogger().addHandler(self._log_handler) - self.mount, self.server = self._mount_and_server_constructor() + app = self.server_implementation.create_development_app() + self.server_implementation.configure(app, root_component) + + started = asyncio.Event() + self._server_future = asyncio.ensure_future( + self.server_implementation.serve_development_app( + app, self.host, self.port, started + ) + ) + + await asyncio.wait_for(started.wait(), timeout=3) + return self - def __exit__( + async def __aexit__( self, exc_type: Optional[Type[BaseException]], exc_value: Optional[BaseException], traceback: Optional[TracebackType], ) -> None: - self.server.stop() + self._server_future.cancel() + logging.getLogger().removeHandler(self._log_handler) - del self.mount, self.server logged_errors = self.list_logged_exceptions(del_log_records=False) if logged_errors: # pragma: no cover raise logged_errors[0] + return None diff --git a/src/idom/types.py b/src/idom/types.py index ca4961732..562d8535a 100644 --- a/src/idom/types.py +++ b/src/idom/types.py @@ -14,6 +14,7 @@ ImportSourceDict, Key, LayoutType, + RootComponentConstructor, VdomAttributes, VdomAttributesAndChildren, VdomChild, @@ -34,6 +35,7 @@ "ImportSourceDict", "Key", "LayoutType", + "RootComponentConstructor", "VdomAttributes", "VdomAttributesAndChildren", "VdomChild", diff --git a/tests/conftest.py b/tests/conftest.py index f8281182a..8913131b7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -63,12 +63,12 @@ def driver_get(driver, server_mount_point): @pytest.fixture -def server_mount_point(): +async def server_mount_point(): """An IDOM layout mount function and server as a tuple The ``mount`` and ``server`` fixtures use this. """ - with ServerMountPoint() as mount_point: + async with ServerMountPoint() as mount_point: yield mount_point diff --git a/tests/test_client.py b/tests/test_client.py index ab0cd7413..21d07456d 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -10,7 +10,7 @@ JS_DIR = Path(__file__).parent / "js" -def test_automatic_reconnect(create_driver): +async def test_automatic_reconnect(create_driver): # we need to wait longer here because the automatic reconnect is not instance driver = create_driver(implicit_wait_timeout=10, page_load_timeout=10) @@ -20,7 +20,7 @@ def OldComponent(): mount_point = ServerMountPoint() - with mount_point: + async with mount_point: mount_point.mount(OldComponent) driver.get(mount_point.url()) # ensure the element is displayed before stopping the server From 2c5ab44a5b3566f56ed53fff1f2a1580fad05cf1 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Thu, 17 Mar 2022 13:34:42 -0700 Subject: [PATCH 05/58] remove shared dispatchers and rename to serve module --- src/idom/__init__.py | 2 +- src/idom/core/dispatcher.py | 235 ------------------ src/idom/core/serve.py | 108 ++++++++ src/idom/server/__init__.py | 1 + src/idom/server/flask.py | 8 +- src/idom/server/sanic.py | 8 +- src/idom/server/starlette.py | 10 +- src/idom/server/tornado.py | 4 +- src/idom/testing.py | 9 +- src/idom/widgets.py | 6 +- tests/test_core/test_dispatcher.py | 12 +- tests/test_core/test_hooks.py | 2 +- tests/test_core/test_layout.py | 2 +- .../test_server/test_common/test_multiview.py | 20 +- 14 files changed, 144 insertions(+), 283 deletions(-) delete mode 100644 src/idom/core/dispatcher.py create mode 100644 src/idom/core/serve.py diff --git a/src/idom/__init__.py b/src/idom/__init__.py index 582c295d4..60d6170b2 100644 --- a/src/idom/__init__.py +++ b/src/idom/__init__.py @@ -1,7 +1,6 @@ from . import config, html, log, types, web from .core import hooks from .core.component import component -from .core.dispatcher import Stop from .core.events import event from .core.hooks import ( create_context, @@ -14,6 +13,7 @@ use_state, ) from .core.layout import Layout +from .core.serve import Stop from .core.vdom import vdom from .sample import run_sample_app from .server.any import run diff --git a/src/idom/core/dispatcher.py b/src/idom/core/dispatcher.py deleted file mode 100644 index 540c2757b..000000000 --- a/src/idom/core/dispatcher.py +++ /dev/null @@ -1,235 +0,0 @@ -from __future__ import annotations - -from asyncio import Future, Queue, ensure_future -from asyncio.tasks import FIRST_COMPLETED, ensure_future, gather, wait -from contextlib import asynccontextmanager -from logging import getLogger -from typing import ( - Any, - AsyncIterator, - Awaitable, - Callable, - Dict, - List, - NamedTuple, - Sequence, - Tuple, - cast, -) -from weakref import WeakSet - -from anyio import create_task_group - -from idom.utils import Ref - -from ._fixed_jsonpatch import apply_patch, make_patch # type: ignore -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 dispatching 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 dispatch_single_view( - layout: LayoutType[LayoutUpdate, LayoutEvent], - send: SendCoroutine, - recv: RecvCoroutine, -) -> None: - """Run a dispatch loop for a single view instance""" - 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") - - -SharedViewDispatcher = Callable[[SendCoroutine, RecvCoroutine], Awaitable[None]] -_SharedViewDispatcherFuture = Callable[[SendCoroutine, RecvCoroutine], "Future[None]"] - - -@asynccontextmanager -async def create_shared_view_dispatcher( - layout: LayoutType[LayoutUpdate, LayoutEvent], -) -> AsyncIterator[_SharedViewDispatcherFuture]: - """Enter a dispatch context where all subsequent view instances share the same state""" - with layout: - ( - dispatch_shared_view, - send_patch, - ) = await _create_shared_view_dispatcher(layout) - - dispatch_tasks: List[Future[None]] = [] - - def dispatch_shared_view_soon( - send: SendCoroutine, recv: RecvCoroutine - ) -> Future[None]: - future = ensure_future(dispatch_shared_view(send, recv)) - dispatch_tasks.append(future) - return future - - yield dispatch_shared_view_soon - - gathered_dispatch_tasks = gather(*dispatch_tasks, return_exceptions=True) - - while True: - ( - update_future, - dispatchers_completed_future, - ) = await _wait_until_first_complete( - layout.render(), - gathered_dispatch_tasks, - ) - - if dispatchers_completed_future.done(): - update_future.cancel() - break - else: - patch = VdomJsonPatch.create_from(update_future.result()) - - send_patch(patch) - - -def ensure_shared_view_dispatcher_future( - layout: LayoutType[LayoutUpdate, LayoutEvent], -) -> Tuple[Future[None], SharedViewDispatcher]: - """Ensure the future of a dispatcher made by :func:`create_shared_view_dispatcher` - - This returns a future that can be awaited to block until all dispatch tasks have - completed as well as the dispatcher coroutine itself which is used to start dispatch - tasks. - - This is required in situations where usage of the async context manager from - :func:`create_shared_view_dispatcher` is not possible. Typically this happens when - integrating IDOM with other frameworks, servers, or applications. - """ - dispatcher_future: Future[SharedViewDispatcher] = Future() - - async def dispatch_shared_view_forever() -> None: - with layout: - ( - dispatch_shared_view, - send_patch, - ) = await _create_shared_view_dispatcher(layout) - - dispatcher_future.set_result(dispatch_shared_view) - - while True: - send_patch(await render_json_patch(layout)) - - async def dispatch(send: SendCoroutine, recv: RecvCoroutine) -> None: - await (await dispatcher_future)(send, recv) - - return ensure_future(dispatch_shared_view_forever()), dispatch - - -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, make_patch(update.old or {}, update.new).patch) - - -async def _create_shared_view_dispatcher( - layout: LayoutType[LayoutUpdate, LayoutEvent], -) -> Tuple[SharedViewDispatcher, Callable[[VdomJsonPatch], None]]: - update = await layout.render() - model_state = Ref(update.new) - - # We push updates to queues instead of pushing directly to send() callbacks in - # order to isolate send_patch() from any errors send() callbacks might raise. - all_patch_queues: WeakSet[Queue[VdomJsonPatch]] = WeakSet() - - async def dispatch_shared_view(send: SendCoroutine, recv: RecvCoroutine) -> None: - patch_queue: Queue[VdomJsonPatch] = Queue() - try: - async with create_task_group() as inner_task_group: - all_patch_queues.add(patch_queue) - effective_update = LayoutUpdate("", None, model_state.current) - await send(VdomJsonPatch.create_from(effective_update)) - inner_task_group.start_soon(_single_incoming_loop, layout, recv) - inner_task_group.start_soon(_shared_outgoing_loop, send, patch_queue) - except Stop: - logger.info("Stopped dispatch task") - finally: - all_patch_queues.remove(patch_queue) - return None - - def send_patch(patch: VdomJsonPatch) -> None: - model_state.current = patch.apply_to(model_state.current) - for queue in all_patch_queues: - queue.put_nowait(patch) - - return dispatch_shared_view, send_patch - - -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())) - - -async def _shared_outgoing_loop( - send: SendCoroutine, queue: Queue[VdomJsonPatch] -) -> None: - while True: - await send(await queue.get()) - - -async def _wait_until_first_complete( - *tasks: Awaitable[Any], -) -> Sequence[Future[Any]]: - futures = [ensure_future(t) for t in tasks] - await wait(futures, return_when=FIRST_COMPLETED) - return futures diff --git a/src/idom/core/serve.py b/src/idom/core/serve.py new file mode 100644 index 000000000..0cde71e15 --- /dev/null +++ b/src/idom/core/serve.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from asyncio import Future, Queue, ensure_future +from asyncio.tasks import FIRST_COMPLETED, ensure_future, gather, wait +from contextlib import asynccontextmanager +from logging import getLogger +from typing import ( + Any, + AsyncIterator, + Awaitable, + Callable, + Dict, + List, + NamedTuple, + Sequence, + Tuple, + cast, +) +from weakref import WeakSet + +from anyio import create_task_group + +from idom.utils import Ref + +from ._fixed_jsonpatch import apply_patch, make_patch # type: ignore +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""" + 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, make_patch(update.old or {}, update.new).patch) + + +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/server/__init__.py b/src/idom/server/__init__.py index e69de29bb..61a057b6e 100644 --- a/src/idom/server/__init__.py +++ b/src/idom/server/__init__.py @@ -0,0 +1 @@ +from .any import all_implementations diff --git a/src/idom/server/flask.py b/src/idom/server/flask.py index 69b8d6903..40e0be932 100644 --- a/src/idom/server/flask.py +++ b/src/idom/server/flask.py @@ -19,8 +19,8 @@ import idom from idom.config import IDOM_DEBUG_MODE, IDOM_WEB_MODULES_DIR -from idom.core.dispatcher import dispatch_single_view from idom.core.layout import LayoutEvent, LayoutUpdate +from idom.core.serve import serve_json_patch from idom.core.types import ComponentType, RootComponentConstructor from .utils import CLIENT_BUILD_DIR @@ -168,7 +168,7 @@ def recv() -> Optional[LayoutEvent]: else: return None - dispatch_single_view_in_thread(constructor(**_get_query_params(ws)), send, recv) + dispatch_in_thread(constructor(**_get_query_params(ws)), send, recv) def _get_query_params(ws: WebSocket) -> Dict[str, Any]: @@ -178,7 +178,7 @@ def _get_query_params(ws: WebSocket) -> Dict[str, Any]: } -def dispatch_single_view_in_thread( +def dispatch_in_thread( component: ComponentType, send: Callable[[Any], None], recv: Callable[[], Optional[LayoutEvent]], @@ -200,7 +200,7 @@ async def recv_coro() -> Any: return await async_recv_queue.get() async def main() -> None: - await dispatch_single_view(idom.Layout(component), send_coro, recv_coro) + await serve_json_patch(idom.Layout(component), send_coro, recv_coro) main_future = asyncio.ensure_future(main()) diff --git a/src/idom/server/sanic.py b/src/idom/server/sanic.py index 90aa1186b..0bd1b0b3c 100644 --- a/src/idom/server/sanic.py +++ b/src/idom/server/sanic.py @@ -11,13 +11,13 @@ from websockets import WebSocketCommonProtocol from idom.config import IDOM_WEB_MODULES_DIR -from idom.core.dispatcher import ( +from idom.core.layout import Layout, LayoutEvent +from idom.core.serve import ( RecvCoroutine, SendCoroutine, VdomJsonPatch, - dispatch_single_view, + serve_json_patch, ) -from idom.core.layout import Layout, LayoutEvent from idom.core.types import RootComponentConstructor from .utils import CLIENT_BUILD_DIR @@ -115,7 +115,7 @@ async def model_stream( ) -> None: send, recv = _make_send_recv_callbacks(socket) component_params = {k: request.args.get(k) for k in request.args} - await dispatch_single_view(Layout(constructor(**component_params)), send, recv) + await serve_json_patch(Layout(constructor(**component_params)), send, recv) def _make_send_recv_callbacks( diff --git a/src/idom/server/starlette.py b/src/idom/server/starlette.py index 27006dffb..7a7b8161a 100644 --- a/src/idom/server/starlette.py +++ b/src/idom/server/starlette.py @@ -16,14 +16,14 @@ from uvicorn.config import Config as UvicornConfig from uvicorn.server import Server as UvicornServer -from idom.config import IDOM_DEBUG_MODE, IDOM_WEB_MODULES_DIR -from idom.core.dispatcher import ( +from idom.config import IDOM_WEB_MODULES_DIR +from idom.core.layout import Layout, LayoutEvent +from idom.core.serve import ( RecvCoroutine, SendCoroutine, VdomJsonPatch, - dispatch_single_view, + serve_json_patch, ) -from idom.core.layout import Layout, LayoutEvent from idom.core.types import RootComponentConstructor from .utils import CLIENT_BUILD_DIR @@ -161,7 +161,7 @@ async def model_stream(socket: WebSocket) -> None: await socket.accept() send, recv = _make_send_recv_callbacks(socket) try: - await dispatch_single_view( + await serve_json_patch( Layout(constructor(**dict(socket.query_params))), send, recv ) except WebSocketDisconnect as error: diff --git a/src/idom/server/tornado.py b/src/idom/server/tornado.py index ea104f820..fe53db01d 100644 --- a/src/idom/server/tornado.py +++ b/src/idom/server/tornado.py @@ -15,8 +15,8 @@ from typing_extensions import TypedDict from idom.config import IDOM_WEB_MODULES_DIR -from idom.core.dispatcher import VdomJsonPatch, dispatch_single_view from idom.core.layout import Layout, LayoutEvent +from idom.core.serve import VdomJsonPatch, serve_json_patch from idom.core.types import ComponentConstructor from .utils import CLIENT_BUILD_DIR, threaded, wait_on_event @@ -194,7 +194,7 @@ async def recv() -> LayoutEvent: self._message_queue = message_queue self._dispatch_future = asyncio.ensure_future( - dispatch_single_view( + serve_json_patch( Layout(self._component_constructor(**query_params)), send, recv, diff --git a/src/idom/testing.py b/src/idom/testing.py index c2ab4ae74..2dcb79649 100644 --- a/src/idom/testing.py +++ b/src/idom/testing.py @@ -79,12 +79,11 @@ def __init__( server_implementation: ServerImplementation = any_server, host: str = "127.0.0.1", port: Optional[int] = None, - update_on_mount: bool = False, ) -> None: self.server_implementation = server_implementation self.host = host self.port = port or find_available_port(host, allow_reuse_waiting_ports=False) - self._update_on_mount = update_on_mount + self.mount, self._root_component = hotswap() @property def log_records(self) -> List[logging.LogRecord]: @@ -139,12 +138,10 @@ def list_logged_exceptions( return found async def __aenter__(self: _Self) -> _Self: - self.mount, root_component = hotswap(self._update_on_mount) - self._log_handler = _LogRecordCaptor() logging.getLogger().addHandler(self._log_handler) app = self.server_implementation.create_development_app() - self.server_implementation.configure(app, root_component) + self.server_implementation.configure(app, self._root_component) started = asyncio.Event() self._server_future = asyncio.ensure_future( @@ -163,6 +160,8 @@ async def __aexit__( exc_value: Optional[BaseException], traceback: Optional[TracebackType], ) -> None: + self.mount(None) # reset the view + self._server_future.cancel() logging.getLogger().removeHandler(self._log_handler) diff --git a/src/idom/widgets.py b/src/idom/widgets.py index 167f84696..49cf7fb2d 100644 --- a/src/idom/widgets.py +++ b/src/idom/widgets.py @@ -144,7 +144,7 @@ def DivTwo(self): # displaying the output now will show DivTwo """ - constructor_ref: Ref[Callable[[], Any]] = Ref(lambda: {"tagName": "div"}) + constructor_ref: Ref[Callable[[], Any]] = Ref(lambda: None) if update_on_change: set_constructor_callbacks: Set[Callable[[Callable[[], Any]], None]] = set() @@ -176,8 +176,8 @@ def swap(constructor: Callable[[], Any]) -> None: def HotSwap() -> Any: return constructor_ref.current() - def swap(constructor: Callable[[], Any]) -> None: - constructor_ref.current = constructor + def swap(constructor: Callable[[], Any] | None) -> None: + constructor_ref.current = constructor or (lambda: None) return None return swap, HotSwap diff --git a/tests/test_core/test_dispatcher.py b/tests/test_core/test_dispatcher.py index 4f0cf34b0..b57dae2e2 100644 --- a/tests/test_core/test_dispatcher.py +++ b/tests/test_core/test_dispatcher.py @@ -5,14 +5,14 @@ import pytest import idom -from idom.core.dispatcher import ( +from idom.core.layout import Layout, LayoutEvent, LayoutUpdate +from idom.core.serve import ( VdomJsonPatch, _create_shared_view_dispatcher, create_shared_view_dispatcher, - dispatch_single_view, ensure_shared_view_dispatcher_future, + serve_json_patch, ) -from idom.core.layout import Layout, LayoutEvent, LayoutUpdate from idom.testing import StaticEventHandler @@ -95,10 +95,10 @@ def Counter(): return idom.html.div({EVENT_NAME: handler, "count": count}) -async def test_dispatch_single_view(): +async def test_dispatch(): events, expected_model = make_events_and_expected_model() changes, send, recv = make_send_recv_callbacks(events) - await asyncio.wait_for(dispatch_single_view(Layout(Counter()), send, recv), 1) + await asyncio.wait_for(serve_json_patch(Layout(Counter()), send, recv), 1) assert_changes_produce_expected_model(changes, expected_model) @@ -211,7 +211,7 @@ async def handle_event(): recv_queue = asyncio.Queue() asyncio.ensure_future( - dispatch_single_view( + serve_json_patch( idom.Layout(ComponentWithTwoEventHandlers()), send_queue.put, recv_queue.get, diff --git a/tests/test_core/test_hooks.py b/tests/test_core/test_hooks.py index a95724856..936634d2d 100644 --- a/tests/test_core/test_hooks.py +++ b/tests/test_core/test_hooks.py @@ -5,9 +5,9 @@ import idom from idom import html -from idom.core.dispatcher import render_json_patch from idom.core.hooks import COMPONENT_DID_RENDER_EFFECT, LifeCycleHook, current_hook from idom.core.layout import Layout +from idom.core.serve import render_json_patch from idom.testing import HookCatcher, assert_idom_logged from idom.utils import Ref from tests.assert_utils import assert_same_items diff --git a/tests/test_core/test_layout.py b/tests/test_core/test_layout.py index f0dbbb4d5..1890e128b 100644 --- a/tests/test_core/test_layout.py +++ b/tests/test_core/test_layout.py @@ -11,9 +11,9 @@ from idom import html from idom.config import IDOM_DEBUG_MODE from idom.core.component import component -from idom.core.dispatcher import render_json_patch from idom.core.hooks import use_effect, use_state from idom.core.layout import Layout, LayoutEvent +from idom.core.serve import render_json_patch from idom.testing import ( HookCatcher, StaticEventHandler, diff --git a/tests/test_server/test_common/test_multiview.py b/tests/test_server/test_common/test_multiview.py index 56c2deaf8..5499274b5 100644 --- a/tests/test_server/test_common/test_multiview.py +++ b/tests/test_server/test_common/test_multiview.py @@ -1,33 +1,21 @@ import pytest import idom +from idom.server import all_implementations from idom.server import fastapi as idom_fastapi from idom.server import flask as idom_flask from idom.server import sanic as idom_sanic from idom.server import starlette as idom_starlette -from idom.server import tornado as idom_tornado -from idom.server.prefab import multiview_server from idom.testing import ServerMountPoint from tests.driver_utils import no_such_element @pytest.fixture( - params=[ - # add new PerClientStateServer implementations here to - # run a suite of tests which check basic functionality - idom_fastapi.PerClientStateServer, - idom_flask.PerClientStateServer, - idom_sanic.PerClientStateServer, - idom_starlette.PerClientStateServer, - idom_tornado.PerClientStateServer, - ], + params=list(all_implementations()), ids=lambda cls: f"{cls.__module__}.{cls.__name__}", ) -def server_mount_point(request): - with ServerMountPoint( - request.param, - mount_and_server_constructor=multiview_server, - ) as mount_point: +async def server_mount_point(request): + async with ServerMountPoint(request.param) as mount_point: yield mount_point From 86e54b06378305ed0e6bc26a7f54cca3dc16682b Mon Sep 17 00:00:00 2001 From: rmorshea Date: Thu, 17 Mar 2022 14:35:55 -0700 Subject: [PATCH 06/58] misc fixes --- src/idom/server/any.py | 12 ++++++------ src/idom/server/tornado.py | 25 +++++++++++++++++++------ src/idom/server/types.py | 2 +- src/idom/testing.py | 4 ++-- src/idom/widgets.py | 6 +++--- 5 files changed, 31 insertions(+), 18 deletions(-) diff --git a/src/idom/server/any.py b/src/idom/server/any.py index d7092bb71..643767c15 100644 --- a/src/idom/server/any.py +++ b/src/idom/server/any.py @@ -27,7 +27,7 @@ def run( host: str = "127.0.0.1", port: int | None = None, open_browser: bool = True, - implementation: ServerImplementation = sys.modules[__name__], + implementation: ServerImplementation[Any] = sys.modules[__name__], ) -> None: """Run a component with a development server""" @@ -40,7 +40,7 @@ def run( app = implementation.create_development_app() implementation.configure(app, component) - coros: list[Awaitable] = [] + coros: list[Awaitable[Any]] = [] host = host port = port or find_available_port(host) @@ -56,7 +56,7 @@ async def _open_browser_after_server() -> None: coros.append(_open_browser_after_server()) - asyncio.get_event_loop().run_forever(asyncio.gather(*coros)) + asyncio.get_event_loop().run_until_complete(asyncio.gather(*coros)) def configure(app: Any, component: RootComponentConstructor) -> None: @@ -78,7 +78,7 @@ async def serve_development_app( ) -def _get_any_implementation() -> ServerImplementation: +def _get_any_implementation() -> ServerImplementation[Any]: """Get the first available server implementation""" global _DEFAULT_IMPLEMENTATION @@ -94,10 +94,10 @@ def _get_any_implementation() -> ServerImplementation: return implementation -_DEFAULT_IMPLEMENTATION: ServerImplementation | None = None +_DEFAULT_IMPLEMENTATION: ServerImplementation[Any] | None = None -def all_implementations() -> Iterator[ServerImplementation]: +def all_implementations() -> Iterator[ServerImplementation[Any]]: """Yield all available server implementations""" for name in SUPPORTED_PACKAGES: try: diff --git a/src/idom/server/tornado.py b/src/idom/server/tornado.py index fe53db01d..4db68504c 100644 --- a/src/idom/server/tornado.py +++ b/src/idom/server/tornado.py @@ -9,6 +9,7 @@ from typing import Any, List, Optional, Tuple, Type, Union from urllib.parse import urljoin +from tornado.httpserver import HTTPServer from tornado.platform.asyncio import AsyncIOMainLoop from tornado.web import Application, RedirectHandler, RequestHandler, StaticFileHandler from tornado.websocket import WebSocketHandler @@ -19,7 +20,7 @@ from idom.core.serve import VdomJsonPatch, serve_json_patch from idom.core.types import ComponentConstructor -from .utils import CLIENT_BUILD_DIR, threaded, wait_on_event +from .utils import CLIENT_BUILD_DIR def configure( @@ -52,11 +53,23 @@ def create_development_app() -> Application: async def serve_development_app( app: Application, host: str, port: int, started: asyncio.Event ) -> None: - loop = AsyncIOMainLoop() - loop.install() - app.listen(port, host) - loop.add_callback(lambda: loop.asyncio_loop.call_soon_threadsafe(started.set)) - await loop.run_in_executor(ThreadPoolExecutor()) + # setup up tornado to use asyncio + AsyncIOMainLoop().install() + + server = HTTPServer(app) + server.listen(port, host) + + # at this point the server is accepting connection + started.set() + + try: + # block forever - tornado has already set up its own background tasks + await asyncio.get_event_loop().create_future() + finally: + # stop accepting new connections + server.stop() + # wait for existing connections to complete + await server.close_all_connections() class Options(TypedDict, total=False): diff --git a/src/idom/server/types.py b/src/idom/server/types.py index 11f34fe71..5c5cc04a0 100644 --- a/src/idom/server/types.py +++ b/src/idom/server/types.py @@ -10,7 +10,7 @@ @runtime_checkable -class ServerImplementation(Protocol): +class ServerImplementation(Protocol[_App]): """Common interface for IDOM's builti-in server implementations""" def configure(self, app: _App, component: RootComponentConstructor) -> None: diff --git a/src/idom/testing.py b/src/idom/testing.py index 2dcb79649..6f8a03260 100644 --- a/src/idom/testing.py +++ b/src/idom/testing.py @@ -72,11 +72,11 @@ class ServerMountPoint: """A context manager for imperatively mounting views to a render server when testing""" _log_handler: "_LogRecordCaptor" - _server_future: asyncio.Task + _server_future: asyncio.Task[Any] def __init__( self, - server_implementation: ServerImplementation = any_server, + server_implementation: ServerImplementation[Any] = any_server, host: str = "127.0.0.1", port: Optional[int] = None, ) -> None: diff --git a/src/idom/widgets.py b/src/idom/widgets.py index 49cf7fb2d..d3cb29937 100644 --- a/src/idom/widgets.py +++ b/src/idom/widgets.py @@ -107,7 +107,7 @@ def __call__(self, value: str) -> _CastTo: ... -MountFunc = Callable[[ComponentConstructor], None] +MountFunc = Callable[["ComponentConstructor | None"], None] def hotswap(update_on_change: bool = False) -> Tuple[MountFunc, ComponentConstructor]: @@ -162,8 +162,8 @@ def add_callback() -> Callable[[], None]: return constructor() - def swap(constructor: Callable[[], Any]) -> None: - constructor_ref.current = constructor + def swap(constructor: Callable[[], Any] | None) -> None: + constructor = constructor_ref.current = constructor or (lambda: None) for set_constructor in set_constructor_callbacks: set_constructor(constructor) From e76104c8c8bff66e18af994772769f783371f94c Mon Sep 17 00:00:00 2001 From: rmorshea Date: Fri, 18 Mar 2022 02:17:13 -0700 Subject: [PATCH 07/58] start to switch test suite to playwright --- .github/workflows/test.yml | 2 - noxfile.py | 2 +- requirements/pkg-extras.txt | 4 +- requirements/test-env.txt | 4 +- setup.cfg | 3 +- src/idom/sample.py | 2 +- tests/__init__.py | 1 + tests/conftest.py | 95 +++---------------- tests/driver_utils.py | 19 ---- tests/test_client.py | 2 +- tests/test_core/test_hooks.py | 2 +- tests/test_core/test_layout.py | 2 +- tests/test_sample.py | 16 +--- .../test_server/test_common/test_multiview.py | 2 +- tests/test_widgets.py | 2 +- tests/utils/__init__.py | 0 tests/{assert_utils.py => utils/asserts.py} | 0 tests/utils/browser.py | 47 +++++++++ 18 files changed, 76 insertions(+), 129 deletions(-) delete mode 100644 tests/driver_utils.py create mode 100644 tests/utils/__init__.py rename tests/{assert_utils.py => utils/asserts.py} (100%) create mode 100644 tests/utils/browser.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 239281f79..142b25c8c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,7 +15,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - - uses: nanasess/setup-chromedriver@master - uses: actions/setup-node@v2 with: node-version: "14.x" @@ -38,7 +37,6 @@ jobs: os: [ubuntu-latest, macos-latest, windows-latest] steps: - uses: actions/checkout@v2 - - uses: nanasess/setup-chromedriver@master - uses: actions/setup-node@v2 with: node-version: "14.x" diff --git a/noxfile.py b/noxfile.py index bd9ad2599..6a892f7cf 100644 --- a/noxfile.py +++ b/noxfile.py @@ -179,7 +179,7 @@ def test_python_suite(session: Session) -> None: """Run the Python-based test suite""" session.env["IDOM_DEBUG_MODE"] = "1" install_requirements_file(session, "test-env") - + session.run("playwright install") posargs = session.posargs posargs += ["--reruns", "3", "--reruns-delay", "1"] diff --git a/requirements/pkg-extras.txt b/requirements/pkg-extras.txt index 3e7d3aab1..f218c88ad 100644 --- a/requirements/pkg-extras.txt +++ b/requirements/pkg-extras.txt @@ -1,5 +1,5 @@ # extra=stable,sanic -sanic <19.12.0 +sanic sanic-cors # extra=fastapi @@ -7,7 +7,7 @@ fastapi >=0.63.0 uvicorn[standard] >=0.13.4 # extra=starlette -fastapi >=0.16.0 +starlette >=0.13.6 uvicorn[standard] >=0.13.4 # extra=flask diff --git a/requirements/test-env.txt b/requirements/test-env.txt index c277b335c..48953a724 100644 --- a/requirements/test-env.txt +++ b/requirements/test-env.txt @@ -1,9 +1,9 @@ ipython pytest -pytest-asyncio +pytest-asyncio>=0.17 pytest-cov pytest-mock pytest-rerunfailures pytest-timeout responses -selenium +playwright diff --git a/setup.cfg b/setup.cfg index 7e05d27bd..01a6c6d1e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -24,7 +24,8 @@ testpaths = tests xfail_strict = True markers = slow: marks tests as slow (deselect with '-m "not slow"') -python_files = assert_*.py test_*.py +python_files = *asserts.py test_*.py +asyncio_mode = auto [coverage:report] fail_under = 100 diff --git a/src/idom/sample.py b/src/idom/sample.py index d907bf43b..464f11b10 100644 --- a/src/idom/sample.py +++ b/src/idom/sample.py @@ -11,7 +11,7 @@ @component def App() -> VdomDict: - return html._( + return html.div( {"style": {"padding": "15px"}}, html.h1("Sample Application"), html.p( diff --git a/tests/__init__.py b/tests/__init__.py index e69de29bb..f4695fa3f 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1 @@ +from .utils.browser import Display diff --git a/tests/conftest.py b/tests/conftest.py index 8913131b7..ccb638c40 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,27 +1,21 @@ from __future__ import annotations -import inspect import os -from typing import Any, List +from dataclasses import dataclass +from typing import Awaitable, Callable, List import pytest from _pytest.config import Config from _pytest.config.argparsing import Parser -from selenium.webdriver import Chrome, ChromeOptions -from selenium.webdriver.support.ui import WebDriverWait import idom -from idom.testing import ( - ServerMountPoint, - clear_idom_web_modules_dir, - create_simple_selenium_web_driver, -) +from idom.testing import ServerMountPoint, clear_idom_web_modules_dir +from tests.utils.browser import launch_browser, open_display def pytest_collection_modifyitems( - session: pytest.Session, config: pytest.config.Config, items: List[pytest.Item] + session: pytest.Session, config: Config, items: List[pytest.Item] ) -> None: - _mark_coros_as_async_tests(items) _skip_web_driver_tests_on_windows(items) @@ -32,78 +26,18 @@ def pytest_addoption(parser: Parser) -> None: action="store_true", help="Whether to run browser tests in headless mode.", ) - parser.addoption( - "--no-restore", - dest="restore_client", - action="store_false", - help="Whether to restore the client build before testing.", - ) @pytest.fixture -def display(driver, server_mount_point): - display_id = idom.Ref(0) - - def mount_and_display(component_constructor, query=None, check_mount=True): - component_id = f"display-{display_id.set_current(display_id.current + 1)}" - server_mount_point.mount( - lambda: idom.html.div({"id": component_id}, component_constructor()) - ) - driver.get(server_mount_point.url(query=query)) - if check_mount: - driver.find_element("id", component_id) - return component_id - - yield mount_and_display +async def display(browser): + async with open_display(browser, idom.server.any) as display: + yield display @pytest.fixture -def driver_get(driver, server_mount_point): - return lambda query=None: driver.get(server_mount_point.url(query=query)) - - -@pytest.fixture -async def server_mount_point(): - """An IDOM layout mount function and server as a tuple - - The ``mount`` and ``server`` fixtures use this. - """ - async with ServerMountPoint() as mount_point: - yield mount_point - - -@pytest.fixture(scope="module") -def driver_wait(driver): - return WebDriverWait(driver, 3) - - -@pytest.fixture(scope="module") -def driver(create_driver) -> Chrome: - """A Selenium web driver""" - return create_driver() - - -@pytest.fixture(scope="module") -def create_driver(driver_is_headless): - """A Selenium web driver""" - drivers = [] - - def create(**kwargs: Any): - options = ChromeOptions() - options.headless = driver_is_headless - driver = create_simple_selenium_web_driver(driver_options=options, **kwargs) - drivers.append(driver) - return driver - - yield create - - for d in drivers: - d.quit() - - -@pytest.fixture(scope="session") -def driver_is_headless(pytestconfig: Config): - return bool(pytestconfig.option.headless) +async def browser(pytestconfig: Config): + async with launch_browser(headless=bool(pytestconfig.option.headless)) as browser: + yield browser @pytest.fixture(autouse=True) @@ -111,13 +45,6 @@ def _clear_web_modules_dir_after_test(): clear_idom_web_modules_dir() -def _mark_coros_as_async_tests(items: List[pytest.Item]) -> None: - for item in items: - if isinstance(item, pytest.Function): - if inspect.iscoroutinefunction(item.function): - item.add_marker(pytest.mark.asyncio) - - def _skip_web_driver_tests_on_windows(items: List[pytest.Item]) -> None: if os.name == "nt": for item in items: diff --git a/tests/driver_utils.py b/tests/driver_utils.py deleted file mode 100644 index 93032486a..000000000 --- a/tests/driver_utils.py +++ /dev/null @@ -1,19 +0,0 @@ -from typing import Any - -from selenium.common.exceptions import NoSuchElementException -from selenium.webdriver.remote.webdriver import WebDriver -from selenium.webdriver.remote.webelement import WebElement - - -def send_keys(element: WebElement, keys: Any) -> None: - for char in keys: - element.send_keys(char) - - -def no_such_element(driver: WebDriver, method: str, param: Any) -> bool: - try: - driver.find_element(method, param) - except NoSuchElementException: - return True - else: - return False diff --git a/tests/test_client.py b/tests/test_client.py index 21d07456d..2601fae2b 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -4,7 +4,7 @@ import idom from idom.testing import ServerMountPoint -from tests.driver_utils import send_keys +from tests.utils.browser import send_keys JS_DIR = Path(__file__).parent / "js" diff --git a/tests/test_core/test_hooks.py b/tests/test_core/test_hooks.py index 936634d2d..9c82cd16f 100644 --- a/tests/test_core/test_hooks.py +++ b/tests/test_core/test_hooks.py @@ -10,7 +10,7 @@ from idom.core.serve import render_json_patch from idom.testing import HookCatcher, assert_idom_logged from idom.utils import Ref -from tests.assert_utils import assert_same_items +from tests.utils.asserts import assert_same_items async def test_must_be_rendering_in_layout_to_use_hooks(): diff --git a/tests/test_core/test_layout.py b/tests/test_core/test_layout.py index 1890e128b..043d271ef 100644 --- a/tests/test_core/test_layout.py +++ b/tests/test_core/test_layout.py @@ -21,7 +21,7 @@ capture_idom_logs, ) from idom.utils import Ref -from tests.assert_utils import assert_same_items +from tests.utils.asserts import assert_same_items @pytest.fixture(autouse=True) diff --git a/tests/test_sample.py b/tests/test_sample.py index be9135820..d3a2e29c3 100644 --- a/tests/test_sample.py +++ b/tests/test_sample.py @@ -1,15 +1,7 @@ -from idom.sample import run_sample_app +from idom.sample import App, run_sample_app from idom.server.utils import find_available_port +from tests import Display -def test_sample_app(driver): - host = "127.0.0.1" - port = find_available_port(host, allow_reuse_waiting_ports=False) - - run_sample_app(host=host, port=port, run_in_thread=True) - - driver.get(f"http://{host}:{port}") - - h1 = driver.find_element("tag name", "h1") - - assert h1.get_attribute("innerHTML") == "Sample Application" +async def test_sample_app(display: Display): + await display.show(App) diff --git a/tests/test_server/test_common/test_multiview.py b/tests/test_server/test_common/test_multiview.py index 5499274b5..e5a908a6e 100644 --- a/tests/test_server/test_common/test_multiview.py +++ b/tests/test_server/test_common/test_multiview.py @@ -7,7 +7,7 @@ from idom.server import sanic as idom_sanic from idom.server import starlette as idom_starlette from idom.testing import ServerMountPoint -from tests.driver_utils import no_such_element +from tests.utils.browser import no_such_element @pytest.fixture( diff --git a/tests/test_widgets.py b/tests/test_widgets.py index 269dec0ef..00791aa7b 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -4,7 +4,7 @@ from selenium.webdriver.common.keys import Keys import idom -from tests.driver_utils import send_keys +from tests.utils.browser import send_keys HERE = Path(__file__).parent diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/assert_utils.py b/tests/utils/asserts.py similarity index 100% rename from tests/assert_utils.py rename to tests/utils/asserts.py diff --git a/tests/utils/browser.py b/tests/utils/browser.py new file mode 100644 index 000000000..f0a6cca54 --- /dev/null +++ b/tests/utils/browser.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from contextlib import asynccontextmanager +from typing import Any + +from playwright.async_api import Browser, Page, async_playwright + +from idom import html +from idom.server import any as any_server +from idom.server.types import ServerImplementation +from idom.testing import ServerMountPoint +from idom.types import RootComponentConstructor + + +@asynccontextmanager +async def launch_browser(headless: bool) -> Browser: + async with async_playwright() as p: + yield await p.chromium.launch(headless=headless) + + +@asynccontextmanager +async def open_display( + browser: Browser, implementation: ServerImplementation[Any] = any_server +) -> Display: + async with ServerMountPoint(implementation) as mount: + page = await browser.new_page() + try: + yield Display(page, mount) + finally: + await page.close() + + +class Display: + def __init__(self, page: Page, mount: ServerMountPoint) -> None: + self.page = page + self.mount = mount + self._next_id = 0 + + async def show( + self, component: RootComponentConstructor, query: dict[str, Any] | None = None + ) -> str: + self._next_id += 1 + component_id = f"display-{self._next_id}" + self.mount.mount(lambda: html.div({"id": component_id}, component())) + await self.page.goto(self.mount.url(query=query)) + await self.page.wait_for_selector(f"#{component_id}") + return component_id From 1bfbc1a82e42bc32c760044de70aa738e2db03bd Mon Sep 17 00:00:00 2001 From: rmorshea Date: Fri, 18 Mar 2022 18:43:51 -0700 Subject: [PATCH 08/58] get basic tests working --- .github/workflows/test.yml | 4 +- requirements/pkg-extras.txt | 3 +- src/idom/__init__.py | 2 +- src/idom/sample.py | 5 +- src/idom/server/__init__.py | 1 - src/idom/server/_asgi.py | 36 ++++++ src/idom/server/any.py | 113 ------------------ src/idom/server/default.py | 27 +++++ src/idom/server/flask.py | 27 +++-- src/idom/server/sanic.py | 19 ++- src/idom/server/starlette.py | 21 +--- src/idom/server/tornado.py | 52 +------- src/idom/server/utils.py | 91 ++++++++++++++ src/idom/testing.py | 113 +++++++++++++----- tests/__init__.py | 1 - tests/conftest.py | 63 +++++----- tests/test_client.py | 6 +- tests/test_core/test_hooks.py | 2 +- tests/test_core/test_layout.py | 2 +- tests/test_sample.py | 4 +- .../test_server/test_common/test_multiview.py | 6 +- .../test_common/test_per_client_state.py | 4 +- .../test_common/test_shared_state_client.py | 4 +- tests/test_web/test_module.py | 4 +- tests/test_widgets.py | 2 +- tests/{utils => tooling}/__init__.py | 0 tests/{utils => tooling}/asserts.py | 0 tests/tooling/loop.py | 45 +++++++ tests/utils/browser.py | 47 -------- 29 files changed, 369 insertions(+), 335 deletions(-) create mode 100644 src/idom/server/_asgi.py delete mode 100644 src/idom/server/any.py create mode 100644 src/idom/server/default.py rename tests/{utils => tooling}/__init__.py (100%) rename tests/{utils => tooling}/asserts.py (100%) create mode 100644 tests/tooling/loop.py delete mode 100644 tests/utils/browser.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 142b25c8c..8be799f5a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,7 +28,7 @@ jobs: run: pip install -r requirements/nox-deps.txt - name: Run Tests env: { "CI": "true" } - run: nox -s test_python_suite -- --headless --maxfail=3 + run: nox -s test_python_suite -- --maxfail=3 test-python-environments: runs-on: ${{ matrix.os }} strategy: @@ -50,7 +50,7 @@ jobs: run: pip install -r requirements/nox-deps.txt - name: Run Tests env: { "CI": "true" } - run: nox -s test_python --stop-on-first-error -- --headless --maxfail=3 --no-cov + run: nox -s test_python --stop-on-first-error -- --maxfail=3 --no-cov test-docs: runs-on: ubuntu-latest steps: diff --git a/requirements/pkg-extras.txt b/requirements/pkg-extras.txt index f218c88ad..85de59b0f 100644 --- a/requirements/pkg-extras.txt +++ b/requirements/pkg-extras.txt @@ -15,12 +15,13 @@ flask<2.0 markupsafe<2.1 flask-cors flask-sockets +a2wsgi # tornado tornado # extra=testing -selenium +playwright # extra=matplotlib matplotlib diff --git a/src/idom/__init__.py b/src/idom/__init__.py index 60d6170b2..1efe3f228 100644 --- a/src/idom/__init__.py +++ b/src/idom/__init__.py @@ -16,7 +16,7 @@ from .core.serve import Stop from .core.vdom import vdom from .sample import run_sample_app -from .server.any import run +from .server.utils import run from .utils import Ref, html_to_vdom from .widgets import hotswap, multiview diff --git a/src/idom/sample.py b/src/idom/sample.py index 464f11b10..d1ac64590 100644 --- a/src/idom/sample.py +++ b/src/idom/sample.py @@ -1,12 +1,9 @@ from __future__ import annotations -import webbrowser -from typing import Any - from . import html from .core.component import component from .core.types import VdomDict -from .server.any import run +from .server.utils import run @component diff --git a/src/idom/server/__init__.py b/src/idom/server/__init__.py index 61a057b6e..e69de29bb 100644 --- a/src/idom/server/__init__.py +++ b/src/idom/server/__init__.py @@ -1 +0,0 @@ -from .any import all_implementations diff --git a/src/idom/server/_asgi.py b/src/idom/server/_asgi.py new file mode 100644 index 000000000..9133d7035 --- /dev/null +++ b/src/idom/server/_asgi.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import asyncio +from asyncio import FIRST_EXCEPTION, CancelledError + +from asgiref.typing import ASGIApplication +from uvicorn.config import Config as UvicornConfig +from uvicorn.server import Server as UvicornServer + + +async def serve_development_asgi( + app: ASGIApplication, + host: str, + port: int, + started: asyncio.Event, +) -> None: + """Run a development server for starlette""" + server = UvicornServer(UvicornConfig(app, host=host, port=port, loop="asyncio")) + + async def check_if_started(): + while not server.started: + await asyncio.sleep(0.2) + started.set() + + coros = [server.serve(), check_if_started()] + _, pending = await asyncio.wait( + list(map(asyncio.create_task, coros)), return_when=FIRST_EXCEPTION + ) + + for task in pending: + task.cancel() + + try: + await asyncio.gather(*list(pending)) + except CancelledError: + pass diff --git a/src/idom/server/any.py b/src/idom/server/any.py deleted file mode 100644 index 643767c15..000000000 --- a/src/idom/server/any.py +++ /dev/null @@ -1,113 +0,0 @@ -from __future__ import annotations - -import asyncio -import sys -import warnings -import webbrowser -from importlib import import_module -from typing import Any, Awaitable, Iterator - -from idom.types import RootComponentConstructor - -from .types import ServerImplementation -from .utils import find_available_port - - -SUPPORTED_PACKAGES = ( - "starlette", - "fastapi", - "sanic", - "flask", - "tornado", -) - - -def run( - component: RootComponentConstructor, - host: str = "127.0.0.1", - port: int | None = None, - open_browser: bool = True, - implementation: ServerImplementation[Any] = sys.modules[__name__], -) -> None: - """Run a component with a development server""" - - warnings.warn( - "You are running a development server, be sure to change this before deploying in production!", - UserWarning, - stacklevel=2, - ) - - app = implementation.create_development_app() - implementation.configure(app, component) - - coros: list[Awaitable[Any]] = [] - - host = host - port = port or find_available_port(host) - started = asyncio.Event() - - coros.append(implementation.serve_development_app(app, host, port, started)) - - if open_browser: - - async def _open_browser_after_server() -> None: - await started.wait() - webbrowser.open(f"http://{host}:{port}") - - coros.append(_open_browser_after_server()) - - asyncio.get_event_loop().run_until_complete(asyncio.gather(*coros)) - - -def configure(app: Any, component: RootComponentConstructor) -> None: - """Configure the given app instance to display the given component""" - return _get_any_implementation().configure(app, component) - - -def create_development_app() -> Any: - """Create an application instance for development purposes""" - return _get_any_implementation().create_development_app() - - -async def serve_development_app( - app: Any, host: str, port: int, started: asyncio.Event -) -> None: - """Run an application using a development server""" - return await _get_any_implementation().serve_development_app( - app, host, port, started - ) - - -def _get_any_implementation() -> ServerImplementation[Any]: - """Get the first available server implementation""" - global _DEFAULT_IMPLEMENTATION - - if _DEFAULT_IMPLEMENTATION is not None: - return _DEFAULT_IMPLEMENTATION - - try: - implementation = next(all_implementations()) - except StopIteration: - raise RuntimeError("No built-in server implementation installed.") - else: - _DEFAULT_IMPLEMENTATION = implementation - return implementation - - -_DEFAULT_IMPLEMENTATION: ServerImplementation[Any] | None = None - - -def all_implementations() -> Iterator[ServerImplementation[Any]]: - """Yield all available server implementations""" - for name in SUPPORTED_PACKAGES: - try: - module = import_module(f"idom.server.{name}") - except ImportError: # pragma: no cover - continue - - if not isinstance(module, ServerImplementation): - raise TypeError( # pragma: no cover - f"{module.__name__!r} is an invalid implementation" - ) - - yield module diff --git a/src/idom/server/default.py b/src/idom/server/default.py new file mode 100644 index 000000000..95ac97d00 --- /dev/null +++ b/src/idom/server/default.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import asyncio +from typing import Any + +from idom.types import RootComponentConstructor + +from .utils import default_implementation + + +def configure(app: Any, component: RootComponentConstructor) -> None: + """Configure the given app instance to display the given component""" + return default_implementation().configure(app, component) + + +def create_development_app() -> Any: + """Create an application instance for development purposes""" + return default_implementation().create_development_app() + + +async def serve_development_app( + app: Any, host: str, port: int, started: asyncio.Event +) -> None: + """Run an application using a development server""" + return await default_implementation().serve_development_app( + app, host, port, started + ) diff --git a/src/idom/server/flask.py b/src/idom/server/flask.py index 40e0be932..580097617 100644 --- a/src/idom/server/flask.py +++ b/src/idom/server/flask.py @@ -13,12 +13,13 @@ from flask import Blueprint, Flask, redirect, request, send_from_directory, url_for from flask_cors import CORS from flask_sockets import Sockets +from gevent import pywsgi +from geventwebsocket.handler import WebSocketHandler from geventwebsocket.websocket import WebSocket from typing_extensions import TypedDict -from werkzeug.serving import ThreadedWSGIServer import idom -from idom.config import IDOM_DEBUG_MODE, IDOM_WEB_MODULES_DIR +from idom.config import IDOM_WEB_MODULES_DIR from idom.core.layout import LayoutEvent, LayoutUpdate from idom.core.serve import serve_json_patch from idom.core.types import ComponentType, RootComponentConstructor @@ -58,28 +59,34 @@ async def serve_development_app( ) -> None: """Run an application using a development server""" loop = asyncio.get_event_loop() - - @app.before_first_request - def set_started(): - loop.call_soon_threadsafe(started.set) - - server = ThreadedWSGIServer(host, port, app) - stopped = asyncio.Event() + server: pywsgi.WSGIServer + def run_server(): + nonlocal server + server = pywsgi.WSGIServer( + (host, port), + app, + handler_class=WebSocketHandler, + ) + server.start() + loop.call_soon_threadsafe(started.set) try: server.serve_forever() finally: loop.call_soon_threadsafe(stopped.set) thread = Thread(target=run_server, daemon=True) + thread.start() + + await started.wait() try: await stopped.wait() finally: # we may have exitted because this task was cancelled - server.shutdown() + server.stop(3) # the thread should eventually join thread.join(timeout=3) # just double check it happened diff --git a/src/idom/server/sanic.py b/src/idom/server/sanic.py index 0bd1b0b3c..e7aac1330 100644 --- a/src/idom/server/sanic.py +++ b/src/idom/server/sanic.py @@ -3,12 +3,14 @@ import asyncio import json import logging +import os +import socket from typing import Any, Dict, Tuple, Union from mypy_extensions import TypedDict from sanic import Blueprint, Sanic, request, response from sanic_cors import CORS -from websockets import WebSocketCommonProtocol +from websockets.legacy.protocol import WebSocketCommonProtocol from idom.config import IDOM_WEB_MODULES_DIR from idom.core.layout import Layout, LayoutEvent @@ -48,12 +50,17 @@ async def serve_development_app( app: Sanic, host: str, port: int, started: asyncio.Event ) -> None: """Run a development server for :mod:`sanic`""" - - @app.listener("after_server_start") - async def after_started(): + try: + server = await app.create_server( + host, port, return_asyncio_server=True, debug=True + ) + await server.startup() + await server.start_serving() started.set() - - await app.create_server(host, port, debug=True) + await server.serve_forever() + except KeyboardInterrupt: + app.shutdown_tasks(3) + app.stop() class Options(TypedDict, total=False): diff --git a/src/idom/server/starlette.py b/src/idom/server/starlette.py index 7a7b8161a..fbd9ce0f3 100644 --- a/src/idom/server/starlette.py +++ b/src/idom/server/starlette.py @@ -26,6 +26,7 @@ ) from idom.core.types import RootComponentConstructor +from ._asgi import serve_development_asgi from .utils import CLIENT_BUILD_DIR @@ -63,25 +64,7 @@ async def serve_development_app( started: asyncio.Event, ) -> None: """Run a development server for starlette""" - server = UvicornServer(UvicornConfig(app, host=host, port=port, loop="asyncio")) - - async def check_if_started(): - while not server.started: - await asyncio.sleep(0.2) - started.set() - - _, pending = await asyncio.wait( - [server.serve(), check_if_started()], - return_when=FIRST_EXCEPTION, - ) - - for task in pending: - task.cancel() - - try: - await asyncio.gather(*list(pending)) - except CancelledError: - pass + await serve_development_asgi(app, host, port, started) class Options(TypedDict, total=False): diff --git a/src/idom/server/tornado.py b/src/idom/server/tornado.py index 4db68504c..9665e46e8 100644 --- a/src/idom/server/tornado.py +++ b/src/idom/server/tornado.py @@ -4,9 +4,7 @@ import json from asyncio import Queue as AsyncQueue from asyncio.futures import Future -from concurrent.futures import ThreadPoolExecutor -from threading import Event as ThreadEvent -from typing import Any, List, Optional, Tuple, Type, Union +from typing import Any, List, Tuple, Type, Union from urllib.parse import urljoin from tornado.httpserver import HTTPServer @@ -27,7 +25,7 @@ def configure( app: Application, component: ComponentConstructor, options: Options | None = None, -) -> TornadoServer: +) -> None: """Return a :class:`TornadoServer` where each client has its own state. Implements the :class:`~idom.server.proto.ServerFactory` protocol @@ -43,7 +41,6 @@ def configure( options, _setup_common_routes(options) + _setup_single_view_dispatcher_route(component), ) - return TornadoServer(app) def create_development_app() -> Application: @@ -88,51 +85,6 @@ class Options(TypedDict, total=False): _RouteHandlerSpecs = List[Tuple[str, Type[RequestHandler], Any]] -class TornadoServer: - """A thin wrapper for running a Tornado application - - See :class:`idom.server.proto.Server` for more info - """ - - _loop: asyncio.AbstractEventLoop - - def __init__(self, app: Application) -> None: - self.app = app - self._did_start = ThreadEvent() - - def run(self, host: str, port: int, *args: Any, **kwargs: Any) -> None: - self._loop = asyncio.get_event_loop() - AsyncIOMainLoop().install() - self.app.listen(port, host, *args, **kwargs) - self._did_start.set() - asyncio.get_event_loop().run_forever() - - @threaded - def run_in_thread(self, host: str, port: int, *args: Any, **kwargs: Any) -> None: - self.run(host, port, *args, **kwargs) - - def wait_until_started(self, timeout: Optional[float] = 3.0) -> None: - self._did_start.wait(timeout) - - def stop(self, timeout: Optional[float] = 3.0) -> None: - try: - loop = self._loop - except AttributeError: # pragma: no cover - raise RuntimeError( - f"Application is not running or was not started by {self}" - ) - else: - did_stop = ThreadEvent() - - def stop() -> None: - loop.stop() - did_stop.set() - - loop.call_soon_threadsafe(stop) - - wait_on_event(f"stop {self.app}", did_stop, timeout) - - def _setup_options(options: Options | None) -> Options: return { "url_prefix": "", diff --git a/src/idom/server/utils.py b/src/idom/server/utils.py index 3168cfeb5..ecf77ec02 100644 --- a/src/idom/server/utils.py +++ b/src/idom/server/utils.py @@ -1,12 +1,68 @@ +from __future__ import annotations + +import asyncio import socket +import sys +import warnings +import webbrowser from contextlib import closing +from importlib import import_module from pathlib import Path +from typing import Any, Awaitable, Iterator import idom +from idom.types import RootComponentConstructor + +from .types import ServerImplementation CLIENT_BUILD_DIR = Path(idom.__file__).parent / "client" +SUPPORTED_PACKAGES = ( + "starlette", + "fastapi", + "sanic", + "tornado", + "flask", +) + + +def run( + component: RootComponentConstructor, + host: str = "127.0.0.1", + port: int | None = None, + open_browser: bool = True, + implementation: ServerImplementation[Any] = sys.modules[__name__], +) -> None: + """Run a component with a development server""" + + warnings.warn( + "You are running a development server, be sure to change this before deploying in production!", + UserWarning, + stacklevel=2, + ) + + app = implementation.create_development_app() + implementation.configure(app, component) + + coros: list[Awaitable[Any]] = [] + + host = host + port = port or find_available_port(host) + started = asyncio.Event() + + coros.append(implementation.serve_development_app(app, host, port, started)) + + if open_browser: + + async def _open_browser_after_server() -> None: + await started.wait() + webbrowser.open(f"http://{host}:{port}") + + coros.append(_open_browser_after_server()) + + asyncio.get_event_loop().run_until_complete(asyncio.gather(*coros)) + def find_available_port( host: str, @@ -33,3 +89,38 @@ def find_available_port( raise RuntimeError( f"Host {host!r} has no available port in range {port_max}-{port_max}" ) + + +def default_implementation() -> ServerImplementation[Any]: + """Get the first available server implementation""" + global _DEFAULT_IMPLEMENTATION + + if _DEFAULT_IMPLEMENTATION is not None: + return _DEFAULT_IMPLEMENTATION + + try: + implementation = next(all_implementations()) + except StopIteration: + raise RuntimeError("No built-in server implementation installed.") + else: + _DEFAULT_IMPLEMENTATION = implementation + return implementation + + +_DEFAULT_IMPLEMENTATION: ServerImplementation[Any] | None = None + + +def all_implementations() -> Iterator[ServerImplementation[Any]]: + """Yield all available server implementations""" + for name in SUPPORTED_PACKAGES: + try: + module = import_module(f"idom.server.{name}") + except ImportError: # pragma: no cover + continue + + if not isinstance(module, ServerImplementation): + raise TypeError( # pragma: no cover + f"{module.__name__!r} is an invalid implementation" + ) + + yield module diff --git a/src/idom/testing.py b/src/idom/testing.py index 6f8a03260..64bd5b02e 100644 --- a/src/idom/testing.py +++ b/src/idom/testing.py @@ -4,7 +4,7 @@ import logging import re import shutil -from contextlib import contextmanager +from contextlib import AsyncExitStack, contextmanager from functools import wraps from traceback import format_exception from types import TracebackType @@ -12,7 +12,6 @@ Any, Callable, Dict, - Generic, Iterator, List, NoReturn, @@ -26,18 +25,17 @@ from uuid import uuid4 from weakref import ref -from selenium.webdriver.chrome.options import Options as ChromeOptions -from selenium.webdriver.chrome.webdriver import WebDriver as Chrome -from selenium.webdriver.common.options import BaseOptions -from selenium.webdriver.remote.webdriver import WebDriver +from playwright.async_api import Browser, BrowserContext, Page, async_playwright +from idom import html from idom.config import IDOM_WEB_MODULES_DIR from idom.core.events import EventHandler, to_event_handler_function from idom.core.hooks import LifeCycleHook, current_hook -from idom.server import any as any_server +from idom.server import default as default_server from idom.server.types import ServerImplementation from idom.server.utils import find_available_port -from idom.widgets import MountFunc, hotswap +from idom.types import RootComponentConstructor +from idom.widgets import hotswap from .log import ROOT_LOGGER @@ -45,42 +43,93 @@ __all__ = [ "find_available_port", "create_simple_selenium_web_driver", - "ServerMountPoint", + "ServerFixture", ] +_Self = TypeVar("_Self") -def create_simple_selenium_web_driver( - driver_type: Type[WebDriver] = Chrome, - driver_options: BaseOptions = ChromeOptions(), - implicit_wait_timeout: float = 10.0, - page_load_timeout: float = 5.0, - window_size: Tuple[int, int] = (1080, 800), -) -> WebDriver: - driver = driver_type(options=driver_options) - driver.set_window_size(*window_size) - driver.set_page_load_timeout(page_load_timeout) - driver.implicitly_wait(implicit_wait_timeout) +class DisplayFixture: + """A fixture for running web-based tests using ``playwright``""" - return driver + _exit_stack: AsyncExitStack + _context: BrowserContext + def __init__( + self, + server: ServerFixture | None = None, + browser: Browser | None = None, + ) -> None: + if server is not None: + self.server = server + if browser is not None: + self.browser = browser + + self._next_view_id = 0 + + async def show( + self, + component: RootComponentConstructor, + query: dict[str, Any] | None = None, + ) -> Page: + self._next_view_id += 1 + view_id = f"display-{self._next_view_id}" + self.server.mount(lambda: html.div({"id": view_id}, component())) + + page = await self._context.new_page() + + await page.goto(self.server.url(query=query)) + await page.wait_for_selector(f"#{view_id}") + + return page + + async def __aenter__(self: _Self) -> _Self: + es = self._exit_stack = AsyncExitStack() + + if not hasattr(self, "browser"): + pw = await es.enter_async_context(async_playwright()) + self.browser = await pw.chromium.launch() + + self._context = await self.browser.new_context() + es.push_async_callback(self._context.close) -_Self = TypeVar("_Self", bound="ServerMountPoint") + if not hasattr(self, "server"): + self.server = ServerFixture(**self._server_options) + await es.enter_async_context(self.server) + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + await self._exit_stack.aclose() + + +class ServerFixture: + """A test fixture for running a server and imperatively displaying views -class ServerMountPoint: - """A context manager for imperatively mounting views to a render server when testing""" + This fixture is typically used alongside async web drivers like ``playwight``. + + Example: + .. code-block:: + + async with ServerFixture() as server: + server.mount(MyComponent) + """ _log_handler: "_LogRecordCaptor" _server_future: asyncio.Task[Any] def __init__( self, - server_implementation: ServerImplementation[Any] = any_server, host: str = "127.0.0.1", port: Optional[int] = None, + implementation: ServerImplementation[Any] = default_server, ) -> None: - self.server_implementation = server_implementation + self.server_implementation = implementation self.host = host self.port = port or find_available_port(host, allow_reuse_waiting_ports=False) self.mount, self._root_component = hotswap() @@ -150,7 +199,12 @@ async def __aenter__(self: _Self) -> _Self: ) ) - await asyncio.wait_for(started.wait(), timeout=3) + try: + await asyncio.wait_for(started.wait(), timeout=3) + except Exception: + # see if we can await the future for a more helpful error + await asyncio.wait_for(self._server_future, timeout=3) + raise return self @@ -164,6 +218,11 @@ async def __aexit__( self._server_future.cancel() + try: + await asyncio.wait_for(self._server_future, timeout=3) + except asyncio.CancelledError: + pass + logging.getLogger().removeHandler(self._log_handler) logged_errors = self.list_logged_exceptions(del_log_records=False) if logged_errors: # pragma: no cover diff --git a/tests/__init__.py b/tests/__init__.py index f4695fa3f..e69de29bb 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +0,0 @@ -from .utils.browser import Display diff --git a/tests/conftest.py b/tests/conftest.py index ccb638c40..99b98291b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,59 +1,50 @@ from __future__ import annotations -import os -from dataclasses import dataclass -from typing import Awaitable, Callable, List - import pytest from _pytest.config import Config from _pytest.config.argparsing import Parser +from playwright.async_api import async_playwright -import idom -from idom.testing import ServerMountPoint, clear_idom_web_modules_dir -from tests.utils.browser import launch_browser, open_display - - -def pytest_collection_modifyitems( - session: pytest.Session, config: Config, items: List[pytest.Item] -) -> None: - _skip_web_driver_tests_on_windows(items) +from idom.server.utils import all_implementations +from idom.testing import DisplayFixture, ServerFixture, clear_idom_web_modules_dir +from tests.tooling.loop import open_event_loop def pytest_addoption(parser: Parser) -> None: parser.addoption( - "--headless", - dest="headless", + "--open-browser", + dest="open_browser", action="store_true", - help="Whether to run browser tests in headless mode.", + help="Open a browser window when runnging web-based tests", ) @pytest.fixture -async def display(browser): - async with open_display(browser, idom.server.any) as display: +async def display(server, browser): + async with DisplayFixture(server, browser) as display: yield display -@pytest.fixture +@pytest.fixture(scope="session", params=list(all_implementations())) +async def server(request): + async with ServerFixture(implementation=request.param) as server: + yield server + + +@pytest.fixture(scope="session") async def browser(pytestconfig: Config): - async with launch_browser(headless=bool(pytestconfig.option.headless)) as browser: - yield browser + async with async_playwright() as pw: + yield await pw.chromium.launch( + headless=not bool(pytestconfig.option.open_browser) + ) -@pytest.fixture(autouse=True) -def _clear_web_modules_dir_after_test(): - clear_idom_web_modules_dir() +@pytest.fixture(scope="session") +def event_loop(): + with open_event_loop() as loop: + yield loop -def _skip_web_driver_tests_on_windows(items: List[pytest.Item]) -> None: - if os.name == "nt": - for item in items: - if isinstance(item, pytest.Function): - if {"display", "driver", "create_driver"}.intersection( - item.fixturenames - ): - item.add_marker( - pytest.mark.skip( - reason="WebDriver tests are not working on Windows", - ) - ) +@pytest.fixture(autouse=True) +def clear_web_modules_dir_after_test(): + clear_idom_web_modules_dir() diff --git a/tests/test_client.py b/tests/test_client.py index 2601fae2b..64bb7a98b 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -3,8 +3,8 @@ from pathlib import Path import idom -from idom.testing import ServerMountPoint -from tests.utils.browser import send_keys +from idom.testing import ServerFixture +from tests.tooling.browser import send_keys JS_DIR = Path(__file__).parent / "js" @@ -18,7 +18,7 @@ async def test_automatic_reconnect(create_driver): def OldComponent(): return idom.html.p({"id": "old-component"}, "old") - mount_point = ServerMountPoint() + mount_point = ServerFixture() async with mount_point: mount_point.mount(OldComponent) diff --git a/tests/test_core/test_hooks.py b/tests/test_core/test_hooks.py index 9c82cd16f..3364bb687 100644 --- a/tests/test_core/test_hooks.py +++ b/tests/test_core/test_hooks.py @@ -10,7 +10,7 @@ from idom.core.serve import render_json_patch from idom.testing import HookCatcher, assert_idom_logged from idom.utils import Ref -from tests.utils.asserts import assert_same_items +from tests.tooling.asserts import assert_same_items async def test_must_be_rendering_in_layout_to_use_hooks(): diff --git a/tests/test_core/test_layout.py b/tests/test_core/test_layout.py index 043d271ef..74f690e39 100644 --- a/tests/test_core/test_layout.py +++ b/tests/test_core/test_layout.py @@ -21,7 +21,7 @@ capture_idom_logs, ) from idom.utils import Ref -from tests.utils.asserts import assert_same_items +from tests.tooling.asserts import assert_same_items @pytest.fixture(autouse=True) diff --git a/tests/test_sample.py b/tests/test_sample.py index d3a2e29c3..0261dd451 100644 --- a/tests/test_sample.py +++ b/tests/test_sample.py @@ -1,7 +1,7 @@ from idom.sample import App, run_sample_app from idom.server.utils import find_available_port -from tests import Display +from idom.testing import DisplayFixture -async def test_sample_app(display: Display): +async def test_sample_app(display: DisplayFixture): await display.show(App) diff --git a/tests/test_server/test_common/test_multiview.py b/tests/test_server/test_common/test_multiview.py index e5a908a6e..e2b56a03e 100644 --- a/tests/test_server/test_common/test_multiview.py +++ b/tests/test_server/test_common/test_multiview.py @@ -6,8 +6,8 @@ from idom.server import flask as idom_flask from idom.server import sanic as idom_sanic from idom.server import starlette as idom_starlette -from idom.testing import ServerMountPoint -from tests.utils.browser import no_such_element +from idom.testing import ServerFixture +from tests.tooling.browser import no_such_element @pytest.fixture( @@ -15,7 +15,7 @@ ids=lambda cls: f"{cls.__module__}.{cls.__name__}", ) async def server_mount_point(request): - async with ServerMountPoint(request.param) as mount_point: + async with ServerFixture(request.param) as mount_point: yield mount_point diff --git a/tests/test_server/test_common/test_per_client_state.py b/tests/test_server/test_common/test_per_client_state.py index 29bd2f26f..3117f674c 100644 --- a/tests/test_server/test_common/test_per_client_state.py +++ b/tests/test_server/test_common/test_per_client_state.py @@ -6,7 +6,7 @@ from idom.server import sanic as idom_sanic from idom.server import starlette as idom_starlette from idom.server import tornado as idom_tornado -from idom.testing import ServerMountPoint +from idom.testing import ServerFixture @pytest.fixture( @@ -22,7 +22,7 @@ ids=lambda cls: f"{cls.__module__}.{cls.__name__}", ) def server_mount_point(request): - with ServerMountPoint(request.param) as mount_point: + with ServerFixture(request.param) as mount_point: yield mount_point diff --git a/tests/test_server/test_common/test_shared_state_client.py b/tests/test_server/test_common/test_shared_state_client.py index 440e73d07..07c92c9eb 100644 --- a/tests/test_server/test_common/test_shared_state_client.py +++ b/tests/test_server/test_common/test_shared_state_client.py @@ -7,7 +7,7 @@ from idom.server import fastapi as idom_fastapi from idom.server import sanic as idom_sanic from idom.server import starlette as idom_starlette -from idom.testing import ServerMountPoint +from idom.testing import ServerFixture @pytest.fixture( @@ -21,7 +21,7 @@ ids=lambda cls: f"{cls.__module__}.{cls.__name__}", ) def server_mount_point(request): - with ServerMountPoint(request.param, sync_views=True) as mount_point: + with ServerFixture(request.param, sync_views=True) as mount_point: yield mount_point diff --git a/tests/test_web/test_module.py b/tests/test_web/test_module.py index 1e31f5d0a..d7fb25726 100644 --- a/tests/test_web/test_module.py +++ b/tests/test_web/test_module.py @@ -8,7 +8,7 @@ import idom from idom.server.sanic import PerClientStateServer -from idom.testing import ServerMountPoint, assert_idom_did_not_log, assert_idom_logged +from idom.testing import ServerFixture, assert_idom_did_not_log, assert_idom_logged from idom.web.module import NAME_SOURCE, WebModule @@ -67,7 +67,7 @@ def test_module_from_url(driver): def ShowSimpleButton(): return SimpleButton({"id": "my-button"}) - with ServerMountPoint(PerClientStateServer, app=app) as mount_point: + with ServerFixture(PerClientStateServer, app=app) as mount_point: mount_point.mount(ShowSimpleButton) driver.get(mount_point.url()) driver.find_element("id", "my-button") diff --git a/tests/test_widgets.py b/tests/test_widgets.py index 00791aa7b..7818753c5 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -4,7 +4,7 @@ from selenium.webdriver.common.keys import Keys import idom -from tests.utils.browser import send_keys +from tests.tooling.browser import send_keys HERE = Path(__file__).parent diff --git a/tests/utils/__init__.py b/tests/tooling/__init__.py similarity index 100% rename from tests/utils/__init__.py rename to tests/tooling/__init__.py diff --git a/tests/utils/asserts.py b/tests/tooling/asserts.py similarity index 100% rename from tests/utils/asserts.py rename to tests/tooling/asserts.py diff --git a/tests/tooling/loop.py b/tests/tooling/loop.py new file mode 100644 index 000000000..87fa0bcd2 --- /dev/null +++ b/tests/tooling/loop.py @@ -0,0 +1,45 @@ +import asyncio +from contextlib import contextmanager +from typing import Iterator + + +@contextmanager +def open_event_loop() -> Iterator[asyncio.AbstractEventLoop]: + loop = asyncio.new_event_loop() + try: + asyncio.set_event_loop(loop) + loop.set_debug(True) + yield loop + finally: + try: + _cancel_all_tasks(loop) + loop.run_until_complete(loop.shutdown_asyncgens()) + loop.run_until_complete(loop.shutdown_default_executor()) + finally: + asyncio.set_event_loop(None) + loop.close() + + +def _cancel_all_tasks(loop: asyncio.AbstractEventLoop) -> None: + to_cancel = asyncio.all_tasks(loop) + if not to_cancel: + return + + for task in to_cancel: + task.cancel() + + loop.run_until_complete( + asyncio.gather(*to_cancel, loop=loop, return_exceptions=True) + ) + + for task in to_cancel: + if task.cancelled(): + continue + if task.exception() is not None: + loop.call_exception_handler( + { + "message": "unhandled exception during event loop shutdown", + "exception": task.exception(), + "task": task, + } + ) diff --git a/tests/utils/browser.py b/tests/utils/browser.py deleted file mode 100644 index f0a6cca54..000000000 --- a/tests/utils/browser.py +++ /dev/null @@ -1,47 +0,0 @@ -from __future__ import annotations - -from contextlib import asynccontextmanager -from typing import Any - -from playwright.async_api import Browser, Page, async_playwright - -from idom import html -from idom.server import any as any_server -from idom.server.types import ServerImplementation -from idom.testing import ServerMountPoint -from idom.types import RootComponentConstructor - - -@asynccontextmanager -async def launch_browser(headless: bool) -> Browser: - async with async_playwright() as p: - yield await p.chromium.launch(headless=headless) - - -@asynccontextmanager -async def open_display( - browser: Browser, implementation: ServerImplementation[Any] = any_server -) -> Display: - async with ServerMountPoint(implementation) as mount: - page = await browser.new_page() - try: - yield Display(page, mount) - finally: - await page.close() - - -class Display: - def __init__(self, page: Page, mount: ServerMountPoint) -> None: - self.page = page - self.mount = mount - self._next_id = 0 - - async def show( - self, component: RootComponentConstructor, query: dict[str, Any] | None = None - ) -> str: - self._next_id += 1 - component_id = f"display-{self._next_id}" - self.mount.mount(lambda: html.div({"id": component_id}, component())) - await self.page.goto(self.mount.url(query=query)) - await self.page.wait_for_selector(f"#{component_id}") - return component_id From 6c342e2ccc732bd3b1eb7411dcbc3a0f93e8be6d Mon Sep 17 00:00:00 2001 From: rmorshea Date: Fri, 18 Mar 2022 23:23:40 -0700 Subject: [PATCH 09/58] get some tests working --- src/idom/testing.py | 63 +++++++++++++++++++++- tests/test_widgets.py | 123 +++++++++++++++++++++++------------------- 2 files changed, 130 insertions(+), 56 deletions(-) diff --git a/src/idom/testing.py b/src/idom/testing.py index 64bd5b02e..99864f286 100644 --- a/src/idom/testing.py +++ b/src/idom/testing.py @@ -2,20 +2,27 @@ import asyncio import logging +import operator import re import shutil +import time from contextlib import AsyncExitStack, contextmanager -from functools import wraps +from functools import partial, wraps +from inspect import isawaitable, iscoroutinefunction from traceback import format_exception from types import TracebackType from typing import ( Any, + Awaitable, Callable, + Coroutine, Dict, + Generic, Iterator, List, NoReturn, Optional, + Sequence, Tuple, Type, TypeVar, @@ -26,6 +33,7 @@ from weakref import ref from playwright.async_api import Browser, BrowserContext, Page, async_playwright +from typing_extensions import ParamSpec from idom import html from idom.config import IDOM_WEB_MODULES_DIR @@ -49,6 +57,59 @@ _Self = TypeVar("_Self") +def assert_same_items(left: Sequence[Any], right: Sequence[Any]) -> None: + """Check that two unordered sequences are equal (only works if reprs are equal)""" + sorted_left = list(sorted(left, key=repr)) + sorted_right = list(sorted(right, key=repr)) + assert sorted_left == sorted_right + + +_P = ParamSpec("_P") +_R = TypeVar("_R") +_DEFAULT_TIMEOUT = 3.0 + + +class poll(Generic[_R]): + """Wait until the result of an sync or async function meets some condition""" + + def __init__( + self, + function: Callable[_P, Awaitable[_R] | _R], + *args: _P.args, + **kwargs: _P.kwargs, + ) -> None: + if iscoroutinefunction(function): + + async def until( + condition: Callable[[_R], bool], timeout: float = _DEFAULT_TIMEOUT + ) -> _R: + started_at = time.time() + while not condition(await function(*args, **kwargs)): + if (time.time() - started_at) > timeout: + raise TimeoutError() + + else: + + def until( + condition: Callable[[_R], bool] | Any, timeout: float = _DEFAULT_TIMEOUT + ) -> _R: + started_at = time.time() + while not condition(function(*args, **kwargs)): + if (time.time() - started_at) > timeout: + raise TimeoutError() + + self.until: Callable[[Callable[[_R], bool]], Any] = until + """Check that the coroutines result meets a condition within the timeout""" + + def eq(self, right: Any, timeout: float = _DEFAULT_TIMEOUT) -> Any: + """Wait until the result is equal to the given value""" + return self.until(lambda left: left == right, timeout) + + def ne(self, right: Any, timeout: float = _DEFAULT_TIMEOUT) -> Any: + """Wait until the result is not equal to the given value""" + return self.until(lambda left: left != right, timeout) + + class DisplayFixture: """A fixture for running web-based tests using ``playwright``""" diff --git a/tests/test_widgets.py b/tests/test_widgets.py index 7818753c5..d79a50ca0 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -1,10 +1,9 @@ +import asyncio from base64 import b64encode from pathlib import Path -from selenium.webdriver.common.keys import Keys - import idom -from tests.tooling.browser import send_keys +from idom.testing import DisplayFixture, poll HERE = Path(__file__).parent @@ -14,7 +13,7 @@ def test_multiview_repr(): assert str(idom.widgets.MultiViewMount({})) == "MultiViewMount({})" -def test_hostwap_update_on_change(driver, display): +async def test_hostwap_update_on_change(display: DisplayFixture): """Ensure shared hotswapping works This basically means that previously rendered views of a hotswap component get updated @@ -48,15 +47,15 @@ async def on_click(event): return idom.html.div(incr, hotswap_view) - display(ButtonSwapsDivs) + page = await display.show(ButtonSwapsDivs) - client_incr_button = driver.find_element("id", "incr-button") + client_incr_button = await page.wait_for_selector("#incr-button") - driver.find_element("id", "hotswap-1") - client_incr_button.click() - driver.find_element("id", "hotswap-2") - client_incr_button.click() - driver.find_element("id", "hotswap-3") + await page.wait_for_selector("#hotswap-1") + await client_incr_button.click() + await page.wait_for_selector("#hotswap-2") + await client_incr_button.click() + await page.wait_for_selector("#hotswap-3") IMAGE_SRC_BYTES = b""" @@ -67,43 +66,48 @@ async def on_click(event): BASE64_IMAGE_SRC = b64encode(IMAGE_SRC_BYTES).decode() -def test_image_from_string(driver, display): +async def test_image_from_string(display: DisplayFixture): src = IMAGE_SRC_BYTES.decode() - display(lambda: idom.widgets.image("svg", src, {"id": "a-circle-1"})) - client_img = driver.find_element("id", "a-circle-1") - assert BASE64_IMAGE_SRC in client_img.get_attribute("src") + page = await display.show( + lambda: idom.widgets.image("svg", src, {"id": "a-circle-1"}) + ) + client_img = await page.wait_for_selector("#a-circle-1") + assert BASE64_IMAGE_SRC in (await client_img.get_attribute("src")) -def test_image_from_bytes(driver, display): +async def test_image_from_bytes(display: DisplayFixture): src = IMAGE_SRC_BYTES - display(lambda: idom.widgets.image("svg", src, {"id": "a-circle-1"})) - client_img = driver.find_element("id", "a-circle-1") - assert BASE64_IMAGE_SRC in client_img.get_attribute("src") + page = await display.show( + lambda: idom.widgets.image("svg", src, {"id": "a-circle-1"}) + ) + client_img = await page.wait_for_selector("#a-circle-1") + assert BASE64_IMAGE_SRC in (await client_img.get_attribute("src")) -def test_use_linked_inputs(driver, driver_wait, display): +async def test_use_linked_inputs(display: DisplayFixture): @idom.component def SomeComponent(): i_1, i_2 = idom.widgets.use_linked_inputs([{"id": "i_1"}, {"id": "i_2"}]) return idom.html.div(i_1, i_2) - display(SomeComponent) + page = await display.show(SomeComponent) - input_1 = driver.find_element("id", "i_1") - input_2 = driver.find_element("id", "i_2") + input_1 = await page.wait_for_selector("#i_1") + input_2 = await page.wait_for_selector("#i_2") - send_keys(input_1, "hello") + await input_1.type("hello", delay=20) - driver_wait.until(lambda d: input_1.get_attribute("value") == "hello") - driver_wait.until(lambda d: input_2.get_attribute("value") == "hello") + assert (await input_1.evaluate("e => e.value")) == "hello" + assert (await input_2.evaluate("e => e.value")) == "hello" - send_keys(input_2, " world") + await input_2.focus() + await input_2.type(" world", delay=20) - driver_wait.until(lambda d: input_1.get_attribute("value") == "hello world") - driver_wait.until(lambda d: input_2.get_attribute("value") == "hello world") + assert (await input_1.evaluate("e => e.value")) == "hello world" + assert (await input_2.evaluate("e => e.value")) == "hello world" -def test_use_linked_inputs_on_change(driver, driver_wait, display): +async def test_use_linked_inputs_on_change(display: DisplayFixture): value = idom.Ref(None) @idom.component @@ -114,21 +118,24 @@ def SomeComponent(): ) return idom.html.div(i_1, i_2) - display(SomeComponent) + page = await display.show(SomeComponent) + + input_1 = await page.wait_for_selector("#i_1") + input_2 = await page.wait_for_selector("#i_2") - input_1 = driver.find_element("id", "i_1") - input_2 = driver.find_element("id", "i_2") + await input_1.type("hello", delay=20) - send_keys(input_1, "hello") + poll_value = poll(lambda: value.current) - driver_wait.until(lambda d: value.current == "hello") + poll_value.eq("hello") - send_keys(input_2, " world") + await input_2.focus() + await input_2.type(" world", delay=20) - driver_wait.until(lambda d: value.current == "hello world") + poll_value.eq("hello world") -def test_use_linked_inputs_on_change_with_cast(driver, driver_wait, display): +async def test_use_linked_inputs_on_change_with_cast(display: DisplayFixture): value = idom.Ref(None) @idom.component @@ -138,21 +145,24 @@ def SomeComponent(): ) return idom.html.div(i_1, i_2) - display(SomeComponent) + page = await display.show(SomeComponent) - input_1 = driver.find_element("id", "i_1") - input_2 = driver.find_element("id", "i_2") + input_1 = await page.wait_for_selector("#i_1") + input_2 = await page.wait_for_selector("#i_2") - send_keys(input_1, "1") + await input_1.type("1") - driver_wait.until(lambda d: value.current == 1) + poll_value = poll(lambda: value.current) - send_keys(input_2, "2") + poll_value.eq(1) - driver_wait.until(lambda d: value.current == 12) + await input_2.focus() + await input_2.type("2") + poll_value.eq(12) -def test_use_linked_inputs_ignore_empty(driver, driver_wait, display): + +async def test_use_linked_inputs_ignore_empty(display: DisplayFixture): value = idom.Ref(None) @idom.component @@ -164,19 +174,22 @@ def SomeComponent(): ) return idom.html.div(i_1, i_2) - display(SomeComponent) + page = await display.show(SomeComponent) + + input_1 = await page.wait_for_selector("#i_1") + input_2 = await page.wait_for_selector("#i_2") - input_1 = driver.find_element("id", "i_1") - input_2 = driver.find_element("id", "i_2") + await input_1.type("1") - send_keys(input_1, "1") + poll_value = poll(lambda: value.current) - driver_wait.until(lambda d: value.current == "1") + poll_value.eq("1") - send_keys(input_2, Keys.BACKSPACE) + await input_2.focus() + await input_2.press("Backspace") - assert value.current == "1" + poll_value.eq("1") - send_keys(input_1, "2") + await input_1.type("2") - driver_wait.until(lambda d: value.current == "2") + poll_value.eq("2") From 3d78eea6c60ee53cce4a50c704d5812b1f30462b Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sat, 19 Mar 2022 00:09:58 -0700 Subject: [PATCH 10/58] slight optimization --- src/idom/testing.py | 33 +++++++++++++++++---------------- tests/conftest.py | 13 +++++++++++-- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/src/idom/testing.py b/src/idom/testing.py index 99864f286..ccbfbc5ce 100644 --- a/src/idom/testing.py +++ b/src/idom/testing.py @@ -114,18 +114,19 @@ class DisplayFixture: """A fixture for running web-based tests using ``playwright``""" _exit_stack: AsyncExitStack - _context: BrowserContext def __init__( self, server: ServerFixture | None = None, - browser: Browser | None = None, + driver: Browser | BrowserContext | Page | None = None, ) -> None: if server is not None: self.server = server - if browser is not None: - self.browser = browser - + if driver is not None: + if isinstance(driver, Page): + self._page = driver + else: + self._browser = browser self._next_view_id = 0 async def show( @@ -137,22 +138,21 @@ async def show( view_id = f"display-{self._next_view_id}" self.server.mount(lambda: html.div({"id": view_id}, component())) - page = await self._context.new_page() - - await page.goto(self.server.url(query=query)) - await page.wait_for_selector(f"#{view_id}") + await self._page.goto(self.server.url(query=query)) + await self._page.wait_for_selector(f"#{view_id}") - return page + return self._page async def __aenter__(self: _Self) -> _Self: es = self._exit_stack = AsyncExitStack() - if not hasattr(self, "browser"): - pw = await es.enter_async_context(async_playwright()) - self.browser = await pw.chromium.launch() - - self._context = await self.browser.new_context() - es.push_async_callback(self._context.close) + if not hasattr(self, "_page"): + if not hasattr(self, "_browser"): + pw = await es.enter_async_context(async_playwright()) + browser = await pw.chromium.launch() + else: + browser = self._browser + self._page = await browser.new_page() if not hasattr(self, "server"): self.server = ServerFixture(**self._server_options) @@ -166,6 +166,7 @@ async def __aexit__( exc_value: BaseException | None, traceback: TracebackType | None, ) -> None: + self.server.mount(None) await self._exit_stack.aclose() diff --git a/tests/conftest.py b/tests/conftest.py index 99b98291b..e99aa3df8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,8 +20,8 @@ def pytest_addoption(parser: Parser) -> None: @pytest.fixture -async def display(server, browser): - async with DisplayFixture(server, browser) as display: +async def display(server, page): + async with DisplayFixture(server, page) as display: yield display @@ -31,6 +31,15 @@ async def server(request): yield server +@pytest.fixture(scope="session") +async def page(browser): + pg = await browser.new_page() + try: + yield pg + finally: + await pg.close() + + @pytest.fixture(scope="session") async def browser(pytestconfig: Config): async with async_playwright() as pw: From 8e0d01011780b2a8bb6f5f9bbeaa5a05f0bfe473 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sun, 20 Mar 2022 16:21:37 -0700 Subject: [PATCH 11/58] misc minor improvements --- docs/source/guides/getting-started/index.rst | 2 +- src/idom/__init__.py | 5 ++-- src/idom/sample.py | 16 ----------- src/idom/server/default.py | 28 +++++++++++++++++--- src/idom/server/utils.py | 25 +++-------------- tests/test_sample.py | 7 ++--- 6 files changed, 35 insertions(+), 48 deletions(-) diff --git a/docs/source/guides/getting-started/index.rst b/docs/source/guides/getting-started/index.rst index 0a5ed4daf..5aa150a1d 100644 --- a/docs/source/guides/getting-started/index.rst +++ b/docs/source/guides/getting-started/index.rst @@ -56,7 +56,7 @@ To check that everything is working you can run the sample application: .. code-block:: bash - python -c "import idom; idom.run_sample_app(open_browser=True)" + python -c "import idom; idom.run(idom.sample.App, open_browser=True)" This should automatically open up a browser window to a page that looks like this: diff --git a/src/idom/__init__.py b/src/idom/__init__.py index 1efe3f228..0f2f3168a 100644 --- a/src/idom/__init__.py +++ b/src/idom/__init__.py @@ -1,4 +1,4 @@ -from . import config, html, log, types, web +from . import config, html, log, sample, types, web from .core import hooks from .core.component import component from .core.events import event @@ -15,7 +15,6 @@ from .core.layout import Layout from .core.serve import Stop from .core.vdom import vdom -from .sample import run_sample_app from .server.utils import run from .utils import Ref, html_to_vdom from .widgets import hotswap, multiview @@ -37,9 +36,9 @@ "log", "multiview", "Ref", - "run_sample_app", "run", "Stop", + "sample", "types", "use_callback", "use_context", diff --git a/src/idom/sample.py b/src/idom/sample.py index d1ac64590..905ce2212 100644 --- a/src/idom/sample.py +++ b/src/idom/sample.py @@ -3,7 +3,6 @@ from . import html from .core.component import component from .core.types import VdomDict -from .server.utils import run @component @@ -20,18 +19,3 @@ def App() -> VdomDict: " to learn more.", ), ) - - -def run_sample_app( - host: str = "127.0.0.1", - port: int | None = None, - open_browser: bool = False, -) -> None: - """Run a sample application. - - Args: - host: host where the server should run - port: the port on the host to serve from - open_browser: whether to open a browser window after starting the server - """ - run(App, host, port, open_browser=open_browser) diff --git a/src/idom/server/default.py b/src/idom/server/default.py index 95ac97d00..ade4836eb 100644 --- a/src/idom/server/default.py +++ b/src/idom/server/default.py @@ -5,23 +5,43 @@ from idom.types import RootComponentConstructor -from .utils import default_implementation +from .types import ServerImplementation +from .utils import all_implementations def configure(app: Any, component: RootComponentConstructor) -> None: """Configure the given app instance to display the given component""" - return default_implementation().configure(app, component) + return _default_implementation().configure(app, component) def create_development_app() -> Any: """Create an application instance for development purposes""" - return default_implementation().create_development_app() + return _default_implementation().create_development_app() async def serve_development_app( app: Any, host: str, port: int, started: asyncio.Event ) -> None: """Run an application using a development server""" - return await default_implementation().serve_development_app( + return await _default_implementation().serve_development_app( app, host, port, started ) + + +def _default_implementation() -> ServerImplementation[Any]: + """Get the first available server implementation""" + global _DEFAULT_IMPLEMENTATION + + if _DEFAULT_IMPLEMENTATION is not None: + return _DEFAULT_IMPLEMENTATION + + try: + implementation = next(all_implementations()) + except StopIteration: + raise RuntimeError("No built-in server implementation installed.") + else: + _DEFAULT_IMPLEMENTATION = implementation + return implementation + + +_DEFAULT_IMPLEMENTATION: ServerImplementation[Any] | None = None diff --git a/src/idom/server/utils.py b/src/idom/server/utils.py index ecf77ec02..d41c0b82d 100644 --- a/src/idom/server/utils.py +++ b/src/idom/server/utils.py @@ -2,7 +2,6 @@ import asyncio import socket -import sys import warnings import webbrowser from contextlib import closing @@ -32,7 +31,7 @@ def run( host: str = "127.0.0.1", port: int | None = None, open_browser: bool = True, - implementation: ServerImplementation[Any] = sys.modules[__name__], + implementation: ServerImplementation[Any] | None = None, ) -> None: """Run a component with a development server""" @@ -42,6 +41,9 @@ def run( stacklevel=2, ) + if implementation is None: + from . import default as implementation + app = implementation.create_development_app() implementation.configure(app, component) @@ -91,25 +93,6 @@ def find_available_port( ) -def default_implementation() -> ServerImplementation[Any]: - """Get the first available server implementation""" - global _DEFAULT_IMPLEMENTATION - - if _DEFAULT_IMPLEMENTATION is not None: - return _DEFAULT_IMPLEMENTATION - - try: - implementation = next(all_implementations()) - except StopIteration: - raise RuntimeError("No built-in server implementation installed.") - else: - _DEFAULT_IMPLEMENTATION = implementation - return implementation - - -_DEFAULT_IMPLEMENTATION: ServerImplementation[Any] | None = None - - def all_implementations() -> Iterator[ServerImplementation[Any]]: """Yield all available server implementations""" for name in SUPPORTED_PACKAGES: diff --git a/tests/test_sample.py b/tests/test_sample.py index 0261dd451..46c8583f5 100644 --- a/tests/test_sample.py +++ b/tests/test_sample.py @@ -1,7 +1,8 @@ -from idom.sample import App, run_sample_app -from idom.server.utils import find_available_port +from idom.sample import App from idom.testing import DisplayFixture async def test_sample_app(display: DisplayFixture): - await display.show(App) + page = await display.show(App) + h1 = await page.wait_for_selector("h1") + assert (await h1.text_content()) == "Sample Application" From e15abba69c12b5483917af47bf7588d08d31fccb Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sun, 20 Mar 2022 18:16:38 -0700 Subject: [PATCH 12/58] fix test_html --- re.txt | 12 +++++++++ src/idom/testing.py | 38 +++++++++++++++++----------- tests/conftest.py | 6 ++--- tests/test_html.py | 58 +++++++++++++++++++++---------------------- tests/test_widgets.py | 14 +++++------ tests/tooling/loop.py | 10 +++++--- 6 files changed, 81 insertions(+), 57 deletions(-) create mode 100644 re.txt diff --git a/re.txt b/re.txt new file mode 100644 index 000000000..b456f8fe9 --- /dev/null +++ b/re.txt @@ -0,0 +1,12 @@ +def test_(.*?)\(.*driver.*\): +async def test_$1(display: DisplayFixture): + +display\((.*?)\) +page = await display.show($1) + +driver\.find_element\("id", "(.*?)"\) +await display.wait_for_selector("#$1") + +driver_wait\.until\(lambda .*: (\w*)\.get_attribute\("(.*?)"\) == (.*)\) +assert (await $1.evaluate("node => node['$2']")) == $3 +await poll($1.evaluate, "node => node['$2']").until_equals($3) diff --git a/src/idom/testing.py b/src/idom/testing.py index ccbfbc5ce..40f32bd67 100644 --- a/src/idom/testing.py +++ b/src/idom/testing.py @@ -82,33 +82,41 @@ def __init__( async def until( condition: Callable[[_R], bool], timeout: float = _DEFAULT_TIMEOUT - ) -> _R: + ) -> None: started_at = time.time() - while not condition(await function(*args, **kwargs)): - if (time.time() - started_at) > timeout: - raise TimeoutError() + while True: + result = await function(*args, **kwargs) + if condition(result): + break + elif (time.time() - started_at) > timeout: + raise TimeoutError( + f"Condition not met within {timeout} " + f"seconds - last value was {result!r}" + ) else: def until( condition: Callable[[_R], bool] | Any, timeout: float = _DEFAULT_TIMEOUT - ) -> _R: + ) -> None: started_at = time.time() - while not condition(function(*args, **kwargs)): - if (time.time() - started_at) > timeout: - raise TimeoutError() + while True: + result = function(*args, **kwargs) + if condition(result): + break + elif (time.time() - started_at) > timeout: + raise TimeoutError( + f"Condition not met within {timeout} " + f"seconds - last value was {result!r}" + ) self.until: Callable[[Callable[[_R], bool]], Any] = until """Check that the coroutines result meets a condition within the timeout""" - def eq(self, right: Any, timeout: float = _DEFAULT_TIMEOUT) -> Any: + def until_equals(self, right: Any, timeout: float = _DEFAULT_TIMEOUT) -> Any: """Wait until the result is equal to the given value""" return self.until(lambda left: left == right, timeout) - def ne(self, right: Any, timeout: float = _DEFAULT_TIMEOUT) -> Any: - """Wait until the result is not equal to the given value""" - return self.until(lambda left: left != right, timeout) - class DisplayFixture: """A fixture for running web-based tests using ``playwright``""" @@ -126,7 +134,7 @@ def __init__( if isinstance(driver, Page): self._page = driver else: - self._browser = browser + self._browser = driver self._next_view_id = 0 async def show( @@ -139,7 +147,7 @@ async def show( self.server.mount(lambda: html.div({"id": view_id}, component())) await self._page.goto(self.server.url(query=query)) - await self._page.wait_for_selector(f"#{view_id}") + await self._page.wait_for_selector(f"#{view_id}", state="attached") return self._page diff --git a/tests/conftest.py b/tests/conftest.py index e99aa3df8..dd2e980a7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -25,9 +25,9 @@ async def display(server, page): yield display -@pytest.fixture(scope="session", params=list(all_implementations())) -async def server(request): - async with ServerFixture(implementation=request.param) as server: +@pytest.fixture(scope="session") +async def server(): + async with ServerFixture() as server: yield server diff --git a/tests/test_html.py b/tests/test_html.py index cc6521efa..cfedef29a 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -1,11 +1,12 @@ import pytest from idom import component, config, html, use_state +from idom.testing import DisplayFixture, poll from idom.utils import Ref -def use_toggle(): - state, set_state = use_state(True) +def use_toggle(initial=True): + state, set_state = use_state(initial) return state, lambda: set_state(not state) @@ -14,20 +15,15 @@ def use_counter(initial_value): return state, lambda: set_state(state + 1) -def test_script_mount_unmount(driver, driver_wait, display): +async def test_script_mount_unmount(display: DisplayFixture): toggle_is_mounted = Ref() @component def Root(): is_mounted, toggle_is_mounted.current = use_toggle() - if is_mounted: - el = HasScript() - else: - el = html.div() - return html.div( html.div({"id": "mount-state", "data-value": False}), - el, + HasScript() if is_mounted else html.div(), ) @component @@ -43,22 +39,23 @@ def HasScript(): }""" ) - display(Root) + page = await display.show(Root) - mount_state = driver.find_element("id", "mount-state") + mount_state = await page.wait_for_selector("#mount-state", state="attached") + poll_mount_state = poll(mount_state.get_attribute, "data-value") - driver_wait.until(lambda d: mount_state.get_attribute("data-value") == "true") + await poll_mount_state.until_equals("true") toggle_is_mounted.current() - driver_wait.until(lambda d: mount_state.get_attribute("data-value") == "false") + await poll_mount_state.until_equals("false") toggle_is_mounted.current() - driver_wait.until(lambda d: mount_state.get_attribute("data-value") == "true") + await poll_mount_state.until_equals("true") -def test_script_re_run_on_content_change(driver, driver_wait, display): +async def test_script_re_run_on_content_change(display: DisplayFixture): incr_count = Ref() @component @@ -77,26 +74,29 @@ def HasScript(): ), ) - display(HasScript) + page = await display.show(HasScript) - mount_count = driver.find_element("id", "mount-count") - unmount_count = driver.find_element("id", "unmount-count") + mount_count = await page.wait_for_selector("#mount-count", state="attached") + poll_mount_count = poll(mount_count.get_attribute, "data-value") - driver_wait.until(lambda d: mount_count.get_attribute("data-value") == "1") - driver_wait.until(lambda d: unmount_count.get_attribute("data-value") == "0") + unmount_count = await page.wait_for_selector("#unmount-count", state="attached") + poll_unmount_count = poll(unmount_count.get_attribute, "data-value") + + await poll_mount_count.until_equals("1") + await poll_unmount_count.until_equals("0") incr_count.current() - driver_wait.until(lambda d: mount_count.get_attribute("data-value") == "2") - driver_wait.until(lambda d: unmount_count.get_attribute("data-value") == "1") + await poll_mount_count.until_equals("2") + await poll_unmount_count.until_equals("1") incr_count.current() - driver_wait.until(lambda d: mount_count.get_attribute("data-value") == "3") - driver_wait.until(lambda d: unmount_count.get_attribute("data-value") == "2") + await poll_mount_count.until_equals("3") + await poll_unmount_count.until_equals("2") -def test_script_from_src(driver, driver_wait, display): +async def test_script_from_src(display: DisplayFixture): incr_src_id = Ref() file_name_template = "__some_js_script_{src_id}__.js" @@ -114,7 +114,7 @@ def HasScript(): ), ) - display(HasScript) + page = await display.show(HasScript) for i in range(1, 4): script_file = config.IDOM_WEB_MODULES_DIR.current / file_name_template.format( @@ -129,9 +129,9 @@ def HasScript(): incr_src_id.current() - run_count = driver.find_element("id", "run-count") - - driver_wait.until(lambda d: (run_count.get_attribute("data-value") == "1")) + run_count = await page.wait_for_selector("#run-count", state="attached") + poll_run_count = poll(run_count.get_attribute, "data-value") + await poll_run_count.until_equals("1") def test_script_may_only_have_one_child(): diff --git a/tests/test_widgets.py b/tests/test_widgets.py index d79a50ca0..cbb1318b0 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -127,12 +127,12 @@ def SomeComponent(): poll_value = poll(lambda: value.current) - poll_value.eq("hello") + poll_value.until_equals("hello") await input_2.focus() await input_2.type(" world", delay=20) - poll_value.eq("hello world") + poll_value.until_equals("hello world") async def test_use_linked_inputs_on_change_with_cast(display: DisplayFixture): @@ -154,12 +154,12 @@ def SomeComponent(): poll_value = poll(lambda: value.current) - poll_value.eq(1) + poll_value.until_equals(1) await input_2.focus() await input_2.type("2") - poll_value.eq(12) + poll_value.until_equals(12) async def test_use_linked_inputs_ignore_empty(display: DisplayFixture): @@ -183,13 +183,13 @@ def SomeComponent(): poll_value = poll(lambda: value.current) - poll_value.eq("1") + poll_value.until_equals("1") await input_2.focus() await input_2.press("Backspace") - poll_value.eq("1") + poll_value.until_equals("1") await input_1.type("2") - poll_value.eq("2") + poll_value.until_equals("2") diff --git a/tests/tooling/loop.py b/tests/tooling/loop.py index 87fa0bcd2..2f13a1824 100644 --- a/tests/tooling/loop.py +++ b/tests/tooling/loop.py @@ -1,8 +1,12 @@ import asyncio +from asyncio import wait_for from contextlib import contextmanager from typing import Iterator +TIMEOUT = 3 + + @contextmanager def open_event_loop() -> Iterator[asyncio.AbstractEventLoop]: loop = asyncio.new_event_loop() @@ -13,8 +17,8 @@ def open_event_loop() -> Iterator[asyncio.AbstractEventLoop]: finally: try: _cancel_all_tasks(loop) - loop.run_until_complete(loop.shutdown_asyncgens()) - loop.run_until_complete(loop.shutdown_default_executor()) + loop.run_until_complete(wait_for(loop.shutdown_asyncgens(), TIMEOUT)) + loop.run_until_complete(wait_for(loop.shutdown_default_executor(), TIMEOUT)) finally: asyncio.set_event_loop(None) loop.close() @@ -29,7 +33,7 @@ def _cancel_all_tasks(loop: asyncio.AbstractEventLoop) -> None: task.cancel() loop.run_until_complete( - asyncio.gather(*to_cancel, loop=loop, return_exceptions=True) + wait_for(asyncio.gather(*to_cancel, loop=loop, return_exceptions=True), TIMEOUT) ) for task in to_cancel: From 680cf6e593d44156a80606f405c75183581d785d Mon Sep 17 00:00:00 2001 From: rmorshea Date: Mon, 21 Mar 2022 23:19:12 -0700 Subject: [PATCH 13/58] implement test_module --- re.txt | 3 +- requirements/test-env.txt | 1 + src/idom/testing.py | 15 ++++++- tests/conftest.py | 10 ++--- tests/test_web/test_module.py | 74 ++++++++++++++++++----------------- 5 files changed, 59 insertions(+), 44 deletions(-) diff --git a/re.txt b/re.txt index b456f8fe9..38101e7e0 100644 --- a/re.txt +++ b/re.txt @@ -5,7 +5,8 @@ display\((.*?)\) page = await display.show($1) driver\.find_element\("id", "(.*?)"\) -await display.wait_for_selector("#$1") +await page.wait_for_selector("#$1") +await page.wait_for_selector("#$1", state="attached") driver_wait\.until\(lambda .*: (\w*)\.get_attribute\("(.*?)"\) == (.*)\) assert (await $1.evaluate("node => node['$2']")) == $3 diff --git a/requirements/test-env.txt b/requirements/test-env.txt index 48953a724..bbb59b103 100644 --- a/requirements/test-env.txt +++ b/requirements/test-env.txt @@ -7,3 +7,4 @@ pytest-rerunfailures pytest-timeout responses playwright +pytest-playwright diff --git a/src/idom/testing.py b/src/idom/testing.py index 40f32bd67..2e3a6f8ab 100644 --- a/src/idom/testing.py +++ b/src/idom/testing.py @@ -113,6 +113,10 @@ def until( self.until: Callable[[Callable[[_R], bool]], Any] = until """Check that the coroutines result meets a condition within the timeout""" + def until_is(self, right: Any, timeout: float = _DEFAULT_TIMEOUT) -> Any: + """Wait until the result is identical to the given value""" + return self.until(lambda left: left is right, timeout) + def until_equals(self, right: Any, timeout: float = _DEFAULT_TIMEOUT) -> Any: """Wait until the result is equal to the given value""" return self.until(lambda left: left == right, timeout) @@ -197,6 +201,7 @@ def __init__( self, host: str = "127.0.0.1", port: Optional[int] = None, + app: Any | None = None, implementation: ServerImplementation[Any] = default_server, ) -> None: self.server_implementation = implementation @@ -204,6 +209,14 @@ def __init__( self.port = port or find_available_port(host, allow_reuse_waiting_ports=False) self.mount, self._root_component = hotswap() + if app is not None: + if implementation is None: + raise ValueError( + "If an application instance its corresponding " + "server implementation must be provided too." + ) + self._app = app + @property def log_records(self) -> List[logging.LogRecord]: """A list of captured log records""" @@ -259,7 +272,7 @@ def list_logged_exceptions( async def __aenter__(self: _Self) -> _Self: self._log_handler = _LogRecordCaptor() logging.getLogger().addHandler(self._log_handler) - app = self.server_implementation.create_development_app() + app = self._app or self.server_implementation.create_development_app() self.server_implementation.configure(app, self._root_component) started = asyncio.Event() diff --git a/tests/conftest.py b/tests/conftest.py index dd2e980a7..0c34ac8de 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,15 +5,14 @@ from _pytest.config.argparsing import Parser from playwright.async_api import async_playwright -from idom.server.utils import all_implementations from idom.testing import DisplayFixture, ServerFixture, clear_idom_web_modules_dir from tests.tooling.loop import open_event_loop def pytest_addoption(parser: Parser) -> None: parser.addoption( - "--open-browser", - dest="open_browser", + "--headed", + dest="headed", action="store_true", help="Open a browser window when runnging web-based tests", ) @@ -34,6 +33,7 @@ async def server(): @pytest.fixture(scope="session") async def page(browser): pg = await browser.new_page() + pg.set_default_timeout(5000) try: yield pg finally: @@ -43,9 +43,7 @@ async def page(browser): @pytest.fixture(scope="session") async def browser(pytestconfig: Config): async with async_playwright() as pw: - yield await pw.chromium.launch( - headless=not bool(pytestconfig.option.open_browser) - ) + yield await pw.chromium.launch(headless=not bool(pytestconfig.option.headed)) @pytest.fixture(scope="session") diff --git a/tests/test_web/test_module.py b/tests/test_web/test_module.py index d7fb25726..5c3e952d2 100644 --- a/tests/test_web/test_module.py +++ b/tests/test_web/test_module.py @@ -1,4 +1,5 @@ from pathlib import Path +from sys import implementation import pytest from sanic import Sanic @@ -7,15 +8,21 @@ from selenium.webdriver.support.ui import WebDriverWait import idom -from idom.server.sanic import PerClientStateServer -from idom.testing import ServerFixture, assert_idom_did_not_log, assert_idom_logged +from idom.server import sanic as sanic_implementation +from idom.testing import ( + DisplayFixture, + ServerFixture, + assert_idom_did_not_log, + assert_idom_logged, + poll, +) from idom.web.module import NAME_SOURCE, WebModule JS_FIXTURES_DIR = Path(__file__).parent / "js_fixtures" -def test_that_js_module_unmount_is_called(driver, display): +async def test_that_js_module_unmount_is_called(display: DisplayFixture): SomeComponent = idom.web.export( idom.web.module_from_file( "set-flag-when-unmount-is-called", @@ -33,23 +40,23 @@ def ShowCurrentComponent(): ) return current_component - display(ShowCurrentComponent) + page = await display.show(ShowCurrentComponent) - driver.find_element("id", "some-component") + await page.wait_for_selector("#some-component", state="attached") set_current_component.current( idom.html.h1({"id": "some-other-component"}, "some other component") ) # the new component has been displayed - driver.find_element("id", "some-other-component") + await page.wait_for_selector("#some-other-component", state="attached") # the unmount callback for the old component was called - driver.find_element("id", "unmount-flag") + await page.wait_for_selector("#unmount-flag", state="attached") -def test_module_from_url(driver): - app = Sanic(__name__) +async def test_module_from_url(browser): + app = Sanic("test_module_from_url") # instead of directing the URL to a CDN, we just point it to this static file app.static( @@ -67,10 +74,10 @@ def test_module_from_url(driver): def ShowSimpleButton(): return SimpleButton({"id": "my-button"}) - with ServerFixture(PerClientStateServer, app=app) as mount_point: - mount_point.mount(ShowSimpleButton) - driver.get(mount_point.url()) - driver.find_element("id", "my-button") + async with ServerFixture(app=app, implementation=sanic_implementation) as server: + async with DisplayFixture(server, browser) as display: + page = await display.show(ShowSimpleButton) + await page.wait_for_selector("#my-button") def test_module_from_template_where_template_does_not_exist(): @@ -78,19 +85,14 @@ def test_module_from_template_where_template_does_not_exist(): idom.web.module_from_template("does-not-exist", "something.js") -def test_module_from_template(driver, display): +async def test_module_from_template(display: DisplayFixture): victory = idom.web.module_from_template("react", "victory-bar@35.4.0") VictoryBar = idom.web.export(victory, "VictoryBar") - display(VictoryBar) - wait = WebDriverWait(driver, 10) - wait.until( - expected_conditions.visibility_of_element_located( - (By.CLASS_NAME, "VictoryContainer") - ) - ) + page = await display.show(VictoryBar) + await page.wait_for_selector(".VictoryContainer") -def test_module_from_file(driver, driver_wait, display): +async def test_module_from_file(display: DisplayFixture): SimpleButton = idom.web.export( idom.web.module_from_file( "simple-button", JS_FIXTURES_DIR / "simple-button.js" @@ -106,11 +108,11 @@ def ShowSimpleButton(): {"id": "my-button", "onClick": lambda event: is_clicked.set_current(True)} ) - display(ShowSimpleButton) + page = await display.show(ShowSimpleButton) - button = driver.find_element("id", "my-button") - button.click() - driver_wait.until(lambda d: is_clicked.current) + button = await page.wait_for_selector("#my-button") + await button.click() + poll(lambda: is_clicked.current).until_is(True) def test_module_from_file_source_conflict(tmp_path): @@ -188,7 +190,7 @@ def test_module_missing_exports(): idom.web.export(module, ["x", "y"]) -def test_module_exports_multiple_components(driver, display): +async def test_module_exports_multiple_components(display: DisplayFixture): Header1, Header2 = idom.web.export( idom.web.module_from_file( "exports-two-components", JS_FIXTURES_DIR / "exports-two-components.js" @@ -196,22 +198,22 @@ def test_module_exports_multiple_components(driver, display): ["Header1", "Header2"], ) - display(lambda: Header1({"id": "my-h1"}, "My Header 1")) + page = await display.show(lambda: Header1({"id": "my-h1"}, "My Header 1")) - driver.find_element("id", "my-h1") + await page.wait_for_selector("#my-h1", state="attached") - display(lambda: Header2({"id": "my-h2"}, "My Header 2")) + page = await display.show(lambda: Header2({"id": "my-h2"}, "My Header 2")) - driver.find_element("id", "my-h2") + await page.wait_for_selector("#my-h2", state="attached") -def test_imported_components_can_render_children(driver, display): +async def test_imported_components_can_render_children(display: DisplayFixture): module = idom.web.module_from_file( "component-can-have-child", JS_FIXTURES_DIR / "component-can-have-child.js" ) Parent, Child = idom.web.export(module, ["Parent", "Child"]) - display( + page = await display.show( lambda: Parent( Child({"index": 1}), Child({"index": 2}), @@ -219,13 +221,13 @@ def test_imported_components_can_render_children(driver, display): ) ) - parent = driver.find_element("id", "the-parent") - children = parent.find_elements("tag name", "li") + parent = await page.wait_for_selector("#the-parent", state="attached") + children = await parent.query_selector_all("li") assert len(children) == 3 for index, child in enumerate(children): - assert child.get_attribute("id") == f"child-{index + 1}" + assert (await child.get_attribute("id")) == f"child-{index + 1}" def test_module_from_string(): From bbc80d546c272a3e7567cb057321ac7418c096db Mon Sep 17 00:00:00 2001 From: rmorshea Date: Tue, 22 Mar 2022 00:49:08 -0700 Subject: [PATCH 14/58] implement connection context --- src/idom/__init__.py | 3 +- src/idom/core/hooks.py | 22 ++++----- src/idom/core/layout.py | 1 + src/idom/server/_conn.py | 9 ++++ src/idom/server/default.py | 4 ++ src/idom/server/fastapi.py | 8 +++- src/idom/server/flask.py | 34 ++++++++++---- src/idom/server/sanic.py | 14 +++++- src/idom/server/starlette.py | 13 +++++- src/idom/server/tornado.py | 12 ++++- src/idom/server/types.py | 5 +- src/idom/types.py | 2 + src/idom/widgets.py | 89 ------------------------------------ 13 files changed, 99 insertions(+), 117 deletions(-) create mode 100644 src/idom/server/_conn.py diff --git a/src/idom/__init__.py b/src/idom/__init__.py index 0f2f3168a..775005ef0 100644 --- a/src/idom/__init__.py +++ b/src/idom/__init__.py @@ -17,7 +17,7 @@ from .core.vdom import vdom from .server.utils import run from .utils import Ref, html_to_vdom -from .widgets import hotswap, multiview +from .widgets import hotswap __author__ = "idom-team" @@ -34,7 +34,6 @@ "html", "Layout", "log", - "multiview", "Ref", "run", "Stop", diff --git a/src/idom/core/hooks.py b/src/idom/core/hooks.py index d8ff3ab54..72502a063 100644 --- a/src/idom/core/hooks.py +++ b/src/idom/core/hooks.py @@ -206,19 +206,19 @@ def effect() -> None: def create_context( default_value: _StateType, name: str | None = None -) -> type[_Context[_StateType]]: +) -> type[Context[_StateType]]: """Return a new context type for use in :func:`use_context`""" - class Context(_Context[_StateType]): + class _Context(Context[_StateType]): _default_value = default_value if name is not None: - Context.__name__ = name + _Context.__name__ = name - return Context + return _Context -def use_context(context_type: type[_Context[_StateType]]) -> _StateType: +def use_context(context_type: type[Context[_StateType]]) -> _StateType: """Get the current value for the given context type. See the full :ref:`Use Context` docs for more information. @@ -228,7 +228,7 @@ def use_context(context_type: type[_Context[_StateType]]) -> _StateType: # that newly present current context. When we update it though, we don't need to # schedule a new render since we're already rending right now. Thus we can't do this # with use_state() since we'd incur an extra render when calling set_state. - context_ref: Ref[_Context[_StateType] | None] = use_ref(None) + context_ref: Ref[Context[_StateType] | None] = use_ref(None) if context_ref.current is None: provided_context = context_type._current.get() @@ -244,7 +244,7 @@ def use_context(context_type: type[_Context[_StateType]]) -> _StateType: @use_effect def subscribe_to_context_change() -> Callable[[], None]: - def set_context(new: _Context[_StateType]) -> None: + def set_context(new: Context[_StateType]) -> None: # We don't need to check if `new is not context_ref.current` because we only # trigger this callback when the value of a context, and thus the context # itself changes. Therefore we can always schedule a render. @@ -260,13 +260,13 @@ def set_context(new: _Context[_StateType]) -> None: _UNDEFINED: Any = object() -class _Context(Generic[_StateType]): +class Context(Generic[_StateType]): # This should be _StateType instead of Any, but it can't due to this limitation: # https://github.com/python/mypy/issues/5144 _default_value: ClassVar[Any] - _current: ClassVar[ThreadLocal[_Context[Any] | None]] + _current: ClassVar[ThreadLocal[Context[Any] | None]] def __init_subclass__(cls) -> None: # every context type tracks which of its instances are currently in use @@ -281,7 +281,7 @@ def __init__( self.children = children self.value: _StateType = self._default_value if value is _UNDEFINED else value self.key = key - self.subscribers: set[Callable[[_Context[_StateType]], None]] = set() + self.subscribers: set[Callable[[Context[_StateType]], None]] = set() self.type = self.__class__ def render(self) -> VdomDict: @@ -297,7 +297,7 @@ def reset_ctx() -> None: return vdom("", *self.children) - def should_render(self, new: _Context[_StateType]) -> bool: + def should_render(self, new: Context[_StateType]) -> bool: if self.value is not new.value: new.subscribers.update(self.subscribers) for set_context in self.subscribers: diff --git a/src/idom/core/layout.py b/src/idom/core/layout.py index fe3817a4d..1f67bd586 100644 --- a/src/idom/core/layout.py +++ b/src/idom/core/layout.py @@ -193,6 +193,7 @@ def _render_component( if ( old_state is not None + and hasattr(old_state.model, "current") and old_state.is_component_state and not _check_should_render( old_state.life_cycle_state.component, component diff --git a/src/idom/server/_conn.py b/src/idom/server/_conn.py new file mode 100644 index 000000000..2c9f6726c --- /dev/null +++ b/src/idom/server/_conn.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from typing import Any + +from idom.core.hooks import create_context +from idom.types import Context + + +Connection: type[Context[Any | None]] = create_context(None, name="Connection") diff --git a/src/idom/server/default.py b/src/idom/server/default.py index ade4836eb..680b92163 100644 --- a/src/idom/server/default.py +++ b/src/idom/server/default.py @@ -28,6 +28,10 @@ async def serve_development_app( ) +def use_connection() -> Any: + return _default_implementation().use_connection() + + def _default_implementation() -> ServerImplementation[Any]: """Get the first available server implementation""" global _DEFAULT_IMPLEMENTATION diff --git a/src/idom/server/fastapi.py b/src/idom/server/fastapi.py index 5c9a3d80f..e6bb63729 100644 --- a/src/idom/server/fastapi.py +++ b/src/idom/server/fastapi.py @@ -11,10 +11,16 @@ _setup_options, _setup_single_view_dispatcher_route, serve_development_app, + use_connection, ) -__all__ = "configure", "serve_development_app", "create_development_app" +__all__ = ( + "configure", + "serve_development_app", + "create_development_app", + "use_connection", +) def configure( diff --git a/src/idom/server/flask.py b/src/idom/server/flask.py index 580097617..90a3e3a17 100644 --- a/src/idom/server/flask.py +++ b/src/idom/server/flask.py @@ -10,7 +10,16 @@ from typing import Any, Callable, Dict, NamedTuple, Optional, Union, cast from urllib.parse import parse_qs as parse_query_string -from flask import Blueprint, Flask, redirect, request, send_from_directory, url_for +from flask import ( + Blueprint, + Flask, + Request, + copy_current_request_context, + redirect, + request, + send_from_directory, + url_for, +) from flask_cors import CORS from flask_sockets import Sockets from gevent import pywsgi @@ -20,10 +29,12 @@ import idom from idom.config import IDOM_WEB_MODULES_DIR +from idom.core.hooks import use_context from idom.core.layout import LayoutEvent, LayoutUpdate from idom.core.serve import serve_json_patch from idom.core.types import ComponentType, RootComponentConstructor +from ._conn import Connection from .utils import CLIENT_BUILD_DIR @@ -94,6 +105,13 @@ def run_server(): raise RuntimeError("Failed to shutdown server.") +def use_connection() -> Request: + value = use_context(Connection) + if value is None: + raise RuntimeError("No established connection.") + return value + + class Options(TypedDict, total=False): """Render server config for :class:`FlaskRenderServer`""" @@ -175,14 +193,7 @@ def recv() -> Optional[LayoutEvent]: else: return None - dispatch_in_thread(constructor(**_get_query_params(ws)), send, recv) - - -def _get_query_params(ws: WebSocket) -> Dict[str, Any]: - return { - k: v if len(v) > 1 else v[0] - for k, v in parse_query_string(ws.environ["QUERY_STRING"]).items() - } + dispatch_in_thread(constructor(), send, recv) def dispatch_in_thread( @@ -193,6 +204,7 @@ def dispatch_in_thread( dispatch_thread_info_created = ThreadEvent() dispatch_thread_info_ref: idom.Ref[Optional[_DispatcherThreadInfo]] = idom.Ref(None) + @copy_current_request_context def run_dispatcher() -> None: loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) @@ -207,7 +219,9 @@ async def recv_coro() -> Any: return await async_recv_queue.get() async def main() -> None: - await serve_json_patch(idom.Layout(component), send_coro, recv_coro) + await serve_json_patch( + idom.Layout(Connection(component, value=request)), send_coro, recv_coro + ) main_future = asyncio.ensure_future(main()) diff --git a/src/idom/server/sanic.py b/src/idom/server/sanic.py index e7aac1330..5a0acae38 100644 --- a/src/idom/server/sanic.py +++ b/src/idom/server/sanic.py @@ -22,6 +22,7 @@ ) from idom.core.types import RootComponentConstructor +from ._conn import Connection from .utils import CLIENT_BUILD_DIR @@ -63,6 +64,13 @@ async def serve_development_app( app.stop() +def use_connection() -> request.Request: + value = use_connection(Connection) + if value is None: + raise RuntimeError("No established connection.") + return value + + class Options(TypedDict, total=False): """Options for :class:`SanicRenderServer`""" @@ -122,7 +130,11 @@ async def model_stream( ) -> None: send, recv = _make_send_recv_callbacks(socket) component_params = {k: request.args.get(k) for k in request.args} - await serve_json_patch(Layout(constructor(**component_params)), send, recv) + await serve_json_patch( + Layout(Connection(constructor(**component_params), value=request)), + send, + recv, + ) def _make_send_recv_callbacks( diff --git a/src/idom/server/starlette.py b/src/idom/server/starlette.py index fbd9ce0f3..d0ce29ab1 100644 --- a/src/idom/server/starlette.py +++ b/src/idom/server/starlette.py @@ -17,6 +17,7 @@ from uvicorn.server import Server as UvicornServer from idom.config import IDOM_WEB_MODULES_DIR +from idom.core.hooks import use_context from idom.core.layout import Layout, LayoutEvent from idom.core.serve import ( RecvCoroutine, @@ -27,6 +28,7 @@ from idom.core.types import RootComponentConstructor from ._asgi import serve_development_asgi +from ._conn import Connection from .utils import CLIENT_BUILD_DIR @@ -67,6 +69,13 @@ async def serve_development_app( await serve_development_asgi(app, host, port, started) +def use_connection() -> WebSocket: + value = use_context(Connection) + if value is None: + raise RuntimeError("No established connection.") + return value + + class Options(TypedDict, total=False): """Optionsuration options for :class:`StarletteRenderServer`""" @@ -145,7 +154,9 @@ async def model_stream(socket: WebSocket) -> None: send, recv = _make_send_recv_callbacks(socket) try: await serve_json_patch( - Layout(constructor(**dict(socket.query_params))), send, recv + Layout(Connection(constructor(), value=socket)), + send, + recv, ) except WebSocketDisconnect as error: logger.info(f"WebSocket disconnect: {error.code}") diff --git a/src/idom/server/tornado.py b/src/idom/server/tornado.py index 9665e46e8..f158c104a 100644 --- a/src/idom/server/tornado.py +++ b/src/idom/server/tornado.py @@ -8,16 +8,19 @@ from urllib.parse import urljoin from tornado.httpserver import HTTPServer +from tornado.httputil import HTTPServerRequest from tornado.platform.asyncio import AsyncIOMainLoop from tornado.web import Application, RedirectHandler, RequestHandler, StaticFileHandler from tornado.websocket import WebSocketHandler from typing_extensions import TypedDict from idom.config import IDOM_WEB_MODULES_DIR +from idom.core.hooks import use_context from idom.core.layout import Layout, LayoutEvent from idom.core.serve import VdomJsonPatch, serve_json_patch from idom.core.types import ComponentConstructor +from ._conn import Connection from .utils import CLIENT_BUILD_DIR @@ -69,6 +72,13 @@ async def serve_development_app( await server.close_all_connections() +def use_connection() -> HTTPServerRequest: + value = use_context(Connection) + if value is None: + raise RuntimeError("No established connection.") + return value + + class Options(TypedDict, total=False): """Render server options for :class:`TornadoRenderServer` subclasses""" @@ -160,7 +170,7 @@ async def recv() -> LayoutEvent: self._message_queue = message_queue self._dispatch_future = asyncio.ensure_future( serve_json_patch( - Layout(self._component_constructor(**query_params)), + Layout(Connection(self._component_constructor(), value=self.request)), send, recv, ) diff --git a/src/idom/server/types.py b/src/idom/server/types.py index 5c5cc04a0..afb5abc66 100644 --- a/src/idom/server/types.py +++ b/src/idom/server/types.py @@ -1,5 +1,5 @@ import asyncio -from typing import Callable, TypeVar +from typing import Any, TypeVar from typing_extensions import Protocol, runtime_checkable @@ -23,3 +23,6 @@ async def serve_development_app( self, app: _App, host: str, port: int, started: asyncio.Event ) -> None: """Run an application using a development server""" + + def use_connection() -> Any: + """Get information about a currently active request to the server""" diff --git a/src/idom/types.py b/src/idom/types.py index 562d8535a..084c38883 100644 --- a/src/idom/types.py +++ b/src/idom/types.py @@ -4,6 +4,7 @@ - :mod:`idom.server.types` """ +from .core.hooks import Context from .core.types import ( ComponentConstructor, ComponentType, @@ -28,6 +29,7 @@ __all__ = [ "ComponentConstructor", "ComponentType", + "Context", "EventHandlerDict", "EventHandlerFunc", "EventHandlerMapping", diff --git a/src/idom/widgets.py b/src/idom/widgets.py index d3cb29937..8cda875b0 100644 --- a/src/idom/widgets.py +++ b/src/idom/widgets.py @@ -189,92 +189,3 @@ def swap(constructor: Callable[[], Any] | None) -> None: def _use_callable(initial_func: _Func) -> Tuple[_Func, Callable[[_Func], None]]: state = hooks.use_state(lambda: initial_func) return state[0], lambda new: state[1](lambda old: new) - - -def multiview() -> Tuple[MultiViewMount, ComponentConstructor]: - """Dynamically add components to a layout on the fly - - Since you can't change the component functions used to create a layout - in an imperative manner, you can use ``multiview`` to do this so - long as you set things up ahead of time. - - Examples: - - .. code-block:: - - import idom - - mount, multiview = idom.widgets.multiview() - - @idom.component - def Hello(): - return idom.html.h1(["hello"]) - - # auto static view ID - mount.add("hello", Hello) - # use the view ID to create the associate component instance - hello_component_instance = multiview("hello") - - @idom.component - def World(): - return idom.html.h1(["world"]) - - generated_view_id = mount.add(None, World) - world_component_instance = multiview(generated_view_id) - - Displaying ``root`` with the parameter ``view_id=hello_world_view_id`` will show - the message 'hello world'. Usually though this is achieved by connecting to the - socket serving up the VDOM with a query parameter for view ID. This allow many - views to be added and then displayed dynamically in, for example, a Jupyter - Notebook where one might want multiple active views which can all be interacted - with at the same time. - - See :func:`idom.server.prefab.multiview_server` for a reference usage. - """ - views: Dict[str, ComponentConstructor] = {} - - @component - def MultiView(view_id: str) -> Any: - try: - return views[view_id]() - except KeyError: - raise ValueError(f"Unknown view {view_id!r}") - - return MultiViewMount(views), MultiView - - -class MultiViewMount: - """Mount point for :func:`multiview`""" - - __slots__ = "_next_auto_id", "_views" - - def __init__(self, views: Dict[str, ComponentConstructor]): - self._next_auto_id = 0 - self._views = views - - def add(self, view_id: Optional[str], constructor: ComponentConstructor) -> str: - """Add a component constructor - - Parameters: - view_id: - The view ID the constructor will be associated with. If ``None`` then - a view ID will be automatically generated. - constructor: - The component constructor to be mounted. It must accept no arguments. - - Returns: - The view ID that was assocaited with the component - most useful for - auto-generated view IDs - """ - if view_id is None: - self._next_auto_id += 1 - view_id = str(self._next_auto_id) - self._views[view_id] = constructor - return view_id - - def remove(self, view_id: str) -> None: - """Remove a mounted component constructor given its view ID""" - del self._views[view_id] - - def __repr__(self) -> str: - return f"{type(self).__name__}({self._views})" From 82a1776dba99e558531bf1e6c9584e5a32b1ae41 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Tue, 22 Mar 2022 01:03:24 -0700 Subject: [PATCH 15/58] fix playwright install in nox --- noxfile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/noxfile.py b/noxfile.py index 6a892f7cf..fe8b4908a 100644 --- a/noxfile.py +++ b/noxfile.py @@ -179,7 +179,7 @@ def test_python_suite(session: Session) -> None: """Run the Python-based test suite""" session.env["IDOM_DEBUG_MODE"] = "1" install_requirements_file(session, "test-env") - session.run("playwright install") + session.run("playwright", "install") posargs = session.posargs posargs += ["--reruns", "3", "--reruns-delay", "1"] From 7dbe32a933cdec917f427abd6c5c0afc5c44430a Mon Sep 17 00:00:00 2001 From: rmorshea Date: Tue, 22 Mar 2022 01:08:44 -0700 Subject: [PATCH 16/58] add minimal docstrings --- src/idom/server/flask.py | 1 + src/idom/server/sanic.py | 1 + src/idom/server/starlette.py | 1 + src/idom/server/tornado.py | 1 + 4 files changed, 4 insertions(+) diff --git a/src/idom/server/flask.py b/src/idom/server/flask.py index 90a3e3a17..238d2be4c 100644 --- a/src/idom/server/flask.py +++ b/src/idom/server/flask.py @@ -106,6 +106,7 @@ def run_server(): def use_connection() -> Request: + """Get the current ``Request``""" value = use_context(Connection) if value is None: raise RuntimeError("No established connection.") diff --git a/src/idom/server/sanic.py b/src/idom/server/sanic.py index 5a0acae38..0d6daa62a 100644 --- a/src/idom/server/sanic.py +++ b/src/idom/server/sanic.py @@ -65,6 +65,7 @@ async def serve_development_app( def use_connection() -> request.Request: + """Get the current ``Request``""" value = use_connection(Connection) if value is None: raise RuntimeError("No established connection.") diff --git a/src/idom/server/starlette.py b/src/idom/server/starlette.py index d0ce29ab1..5787a6cf7 100644 --- a/src/idom/server/starlette.py +++ b/src/idom/server/starlette.py @@ -70,6 +70,7 @@ async def serve_development_app( def use_connection() -> WebSocket: + """Get the current ``WebSocket`` connection""" value = use_context(Connection) if value is None: raise RuntimeError("No established connection.") diff --git a/src/idom/server/tornado.py b/src/idom/server/tornado.py index f158c104a..a8b604095 100644 --- a/src/idom/server/tornado.py +++ b/src/idom/server/tornado.py @@ -73,6 +73,7 @@ async def serve_development_app( def use_connection() -> HTTPServerRequest: + """Get the current ``HTTPServerRequest``""" value = use_context(Connection) if value is None: raise RuntimeError("No established connection.") From 3a88e96e9931496ce425b1a45b3446cf27cba1a6 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Tue, 22 Mar 2022 01:09:47 -0700 Subject: [PATCH 17/58] remove pytest-playwright --- requirements/test-env.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/requirements/test-env.txt b/requirements/test-env.txt index bbb59b103..48953a724 100644 --- a/requirements/test-env.txt +++ b/requirements/test-env.txt @@ -7,4 +7,3 @@ pytest-rerunfailures pytest-timeout responses playwright -pytest-playwright From 8b09a5d0f9b8bfd64352b0f402ac9394589c1cb8 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Tue, 22 Mar 2022 01:14:53 -0700 Subject: [PATCH 18/58] get suite to run (with failures) --- tests/test_client.py | 1 - tests/test_core/test_dispatcher.py | 89 +------------ ...est_per_client_state.py => test_common.py} | 18 +-- tests/test_server/test_common/__init__.py | 0 .../test_server/test_common/test_multiview.py | 46 ------- .../test_common/test_shared_state_client.py | 125 ------------------ tests/test_server/test_utils.py | 16 +-- 7 files changed, 5 insertions(+), 290 deletions(-) rename tests/test_server/{test_common/test_per_client_state.py => test_common.py} (69%) delete mode 100644 tests/test_server/test_common/__init__.py delete mode 100644 tests/test_server/test_common/test_multiview.py delete mode 100644 tests/test_server/test_common/test_shared_state_client.py diff --git a/tests/test_client.py b/tests/test_client.py index 64bb7a98b..df5b43d59 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -4,7 +4,6 @@ import idom from idom.testing import ServerFixture -from tests.tooling.browser import send_keys JS_DIR = Path(__file__).parent / "js" diff --git a/tests/test_core/test_dispatcher.py b/tests/test_core/test_dispatcher.py index b57dae2e2..908c62f40 100644 --- a/tests/test_core/test_dispatcher.py +++ b/tests/test_core/test_dispatcher.py @@ -6,13 +6,7 @@ import idom from idom.core.layout import Layout, LayoutEvent, LayoutUpdate -from idom.core.serve import ( - VdomJsonPatch, - _create_shared_view_dispatcher, - create_shared_view_dispatcher, - ensure_shared_view_dispatcher_future, - serve_json_patch, -) +from idom.core.serve import VdomJsonPatch, serve_json_patch from idom.testing import StaticEventHandler @@ -102,87 +96,6 @@ async def test_dispatch(): assert_changes_produce_expected_model(changes, expected_model) -async def test_create_shared_state_dispatcher(): - events, model = make_events_and_expected_model() - changes_1, send_1, recv_1 = make_send_recv_callbacks(events) - changes_2, send_2, recv_2 = make_send_recv_callbacks(events) - - async with create_shared_view_dispatcher(Layout(Counter())) as dispatcher: - dispatcher(send_1, recv_1) - dispatcher(send_2, recv_2) - - assert_changes_produce_expected_model(changes_1, model) - assert_changes_produce_expected_model(changes_2, model) - - -async def test_ensure_shared_view_dispatcher_future(): - events, model = make_events_and_expected_model() - changes_1, send_1, recv_1 = make_send_recv_callbacks(events) - changes_2, send_2, recv_2 = make_send_recv_callbacks(events) - - dispatch_future, dispatch = ensure_shared_view_dispatcher_future(Layout(Counter())) - - await asyncio.gather( - dispatch(send_1, recv_1), - dispatch(send_2, recv_2), - return_exceptions=True, - ) - - # the dispatch future should run forever, until cancelled - with pytest.raises(asyncio.TimeoutError): - await asyncio.wait_for(dispatch_future, timeout=1) - - dispatch_future.cancel() - await asyncio.gather(dispatch_future, return_exceptions=True) - - assert_changes_produce_expected_model(changes_1, model) - assert_changes_produce_expected_model(changes_2, model) - - -async def test_private_create_shared_view_dispatcher_cleans_up_patch_queues(): - """Report an issue if this test breaks - - Some internals of idom.core.dispatcher may need to be changed in order to make some - internal state easier to introspect. - - Ideally we would just check if patch queues are getting cleaned up more directly, - but without having access to that, we use some side effects to try and infer whether - it happens. - """ - - @idom.component - def SomeComponent(): - return idom.html.div() - - async def send(patch): - raise idom.Stop() - - async def recv(): - return LayoutEvent("something", []) - - with idom.Layout(SomeComponent()) as layout: - dispatch_shared_view, push_patch = await _create_shared_view_dispatcher(layout) - - # Dispatch a view that should exit. After exiting its patch queue should be - # cleaned up and removed. Since we only dispatched one view there should be - # no patch queues. - await dispatch_shared_view(send, recv) # this should stop immediately - - # We create a patch and check its ref count. We will check this after attempting - # to push out the change. - patch = VdomJsonPatch("anything", []) - patch_ref_count = sys.getrefcount(patch) - - # We push out this change. - push_patch(patch) - - # Because there should be no patch queues, we expect that the ref count remains - # the same. If the ref count had increased, then we would know that the patch - # queue has not been cleaned up and that the patch we just pushed was added to - # it. - assert not sys.getrefcount(patch) > patch_ref_count - - async def test_dispatcher_handles_more_than_one_event_at_a_time(): block_and_never_set = asyncio.Event() will_block = asyncio.Event() diff --git a/tests/test_server/test_common/test_per_client_state.py b/tests/test_server/test_common.py similarity index 69% rename from tests/test_server/test_common/test_per_client_state.py rename to tests/test_server/test_common.py index 3117f674c..154ff5f6a 100644 --- a/tests/test_server/test_common/test_per_client_state.py +++ b/tests/test_server/test_common.py @@ -1,25 +1,13 @@ import pytest import idom -from idom.server import fastapi as idom_fastapi -from idom.server import flask as idom_flask -from idom.server import sanic as idom_sanic -from idom.server import starlette as idom_starlette -from idom.server import tornado as idom_tornado +from idom.server.utils import all_implementations from idom.testing import ServerFixture @pytest.fixture( - params=[ - # add new PerClientStateServer implementations here to - # run a suite of tests which check basic functionality - idom_fastapi.PerClientStateServer, - idom_flask.PerClientStateServer, - idom_sanic.PerClientStateServer, - idom_starlette.PerClientStateServer, - idom_tornado.PerClientStateServer, - ], - ids=lambda cls: f"{cls.__module__}.{cls.__name__}", + params=list(all_implementations()), + ids=lambda imp: imp.__name__, ) def server_mount_point(request): with ServerFixture(request.param) as mount_point: diff --git a/tests/test_server/test_common/__init__.py b/tests/test_server/test_common/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/test_server/test_common/test_multiview.py b/tests/test_server/test_common/test_multiview.py deleted file mode 100644 index e2b56a03e..000000000 --- a/tests/test_server/test_common/test_multiview.py +++ /dev/null @@ -1,46 +0,0 @@ -import pytest - -import idom -from idom.server import all_implementations -from idom.server import fastapi as idom_fastapi -from idom.server import flask as idom_flask -from idom.server import sanic as idom_sanic -from idom.server import starlette as idom_starlette -from idom.testing import ServerFixture -from tests.tooling.browser import no_such_element - - -@pytest.fixture( - params=list(all_implementations()), - ids=lambda cls: f"{cls.__module__}.{cls.__name__}", -) -async def server_mount_point(request): - async with ServerFixture(request.param) as mount_point: - yield mount_point - - -def test_multiview_server(driver_get, driver, server_mount_point): - manual_id = server_mount_point.mount.add( - "manually_set_id", - lambda: idom.html.h1({"id": "e1"}, ["e1"]), - ) - auto_view_id = server_mount_point.mount.add( - None, - lambda: idom.html.h1({"id": "e2"}, ["e2"]), - ) - - driver_get({"view_id": manual_id}) - driver.find_element("id", "e1") - - driver_get({"view_id": auto_view_id}) - driver.find_element("id", "e2") - - server_mount_point.mount.remove(auto_view_id) - server_mount_point.mount.remove(manual_id) - - driver.refresh() - - assert no_such_element(driver, "id", "e1") - assert no_such_element(driver, "id", "e2") - - server_mount_point.log_records.clear() diff --git a/tests/test_server/test_common/test_shared_state_client.py b/tests/test_server/test_common/test_shared_state_client.py deleted file mode 100644 index 07c92c9eb..000000000 --- a/tests/test_server/test_common/test_shared_state_client.py +++ /dev/null @@ -1,125 +0,0 @@ -from threading import Event -from weakref import finalize - -import pytest - -import idom -from idom.server import fastapi as idom_fastapi -from idom.server import sanic as idom_sanic -from idom.server import starlette as idom_starlette -from idom.testing import ServerFixture - - -@pytest.fixture( - params=[ - # add new SharedClientStateServer implementations here to - # run a suite of tests which check basic functionality - idom_sanic.SharedClientStateServer, - idom_fastapi.SharedClientStateServer, - idom_starlette.SharedClientStateServer, - ], - ids=lambda cls: f"{cls.__module__}.{cls.__name__}", -) -def server_mount_point(request): - with ServerFixture(request.param, sync_views=True) as mount_point: - yield mount_point - - -def test_shared_client_state(create_driver, server_mount_point): - was_garbage_collected = Event() - - @idom.component - def IncrCounter(): - count, set_count = idom.hooks.use_state(0) - - def incr_on_click(event): - set_count(count + 1) - - button = idom.html.button( - {"onClick": incr_on_click, "id": "incr-button"}, "click to increment" - ) - - counter = Counter(count) - finalize(counter, was_garbage_collected.set) - - return idom.html.div(button, counter) - - @idom.component - def Counter(count): - return idom.html.div({"id": f"count-is-{count}"}, count) - - server_mount_point.mount(IncrCounter) - - driver_1 = create_driver() - driver_2 = create_driver() - - driver_1.get(server_mount_point.url()) - driver_2.get(server_mount_point.url()) - - client_1_button = driver_1.find_element("id", "incr-button") - client_2_button = driver_2.find_element("id", "incr-button") - - driver_1.find_element("id", "count-is-0") - driver_2.find_element("id", "count-is-0") - - client_1_button.click() - - driver_1.find_element("id", "count-is-1") - driver_2.find_element("id", "count-is-1") - - client_2_button.click() - - driver_1.find_element("id", "count-is-2") - driver_2.find_element("id", "count-is-2") - - assert was_garbage_collected.wait(1) - was_garbage_collected.clear() - - # Ensure this continues working after a refresh. In the past dispatchers failed to - # exit when the connections closed. This was due to an expected error that is raised - # when the web socket closes. - driver_1.refresh() - driver_2.refresh() - - client_1_button = driver_1.find_element("id", "incr-button") - client_2_button = driver_2.find_element("id", "incr-button") - - client_1_button.click() - - driver_1.find_element("id", "count-is-3") - driver_2.find_element("id", "count-is-3") - - client_1_button.click() - - driver_1.find_element("id", "count-is-4") - driver_2.find_element("id", "count-is-4") - - client_2_button.click() - - assert was_garbage_collected.wait(1) - - -def test_shared_client_state_server_does_not_support_per_client_parameters( - driver_get, - driver_wait, - server_mount_point, -): - driver_get( - { - "per_client_param": 1, - # we need to stop reconnect attempts to prevent the error from happening - # more than once - "noReconnect": True, - } - ) - - driver_wait.until( - lambda driver: ( - len( - server_mount_point.list_logged_exceptions( - "does not support per-client view parameters", ValueError - ) - ) - == 1 - ) - ) diff --git a/tests/test_server/test_utils.py b/tests/test_server/test_utils.py index 11e5da089..9ec745663 100644 --- a/tests/test_server/test_utils.py +++ b/tests/test_server/test_utils.py @@ -2,21 +2,7 @@ import pytest -from idom.server.utils import find_available_port, poll, wait_on_event - - -def test_poll(): - with pytest.raises(TimeoutError, match="Did not do something within 0.1 seconds"): - poll("do something", 0.01, 0.1, lambda: False) - poll("do something", 0.01, None, [True, False, False].pop) - - -def test_wait_on_event(): - event = Event() - with pytest.raises(TimeoutError, match="Did not do something within 0.1 seconds"): - wait_on_event("do something", event, 0.1) - event.set() - wait_on_event("do something", event, None) +from idom.server.utils import find_available_port def test_find_available_port(): From 1c422ed633f3d5893374c90b0d7462780bc74235 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Wed, 23 Mar 2022 22:00:16 -0700 Subject: [PATCH 19/58] fix log assertion problems --- src/idom/core/hooks.py | 3 +- src/idom/testing.py | 162 ++++++++++++++++----------------- tests/conftest.py | 19 +++- tests/test_core/test_hooks.py | 12 +-- tests/test_core/test_layout.py | 14 +-- tests/test_web/test_utils.py | 12 +-- tests/test_widgets.py | 5 - 7 files changed, 118 insertions(+), 109 deletions(-) diff --git a/src/idom/core/hooks.py b/src/idom/core/hooks.py index 72502a063..d6e8983ec 100644 --- a/src/idom/core/hooks.py +++ b/src/idom/core/hooks.py @@ -212,8 +212,7 @@ def create_context( class _Context(Context[_StateType]): _default_value = default_value - if name is not None: - _Context.__name__ = name + _Context.__name__ = name or "Context" return _Context diff --git a/src/idom/testing.py b/src/idom/testing.py index 2e3a6f8ab..2a6f02237 100644 --- a/src/idom/testing.py +++ b/src/idom/testing.py @@ -6,7 +6,7 @@ import re import shutil import time -from contextlib import AsyncExitStack, contextmanager +from contextlib import AsyncExitStack, ExitStack, contextmanager from functools import partial, wraps from inspect import isawaitable, iscoroutinefunction from traceback import format_exception @@ -194,8 +194,9 @@ class ServerFixture: server.mount(MyComponent) """ - _log_handler: "_LogRecordCaptor" + _records: list[logging.LogRecord] _server_future: asyncio.Task[Any] + _exit_stack = ExitStack() def __init__( self, @@ -220,7 +221,7 @@ def __init__( @property def log_records(self) -> List[logging.LogRecord]: """A list of captured log records""" - return self._log_handler.records + return self._records def url(self, path: str = "", query: Optional[Any] = None) -> str: """Return a URL string pointing to the host and point of the server @@ -270,8 +271,9 @@ def list_logged_exceptions( return found async def __aenter__(self: _Self) -> _Self: - self._log_handler = _LogRecordCaptor() - logging.getLogger().addHandler(self._log_handler) + self._exit_stack = ExitStack() + self._records = self._exit_stack.enter_context(capture_idom_logs()) + app = self._app or self.server_implementation.create_development_app() self.server_implementation.configure(app, self._root_component) @@ -282,6 +284,8 @@ async def __aenter__(self: _Self) -> _Self: ) ) + self._exit_stack.callback(self._server_future.cancel) + try: await asyncio.wait_for(started.wait(), timeout=3) except Exception: @@ -297,19 +301,18 @@ async def __aexit__( exc_value: Optional[BaseException], traceback: Optional[TracebackType], ) -> None: - self.mount(None) # reset the view + self._exit_stack.close() - self._server_future.cancel() + self.mount(None) # reset the view try: await asyncio.wait_for(self._server_future, timeout=3) except asyncio.CancelledError: pass - logging.getLogger().removeHandler(self._log_handler) logged_errors = self.list_logged_exceptions(del_log_records=False) if logged_errors: # pragma: no cover - raise logged_errors[0] + raise LogAssertionError("Unexpected logged exception") from logged_errors[0] return None @@ -323,7 +326,7 @@ def assert_idom_logged( match_message: str = "", error_type: type[Exception] | None = None, match_error: str = "", - clear_matched_records: bool = False, + clear_after: bool = True, ) -> Iterator[None]: """Assert that IDOM produced a log matching the described message or error. @@ -331,53 +334,49 @@ def assert_idom_logged( match_message: Must match a logged message. error_type: Checks the type of logged exceptions. match_error: Must match an error message. - clear_matched_records: Whether to remove logged records that match. + clear_after: Whether to remove logged records that match. """ message_pattern = re.compile(match_message) error_pattern = re.compile(match_error) - try: - with capture_idom_logs(yield_existing=clear_matched_records) as log_records: + with capture_idom_logs(clear_after=clear_after) as log_records: + try: yield None - except Exception: - raise - else: - found = False - for record in list(log_records): - if ( - # record message matches - message_pattern.findall(record.getMessage()) - # error type matches - and ( - error_type is None - or ( - record.exc_info is not None - and record.exc_info[0] is not None - and issubclass(record.exc_info[0], error_type) + except Exception: + raise + else: + for record in list(log_records): + if ( + # record message matches + message_pattern.findall(record.getMessage()) + # error type matches + and ( + error_type is None + or ( + record.exc_info is not None + and record.exc_info[0] is not None + and issubclass(record.exc_info[0], error_type) + ) ) - ) - # error message pattern matches - and ( - not match_error - or ( - record.exc_info is not None - and error_pattern.findall( - "".join(format_exception(*record.exc_info)) + # error message pattern matches + and ( + not match_error + or ( + record.exc_info is not None + and error_pattern.findall( + "".join(format_exception(*record.exc_info)) + ) ) ) + ): + break + else: # pragma: no cover + _raise_log_message_error( + "Could not find a log record matching the given", + match_message, + error_type, + match_error, ) - ): - found = True - if clear_matched_records: - log_records.remove(record) - - if not found: # pragma: no cover - _raise_log_message_error( - "Could not find a log record matching the given", - match_message, - error_type, - match_error, - ) @contextmanager @@ -385,13 +384,11 @@ def assert_idom_did_not_log( match_message: str = "", error_type: type[Exception] | None = None, match_error: str = "", - clear_matched_records: bool = False, + clear_after: bool = True, ) -> Iterator[None]: """Assert the inverse of :func:`assert_idom_logged`""" try: - with assert_idom_logged( - match_message, error_type, match_error, clear_matched_records - ): + with assert_idom_logged(match_message, error_type, match_error, clear_after): yield None except LogAssertionError: pass @@ -421,45 +418,35 @@ def _raise_log_message_error( @contextmanager -def capture_idom_logs( - yield_existing: bool = False, -) -> Iterator[list[logging.LogRecord]]: +def capture_idom_logs(clear_after: bool = True) -> Iterator[list[logging.LogRecord]]: """Capture logs from IDOM - Parameters: - yield_existing: - If already inside an existing capture context yield the same list of logs. - This is useful if you need to mutate the list of logs to affect behavior in - the outer context. + Args: + clear_after: + Clear any records which were produced in this context when exiting. """ - if yield_existing: - for handler in reversed(ROOT_LOGGER.handlers): - if isinstance(handler, _LogRecordCaptor): - yield handler.records - return None - - handler = _LogRecordCaptor() original_level = ROOT_LOGGER.level - ROOT_LOGGER.setLevel(logging.DEBUG) - ROOT_LOGGER.addHandler(handler) try: - yield handler.records + if _LOG_RECORD_CAPTOR in ROOT_LOGGER.handlers: + start_index = len(_LOG_RECORD_CAPTOR.records) + try: + yield _LOG_RECORD_CAPTOR.records + finally: + end_index = len(_LOG_RECORD_CAPTOR.records) + _LOG_RECORD_CAPTOR.records[start_index:end_index] = [] + return None + + ROOT_LOGGER.addHandler(_LOG_RECORD_CAPTOR) + try: + yield _LOG_RECORD_CAPTOR.records + finally: + ROOT_LOGGER.removeHandler(_LOG_RECORD_CAPTOR) + _LOG_RECORD_CAPTOR.records.clear() finally: - ROOT_LOGGER.removeHandler(handler) ROOT_LOGGER.setLevel(original_level) -class _LogRecordCaptor(logging.NullHandler): - def __init__(self) -> None: - self.records: List[logging.LogRecord] = [] - super().__init__() - - def handle(self, record: logging.LogRecord) -> bool: - self.records.append(record) - return True - - class HookCatcher: """Utility for capturing a LifeCycleHook from a component @@ -575,3 +562,16 @@ def use( def clear_idom_web_modules_dir() -> None: for path in IDOM_WEB_MODULES_DIR.current.iterdir(): shutil.rmtree(path) if path.is_dir() else path.unlink() + + +class _LogRecordCaptor(logging.NullHandler): + def __init__(self) -> None: + self.records: List[logging.LogRecord] = [] + super().__init__() + + def handle(self, record: logging.LogRecord) -> bool: + self.records.append(record) + return True + + +_LOG_RECORD_CAPTOR = _LogRecordCaptor() diff --git a/tests/conftest.py b/tests/conftest.py index 0c34ac8de..c74c160ed 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,7 +5,12 @@ from _pytest.config.argparsing import Parser from playwright.async_api import async_playwright -from idom.testing import DisplayFixture, ServerFixture, clear_idom_web_modules_dir +from idom.testing import ( + DisplayFixture, + ServerFixture, + capture_idom_logs, + clear_idom_web_modules_dir, +) from tests.tooling.loop import open_event_loop @@ -55,3 +60,15 @@ def event_loop(): @pytest.fixture(autouse=True) def clear_web_modules_dir_after_test(): clear_idom_web_modules_dir() + + +@pytest.fixture(autouse=True) +def assert_no_logged_exceptions(): + with capture_idom_logs() as records: + yield + try: + for r in records: + if r.exc_info is not None: + raise r.exc_info[1] + finally: + records.clear() diff --git a/tests/test_core/test_hooks.py b/tests/test_core/test_hooks.py index 3364bb687..ec2e53d65 100644 --- a/tests/test_core/test_hooks.py +++ b/tests/test_core/test_hooks.py @@ -832,16 +832,14 @@ def ComponentWithRef(): assert len(used_refs) == 2 -def test_bad_schedule_render_callback(caplog): +def test_bad_schedule_render_callback(): def bad_callback(): raise ValueError("something went wrong") - hook = LifeCycleHook(bad_callback) - - hook.schedule_render() - - first_log_line = next(iter(caplog.records)).msg.split("\n", 1)[0] - assert re.match(f"Failed to schedule render via {bad_callback}", first_log_line) + with assert_idom_logged( + match_message=f"Failed to schedule render via {bad_callback}" + ): + LifeCycleHook(bad_callback).schedule_render() async def test_use_effect_automatically_infers_closure_values(): diff --git a/tests/test_core/test_layout.py b/tests/test_core/test_layout.py index 74f690e39..ad7f767d4 100644 --- a/tests/test_core/test_layout.py +++ b/tests/test_core/test_layout.py @@ -183,7 +183,7 @@ def BadChild(): with assert_idom_logged( match_error="error from bad child", - clear_matched_records=True, + clear_after=True, ): with idom.Layout(Main()) as layout: @@ -242,7 +242,7 @@ def BadChild(): with assert_idom_logged( match_error="error from bad child", - clear_matched_records=True, + clear_after=True, ): with idom.Layout(Main()) as layout: @@ -743,7 +743,7 @@ def ComponentReturnsDuplicateKeys(): with assert_idom_logged( error_type=ValueError, match_error=r"Duplicate keys \['duplicate'\] at '/children/0'", - clear_matched_records=True, + clear_after=True, ): await layout.render() @@ -757,7 +757,7 @@ def ComponentReturnsDuplicateKeys(): with assert_idom_logged( error_type=ValueError, match_error=r"Duplicate keys \['duplicate'\] at '/children/0'", - clear_matched_records=True, + clear_after=True, ): await layout.render() @@ -798,7 +798,7 @@ def raise_error(): with assert_idom_logged( match_error="bad event handler", - clear_matched_records=True, + clear_after=True, ): with idom.Layout(ComponentWithBadEventHandler()) as layout: @@ -807,7 +807,7 @@ def raise_error(): await layout.deliver(event) -async def test_schedule_render_from_unmounted_hook(caplog): +async def test_schedule_render_from_unmounted_hook(): parent_set_state = idom.Ref() @idom.component @@ -1233,7 +1233,7 @@ def bad_should_render(new): match_message=r".* component failed to check if .* should be rendered", error_type=ValueError, match_error="The error message", - clear_matched_records=True, + clear_after=True, ): with idom.Layout(Root()) as layout: await layout.render() diff --git a/tests/test_web/test_utils.py b/tests/test_web/test_utils.py index ce6badf2b..5286db53d 100644 --- a/tests/test_web/test_utils.py +++ b/tests/test_web/test_utils.py @@ -3,6 +3,7 @@ import pytest import responses +from idom.testing import assert_idom_logged from idom.web.utils import ( module_name_suffix, resolve_module_exports_from_file, @@ -145,9 +146,8 @@ def test_resolve_module_exports_from_source(): ) and references == {"https://source1.com", "https://source2.com"} -def test_log_on_unknown_export_type(caplog): - assert resolve_module_exports_from_source( - "export something unknown;", exclude_default=False - ) == (set(), set()) - assert len(caplog.records) == 1 - assert caplog.records[0].message.startswith("Unknown export type ") +def test_log_on_unknown_export_type(): + with assert_idom_logged(match_message="Unknown export type "): + assert resolve_module_exports_from_source( + "export something unknown;", exclude_default=False + ) == (set(), set()) diff --git a/tests/test_widgets.py b/tests/test_widgets.py index cbb1318b0..2a7bc8024 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -1,4 +1,3 @@ -import asyncio from base64 import b64encode from pathlib import Path @@ -9,10 +8,6 @@ HERE = Path(__file__).parent -def test_multiview_repr(): - assert str(idom.widgets.MultiViewMount({})) == "MultiViewMount({})" - - async def test_hostwap_update_on_change(display: DisplayFixture): """Ensure shared hotswapping works From 45f4949fdf8693ea384e098307929ed2cfa99ba5 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Wed, 23 Mar 2022 23:10:49 -0700 Subject: [PATCH 20/58] misc fixes in order to get reconnect test to work --- src/idom/server/_asgi.py | 14 +++------- src/idom/testing.py | 51 ++++++++++++++++++----------------- tests/test_client.py | 41 ++++++++++++++++++---------- tests/test_html.py | 16 ++++++----- tests/test_sample.py | 5 ++-- tests/test_web/test_module.py | 34 ++++++++++++----------- tests/test_widgets.py | 46 +++++++++++++++---------------- 7 files changed, 107 insertions(+), 100 deletions(-) diff --git a/src/idom/server/_asgi.py b/src/idom/server/_asgi.py index 9133d7035..77278c171 100644 --- a/src/idom/server/_asgi.py +++ b/src/idom/server/_asgi.py @@ -22,15 +22,7 @@ async def check_if_started(): await asyncio.sleep(0.2) started.set() - coros = [server.serve(), check_if_started()] - _, pending = await asyncio.wait( - list(map(asyncio.create_task, coros)), return_when=FIRST_EXCEPTION - ) - - for task in pending: - task.cancel() - try: - await asyncio.gather(*list(pending)) - except CancelledError: - pass + await asyncio.gather(server.serve(), check_if_started()) + finally: + await asyncio.wait_for(server.shutdown(), timeout=3) diff --git a/src/idom/testing.py b/src/idom/testing.py index 2a6f02237..2b7670324 100644 --- a/src/idom/testing.py +++ b/src/idom/testing.py @@ -136,7 +136,7 @@ def __init__( self.server = server if driver is not None: if isinstance(driver, Page): - self._page = driver + self.page = driver else: self._browser = driver self._next_view_id = 0 @@ -145,29 +145,27 @@ async def show( self, component: RootComponentConstructor, query: dict[str, Any] | None = None, - ) -> Page: + ) -> None: self._next_view_id += 1 view_id = f"display-{self._next_view_id}" self.server.mount(lambda: html.div({"id": view_id}, component())) - await self._page.goto(self.server.url(query=query)) - await self._page.wait_for_selector(f"#{view_id}", state="attached") - - return self._page + await self.page.goto(self.server.url(query=query)) + await self.page.wait_for_selector(f"#{view_id}", state="attached") - async def __aenter__(self: _Self) -> _Self: + async def __aenter__(self) -> DisplayFixture: es = self._exit_stack = AsyncExitStack() - if not hasattr(self, "_page"): + if not hasattr(self, "page"): if not hasattr(self, "_browser"): pw = await es.enter_async_context(async_playwright()) browser = await pw.chromium.launch() else: browser = self._browser - self._page = await browser.new_page() + self.page = await browser.new_page() if not hasattr(self, "server"): - self.server = ServerFixture(**self._server_options) + self.server = ServerFixture() await es.enter_async_context(self.server) return self @@ -196,7 +194,7 @@ class ServerFixture: _records: list[logging.LogRecord] _server_future: asyncio.Task[Any] - _exit_stack = ExitStack() + _exit_stack = AsyncExitStack() def __init__( self, @@ -205,7 +203,6 @@ def __init__( app: Any | None = None, implementation: ServerImplementation[Any] = default_server, ) -> None: - self.server_implementation = implementation self.host = host self.port = port or find_available_port(host, allow_reuse_waiting_ports=False) self.mount, self._root_component = hotswap() @@ -216,7 +213,9 @@ def __init__( "If an application instance its corresponding " "server implementation must be provided too." ) + self._app = app + self._server_implementation = implementation @property def log_records(self) -> List[logging.LogRecord]: @@ -270,21 +269,28 @@ def list_logged_exceptions( found.append(error) return found - async def __aenter__(self: _Self) -> _Self: - self._exit_stack = ExitStack() + async def __aenter__(self) -> ServerFixture: + self._exit_stack = AsyncExitStack() self._records = self._exit_stack.enter_context(capture_idom_logs()) - app = self._app or self.server_implementation.create_development_app() - self.server_implementation.configure(app, self._root_component) + app = self._app or self._server_implementation.create_development_app() + self._server_implementation.configure(app, self._root_component) started = asyncio.Event() - self._server_future = asyncio.ensure_future( - self.server_implementation.serve_development_app( + server_future = asyncio.create_task( + self._server_implementation.serve_development_app( app, self.host, self.port, started ) ) - self._exit_stack.callback(self._server_future.cancel) + async def stop_server(): + server_future.cancel() + try: + await asyncio.wait_for(server_future, timeout=3) + except asyncio.CancelledError: + pass + + self._exit_stack.push_async_callback(stop_server) try: await asyncio.wait_for(started.wait(), timeout=3) @@ -301,15 +307,10 @@ async def __aexit__( exc_value: Optional[BaseException], traceback: Optional[TracebackType], ) -> None: - self._exit_stack.close() + await self._exit_stack.aclose() self.mount(None) # reset the view - try: - await asyncio.wait_for(self._server_future, timeout=3) - except asyncio.CancelledError: - pass - logged_errors = self.list_logged_exceptions(del_log_records=False) if logged_errors: # pragma: no cover raise LogAssertionError("Unexpected logged exception") from logged_errors[0] diff --git a/tests/test_client.py b/tests/test_client.py index df5b43d59..35f90e554 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,32 +1,40 @@ import asyncio import time +from contextlib import AsyncExitStack from pathlib import Path +from playwright.async_api import Browser + import idom -from idom.testing import ServerFixture +from idom.testing import DisplayFixture, ServerFixture JS_DIR = Path(__file__).parent / "js" -async def test_automatic_reconnect(create_driver): - # we need to wait longer here because the automatic reconnect is not instance - driver = create_driver(implicit_wait_timeout=10, page_load_timeout=10) +async def test_automatic_reconnect(browser: Browser): + page = await browser.new_page() + + # we need to wait longer here because the automatic reconnect is not instant + page.set_default_timeout(10000) @idom.component def OldComponent(): return idom.html.p({"id": "old-component"}, "old") - mount_point = ServerFixture() + async with AsyncExitStack() as exit_stack: + server = await exit_stack.enter_async_context(ServerFixture(port=8000)) + display = await exit_stack.enter_async_context( + DisplayFixture(server, driver=page) + ) + + await display.show(OldComponent) - async with mount_point: - mount_point.mount(OldComponent) - driver.get(mount_point.url()) # ensure the element is displayed before stopping the server - driver.find_element("id", "old-component") + await page.wait_for_selector("#old-component") # the server is disconnected but the last view state is still shown - driver.find_element("id", "old-component") + await page.wait_for_selector("#old-component") set_state = idom.Ref(None) @@ -35,16 +43,21 @@ def NewComponent(): state, set_state.current = idom.hooks.use_state(0) return idom.html.p({"id": f"new-component-{state}"}, f"new-{state}") - with mount_point: - mount_point.mount(NewComponent) + async with AsyncExitStack() as exit_stack: + server = await exit_stack.enter_async_context(ServerFixture(port=8000)) + display = await exit_stack.enter_async_context( + DisplayFixture(server, driver=page) + ) + + await display.show(NewComponent) # Note the lack of a page refresh before looking up this new component. The # client should attempt to reconnect and display the new view automatically. - driver.find_element("id", "new-component-0") + await page.wait_for_selector("#new-component-0") # check that we can resume normal operation set_state.current(1) - driver.find_element("id", "new-component-1") + await page.wait_for_selector("#new-component-1") def test_style_can_be_changed(display, driver, driver_wait): diff --git a/tests/test_html.py b/tests/test_html.py index cfedef29a..c4d74d86b 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -39,9 +39,9 @@ def HasScript(): }""" ) - page = await display.show(Root) + await display.show(Root) - mount_state = await page.wait_for_selector("#mount-state", state="attached") + mount_state = await display.page.wait_for_selector("#mount-state", state="attached") poll_mount_state = poll(mount_state.get_attribute, "data-value") await poll_mount_state.until_equals("true") @@ -74,12 +74,14 @@ def HasScript(): ), ) - page = await display.show(HasScript) + await display.show(HasScript) - mount_count = await page.wait_for_selector("#mount-count", state="attached") + mount_count = await display.page.wait_for_selector("#mount-count", state="attached") poll_mount_count = poll(mount_count.get_attribute, "data-value") - unmount_count = await page.wait_for_selector("#unmount-count", state="attached") + unmount_count = await display.page.wait_for_selector( + "#unmount-count", state="attached" + ) poll_unmount_count = poll(unmount_count.get_attribute, "data-value") await poll_mount_count.until_equals("1") @@ -114,7 +116,7 @@ def HasScript(): ), ) - page = await display.show(HasScript) + await display.show(HasScript) for i in range(1, 4): script_file = config.IDOM_WEB_MODULES_DIR.current / file_name_template.format( @@ -129,7 +131,7 @@ def HasScript(): incr_src_id.current() - run_count = await page.wait_for_selector("#run-count", state="attached") + run_count = await display.page.wait_for_selector("#run-count", state="attached") poll_run_count = poll(run_count.get_attribute, "data-value") await poll_run_count.until_equals("1") diff --git a/tests/test_sample.py b/tests/test_sample.py index 46c8583f5..6a68cec22 100644 --- a/tests/test_sample.py +++ b/tests/test_sample.py @@ -3,6 +3,7 @@ async def test_sample_app(display: DisplayFixture): - page = await display.show(App) - h1 = await page.wait_for_selector("h1") + await display.show(App) + + h1 = await display.page.wait_for_selector("h1") assert (await h1.text_content()) == "Sample Application" diff --git a/tests/test_web/test_module.py b/tests/test_web/test_module.py index 5c3e952d2..ad4d1c957 100644 --- a/tests/test_web/test_module.py +++ b/tests/test_web/test_module.py @@ -40,19 +40,19 @@ def ShowCurrentComponent(): ) return current_component - page = await display.show(ShowCurrentComponent) + await display.show(ShowCurrentComponent) - await page.wait_for_selector("#some-component", state="attached") + await display.page.wait_for_selector("#some-component", state="attached") set_current_component.current( idom.html.h1({"id": "some-other-component"}, "some other component") ) # the new component has been displayed - await page.wait_for_selector("#some-other-component", state="attached") + await display.page.wait_for_selector("#some-other-component", state="attached") # the unmount callback for the old component was called - await page.wait_for_selector("#unmount-flag", state="attached") + await display.page.wait_for_selector("#unmount-flag", state="attached") async def test_module_from_url(browser): @@ -76,8 +76,9 @@ def ShowSimpleButton(): async with ServerFixture(app=app, implementation=sanic_implementation) as server: async with DisplayFixture(server, browser) as display: - page = await display.show(ShowSimpleButton) - await page.wait_for_selector("#my-button") + await display.show(ShowSimpleButton) + + await display.page.wait_for_selector("#my-button") def test_module_from_template_where_template_does_not_exist(): @@ -88,8 +89,9 @@ def test_module_from_template_where_template_does_not_exist(): async def test_module_from_template(display: DisplayFixture): victory = idom.web.module_from_template("react", "victory-bar@35.4.0") VictoryBar = idom.web.export(victory, "VictoryBar") - page = await display.show(VictoryBar) - await page.wait_for_selector(".VictoryContainer") + await display.show(VictoryBar) + + await display.page.wait_for_selector(".VictoryContainer") async def test_module_from_file(display: DisplayFixture): @@ -108,9 +110,9 @@ def ShowSimpleButton(): {"id": "my-button", "onClick": lambda event: is_clicked.set_current(True)} ) - page = await display.show(ShowSimpleButton) + await display.show(ShowSimpleButton) - button = await page.wait_for_selector("#my-button") + button = await display.page.wait_for_selector("#my-button") await button.click() poll(lambda: is_clicked.current).until_is(True) @@ -198,13 +200,13 @@ async def test_module_exports_multiple_components(display: DisplayFixture): ["Header1", "Header2"], ) - page = await display.show(lambda: Header1({"id": "my-h1"}, "My Header 1")) + await display.show(lambda: Header1({"id": "my-h1"}, "My Header 1")) - await page.wait_for_selector("#my-h1", state="attached") + await display.page.wait_for_selector("#my-h1", state="attached") - page = await display.show(lambda: Header2({"id": "my-h2"}, "My Header 2")) + await display.show(lambda: Header2({"id": "my-h2"}, "My Header 2")) - await page.wait_for_selector("#my-h2", state="attached") + await display.page.wait_for_selector("#my-h2", state="attached") async def test_imported_components_can_render_children(display: DisplayFixture): @@ -213,7 +215,7 @@ async def test_imported_components_can_render_children(display: DisplayFixture): ) Parent, Child = idom.web.export(module, ["Parent", "Child"]) - page = await display.show( + await display.show( lambda: Parent( Child({"index": 1}), Child({"index": 2}), @@ -221,7 +223,7 @@ async def test_imported_components_can_render_children(display: DisplayFixture): ) ) - parent = await page.wait_for_selector("#the-parent", state="attached") + parent = await display.page.wait_for_selector("#the-parent", state="attached") children = await parent.query_selector_all("li") assert len(children) == 3 diff --git a/tests/test_widgets.py b/tests/test_widgets.py index 2a7bc8024..ab8d99f99 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -42,15 +42,15 @@ async def on_click(event): return idom.html.div(incr, hotswap_view) - page = await display.show(ButtonSwapsDivs) + await display.show(ButtonSwapsDivs) - client_incr_button = await page.wait_for_selector("#incr-button") + client_incr_button = await display.page.wait_for_selector("#incr-button") - await page.wait_for_selector("#hotswap-1") + await display.page.wait_for_selector("#hotswap-1") await client_incr_button.click() - await page.wait_for_selector("#hotswap-2") + await display.page.wait_for_selector("#hotswap-2") await client_incr_button.click() - await page.wait_for_selector("#hotswap-3") + await display.page.wait_for_selector("#hotswap-3") IMAGE_SRC_BYTES = b""" @@ -63,19 +63,15 @@ async def on_click(event): async def test_image_from_string(display: DisplayFixture): src = IMAGE_SRC_BYTES.decode() - page = await display.show( - lambda: idom.widgets.image("svg", src, {"id": "a-circle-1"}) - ) - client_img = await page.wait_for_selector("#a-circle-1") + await display.show(lambda: idom.widgets.image("svg", src, {"id": "a-circle-1"})) + client_img = await display.page.wait_for_selector("#a-circle-1") assert BASE64_IMAGE_SRC in (await client_img.get_attribute("src")) async def test_image_from_bytes(display: DisplayFixture): src = IMAGE_SRC_BYTES - page = await display.show( - lambda: idom.widgets.image("svg", src, {"id": "a-circle-1"}) - ) - client_img = await page.wait_for_selector("#a-circle-1") + await display.show(lambda: idom.widgets.image("svg", src, {"id": "a-circle-1"})) + client_img = await display.page.wait_for_selector("#a-circle-1") assert BASE64_IMAGE_SRC in (await client_img.get_attribute("src")) @@ -85,10 +81,10 @@ def SomeComponent(): i_1, i_2 = idom.widgets.use_linked_inputs([{"id": "i_1"}, {"id": "i_2"}]) return idom.html.div(i_1, i_2) - page = await display.show(SomeComponent) + await display.show(SomeComponent) - input_1 = await page.wait_for_selector("#i_1") - input_2 = await page.wait_for_selector("#i_2") + input_1 = await display.page.wait_for_selector("#i_1") + input_2 = await display.page.wait_for_selector("#i_2") await input_1.type("hello", delay=20) @@ -113,10 +109,10 @@ def SomeComponent(): ) return idom.html.div(i_1, i_2) - page = await display.show(SomeComponent) + await display.show(SomeComponent) - input_1 = await page.wait_for_selector("#i_1") - input_2 = await page.wait_for_selector("#i_2") + input_1 = await display.page.wait_for_selector("#i_1") + input_2 = await display.page.wait_for_selector("#i_2") await input_1.type("hello", delay=20) @@ -140,10 +136,10 @@ def SomeComponent(): ) return idom.html.div(i_1, i_2) - page = await display.show(SomeComponent) + await display.show(SomeComponent) - input_1 = await page.wait_for_selector("#i_1") - input_2 = await page.wait_for_selector("#i_2") + input_1 = await display.page.wait_for_selector("#i_1") + input_2 = await display.page.wait_for_selector("#i_2") await input_1.type("1") @@ -169,10 +165,10 @@ def SomeComponent(): ) return idom.html.div(i_1, i_2) - page = await display.show(SomeComponent) + await display.show(SomeComponent) - input_1 = await page.wait_for_selector("#i_1") - input_2 = await page.wait_for_selector("#i_2") + input_1 = await display.page.wait_for_selector("#i_1") + input_2 = await display.page.wait_for_selector("#i_2") await input_1.type("1") From c3c7cdbac922db25b0a4c6ae6e0683e440aa5f43 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Fri, 25 Mar 2022 23:37:31 -0700 Subject: [PATCH 21/58] expose use_scope instead of use_connection --- src/idom/server/_conn.py | 9 --------- src/idom/server/default.py | 4 ++-- src/idom/server/fastapi.py | 4 ++-- src/idom/server/flask.py | 25 +++++++++++++++++-------- src/idom/server/sanic.py | 24 +++++++++++++++++------- src/idom/server/starlette.py | 30 ++++++++++++++++++------------ src/idom/server/tornado.py | 29 ++++++++++++++++++++--------- src/idom/server/types.py | 10 ++++++---- 8 files changed, 82 insertions(+), 53 deletions(-) delete mode 100644 src/idom/server/_conn.py diff --git a/src/idom/server/_conn.py b/src/idom/server/_conn.py deleted file mode 100644 index 2c9f6726c..000000000 --- a/src/idom/server/_conn.py +++ /dev/null @@ -1,9 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from idom.core.hooks import create_context -from idom.types import Context - - -Connection: type[Context[Any | None]] = create_context(None, name="Connection") diff --git a/src/idom/server/default.py b/src/idom/server/default.py index 680b92163..23955b070 100644 --- a/src/idom/server/default.py +++ b/src/idom/server/default.py @@ -28,8 +28,8 @@ async def serve_development_app( ) -def use_connection() -> Any: - return _default_implementation().use_connection() +def use_scope() -> Any: + return _default_implementation().use_scope() def _default_implementation() -> ServerImplementation[Any]: diff --git a/src/idom/server/fastapi.py b/src/idom/server/fastapi.py index e6bb63729..7197f5e3b 100644 --- a/src/idom/server/fastapi.py +++ b/src/idom/server/fastapi.py @@ -11,7 +11,7 @@ _setup_options, _setup_single_view_dispatcher_route, serve_development_app, - use_connection, + use_scope, ) @@ -19,7 +19,7 @@ "configure", "serve_development_app", "create_development_app", - "use_connection", + "use_scope", ) diff --git a/src/idom/server/flask.py b/src/idom/server/flask.py index 238d2be4c..552f77b42 100644 --- a/src/idom/server/flask.py +++ b/src/idom/server/flask.py @@ -9,6 +9,7 @@ from threading import Thread from typing import Any, Callable, Dict, NamedTuple, Optional, Union, cast from urllib.parse import parse_qs as parse_query_string +from wsgiref.types import WSGIEnvironment from flask import ( Blueprint, @@ -29,17 +30,18 @@ import idom from idom.config import IDOM_WEB_MODULES_DIR -from idom.core.hooks import use_context +from idom.core.hooks import Context, create_context, use_context from idom.core.layout import LayoutEvent, LayoutUpdate from idom.core.serve import serve_json_patch from idom.core.types import ComponentType, RootComponentConstructor -from ._conn import Connection from .utils import CLIENT_BUILD_DIR logger = logging.getLogger(__name__) +RequestContext: type[Context[Request | None]] = create_context(None, "RequestContext") + def configure( app: Flask, component: RootComponentConstructor, options: Options | None = None @@ -105,12 +107,17 @@ def run_server(): raise RuntimeError("Failed to shutdown server.") -def use_connection() -> Request: +def use_request() -> Request: """Get the current ``Request``""" - value = use_context(Connection) - if value is None: - raise RuntimeError("No established connection.") - return value + request = use_context(RequestContext) + if request is None: + raise RuntimeError("No request. Are you running with a Flask server?") + return request + + +def use_scope() -> WSGIEnvironment: + """Get the current WSGI environment""" + return use_request().environ class Options(TypedDict, total=False): @@ -221,7 +228,9 @@ async def recv_coro() -> Any: async def main() -> None: await serve_json_patch( - idom.Layout(Connection(component, value=request)), send_coro, recv_coro + idom.Layout(RequestContext(component, value=request)), + send_coro, + recv_coro, ) main_future = asyncio.ensure_future(main()) diff --git a/src/idom/server/sanic.py b/src/idom/server/sanic.py index 0d6daa62a..1ab08f21d 100644 --- a/src/idom/server/sanic.py +++ b/src/idom/server/sanic.py @@ -9,10 +9,12 @@ from mypy_extensions import TypedDict from sanic import Blueprint, Sanic, request, response +from sanic.models.asgi import ASGIScope from sanic_cors import CORS from websockets.legacy.protocol import WebSocketCommonProtocol from idom.config import IDOM_WEB_MODULES_DIR +from idom.core.hooks import Context, create_context, use_context from idom.core.layout import Layout, LayoutEvent from idom.core.serve import ( RecvCoroutine, @@ -22,12 +24,15 @@ ) from idom.core.types import RootComponentConstructor -from ._conn import Connection from .utils import CLIENT_BUILD_DIR logger = logging.getLogger(__name__) +RequestContext: type[Context[request.Request | None]] = create_context( + None, "RequestContext" +) + def configure( app: Sanic, component: RootComponentConstructor, options: Options | None = None @@ -64,12 +69,17 @@ async def serve_development_app( app.stop() -def use_connection() -> request.Request: +def use_request() -> request.Request: """Get the current ``Request``""" - value = use_connection(Connection) - if value is None: - raise RuntimeError("No established connection.") - return value + request = use_context(RequestContext) + if request is None: + raise RuntimeError("No request. Are you running with a Sanic server?") + return request + + +def use_scope() -> ASGIScope: + """Get the current ASGI scope""" + return use_request().app._asgi_app.transport.scope class Options(TypedDict, total=False): @@ -132,7 +142,7 @@ async def model_stream( send, recv = _make_send_recv_callbacks(socket) component_params = {k: request.args.get(k) for k in request.args} await serve_json_patch( - Layout(Connection(constructor(**component_params), value=request)), + Layout(RequestContext(constructor(**component_params), value=request)), send, recv, ) diff --git a/src/idom/server/starlette.py b/src/idom/server/starlette.py index 5787a6cf7..d9ed9c20f 100644 --- a/src/idom/server/starlette.py +++ b/src/idom/server/starlette.py @@ -3,7 +3,6 @@ import asyncio import json import logging -from asyncio import FIRST_EXCEPTION, CancelledError from typing import Any, Dict, Tuple, Union from mypy_extensions import TypedDict @@ -12,12 +11,11 @@ from starlette.requests import Request from starlette.responses import RedirectResponse from starlette.staticfiles import StaticFiles +from starlette.types import Scope from starlette.websockets import WebSocket, WebSocketDisconnect -from uvicorn.config import Config as UvicornConfig -from uvicorn.server import Server as UvicornServer from idom.config import IDOM_WEB_MODULES_DIR -from idom.core.hooks import use_context +from idom.core.hooks import Context, create_context, use_context from idom.core.layout import Layout, LayoutEvent from idom.core.serve import ( RecvCoroutine, @@ -28,12 +26,15 @@ from idom.core.types import RootComponentConstructor from ._asgi import serve_development_asgi -from ._conn import Connection from .utils import CLIENT_BUILD_DIR logger = logging.getLogger(__name__) +WebSocketContext: type[Context[WebSocket | None]] = create_context( + None, "WebSocketContext" +) + def configure( app: Starlette, @@ -69,12 +70,17 @@ async def serve_development_app( await serve_development_asgi(app, host, port, started) -def use_connection() -> WebSocket: - """Get the current ``WebSocket`` connection""" - value = use_context(Connection) - if value is None: - raise RuntimeError("No established connection.") - return value +def use_websocket() -> WebSocket: + """Get the current WebSocket object""" + websocket = use_context(WebSocketContext) + if websocket is None: + raise RuntimeError("No websocket. Are you running with a Starllette server?") + return websocket + + +def use_scope() -> Scope: + """Get the current ASGI scope dictionary""" + return use_websocket().scope class Options(TypedDict, total=False): @@ -155,7 +161,7 @@ async def model_stream(socket: WebSocket) -> None: send, recv = _make_send_recv_callbacks(socket) try: await serve_json_patch( - Layout(Connection(constructor(), value=socket)), + Layout(WebSocketContext(constructor(), value=socket)), send, recv, ) diff --git a/src/idom/server/tornado.py b/src/idom/server/tornado.py index a8b604095..6dbb13371 100644 --- a/src/idom/server/tornado.py +++ b/src/idom/server/tornado.py @@ -12,18 +12,23 @@ from tornado.platform.asyncio import AsyncIOMainLoop from tornado.web import Application, RedirectHandler, RequestHandler, StaticFileHandler from tornado.websocket import WebSocketHandler +from tornado.wsgi import WSGIContainer from typing_extensions import TypedDict from idom.config import IDOM_WEB_MODULES_DIR -from idom.core.hooks import use_context +from idom.core.hooks import Context, create_context, use_context from idom.core.layout import Layout, LayoutEvent from idom.core.serve import VdomJsonPatch, serve_json_patch from idom.core.types import ComponentConstructor -from ._conn import Connection from .utils import CLIENT_BUILD_DIR +RequestContext: type[Context[HTTPServerRequest | None]] = create_context( + None, "RequestContext" +) + + def configure( app: Application, component: ComponentConstructor, @@ -72,12 +77,17 @@ async def serve_development_app( await server.close_all_connections() -def use_connection() -> HTTPServerRequest: +def use_request() -> HTTPServerRequest: """Get the current ``HTTPServerRequest``""" - value = use_context(Connection) - if value is None: - raise RuntimeError("No established connection.") - return value + request = use_context(RequestContext) + if request is None: + raise RuntimeError("No request. Are you running with a Tornado server?") + return request + + +def use_scope() -> dict[str, Any]: + """Get the current WSGI environment dictionary""" + return WSGIContainer.environ(use_request()) class Options(TypedDict, total=False): @@ -160,7 +170,6 @@ def initialize(self, component_constructor: ComponentConstructor) -> None: async def open(self, *args: str, **kwargs: str) -> None: message_queue: "AsyncQueue[str]" = AsyncQueue() - query_params = {k: v[0].decode() for k, v in self.request.arguments.items()} async def send(value: VdomJsonPatch) -> None: await self.write_message(json.dumps(value)) @@ -171,7 +180,9 @@ async def recv() -> LayoutEvent: self._message_queue = message_queue self._dispatch_future = asyncio.ensure_future( serve_json_patch( - Layout(Connection(self._component_constructor(), value=self.request)), + Layout( + RequestContext(self._component_constructor(), value=self.request) + ), send, recv, ) diff --git a/src/idom/server/types.py b/src/idom/server/types.py index afb5abc66..5d88b380c 100644 --- a/src/idom/server/types.py +++ b/src/idom/server/types.py @@ -1,9 +1,11 @@ +from __future__ import annotations + import asyncio -from typing import Any, TypeVar +from typing import Any, MutableMapping, TypeVar from typing_extensions import Protocol, runtime_checkable -from idom.core.types import RootComponentConstructor +from idom.types import RootComponentConstructor _App = TypeVar("_App") @@ -24,5 +26,5 @@ async def serve_development_app( ) -> None: """Run an application using a development server""" - def use_connection() -> Any: - """Get information about a currently active request to the server""" + def use_scope() -> MutableMapping[str, Any]: + """Get an ASGI scope or WSGI environment dictionary""" From fa764f8d59f2e475a28ed94e1a8191649eb64734 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sat, 26 Mar 2022 00:18:32 -0700 Subject: [PATCH 22/58] fix test common --- requirements/test-env.txt | 4 ++- src/idom/server/sanic.py | 6 ++--- src/idom/testing.py | 2 +- tests/test_server/test_common.py | 44 +++++++++++++++----------------- 4 files changed, 27 insertions(+), 29 deletions(-) diff --git a/requirements/test-env.txt b/requirements/test-env.txt index 48953a724..7e5ab1955 100644 --- a/requirements/test-env.txt +++ b/requirements/test-env.txt @@ -1,4 +1,3 @@ -ipython pytest pytest-asyncio>=0.17 pytest-cov @@ -7,3 +6,6 @@ pytest-rerunfailures pytest-timeout responses playwright + +# I'm not quite sure why this needs to be installed for tests with Sanic to pass +sanic-testing diff --git a/src/idom/server/sanic.py b/src/idom/server/sanic.py index 1ab08f21d..e4f19b645 100644 --- a/src/idom/server/sanic.py +++ b/src/idom/server/sanic.py @@ -3,12 +3,12 @@ import asyncio import json import logging -import os -import socket from typing import Any, Dict, Tuple, Union +from uuid import uuid4 from mypy_extensions import TypedDict from sanic import Blueprint, Sanic, request, response +from sanic.config import Config from sanic.models.asgi import ASGIScope from sanic_cors import CORS from websockets.legacy.protocol import WebSocketCommonProtocol @@ -49,7 +49,7 @@ def configure( def create_development_app() -> Sanic: """Return a :class:`Sanic` app instance in debug mode""" - return Sanic("idom_development_app") + return Sanic(f"idom_development_app_{uuid4().hex}", Config()) async def serve_development_app( diff --git a/src/idom/testing.py b/src/idom/testing.py index 2b7670324..50054f1b0 100644 --- a/src/idom/testing.py +++ b/src/idom/testing.py @@ -296,7 +296,7 @@ async def stop_server(): await asyncio.wait_for(started.wait(), timeout=3) except Exception: # see if we can await the future for a more helpful error - await asyncio.wait_for(self._server_future, timeout=3) + await asyncio.wait_for(server_future, timeout=3) raise return self diff --git a/tests/test_server/test_common.py b/tests/test_server/test_common.py index 154ff5f6a..6a338a3f0 100644 --- a/tests/test_server/test_common.py +++ b/tests/test_server/test_common.py @@ -2,61 +2,57 @@ import idom from idom.server.utils import all_implementations -from idom.testing import ServerFixture +from idom.testing import DisplayFixture, ServerFixture, poll @pytest.fixture( params=list(all_implementations()), ids=lambda imp: imp.__name__, ) -def server_mount_point(request): - with ServerFixture(request.param) as mount_point: - yield mount_point +async def display(page, request): + async with ServerFixture(implementation=request.param) as server: + async with DisplayFixture(server=server, driver=page) as display: + yield display -def test_display_simple_hello_world(driver, display): +async def test_display_simple_hello_world(display: DisplayFixture): @idom.component def Hello(): return idom.html.p({"id": "hello"}, ["Hello World"]) - display(Hello) + await display.show(Hello) - assert driver.find_element("id", "hello") + await display.page.wait_for_selector("#hello") # test that we can reconnect succefully - driver.refresh() + await display.page.reload() - assert driver.find_element("id", "hello") + await display.page.wait_for_selector("#hello") -def test_display_simple_click_counter(driver, driver_wait, display): - def increment(count): - return count + 1 - +async def test_display_simple_click_counter(display: DisplayFixture): @idom.component def Counter(): count, set_count = idom.hooks.use_state(0) return idom.html.button( { "id": "counter", - "onClick": lambda event: set_count(increment), + "onClick": lambda event: set_count(lambda old_count: old_count + 1), }, f"Count: {count}", ) - display(Counter) + await display.show(Counter) - client_counter = driver.find_element("id", "counter") + counter = await display.page.wait_for_selector("#counter") - for i in range(3): - driver_wait.until( - lambda driver: client_counter.get_attribute("innerHTML") == f"Count: {i}" - ) - client_counter.click() + for i in range(5): + assert (await counter.text_content()) == f"Count: {i}" + await counter.click() -def test_module_from_template(driver, display): +async def test_module_from_template(display: DisplayFixture): victory = idom.web.module_from_template("react", "victory-bar@35.4.0") VictoryBar = idom.web.export(victory, "VictoryBar") - display(VictoryBar) - driver.find_element_by_class_name("VictoryContainer") + await display.show(VictoryBar) + await display.page.wait_for_selector(".VictoryContainer") From 7ddc2c8e7abc9390e32351d56917488f417525a1 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sat, 26 Mar 2022 00:29:34 -0700 Subject: [PATCH 23/58] fix test_client --- tests/test_client.py | 30 +++++++++++++----------------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/tests/test_client.py b/tests/test_client.py index 35f90e554..3c5ccb6df 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -60,7 +60,7 @@ def NewComponent(): await page.wait_for_selector("#new-component-1") -def test_style_can_be_changed(display, driver, driver_wait): +async def test_style_can_be_changed(display: DisplayFixture): """This test was introduced to verify the client does not mutate the model A bug was introduced where the client-side model was mutated and React was relying @@ -82,24 +82,24 @@ def ButtonWithChangingColor(): f"color: {color}", ) - display(ButtonWithChangingColor) + await display.show(ButtonWithChangingColor) - button = driver.find_element("id", "my-button") + button = await display.page.wait_for_selector("#my-button") - assert _get_style(button)["background-color"] == "red" + assert (await _get_style(button))["background-color"] == "red" for color in ["blue", "red"] * 2: - button.click() - driver_wait.until(lambda _: _get_style(button)["background-color"] == color) + await button.click() + assert (await _get_style(button))["background-color"] == color -def _get_style(element): - items = element.get_attribute("style").split(";") +async def _get_style(element): + items = (await element.get_attribute("style")).split(";") pairs = [item.split(":", 1) for item in map(str.strip, items) if item] return {key.strip(): value.strip() for key, value in pairs} -def test_slow_server_response_on_input_change(display, driver, driver_wait): +async def test_slow_server_response_on_input_change(display: DisplayFixture): """A delay server-side could cause input values to be overwritten. For more info see: https://github.com/idom-team/idom/issues/684 @@ -117,13 +117,9 @@ async def handle_change(event): return idom.html.input({"onChange": handle_change, "id": "test-input"}) - display(SomeComponent) + await display.show(SomeComponent) - inp = driver.find_element("id", "test-input") + inp = await display.page.wait_for_selector("#test-input") + await inp.type("hello") - text = "hello" - send_keys(inp, text) - - time.sleep(delay * len(text) * 1.1) - - driver_wait.until(lambda _: inp.get_attribute("value") == "hello") + assert (await inp.evaluate("node => node.value")) == "hello" From 04236f5f35424a90477b4bce1090398b895966b5 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sat, 26 Mar 2022 00:48:19 -0700 Subject: [PATCH 24/58] test use_scope --- src/idom/server/sanic.py | 20 ++++++++------------ src/idom/testing.py | 8 ++++---- tests/test_server/test_common.py | 19 +++++++++++++++++++ 3 files changed, 31 insertions(+), 16 deletions(-) diff --git a/src/idom/server/sanic.py b/src/idom/server/sanic.py index e4f19b645..b24d8ae8c 100644 --- a/src/idom/server/sanic.py +++ b/src/idom/server/sanic.py @@ -24,6 +24,7 @@ ) from idom.core.types import RootComponentConstructor +from ._asgi import serve_development_asgi from .utils import CLIENT_BUILD_DIR @@ -56,17 +57,7 @@ async def serve_development_app( app: Sanic, host: str, port: int, started: asyncio.Event ) -> None: """Run a development server for :mod:`sanic`""" - try: - server = await app.create_server( - host, port, return_asyncio_server=True, debug=True - ) - await server.startup() - await server.start_serving() - started.set() - await server.serve_forever() - except KeyboardInterrupt: - app.shutdown_tasks(3) - app.stop() + await serve_development_asgi(app, host, port, started) def use_request() -> request.Request: @@ -79,7 +70,12 @@ def use_request() -> request.Request: def use_scope() -> ASGIScope: """Get the current ASGI scope""" - return use_request().app._asgi_app.transport.scope + app = use_request().app + try: + asgi_app = app._asgi_app + except AttributeError: + raise RuntimeError("No scope. Sanic may not be running with an ASGI server") + return asgi_app.transport.scope class Options(TypedDict, total=False): diff --git a/src/idom/testing.py b/src/idom/testing.py index 50054f1b0..df456ef3f 100644 --- a/src/idom/testing.py +++ b/src/idom/testing.py @@ -215,7 +215,7 @@ def __init__( ) self._app = app - self._server_implementation = implementation + self.implementation = implementation @property def log_records(self) -> List[logging.LogRecord]: @@ -273,12 +273,12 @@ async def __aenter__(self) -> ServerFixture: self._exit_stack = AsyncExitStack() self._records = self._exit_stack.enter_context(capture_idom_logs()) - app = self._app or self._server_implementation.create_development_app() - self._server_implementation.configure(app, self._root_component) + app = self._app or self.implementation.create_development_app() + self.implementation.configure(app, self._root_component) started = asyncio.Event() server_future = asyncio.create_task( - self._server_implementation.serve_development_app( + self.implementation.serve_development_app( app, self.host, self.port, started ) ) diff --git a/tests/test_server/test_common.py b/tests/test_server/test_common.py index 6a338a3f0..d93fd3e01 100644 --- a/tests/test_server/test_common.py +++ b/tests/test_server/test_common.py @@ -1,6 +1,10 @@ +import json +from typing import MutableMapping + import pytest import idom +from idom import html from idom.server.utils import all_implementations from idom.testing import DisplayFixture, ServerFixture, poll @@ -8,6 +12,7 @@ @pytest.fixture( params=list(all_implementations()), ids=lambda imp: imp.__name__, + scope="module", ) async def display(page, request): async with ServerFixture(implementation=request.param) as server: @@ -56,3 +61,17 @@ async def test_module_from_template(display: DisplayFixture): VictoryBar = idom.web.export(victory, "VictoryBar") await display.show(VictoryBar) await display.page.wait_for_selector(".VictoryContainer") + + +async def test_use_scope(display: DisplayFixture): + scope = idom.Ref() + + @idom.component + def ShowScope(): + scope.current = display.server.implementation.use_scope() + return html.pre({"id": "scope"}, str(scope.current)) + + await display.show(ShowScope) + + await display.page.wait_for_selector("#scope") + assert isinstance(scope.current, MutableMapping) From 56a213428530066dcd984eae80b271070488176e Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sat, 26 Mar 2022 00:51:08 -0700 Subject: [PATCH 25/58] remove unused imports --- tests/test_web/test_module.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/test_web/test_module.py b/tests/test_web/test_module.py index ad4d1c957..3f192cf11 100644 --- a/tests/test_web/test_module.py +++ b/tests/test_web/test_module.py @@ -1,11 +1,7 @@ from pathlib import Path -from sys import implementation import pytest from sanic import Sanic -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions -from selenium.webdriver.support.ui import WebDriverWait import idom from idom.server import sanic as sanic_implementation From 89afa71d33b901cb07399ae6e0e3150b582fbb5b Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sat, 26 Mar 2022 01:38:27 -0700 Subject: [PATCH 26/58] fix live docs --- docs/app.py | 42 +++++++++++++++++++------------ requirements/pkg-extras.txt | 2 +- scripts/live_docs.py | 49 +++++++++++++++++++++++++++---------- src/idom/server/sanic.py | 3 +-- 4 files changed, 65 insertions(+), 31 deletions(-) diff --git a/docs/app.py b/docs/app.py index b065dd09e..a2f4fe6e2 100644 --- a/docs/app.py +++ b/docs/app.py @@ -4,8 +4,9 @@ from sanic import Sanic, response -from idom.server.sanic import PerClientStateServer -from idom.widgets import multiview +from idom import component +from idom.core.types import ComponentConstructor +from idom.server.sanic import configure, use_request from .examples import load_examples @@ -22,13 +23,13 @@ def run(): app = make_app() - PerClientStateServer( - make_examples_component(), + configure( + app, + Example(), { "redirect_root_to_index": False, "url_prefix": IDOM_MODEL_SERVER_URL_PREFIX, }, - app, ) app.run( @@ -39,8 +40,28 @@ def run(): ) +@component +def Example(): + view_id = use_request().get_args().get("view_id") + return _get_examples()[view_id]() + + +def _get_examples(): + if not _EXAMPLES: + _EXAMPLES.update(load_examples()) + return _EXAMPLES + + +def reload_examples(): + _EXAMPLES.clear() + _EXAMPLES.update(load_examples()) + + +_EXAMPLES: dict[str, ComponentConstructor] = {} + + def make_app(): - app = Sanic(__name__) + app = Sanic("docs_app") app.static("/docs", str(HERE / "build")) @@ -49,12 +70,3 @@ async def forward_to_index(request): return response.redirect("/docs/index.html") return app - - -def make_examples_component(): - mount, component = multiview() - - for example_name, example_component in load_examples(): - mount.add(example_name, example_component) - - return component diff --git a/requirements/pkg-extras.txt b/requirements/pkg-extras.txt index 85de59b0f..1c41b5842 100644 --- a/requirements/pkg-extras.txt +++ b/requirements/pkg-extras.txt @@ -1,5 +1,5 @@ # extra=stable,sanic -sanic +sanic >=21 sanic-cors # extra=fastapi diff --git a/scripts/live_docs.py b/scripts/live_docs.py index f0173d436..c968394cd 100644 --- a/scripts/live_docs.py +++ b/scripts/live_docs.py @@ -1,4 +1,6 @@ +import asyncio import os +import threading from sphinx_autobuild.cli import ( Server, @@ -9,8 +11,8 @@ get_parser, ) -from docs.app import IDOM_MODEL_SERVER_URL_PREFIX, make_app, make_examples_component -from idom.server.sanic import PerClientStateServer +from docs.app import IDOM_MODEL_SERVER_URL_PREFIX, Example, make_app, reload_examples +from idom.server.sanic import configure, serve_development_app from idom.testing import clear_idom_web_modules_dir @@ -23,20 +25,41 @@ def wrap_builder(old_builder): # This is the bit that we're injecting to get the example components to reload too - def new_builder(): - [s.stop() for s in _running_idom_servers] - clear_idom_web_modules_dir() - server = PerClientStateServer( - make_examples_component(), - {"cors": True, "url_prefix": IDOM_MODEL_SERVER_URL_PREFIX}, - make_app(), + app = make_app() + + configure( + app, + Example, + {"cors": True, "url_prefix": IDOM_MODEL_SERVER_URL_PREFIX}, + ) + + thread_started = threading.Event() + + def run_in_thread(): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + server_started = asyncio.Event() + + async def set_thread_event_when_started(): + await server_started.wait() + thread_started.set() + + loop.run_until_complete( + asyncio.gather( + serve_development_app(app, "127.0.0.1", 5555, server_started), + set_thread_event_when_started(), + ) ) - server.run_in_thread("127.0.0.1", 5555, debug=True) - _running_idom_servers.append(server) - server.wait_until_started() - old_builder() + threading.Thread(target=run_in_thread, daemon=True).start() + + thread_started.wait() + + def new_builder(): + clear_idom_web_modules_dir() + reload_examples() return new_builder diff --git a/src/idom/server/sanic.py b/src/idom/server/sanic.py index b24d8ae8c..a6b5d92ea 100644 --- a/src/idom/server/sanic.py +++ b/src/idom/server/sanic.py @@ -136,9 +136,8 @@ async def model_stream( request: request.Request, socket: WebSocketCommonProtocol ) -> None: send, recv = _make_send_recv_callbacks(socket) - component_params = {k: request.args.get(k) for k in request.args} await serve_json_patch( - Layout(RequestContext(constructor(**component_params), value=request)), + Layout(RequestContext(constructor(), value=request)), send, recv, ) From 6b0444749d3d7009e193ce1bc83d2c618429284d Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sat, 26 Mar 2022 02:08:51 -0700 Subject: [PATCH 27/58] fix typing issues --- src/idom/core/types.py | 3 ++- src/idom/server/_asgi.py | 6 ++--- src/idom/server/flask.py | 2 +- src/idom/server/types.py | 2 +- src/idom/server/utils.py | 8 +++++- src/idom/testing.py | 53 +++++++++++++++++++++++++--------------- src/idom/widgets.py | 2 +- 7 files changed, 48 insertions(+), 28 deletions(-) diff --git a/src/idom/core/types.py b/src/idom/core/types.py index 3bc498181..cdac08b50 100644 --- a/src/idom/core/types.py +++ b/src/idom/core/types.py @@ -38,7 +38,7 @@ class ComponentType(Protocol): key: Key | None """An identifier which is unique amongst a component's immediate siblings""" - type: type[Any] | Callable[..., Any] + type: Any """The function or class defining the behavior of this component This is used to see if two component instances share the same definition. @@ -87,6 +87,7 @@ def __exit__( VdomAttributesAndChildren = Union[ Mapping[str, Any], # this describes both VdomDict and VdomAttributes Iterable[VdomChild], + VdomChild, ] """Useful for the ``*attributes_and_children`` parameter in :func:`idom.core.vdom.vdom`""" diff --git a/src/idom/server/_asgi.py b/src/idom/server/_asgi.py index 77278c171..fac48c73e 100644 --- a/src/idom/server/_asgi.py +++ b/src/idom/server/_asgi.py @@ -1,7 +1,7 @@ from __future__ import annotations import asyncio -from asyncio import FIRST_EXCEPTION, CancelledError +from typing import Any from asgiref.typing import ASGIApplication from uvicorn.config import Config as UvicornConfig @@ -9,7 +9,7 @@ async def serve_development_asgi( - app: ASGIApplication, + app: ASGIApplication | Any, host: str, port: int, started: asyncio.Event, @@ -17,7 +17,7 @@ async def serve_development_asgi( """Run a development server for starlette""" server = UvicornServer(UvicornConfig(app, host=host, port=port, loop="asyncio")) - async def check_if_started(): + async def check_if_started() -> None: while not server.started: await asyncio.sleep(0.2) started.set() diff --git a/src/idom/server/flask.py b/src/idom/server/flask.py index 552f77b42..6439ff123 100644 --- a/src/idom/server/flask.py +++ b/src/idom/server/flask.py @@ -76,7 +76,7 @@ async def serve_development_app( server: pywsgi.WSGIServer - def run_server(): + def run_server() -> None: nonlocal server server = pywsgi.WSGIServer( (host, port), diff --git a/src/idom/server/types.py b/src/idom/server/types.py index 5d88b380c..72866465a 100644 --- a/src/idom/server/types.py +++ b/src/idom/server/types.py @@ -26,5 +26,5 @@ async def serve_development_app( ) -> None: """Run an application using a development server""" - def use_scope() -> MutableMapping[str, Any]: + def use_scope(self) -> MutableMapping[str, Any]: """Get an ASGI scope or WSGI environment dictionary""" diff --git a/src/idom/server/utils.py b/src/idom/server/utils.py index d41c0b82d..ff9e44ba6 100644 --- a/src/idom/server/utils.py +++ b/src/idom/server/utils.py @@ -42,7 +42,7 @@ def run( ) if implementation is None: - from . import default as implementation + implementation = _get_default_implementation() app = implementation.create_development_app() implementation.configure(app, component) @@ -66,6 +66,12 @@ async def _open_browser_after_server() -> None: asyncio.get_event_loop().run_until_complete(asyncio.gather(*coros)) +def _get_default_implementation() -> ServerImplementation[Any]: + from . import default + + return default + + def find_available_port( host: str, port_min: int = 8000, diff --git a/src/idom/testing.py b/src/idom/testing.py index df456ef3f..d6e50ffda 100644 --- a/src/idom/testing.py +++ b/src/idom/testing.py @@ -2,31 +2,29 @@ import asyncio import logging -import operator import re import shutil import time -from contextlib import AsyncExitStack, ExitStack, contextmanager -from functools import partial, wraps -from inspect import isawaitable, iscoroutinefunction +from contextlib import AsyncExitStack, contextmanager +from functools import wraps +from inspect import iscoroutinefunction from traceback import format_exception from types import TracebackType from typing import ( Any, Awaitable, Callable, - Coroutine, - Dict, Generic, Iterator, - List, NoReturn, Optional, + Protocol, Sequence, Tuple, Type, TypeVar, Union, + cast, ) from urllib.parse import urlencode, urlunparse from uuid import uuid4 @@ -66,9 +64,17 @@ def assert_same_items(left: Sequence[Any], right: Sequence[Any]) -> None: _P = ParamSpec("_P") _R = TypeVar("_R") +_RC = TypeVar("_RC", covariant=True) _DEFAULT_TIMEOUT = 3.0 +class _UntilFunc(Protocol[_RC]): + def __call__( + self, condition: Callable[[_RC], bool], timeout: float = _DEFAULT_TIMEOUT + ) -> Any: + ... + + class poll(Generic[_R]): """Wait until the result of an sync or async function meets some condition""" @@ -78,14 +84,18 @@ def __init__( *args: _P.args, **kwargs: _P.kwargs, ) -> None: + self.until: _UntilFunc[_R] + """Check that the coroutines result meets a condition within the timeout""" + if iscoroutinefunction(function): + coro_function = cast(Callable[_P, Awaitable[_R]], function) - async def until( + async def coro_until( condition: Callable[[_R], bool], timeout: float = _DEFAULT_TIMEOUT ) -> None: started_at = time.time() while True: - result = await function(*args, **kwargs) + result = await coro_function(*args, **kwargs) if condition(result): break elif (time.time() - started_at) > timeout: @@ -94,14 +104,16 @@ async def until( f"seconds - last value was {result!r}" ) + self.until = coro_until else: + sync_function = cast(Callable[_P, _R], function) - def until( + def sync_until( condition: Callable[[_R], bool] | Any, timeout: float = _DEFAULT_TIMEOUT ) -> None: started_at = time.time() while True: - result = function(*args, **kwargs) + result = sync_function(*args, **kwargs) if condition(result): break elif (time.time() - started_at) > timeout: @@ -110,8 +122,7 @@ def until( f"seconds - last value was {result!r}" ) - self.until: Callable[[Callable[[_R], bool]], Any] = until - """Check that the coroutines result meets a condition within the timeout""" + self.until = sync_until def until_is(self, right: Any, timeout: float = _DEFAULT_TIMEOUT) -> Any: """Wait until the result is identical to the given value""" @@ -119,7 +130,8 @@ def until_is(self, right: Any, timeout: float = _DEFAULT_TIMEOUT) -> Any: def until_equals(self, right: Any, timeout: float = _DEFAULT_TIMEOUT) -> Any: """Wait until the result is equal to the given value""" - return self.until(lambda left: left == right, timeout) + # not really sure why I need a type ignore comment here + return self.until(lambda left: left == right, timeout) # type: ignore class DisplayFixture: @@ -156,6 +168,7 @@ async def show( async def __aenter__(self) -> DisplayFixture: es = self._exit_stack = AsyncExitStack() + browser: Browser | BrowserContext if not hasattr(self, "page"): if not hasattr(self, "_browser"): pw = await es.enter_async_context(async_playwright()) @@ -218,7 +231,7 @@ def __init__( self.implementation = implementation @property - def log_records(self) -> List[logging.LogRecord]: + def log_records(self) -> list[logging.LogRecord]: """A list of captured log records""" return self._records @@ -246,7 +259,7 @@ def list_logged_exceptions( types: Union[Type[Any], Tuple[Type[Any], ...]] = Exception, log_level: int = logging.ERROR, del_log_records: bool = True, - ) -> List[BaseException]: + ) -> list[BaseException]: """Return a list of logged exception matching the given criteria Args: @@ -254,7 +267,7 @@ def list_logged_exceptions( exclude_exc_types: Any exception types to ignore del_log_records: Whether to delete the log records for yielded exceptions """ - found: List[BaseException] = [] + found: list[BaseException] = [] compiled_pattern = re.compile(pattern) for index, record in enumerate(self.log_records): if record.levelno >= log_level and record.exc_info: @@ -283,7 +296,7 @@ async def __aenter__(self) -> ServerFixture: ) ) - async def stop_server(): + async def stop_server() -> None: server_future.cancel() try: await asyncio.wait_for(server_future, timeout=3) @@ -476,7 +489,7 @@ def MyComponent(thing): def __init__(self, index_by_kwarg: Optional[str] = None): self.index_by_kwarg = index_by_kwarg - self.index: Dict[Any, LifeCycleHook] = {} + self.index: dict[Any, LifeCycleHook] = {} def capture(self, render_function: Callable[..., Any]) -> Callable[..., Any]: """Decorator for capturing a ``LifeCycleHook`` on each render of a component""" @@ -567,7 +580,7 @@ def clear_idom_web_modules_dir() -> None: class _LogRecordCaptor(logging.NullHandler): def __init__(self) -> None: - self.records: List[logging.LogRecord] = [] + self.records: list[logging.LogRecord] = [] super().__init__() def handle(self, record: logging.LogRecord) -> bool: diff --git a/src/idom/widgets.py b/src/idom/widgets.py index 8cda875b0..a089b9d21 100644 --- a/src/idom/widgets.py +++ b/src/idom/widgets.py @@ -107,7 +107,7 @@ def __call__(self, value: str) -> _CastTo: ... -MountFunc = Callable[["ComponentConstructor | None"], None] +MountFunc = Callable[["Callable[[], Any] | None"], None] def hotswap(update_on_change: bool = False) -> Tuple[MountFunc, ComponentConstructor]: From ee6f6255f5a059cbcf4c7a25f3ddfae598a0cd60 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sat, 26 Mar 2022 12:41:18 -0700 Subject: [PATCH 28/58] fix rest of tests --- re.txt | 13 ------ tests/test_core/test_component.py | 18 ++++---- tests/test_core/test_events.py | 31 ++++++------- tests/test_core/test_hooks.py | 75 +++++++++++++++++-------------- 4 files changed, 66 insertions(+), 71 deletions(-) delete mode 100644 re.txt diff --git a/re.txt b/re.txt deleted file mode 100644 index 38101e7e0..000000000 --- a/re.txt +++ /dev/null @@ -1,13 +0,0 @@ -def test_(.*?)\(.*driver.*\): -async def test_$1(display: DisplayFixture): - -display\((.*?)\) -page = await display.show($1) - -driver\.find_element\("id", "(.*?)"\) -await page.wait_for_selector("#$1") -await page.wait_for_selector("#$1", state="attached") - -driver_wait\.until\(lambda .*: (\w*)\.get_attribute\("(.*?)"\) == (.*)\) -assert (await $1.evaluate("node => node['$2']")) == $3 -await poll($1.evaluate, "node => node['$2']").until_equals($3) diff --git a/tests/test_core/test_component.py b/tests/test_core/test_component.py index 6880d9270..65ad43db6 100644 --- a/tests/test_core/test_component.py +++ b/tests/test_core/test_component.py @@ -1,4 +1,5 @@ import idom +from idom.testing import DisplayFixture def test_component_repr(): @@ -43,17 +44,17 @@ def ComponentWithVarArgsAndKwargs(*args, **kwargs): } -def test_display_simple_hello_world(driver, display): +async def test_display_simple_hello_world(display: DisplayFixture): @idom.component def Hello(): return idom.html.p({"id": "hello"}, ["Hello World"]) - display(Hello) + await display.show(Hello) - assert driver.find_element("id", "hello") + assert display.page.wait_for_selector("#hello") -def test_pre_tags_are_rendered_correctly(driver, display): +async def test_pre_tags_are_rendered_correctly(display: DisplayFixture): @idom.component def PreFormated(): return idom.html.pre( @@ -63,11 +64,10 @@ def PreFormated(): " text", ) - display(PreFormated) + await display.show(PreFormated) - pre = driver.find_element("id", "pre-form-test") + pre = await display.page.wait_for_selector("#pre-form-test") assert ( - pre.get_attribute("innerHTML") - == "thisissomepre-formated text" - ) + await pre.evaluate("node => node.innerHTML") + ) == "thisissomepre-formated text" diff --git a/tests/test_core/test_events.py b/tests/test_core/test_events.py index 9be849f4c..29210d19b 100644 --- a/tests/test_core/test_events.py +++ b/tests/test_core/test_events.py @@ -7,6 +7,7 @@ merge_event_handlers, to_event_handler_function, ) +from idom.testing import DisplayFixture, poll def test_event_handler_repr(): @@ -142,7 +143,7 @@ async def some_other_func(data): assert calls == ["some_func", "some_other_func"] -def test_can_prevent_event_default_operation(driver, display): +async def test_can_prevent_event_default_operation(display: DisplayFixture): @idom.component def Input(): @idom.event(prevent_default=True) @@ -151,15 +152,15 @@ async def on_key_down(value): return idom.html.input({"onKeyDown": on_key_down, "id": "input"}) - display(Input) + await display.show(Input) - inp = driver.find_element("id", "input") - inp.send_keys("hello") + inp = await display.page.wait_for_selector("#input") + await inp.type("hello") # the default action of updating the element's value did not take place - assert inp.get_attribute("value") == "" + assert (await inp.evaluate("node => node.value")) == "" -def test_simple_click_event(driver, display): +async def test_simple_click_event(display: DisplayFixture): @idom.component def Button(): clicked, set_clicked = idom.hooks.use_state(False) @@ -172,14 +173,14 @@ async def on_click(event): else: return idom.html.p({"id": "complete"}, ["Complete"]) - display(Button) + await display.show(Button) - button = driver.find_element("id", "click") - button.click() - driver.find_element("id", "complete") + button = await display.page.wait_for_selector("#click") + await button.click() + await display.page.wait_for_selector("#complete") -def test_can_stop_event_propogation(driver, driver_wait, display): +async def test_can_stop_event_propogation(display: DisplayFixture): clicked = idom.Ref(False) @idom.component @@ -215,9 +216,9 @@ def outer_click_is_not_triggered(event): ) return outer - display(DivInDiv) + await display.show(DivInDiv) - inner = driver.find_element("id", "inner") - inner.click() + inner = await display.page.wait_for_selector("#inner") + await inner.click() - driver_wait.until(lambda _: clicked.current) + poll(lambda: clicked.current).until_is(True) diff --git a/tests/test_core/test_hooks.py b/tests/test_core/test_hooks.py index ec2e53d65..744d21a12 100644 --- a/tests/test_core/test_hooks.py +++ b/tests/test_core/test_hooks.py @@ -8,7 +8,7 @@ from idom.core.hooks import COMPONENT_DID_RENDER_EFFECT, LifeCycleHook, current_hook from idom.core.layout import Layout from idom.core.serve import render_json_patch -from idom.testing import HookCatcher, assert_idom_logged +from idom.testing import DisplayFixture, HookCatcher, assert_idom_logged, poll from idom.utils import Ref from tests.tooling.asserts import assert_same_items @@ -150,7 +150,7 @@ def Counter(): await layout.render() -def test_set_state_checks_identity_not_equality(driver, display, driver_wait): +async def test_set_state_checks_identity_not_equality(display: DisplayFixture): r_1 = idom.Ref("value") r_2 = idom.Ref("value") @@ -191,31 +191,34 @@ def TestComponent(): f"Last state: {'r_1' if state is r_1 else 'r_2'}", ) - display(TestComponent) + await display.show(TestComponent) - client_r_1_button = driver.find_element("id", "r_1") - client_r_2_button = driver.find_element("id", "r_2") + client_r_1_button = await display.page.wait_for_selector("#r_1") + client_r_2_button = await display.page.wait_for_selector("#r_2") + + poll_event_count = poll(lambda: event_count.current) + poll_render_count = poll(lambda: render_count.current) assert render_count.current == 1 assert event_count.current == 0 - client_r_1_button.click() + await client_r_1_button.click() - driver_wait.until(lambda d: event_count.current == 1) - driver_wait.until(lambda d: render_count.current == 1) + poll_event_count.until_equals(1) + poll_render_count.until_equals(1) - client_r_2_button.click() + await client_r_2_button.click() - driver_wait.until(lambda d: event_count.current == 2) - driver_wait.until(lambda d: render_count.current == 2) + poll_event_count.until_equals(2) + poll_render_count.until_equals(2) - client_r_2_button.click() + await client_r_2_button.click() - driver_wait.until(lambda d: event_count.current == 3) - driver_wait.until(lambda d: render_count.current == 2) + poll_event_count.until_equals(3) + poll_render_count.until_equals(2) -def test_simple_input_with_use_state(driver, display): +async def test_simple_input_with_use_state(display: DisplayFixture): message_ref = idom.Ref(None) @idom.component @@ -232,16 +235,16 @@ async def on_change(event): else: return idom.html.p({"id": "complete"}, ["Complete"]) - display(Input) + await display.show(Input) - button = driver.find_element("id", "input") - button.send_keys("this is a test") - driver.find_element("id", "complete") + button = await display.page.wait_for_selector("#input") + await button.type("this is a test") + await display.page.wait_for_selector("#complete") assert message_ref.current == "this is a test" -def test_double_set_state(driver, driver_wait, display): +async def test_double_set_state(display: DisplayFixture): @idom.component def SomeComponent(): state_1, set_state_1 = idom.hooks.use_state(0) @@ -252,29 +255,33 @@ def double_set_state(event): set_state_2(state_2 + 1) return idom.html.div( - idom.html.div({"id": "first", "value": state_1}, f"value is: {state_1}"), - idom.html.div({"id": "second", "value": state_2}, f"value is: {state_2}"), + idom.html.div( + {"id": "first", "data-value": state_1}, f"value is: {state_1}" + ), + idom.html.div( + {"id": "second", "data-value": state_2}, f"value is: {state_2}" + ), idom.html.button({"id": "button", "onClick": double_set_state}, "click me"), ) - display(SomeComponent) + await display.show(SomeComponent) - button = driver.find_element("id", "button") - first = driver.find_element("id", "first") - second = driver.find_element("id", "second") + button = await display.page.wait_for_selector("#button") + first = await display.page.wait_for_selector("#first") + second = await display.page.wait_for_selector("#second") - assert first.get_attribute("value") == "0" - assert second.get_attribute("value") == "0" + assert (await first.get_attribute("data-value")) == "0" + assert (await second.get_attribute("data-value")) == "0" - button.click() + await button.click() - assert driver_wait.until(lambda _: first.get_attribute("value") == "1") - assert driver_wait.until(lambda _: second.get_attribute("value") == "1") + assert (await first.get_attribute("data-value")) == "1" + assert (await second.get_attribute("data-value")) == "1" - button.click() + await button.click() - assert driver_wait.until(lambda _: first.get_attribute("value") == "2") - assert driver_wait.until(lambda _: second.get_attribute("value") == "2") + assert (await first.get_attribute("data-value")) == "2" + assert (await second.get_attribute("data-value")) == "2" async def test_use_effect_callback_occurs_after_full_render_is_complete(): From 91214360de8a4825072be85b653fdfbd92a77820 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sat, 26 Mar 2022 12:55:54 -0700 Subject: [PATCH 29/58] improve coverage --- src/idom/server/default.py | 2 +- src/idom/server/flask.py | 13 +++++++------ src/idom/server/sanic.py | 6 ++++-- src/idom/server/starlette.py | 4 +++- src/idom/server/tornado.py | 4 +++- src/idom/testing.py | 4 ++-- tests/test_server/test_common.py | 3 ++- 7 files changed, 22 insertions(+), 14 deletions(-) diff --git a/src/idom/server/default.py b/src/idom/server/default.py index 23955b070..e96b37026 100644 --- a/src/idom/server/default.py +++ b/src/idom/server/default.py @@ -41,7 +41,7 @@ def _default_implementation() -> ServerImplementation[Any]: try: implementation = next(all_implementations()) - except StopIteration: + except StopIteration: # pragma: no cover raise RuntimeError("No built-in server implementation installed.") else: _DEFAULT_IMPLEMENTATION = implementation diff --git a/src/idom/server/flask.py b/src/idom/server/flask.py index 6439ff123..a627e0811 100644 --- a/src/idom/server/flask.py +++ b/src/idom/server/flask.py @@ -8,8 +8,6 @@ from threading import Event as ThreadEvent from threading import Thread from typing import Any, Callable, Dict, NamedTuple, Optional, Union, cast -from urllib.parse import parse_qs as parse_query_string -from wsgiref.types import WSGIEnvironment from flask import ( Blueprint, @@ -76,7 +74,8 @@ async def serve_development_app( server: pywsgi.WSGIServer - def run_server() -> None: + def run_server() -> None: # pragma: no cover + # we don't cover this function because coverage doesn't work right in threads nonlocal server server = pywsgi.WSGIServer( (host, port), @@ -103,7 +102,7 @@ def run_server() -> None: # the thread should eventually join thread.join(timeout=3) # just double check it happened - if thread.is_alive(): + if thread.is_alive(): # pragma: no cover raise RuntimeError("Failed to shutdown server.") @@ -111,11 +110,13 @@ def use_request() -> Request: """Get the current ``Request``""" request = use_context(RequestContext) if request is None: - raise RuntimeError("No request. Are you running with a Flask server?") + raise RuntimeError( # pragma: no cover + "No request. Are you running with a Flask server?" + ) return request -def use_scope() -> WSGIEnvironment: +def use_scope() -> dict[str, Any]: """Get the current WSGI environment""" return use_request().environ diff --git a/src/idom/server/sanic.py b/src/idom/server/sanic.py index a6b5d92ea..6adcc8e04 100644 --- a/src/idom/server/sanic.py +++ b/src/idom/server/sanic.py @@ -64,7 +64,9 @@ def use_request() -> request.Request: """Get the current ``Request``""" request = use_context(RequestContext) if request is None: - raise RuntimeError("No request. Are you running with a Sanic server?") + raise RuntimeError( # pragma: no cover + "No request. Are you running with a Sanic server?" + ) return request @@ -73,7 +75,7 @@ def use_scope() -> ASGIScope: app = use_request().app try: asgi_app = app._asgi_app - except AttributeError: + except AttributeError: # pragma: no cover raise RuntimeError("No scope. Sanic may not be running with an ASGI server") return asgi_app.transport.scope diff --git a/src/idom/server/starlette.py b/src/idom/server/starlette.py index d9ed9c20f..a2c942895 100644 --- a/src/idom/server/starlette.py +++ b/src/idom/server/starlette.py @@ -74,7 +74,9 @@ def use_websocket() -> WebSocket: """Get the current WebSocket object""" websocket = use_context(WebSocketContext) if websocket is None: - raise RuntimeError("No websocket. Are you running with a Starllette server?") + raise RuntimeError( # pragma: no cover + "No websocket. Are you running with a Starllette server?" + ) return websocket diff --git a/src/idom/server/tornado.py b/src/idom/server/tornado.py index 6dbb13371..1bb6c2344 100644 --- a/src/idom/server/tornado.py +++ b/src/idom/server/tornado.py @@ -81,7 +81,9 @@ def use_request() -> HTTPServerRequest: """Get the current ``HTTPServerRequest``""" request = use_context(RequestContext) if request is None: - raise RuntimeError("No request. Are you running with a Tornado server?") + raise RuntimeError( # pragma: no cover + "No request. Are you running with a Tornado server?" + ) return request diff --git a/src/idom/testing.py b/src/idom/testing.py index d6e50ffda..c3015f4d9 100644 --- a/src/idom/testing.py +++ b/src/idom/testing.py @@ -98,7 +98,7 @@ async def coro_until( result = await coro_function(*args, **kwargs) if condition(result): break - elif (time.time() - started_at) > timeout: + elif (time.time() - started_at) > timeout: # pragma: no cover raise TimeoutError( f"Condition not met within {timeout} " f"seconds - last value was {result!r}" @@ -116,7 +116,7 @@ def sync_until( result = sync_function(*args, **kwargs) if condition(result): break - elif (time.time() - started_at) > timeout: + elif (time.time() - started_at) > timeout: # pragma: no cover raise TimeoutError( f"Condition not met within {timeout} " f"seconds - last value was {result!r}" diff --git a/tests/test_server/test_common.py b/tests/test_server/test_common.py index d93fd3e01..796367240 100644 --- a/tests/test_server/test_common.py +++ b/tests/test_server/test_common.py @@ -5,12 +5,13 @@ import idom from idom import html +from idom.server import default as default_implementation from idom.server.utils import all_implementations from idom.testing import DisplayFixture, ServerFixture, poll @pytest.fixture( - params=list(all_implementations()), + params=list(all_implementations()) + [default_implementation], ids=lambda imp: imp.__name__, scope="module", ) From c8f96b5c3571cf0295aec09c8317a0aed8b0293d Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sat, 26 Mar 2022 13:04:16 -0700 Subject: [PATCH 30/58] import protocol from typing extensions --- src/idom/testing.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/idom/testing.py b/src/idom/testing.py index c3015f4d9..500a8431d 100644 --- a/src/idom/testing.py +++ b/src/idom/testing.py @@ -18,7 +18,6 @@ Iterator, NoReturn, Optional, - Protocol, Sequence, Tuple, Type, @@ -31,7 +30,7 @@ from weakref import ref from playwright.async_api import Browser, BrowserContext, Page, async_playwright -from typing_extensions import ParamSpec +from typing_extensions import ParamSpec, Protocol from idom import html from idom.config import IDOM_WEB_MODULES_DIR From 57264360e5b7632d598163f5eb7e6bc773869cf6 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sat, 26 Mar 2022 13:09:47 -0700 Subject: [PATCH 31/58] fix syntax error --- src/idom/server/default.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/idom/server/default.py b/src/idom/server/default.py index e96b37026..b8d8c0227 100644 --- a/src/idom/server/default.py +++ b/src/idom/server/default.py @@ -32,6 +32,9 @@ def use_scope() -> Any: return _default_implementation().use_scope() +_DEFAULT_IMPLEMENTATION: ServerImplementation[Any] | None = None + + def _default_implementation() -> ServerImplementation[Any]: """Get the first available server implementation""" global _DEFAULT_IMPLEMENTATION @@ -46,6 +49,3 @@ def _default_implementation() -> ServerImplementation[Any]: else: _DEFAULT_IMPLEMENTATION = implementation return implementation - - -_DEFAULT_IMPLEMENTATION: ServerImplementation[Any] | None = None From 683b1cede4fbb74ab970f73a2201b7059c799990 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sat, 26 Mar 2022 13:37:40 -0700 Subject: [PATCH 32/58] fix docs test --- src/idom/__init__.py | 5 +++-- src/idom/server/fastapi.py | 34 +++++++++++++++------------------- 2 files changed, 18 insertions(+), 21 deletions(-) diff --git a/src/idom/__init__.py b/src/idom/__init__.py index 775005ef0..b14aa3d6b 100644 --- a/src/idom/__init__.py +++ b/src/idom/__init__.py @@ -1,4 +1,4 @@ -from . import config, html, log, sample, types, web +from . import config, html, log, sample, server, types, web from .core import hooks from .core.component import component from .core.events import event @@ -36,8 +36,9 @@ "log", "Ref", "run", - "Stop", "sample", + "server", + "Stop", "types", "use_callback", "use_context", diff --git a/src/idom/server/fastapi.py b/src/idom/server/fastapi.py index 7197f5e3b..a51a61dea 100644 --- a/src/idom/server/fastapi.py +++ b/src/idom/server/fastapi.py @@ -5,28 +5,23 @@ from idom.config import IDOM_DEBUG_MODE from idom.core.types import RootComponentConstructor -from .starlette import ( - Options, - _setup_common_routes, - _setup_options, - _setup_single_view_dispatcher_route, - serve_development_app, - use_scope, -) +from . import starlette -__all__ = ( - "configure", - "serve_development_app", - "create_development_app", - "use_scope", -) +serve_development_app = starlette.serve_development_app +"""Alias for :func:`starlette.serve_development_app`""" + +use_scope = starlette.use_scope +"""Alias for :func:`starlette.use_scope`""" + +use_websocket = starlette.use_websocket +"""Alias for :func:`starlette.use_websocket`""" def configure( app: FastAPI, constructor: RootComponentConstructor, - options: Options | None = None, + options: starlette.Options | None = None, ) -> None: """Prepare a :class:`FastAPI` server to serve the given component @@ -34,11 +29,12 @@ def configure( app: An application instance constructor: A component constructor config: Options for configuring server behavior - """ - options = _setup_options(options) - _setup_common_routes(options, app) - _setup_single_view_dispatcher_route(options["url_prefix"], app, constructor) + options = starlette._setup_options(options) + starlette._setup_common_routes(options, app) + starlette._setup_single_view_dispatcher_route( + options["url_prefix"], app, constructor + ) def create_development_app() -> FastAPI: From 9e9fa81894d23e7d9aa38c64409b7f7fff8ddc66 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sat, 26 Mar 2022 13:51:53 -0700 Subject: [PATCH 33/58] fix flake8 --- src/idom/core/serve.py | 21 +++------------------ src/idom/server/fastapi.py | 6 ++++-- src/idom/testing.py | 8 +------- tests/test_client.py | 1 - tests/test_core/test_dispatcher.py | 3 --- tests/test_server/test_common.py | 3 +-- tests/test_server/test_utils.py | 2 -- 7 files changed, 9 insertions(+), 35 deletions(-) diff --git a/src/idom/core/serve.py b/src/idom/core/serve.py index 0cde71e15..af21f40f7 100644 --- a/src/idom/core/serve.py +++ b/src/idom/core/serve.py @@ -1,27 +1,12 @@ from __future__ import annotations -from asyncio import Future, Queue, ensure_future -from asyncio.tasks import FIRST_COMPLETED, ensure_future, gather, wait -from contextlib import asynccontextmanager +from asyncio import ensure_future +from asyncio.tasks import ensure_future from logging import getLogger -from typing import ( - Any, - AsyncIterator, - Awaitable, - Callable, - Dict, - List, - NamedTuple, - Sequence, - Tuple, - cast, -) -from weakref import WeakSet +from typing import Any, Awaitable, Callable, Dict, List, NamedTuple, cast from anyio import create_task_group -from idom.utils import Ref - from ._fixed_jsonpatch import apply_patch, make_patch # type: ignore from .layout import LayoutEvent, LayoutUpdate from .types import LayoutType, VdomJson diff --git a/src/idom/server/fastapi.py b/src/idom/server/fastapi.py index a51a61dea..3187e71b7 100644 --- a/src/idom/server/fastapi.py +++ b/src/idom/server/fastapi.py @@ -11,10 +11,12 @@ serve_development_app = starlette.serve_development_app """Alias for :func:`starlette.serve_development_app`""" -use_scope = starlette.use_scope +# see: https://github.com/idom-team/flake8-idom-hooks/issues/12 +use_scope = starlette.use_scope # noqa: ROH101 """Alias for :func:`starlette.use_scope`""" -use_websocket = starlette.use_websocket +# see: https://github.com/idom-team/flake8-idom-hooks/issues/12 +use_websocket = starlette.use_websocket # noqa: ROH101 """Alias for :func:`starlette.use_websocket`""" diff --git a/src/idom/testing.py b/src/idom/testing.py index 500a8431d..35aae903f 100644 --- a/src/idom/testing.py +++ b/src/idom/testing.py @@ -45,12 +45,6 @@ from .log import ROOT_LOGGER -__all__ = [ - "find_available_port", - "create_simple_selenium_web_driver", - "ServerFixture", -] - _Self = TypeVar("_Self") @@ -74,7 +68,7 @@ def __call__( ... -class poll(Generic[_R]): +class poll(Generic[_R]): # noqa: N801 """Wait until the result of an sync or async function meets some condition""" def __init__( diff --git a/tests/test_client.py b/tests/test_client.py index 3c5ccb6df..b17d690cc 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,5 +1,4 @@ import asyncio -import time from contextlib import AsyncExitStack from pathlib import Path diff --git a/tests/test_core/test_dispatcher.py b/tests/test_core/test_dispatcher.py index 908c62f40..8e3f05ded 100644 --- a/tests/test_core/test_dispatcher.py +++ b/tests/test_core/test_dispatcher.py @@ -1,9 +1,6 @@ import asyncio -import sys from typing import Any, Sequence -import pytest - import idom from idom.core.layout import Layout, LayoutEvent, LayoutUpdate from idom.core.serve import VdomJsonPatch, serve_json_patch diff --git a/tests/test_server/test_common.py b/tests/test_server/test_common.py index 796367240..7b4acb228 100644 --- a/tests/test_server/test_common.py +++ b/tests/test_server/test_common.py @@ -1,4 +1,3 @@ -import json from typing import MutableMapping import pytest @@ -7,7 +6,7 @@ from idom import html from idom.server import default as default_implementation from idom.server.utils import all_implementations -from idom.testing import DisplayFixture, ServerFixture, poll +from idom.testing import DisplayFixture, ServerFixture @pytest.fixture( diff --git a/tests/test_server/test_utils.py b/tests/test_server/test_utils.py index 9ec745663..fd932a9d8 100644 --- a/tests/test_server/test_utils.py +++ b/tests/test_server/test_utils.py @@ -1,5 +1,3 @@ -from threading import Event - import pytest from idom.server.utils import find_available_port From 843cf7746b332c4eb3a20d6334f80689420541aa Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sat, 26 Mar 2022 15:04:18 -0700 Subject: [PATCH 34/58] test run function --- src/idom/sample.py | 2 +- src/idom/server/utils.py | 3 +-- tests/test_core/test_component.py | 2 +- tests/test_server/test_utils.py | 38 ++++++++++++++++++++++++++ tests/tooling/loop.py | 44 ++++++++++++++++++++++++------- 5 files changed, 76 insertions(+), 13 deletions(-) diff --git a/src/idom/sample.py b/src/idom/sample.py index 905ce2212..ea8539d6d 100644 --- a/src/idom/sample.py +++ b/src/idom/sample.py @@ -8,7 +8,7 @@ @component def App() -> VdomDict: return html.div( - {"style": {"padding": "15px"}}, + {"id": "sample", "style": {"padding": "15px"}}, html.h1("Sample Application"), html.p( "This is a basic application made with IDOM. Click ", diff --git a/src/idom/server/utils.py b/src/idom/server/utils.py index ff9e44ba6..37101de30 100644 --- a/src/idom/server/utils.py +++ b/src/idom/server/utils.py @@ -41,8 +41,7 @@ def run( stacklevel=2, ) - if implementation is None: - implementation = _get_default_implementation() + implementation = implementation or _get_default_implementation() app = implementation.create_development_app() implementation.configure(app, component) diff --git a/tests/test_core/test_component.py b/tests/test_core/test_component.py index 65ad43db6..28c8b00f2 100644 --- a/tests/test_core/test_component.py +++ b/tests/test_core/test_component.py @@ -51,7 +51,7 @@ def Hello(): await display.show(Hello) - assert display.page.wait_for_selector("#hello") + await display.page.wait_for_selector("#hello") async def test_pre_tags_are_rendered_correctly(display: DisplayFixture): diff --git a/tests/test_server/test_utils.py b/tests/test_server/test_utils.py index fd932a9d8..fd0c02739 100644 --- a/tests/test_server/test_utils.py +++ b/tests/test_server/test_utils.py @@ -1,6 +1,21 @@ +import asyncio +import threading +from contextlib import ExitStack + import pytest +from playwright.async_api import Page +from idom.sample import App as SampleApp +from idom.server import flask as flask_implementation from idom.server.utils import find_available_port +from idom.server.utils import run as sync_run +from tests.tooling.loop import open_event_loop + + +@pytest.fixture +def exit_stack(): + with ExitStack() as es: + yield es def test_find_available_port(): @@ -8,3 +23,26 @@ def test_find_available_port(): with pytest.raises(RuntimeError, match="no available port"): # check that if port range is exhausted we raise find_available_port("localhost", port_min=0, port_max=0) + + +async def test_run(page: Page, exit_stack: ExitStack): + loop = exit_stack.enter_context(open_event_loop(as_current=False)) + + host = "127.0.0.1" + port = find_available_port(host) + url = f"http://{host}:{port}" + + def run_in_thread(): + asyncio.set_event_loop(loop) + sync_run( + SampleApp, + host, + port, + open_browser=False, + implementation=flask_implementation, + ) + + threading.Thread(target=run_in_thread, daemon=True).start() + + await page.goto(url) + await page.wait_for_selector("#sample") diff --git a/tests/tooling/loop.py b/tests/tooling/loop.py index 2f13a1824..8a5211993 100644 --- a/tests/tooling/loop.py +++ b/tests/tooling/loop.py @@ -1,6 +1,8 @@ import asyncio +import threading from asyncio import wait_for from contextlib import contextmanager +from re import L from typing import Iterator @@ -8,33 +10,57 @@ @contextmanager -def open_event_loop() -> Iterator[asyncio.AbstractEventLoop]: +def open_event_loop(as_current: bool = True) -> Iterator[asyncio.AbstractEventLoop]: + """Open a new event loop and cleanly stop it + + Args: + as_current: whether to make this loop the current loop in this thread + """ loop = asyncio.new_event_loop() try: - asyncio.set_event_loop(loop) + if as_current: + asyncio.set_event_loop(loop) loop.set_debug(True) yield loop finally: try: - _cancel_all_tasks(loop) + _cancel_all_tasks(loop, as_current) loop.run_until_complete(wait_for(loop.shutdown_asyncgens(), TIMEOUT)) loop.run_until_complete(wait_for(loop.shutdown_default_executor(), TIMEOUT)) finally: - asyncio.set_event_loop(None) + if as_current: + asyncio.set_event_loop(None) loop.close() -def _cancel_all_tasks(loop: asyncio.AbstractEventLoop) -> None: +def _cancel_all_tasks(loop: asyncio.AbstractEventLoop, is_current: bool) -> None: to_cancel = asyncio.all_tasks(loop) if not to_cancel: return + done = threading.Event() + count = len(to_cancel) + + def one_task_finished(future): + nonlocal count + count -= 1 + if count == 0: + done.set() + for task in to_cancel: - task.cancel() + loop.call_soon_threadsafe(task.cancel) + task.add_done_callback(one_task_finished) - loop.run_until_complete( - wait_for(asyncio.gather(*to_cancel, loop=loop, return_exceptions=True), TIMEOUT) - ) + if is_current: + loop.run_until_complete( + wait_for( + asyncio.gather(*to_cancel, loop=loop, return_exceptions=True), TIMEOUT + ) + ) + else: + # user was responsible for cancelling all tasks + if not done.wait(timeout=3): + raise TimeoutError("Could not stop event loop in time") for task in to_cancel: if task.cancelled(): From 6d402406b114c0ff760f8ce51a4783b9a94aa0b2 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sat, 26 Mar 2022 15:07:16 -0700 Subject: [PATCH 35/58] use selector event loop --- tests/tooling/loop.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/tooling/loop.py b/tests/tooling/loop.py index 8a5211993..fb4c8df3e 100644 --- a/tests/tooling/loop.py +++ b/tests/tooling/loop.py @@ -16,7 +16,7 @@ def open_event_loop(as_current: bool = True) -> Iterator[asyncio.AbstractEventLo Args: as_current: whether to make this loop the current loop in this thread """ - loop = asyncio.new_event_loop() + loop = asyncio.SelectorEventLoop() try: if as_current: asyncio.set_event_loop(loop) @@ -25,8 +25,11 @@ def open_event_loop(as_current: bool = True) -> Iterator[asyncio.AbstractEventLo finally: try: _cancel_all_tasks(loop, as_current) - loop.run_until_complete(wait_for(loop.shutdown_asyncgens(), TIMEOUT)) - loop.run_until_complete(wait_for(loop.shutdown_default_executor(), TIMEOUT)) + if as_current: + loop.run_until_complete(wait_for(loop.shutdown_asyncgens(), TIMEOUT)) + loop.run_until_complete( + wait_for(loop.shutdown_default_executor(), TIMEOUT) + ) finally: if as_current: asyncio.set_event_loop(None) From 4ccc5231ca3f404c67fac51d6abb5da01b3599f5 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sat, 26 Mar 2022 15:24:41 -0700 Subject: [PATCH 36/58] try to fix loop --- tests/test_server/test_utils.py | 4 ++++ tests/tooling/loop.py | 3 +++ 2 files changed, 7 insertions(+) diff --git a/tests/test_server/test_utils.py b/tests/test_server/test_utils.py index fd0c02739..d01a64cdb 100644 --- a/tests/test_server/test_utils.py +++ b/tests/test_server/test_utils.py @@ -1,5 +1,6 @@ import asyncio import threading +import time from contextlib import ExitStack import pytest @@ -44,5 +45,8 @@ def run_in_thread(): threading.Thread(target=run_in_thread, daemon=True).start() + # give the server a moment to start + time.sleep(0.5) + await page.goto(url) await page.wait_for_selector("#sample") diff --git a/tests/tooling/loop.py b/tests/tooling/loop.py index fb4c8df3e..9ce6310ea 100644 --- a/tests/tooling/loop.py +++ b/tests/tooling/loop.py @@ -5,6 +5,8 @@ from re import L from typing import Iterator +from idom.testing import poll + TIMEOUT = 3 @@ -33,6 +35,7 @@ def open_event_loop(as_current: bool = True) -> Iterator[asyncio.AbstractEventLo finally: if as_current: asyncio.set_event_loop(None) + poll(loop.is_running).until_is(False) loop.close() From c8f393a1a9cb0f86d9486efcf1ced809dd86ae73 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sat, 26 Mar 2022 15:25:58 -0700 Subject: [PATCH 37/58] only install chromium --- noxfile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/noxfile.py b/noxfile.py index fe8b4908a..b361c95ab 100644 --- a/noxfile.py +++ b/noxfile.py @@ -179,7 +179,7 @@ def test_python_suite(session: Session) -> None: """Run the Python-based test suite""" session.env["IDOM_DEBUG_MODE"] = "1" install_requirements_file(session, "test-env") - session.run("playwright", "install") + session.run("playwright", "install", "chromium") posargs = session.posargs posargs += ["--reruns", "3", "--reruns-delay", "1"] From 5d12ab4da1f8f0aa3568ef19f8afde716212de82 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sat, 26 Mar 2022 15:29:30 -0700 Subject: [PATCH 38/58] remove unused import --- tests/tooling/loop.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/tooling/loop.py b/tests/tooling/loop.py index 9ce6310ea..eaee99e1c 100644 --- a/tests/tooling/loop.py +++ b/tests/tooling/loop.py @@ -2,7 +2,6 @@ import threading from asyncio import wait_for from contextlib import contextmanager -from re import L from typing import Iterator from idom.testing import poll From e015ab7968769597158ec2045bda009820a06fdc Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sat, 26 Mar 2022 15:37:42 -0700 Subject: [PATCH 39/58] fix shutdown code --- src/idom/server/utils.py | 10 ++-------- tests/tooling/loop.py | 11 +++++++---- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/src/idom/server/utils.py b/src/idom/server/utils.py index 37101de30..58ee9c633 100644 --- a/src/idom/server/utils.py +++ b/src/idom/server/utils.py @@ -41,7 +41,7 @@ def run( stacklevel=2, ) - implementation = implementation or _get_default_implementation() + implementation = implementation or import_module("idom.server.default") app = implementation.create_development_app() implementation.configure(app, component) @@ -54,7 +54,7 @@ def run( coros.append(implementation.serve_development_app(app, host, port, started)) - if open_browser: + if open_browser: # pragma: no cover async def _open_browser_after_server() -> None: await started.wait() @@ -65,12 +65,6 @@ async def _open_browser_after_server() -> None: asyncio.get_event_loop().run_until_complete(asyncio.gather(*coros)) -def _get_default_implementation() -> ServerImplementation[Any]: - from . import default - - return default - - def find_available_port( host: str, port_min: int = 8000, diff --git a/tests/tooling/loop.py b/tests/tooling/loop.py index eaee99e1c..9b872f1fa 100644 --- a/tests/tooling/loop.py +++ b/tests/tooling/loop.py @@ -1,4 +1,5 @@ import asyncio +import sys import threading from asyncio import wait_for from contextlib import contextmanager @@ -17,7 +18,7 @@ def open_event_loop(as_current: bool = True) -> Iterator[asyncio.AbstractEventLo Args: as_current: whether to make this loop the current loop in this thread """ - loop = asyncio.SelectorEventLoop() + loop = asyncio.new_event_loop() try: if as_current: asyncio.set_event_loop(loop) @@ -28,9 +29,11 @@ def open_event_loop(as_current: bool = True) -> Iterator[asyncio.AbstractEventLo _cancel_all_tasks(loop, as_current) if as_current: loop.run_until_complete(wait_for(loop.shutdown_asyncgens(), TIMEOUT)) - loop.run_until_complete( - wait_for(loop.shutdown_default_executor(), TIMEOUT) - ) + if sys.version_info >= (3, 9): + # shutdown_default_executor only available in Python 3.9+ + loop.run_until_complete( + wait_for(loop.shutdown_default_executor(), TIMEOUT) + ) finally: if as_current: asyncio.set_event_loop(None) From fd420a8cd0f6b58e3ad95f595430029ea554301f Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sat, 26 Mar 2022 15:50:41 -0700 Subject: [PATCH 40/58] skip browser tests on windows --- src/idom/testing.py | 11 ----------- tests/conftest.py | 4 ++++ tests/test_testing.py | 10 ++++++++++ 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/idom/testing.py b/src/idom/testing.py index 35aae903f..ccaeece73 100644 --- a/src/idom/testing.py +++ b/src/idom/testing.py @@ -18,7 +18,6 @@ Iterator, NoReturn, Optional, - Sequence, Tuple, Type, TypeVar, @@ -45,16 +44,6 @@ from .log import ROOT_LOGGER -_Self = TypeVar("_Self") - - -def assert_same_items(left: Sequence[Any], right: Sequence[Any]) -> None: - """Check that two unordered sequences are equal (only works if reprs are equal)""" - sorted_left = list(sorted(left, key=repr)) - sorted_right = list(sorted(right, key=repr)) - assert sorted_left == sorted_right - - _P = ParamSpec("_P") _R = TypeVar("_R") _RC = TypeVar("_RC", covariant=True) diff --git a/tests/conftest.py b/tests/conftest.py index c74c160ed..709319fbb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,7 @@ from __future__ import annotations +import os + import pytest from _pytest.config import Config from _pytest.config.argparsing import Parser @@ -47,6 +49,8 @@ async def page(browser): @pytest.fixture(scope="session") async def browser(pytestconfig: Config): + if os.name == "nt": # pragma: no cover + pytest.skip("Browser tests not supported on Windows") async with async_playwright() as pw: yield await pw.chromium.launch(headless=not bool(pytestconfig.option.headed)) diff --git a/tests/test_testing.py b/tests/test_testing.py index 8c7529bcd..4ca3bb90f 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -1,9 +1,11 @@ import logging +import os import pytest from idom import testing from idom.log import ROOT_LOGGER +from idom.sample import App as SampleApp def test_assert_idom_logged_does_not_supress_errors(): @@ -123,3 +125,11 @@ def test_assert_idom_did_not_log(): raise Exception("something") except Exception: ROOT_LOGGER.exception("something") + + +async def test_simple_display_fixture(): + if os.name == "nt": + pytest.skip("Browser tests not supported on Windows") + async with testing.DisplayFixture() as display: + await display.show(SampleApp) + await display.page.wait_for_selector("#sample") From 3de55077fc3c02c67bec3b57ff2c6349d5ad507c Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sat, 26 Mar 2022 16:45:49 -0700 Subject: [PATCH 41/58] fix coverage + reorg testing utils --- src/idom/testing.py | 573 ------------------------------- src/idom/testing/__init__.py | 23 ++ src/idom/testing/common.py | 205 +++++++++++ src/idom/testing/display.py | 71 ++++ src/idom/testing/logs.py | 177 ++++++++++ src/idom/testing/server.py | 146 ++++++++ tests/conftest.py | 12 +- tests/test_core/test_layout.py | 18 +- tests/test_server/test_common.py | 4 +- tests/test_testing.py | 29 ++ 10 files changed, 662 insertions(+), 596 deletions(-) delete mode 100644 src/idom/testing.py create mode 100644 src/idom/testing/__init__.py create mode 100644 src/idom/testing/common.py create mode 100644 src/idom/testing/display.py create mode 100644 src/idom/testing/logs.py create mode 100644 src/idom/testing/server.py diff --git a/src/idom/testing.py b/src/idom/testing.py deleted file mode 100644 index ccaeece73..000000000 --- a/src/idom/testing.py +++ /dev/null @@ -1,573 +0,0 @@ -from __future__ import annotations - -import asyncio -import logging -import re -import shutil -import time -from contextlib import AsyncExitStack, contextmanager -from functools import wraps -from inspect import iscoroutinefunction -from traceback import format_exception -from types import TracebackType -from typing import ( - Any, - Awaitable, - Callable, - Generic, - Iterator, - NoReturn, - Optional, - Tuple, - Type, - TypeVar, - Union, - cast, -) -from urllib.parse import urlencode, urlunparse -from uuid import uuid4 -from weakref import ref - -from playwright.async_api import Browser, BrowserContext, Page, async_playwright -from typing_extensions import ParamSpec, Protocol - -from idom import html -from idom.config import IDOM_WEB_MODULES_DIR -from idom.core.events import EventHandler, to_event_handler_function -from idom.core.hooks import LifeCycleHook, current_hook -from idom.server import default as default_server -from idom.server.types import ServerImplementation -from idom.server.utils import find_available_port -from idom.types import RootComponentConstructor -from idom.widgets import hotswap - -from .log import ROOT_LOGGER - - -_P = ParamSpec("_P") -_R = TypeVar("_R") -_RC = TypeVar("_RC", covariant=True) -_DEFAULT_TIMEOUT = 3.0 - - -class _UntilFunc(Protocol[_RC]): - def __call__( - self, condition: Callable[[_RC], bool], timeout: float = _DEFAULT_TIMEOUT - ) -> Any: - ... - - -class poll(Generic[_R]): # noqa: N801 - """Wait until the result of an sync or async function meets some condition""" - - def __init__( - self, - function: Callable[_P, Awaitable[_R] | _R], - *args: _P.args, - **kwargs: _P.kwargs, - ) -> None: - self.until: _UntilFunc[_R] - """Check that the coroutines result meets a condition within the timeout""" - - if iscoroutinefunction(function): - coro_function = cast(Callable[_P, Awaitable[_R]], function) - - async def coro_until( - condition: Callable[[_R], bool], timeout: float = _DEFAULT_TIMEOUT - ) -> None: - started_at = time.time() - while True: - result = await coro_function(*args, **kwargs) - if condition(result): - break - elif (time.time() - started_at) > timeout: # pragma: no cover - raise TimeoutError( - f"Condition not met within {timeout} " - f"seconds - last value was {result!r}" - ) - - self.until = coro_until - else: - sync_function = cast(Callable[_P, _R], function) - - def sync_until( - condition: Callable[[_R], bool] | Any, timeout: float = _DEFAULT_TIMEOUT - ) -> None: - started_at = time.time() - while True: - result = sync_function(*args, **kwargs) - if condition(result): - break - elif (time.time() - started_at) > timeout: # pragma: no cover - raise TimeoutError( - f"Condition not met within {timeout} " - f"seconds - last value was {result!r}" - ) - - self.until = sync_until - - def until_is(self, right: Any, timeout: float = _DEFAULT_TIMEOUT) -> Any: - """Wait until the result is identical to the given value""" - return self.until(lambda left: left is right, timeout) - - def until_equals(self, right: Any, timeout: float = _DEFAULT_TIMEOUT) -> Any: - """Wait until the result is equal to the given value""" - # not really sure why I need a type ignore comment here - return self.until(lambda left: left == right, timeout) # type: ignore - - -class DisplayFixture: - """A fixture for running web-based tests using ``playwright``""" - - _exit_stack: AsyncExitStack - - def __init__( - self, - server: ServerFixture | None = None, - driver: Browser | BrowserContext | Page | None = None, - ) -> None: - if server is not None: - self.server = server - if driver is not None: - if isinstance(driver, Page): - self.page = driver - else: - self._browser = driver - self._next_view_id = 0 - - async def show( - self, - component: RootComponentConstructor, - query: dict[str, Any] | None = None, - ) -> None: - self._next_view_id += 1 - view_id = f"display-{self._next_view_id}" - self.server.mount(lambda: html.div({"id": view_id}, component())) - - await self.page.goto(self.server.url(query=query)) - await self.page.wait_for_selector(f"#{view_id}", state="attached") - - async def __aenter__(self) -> DisplayFixture: - es = self._exit_stack = AsyncExitStack() - - browser: Browser | BrowserContext - if not hasattr(self, "page"): - if not hasattr(self, "_browser"): - pw = await es.enter_async_context(async_playwright()) - browser = await pw.chromium.launch() - else: - browser = self._browser - self.page = await browser.new_page() - - if not hasattr(self, "server"): - self.server = ServerFixture() - await es.enter_async_context(self.server) - - return self - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc_value: BaseException | None, - traceback: TracebackType | None, - ) -> None: - self.server.mount(None) - await self._exit_stack.aclose() - - -class ServerFixture: - """A test fixture for running a server and imperatively displaying views - - This fixture is typically used alongside async web drivers like ``playwight``. - - Example: - .. code-block:: - - async with ServerFixture() as server: - server.mount(MyComponent) - """ - - _records: list[logging.LogRecord] - _server_future: asyncio.Task[Any] - _exit_stack = AsyncExitStack() - - def __init__( - self, - host: str = "127.0.0.1", - port: Optional[int] = None, - app: Any | None = None, - implementation: ServerImplementation[Any] = default_server, - ) -> None: - self.host = host - self.port = port or find_available_port(host, allow_reuse_waiting_ports=False) - self.mount, self._root_component = hotswap() - - if app is not None: - if implementation is None: - raise ValueError( - "If an application instance its corresponding " - "server implementation must be provided too." - ) - - self._app = app - self.implementation = implementation - - @property - def log_records(self) -> list[logging.LogRecord]: - """A list of captured log records""" - return self._records - - def url(self, path: str = "", query: Optional[Any] = None) -> str: - """Return a URL string pointing to the host and point of the server - - Args: - path: the path to a resource on the server - query: a dictionary or list of query parameters - """ - return urlunparse( - [ - "http", - f"{self.host}:{self.port}", - path, - "", - urlencode(query or ()), - "", - ] - ) - - def list_logged_exceptions( - self, - pattern: str = "", - types: Union[Type[Any], Tuple[Type[Any], ...]] = Exception, - log_level: int = logging.ERROR, - del_log_records: bool = True, - ) -> list[BaseException]: - """Return a list of logged exception matching the given criteria - - Args: - log_level: The level of log to check - exclude_exc_types: Any exception types to ignore - del_log_records: Whether to delete the log records for yielded exceptions - """ - found: list[BaseException] = [] - compiled_pattern = re.compile(pattern) - for index, record in enumerate(self.log_records): - if record.levelno >= log_level and record.exc_info: - error = record.exc_info[1] - if ( - error is not None - and isinstance(error, types) - and compiled_pattern.search(str(error)) - ): - if del_log_records: - del self.log_records[index - len(found)] - found.append(error) - return found - - async def __aenter__(self) -> ServerFixture: - self._exit_stack = AsyncExitStack() - self._records = self._exit_stack.enter_context(capture_idom_logs()) - - app = self._app or self.implementation.create_development_app() - self.implementation.configure(app, self._root_component) - - started = asyncio.Event() - server_future = asyncio.create_task( - self.implementation.serve_development_app( - app, self.host, self.port, started - ) - ) - - async def stop_server() -> None: - server_future.cancel() - try: - await asyncio.wait_for(server_future, timeout=3) - except asyncio.CancelledError: - pass - - self._exit_stack.push_async_callback(stop_server) - - try: - await asyncio.wait_for(started.wait(), timeout=3) - except Exception: - # see if we can await the future for a more helpful error - await asyncio.wait_for(server_future, timeout=3) - raise - - return self - - async def __aexit__( - self, - exc_type: Optional[Type[BaseException]], - exc_value: Optional[BaseException], - traceback: Optional[TracebackType], - ) -> None: - await self._exit_stack.aclose() - - self.mount(None) # reset the view - - logged_errors = self.list_logged_exceptions(del_log_records=False) - if logged_errors: # pragma: no cover - raise LogAssertionError("Unexpected logged exception") from logged_errors[0] - - return None - - -class LogAssertionError(AssertionError): - """An assertion error raised in relation to log messages.""" - - -@contextmanager -def assert_idom_logged( - match_message: str = "", - error_type: type[Exception] | None = None, - match_error: str = "", - clear_after: bool = True, -) -> Iterator[None]: - """Assert that IDOM produced a log matching the described message or error. - - Args: - match_message: Must match a logged message. - error_type: Checks the type of logged exceptions. - match_error: Must match an error message. - clear_after: Whether to remove logged records that match. - """ - message_pattern = re.compile(match_message) - error_pattern = re.compile(match_error) - - with capture_idom_logs(clear_after=clear_after) as log_records: - try: - yield None - except Exception: - raise - else: - for record in list(log_records): - if ( - # record message matches - message_pattern.findall(record.getMessage()) - # error type matches - and ( - error_type is None - or ( - record.exc_info is not None - and record.exc_info[0] is not None - and issubclass(record.exc_info[0], error_type) - ) - ) - # error message pattern matches - and ( - not match_error - or ( - record.exc_info is not None - and error_pattern.findall( - "".join(format_exception(*record.exc_info)) - ) - ) - ) - ): - break - else: # pragma: no cover - _raise_log_message_error( - "Could not find a log record matching the given", - match_message, - error_type, - match_error, - ) - - -@contextmanager -def assert_idom_did_not_log( - match_message: str = "", - error_type: type[Exception] | None = None, - match_error: str = "", - clear_after: bool = True, -) -> Iterator[None]: - """Assert the inverse of :func:`assert_idom_logged`""" - try: - with assert_idom_logged(match_message, error_type, match_error, clear_after): - yield None - except LogAssertionError: - pass - else: - _raise_log_message_error( - "Did find a log record matching the given", - match_message, - error_type, - match_error, - ) - - -def _raise_log_message_error( - prefix: str, - match_message: str = "", - error_type: type[Exception] | None = None, - match_error: str = "", -) -> NoReturn: - conditions = [] - if match_message: - conditions.append(f"log message pattern {match_message!r}") - if error_type: - conditions.append(f"exception type {error_type}") - if match_error: - conditions.append(f"error message pattern {match_error!r}") - raise LogAssertionError(prefix + " " + " and ".join(conditions)) - - -@contextmanager -def capture_idom_logs(clear_after: bool = True) -> Iterator[list[logging.LogRecord]]: - """Capture logs from IDOM - - Args: - clear_after: - Clear any records which were produced in this context when exiting. - """ - original_level = ROOT_LOGGER.level - ROOT_LOGGER.setLevel(logging.DEBUG) - try: - if _LOG_RECORD_CAPTOR in ROOT_LOGGER.handlers: - start_index = len(_LOG_RECORD_CAPTOR.records) - try: - yield _LOG_RECORD_CAPTOR.records - finally: - end_index = len(_LOG_RECORD_CAPTOR.records) - _LOG_RECORD_CAPTOR.records[start_index:end_index] = [] - return None - - ROOT_LOGGER.addHandler(_LOG_RECORD_CAPTOR) - try: - yield _LOG_RECORD_CAPTOR.records - finally: - ROOT_LOGGER.removeHandler(_LOG_RECORD_CAPTOR) - _LOG_RECORD_CAPTOR.records.clear() - finally: - ROOT_LOGGER.setLevel(original_level) - - -class HookCatcher: - """Utility for capturing a LifeCycleHook from a component - - Example: - .. code-block:: - - hooks = HookCatcher(index_by_kwarg="thing") - - @idom.component - @hooks.capture - def MyComponent(thing): - ... - - ... # render the component - - # grab the last render of where MyComponent(thing='something') - hooks.index["something"] - # or grab the hook from the component's last render - hooks.latest - - After the first render of ``MyComponent`` the ``HookCatcher`` will have - captured the component's ``LifeCycleHook``. - """ - - latest: LifeCycleHook - - def __init__(self, index_by_kwarg: Optional[str] = None): - self.index_by_kwarg = index_by_kwarg - self.index: dict[Any, LifeCycleHook] = {} - - def capture(self, render_function: Callable[..., Any]) -> Callable[..., Any]: - """Decorator for capturing a ``LifeCycleHook`` on each render of a component""" - - # The render function holds a reference to `self` and, via the `LifeCycleHook`, - # the component. Some tests check whether components are garbage collected, thus - # we must use a `ref` here to ensure these checks pass once the catcher itself - # has been collected. - self_ref = ref(self) - - @wraps(render_function) - def wrapper(*args: Any, **kwargs: Any) -> Any: - self = self_ref() - assert self is not None, "Hook catcher has been garbage collected" - - hook = current_hook() - if self.index_by_kwarg is not None: - self.index[kwargs[self.index_by_kwarg]] = hook - self.latest = hook - return render_function(*args, **kwargs) - - return wrapper - - -class StaticEventHandler: - """Utility for capturing the target of one event handler - - Example: - .. code-block:: - - static_handler = StaticEventHandler() - - @idom.component - def MyComponent(): - state, set_state = idom.hooks.use_state(0) - handler = static_handler.use(lambda event: set_state(state + 1)) - return idom.html.button({"onClick": handler}, "Click me!") - - # gives the target ID for onClick where from the last render of MyComponent - static_handlers.target - - If you need to capture event handlers from different instances of a component - the you should create multiple ``StaticEventHandler`` instances. - - .. code-block:: - - static_handlers_by_key = { - "first": StaticEventHandler(), - "second": StaticEventHandler(), - } - - @idom.component - def Parent(): - return idom.html.div(Child(key="first"), Child(key="second")) - - @idom.component - def Child(key): - state, set_state = idom.hooks.use_state(0) - handler = static_handlers_by_key[key].use(lambda event: set_state(state + 1)) - return idom.html.button({"onClick": handler}, "Click me!") - - # grab the individual targets for each instance above - first_target = static_handlers_by_key["first"].target - second_target = static_handlers_by_key["second"].target - """ - - def __init__(self) -> None: - self.target = uuid4().hex - - def use( - self, - function: Callable[..., Any], - stop_propagation: bool = False, - prevent_default: bool = False, - ) -> EventHandler: - return EventHandler( - to_event_handler_function(function), - stop_propagation, - prevent_default, - self.target, - ) - - -def clear_idom_web_modules_dir() -> None: - for path in IDOM_WEB_MODULES_DIR.current.iterdir(): - shutil.rmtree(path) if path.is_dir() else path.unlink() - - -class _LogRecordCaptor(logging.NullHandler): - def __init__(self) -> None: - self.records: list[logging.LogRecord] = [] - super().__init__() - - def handle(self, record: logging.LogRecord) -> bool: - self.records.append(record) - return True - - -_LOG_RECORD_CAPTOR = _LogRecordCaptor() diff --git a/src/idom/testing/__init__.py b/src/idom/testing/__init__.py new file mode 100644 index 000000000..62c80adcb --- /dev/null +++ b/src/idom/testing/__init__.py @@ -0,0 +1,23 @@ +from .common import HookCatcher, StaticEventHandler, clear_idom_web_modules_dir, poll +from .display import DisplayFixture +from .logs import ( + LogAssertionError, + assert_idom_did_not_log, + assert_idom_logged, + capture_idom_logs, +) +from .server import ServerFixture + + +__all__ = [ + "assert_idom_did_not_log", + "assert_idom_logged", + "capture_idom_logs", + "clear_idom_web_modules_dir", + "DisplayFixture", + "HookCatcher", + "LogAssertionError", + "poll", + "ServerFixture", + "StaticEventHandler", +] diff --git a/src/idom/testing/common.py b/src/idom/testing/common.py new file mode 100644 index 000000000..3e7ccbd0d --- /dev/null +++ b/src/idom/testing/common.py @@ -0,0 +1,205 @@ +from __future__ import annotations + +import shutil +import time +from functools import wraps +from inspect import iscoroutinefunction +from typing import Any, Awaitable, Callable, Generic, Optional, TypeVar, cast +from uuid import uuid4 +from weakref import ref + +from typing_extensions import ParamSpec, Protocol + +from idom.config import IDOM_WEB_MODULES_DIR +from idom.core.events import EventHandler, to_event_handler_function +from idom.core.hooks import LifeCycleHook, current_hook + + +def clear_idom_web_modules_dir() -> None: + """Clear the directory where IDOM stores registered web modules""" + for path in IDOM_WEB_MODULES_DIR.current.iterdir(): + shutil.rmtree(path) if path.is_dir() else path.unlink() + + +_P = ParamSpec("_P") +_R = TypeVar("_R") +_RC = TypeVar("_RC", covariant=True) +_DEFAULT_TIMEOUT = 3.0 + + +class _UntilFunc(Protocol[_RC]): + def __call__( + self, condition: Callable[[_RC], bool], timeout: float = _DEFAULT_TIMEOUT + ) -> Any: + ... + + +class poll(Generic[_R]): # noqa: N801 + """Wait until the result of an sync or async function meets some condition""" + + def __init__( + self, + function: Callable[_P, Awaitable[_R] | _R], + *args: _P.args, + **kwargs: _P.kwargs, + ) -> None: + self.until: _UntilFunc[_R] + """Check that the coroutines result meets a condition within the timeout""" + + if iscoroutinefunction(function): + coro_function = cast(Callable[_P, Awaitable[_R]], function) + + async def coro_until( + condition: Callable[[_R], bool], timeout: float = _DEFAULT_TIMEOUT + ) -> None: + started_at = time.time() + while True: + result = await coro_function(*args, **kwargs) + if condition(result): + break + elif (time.time() - started_at) > timeout: # pragma: no cover + raise TimeoutError( + f"Condition not met within {timeout} " + f"seconds - last value was {result!r}" + ) + + self.until = coro_until + else: + sync_function = cast(Callable[_P, _R], function) + + def sync_until( + condition: Callable[[_R], bool] | Any, timeout: float = _DEFAULT_TIMEOUT + ) -> None: + started_at = time.time() + while True: + result = sync_function(*args, **kwargs) + if condition(result): + break + elif (time.time() - started_at) > timeout: # pragma: no cover + raise TimeoutError( + f"Condition not met within {timeout} " + f"seconds - last value was {result!r}" + ) + + self.until = sync_until + + def until_is(self, right: Any, timeout: float = _DEFAULT_TIMEOUT) -> Any: + """Wait until the result is identical to the given value""" + return self.until(lambda left: left is right, timeout) + + def until_equals(self, right: Any, timeout: float = _DEFAULT_TIMEOUT) -> Any: + """Wait until the result is equal to the given value""" + # not really sure why I need a type ignore comment here + return self.until(lambda left: left == right, timeout) # type: ignore + + +class HookCatcher: + """Utility for capturing a LifeCycleHook from a component + + Example: + .. code-block:: + + hooks = HookCatcher(index_by_kwarg="thing") + + @idom.component + @hooks.capture + def MyComponent(thing): + ... + + ... # render the component + + # grab the last render of where MyComponent(thing='something') + hooks.index["something"] + # or grab the hook from the component's last render + hooks.latest + + After the first render of ``MyComponent`` the ``HookCatcher`` will have + captured the component's ``LifeCycleHook``. + """ + + latest: LifeCycleHook + + def __init__(self, index_by_kwarg: Optional[str] = None): + self.index_by_kwarg = index_by_kwarg + self.index: dict[Any, LifeCycleHook] = {} + + def capture(self, render_function: Callable[..., Any]) -> Callable[..., Any]: + """Decorator for capturing a ``LifeCycleHook`` on each render of a component""" + + # The render function holds a reference to `self` and, via the `LifeCycleHook`, + # the component. Some tests check whether components are garbage collected, thus + # we must use a `ref` here to ensure these checks pass once the catcher itself + # has been collected. + self_ref = ref(self) + + @wraps(render_function) + def wrapper(*args: Any, **kwargs: Any) -> Any: + self = self_ref() + assert self is not None, "Hook catcher has been garbage collected" + + hook = current_hook() + if self.index_by_kwarg is not None: + self.index[kwargs[self.index_by_kwarg]] = hook + self.latest = hook + return render_function(*args, **kwargs) + + return wrapper + + +class StaticEventHandler: + """Utility for capturing the target of one event handler + + Example: + .. code-block:: + + static_handler = StaticEventHandler() + + @idom.component + def MyComponent(): + state, set_state = idom.hooks.use_state(0) + handler = static_handler.use(lambda event: set_state(state + 1)) + return idom.html.button({"onClick": handler}, "Click me!") + + # gives the target ID for onClick where from the last render of MyComponent + static_handlers.target + + If you need to capture event handlers from different instances of a component + the you should create multiple ``StaticEventHandler`` instances. + + .. code-block:: + + static_handlers_by_key = { + "first": StaticEventHandler(), + "second": StaticEventHandler(), + } + + @idom.component + def Parent(): + return idom.html.div(Child(key="first"), Child(key="second")) + + @idom.component + def Child(key): + state, set_state = idom.hooks.use_state(0) + handler = static_handlers_by_key[key].use(lambda event: set_state(state + 1)) + return idom.html.button({"onClick": handler}, "Click me!") + + # grab the individual targets for each instance above + first_target = static_handlers_by_key["first"].target + second_target = static_handlers_by_key["second"].target + """ + + def __init__(self) -> None: + self.target = uuid4().hex + + def use( + self, + function: Callable[..., Any], + stop_propagation: bool = False, + prevent_default: bool = False, + ) -> EventHandler: + return EventHandler( + to_event_handler_function(function), + stop_propagation, + prevent_default, + self.target, + ) diff --git a/src/idom/testing/display.py b/src/idom/testing/display.py new file mode 100644 index 000000000..b89aa92ca --- /dev/null +++ b/src/idom/testing/display.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from contextlib import AsyncExitStack +from types import TracebackType +from typing import Any + +from playwright.async_api import Browser, BrowserContext, Page, async_playwright + +from idom import html +from idom.types import RootComponentConstructor + +from .server import ServerFixture + + +class DisplayFixture: + """A fixture for running web-based tests using ``playwright``""" + + _exit_stack: AsyncExitStack + + def __init__( + self, + server: ServerFixture | None = None, + driver: Browser | BrowserContext | Page | None = None, + ) -> None: + if server is not None: + self.server = server + if driver is not None: + if isinstance(driver, Page): + self.page = driver + else: + self._browser = driver + self._next_view_id = 0 + + async def show( + self, + component: RootComponentConstructor, + query: dict[str, Any] | None = None, + ) -> None: + self._next_view_id += 1 + view_id = f"display-{self._next_view_id}" + self.server.mount(lambda: html.div({"id": view_id}, component())) + + await self.page.goto(self.server.url(query=query)) + await self.page.wait_for_selector(f"#{view_id}", state="attached") + + async def __aenter__(self) -> DisplayFixture: + es = self._exit_stack = AsyncExitStack() + + browser: Browser | BrowserContext + if not hasattr(self, "page"): + if not hasattr(self, "_browser"): + pw = await es.enter_async_context(async_playwright()) + browser = await pw.chromium.launch() + else: + browser = self._browser + self.page = await browser.new_page() + + if not hasattr(self, "server"): + self.server = ServerFixture() + await es.enter_async_context(self.server) + + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + self.server.mount(None) + await self._exit_stack.aclose() diff --git a/src/idom/testing/logs.py b/src/idom/testing/logs.py new file mode 100644 index 000000000..9b534daa9 --- /dev/null +++ b/src/idom/testing/logs.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +import logging +import re +from contextlib import contextmanager +from traceback import format_exception +from typing import Any, Iterator, NoReturn + +from idom.log import ROOT_LOGGER + + +class LogAssertionError(AssertionError): + """An assertion error raised in relation to log messages.""" + + +@contextmanager +def assert_idom_logged( + match_message: str = "", + error_type: type[Exception] | None = None, + match_error: str = "", +) -> Iterator[None]: + """Assert that IDOM produced a log matching the described message or error. + + Args: + match_message: Must match a logged message. + error_type: Checks the type of logged exceptions. + match_error: Must match an error message. + """ + message_pattern = re.compile(match_message) + error_pattern = re.compile(match_error) + + with capture_idom_logs() as log_records: + try: + yield None + except Exception: + raise + else: + for record in list(log_records): + if ( + # record message matches + message_pattern.findall(record.getMessage()) + # error type matches + and ( + error_type is None + or ( + record.exc_info is not None + and record.exc_info[0] is not None + and issubclass(record.exc_info[0], error_type) + ) + ) + # error message pattern matches + and ( + not match_error + or ( + record.exc_info is not None + and error_pattern.findall( + "".join(format_exception(*record.exc_info)) + ) + ) + ) + ): + break + else: # pragma: no cover + _raise_log_message_error( + "Could not find a log record matching the given", + match_message, + error_type, + match_error, + ) + + +@contextmanager +def assert_idom_did_not_log( + match_message: str = "", + error_type: type[Exception] | None = None, + match_error: str = "", +) -> Iterator[None]: + """Assert the inverse of :func:`assert_idom_logged`""" + try: + with assert_idom_logged(match_message, error_type, match_error): + yield None + except LogAssertionError: + pass + else: + _raise_log_message_error( + "Did find a log record matching the given", + match_message, + error_type, + match_error, + ) + + +def list_logged_exceptions( + log_records: list[logging.LogRecord], + pattern: str = "", + types: type[Any] | tuple[type[Any], ...] = Exception, + log_level: int = logging.ERROR, + del_log_records: bool = True, +) -> list[BaseException]: + """Return a list of logged exception matching the given criteria + + Args: + log_level: The level of log to check + exclude_exc_types: Any exception types to ignore + del_log_records: Whether to delete the log records for yielded exceptions + """ + found: list[BaseException] = [] + compiled_pattern = re.compile(pattern) + for index, record in enumerate(log_records): + if record.levelno >= log_level and record.exc_info: + error = record.exc_info[1] + if ( + error is not None + and isinstance(error, types) + and compiled_pattern.search(str(error)) + ): + if del_log_records: + del log_records[index - len(found)] + found.append(error) + return found + + +@contextmanager +def capture_idom_logs() -> Iterator[list[logging.LogRecord]]: + """Capture logs from IDOM + + Any logs produced in this context are cleared afterwards + """ + original_level = ROOT_LOGGER.level + ROOT_LOGGER.setLevel(logging.DEBUG) + try: + if _LOG_RECORD_CAPTOR in ROOT_LOGGER.handlers: + start_index = len(_LOG_RECORD_CAPTOR.records) + try: + yield _LOG_RECORD_CAPTOR.records + finally: + end_index = len(_LOG_RECORD_CAPTOR.records) + _LOG_RECORD_CAPTOR.records[start_index:end_index] = [] + return None + + ROOT_LOGGER.addHandler(_LOG_RECORD_CAPTOR) + try: + yield _LOG_RECORD_CAPTOR.records + finally: + ROOT_LOGGER.removeHandler(_LOG_RECORD_CAPTOR) + _LOG_RECORD_CAPTOR.records.clear() + finally: + ROOT_LOGGER.setLevel(original_level) + + +class _LogRecordCaptor(logging.NullHandler): + def __init__(self) -> None: + self.records: list[logging.LogRecord] = [] + super().__init__() + + def handle(self, record: logging.LogRecord) -> bool: + self.records.append(record) + return True + + +_LOG_RECORD_CAPTOR = _LogRecordCaptor() + + +def _raise_log_message_error( + prefix: str, + match_message: str = "", + error_type: type[Exception] | None = None, + match_error: str = "", +) -> NoReturn: + conditions = [] + if match_message: + conditions.append(f"log message pattern {match_message!r}") + if error_type: + conditions.append(f"exception type {error_type}") + if match_error: + conditions.append(f"error message pattern {match_error!r}") + raise LogAssertionError(prefix + " " + " and ".join(conditions)) diff --git a/src/idom/testing/server.py b/src/idom/testing/server.py new file mode 100644 index 000000000..2a21e762e --- /dev/null +++ b/src/idom/testing/server.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +import asyncio +import logging +from contextlib import AsyncExitStack +from types import TracebackType +from typing import Any, Optional, Tuple, Type, Union +from urllib.parse import urlencode, urlunparse + +from idom.server import default as default_server +from idom.server.types import ServerImplementation +from idom.server.utils import find_available_port +from idom.widgets import hotswap + +from .logs import LogAssertionError, capture_idom_logs, list_logged_exceptions + + +class ServerFixture: + """A test fixture for running a server and imperatively displaying views + + This fixture is typically used alongside async web drivers like ``playwight``. + + Example: + .. code-block:: + + async with ServerFixture() as server: + server.mount(MyComponent) + """ + + _records: list[logging.LogRecord] + _server_future: asyncio.Task[Any] + _exit_stack = AsyncExitStack() + + def __init__( + self, + host: str = "127.0.0.1", + port: Optional[int] = None, + app: Any | None = None, + implementation: ServerImplementation[Any] | None = None, + ) -> None: + self.host = host + self.port = port or find_available_port(host, allow_reuse_waiting_ports=False) + self.mount, self._root_component = hotswap() + + if app is not None: + if implementation is None: + raise ValueError( + "If an application instance its corresponding " + "server implementation must be provided too." + ) + + self._app = app + self.implementation = implementation or default_server + + @property + def log_records(self) -> list[logging.LogRecord]: + """A list of captured log records""" + return self._records + + def url(self, path: str = "", query: Optional[Any] = None) -> str: + """Return a URL string pointing to the host and point of the server + + Args: + path: the path to a resource on the server + query: a dictionary or list of query parameters + """ + return urlunparse( + [ + "http", + f"{self.host}:{self.port}", + path, + "", + urlencode(query or ()), + "", + ] + ) + + def list_logged_exceptions( + self, + pattern: str = "", + types: Union[Type[Any], Tuple[Type[Any], ...]] = Exception, + log_level: int = logging.ERROR, + del_log_records: bool = True, + ) -> list[BaseException]: + """Return a list of logged exception matching the given criteria + + Args: + log_level: The level of log to check + exclude_exc_types: Any exception types to ignore + del_log_records: Whether to delete the log records for yielded exceptions + """ + return list_logged_exceptions( + self.log_records, + pattern, + types, + log_level, + del_log_records, + ) + + async def __aenter__(self) -> ServerFixture: + self._exit_stack = AsyncExitStack() + self._records = self._exit_stack.enter_context(capture_idom_logs()) + + app = self._app or self.implementation.create_development_app() + self.implementation.configure(app, self._root_component) + + started = asyncio.Event() + server_future = asyncio.create_task( + self.implementation.serve_development_app( + app, self.host, self.port, started + ) + ) + + async def stop_server() -> None: + server_future.cancel() + try: + await asyncio.wait_for(server_future, timeout=3) + except asyncio.CancelledError: + pass + + self._exit_stack.push_async_callback(stop_server) + + try: + await asyncio.wait_for(started.wait(), timeout=3) + except Exception: # pragma: no cover + # see if we can await the future for a more helpful error + await asyncio.wait_for(server_future, timeout=3) + raise + + return self + + async def __aexit__( + self, + exc_type: Optional[Type[BaseException]], + exc_value: Optional[BaseException], + traceback: Optional[TracebackType], + ) -> None: + await self._exit_stack.aclose() + + self.mount(None) # reset the view + + logged_errors = self.list_logged_exceptions(del_log_records=False) + if logged_errors: # pragma: no cover + raise LogAssertionError("Unexpected logged exception") from logged_errors[0] + + return None diff --git a/tests/conftest.py b/tests/conftest.py index 709319fbb..a6cae2a2d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -70,9 +70,9 @@ def clear_web_modules_dir_after_test(): def assert_no_logged_exceptions(): with capture_idom_logs() as records: yield - try: - for r in records: - if r.exc_info is not None: - raise r.exc_info[1] - finally: - records.clear() + try: + for r in records: + if r.exc_info is not None: + raise r.exc_info[1] + finally: + records.clear() diff --git a/tests/test_core/test_layout.py b/tests/test_core/test_layout.py index ad7f767d4..ddeb9f4ae 100644 --- a/tests/test_core/test_layout.py +++ b/tests/test_core/test_layout.py @@ -181,10 +181,7 @@ def OkChild(): def BadChild(): raise ValueError("error from bad child") - with assert_idom_logged( - match_error="error from bad child", - clear_after=True, - ): + with assert_idom_logged(match_error="error from bad child"): with idom.Layout(Main()) as layout: patch = await render_json_patch(layout) @@ -240,10 +237,7 @@ def OkChild(): def BadChild(): raise ValueError("error from bad child") - with assert_idom_logged( - match_error="error from bad child", - clear_after=True, - ): + with assert_idom_logged(match_error="error from bad child"): with idom.Layout(Main()) as layout: patch = await render_json_patch(layout) @@ -743,7 +737,6 @@ def ComponentReturnsDuplicateKeys(): with assert_idom_logged( error_type=ValueError, match_error=r"Duplicate keys \['duplicate'\] at '/children/0'", - clear_after=True, ): await layout.render() @@ -757,7 +750,6 @@ def ComponentReturnsDuplicateKeys(): with assert_idom_logged( error_type=ValueError, match_error=r"Duplicate keys \['duplicate'\] at '/children/0'", - clear_after=True, ): await layout.render() @@ -796,10 +788,7 @@ def raise_error(): return idom.html.button({"onClick": raise_error}) - with assert_idom_logged( - match_error="bad event handler", - clear_after=True, - ): + with assert_idom_logged(match_error="bad event handler"): with idom.Layout(ComponentWithBadEventHandler()) as layout: await layout.render() @@ -1233,7 +1222,6 @@ def bad_should_render(new): match_message=r".* component failed to check if .* should be rendered", error_type=ValueError, match_error="The error message", - clear_after=True, ): with idom.Layout(Root()) as layout: await layout.render() diff --git a/tests/test_server/test_common.py b/tests/test_server/test_common.py index 7b4acb228..ea0850ce9 100644 --- a/tests/test_server/test_common.py +++ b/tests/test_server/test_common.py @@ -6,7 +6,7 @@ from idom import html from idom.server import default as default_implementation from idom.server.utils import all_implementations -from idom.testing import DisplayFixture, ServerFixture +from idom.testing import DisplayFixture, ServerFixture, poll @pytest.fixture( @@ -52,7 +52,7 @@ def Counter(): counter = await display.page.wait_for_selector("#counter") for i in range(5): - assert (await counter.text_content()) == f"Count: {i}" + await poll(counter.text_content).until_equals(f"Count: {i}") await counter.click() diff --git a/tests/test_testing.py b/tests/test_testing.py index 4ca3bb90f..7394c9904 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -6,6 +6,7 @@ from idom import testing from idom.log import ROOT_LOGGER from idom.sample import App as SampleApp +from idom.server import starlette as starlette_implementation def test_assert_idom_logged_does_not_supress_errors(): @@ -133,3 +134,31 @@ async def test_simple_display_fixture(): async with testing.DisplayFixture() as display: await display.show(SampleApp) await display.page.wait_for_selector("#sample") + + +def test_if_app_is_given_implementation_must_be_too(): + with pytest.raises( + ValueError, + match=r"If an application instance its corresponding server implementation must be provided too", + ): + testing.ServerFixture(app=starlette_implementation.create_development_app()) + + testing.ServerFixture( + app=starlette_implementation.create_development_app(), + implementation=starlette_implementation, + ) + + +def test_list_logged_excptions(): + the_error = None + with testing.capture_idom_logs() as records: + ROOT_LOGGER.info("A non-error log message") + + try: + raise ValueError("An error for testing") + except Exception as error: + ROOT_LOGGER.exception("Log the error") + the_error = error + + logged_errors = testing.logs.list_logged_exceptions(records) + assert logged_errors == [the_error] From 5cd8f456976ef8464399f97c5c2f329c9ddb5ee1 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sat, 26 Mar 2022 21:52:27 -0700 Subject: [PATCH 42/58] minor logging changes --- docs/source/guides/getting-started/index.rst | 22 +++++++------- requirements/pkg-deps.txt | 1 + scripts/live_docs.py | 1 + src/idom/__init__.py | 4 +-- src/idom/{log.py => logging.py} | 4 +-- src/idom/server/_asgi.py | 30 +++++++++++++----- src/idom/server/default.py | 5 ++- src/idom/server/flask.py | 11 +++++-- src/idom/server/sanic.py | 5 ++- src/idom/server/starlette.py | 2 +- src/idom/server/tornado.py | 13 ++++++-- src/idom/server/types.py | 6 +++- src/idom/server/utils.py | 32 ++++++-------------- src/idom/testing/logs.py | 2 +- tests/test_server/test_utils.py | 1 - tests/test_testing.py | 2 +- 16 files changed, 83 insertions(+), 58 deletions(-) rename src/idom/{log.py => logging.py} (85%) diff --git a/docs/source/guides/getting-started/index.rst b/docs/source/guides/getting-started/index.rst index 5aa150a1d..d2bfbac15 100644 --- a/docs/source/guides/getting-started/index.rst +++ b/docs/source/guides/getting-started/index.rst @@ -18,15 +18,15 @@ Getting Started :link: installing-idom :link-type: doc - Learn how IDOM can be installed in a variety of different ways - with different web - servers and even in different frameworks. + Learn how IDOM can be installed in a variety of different ways - with + different web servers and even in different frameworks. .. grid-item-card:: :octicon:`play` Running IDOM :link: running-idom :link-type: doc - See the ways that IDOM can be run with servers or be embedded in existing - applications. + See how IDOM can be run with a variety of different production servers or be + added to existing applications. The fastest way to get started with IDOM is to try it out in a `Juptyer Notebook `__. @@ -89,11 +89,11 @@ For anything else, report your issue in IDOM's :discussion-type:`discussion foru Section 2: Running IDOM ----------------------- -Once you've :ref:`installed IDOM `. The simplest way to run IDOM is -with the :func:`~idom.server.prefab.run` function. By default this will execute your -application using one of the builtin server implementations whose dependencies have all -been installed. Running a tiny "hello world" application just requires the following -code: +Once you've :ref:`installed IDOM `, you'll want to learn how to run an +application. Throughout most of the examples in this documentation, you'll see the +:func:`~idom.server.utils.run` function used. While it's convenient tool for development +it shouldn't be used in production settings - it's slow, and could leak secrets through +debug log messages. .. idom:: _examples/hello_world @@ -104,5 +104,5 @@ code: :octicon:`book` Read More ^^^^^^^^^^^^^^^^^^^^^^^^^ - See the ways that IDOM can be run with servers or be embedded in existing - applications. + See how IDOM can be run with a variety of different production servers or be + added to existing applications. diff --git a/requirements/pkg-deps.txt b/requirements/pkg-deps.txt index f4d440e32..706e6fdcd 100644 --- a/requirements/pkg-deps.txt +++ b/requirements/pkg-deps.txt @@ -4,3 +4,4 @@ anyio >=3.0 jsonpatch >=1.32 fastjsonschema >=2.14.5 requests >=2.0 +colorlog >=6 diff --git a/scripts/live_docs.py b/scripts/live_docs.py index c968394cd..c63fd476c 100644 --- a/scripts/live_docs.py +++ b/scripts/live_docs.py @@ -60,6 +60,7 @@ async def set_thread_event_when_started(): def new_builder(): clear_idom_web_modules_dir() reload_examples() + old_builder() return new_builder diff --git a/src/idom/__init__.py b/src/idom/__init__.py index b14aa3d6b..336f3332f 100644 --- a/src/idom/__init__.py +++ b/src/idom/__init__.py @@ -1,4 +1,4 @@ -from . import config, html, log, sample, server, types, web +from . import config, html, logging, sample, server, types, web from .core import hooks from .core.component import component from .core.events import event @@ -33,7 +33,7 @@ "html_to_vdom", "html", "Layout", - "log", + "logging", "Ref", "run", "sample", diff --git a/src/idom/log.py b/src/idom/logging.py similarity index 85% rename from src/idom/log.py rename to src/idom/logging.py index 856ee9ab2..4f77e72c2 100644 --- a/src/idom/log.py +++ b/src/idom/logging.py @@ -24,9 +24,9 @@ }, "formatters": { "generic": { - "format": "%(asctime)s | %(levelname)s | %(message)s", + "format": "%(asctime)s | %(log_color)s%(levelname)s%(reset)s | %(message)s", "datefmt": r"%Y-%m-%dT%H:%M:%S%z", - "class": "logging.Formatter", + "class": "colorlog.ColoredFormatter", } }, } diff --git a/src/idom/server/_asgi.py b/src/idom/server/_asgi.py index fac48c73e..d84c85f43 100644 --- a/src/idom/server/_asgi.py +++ b/src/idom/server/_asgi.py @@ -1,7 +1,7 @@ from __future__ import annotations import asyncio -from typing import Any +from typing import Any, Awaitable from asgiref.typing import ASGIApplication from uvicorn.config import Config as UvicornConfig @@ -12,17 +12,31 @@ async def serve_development_asgi( app: ASGIApplication | Any, host: str, port: int, - started: asyncio.Event, + started: asyncio.Event | None, ) -> None: """Run a development server for starlette""" - server = UvicornServer(UvicornConfig(app, host=host, port=port, loop="asyncio")) + server = UvicornServer( + UvicornConfig( + app, + host=host, + port=port, + loop="asyncio", + debug=True, + ) + ) - async def check_if_started() -> None: - while not server.started: - await asyncio.sleep(0.2) - started.set() + coros: list[Awaitable[Any]] = [server.serve()] + + if started: + + async def check_if_started() -> None: + while not server.started: + await asyncio.sleep(0.2) + started.set() + + coros.append(check_if_started()) try: - await asyncio.gather(server.serve(), check_if_started()) + await asyncio.gather(*coros) finally: await asyncio.wait_for(server.shutdown(), timeout=3) diff --git a/src/idom/server/default.py b/src/idom/server/default.py index b8d8c0227..e94b28dd8 100644 --- a/src/idom/server/default.py +++ b/src/idom/server/default.py @@ -20,7 +20,10 @@ def create_development_app() -> Any: async def serve_development_app( - app: Any, host: str, port: int, started: asyncio.Event + app: Any, + host: str, + port: int, + started: asyncio.Event | None = None, ) -> None: """Run an application using a development server""" return await _default_implementation().serve_development_app( diff --git a/src/idom/server/flask.py b/src/idom/server/flask.py index a627e0811..955507a51 100644 --- a/src/idom/server/flask.py +++ b/src/idom/server/flask.py @@ -66,7 +66,10 @@ def create_development_app() -> Flask: async def serve_development_app( - app: Flask, host: str, port: int, started: asyncio.Event + app: Flask, + host: str, + port: int, + started: asyncio.Event | None = None, ) -> None: """Run an application using a development server""" loop = asyncio.get_event_loop() @@ -83,7 +86,8 @@ def run_server() -> None: # pragma: no cover handler_class=WebSocketHandler, ) server.start() - loop.call_soon_threadsafe(started.set) + if started: + loop.call_soon_threadsafe(started.set) try: server.serve_forever() finally: @@ -92,7 +96,8 @@ def run_server() -> None: # pragma: no cover thread = Thread(target=run_server, daemon=True) thread.start() - await started.wait() + if started: + await started.wait() try: await stopped.wait() diff --git a/src/idom/server/sanic.py b/src/idom/server/sanic.py index 6adcc8e04..aac1045e1 100644 --- a/src/idom/server/sanic.py +++ b/src/idom/server/sanic.py @@ -54,7 +54,10 @@ def create_development_app() -> Sanic: async def serve_development_app( - app: Sanic, host: str, port: int, started: asyncio.Event + app: Sanic, + host: str, + port: int, + started: asyncio.Event | None = None, ) -> None: """Run a development server for :mod:`sanic`""" await serve_development_asgi(app, host, port, started) diff --git a/src/idom/server/starlette.py b/src/idom/server/starlette.py index a2c942895..53deeba29 100644 --- a/src/idom/server/starlette.py +++ b/src/idom/server/starlette.py @@ -64,7 +64,7 @@ async def serve_development_app( app: Starlette, host: str, port: int, - started: asyncio.Event, + started: asyncio.Event | None = None, ) -> None: """Run a development server for starlette""" await serve_development_asgi(app, host, port, started) diff --git a/src/idom/server/tornado.py b/src/idom/server/tornado.py index 1bb6c2344..d41c21f7b 100644 --- a/src/idom/server/tornado.py +++ b/src/idom/server/tornado.py @@ -9,6 +9,7 @@ from tornado.httpserver import HTTPServer from tornado.httputil import HTTPServerRequest +from tornado.log import enable_pretty_logging from tornado.platform.asyncio import AsyncIOMainLoop from tornado.web import Application, RedirectHandler, RequestHandler, StaticFileHandler from tornado.websocket import WebSocketHandler @@ -56,16 +57,22 @@ def create_development_app() -> Application: async def serve_development_app( - app: Application, host: str, port: int, started: asyncio.Event + app: Application, + host: str, + port: int, + started: asyncio.Event | None = None, ) -> None: + enable_pretty_logging() + # setup up tornado to use asyncio AsyncIOMainLoop().install() server = HTTPServer(app) server.listen(port, host) - # at this point the server is accepting connection - started.set() + if started: + # at this point the server is accepting connection + started.set() try: # block forever - tornado has already set up its own background tasks diff --git a/src/idom/server/types.py b/src/idom/server/types.py index 72866465a..6ab8a5940 100644 --- a/src/idom/server/types.py +++ b/src/idom/server/types.py @@ -22,7 +22,11 @@ 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 + self, + app: _App, + host: str, + port: int, + started: asyncio.Event | None = None, ) -> None: """Run an application using a development server""" diff --git a/src/idom/server/utils.py b/src/idom/server/utils.py index 58ee9c633..fa37d9efc 100644 --- a/src/idom/server/utils.py +++ b/src/idom/server/utils.py @@ -1,13 +1,12 @@ from __future__ import annotations import asyncio +import logging import socket -import warnings -import webbrowser from contextlib import closing from importlib import import_module from pathlib import Path -from typing import Any, Awaitable, Iterator +from typing import Any, Iterator import idom from idom.types import RootComponentConstructor @@ -15,6 +14,7 @@ from .types import ServerImplementation +logger = logging.getLogger(__name__) CLIENT_BUILD_DIR = Path(idom.__file__).parent / "client" SUPPORTED_PACKAGES = ( @@ -30,14 +30,12 @@ def run( component: RootComponentConstructor, host: str = "127.0.0.1", port: int | None = None, - open_browser: bool = True, implementation: ServerImplementation[Any] | None = None, ) -> None: """Run a component with a development server""" - - warnings.warn( - "You are running a development server, be sure to change this before deploying in production!", - UserWarning, + logger.warn( + "You are running a development server. " + "Change this before deploying in production!", stacklevel=2, ) @@ -46,23 +44,13 @@ def run( app = implementation.create_development_app() implementation.configure(app, component) - coros: list[Awaitable[Any]] = [] - host = host port = port or find_available_port(host) - started = asyncio.Event() - - coros.append(implementation.serve_development_app(app, host, port, started)) - - if open_browser: # pragma: no cover + logger.info(f"Running at http://{host}:{port}") - async def _open_browser_after_server() -> None: - await started.wait() - webbrowser.open(f"http://{host}:{port}") - - coros.append(_open_browser_after_server()) - - asyncio.get_event_loop().run_until_complete(asyncio.gather(*coros)) + asyncio.get_event_loop().run_until_complete( + implementation.serve_development_app(app, host, port) + ) def find_available_port( diff --git a/src/idom/testing/logs.py b/src/idom/testing/logs.py index 9b534daa9..f0639bb40 100644 --- a/src/idom/testing/logs.py +++ b/src/idom/testing/logs.py @@ -6,7 +6,7 @@ from traceback import format_exception from typing import Any, Iterator, NoReturn -from idom.log import ROOT_LOGGER +from idom.logging import ROOT_LOGGER class LogAssertionError(AssertionError): diff --git a/tests/test_server/test_utils.py b/tests/test_server/test_utils.py index d01a64cdb..66c296d84 100644 --- a/tests/test_server/test_utils.py +++ b/tests/test_server/test_utils.py @@ -39,7 +39,6 @@ def run_in_thread(): SampleApp, host, port, - open_browser=False, implementation=flask_implementation, ) diff --git a/tests/test_testing.py b/tests/test_testing.py index 7394c9904..c814df4b2 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -4,7 +4,7 @@ import pytest from idom import testing -from idom.log import ROOT_LOGGER +from idom.logging import ROOT_LOGGER from idom.sample import App as SampleApp from idom.server import starlette as starlette_implementation From 37282bc1e98c56735bd6e345168280d2e6dd0d14 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sat, 26 Mar 2022 22:50:07 -0700 Subject: [PATCH 43/58] fix live docs script --- scripts/live_docs.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/scripts/live_docs.py b/scripts/live_docs.py index c63fd476c..aa0d10337 100644 --- a/scripts/live_docs.py +++ b/scripts/live_docs.py @@ -1,6 +1,8 @@ import asyncio import os import threading +import time +import webbrowser from sphinx_autobuild.cli import ( Server, @@ -95,9 +97,14 @@ def main(): # Find the free port portn = args.port or find_free_port() if args.openbrowser is True: - server.serve(port=portn, host=args.host, root=outdir, open_url_delay=args.delay) - else: - server.serve(port=portn, host=args.host, root=outdir) + + def opener(): + time.sleep(args.delay) + webbrowser.open("http://%s:%s/index.html" % (args.host, 8000)) + + threading.Thread(target=opener, daemon=True).start() + + server.serve(port=portn, host=args.host, root=outdir) if __name__ == "__main__": From 0ea579109c50dfbfe5536ce513661dd34bc75c9c Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sat, 26 Mar 2022 23:20:41 -0700 Subject: [PATCH 44/58] fix log message --- src/idom/server/utils.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/idom/server/utils.py b/src/idom/server/utils.py index fa37d9efc..8b9df8986 100644 --- a/src/idom/server/utils.py +++ b/src/idom/server/utils.py @@ -35,8 +35,7 @@ def run( """Run a component with a development server""" logger.warn( "You are running a development server. " - "Change this before deploying in production!", - stacklevel=2, + "Change this before deploying in production!" ) implementation = implementation or import_module("idom.server.default") From a8add30f0455c9affabb726d1dd025e3755a6e38 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sat, 26 Mar 2022 23:26:37 -0700 Subject: [PATCH 45/58] fix types --- src/idom/server/_asgi.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/idom/server/_asgi.py b/src/idom/server/_asgi.py index d84c85f43..9e01a21e7 100644 --- a/src/idom/server/_asgi.py +++ b/src/idom/server/_asgi.py @@ -28,15 +28,15 @@ async def serve_development_asgi( coros: list[Awaitable[Any]] = [server.serve()] if started: - - async def check_if_started() -> None: - while not server.started: - await asyncio.sleep(0.2) - started.set() - - coros.append(check_if_started()) + coros.append(_check_if_started(server, started)) try: await asyncio.gather(*coros) finally: await asyncio.wait_for(server.shutdown(), timeout=3) + + +async def _check_if_started(server: UvicornServer, started: asyncio.Event) -> None: + while not server.started: + await asyncio.sleep(0.2) + started.set() From e1478a9fe5657477616823ac29ae5e938078100b Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sun, 27 Mar 2022 15:03:13 -0700 Subject: [PATCH 46/58] documentation updates --- docs/examples.py | 4 +- docs/source/_exts/idom_example.py | 2 +- .../adding_state_variable/{app.py => main.py} | 0 .../isolated_state/{app.py => main.py} | 0 .../{app.py => main.py} | 0 .../{app.py => main.py} | 0 .../components-with-state/index.rst | 18 +- .../super_simple_chart/{app.py => main.py} | 0 .../getting-started/_examples/hello_world.py | 2 +- .../getting-started/_examples/run_fastapi.py | 23 ++ .../getting-started/_examples/run_flask.py | 23 ++ .../getting-started/_examples/run_sanic.py | 27 ++ .../_examples/run_starlette.py | 23 ++ .../getting-started/_examples/run_tornado.py | 32 +++ .../getting-started/_examples/sample_app.py | 2 +- .../_static/embed-idom-view/main.py | 6 +- docs/source/guides/getting-started/index.rst | 29 +- .../getting-started/installing-idom.rst | 30 ++- .../guides/getting-started/running-idom.rst | 251 ++++++------------ .../filterable_list/{app.py => main.py} | 0 .../synced_inputs/{app.py => main.py} | 0 .../character_movement/{app.py => main.py} | 0 requirements/pkg-extras.txt | 16 +- src/idom/sample.py | 2 +- src/idom/server/utils.py | 3 +- tests/test_sample.py | 4 +- tests/test_server/test_utils.py | 2 +- tests/test_testing.py | 2 +- 28 files changed, 283 insertions(+), 218 deletions(-) rename docs/source/guides/adding-interactivity/components-with-state/_examples/adding_state_variable/{app.py => main.py} (100%) rename docs/source/guides/adding-interactivity/components-with-state/_examples/isolated_state/{app.py => main.py} (100%) rename docs/source/guides/adding-interactivity/components-with-state/_examples/multiple_state_variables/{app.py => main.py} (100%) rename docs/source/guides/adding-interactivity/components-with-state/_examples/when_variables_are_not_enough/{app.py => main.py} (100%) rename docs/source/guides/escape-hatches/_examples/super_simple_chart/{app.py => main.py} (100%) create mode 100644 docs/source/guides/getting-started/_examples/run_fastapi.py create mode 100644 docs/source/guides/getting-started/_examples/run_flask.py create mode 100644 docs/source/guides/getting-started/_examples/run_sanic.py create mode 100644 docs/source/guides/getting-started/_examples/run_starlette.py create mode 100644 docs/source/guides/getting-started/_examples/run_tornado.py rename docs/source/guides/managing-state/sharing-component-state/_examples/filterable_list/{app.py => main.py} (100%) rename docs/source/guides/managing-state/sharing-component-state/_examples/synced_inputs/{app.py => main.py} (100%) rename docs/source/reference/_examples/character_movement/{app.py => main.py} (100%) diff --git a/docs/examples.py b/docs/examples.py index e5f8eacc6..b61e7295e 100644 --- a/docs/examples.py +++ b/docs/examples.py @@ -23,7 +23,7 @@ def load_examples() -> Iterator[tuple[str, Callable[[], ComponentType]]]: def all_example_names() -> set[str]: names = set() for file in _iter_example_files(SOURCE_DIR): - path = file.parent if file.name == "app.py" else file + path = file.parent if file.name == "main.py" else file names.add("/".join(path.relative_to(SOURCE_DIR).with_suffix("").parts)) return names @@ -48,7 +48,7 @@ def get_main_example_file_by_name( ) -> Path: path = _get_root_example_path_by_name(name, relative_to) if path.is_dir(): - return path / "app.py" + return path / "main.py" else: return path.with_suffix(".py") diff --git a/docs/source/_exts/idom_example.py b/docs/source/_exts/idom_example.py index 07c0a74a2..686734392 100644 --- a/docs/source/_exts/idom_example.py +++ b/docs/source/_exts/idom_example.py @@ -51,7 +51,7 @@ def run(self): if len(ex_files) == 1: labeled_tab_items.append( ( - "app.py", + "main.py", _literal_include( path=ex_files[0], linenos=show_linenos, diff --git a/docs/source/guides/adding-interactivity/components-with-state/_examples/adding_state_variable/app.py b/docs/source/guides/adding-interactivity/components-with-state/_examples/adding_state_variable/main.py similarity index 100% rename from docs/source/guides/adding-interactivity/components-with-state/_examples/adding_state_variable/app.py rename to docs/source/guides/adding-interactivity/components-with-state/_examples/adding_state_variable/main.py diff --git a/docs/source/guides/adding-interactivity/components-with-state/_examples/isolated_state/app.py b/docs/source/guides/adding-interactivity/components-with-state/_examples/isolated_state/main.py similarity index 100% rename from docs/source/guides/adding-interactivity/components-with-state/_examples/isolated_state/app.py rename to docs/source/guides/adding-interactivity/components-with-state/_examples/isolated_state/main.py diff --git a/docs/source/guides/adding-interactivity/components-with-state/_examples/multiple_state_variables/app.py b/docs/source/guides/adding-interactivity/components-with-state/_examples/multiple_state_variables/main.py similarity index 100% rename from docs/source/guides/adding-interactivity/components-with-state/_examples/multiple_state_variables/app.py rename to docs/source/guides/adding-interactivity/components-with-state/_examples/multiple_state_variables/main.py diff --git a/docs/source/guides/adding-interactivity/components-with-state/_examples/when_variables_are_not_enough/app.py b/docs/source/guides/adding-interactivity/components-with-state/_examples/when_variables_are_not_enough/main.py similarity index 100% rename from docs/source/guides/adding-interactivity/components-with-state/_examples/when_variables_are_not_enough/app.py rename to docs/source/guides/adding-interactivity/components-with-state/_examples/when_variables_are_not_enough/main.py diff --git a/docs/source/guides/adding-interactivity/components-with-state/index.rst b/docs/source/guides/adding-interactivity/components-with-state/index.rst index 5c9161de2..3925b3cf2 100644 --- a/docs/source/guides/adding-interactivity/components-with-state/index.rst +++ b/docs/source/guides/adding-interactivity/components-with-state/index.rst @@ -151,7 +151,7 @@ below highlights a line of code where something of interest occurs:

Initial render

- .. literalinclude:: _examples/adding_state_variable/app.py + .. literalinclude:: _examples/adding_state_variable/main.py :lines: 12-33 :emphasize-lines: 2 @@ -165,7 +165,7 @@ below highlights a line of code where something of interest occurs:

Initial state declaration

- .. literalinclude:: _examples/adding_state_variable/app.py + .. literalinclude:: _examples/adding_state_variable/main.py :lines: 12-33 :emphasize-lines: 3 @@ -181,7 +181,7 @@ below highlights a line of code where something of interest occurs:

Define event handler

- .. literalinclude:: _examples/adding_state_variable/app.py + .. literalinclude:: _examples/adding_state_variable/main.py :lines: 12-33 :emphasize-lines: 5 @@ -196,7 +196,7 @@ below highlights a line of code where something of interest occurs:

Return the view

- .. literalinclude:: _examples/adding_state_variable/app.py + .. literalinclude:: _examples/adding_state_variable/main.py :lines: 12-33 :emphasize-lines: 16 @@ -212,7 +212,7 @@ below highlights a line of code where something of interest occurs:

User interaction

- .. literalinclude:: _examples/adding_state_variable/app.py + .. literalinclude:: _examples/adding_state_variable/main.py :lines: 12-33 :emphasize-lines: 5 @@ -226,7 +226,7 @@ below highlights a line of code where something of interest occurs:

New state is set

- .. literalinclude:: _examples/adding_state_variable/app.py + .. literalinclude:: _examples/adding_state_variable/main.py :lines: 12-33 :emphasize-lines: 6 @@ -242,7 +242,7 @@ below highlights a line of code where something of interest occurs:

Next render begins

- .. literalinclude:: _examples/adding_state_variable/app.py + .. literalinclude:: _examples/adding_state_variable/main.py :lines: 12-33 :emphasize-lines: 2 @@ -256,7 +256,7 @@ below highlights a line of code where something of interest occurs:

Next state is acquired

- .. literalinclude:: _examples/adding_state_variable/app.py + .. literalinclude:: _examples/adding_state_variable/main.py :lines: 12-33 :emphasize-lines: 3 @@ -272,7 +272,7 @@ below highlights a line of code where something of interest occurs:

Repeat...

- .. literalinclude:: _examples/adding_state_variable/app.py + .. literalinclude:: _examples/adding_state_variable/main.py :lines: 12-33 From this point on, the steps remain the same. The only difference being the diff --git a/docs/source/guides/escape-hatches/_examples/super_simple_chart/app.py b/docs/source/guides/escape-hatches/_examples/super_simple_chart/main.py similarity index 100% rename from docs/source/guides/escape-hatches/_examples/super_simple_chart/app.py rename to docs/source/guides/escape-hatches/_examples/super_simple_chart/main.py diff --git a/docs/source/guides/getting-started/_examples/hello_world.py b/docs/source/guides/getting-started/_examples/hello_world.py index a5621718e..1ad68582e 100644 --- a/docs/source/guides/getting-started/_examples/hello_world.py +++ b/docs/source/guides/getting-started/_examples/hello_world.py @@ -3,7 +3,7 @@ @component def App(): - return html.h1("Hello, World!") + return html.h1("Hello, world!") run(App) diff --git a/docs/source/guides/getting-started/_examples/run_fastapi.py b/docs/source/guides/getting-started/_examples/run_fastapi.py new file mode 100644 index 000000000..f114333bb --- /dev/null +++ b/docs/source/guides/getting-started/_examples/run_fastapi.py @@ -0,0 +1,23 @@ +# :lines: 11- + +from idom import run +from idom.server import fastapi as fastapi_server + + +# the run() function is the entry point for examples +fastapi_server.configure = lambda _, cmpt: run(cmpt) + + +from fastapi import FastAPI + +from idom import component, html +from idom.server.fastapi import configure + + +@component +def HelloWorld(): + return html.h1("Hello, world!") + + +app = FastAPI() +configure(app, HelloWorld) diff --git a/docs/source/guides/getting-started/_examples/run_flask.py b/docs/source/guides/getting-started/_examples/run_flask.py new file mode 100644 index 000000000..9f64d0e15 --- /dev/null +++ b/docs/source/guides/getting-started/_examples/run_flask.py @@ -0,0 +1,23 @@ +# :lines: 11- + +from idom import run +from idom.server import flask as flask_server + + +# the run() function is the entry point for examples +flask_server.configure = lambda _, cmpt: run(cmpt) + + +from flask import Flask + +from idom import component, html +from idom.server.flask import configure + + +@component +def HelloWorld(): + return html.h1("Hello, world!") + + +app = Flask(__name__) +configure(app, HelloWorld) diff --git a/docs/source/guides/getting-started/_examples/run_sanic.py b/docs/source/guides/getting-started/_examples/run_sanic.py new file mode 100644 index 000000000..449e2b2e1 --- /dev/null +++ b/docs/source/guides/getting-started/_examples/run_sanic.py @@ -0,0 +1,27 @@ +# :lines: 11- + +from idom import run +from idom.server import sanic as sanic_server + + +# the run() function is the entry point for examples +sanic_server.configure = lambda _, cmpt: run(cmpt) + + +from sanic import Sanic + +from idom import component, html +from idom.server.sanic import configure + + +@component +def HelloWorld(): + return html.h1("Hello, world!") + + +app = Sanic("MyApp") +configure(app, HelloWorld) + + +if __name__ == "__main__": + app.run(port=8000) diff --git a/docs/source/guides/getting-started/_examples/run_starlette.py b/docs/source/guides/getting-started/_examples/run_starlette.py new file mode 100644 index 000000000..f287b831b --- /dev/null +++ b/docs/source/guides/getting-started/_examples/run_starlette.py @@ -0,0 +1,23 @@ +# :lines: 11- + +from idom import run +from idom.server import starlette as starlette_server + + +# the run() function is the entry point for examples +starlette_server.configure = lambda _, cmpt: run(cmpt) + + +from starlette.applications import Starlette + +from idom import component, html +from idom.server.starlette import configure + + +@component +def HelloWorld(): + return html.h1("Hello, world!") + + +app = Starlette() +configure(app, HelloWorld) diff --git a/docs/source/guides/getting-started/_examples/run_tornado.py b/docs/source/guides/getting-started/_examples/run_tornado.py new file mode 100644 index 000000000..313fdf4fe --- /dev/null +++ b/docs/source/guides/getting-started/_examples/run_tornado.py @@ -0,0 +1,32 @@ +# :lines: 11- + +from idom import run +from idom.server import tornado as tornado_server + + +# the run() function is the entry point for examples +tornado_server.configure = lambda _, cmpt: run(cmpt) + + +import tornado.ioloop +import tornado.web + +from idom import component, html +from idom.server.tornado import configure + + +@component +def HelloWorld(): + return html.h1("Hello, world!") + + +def make_app(): + app = tornado.web.Application() + configure(app, HelloWorld) + return app + + +if __name__ == "__main__": + app = make_app() + app.listen(8000) + tornado.ioloop.IOLoop.current().start() diff --git a/docs/source/guides/getting-started/_examples/sample_app.py b/docs/source/guides/getting-started/_examples/sample_app.py index 610f8988a..332035e87 100644 --- a/docs/source/guides/getting-started/_examples/sample_app.py +++ b/docs/source/guides/getting-started/_examples/sample_app.py @@ -1,4 +1,4 @@ import idom -idom.run(idom.sample.App) +idom.run(idom.sample.SampleApp) diff --git a/docs/source/guides/getting-started/_static/embed-idom-view/main.py b/docs/source/guides/getting-started/_static/embed-idom-view/main.py index 0c0cb5ac0..e33173173 100644 --- a/docs/source/guides/getting-started/_static/embed-idom-view/main.py +++ b/docs/source/guides/getting-started/_static/embed-idom-view/main.py @@ -2,10 +2,10 @@ from sanic.response import file from idom import component, html -from idom.server.sanic import Config, PerClientStateServer +from idom.server.sanic import Options, configure -app = Sanic(__name__) +app = Sanic("MyApp") @app.route("/") @@ -18,6 +18,6 @@ def IdomView(): return html.code("This text came from an IDOM App") -PerClientStateServer(IdomView, app=app, config=Config(url_prefix="/_idom")) +configure(app, IdomView, Options(url_prefix="/_idom")) app.run(host="127.0.0.1", port=5000) diff --git a/docs/source/guides/getting-started/index.rst b/docs/source/guides/getting-started/index.rst index d2bfbac15..1aed237d9 100644 --- a/docs/source/guides/getting-started/index.rst +++ b/docs/source/guides/getting-started/index.rst @@ -46,19 +46,35 @@ notebook linked below will demonstrate how to do this: Section 1: Installing IDOM -------------------------- -The next fastest option is to install IDOM with ``pip``: +The next fastest option is to install IDOM along with a supported server (like +``starlette``) with ``pip``: .. code-block:: bash - pip install "idom[stable]" + pip install "idom[starlette]" To check that everything is working you can run the sample application: .. code-block:: bash - python -c "import idom; idom.run(idom.sample.App, open_browser=True)" + python -c "import idom; idom.run(idom.sample.SampleApp)" -This should automatically open up a browser window to a page that looks like this: +.. note:: + + This launches a simple development server which is good enough for testing, but + probably not what you want to use in production. When deploying in production, + there's a number of different ways of :ref:`running IDOM
`. + +You should then see a few log messages: + +.. code-block:: text + + 2022-03-27T11:58:59-0700 | WARNING | You are running a development server. Change this before deploying in production! + 2022-03-27T11:58:59-0700 | INFO | Running with 'Starlette' at http://127.0.0.1:8000 + +The second log message includes a URL indicating where you should go to view the app. +That will usually be http://127.0.0.1:8000. Once you go to that URL you should see +something like this: .. card:: @@ -70,9 +86,8 @@ If you get a ``RuntimeError`` similar to the following: Found none of the following builtin server implementations... -Then be sure you installed ``"idom[stable]"`` and not just ``idom``. - -For anything else, report your issue in IDOM's :discussion-type:`discussion forum +Then be sure you run ``pip install "idom[starlette]"`` instead of just ``idom``. For +anything else, report your issue in IDOM's :discussion-type:`discussion forum `. .. card:: diff --git a/docs/source/guides/getting-started/installing-idom.rst b/docs/source/guides/getting-started/installing-idom.rst index 1187704e4..ed2b5af24 100644 --- a/docs/source/guides/getting-started/installing-idom.rst +++ b/docs/source/guides/getting-started/installing-idom.rst @@ -1,34 +1,42 @@ Installing IDOM =============== -The easiest way to ``pip`` install idom is to do so using the ``stable`` option: +Installing IDOM with ``pip`` will generally require doing so alongside a supported +server implementation. This can be done by specifying an installation extra using square +brackets. For example, if we want to run IDOM using `Starlette +`__ we would run: .. code-block:: bash - pip install "idom[stable]" + pip install "idom[starlette]" -This includes a set of default dependencies for one of the builtin web server -implementation. If you want to install IDOM without these dependencies you may simply -``pip install idom``. +If you want to install a "pure" version of IDOM without a server implementation you can +do so without any installation extras. You might do this if you wanted to user a server +which is not officially supported or if you wanted to manually pin your dependencies: + +.. code-block:: bash + + pip install idom -Installing Other Servers ------------------------- +Officially Supported Servers +---------------------------- IDOM includes built-in support for a variety web server implementations. To install the -required dependencies for each you should substitute ``stable`` from the ``pip install`` -command above with one of the options below: +required dependencies for each you should substitute ``starlette`` from the ``pip +install`` command above with one of the options below: - ``fastapi`` - https://fastapi.tiangolo.com - ``flask`` - https://palletsprojects.com/p/flask/ - ``sanic`` - https://sanicframework.org +- ``starlette`` - https://www.starlette.io/ - ``tornado`` - https://www.tornadoweb.org/en/stable/ If you need to, you can install more than one option by separating them with commas: .. code-block:: bash - pip install idom[fastapi,flask,sanic,tornado] + pip install "idom[fastapi,flask,sanic,starlette,tornado]" Once this is complete you should be able to :ref:`run IDOM ` with your :ref:`chosen server implementation `. @@ -40,7 +48,7 @@ Installing In Other Frameworks While IDOM can run in a variety of contexts, sometimes web frameworks require extra work in order to integrate with them. In these cases, the IDOM team distributes bindings for various frameworks as separate Python packages. For documentation on how to install and -run IDOM in the supported frameworks, follow the links below: +run IDOM in these supported frameworks, follow the links below: .. raw:: html diff --git a/docs/source/guides/getting-started/running-idom.rst b/docs/source/guides/getting-started/running-idom.rst index 1a471979b..77b72841f 100644 --- a/docs/source/guides/getting-started/running-idom.rst +++ b/docs/source/guides/getting-started/running-idom.rst @@ -1,236 +1,153 @@ Running IDOM ============ -The simplest way to run IDOM is with the :func:`~idom.server.prefab.run` function. By -default this will execute your application using one of the builtin server -implementations whose dependencies have all been installed. Running a tiny "hello world" -application just requires the following code: +The simplest way to run IDOM is with the :func:`~idom.server.utils.run` function. This +is the method you'll see used throughout this documentation. However, this executes your +application using a development server which is great for testing, but probably not what +if you're :ref:`deploying in production `. Below are some +more robust and performant ways of running IDOM with various supported servers. -.. idom:: _examples/hello_world -.. note:: - - Try clicking the **▶️ Result** tab to see what this displays! - - -Running IDOM in Debug Mode +Running IDOM in Production -------------------------- -IDOM provides a debug mode that is turned off by default. This can be enabled when you -run your application by setting the ``IDOM_DEBUG_MODE`` environment variable. - -.. tab-set:: - - .. tab-item:: Unix Shell - - .. code-block:: - - export IDOM_DEBUG_MODE=1 - python my_idom_app.py - - .. tab-item:: Command Prompt - - .. code-block:: text - - set IDOM_DEBUG_MODE=1 - python my_idom_app.py - - .. tab-item:: PowerShell - - .. code-block:: powershell - - $env:IDOM_DEBUG_MODE = "1" - python my_idom_app.py - -.. danger:: - - Leave debug mode off in production! - -Among other things, running in this mode: - -- Turns on debug log messages -- Adds checks to ensure the :ref:`VDOM` spec is adhered to -- Displays error messages that occur within your app - -Errors will be displayed where the uppermost component is located in the view: - -.. idom:: _examples/debug_error_example - - -Choosing a Server Implementation --------------------------------- - -Without extra care, running an IDOM app with the ``run()`` function can be somewhat -inpredictable since the kind of server being used by default depends on what gets -discovered first. To be more explicit about which server implementation you want to run -with you can import your chosen server class and pass it to the ``server_type`` -parameter of ``run()``: +The first thing you'll need to do if you want to run IDOM in production is choose a +server implementation and follow its documentation on how to create and run an +application. This is the server :ref:`you probably chose ` +when installing IDOM. Then you'll need to configure that application with an IDOM view. +We should the basics how how to run each supported server below, but all implementations +will follow a pattern similar to the following: .. code-block:: - from idom import component, html, run - from idom.server.sanic import PerClientStateServer - - - @component - def App(): - return html.h1(f"Hello, World!") + from my_chosen_server import Application + from idom import component, html + from idom.server.my_chosen_server import configure - run(App, server_type=PerClientStateServer) -Presently IDOM's core library supports the following server implementations: + @component + def HelloWorld(): + return html.h1("Hello, world!") -- :mod:`idom.server.fastapi` -- :mod:`idom.server.sanic` -- :mod:`idom.server.flask` -- :mod:`idom.server.tornado` -.. hint:: + app = Application() + configure(app, HelloWorld) - To install them, see the :ref:`Installing Other Servers` section. +You'll then run this ``app`` using a `ASGI `__ or +`WSGI `__ server from the command line. -Available Server Types ----------------------- +Running with `FastAPI `__ +....................................................... -Some of server implementations have more than one server type available. The server type -which exists for all implementations is the ``PerClientStateServer``. This server type -displays a unique view to each user who visits the site. For those that support it, -there may also be a ``SharedClientStateServer`` available. This server type presents the -same view to all users who visit the site. For example, if you were to run the following -code: +.. idom:: _examples/run_fastapi -.. code-block:: +Then assuming you put this in ``main.py``, you can run the ``app`` using `Uvicorn +`__: - from idom import component, hooks, html, run - from idom.server.sanic import SharedClientStateServer +.. code-block:: bash - - @component - def Slider(): - value, set_value = hooks.use_state(50) - return html.input({"type": "range", "min": 1, "max": 100, "value": value}) + uvicorn main:app - run(Slider, server_type=SharedClientStateServer) +Running with `Flask `__ +............................................................. -Two clients could see the slider and see a synchronized view of it. That is, when one -client moved the slider, the other would see the slider update without their action. -This might look similar to the video below: +.. idom:: _examples/run_flask -.. image:: _static/shared-client-state-server-slider.gif +Then assuming you put this in ``main.py``, you can run the ``app`` using `Gunicorn +`__: -Presently the following server implementations support the ``SharedClientStateServer``: +.. code-block:: bash -- :func:`idom.server.fastapi.SharedClientStateServer` -- :func:`idom.server.sanic.SharedClientStateServer` + gunicorn main:app -.. note:: - If you need to, your can :ref:`write your own server implementation `. +Running with `Sanic `__ +................................................... -Common Server Settings ----------------------- +.. idom:: _examples/run_sanic -Each server implementation has its own high-level settings that are defined by its -respective ``Config`` (a typed dictionary). As a general rule, these ``Config`` types -expose the same options across implementations. These configuration dictionaries can -then be passed to the ``run()`` function via the ``config`` parameter: +Then assuming you put this in ``main.py``, you can run the ``app`` using Sanic's builtin +server: -.. code-block:: +.. code-block:: bash - from idom import run, component, html - from idom.server.sanic import PerClientStateServer, Config + sanic main.app - @component - def App(): - return html.h1(f"Hello, World!") +Running with `Starlette `__ +...................................................... +.. idom:: _examples/run_starlette - server_config = Config( - cors=False, - url_prefix="", - serve_static_files=True, - redirect_root_to_index=True, - ) +Then assuming you put this in ``main.py``, you can run the application using `Uvicorn +`__: - run(App, server_type=PerClientStateServer, server_config=server_config) +.. code-block:: bash -Here's the list of available configuration types: + uvicorn main:app -- :class:`idom.server.fastapi.Config` -- :class:`idom.server.sanic.Config` -- :class:`idom.server.flask.Config` -- :class:`idom.server.tornado.Config` +Running with `Tornado `__ +................................................................ -Specific Server Settings ------------------------- +.. idom:: _examples/run_tornado -The ``Config`` :ref:`described above ` is meant to be an -implementation agnostic - all ``Config`` objects support a similar set of options. -However, there are inevitably cases where you need to set up your chosen server using -implementation specific details. For instance, you might want to add an extra route to -the server your using in order to provide extra resources to your application. +Tornado is run using it's own builtin server rather than an external WSGI or ASGI +server. -Doing this kind of set up can be achieved by passing in an instance of your chosen -server implementation into the ``app`` parameter of the ``run()`` function. To -illustrate, if I'm making my application with ``sanic`` and I want to add an extra route -I would do the following: -.. code-block:: +Configuration Options +--------------------- - from sanic import Sanic - from idom import component, html, run - from idom.server.sanic import PerClientStateServer +IDOM's various server implementations come with ``Options`` that can be passed to their +respective ``configure()`` functions. Each provides a similar set of key value pairs - app = Sanic(__name__) - # these are implementation specific settings only known to `sanic` servers - app.config.REQUEST_TIMEOUT = 60 - app.config.RESPONSE_TIMEOUT = 60 +Running IDOM in Debug Mode +-------------------------- +IDOM provides a debug mode that is turned off by default. This can be enabled when you +run your application by setting the ``IDOM_DEBUG_MODE`` environment variable. - @component - def SomeView(): - return html.form({"action": }) +.. tab-set:: + .. tab-item:: Unix Shell - run(SomeView, server_type=PerClientStateServer, app=app) + .. code-block:: + export IDOM_DEBUG_MODE=1 + python my_idom_app.py -Add to an Existing Web Server ------------------------------ + .. tab-item:: Command Prompt -If you're already serving an application with one of the supported web servers listed -above, you can add an IDOM to them as a server extension. Instead of using the ``run()`` -function, you'll instantiate one of IDOM's server implementations by passing it an -instance of your existing application: + .. code-block:: text -.. code-block:: + set IDOM_DEBUG_MODE=1 + python my_idom_app.py - from sanic import Sanic + .. tab-item:: PowerShell - from idom import component, html - from idom.server.sanic import PerClientStateServer, Config + .. code-block:: powershell - existing_app = Sanic(__name__) + $env:IDOM_DEBUG_MODE = "1" + python my_idom_app.py +.. danger:: - @component - def IdomView(): - return html.h1("This is an IDOM App") + Leave debug mode off in production! +Among other things, running in this mode: - PerClientStateServer(IdomView, app=existing_app, config=Config(url_prefix="app")) +- Turns on debug log messages +- Adds checks to ensure the :ref:`VDOM` spec is adhered to +- Displays error messages that occur within your app - existing_app.run(host="127.0.0.1", port=8000) +Errors will be displayed where the uppermost component is located in the view: -To test that everything is working, you should be able to navigate to -``https://127.0.0.1:8000/app`` where you should see the results from ``IdomView``. +.. idom:: _examples/debug_error_example Embed in an Existing Webpage diff --git a/docs/source/guides/managing-state/sharing-component-state/_examples/filterable_list/app.py b/docs/source/guides/managing-state/sharing-component-state/_examples/filterable_list/main.py similarity index 100% rename from docs/source/guides/managing-state/sharing-component-state/_examples/filterable_list/app.py rename to docs/source/guides/managing-state/sharing-component-state/_examples/filterable_list/main.py diff --git a/docs/source/guides/managing-state/sharing-component-state/_examples/synced_inputs/app.py b/docs/source/guides/managing-state/sharing-component-state/_examples/synced_inputs/main.py similarity index 100% rename from docs/source/guides/managing-state/sharing-component-state/_examples/synced_inputs/app.py rename to docs/source/guides/managing-state/sharing-component-state/_examples/synced_inputs/main.py diff --git a/docs/source/reference/_examples/character_movement/app.py b/docs/source/reference/_examples/character_movement/main.py similarity index 100% rename from docs/source/reference/_examples/character_movement/app.py rename to docs/source/reference/_examples/character_movement/main.py diff --git a/requirements/pkg-extras.txt b/requirements/pkg-extras.txt index 1c41b5842..834885aa4 100644 --- a/requirements/pkg-extras.txt +++ b/requirements/pkg-extras.txt @@ -1,4 +1,8 @@ -# extra=stable,sanic +# extra=starlette +starlette >=0.13.6 +uvicorn[standard] >=0.13.4 + +# extra=sanic sanic >=21 sanic-cors @@ -6,22 +10,14 @@ sanic-cors fastapi >=0.63.0 uvicorn[standard] >=0.13.4 -# extra=starlette -starlette >=0.13.6 -uvicorn[standard] >=0.13.4 - # extra=flask flask<2.0 markupsafe<2.1 flask-cors flask-sockets -a2wsgi -# tornado +# extra=tornado tornado # extra=testing playwright - -# extra=matplotlib -matplotlib diff --git a/src/idom/sample.py b/src/idom/sample.py index ea8539d6d..908de34b7 100644 --- a/src/idom/sample.py +++ b/src/idom/sample.py @@ -6,7 +6,7 @@ @component -def App() -> VdomDict: +def SampleApp() -> VdomDict: return html.div( {"id": "sample", "style": {"padding": "15px"}}, html.h1("Sample Application"), diff --git a/src/idom/server/utils.py b/src/idom/server/utils.py index 8b9df8986..d84ad34e4 100644 --- a/src/idom/server/utils.py +++ b/src/idom/server/utils.py @@ -45,7 +45,8 @@ def run( host = host port = port or find_available_port(host) - logger.info(f"Running at http://{host}:{port}") + + logger.info(f"Running with {type(app).__name__!r} at http://{host}:{port}") asyncio.get_event_loop().run_until_complete( implementation.serve_development_app(app, host, port) diff --git a/tests/test_sample.py b/tests/test_sample.py index 6a68cec22..cc9f86dd1 100644 --- a/tests/test_sample.py +++ b/tests/test_sample.py @@ -1,9 +1,9 @@ -from idom.sample import App +from idom.sample import SampleApp from idom.testing import DisplayFixture async def test_sample_app(display: DisplayFixture): - await display.show(App) + await display.show(SampleApp) h1 = await display.page.wait_for_selector("h1") assert (await h1.text_content()) == "Sample Application" diff --git a/tests/test_server/test_utils.py b/tests/test_server/test_utils.py index 66c296d84..a24d607af 100644 --- a/tests/test_server/test_utils.py +++ b/tests/test_server/test_utils.py @@ -6,7 +6,7 @@ import pytest from playwright.async_api import Page -from idom.sample import App as SampleApp +from idom.sample import SampleApp as SampleApp from idom.server import flask as flask_implementation from idom.server.utils import find_available_port from idom.server.utils import run as sync_run diff --git a/tests/test_testing.py b/tests/test_testing.py index c814df4b2..abd738643 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -5,7 +5,7 @@ from idom import testing from idom.logging import ROOT_LOGGER -from idom.sample import App as SampleApp +from idom.sample import SampleApp as SampleApp from idom.server import starlette as starlette_implementation From 89b882547e93b7c5e5f7422cb41010c8c9fa7e43 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sun, 27 Mar 2022 15:52:59 -0700 Subject: [PATCH 47/58] use dataclasses for options instead of typeddict --- docs/app.py | 10 ++--- .../guides/getting-started/running-idom.rst | 24 +++++++++-- scripts/live_docs.py | 4 +- setup.cfg | 3 ++ src/idom/server/default.py | 6 ++- src/idom/server/fastapi.py | 7 ++-- src/idom/server/flask.py | 41 ++++++------------- src/idom/server/sanic.py | 35 ++++++---------- src/idom/server/starlette.py | 35 ++++++---------- src/idom/server/tornado.py | 32 ++++++--------- src/idom/server/types.py | 7 +++- src/idom/testing/server.py | 4 +- 12 files changed, 100 insertions(+), 108 deletions(-) diff --git a/docs/app.py b/docs/app.py index a2f4fe6e2..c51095ee2 100644 --- a/docs/app.py +++ b/docs/app.py @@ -6,7 +6,7 @@ from idom import component from idom.core.types import ComponentConstructor -from idom.server.sanic import configure, use_request +from idom.server.sanic import Options, configure, use_request from .examples import load_examples @@ -26,10 +26,10 @@ def run(): configure( app, Example(), - { - "redirect_root_to_index": False, - "url_prefix": IDOM_MODEL_SERVER_URL_PREFIX, - }, + Options( + redirect_root=False, + url_prefix=IDOM_MODEL_SERVER_URL_PREFIX, + ), ) app.run( diff --git a/docs/source/guides/getting-started/running-idom.rst b/docs/source/guides/getting-started/running-idom.rst index 77b72841f..1c7271b99 100644 --- a/docs/source/guides/getting-started/running-idom.rst +++ b/docs/source/guides/getting-started/running-idom.rst @@ -99,11 +99,29 @@ Tornado is run using it's own builtin server rather than an external WSGI or ASG server. -Configuration Options ---------------------- +Server Configuration Options +---------------------------- IDOM's various server implementations come with ``Options`` that can be passed to their -respective ``configure()`` functions. Each provides a similar set of key value pairs +respective ``configure()`` functions. Those which are common amongst the options are: + +- ``url_prefix`` - prefix all routes configured by IDOM +- ``redirect_root`` - whether to redirect the root of the application to the IDOM view +- ``serve_static_files`` - whether to server IDOM's static files from it's default route + +You'd then pass these options to ``configure()`` in the following way: + +.. code-block:: + + configure(app, MyComponent, Options(...)) + +To learn more read the description for your chosen server implementation: + +- :class:`idom.server.fastapi.Options` +- :class:`idom.server.flask.Options` +- :class:`idom.server.sanic.Options` +- :class:`idom.server.starlette.Options` +- :class:`idom.server.tornado.Options` Running IDOM in Debug Mode diff --git a/scripts/live_docs.py b/scripts/live_docs.py index aa0d10337..e21339326 100644 --- a/scripts/live_docs.py +++ b/scripts/live_docs.py @@ -14,7 +14,7 @@ ) from docs.app import IDOM_MODEL_SERVER_URL_PREFIX, Example, make_app, reload_examples -from idom.server.sanic import configure, serve_development_app +from idom.server.sanic import Options, configure, serve_development_app from idom.testing import clear_idom_web_modules_dir @@ -33,7 +33,7 @@ def wrap_builder(old_builder): configure( app, Example, - {"cors": True, "url_prefix": IDOM_MODEL_SERVER_URL_PREFIX}, + Options(cors=True, url_prefix=IDOM_MODEL_SERVER_URL_PREFIX), ) thread_started = threading.Event() diff --git a/setup.cfg b/setup.cfg index 01a6c6d1e..293907987 100644 --- a/setup.cfg +++ b/setup.cfg @@ -9,6 +9,9 @@ warn_unused_ignores = True [flake8] ignore = E203, E266, E501, W503, F811, N802, N806 +per-file-ignores = + # sometimes this is required in order to hide setup for an example + docs/*/_examples/*.py:E402 max-line-length = 88 max-complexity = 18 select = B,C,E,F,W,T4,B9,N,ROH diff --git a/src/idom/server/default.py b/src/idom/server/default.py index e94b28dd8..68adaf41b 100644 --- a/src/idom/server/default.py +++ b/src/idom/server/default.py @@ -9,8 +9,12 @@ from .utils import all_implementations -def configure(app: Any, component: RootComponentConstructor) -> None: +def configure( + app: Any, component: RootComponentConstructor, options: None = None +) -> None: """Configure the given app instance to display the given component""" + if options is not None: # pragma: no cover + raise ValueError("Default implementation cannot be configured with options") return _default_implementation().configure(app, component) diff --git a/src/idom/server/fastapi.py b/src/idom/server/fastapi.py index 3187e71b7..009a465de 100644 --- a/src/idom/server/fastapi.py +++ b/src/idom/server/fastapi.py @@ -19,6 +19,9 @@ use_websocket = starlette.use_websocket # noqa: ROH101 """Alias for :func:`starlette.use_websocket`""" +Options = starlette.Options +"""Alias for :class:`starlette.Options`""" + def configure( app: FastAPI, @@ -34,9 +37,7 @@ def configure( """ options = starlette._setup_options(options) starlette._setup_common_routes(options, app) - starlette._setup_single_view_dispatcher_route( - options["url_prefix"], app, constructor - ) + starlette._setup_single_view_dispatcher_route(options.url_prefix, app, constructor) def create_development_app() -> FastAPI: diff --git a/src/idom/server/flask.py b/src/idom/server/flask.py index 955507a51..1cf2ddb66 100644 --- a/src/idom/server/flask.py +++ b/src/idom/server/flask.py @@ -4,6 +4,7 @@ import json import logging from asyncio import Queue as AsyncQueue +from dataclasses import dataclass from queue import Queue as ThreadQueue from threading import Event as ThreadEvent from threading import Thread @@ -24,7 +25,6 @@ from gevent import pywsgi from geventwebsocket.handler import WebSocketHandler from geventwebsocket.websocket import WebSocket -from typing_extensions import TypedDict import idom from idom.config import IDOM_WEB_MODULES_DIR @@ -53,8 +53,8 @@ def configure( options: Options for configuring server behavior app: An application instance (otherwise a default instance is created) """ - options = _setup_options(options) - blueprint = Blueprint("idom", __name__, url_prefix=options["url_prefix"]) + options = options or Options() + blueprint = Blueprint("idom", __name__, url_prefix=options.url_prefix) _setup_common_routes(blueprint, options) _setup_single_view_dispatcher_route(app, options, component) app.register_blueprint(blueprint) @@ -126,48 +126,33 @@ def use_scope() -> dict[str, Any]: return use_request().environ -class Options(TypedDict, total=False): +@dataclass +class Options: """Render server config for :class:`FlaskRenderServer`""" - cors: Union[bool, Dict[str, Any]] + cors: Union[bool, Dict[str, Any]] = False """Enable or configure Cross Origin Resource Sharing (CORS) For more information see docs for ``flask_cors.CORS`` """ - import_name: str - """The module where the application instance was created - - For more info see :class:`flask.Flask`. - """ - - redirect_root_to_index: bool + redirect_root: bool = True """Whether to redirect the root URL (with prefix) to ``index.html``""" - serve_static_files: bool + serve_static_files: bool = True """Whether or not to serve static files (i.e. web modules)""" - url_prefix: str + url_prefix: str = "" """The URL prefix where IDOM resources will be served from""" -def _setup_options(options: Options | None) -> Options: - return { - "url_prefix": "", - "cors": False, - "serve_static_files": True, - "redirect_root_to_index": True, - **(options or {}), # type: ignore - } - - def _setup_common_routes(blueprint: Blueprint, options: Options) -> None: - cors_options = options["cors"] + cors_options = options.cors if cors_options: # pragma: no cover cors_params = cors_options if isinstance(cors_options, dict) else {} CORS(blueprint, **cors_params) - if options["serve_static_files"]: + if options.serve_static_files: @blueprint.route("/client/") def send_client_dir(path: str) -> Any: @@ -177,7 +162,7 @@ def send_client_dir(path: str) -> Any: def send_modules_dir(path: str) -> Any: return send_from_directory(str(IDOM_WEB_MODULES_DIR.current), path) - if options["redirect_root_to_index"]: + if options.redirect_root: @blueprint.route("/") def redirect_to_index() -> Any: @@ -195,7 +180,7 @@ def _setup_single_view_dispatcher_route( ) -> None: sockets = Sockets(app) - @sockets.route(_join_url_paths(options["url_prefix"], "/stream")) # type: ignore + @sockets.route(_join_url_paths(options.url_prefix, "/stream")) # type: ignore def model_stream(ws: WebSocket) -> None: def send(value: Any) -> None: ws.send(json.dumps(value)) diff --git a/src/idom/server/sanic.py b/src/idom/server/sanic.py index aac1045e1..3c47250a6 100644 --- a/src/idom/server/sanic.py +++ b/src/idom/server/sanic.py @@ -3,10 +3,10 @@ import asyncio import json import logging +from dataclasses import dataclass from typing import Any, Dict, Tuple, Union from uuid import uuid4 -from mypy_extensions import TypedDict from sanic import Blueprint, Sanic, request, response from sanic.config import Config from sanic.models.asgi import ASGIScope @@ -39,10 +39,8 @@ def configure( app: Sanic, component: RootComponentConstructor, options: Options | None = None ) -> None: """Configure an application instance to display the given component""" - options = _setup_options(options) - blueprint = Blueprint( - f"idom_dispatcher_{id(app)}", url_prefix=options["url_prefix"] - ) + options = options or Options() + blueprint = Blueprint(f"idom_dispatcher_{id(app)}", url_prefix=options.url_prefix) _setup_common_routes(blueprint, options) _setup_single_view_dispatcher_route(blueprint, component) app.blueprint(blueprint) @@ -83,46 +81,37 @@ def use_scope() -> ASGIScope: return asgi_app.transport.scope -class Options(TypedDict, total=False): +@dataclass +class Options: """Options for :class:`SanicRenderServer`""" - cors: Union[bool, Dict[str, Any]] + cors: Union[bool, Dict[str, Any]] = False """Enable or configure Cross Origin Resource Sharing (CORS) For more information see docs for ``sanic_cors.CORS`` """ - redirect_root_to_index: bool + redirect_root: bool = True """Whether to redirect the root URL (with prefix) to ``index.html``""" - serve_static_files: bool + serve_static_files: bool = True """Whether or not to serve static files (i.e. web modules)""" - url_prefix: str + url_prefix: str = "" """The URL prefix where IDOM resources will be served from""" -def _setup_options(options: Options | None) -> Options: - return { - "cors": False, - "url_prefix": "", - "serve_static_files": True, - "redirect_root_to_index": True, - **(options or {}), # type: ignore - } - - def _setup_common_routes(blueprint: Blueprint, options: Options) -> None: - cors_options = options["cors"] + cors_options = options.cors if cors_options: # pragma: no cover cors_params = cors_options if isinstance(cors_options, dict) else {} CORS(blueprint, **cors_params) - if options["serve_static_files"]: + if options.serve_static_files: blueprint.static("/client", str(CLIENT_BUILD_DIR)) blueprint.static("/modules", str(IDOM_WEB_MODULES_DIR.current)) - if options["redirect_root_to_index"]: + if options.redirect_root: @blueprint.route("/") # type: ignore def redirect_to_index( diff --git a/src/idom/server/starlette.py b/src/idom/server/starlette.py index 53deeba29..6e483241f 100644 --- a/src/idom/server/starlette.py +++ b/src/idom/server/starlette.py @@ -3,9 +3,9 @@ import asyncio import json import logging +from dataclasses import dataclass from typing import Any, Dict, Tuple, Union -from mypy_extensions import TypedDict from starlette.applications import Starlette from starlette.middleware.cors import CORSMiddleware from starlette.requests import Request @@ -50,9 +50,9 @@ def configure( constructor: A component constructor options: Options for configuring server behavior """ - options = _setup_options(options) + options = options or Options() _setup_common_routes(options, app) - _setup_single_view_dispatcher_route(options["url_prefix"], app, constructor) + _setup_single_view_dispatcher_route(options.url_prefix, app, constructor) def create_development_app() -> Starlette: @@ -85,37 +85,28 @@ def use_scope() -> Scope: return use_websocket().scope -class Options(TypedDict, total=False): +@dataclass +class Options: """Optionsuration options for :class:`StarletteRenderServer`""" - cors: Union[bool, Dict[str, Any]] + cors: Union[bool, Dict[str, Any]] = False """Enable or configure Cross Origin Resource Sharing (CORS) For more information see docs for ``starlette.middleware.cors.CORSMiddleware`` """ - redirect_root_to_index: bool + redirect_root: bool = True """Whether to redirect the root URL (with prefix) to ``index.html``""" - serve_static_files: bool + serve_static_files: bool = True """Whether or not to serve static files (i.e. web modules)""" - url_prefix: str + url_prefix: str = "" """The URL prefix where IDOM resources will be served from""" -def _setup_options(options: Options | None) -> Options: - return { - "cors": False, - "url_prefix": "", - "serve_static_files": True, - "redirect_root_to_index": True, - **(options or {}), # type: ignore - } - - def _setup_common_routes(options: Options, app: Starlette) -> None: - cors_options = options["cors"] + cors_options = options.cors if cors_options: # pragma: no cover cors_params = ( cors_options if isinstance(cors_options, dict) else {"allow_origins": ["*"]} @@ -124,8 +115,8 @@ def _setup_common_routes(options: Options, app: Starlette) -> None: # This really should be added to the APIRouter, but there's a bug in Starlette # BUG: https://github.com/tiangolo/fastapi/issues/1469 - url_prefix = options["url_prefix"] - if options["serve_static_files"]: + url_prefix = options.url_prefix + if options.serve_static_files: app.mount( f"{url_prefix}/client", StaticFiles( @@ -145,7 +136,7 @@ def _setup_common_routes(options: Options, app: Starlette) -> None: name="idom_web_module_files", ) - if options["redirect_root_to_index"]: + if options.redirect_root: @app.route(f"{url_prefix}/") def redirect_to_index(request: Request) -> RedirectResponse: diff --git a/src/idom/server/tornado.py b/src/idom/server/tornado.py index d41c21f7b..ce327e378 100644 --- a/src/idom/server/tornado.py +++ b/src/idom/server/tornado.py @@ -4,6 +4,7 @@ import json from asyncio import Queue as AsyncQueue from asyncio.futures import Future +from dataclasses import dataclass from typing import Any, List, Tuple, Type, Union from urllib.parse import urljoin @@ -14,7 +15,6 @@ from tornado.web import Application, RedirectHandler, RequestHandler, StaticFileHandler from tornado.websocket import WebSocketHandler from tornado.wsgi import WSGIContainer -from typing_extensions import TypedDict from idom.config import IDOM_WEB_MODULES_DIR from idom.core.hooks import Context, create_context, use_context @@ -44,7 +44,7 @@ def configure( component: A root component constructor options: Options for configuring how the component is mounted to the server. """ - options = _setup_options(options) + options = options or Options() _add_handler( app, options, @@ -99,34 +99,26 @@ def use_scope() -> dict[str, Any]: return WSGIContainer.environ(use_request()) -class Options(TypedDict, total=False): +@dataclass +class Options: """Render server options for :class:`TornadoRenderServer` subclasses""" - redirect_root_to_index: bool + redirect_root: bool = True """Whether to redirect the root URL (with prefix) to ``index.html``""" - serve_static_files: bool + serve_static_files: bool = True """Whether or not to serve static files (i.e. web modules)""" - url_prefix: str + url_prefix: str = "" """The URL prefix where IDOM resources will be served from""" _RouteHandlerSpecs = List[Tuple[str, Type[RequestHandler], Any]] -def _setup_options(options: Options | None) -> Options: - return { - "url_prefix": "", - "serve_static_files": True, - "redirect_root_to_index": True, - **(options or {}), # type: ignore - } - - def _setup_common_routes(options: Options) -> _RouteHandlerSpecs: handlers: _RouteHandlerSpecs = [] - if options["serve_static_files"]: + if options.serve_static_files: handlers.append( ( r"/client/(.*)", @@ -141,8 +133,10 @@ def _setup_common_routes(options: Options) -> _RouteHandlerSpecs: {"path": str(IDOM_WEB_MODULES_DIR.current)}, ) ) - if options["redirect_root_to_index"]: - handlers.append(("/", RedirectHandler, {"url": "./client/index.html"})) + if options.redirect_root: + handlers.append( + (options.url_prefix, RedirectHandler, {"url": "./client/index.html"}) + ) return handlers @@ -150,7 +144,7 @@ def _add_handler( app: Application, options: Options, handlers: _RouteHandlerSpecs ) -> None: prefixed_handlers: List[Any] = [ - (urljoin(options["url_prefix"], route_pattern),) + tuple(handler_info) + (urljoin(options.url_prefix, route_pattern),) + tuple(handler_info) for route_pattern, *handler_info in handlers ] app.add_handlers(r".*", prefixed_handlers) diff --git a/src/idom/server/types.py b/src/idom/server/types.py index 6ab8a5940..73b34f1b1 100644 --- a/src/idom/server/types.py +++ b/src/idom/server/types.py @@ -15,7 +15,12 @@ class ServerImplementation(Protocol[_App]): """Common interface for IDOM's builti-in server implementations""" - def configure(self, app: _App, component: RootComponentConstructor) -> None: + 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: diff --git a/src/idom/testing/server.py b/src/idom/testing/server.py index 2a21e762e..862d50a7d 100644 --- a/src/idom/testing/server.py +++ b/src/idom/testing/server.py @@ -37,6 +37,7 @@ def __init__( port: Optional[int] = None, app: Any | None = None, implementation: ServerImplementation[Any] | None = None, + options: Any | None = None, ) -> None: self.host = host self.port = port or find_available_port(host, allow_reuse_waiting_ports=False) @@ -51,6 +52,7 @@ def __init__( self._app = app self.implementation = implementation or default_server + self._options = options @property def log_records(self) -> list[logging.LogRecord]: @@ -102,7 +104,7 @@ async def __aenter__(self) -> ServerFixture: self._records = self._exit_stack.enter_context(capture_idom_logs()) app = self._app or self.implementation.create_development_app() - self.implementation.configure(app, self._root_component) + self.implementation.configure(app, self._root_component, self._options) started = asyncio.Event() server_future = asyncio.create_task( From f4bb85e57c15c27d6cdbb0b2968f233432c2754a Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sun, 27 Mar 2022 15:53:46 -0700 Subject: [PATCH 48/58] move section --- .../guides/getting-started/running-idom.rst | 50 +++++++++---------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/docs/source/guides/getting-started/running-idom.rst b/docs/source/guides/getting-started/running-idom.rst index 1c7271b99..c603677a4 100644 --- a/docs/source/guides/getting-started/running-idom.rst +++ b/docs/source/guides/getting-started/running-idom.rst @@ -99,31 +99,6 @@ Tornado is run using it's own builtin server rather than an external WSGI or ASG server. -Server Configuration Options ----------------------------- - -IDOM's various server implementations come with ``Options`` that can be passed to their -respective ``configure()`` functions. Those which are common amongst the options are: - -- ``url_prefix`` - prefix all routes configured by IDOM -- ``redirect_root`` - whether to redirect the root of the application to the IDOM view -- ``serve_static_files`` - whether to server IDOM's static files from it's default route - -You'd then pass these options to ``configure()`` in the following way: - -.. code-block:: - - configure(app, MyComponent, Options(...)) - -To learn more read the description for your chosen server implementation: - -- :class:`idom.server.fastapi.Options` -- :class:`idom.server.flask.Options` -- :class:`idom.server.sanic.Options` -- :class:`idom.server.starlette.Options` -- :class:`idom.server.tornado.Options` - - Running IDOM in Debug Mode -------------------------- @@ -168,6 +143,31 @@ Errors will be displayed where the uppermost component is located in the view: .. idom:: _examples/debug_error_example +Server Configuration Options +---------------------------- + +IDOM's various server implementations come with ``Options`` that can be passed to their +respective ``configure()`` functions. Those which are common amongst the options are: + +- ``url_prefix`` - prefix all routes configured by IDOM +- ``redirect_root`` - whether to redirect the root of the application to the IDOM view +- ``serve_static_files`` - whether to server IDOM's static files from it's default route + +You'd then pass these options to ``configure()`` in the following way: + +.. code-block:: + + configure(app, MyComponent, Options(...)) + +To learn more read the description for your chosen server implementation: + +- :class:`idom.server.fastapi.Options` +- :class:`idom.server.flask.Options` +- :class:`idom.server.sanic.Options` +- :class:`idom.server.starlette.Options` +- :class:`idom.server.tornado.Options` + + Embed in an Existing Webpage ---------------------------- From f889d81d06d16618a5ed5506b2b8f88ebf0249e1 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sun, 27 Mar 2022 16:13:51 -0700 Subject: [PATCH 49/58] add note to changelog --- docs/source/about/changelog.rst | 8 +++++++- docs/source/about/contributor-guide.rst | 4 ++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index cf9a35696..87321037d 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -11,9 +11,15 @@ Changelog All notable changes to this project will be recorded in this document. The style of which is based on `Keep a Changelog `__. The versioning scheme for the project adheres to `Semantic Versioning `__. For -more info, see the :ref:`Contributor Guide `. +more info, see the :ref:`Contributor Guide `. +.. INSTRUCTIONS FOR CHANGELOG CONTRIBUTORS +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! +.. If you're adding a changelog entry, be sure to read the "Creating a Changelog Entry" +.. section of the documentation before doing so for instructions on how to adhere to the +.. "Keep a Changelog" style guide (https://keepachangelog.com). + Unreleased ---------- diff --git a/docs/source/about/contributor-guide.rst b/docs/source/about/contributor-guide.rst index 38c362edf..b70b7cd32 100644 --- a/docs/source/about/contributor-guide.rst +++ b/docs/source/about/contributor-guide.rst @@ -66,8 +66,8 @@ about how to get started. To make a change to IDOM you'll do the following: :ref:`equality checks ` and, with any luck, accept your request. At that point your contribution will be merged into the main codebase! -Create a Changelog Entry -........................ +Creating a Changelog Entry +.......................... As part of your pull request, you'll want to edit the `Changelog `__ by From fca1b4d168db56763382bb1180bf851515529332 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sun, 27 Mar 2022 16:18:06 -0700 Subject: [PATCH 50/58] fix missing refs --- docs/source/guides/getting-started/installing-idom.rst | 2 +- docs/source/guides/getting-started/running-idom.rst | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/source/guides/getting-started/installing-idom.rst b/docs/source/guides/getting-started/installing-idom.rst index ed2b5af24..06dffe0a7 100644 --- a/docs/source/guides/getting-started/installing-idom.rst +++ b/docs/source/guides/getting-started/installing-idom.rst @@ -39,7 +39,7 @@ If you need to, you can install more than one option by separating them with com pip install "idom[fastapi,flask,sanic,starlette,tornado]" Once this is complete you should be able to :ref:`run IDOM ` with your -:ref:`chosen server implementation `. +chosen server implementation. Installing In Other Frameworks diff --git a/docs/source/guides/getting-started/running-idom.rst b/docs/source/guides/getting-started/running-idom.rst index c603677a4..0b913070b 100644 --- a/docs/source/guides/getting-started/running-idom.rst +++ b/docs/source/guides/getting-started/running-idom.rst @@ -196,8 +196,8 @@ embedding one the examples from this documentation into your own webpage: As mentioned though, this is connecting to the server that is hosting this documentation. If you want to connect to a view from your own server, you'll need to -change the URL above to one you provide. One way to do this might be to :ref:`add to an -existing web server`. Another would be to run IDOM in an adjacent web server instance +change the URL above to one you provide. One way to do this might be to add to an +existing application. Another would be to run IDOM in an adjacent web server instance that you coordinate with something like `NGINX `__. For the sake of simplicity, we'll assume you do something similar to the following in an existing Python app: From 90c89be312b6e56b7c93d25c901d7822d59aa7d2 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sun, 27 Mar 2022 16:24:17 -0700 Subject: [PATCH 51/58] fix ref --- src/idom/server/fastapi.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/idom/server/fastapi.py b/src/idom/server/fastapi.py index 009a465de..811ed6fff 100644 --- a/src/idom/server/fastapi.py +++ b/src/idom/server/fastapi.py @@ -9,18 +9,18 @@ serve_development_app = starlette.serve_development_app -"""Alias for :func:`starlette.serve_development_app`""" +"""Alias for :func:`idom.server.starlette.serve_development_app`""" # see: https://github.com/idom-team/flake8-idom-hooks/issues/12 use_scope = starlette.use_scope # noqa: ROH101 -"""Alias for :func:`starlette.use_scope`""" +"""Alias for :func:`idom.server.starlette.use_scope`""" # see: https://github.com/idom-team/flake8-idom-hooks/issues/12 use_websocket = starlette.use_websocket # noqa: ROH101 -"""Alias for :func:`starlette.use_websocket`""" +"""Alias for :func:`idom.server.starlette.use_websocket`""" Options = starlette.Options -"""Alias for :class:`starlette.Options`""" +"""Alias for :class:`idom.server.starlette.Options`""" def configure( From 8e770800ea87f74631b1fb3149cc25c0c0373307 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sun, 27 Mar 2022 16:34:17 -0700 Subject: [PATCH 52/58] fix fastapi alias --- src/idom/server/fastapi.py | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/src/idom/server/fastapi.py b/src/idom/server/fastapi.py index 811ed6fff..991e89f67 100644 --- a/src/idom/server/fastapi.py +++ b/src/idom/server/fastapi.py @@ -22,23 +22,10 @@ Options = starlette.Options """Alias for :class:`idom.server.starlette.Options`""" - -def configure( - app: FastAPI, - constructor: RootComponentConstructor, - options: starlette.Options | None = None, -) -> None: - """Prepare a :class:`FastAPI` server to serve the given component - - Parameters: - app: An application instance - constructor: A component constructor - config: Options for configuring server behavior - """ - options = starlette._setup_options(options) - starlette._setup_common_routes(options, app) - starlette._setup_single_view_dispatcher_route(options.url_prefix, app, constructor) +configure = starlette.configure +"""Alias for :class:`idom.server.starlette.configure`""" def create_development_app() -> FastAPI: + """Create a development ``FastAPI`` application instance.""" return FastAPI(debug=IDOM_DEBUG_MODE.current) From 6db0f1070e9dc56109f9d25bf852248102ae35a8 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sun, 27 Mar 2022 16:37:07 -0700 Subject: [PATCH 53/58] fix tornado redirect --- src/idom/server/tornado.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/idom/server/tornado.py b/src/idom/server/tornado.py index ce327e378..d1d708c42 100644 --- a/src/idom/server/tornado.py +++ b/src/idom/server/tornado.py @@ -135,7 +135,11 @@ def _setup_common_routes(options: Options) -> _RouteHandlerSpecs: ) if options.redirect_root: handlers.append( - (options.url_prefix, RedirectHandler, {"url": "./client/index.html"}) + ( + urljoin("/", options.url_prefix), + RedirectHandler, + {"url": "./client/index.html"}, + ) ) return handlers From 420c08fc6ea6652a5f4e79303add5b143480727b Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sun, 27 Mar 2022 16:47:57 -0700 Subject: [PATCH 54/58] remove unused imports --- src/idom/server/fastapi.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/idom/server/fastapi.py b/src/idom/server/fastapi.py index 991e89f67..2cf66918d 100644 --- a/src/idom/server/fastapi.py +++ b/src/idom/server/fastapi.py @@ -2,9 +2,6 @@ from fastapi import FastAPI -from idom.config import IDOM_DEBUG_MODE -from idom.core.types import RootComponentConstructor - from . import starlette @@ -28,4 +25,4 @@ def create_development_app() -> FastAPI: """Create a development ``FastAPI`` application instance.""" - return FastAPI(debug=IDOM_DEBUG_MODE.current) + return FastAPI(debug=True) From 2e86b331a25f7297f9f70158900735976dd7f4e4 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sun, 27 Mar 2022 17:09:23 -0700 Subject: [PATCH 55/58] changelog entry --- docs/source/about/changelog.rst | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index 87321037d..fad689b10 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -23,7 +23,22 @@ more info, see the :ref:`Contributor Guide `. Unreleased ---------- -Nothing yet... +Changed: + +- How IDOM integrates with servers - :pull:`703` + + - ``idom.run`` no longer accepts an app instance to discourage use outside of testing + - IDOM's server implementations now provide ``configure()`` functions instead + - ``idom.testing`` has been completely reworked in order to support async web drivers + +Added: + +- Support for access to underlying server requests via contexts - :issue:`669` + +Fixed: + +- IDOM's test suite no longer uses sync web drivers - :issue:`591` +- Updated Sanic requirement to ``>=21`` - :issue:`678` 0.37.2 From 70a2b270da0bf5769860ca0f13ed396d59de36c8 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sun, 27 Mar 2022 17:13:10 -0700 Subject: [PATCH 56/58] fix noxfile tag commit msg --- noxfile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/noxfile.py b/noxfile.py index b361c95ab..b954df00d 100644 --- a/noxfile.py +++ b/noxfile.py @@ -353,7 +353,7 @@ def tag(session: Session) -> None: # stage, commit, tag, and push version bump session.run("git", "add", "--all", external=True) - session.run("git", "commit", "-m", repr(f"version {new_version}"), external=True) + session.run("git", "commit", "-m", f"version {new_version}", external=True) session.run("git", "tag", version, external=True) session.run("git", "push", "origin", "main", "--tags", external=True) From 75413cc1f18a7bb351b9a76df42dedbe884a1cc7 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sun, 27 Mar 2022 17:25:52 -0700 Subject: [PATCH 57/58] improve changelog entry --- docs/source/about/changelog.rst | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index fad689b10..5d969c43c 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -33,12 +33,19 @@ Changed: Added: -- Support for access to underlying server requests via contexts - :issue:`669` +- Access to underlying server requests via contexts - :issue:`669` + +Removed: + +- ``idom.widgets.multiview`` since basic routing view ``use_scope`` is now possible +- All ``SharedClientStateServer`` implementations. +- All ``PerClientStateServer`` implementations have been replaced with ``configure()`` Fixed: - IDOM's test suite no longer uses sync web drivers - :issue:`591` - Updated Sanic requirement to ``>=21`` - :issue:`678` +- How we advertise ``idom.run`` - :issue:`657` 0.37.2 From 0f22e775a0733c1c984189270ef321f087cd580c Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sun, 27 Mar 2022 17:30:12 -0700 Subject: [PATCH 58/58] fix typo in old cl entry --- docs/source/about/changelog.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index 5d969c43c..f4bbab94a 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -58,7 +58,8 @@ Changed: Fixed: -- A typo caused IDOM to use the insecure `ws` web-socket protocol on pages loaded with `https` instead of the secure `wss` protocol - :pull:`716` +- A typo caused IDOM to use the insecure ``ws`` web-socket protocol on pages loaded with + ``https`` instead of the secure ``wss`` protocol - :pull:`716` 0.37.1