From 876c992156409404b9c939b6328caf9015a25ff5 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 13 Jun 2023 23:36:07 -0700 Subject: [PATCH 01/32] More predictable `reactpy.run` failures --- src/py/reactpy/reactpy/backend/_common.py | 71 ++++++++++----------- src/py/reactpy/reactpy/backend/starlette.py | 2 +- src/py/reactpy/reactpy/backend/utils.py | 21 ++---- 3 files changed, 42 insertions(+), 52 deletions(-) diff --git a/src/py/reactpy/reactpy/backend/_common.py b/src/py/reactpy/reactpy/backend/_common.py index 80b4eeee1..5b0edc723 100644 --- a/src/py/reactpy/reactpy/backend/_common.py +++ b/src/py/reactpy/reactpy/backend/_common.py @@ -23,44 +23,43 @@ CLIENT_BUILD_DIR = Path(_reactpy_file_path).parent / "_static" / "app" / "dist" -try: + + +async def serve_development_asgi( + app: ASGIApplication | Any, + host: str, + port: int, + started: asyncio.Event | None, +) -> None: + """Run a development server for an ASGI application""" + import uvicorn -except ImportError: # nocov - pass -else: - - async def serve_development_asgi( - app: ASGIApplication | Any, - host: str, - port: int, - started: asyncio.Event | None, - ) -> None: - """Run a development server for an ASGI application""" - server = uvicorn.Server( - uvicorn.Config( - app, - host=host, - port=port, - loop="asyncio", - reload=True, - ) + + server = uvicorn.Server( + uvicorn.Config( + app, + host=host, + port=port, + loop="asyncio", + reload=True, ) - server.config.setup_event_loop() - coros: list[Awaitable[Any]] = [server.serve()] - - # If a started event is provided, then use it signal based on `server.started` - if started: - coros.append(_check_if_started(server, started)) - - try: - await asyncio.gather(*coros) - finally: - # Since we aren't using the uvicorn's `run()` API, we can't guarantee uvicorn's - # order of operations. So we need to make sure `shutdown()` always has an initialized - # list of `self.servers` to use. - if not hasattr(server, "servers"): # nocov - server.servers = [] - await asyncio.wait_for(server.shutdown(), timeout=3) + ) + server.config.setup_event_loop() + coros: list[Awaitable[Any]] = [server.serve()] + + # If a started event is provided, then use it signal based on `server.started` + if started: + coros.append(_check_if_started(server, started)) + + try: + await asyncio.gather(*coros) + finally: + # Since we aren't using the uvicorn's `run()` API, we can't guarantee uvicorn's + # order of operations. So we need to make sure `shutdown()` always has an initialized + # list of `self.servers` to use. + if not hasattr(server, "servers"): # nocov + server.servers = [] + await asyncio.wait_for(server.shutdown(), timeout=3) async def _check_if_started(server: uvicorn.Server, started: asyncio.Event) -> None: diff --git a/src/py/reactpy/reactpy/backend/starlette.py b/src/py/reactpy/reactpy/backend/starlette.py index 3a9695b33..8058df7cc 100644 --- a/src/py/reactpy/reactpy/backend/starlette.py +++ b/src/py/reactpy/reactpy/backend/starlette.py @@ -115,7 +115,7 @@ def _setup_common_routes(options: Options, app: Starlette) -> None: ) # register this last so it takes least priority index_route = _make_index_route(options) - app.add_route(url_prefix + "/", index_route) + app.add_route(f"{url_prefix}/", index_route) app.add_route(url_prefix + "/{path:path}", index_route) diff --git a/src/py/reactpy/reactpy/backend/utils.py b/src/py/reactpy/reactpy/backend/utils.py index 3d9be13a4..7f985f32b 100644 --- a/src/py/reactpy/reactpy/backend/utils.py +++ b/src/py/reactpy/reactpy/backend/utils.py @@ -32,18 +32,15 @@ def run( logger.warning(_DEVELOPMENT_RUN_FUNC_WARNING) implementation = implementation or import_module("reactpy.backend.default") - app = implementation.create_development_app() implementation.configure(app, component) - host = host port = port or find_available_port(host) - app_cls = type(app) + logger.info( - f"Running with {app_cls.__module__}.{app_cls.__name__} at http://{host}:{port}" + f"ReactPy is running with '{app_cls.__module__}.{app_cls.__name__}' at http://{host}:{port}" ) - asyncio.run(implementation.serve_development_app(app, host, port)) @@ -77,22 +74,16 @@ def all_implementations() -> Iterator[BackendImplementation[Any]]: """Yield all available server implementations""" for name in SUPPORTED_PACKAGES: try: - relative_import_name = f"{__name__.rsplit('.', 1)[0]}.{name}" - module = import_module(relative_import_name) + import_module(name) except ImportError: # nocov logger.debug(f"Failed to import {name!r}", exc_info=True) continue - if not isinstance(module, BackendImplementation): # nocov - msg = f"{module.__name__!r} is an invalid implementation" - raise TypeError(msg) - - yield module + reactpy_backend_name = f"{__name__.rsplit('.', 1)[0]}.{name}" + yield import_module(reactpy_backend_name) _DEVELOPMENT_RUN_FUNC_WARNING = f"""\ The `run()` function is only intended for testing during development! To run in \ -production, consider selecting a supported backend and importing its associated \ -`configure()` function from `reactpy.backend.` where `` is one of \ -{list(SUPPORTED_PACKAGES)}. For details refer to the docs on how to run each package.\ +production, refer to the docs on how to use reactpy.backend.*.configure.\ """ From 465ef00ffc075e8856f74a8aeb819b79b9dc3890 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 13 Jun 2023 23:46:01 -0700 Subject: [PATCH 02/32] move starlette to bottom so fastapi has a chance to run --- src/py/reactpy/reactpy/backend/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/py/reactpy/reactpy/backend/utils.py b/src/py/reactpy/reactpy/backend/utils.py index 7f985f32b..101c0c19f 100644 --- a/src/py/reactpy/reactpy/backend/utils.py +++ b/src/py/reactpy/reactpy/backend/utils.py @@ -14,11 +14,11 @@ logger = logging.getLogger(__name__) SUPPORTED_PACKAGES = ( - "starlette", "fastapi", "sanic", "tornado", "flask", + "starlette", ) From 43137d7bd3571ec941e053aa67ffd93c04d9ec0f Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 13 Jun 2023 23:47:28 -0700 Subject: [PATCH 03/32] remove testing from all --- docs/poetry.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/poetry.lock b/docs/poetry.lock index 8e1daef24..924ccb5a4 100644 --- a/docs/poetry.lock +++ b/docs/poetry.lock @@ -1492,13 +1492,13 @@ typing-extensions = ">=3.10" uvicorn = {version = ">=0.19.0", extras = ["standard"], optional = true, markers = "extra == \"fastapi\" or extra == \"sanic\" or extra == \"starlette\""} [package.extras] -all = ["reactpy[fastapi,flask,sanic,starlette,testing,tornado]"] +all = ["reactpy[fastapi,flask,sanic,starlette,tornado]"] fastapi = ["fastapi (>=0.63.0)", "uvicorn[standard] (>=0.19.0)"] flask = ["flask", "flask-cors", "flask-sock", "markupsafe (>=1.1.1,<2.1)"] sanic = ["sanic (>=21)", "sanic-cors", "uvicorn[standard] (>=0.19.0)"] starlette = ["starlette (>=0.13.6)", "uvicorn[standard] (>=0.19.0)"] -testing = ["playwright"] tornado = ["tornado"] +testing = ["playwright"] [package.source] type = "directory" From ab426e61663a84896655fe3e05771cb1b4656437 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 14 Jun 2023 01:49:23 -0700 Subject: [PATCH 04/32] refactoring and flask with uvicorn --- docs/poetry.lock | 2 +- src/py/reactpy/reactpy/backend/_common.py | 14 ++-- src/py/reactpy/reactpy/backend/default.py | 24 +++---- src/py/reactpy/reactpy/backend/fastapi.py | 22 +++---- src/py/reactpy/reactpy/backend/flask.py | 66 ++++++------------- src/py/reactpy/reactpy/backend/sanic.py | 36 +++++----- src/py/reactpy/reactpy/backend/starlette.py | 30 +++++---- src/py/reactpy/reactpy/backend/tornado.py | 11 +++- src/py/reactpy/reactpy/backend/types.py | 11 ++-- src/py/reactpy/reactpy/backend/utils.py | 6 +- src/py/reactpy/reactpy/testing/backend.py | 17 ++--- src/py/reactpy/reactpy/types.py | 4 +- src/py/reactpy/tests/test_backend/test_all.py | 6 +- 13 files changed, 119 insertions(+), 130 deletions(-) diff --git a/docs/poetry.lock b/docs/poetry.lock index 924ccb5a4..b1006a6ed 100644 --- a/docs/poetry.lock +++ b/docs/poetry.lock @@ -1494,7 +1494,7 @@ uvicorn = {version = ">=0.19.0", extras = ["standard"], optional = true, markers [package.extras] all = ["reactpy[fastapi,flask,sanic,starlette,tornado]"] fastapi = ["fastapi (>=0.63.0)", "uvicorn[standard] (>=0.19.0)"] -flask = ["flask", "flask-cors", "flask-sock", "markupsafe (>=1.1.1,<2.1)"] +flask = ["flask", "flask-cors", "flask-sock", "markupsafe (>=1.1.1,<2.1)", "uvicorn[standard] (>=0.19.0)"] sanic = ["sanic (>=21)", "sanic-cors", "uvicorn[standard] (>=0.19.0)"] starlette = ["starlette (>=0.13.6)", "uvicorn[standard] (>=0.19.0)"] tornado = ["tornado"] diff --git a/src/py/reactpy/reactpy/backend/_common.py b/src/py/reactpy/reactpy/backend/_common.py index 5b0edc723..0b75e8d48 100644 --- a/src/py/reactpy/reactpy/backend/_common.py +++ b/src/py/reactpy/reactpy/backend/_common.py @@ -24,17 +24,15 @@ CLIENT_BUILD_DIR = Path(_reactpy_file_path).parent / "_static" / "app" / "dist" - -async def serve_development_asgi( +async def serve_with_uvicorn( app: ASGIApplication | Any, host: str, port: int, started: asyncio.Event | None, ) -> None: """Run a development server for an ASGI application""" - import uvicorn - + server = uvicorn.Server( uvicorn.Config( app, @@ -62,7 +60,10 @@ async def serve_development_asgi( await asyncio.wait_for(server.shutdown(), timeout=3) -async def _check_if_started(server: uvicorn.Server, started: asyncio.Event) -> None: +async def _check_if_started(server, started: asyncio.Event) -> None: + import uvicorn + + server: uvicorn.Server = server while not server.started: await asyncio.sleep(0.2) started.set() @@ -71,8 +72,7 @@ async def _check_if_started(server: uvicorn.Server, started: asyncio.Event) -> N def safe_client_build_dir_path(path: str) -> Path: """Prevent path traversal out of :data:`CLIENT_BUILD_DIR`""" return traversal_safe_path( - CLIENT_BUILD_DIR, - *("index.html" if path in ("", "/") else path).split("/"), + CLIENT_BUILD_DIR, *("index.html" if path in {"", "/"} else path).split("/") ) diff --git a/src/py/reactpy/reactpy/backend/default.py b/src/py/reactpy/reactpy/backend/default.py index 4ca192c1c..c20ea80c2 100644 --- a/src/py/reactpy/reactpy/backend/default.py +++ b/src/py/reactpy/reactpy/backend/default.py @@ -5,13 +5,22 @@ from sys import exc_info from typing import Any, NoReturn -from reactpy.backend.types import BackendImplementation +from reactpy.backend.types import BackendProtocol from reactpy.backend.utils import SUPPORTED_PACKAGES, all_implementations from reactpy.types import RootComponentConstructor logger = getLogger(__name__) +_DEFAULT_IMPLEMENTATION: BackendProtocol[Any] | None = None +# BackendProtocol.Options +def Options(*args: Any, **kwargs: Any) -> NoReturn: # nocov + """Create configuration options""" + msg = "Default implementation has no options." + raise ValueError(msg) + + +# BackendProtocol.configure def configure( app: Any, component: RootComponentConstructor, options: None = None ) -> None: @@ -22,17 +31,13 @@ def configure( return _default_implementation().configure(app, component) +# BackendProtocol.create_development_app def create_development_app() -> Any: """Create an application instance for development purposes""" return _default_implementation().create_development_app() -def Options(*args: Any, **kwargs: Any) -> NoReturn: # nocov - """Create configuration options""" - msg = "Default implementation has no options." - raise ValueError(msg) - - +# BackendProtocol.serve_development_app async def serve_development_app( app: Any, host: str, @@ -45,10 +50,7 @@ async def serve_development_app( ) -_DEFAULT_IMPLEMENTATION: BackendImplementation[Any] | None = None - - -def _default_implementation() -> BackendImplementation[Any]: +def _default_implementation() -> BackendProtocol[Any]: """Get the first available server implementation""" global _DEFAULT_IMPLEMENTATION # noqa: PLW0603 diff --git a/src/py/reactpy/reactpy/backend/fastapi.py b/src/py/reactpy/reactpy/backend/fastapi.py index 575fce1fe..f4312903a 100644 --- a/src/py/reactpy/reactpy/backend/fastapi.py +++ b/src/py/reactpy/reactpy/backend/fastapi.py @@ -4,22 +4,22 @@ from reactpy.backend import starlette -serve_development_app = starlette.serve_development_app -"""Alias for :func:`reactpy.backend.starlette.serve_development_app`""" - -use_connection = starlette.use_connection -"""Alias for :func:`reactpy.backend.starlette.use_location`""" - -use_websocket = starlette.use_websocket -"""Alias for :func:`reactpy.backend.starlette.use_websocket`""" - +# BackendProtocol.Options Options = starlette.Options -"""Alias for :class:`reactpy.backend.starlette.Options`""" +# BackendProtocol.configure configure = starlette.configure -"""Alias for :class:`reactpy.backend.starlette.configure`""" +# BackendProtocol.create_development_app def create_development_app() -> FastAPI: """Create a development ``FastAPI`` application instance.""" return FastAPI(debug=True) + + +# BackendProtocol.serve_development_app +serve_development_app = starlette.serve_development_app + +use_connection = starlette.use_connection + +use_websocket = starlette.use_websocket diff --git a/src/py/reactpy/reactpy/backend/flask.py b/src/py/reactpy/reactpy/backend/flask.py index 46aed3c46..0ab37a57e 100644 --- a/src/py/reactpy/reactpy/backend/flask.py +++ b/src/py/reactpy/reactpy/backend/flask.py @@ -22,7 +22,6 @@ from flask_cors import CORS from flask_sock import Sock from simple_websocket import Server as WebSocket -from werkzeug.serving import BaseWSGIServer, make_server import reactpy from reactpy.backend._common import ( @@ -34,17 +33,30 @@ read_client_index_html, safe_client_build_dir_path, safe_web_modules_dir_path, + serve_with_uvicorn, ) from reactpy.backend.hooks import ConnectionContext from reactpy.backend.hooks import use_connection as _use_connection from reactpy.backend.types import Connection, Location from reactpy.core.serve import serve_layout from reactpy.core.types import ComponentType, RootComponentConstructor -from reactpy.utils import Ref logger = logging.getLogger(__name__) +# BackendProtocol.Options +@dataclass +class Options(CommonOptions): + """Render server config for :func:`reactpy.backend.flask.configure`""" + + cors: bool | dict[str, Any] = False + """Enable or configure Cross Origin Resource Sharing (CORS) + + For more information see docs for ``flask_cors.CORS`` + """ + + +# BackendProtocol.configure def configure( app: Flask, component: RootComponentConstructor, options: Options | None = None ) -> None: @@ -69,51 +81,22 @@ def configure( app.register_blueprint(spa_bp) +# BackendProtocol.create_development_app def create_development_app() -> Flask: """Create an application instance for development purposes""" os.environ["FLASK_DEBUG"] = "true" - app = Flask(__name__) - return app + return Flask(__name__) +# BackendProtocol.serve_development_app async def serve_development_app( app: Flask, host: str, port: int, started: asyncio.Event | None = None, ) -> None: - """Run an application using a development server""" - loop = asyncio.get_running_loop() - stopped = asyncio.Event() - - server: Ref[BaseWSGIServer] = Ref() - - def run_server() -> None: - server.current = make_server(host, port, app, threaded=True) - if started: - loop.call_soon_threadsafe(started.set) - try: - server.current.serve_forever() # type: ignore - finally: - loop.call_soon_threadsafe(stopped.set) - - thread = Thread(target=run_server, daemon=True) - thread.start() - - if started: - await started.wait() - - try: - await stopped.wait() - finally: - # we may have exited because this task was cancelled - server.current.shutdown() - # the thread should eventually join - thread.join(timeout=3) - # just double check it happened - if thread.is_alive(): # nocov - msg = "Failed to shutdown server." - raise RuntimeError(msg) + """Run a development server for FastAPI""" + await serve_with_uvicorn(app, host, port, started) def use_websocket() -> WebSocket: @@ -135,17 +118,6 @@ def use_connection() -> Connection[_FlaskCarrier]: return conn -@dataclass -class Options(CommonOptions): - """Render server config for :func:`reactpy.backend.flask.configure`""" - - cors: bool | dict[str, Any] = False - """Enable or configure Cross Origin Resource Sharing (CORS) - - For more information see docs for ``flask_cors.CORS`` - """ - - def _setup_common_routes( api_blueprint: Blueprint, spa_blueprint: Blueprint, diff --git a/src/py/reactpy/reactpy/backend/sanic.py b/src/py/reactpy/reactpy/backend/sanic.py index 53dd0ce68..8993b0adb 100644 --- a/src/py/reactpy/reactpy/backend/sanic.py +++ b/src/py/reactpy/reactpy/backend/sanic.py @@ -22,7 +22,7 @@ read_client_index_html, safe_client_build_dir_path, safe_web_modules_dir_path, - serve_development_asgi, + serve_with_uvicorn, ) from reactpy.backend.hooks import ConnectionContext from reactpy.backend.hooks import use_connection as _use_connection @@ -34,6 +34,20 @@ logger = logging.getLogger(__name__) +# BackendProtocol.Options +@dataclass +class Options(CommonOptions): + """Render server config for :func:`reactpy.backend.sanic.configure`""" + + cors: bool | dict[str, Any] = False + """Enable or configure Cross Origin Resource Sharing (CORS) + + For more information see docs for ``sanic_cors.CORS`` + """ + + +# BackendProtocol.configure +@staticmethod def configure( app: Sanic, component: RootComponentConstructor, options: Options | None = None ) -> None: @@ -49,14 +63,17 @@ def configure( app.blueprint([spa_bp, api_bp]) +# BackendProtocol.create_development_app +@staticmethod def create_development_app() -> Sanic: """Return a :class:`Sanic` app instance in test mode""" Sanic.test_mode = True logger.warning("Sanic.test_mode is now active") - app = Sanic(f"reactpy_development_app_{uuid4().hex}", Config()) - return app + return Sanic(f"reactpy_development_app_{uuid4().hex}", Config()) +# BackendProtocol.serve_development_app +@staticmethod async def serve_development_app( app: Sanic, host: str, @@ -64,7 +81,7 @@ async def serve_development_app( started: asyncio.Event | None = None, ) -> None: """Run a development server for :mod:`sanic`""" - await serve_development_asgi(app, host, port, started) + await serve_with_uvicorn(app, host, port, started) def use_request() -> request.Request: @@ -86,17 +103,6 @@ def use_connection() -> Connection[_SanicCarrier]: return conn -@dataclass -class Options(CommonOptions): - """Render server config for :func:`reactpy.backend.sanic.configure`""" - - cors: bool | dict[str, Any] = False - """Enable or configure Cross Origin Resource Sharing (CORS) - - For more information see docs for ``sanic_cors.CORS`` - """ - - def _setup_common_routes( api_blueprint: Blueprint, spa_blueprint: Blueprint, diff --git a/src/py/reactpy/reactpy/backend/starlette.py b/src/py/reactpy/reactpy/backend/starlette.py index 8058df7cc..dd347cc63 100644 --- a/src/py/reactpy/reactpy/backend/starlette.py +++ b/src/py/reactpy/reactpy/backend/starlette.py @@ -21,7 +21,7 @@ STREAM_PATH, CommonOptions, read_client_index_html, - serve_development_asgi, + serve_with_uvicorn, ) from reactpy.backend.hooks import ConnectionContext from reactpy.backend.hooks import use_connection as _use_connection @@ -34,6 +34,19 @@ logger = logging.getLogger(__name__) +# BackendProtocol.Options +@dataclass +class Options(CommonOptions): + """Render server config for :func:`reactpy.backend.starlette.configure`""" + + cors: bool | dict[str, Any] = False + """Enable or configure Cross Origin Resource Sharing (CORS) + + For more information see docs for ``starlette.middleware.cors.CORSMiddleware`` + """ + + +# BackendProtocol.configure def configure( app: Starlette, component: RootComponentConstructor, @@ -54,11 +67,13 @@ def configure( _setup_common_routes(options, app) +# BackendProtocol.create_development_app def create_development_app() -> Starlette: """Return a :class:`Starlette` app instance in debug mode""" return Starlette(debug=True) +# BackendProtocol.serve_development_app async def serve_development_app( app: Starlette, host: str, @@ -66,7 +81,7 @@ async def serve_development_app( started: asyncio.Event | None = None, ) -> None: """Run a development server for starlette""" - await serve_development_asgi(app, host, port, started) + await serve_with_uvicorn(app, host, port, started) def use_websocket() -> WebSocket: @@ -82,17 +97,6 @@ def use_connection() -> Connection[WebSocket]: return conn -@dataclass -class Options(CommonOptions): - """Render server config for :func:`reactpy.backend.starlette.configure`""" - - cors: bool | dict[str, Any] = False - """Enable or configure Cross Origin Resource Sharing (CORS) - - For more information see docs for ``starlette.middleware.cors.CORSMiddleware`` - """ - - def _setup_common_routes(options: Options, app: Starlette) -> None: cors_options = options.cors if cors_options: # nocov diff --git a/src/py/reactpy/reactpy/backend/tornado.py b/src/py/reactpy/reactpy/backend/tornado.py index 5ec877532..f1d2c94f4 100644 --- a/src/py/reactpy/reactpy/backend/tornado.py +++ b/src/py/reactpy/reactpy/backend/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 from urllib.parse import urljoin @@ -32,10 +33,14 @@ from reactpy.core.serve import serve_layout from reactpy.core.types import ComponentConstructor -Options = CommonOptions -"""Render server config for :func:`reactpy.backend.tornado.configure`""" +# BackendProtocol.Options +@dataclass +class Options(CommonOptions): + """Render server config for :func:`reactpy.backend.tornado.configure`""" + +# BackendProtocol.configure def configure( app: Application, component: ComponentConstructor, @@ -60,10 +65,12 @@ def configure( ) +# BackendProtocol.create_development_app def create_development_app() -> Application: return Application(debug=True) +# BackendProtocol.serve_development_app async def serve_development_app( app: Application, host: str, diff --git a/src/py/reactpy/reactpy/backend/types.py b/src/py/reactpy/reactpy/backend/types.py index fbc4addc0..3a93a6c6a 100644 --- a/src/py/reactpy/reactpy/backend/types.py +++ b/src/py/reactpy/reactpy/backend/types.py @@ -11,25 +11,26 @@ @runtime_checkable -class BackendImplementation(Protocol[_App]): +class BackendProtocol(Protocol[_App]): """Common interface for built-in web server/framework integrations""" Options: Callable[..., Any] - """A constructor for options passed to :meth:`BackendImplementation.configure`""" + """A constructor for options passed to :meth:`BackendProtocol.configure`""" + @staticmethod 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: + @staticmethod + def create_development_app() -> _App: """Create an application instance for development purposes""" + @staticmethod async def serve_development_app( - self, app: _App, host: str, port: int, diff --git a/src/py/reactpy/reactpy/backend/utils.py b/src/py/reactpy/reactpy/backend/utils.py index 101c0c19f..e13021586 100644 --- a/src/py/reactpy/reactpy/backend/utils.py +++ b/src/py/reactpy/reactpy/backend/utils.py @@ -8,7 +8,7 @@ from importlib import import_module from typing import Any -from reactpy.backend.types import BackendImplementation +from reactpy.backend.types import BackendProtocol from reactpy.types import RootComponentConstructor logger = logging.getLogger(__name__) @@ -26,7 +26,7 @@ def run( component: RootComponentConstructor, host: str = "127.0.0.1", port: int | None = None, - implementation: BackendImplementation[Any] | None = None, + implementation: BackendProtocol[Any] | None = None, ) -> None: """Run a component with a development server""" logger.warning(_DEVELOPMENT_RUN_FUNC_WARNING) @@ -70,7 +70,7 @@ def find_available_port( raise RuntimeError(msg) -def all_implementations() -> Iterator[BackendImplementation[Any]]: +def all_implementations() -> Iterator[BackendProtocol[Any]]: """Yield all available server implementations""" for name in SUPPORTED_PACKAGES: try: diff --git a/src/py/reactpy/reactpy/testing/backend.py b/src/py/reactpy/reactpy/testing/backend.py index 549e16056..3c69ffcf1 100644 --- a/src/py/reactpy/reactpy/testing/backend.py +++ b/src/py/reactpy/reactpy/testing/backend.py @@ -2,13 +2,13 @@ import asyncio import logging -from contextlib import AsyncExitStack +from contextlib import AsyncExitStack, suppress from types import TracebackType from typing import Any, Callable from urllib.parse import urlencode, urlunparse from reactpy.backend import default as default_server -from reactpy.backend.types import BackendImplementation +from reactpy.backend.types import BackendProtocol from reactpy.backend.utils import find_available_port from reactpy.config import REACTPY_TESTING_DEFAULT_TIMEOUT from reactpy.core.component import component @@ -43,7 +43,7 @@ def __init__( host: str = "127.0.0.1", port: int | None = None, app: Any | None = None, - implementation: BackendImplementation[Any] | None = None, + implementation: BackendProtocol[Any] | None = None, options: Any | None = None, timeout: float | None = None, ) -> None: @@ -54,10 +54,9 @@ def __init__( REACTPY_TESTING_DEFAULT_TIMEOUT.current if timeout is None else timeout ) - if app is not None: - if implementation is None: - msg = "If an application instance its corresponding server implementation must be provided too." - raise ValueError(msg) + if app is not None and implementation is None: + msg = "If an application instance its corresponding server implementation must be provided too." + raise ValueError(msg) self._app = app self.implementation = implementation or default_server @@ -124,10 +123,8 @@ async def __aenter__(self) -> BackendFixture: async def stop_server() -> None: server_future.cancel() - try: + with suppress(asyncio.CancelledError): await asyncio.wait_for(server_future, timeout=self.timeout) - except asyncio.CancelledError: - pass self._exit_stack.push_async_callback(stop_server) diff --git a/src/py/reactpy/reactpy/types.py b/src/py/reactpy/reactpy/types.py index 715b66fff..bac5511ad 100644 --- a/src/py/reactpy/reactpy/types.py +++ b/src/py/reactpy/reactpy/types.py @@ -4,7 +4,7 @@ - :mod:`reactpy.backend.types` """ -from reactpy.backend.types import BackendImplementation, Connection, Location +from reactpy.backend.types import BackendProtocol, Connection, Location from reactpy.core.component import Component from reactpy.core.hooks import Context from reactpy.core.types import ( @@ -27,7 +27,7 @@ ) __all__ = [ - "BackendImplementation", + "BackendProtocol", "Component", "ComponentConstructor", "ComponentType", diff --git a/src/py/reactpy/tests/test_backend/test_all.py b/src/py/reactpy/tests/test_backend/test_all.py index 11b9693a2..36e829b14 100644 --- a/src/py/reactpy/tests/test_backend/test_all.py +++ b/src/py/reactpy/tests/test_backend/test_all.py @@ -6,7 +6,7 @@ from reactpy import html from reactpy.backend import default as default_implementation from reactpy.backend._common import PATH_PREFIX -from reactpy.backend.types import BackendImplementation, Connection, Location +from reactpy.backend.types import BackendProtocol, Connection, Location from reactpy.backend.utils import all_implementations from reactpy.testing import BackendFixture, DisplayFixture, poll @@ -17,7 +17,7 @@ scope="module", ) async def display(page, request): - imp: BackendImplementation = request.param + imp: BackendProtocol = request.param # we do this to check that route priorities for each backend are correct if imp is default_implementation: @@ -158,7 +158,7 @@ def ShowRoute(): @pytest.mark.parametrize("imp", all_implementations()) -async def test_customized_head(imp: BackendImplementation, page): +async def test_customized_head(imp: BackendProtocol, page): custom_title = f"Custom Title for {imp.__name__}" @reactpy.component From 4aebab50d54bcb1642ba40280c1674a91162451e Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 14 Jun 2023 02:25:08 -0700 Subject: [PATCH 05/32] Remove accidental staticmethod --- src/py/reactpy/reactpy/backend/sanic.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/py/reactpy/reactpy/backend/sanic.py b/src/py/reactpy/reactpy/backend/sanic.py index 8993b0adb..c304d409a 100644 --- a/src/py/reactpy/reactpy/backend/sanic.py +++ b/src/py/reactpy/reactpy/backend/sanic.py @@ -47,7 +47,6 @@ class Options(CommonOptions): # BackendProtocol.configure -@staticmethod def configure( app: Sanic, component: RootComponentConstructor, options: Options | None = None ) -> None: @@ -64,7 +63,6 @@ def configure( # BackendProtocol.create_development_app -@staticmethod def create_development_app() -> Sanic: """Return a :class:`Sanic` app instance in test mode""" Sanic.test_mode = True @@ -73,7 +71,6 @@ def create_development_app() -> Sanic: # BackendProtocol.serve_development_app -@staticmethod async def serve_development_app( app: Sanic, host: str, From 5a3f68df02d32a48a3fbeb079b25f00d3e1a7180 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 14 Jun 2023 02:33:44 -0700 Subject: [PATCH 06/32] Add self to protocol --- src/py/reactpy/reactpy/backend/types.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/py/reactpy/reactpy/backend/types.py b/src/py/reactpy/reactpy/backend/types.py index 3a93a6c6a..a444731f4 100644 --- a/src/py/reactpy/reactpy/backend/types.py +++ b/src/py/reactpy/reactpy/backend/types.py @@ -17,20 +17,19 @@ class BackendProtocol(Protocol[_App]): Options: Callable[..., Any] """A constructor for options passed to :meth:`BackendProtocol.configure`""" - @staticmethod def configure( + self, app: _App, component: RootComponentConstructor, options: Any | None = None, ) -> None: """Configure the given app instance to display the given component""" - @staticmethod - def create_development_app() -> _App: + def create_development_app(self) -> _App: """Create an application instance for development purposes""" - @staticmethod async def serve_development_app( + self, app: _App, host: str, port: int, From e06db92c660ee1d755532f173d82d3def0f717fa Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 14 Jun 2023 02:33:55 -0700 Subject: [PATCH 07/32] Fix windows port assignment bug --- src/py/reactpy/reactpy/backend/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/py/reactpy/reactpy/backend/utils.py b/src/py/reactpy/reactpy/backend/utils.py index e13021586..9d24bb0ba 100644 --- a/src/py/reactpy/reactpy/backend/utils.py +++ b/src/py/reactpy/reactpy/backend/utils.py @@ -48,7 +48,7 @@ def find_available_port( host: str, port_min: int = 8000, port_max: int = 9000, - allow_reuse_waiting_ports: bool = True, + allow_reuse_waiting_ports: bool = False, ) -> int: """Get a port that's available for the given host and port range""" for port in range(port_min, port_max): From 8217963244296a5251dc328b20adb9c00cbbcbfd Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 14 Jun 2023 02:54:08 -0700 Subject: [PATCH 08/32] remove unusable reload parameter --- src/py/reactpy/reactpy/backend/_common.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/py/reactpy/reactpy/backend/_common.py b/src/py/reactpy/reactpy/backend/_common.py index 0b75e8d48..665aabd94 100644 --- a/src/py/reactpy/reactpy/backend/_common.py +++ b/src/py/reactpy/reactpy/backend/_common.py @@ -39,7 +39,6 @@ async def serve_with_uvicorn( host=host, port=port, loop="asyncio", - reload=True, ) ) server.config.setup_event_loop() From 49c6a26df56bb8d5582ea1744045af1430c09a01 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 14 Jun 2023 03:06:58 -0700 Subject: [PATCH 09/32] remove useless f string --- src/py/reactpy/reactpy/backend/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/py/reactpy/reactpy/backend/utils.py b/src/py/reactpy/reactpy/backend/utils.py index 9d24bb0ba..c236a1945 100644 --- a/src/py/reactpy/reactpy/backend/utils.py +++ b/src/py/reactpy/reactpy/backend/utils.py @@ -83,7 +83,7 @@ def all_implementations() -> Iterator[BackendProtocol[Any]]: yield import_module(reactpy_backend_name) -_DEVELOPMENT_RUN_FUNC_WARNING = f"""\ +_DEVELOPMENT_RUN_FUNC_WARNING = """\ The `run()` function is only intended for testing during development! To run in \ production, refer to the docs on how to use reactpy.backend.*.configure.\ """ From 7785536e3682bff4a4018a804b622c43f449faac Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 14 Jun 2023 03:07:05 -0700 Subject: [PATCH 10/32] reset lock file --- docs/poetry.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/poetry.lock b/docs/poetry.lock index b1006a6ed..8e1daef24 100644 --- a/docs/poetry.lock +++ b/docs/poetry.lock @@ -1492,13 +1492,13 @@ typing-extensions = ">=3.10" uvicorn = {version = ">=0.19.0", extras = ["standard"], optional = true, markers = "extra == \"fastapi\" or extra == \"sanic\" or extra == \"starlette\""} [package.extras] -all = ["reactpy[fastapi,flask,sanic,starlette,tornado]"] +all = ["reactpy[fastapi,flask,sanic,starlette,testing,tornado]"] fastapi = ["fastapi (>=0.63.0)", "uvicorn[standard] (>=0.19.0)"] -flask = ["flask", "flask-cors", "flask-sock", "markupsafe (>=1.1.1,<2.1)", "uvicorn[standard] (>=0.19.0)"] +flask = ["flask", "flask-cors", "flask-sock", "markupsafe (>=1.1.1,<2.1)"] sanic = ["sanic (>=21)", "sanic-cors", "uvicorn[standard] (>=0.19.0)"] starlette = ["starlette (>=0.13.6)", "uvicorn[standard] (>=0.19.0)"] -tornado = ["tornado"] testing = ["playwright"] +tornado = ["tornado"] [package.source] type = "directory" From 8cf1d3f1dd26c5422c825370066b0c875a105030 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 14 Jun 2023 03:07:19 -0700 Subject: [PATCH 11/32] add uvicorn to flask --- src/py/reactpy/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/py/reactpy/pyproject.toml b/src/py/reactpy/pyproject.toml index 659ddbf94..dbd1b561e 100644 --- a/src/py/reactpy/pyproject.toml +++ b/src/py/reactpy/pyproject.toml @@ -56,6 +56,7 @@ flask = [ "markupsafe>=1.1.1,<2.1", "flask-cors", "flask-sock", + "uvicorn[standard] >=0.19.0", ] tornado = [ "tornado", @@ -143,7 +144,6 @@ asyncio_mode = "auto" # --- MyPy ----------------------------------------------------------------------------- [tool.mypy] -incremental = false ignore_missing_imports = true warn_unused_configs = true warn_redundant_casts = true From e7992758107bce62797d30b29866408b1965b077 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 14 Jun 2023 03:19:25 -0700 Subject: [PATCH 12/32] Revert "add uvicorn to flask" This reverts commit 8cf1d3f1dd26c5422c825370066b0c875a105030. --- src/py/reactpy/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/py/reactpy/pyproject.toml b/src/py/reactpy/pyproject.toml index dbd1b561e..659ddbf94 100644 --- a/src/py/reactpy/pyproject.toml +++ b/src/py/reactpy/pyproject.toml @@ -56,7 +56,6 @@ flask = [ "markupsafe>=1.1.1,<2.1", "flask-cors", "flask-sock", - "uvicorn[standard] >=0.19.0", ] tornado = [ "tornado", @@ -144,6 +143,7 @@ asyncio_mode = "auto" # --- MyPy ----------------------------------------------------------------------------- [tool.mypy] +incremental = false ignore_missing_imports = true warn_unused_configs = true warn_redundant_casts = true From 6f2a18c77f97c3338ab317d7c9af271e372230ec Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 14 Jun 2023 03:22:11 -0700 Subject: [PATCH 13/32] Revert "add uvicorn to flask" This reverts commit 8cf1d3f1dd26c5422c825370066b0c875a105030. --- src/py/reactpy/reactpy/backend/flask.py | 35 +++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/src/py/reactpy/reactpy/backend/flask.py b/src/py/reactpy/reactpy/backend/flask.py index 0ab37a57e..d2e84ceca 100644 --- a/src/py/reactpy/reactpy/backend/flask.py +++ b/src/py/reactpy/reactpy/backend/flask.py @@ -22,6 +22,7 @@ from flask_cors import CORS from flask_sock import Sock from simple_websocket import Server as WebSocket +from werkzeug.serving import BaseWSGIServer, make_server import reactpy from reactpy.backend._common import ( @@ -33,13 +34,13 @@ read_client_index_html, safe_client_build_dir_path, safe_web_modules_dir_path, - serve_with_uvicorn, ) from reactpy.backend.hooks import ConnectionContext from reactpy.backend.hooks import use_connection as _use_connection from reactpy.backend.types import Connection, Location from reactpy.core.serve import serve_layout from reactpy.core.types import ComponentType, RootComponentConstructor +from reactpy.utils import Ref logger = logging.getLogger(__name__) @@ -96,7 +97,37 @@ async def serve_development_app( started: asyncio.Event | None = None, ) -> None: """Run a development server for FastAPI""" - await serve_with_uvicorn(app, host, port, started) + loop = asyncio.get_running_loop() + stopped = asyncio.Event() + + server: Ref[BaseWSGIServer] = Ref() + + def run_server() -> None: + server.current = make_server(host, port, app, threaded=True) + if started: + loop.call_soon_threadsafe(started.set) + try: + server.current.serve_forever() # type: ignore + finally: + loop.call_soon_threadsafe(stopped.set) + + thread = Thread(target=run_server, daemon=True) + thread.start() + + if started: + await started.wait() + + try: + await stopped.wait() + finally: + # we may have exited because this task was cancelled + server.current.shutdown() + # the thread should eventually join + thread.join(timeout=3) + # just double check it happened + if thread.is_alive(): # nocov + msg = "Failed to shutdown server." + raise RuntimeError(msg) def use_websocket() -> WebSocket: From 577a6d3dca2212bda967cd20b0a1a34d90ba1a87 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 14 Jun 2023 03:31:16 -0700 Subject: [PATCH 14/32] lint fixes --- src/py/reactpy/reactpy/backend/_common.py | 5 +---- src/py/reactpy/reactpy/backend/tornado.py | 4 +--- src/py/reactpy/reactpy/backend/utils.py | 14 +------------- src/py/reactpy/reactpy/testing/backend.py | 2 +- 4 files changed, 4 insertions(+), 21 deletions(-) diff --git a/src/py/reactpy/reactpy/backend/_common.py b/src/py/reactpy/reactpy/backend/_common.py index 665aabd94..9ce8aee87 100644 --- a/src/py/reactpy/reactpy/backend/_common.py +++ b/src/py/reactpy/reactpy/backend/_common.py @@ -59,10 +59,7 @@ async def serve_with_uvicorn( await asyncio.wait_for(server.shutdown(), timeout=3) -async def _check_if_started(server, started: asyncio.Event) -> None: - import uvicorn - - server: uvicorn.Server = server +async def _check_if_started(server: Any, started: asyncio.Event) -> None: while not server.started: await asyncio.sleep(0.2) started.set() diff --git a/src/py/reactpy/reactpy/backend/tornado.py b/src/py/reactpy/reactpy/backend/tornado.py index f1d2c94f4..c2c19e482 100644 --- a/src/py/reactpy/reactpy/backend/tornado.py +++ b/src/py/reactpy/reactpy/backend/tornado.py @@ -35,9 +35,7 @@ # BackendProtocol.Options -@dataclass -class Options(CommonOptions): - """Render server config for :func:`reactpy.backend.tornado.configure`""" +Options = CommonOptions # BackendProtocol.configure diff --git a/src/py/reactpy/reactpy/backend/utils.py b/src/py/reactpy/reactpy/backend/utils.py index c236a1945..88e004984 100644 --- a/src/py/reactpy/reactpy/backend/utils.py +++ b/src/py/reactpy/reactpy/backend/utils.py @@ -44,23 +44,11 @@ def run( asyncio.run(implementation.serve_development_app(app, host, port)) -def find_available_port( - host: str, - port_min: int = 8000, - port_max: int = 9000, - allow_reuse_waiting_ports: bool = False, -) -> int: +def find_available_port(host: str, port_min: int = 8000, port_max: int = 9000) -> int: """Get a port that's available for the given host and port range""" for port in range(port_min, port_max): with closing(socket.socket()) as sock: try: - if allow_reuse_waiting_ports: - # As per this answer: https://stackoverflow.com/a/19247688/3159288 - # setting can be somewhat unreliable because we allow the use of - # ports that are stuck in TIME_WAIT. However, not setting the option - # means we're overly cautious and almost always use a different addr - # even if it could have actually been used. - sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.bind((host, port)) except OSError: pass diff --git a/src/py/reactpy/reactpy/testing/backend.py b/src/py/reactpy/reactpy/testing/backend.py index 3c69ffcf1..97b129cf6 100644 --- a/src/py/reactpy/reactpy/testing/backend.py +++ b/src/py/reactpy/reactpy/testing/backend.py @@ -48,7 +48,7 @@ def __init__( timeout: float | None = None, ) -> None: self.host = host - self.port = port or find_available_port(host, allow_reuse_waiting_ports=False) + self.port = port or find_available_port(host) self.mount, self._root_component = _hotswap() self.timeout = ( REACTPY_TESTING_DEFAULT_TIMEOUT.current if timeout is None else timeout From 9bbd07878da8049abb1c778bc2f849c9167ba83e Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 14 Jun 2023 03:34:54 -0700 Subject: [PATCH 15/32] more lint fix --- src/py/reactpy/reactpy/backend/tornado.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/py/reactpy/reactpy/backend/tornado.py b/src/py/reactpy/reactpy/backend/tornado.py index c2c19e482..74f01875e 100644 --- a/src/py/reactpy/reactpy/backend/tornado.py +++ b/src/py/reactpy/reactpy/backend/tornado.py @@ -4,7 +4,6 @@ import json from asyncio import Queue as AsyncQueue from asyncio.futures import Future -from dataclasses import dataclass from typing import Any from urllib.parse import urljoin @@ -33,7 +32,6 @@ from reactpy.core.serve import serve_layout from reactpy.core.types import ComponentConstructor - # BackendProtocol.Options Options = CommonOptions From fdc04d99ae3ceaed43b27aae0312711c546eeee2 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 14 Jun 2023 13:33:03 -0700 Subject: [PATCH 16/32] add changelog --- 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 f739ce980..c84dc1601 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -23,8 +23,15 @@ more info, see the :ref:`Contributor Guide `. Unreleased ---------- -No changes. +**Fixed** + +- :pull:`1051` - Fix ``reactpy.run`` port assignment sometimes attaching to in-use ports on Windows +- :pull:`1051` - Fix ``reactpy.run`` not recognizing ``fastapi`` + +**Changed** +- :pull:`1051` - Rename ``reactpy.backend.BackendImplementation`` to ``reactpy.backend.BackendProtocol`` +- :pull:`1051` - Allow ``reactpy.run`` to fail in more predictable ways v1.0.0 ------ From 093625c3a271ad73b05aa94f8700e3759de93879 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 14 Jun 2023 14:02:09 -0700 Subject: [PATCH 17/32] remove dead requirements.txt --- requirements.txt | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 requirements.txt diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index dab76855e..000000000 --- a/requirements.txt +++ /dev/null @@ -1,9 +0,0 @@ --r requirements/build-docs.txt --r requirements/build-pkg.txt --r requirements/check-style.txt --r requirements/check-types.txt --r requirements/make-release.txt --r requirements/pkg-deps.txt --r requirements/pkg-extras.txt --r requirements/test-env.txt --r requirements/nox-deps.txt From 24d484bb70fbe6e6367260475ac1bbf5a3b4d20d Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 14 Jun 2023 14:30:13 -0700 Subject: [PATCH 18/32] bump workflow versions --- .github/workflows/.hatch-run.yml | 108 +++++++++++++++---------------- 1 file changed, 54 insertions(+), 54 deletions(-) diff --git a/.github/workflows/.hatch-run.yml b/.github/workflows/.hatch-run.yml index b312869e4..f3dfee04e 100644 --- a/.github/workflows/.hatch-run.yml +++ b/.github/workflows/.hatch-run.yml @@ -1,59 +1,59 @@ name: hatch-run on: - workflow_call: - inputs: - job-name: - required: true - type: string - hatch-run: - required: true - type: string - runs-on-array: - required: false - type: string - default: '["ubuntu-latest"]' - python-version-array: - required: false - type: string - default: '["3.x"]' - node-registry-url: - required: false - type: string - default: "" - secrets: - node-auth-token: - required: false - pypi-username: - required: false - pypi-password: - required: false + workflow_call: + inputs: + job-name: + required: true + type: string + hatch-run: + required: true + type: string + runs-on-array: + required: false + type: string + default: '["ubuntu-latest"]' + python-version-array: + required: false + type: string + default: '["3.x"]' + node-registry-url: + required: false + type: string + default: "" + secrets: + node-auth-token: + required: false + pypi-username: + required: false + pypi-password: + required: false jobs: - hatch: - name: ${{ format(inputs.job-name, matrix.python-version, matrix.runs-on) }} - strategy: - matrix: - python-version: ${{ fromJson(inputs.python-version-array) }} - runs-on: ${{ fromJson(inputs.runs-on-array) }} - runs-on: ${{ matrix.runs-on }} - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v2 - with: - node-version: "14.x" - registry-url: ${{ inputs.node-registry-url }} - - name: Pin NPM Version - run: npm install -g npm@8.19.3 - - name: Use Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - name: Install Python Dependencies - run: pip install hatch poetry - - name: Run Scripts - env: - NODE_AUTH_TOKEN: ${{ secrets.node-auth-token }} - PYPI_USERNAME: ${{ secrets.pypi-username }} - PYPI_PASSWORD: ${{ secrets.pypi-password }} - run: hatch run ${{ inputs.hatch-run }} + hatch: + name: ${{ format(inputs.job-name, matrix.python-version, matrix.runs-on) }} + strategy: + matrix: + python-version: ${{ fromJson(inputs.python-version-array) }} + runs-on: ${{ fromJson(inputs.runs-on-array) }} + runs-on: ${{ matrix.runs-on }} + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: "14.x" + registry-url: ${{ inputs.node-registry-url }} + - name: Pin NPM Version + run: npm install -g npm@8.19.3 + - name: Use Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install Python Dependencies + run: pip install hatch poetry + - name: Run Scripts + env: + NODE_AUTH_TOKEN: ${{ secrets.node-auth-token }} + PYPI_USERNAME: ${{ secrets.pypi-username }} + PYPI_PASSWORD: ${{ secrets.pypi-password }} + run: hatch run ${{ inputs.hatch-run }} From 955a84570ede274425d717084ce9dbcbf9c0b65d Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 14 Jun 2023 21:28:16 -0700 Subject: [PATCH 19/32] Revert "bump workflow versions" This reverts commit 24d484bb70fbe6e6367260475ac1bbf5a3b4d20d. --- .github/workflows/.hatch-run.yml | 108 +++++++++++++++---------------- 1 file changed, 54 insertions(+), 54 deletions(-) diff --git a/.github/workflows/.hatch-run.yml b/.github/workflows/.hatch-run.yml index f3dfee04e..b312869e4 100644 --- a/.github/workflows/.hatch-run.yml +++ b/.github/workflows/.hatch-run.yml @@ -1,59 +1,59 @@ name: hatch-run on: - workflow_call: - inputs: - job-name: - required: true - type: string - hatch-run: - required: true - type: string - runs-on-array: - required: false - type: string - default: '["ubuntu-latest"]' - python-version-array: - required: false - type: string - default: '["3.x"]' - node-registry-url: - required: false - type: string - default: "" - secrets: - node-auth-token: - required: false - pypi-username: - required: false - pypi-password: - required: false + workflow_call: + inputs: + job-name: + required: true + type: string + hatch-run: + required: true + type: string + runs-on-array: + required: false + type: string + default: '["ubuntu-latest"]' + python-version-array: + required: false + type: string + default: '["3.x"]' + node-registry-url: + required: false + type: string + default: "" + secrets: + node-auth-token: + required: false + pypi-username: + required: false + pypi-password: + required: false jobs: - hatch: - name: ${{ format(inputs.job-name, matrix.python-version, matrix.runs-on) }} - strategy: - matrix: - python-version: ${{ fromJson(inputs.python-version-array) }} - runs-on: ${{ fromJson(inputs.runs-on-array) }} - runs-on: ${{ matrix.runs-on }} - steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 - with: - node-version: "14.x" - registry-url: ${{ inputs.node-registry-url }} - - name: Pin NPM Version - run: npm install -g npm@8.19.3 - - name: Use Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - name: Install Python Dependencies - run: pip install hatch poetry - - name: Run Scripts - env: - NODE_AUTH_TOKEN: ${{ secrets.node-auth-token }} - PYPI_USERNAME: ${{ secrets.pypi-username }} - PYPI_PASSWORD: ${{ secrets.pypi-password }} - run: hatch run ${{ inputs.hatch-run }} + hatch: + name: ${{ format(inputs.job-name, matrix.python-version, matrix.runs-on) }} + strategy: + matrix: + python-version: ${{ fromJson(inputs.python-version-array) }} + runs-on: ${{ fromJson(inputs.runs-on-array) }} + runs-on: ${{ matrix.runs-on }} + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v2 + with: + node-version: "14.x" + registry-url: ${{ inputs.node-registry-url }} + - name: Pin NPM Version + run: npm install -g npm@8.19.3 + - name: Use Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install Python Dependencies + run: pip install hatch poetry + - name: Run Scripts + env: + NODE_AUTH_TOKEN: ${{ secrets.node-auth-token }} + PYPI_USERNAME: ${{ secrets.pypi-username }} + PYPI_PASSWORD: ${{ secrets.pypi-password }} + run: hatch run ${{ inputs.hatch-run }} From 67ca3df398fb2d8a687611d6455e06e64a3584e1 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 14 Jun 2023 21:40:18 -0700 Subject: [PATCH 20/32] Rename SUPPORTED_PACKAGES to SUPPORTED_BACKENDS --- src/py/reactpy/reactpy/backend/default.py | 12 +++++++----- src/py/reactpy/reactpy/backend/utils.py | 20 ++++++++++++-------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/py/reactpy/reactpy/backend/default.py b/src/py/reactpy/reactpy/backend/default.py index c20ea80c2..ddc4f1e54 100644 --- a/src/py/reactpy/reactpy/backend/default.py +++ b/src/py/reactpy/reactpy/backend/default.py @@ -6,7 +6,7 @@ from typing import Any, NoReturn from reactpy.backend.types import BackendProtocol -from reactpy.backend.utils import SUPPORTED_PACKAGES, all_implementations +from reactpy.backend.utils import SUPPORTED_BACKENDS, all_implementations from reactpy.types import RootComponentConstructor logger = getLogger(__name__) @@ -14,10 +14,12 @@ # BackendProtocol.Options -def Options(*args: Any, **kwargs: Any) -> NoReturn: # nocov +class Options: # nocov """Create configuration options""" - msg = "Default implementation has no options." - raise ValueError(msg) + + def __call__(self, *args: Any, **kwds: Any) -> NoReturn: + msg = "Default implementation has no options." + raise ValueError(msg) # BackendProtocol.configure @@ -61,7 +63,7 @@ def _default_implementation() -> BackendProtocol[Any]: implementation = next(all_implementations()) except StopIteration: # nocov logger.debug("Backend implementation import failed", exc_info=exc_info()) - supported_backends = ", ".join(SUPPORTED_PACKAGES) + supported_backends = ", ".join(SUPPORTED_BACKENDS) msg = ( "It seems you haven't installed a backend. To resolve this issue, " "you can install a backend by running:\n\n" diff --git a/src/py/reactpy/reactpy/backend/utils.py b/src/py/reactpy/reactpy/backend/utils.py index 88e004984..be0f3a91b 100644 --- a/src/py/reactpy/reactpy/backend/utils.py +++ b/src/py/reactpy/reactpy/backend/utils.py @@ -13,7 +13,7 @@ logger = logging.getLogger(__name__) -SUPPORTED_PACKAGES = ( +SUPPORTED_BACKENDS = ( "fastapi", "sanic", "tornado", @@ -24,7 +24,7 @@ def run( component: RootComponentConstructor, - host: str = "127.0.0.1", + host: str | None = None, port: int | None = None, implementation: BackendProtocol[Any] | None = None, ) -> None: @@ -34,12 +34,16 @@ def run( implementation = implementation or import_module("reactpy.backend.default") app = implementation.create_development_app() implementation.configure(app, component) - host = host + host = host or "127.0.0.1" port = port or find_available_port(host) app_cls = type(app) logger.info( - f"ReactPy is running with '{app_cls.__module__}.{app_cls.__name__}' at http://{host}:{port}" + "ReactPy is running with '%s.%s' at http://%s:%s", + app_cls.__module__, + app_cls.__name__, + host, + port, ) asyncio.run(implementation.serve_development_app(app, host, port)) @@ -60,11 +64,11 @@ def find_available_port(host: str, port_min: int = 8000, port_max: int = 9000) - def all_implementations() -> Iterator[BackendProtocol[Any]]: """Yield all available server implementations""" - for name in SUPPORTED_PACKAGES: + for name in SUPPORTED_BACKENDS: try: import_module(name) except ImportError: # nocov - logger.debug(f"Failed to import {name!r}", exc_info=True) + logger.debug("Failed to import %s", name, exc_info=True) continue reactpy_backend_name = f"{__name__.rsplit('.', 1)[0]}.{name}" @@ -72,6 +76,6 @@ def all_implementations() -> Iterator[BackendProtocol[Any]]: _DEVELOPMENT_RUN_FUNC_WARNING = """\ -The `run()` function is only intended for testing during development! To run in \ -production, refer to the docs on how to use reactpy.backend.*.configure.\ +The `run()` function is only intended for testing during development! To run \ +in production, refer to the docs on how to use reactpy.backend.*.configure.\ """ From 0adc456e04677eeaf2bd038e8e4f34917f79cdc5 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 14 Jun 2023 23:17:05 -0700 Subject: [PATCH 21/32] fix merge issue --- docs/source/about/changelog.rst | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index 6cb885d51..82a448c01 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -23,10 +23,6 @@ more info, see the :ref:`Contributor Guide `. Unreleased ---------- -**Changed** - -- :pull:`1050` - Warn and attempt to fix missing mime types, which can result in ``reactpy.run`` not working as expected. - **Fixed** - :issue:`930` - better traceback for JSON serialization errors (via :pull:`1008`) @@ -36,6 +32,7 @@ Unreleased **Changed** +- :pull:`1050` - Warn and attempt to fix missing mime types, which can result in ``reactpy.run`` not working as expected. - :pull:`1051` - Rename ``reactpy.backend.BackendImplementation`` to ``reactpy.backend.BackendProtocol`` - :pull:`1051` - Allow ``reactpy.run`` to fail in more predictable ways From a827ca1be0e2af52c0b0cbeb80402692c9baa7da Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 14 Jun 2023 23:18:15 -0700 Subject: [PATCH 22/32] move around stuff --- docs/source/about/changelog.rst | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index 82a448c01..3ea1288b9 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -23,6 +23,12 @@ more info, see the :ref:`Contributor Guide `. Unreleased ---------- +**Changed** + +- :pull:`1050` - Warn and attempt to fix missing mime types, which can result in ``reactpy.run`` not working as expected. +- :pull:`1051` - Rename ``reactpy.backend.BackendImplementation`` to ``reactpy.backend.BackendProtocol`` +- :pull:`1051` - Allow ``reactpy.run`` to fail in more predictable ways + **Fixed** - :issue:`930` - better traceback for JSON serialization errors (via :pull:`1008`) @@ -30,11 +36,6 @@ Unreleased - :pull:`1051` - Fix ``reactpy.run`` port assignment sometimes attaching to in-use ports on Windows - :pull:`1051` - Fix ``reactpy.run`` not recognizing ``fastapi`` -**Changed** - -- :pull:`1050` - Warn and attempt to fix missing mime types, which can result in ``reactpy.run`` not working as expected. -- :pull:`1051` - Rename ``reactpy.backend.BackendImplementation`` to ``reactpy.backend.BackendProtocol`` -- :pull:`1051` - Allow ``reactpy.run`` to fail in more predictable ways v1.0.0 ------ From 9e3248c611aa4a12e612aa9fd6fcd9ed64d58d79 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 15 Jun 2023 04:29:18 -0700 Subject: [PATCH 23/32] don't clutter the routes when not needed --- src/py/reactpy/reactpy/backend/_common.py | 3 +++ src/py/reactpy/reactpy/backend/flask.py | 10 ++++++---- src/py/reactpy/reactpy/backend/sanic.py | 21 +++++++++++---------- src/py/reactpy/reactpy/backend/starlette.py | 6 ++++-- src/py/reactpy/reactpy/backend/tornado.py | 17 +++++++++++------ 5 files changed, 35 insertions(+), 22 deletions(-) diff --git a/src/py/reactpy/reactpy/backend/_common.py b/src/py/reactpy/reactpy/backend/_common.py index 399678628..640f46581 100644 --- a/src/py/reactpy/reactpy/backend/_common.py +++ b/src/py/reactpy/reactpy/backend/_common.py @@ -135,6 +135,9 @@ class CommonOptions: url_prefix: str = "" """The URL prefix where ReactPy resources will be served from""" + serve_index_route: bool = True + """Automatically generate and serve the index route (``/``)""" + def __post_init__(self) -> None: if self.url_prefix and not self.url_prefix.startswith("/"): msg = "Expected 'url_prefix' to start with '/'" diff --git a/src/py/reactpy/reactpy/backend/flask.py b/src/py/reactpy/reactpy/backend/flask.py index d2e84ceca..d9d3a7e70 100644 --- a/src/py/reactpy/reactpy/backend/flask.py +++ b/src/py/reactpy/reactpy/backend/flask.py @@ -169,10 +169,12 @@ def send_modules_dir(path: str = "") -> Any: index_html = read_client_index_html(options) - @spa_blueprint.route("/") - @spa_blueprint.route("/") - def send_client_dir(_: str = "") -> Any: - return index_html + if options.serve_index_route: + + @spa_blueprint.route("/") + @spa_blueprint.route("/") + def send_client_dir(_: str = "") -> Any: + return index_html def _setup_single_view_dispatcher_route( diff --git a/src/py/reactpy/reactpy/backend/sanic.py b/src/py/reactpy/reactpy/backend/sanic.py index c304d409a..22e88d208 100644 --- a/src/py/reactpy/reactpy/backend/sanic.py +++ b/src/py/reactpy/reactpy/backend/sanic.py @@ -118,16 +118,17 @@ async def single_page_app_files( ) -> response.HTTPResponse: return response.html(index_html) - spa_blueprint.add_route( - single_page_app_files, - "/", - name="single_page_app_files_root", - ) - spa_blueprint.add_route( - single_page_app_files, - "/<_:path>", - name="single_page_app_files_path", - ) + if options.serve_index_route: + spa_blueprint.add_route( + single_page_app_files, + "/", + name="single_page_app_files_root", + ) + spa_blueprint.add_route( + single_page_app_files, + "/<_:path>", + name="single_page_app_files_path", + ) async def asset_files( request: request.Request, diff --git a/src/py/reactpy/reactpy/backend/starlette.py b/src/py/reactpy/reactpy/backend/starlette.py index dd347cc63..969ed2385 100644 --- a/src/py/reactpy/reactpy/backend/starlette.py +++ b/src/py/reactpy/reactpy/backend/starlette.py @@ -119,8 +119,10 @@ def _setup_common_routes(options: Options, app: Starlette) -> None: ) # register this last so it takes least priority index_route = _make_index_route(options) - app.add_route(f"{url_prefix}/", index_route) - app.add_route(url_prefix + "/{path:path}", index_route) + + if options.serve_index_route: + app.add_route(f"{url_prefix}/", index_route) + app.add_route(url_prefix + "/{path:path}", index_route) def _make_index_route(options: Options) -> Callable[[Request], Awaitable[HTMLResponse]]: diff --git a/src/py/reactpy/reactpy/backend/tornado.py b/src/py/reactpy/reactpy/backend/tornado.py index 74f01875e..9bfc12415 100644 --- a/src/py/reactpy/reactpy/backend/tornado.py +++ b/src/py/reactpy/reactpy/backend/tornado.py @@ -122,12 +122,17 @@ def _setup_common_routes(options: Options) -> _RouteHandlerSpecs: StaticFileHandler, {"path": str(CLIENT_BUILD_DIR / "assets")}, ), - ( - r"/(.*)", - IndexHandler, - {"index_html": read_client_index_html(options)}, - ), - ] + ] + ( + [ + ( + r"/(.*)", + IndexHandler, + {"index_html": read_client_index_html(options)}, + ), + ] + if options.serve_index_route + else [] + ) def _add_handler( From 15932a425a7c3580d9dcb6f8a6d9e60d4d02852d Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 4 Jul 2023 14:21:51 -0700 Subject: [PATCH 24/32] import uvicorn if type checking --- src/py/reactpy/reactpy/backend/_common.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/py/reactpy/reactpy/backend/_common.py b/src/py/reactpy/reactpy/backend/_common.py index 640f46581..b4d6af19c 100644 --- a/src/py/reactpy/reactpy/backend/_common.py +++ b/src/py/reactpy/reactpy/backend/_common.py @@ -14,13 +14,13 @@ from reactpy.utils import vdom_to_html if TYPE_CHECKING: + import uvicorn from asgiref.typing import ASGIApplication PATH_PREFIX = PurePosixPath("/_reactpy") MODULES_PATH = PATH_PREFIX / "modules" ASSETS_PATH = PATH_PREFIX / "assets" STREAM_PATH = PATH_PREFIX / "stream" - CLIENT_BUILD_DIR = Path(_reactpy_file_path).parent / "_static" / "app" / "dist" @@ -59,7 +59,7 @@ async def serve_with_uvicorn( await asyncio.wait_for(server.shutdown(), timeout=3) -async def _check_if_started(server: Any, started: asyncio.Event) -> None: +async def _check_if_started(server: uvicorn.Server, started: asyncio.Event) -> None: while not server.started: await asyncio.sleep(0.2) started.set() From a28f75feed0c0c17e441b10ca3c16531c97f1f23 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 4 Jul 2023 14:31:58 -0700 Subject: [PATCH 25/32] change options constructor to init --- src/py/reactpy/reactpy/backend/default.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/py/reactpy/reactpy/backend/default.py b/src/py/reactpy/reactpy/backend/default.py index ddc4f1e54..dbd1ce082 100644 --- a/src/py/reactpy/reactpy/backend/default.py +++ b/src/py/reactpy/reactpy/backend/default.py @@ -17,7 +17,7 @@ class Options: # nocov """Create configuration options""" - def __call__(self, *args: Any, **kwds: Any) -> NoReturn: + def __init__(self, *args: Any, **kwds: Any) -> NoReturn: msg = "Default implementation has no options." raise ValueError(msg) From cf90737eeea1c5edff586bd8108893a38b3c012f Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 4 Jul 2023 14:32:30 -0700 Subject: [PATCH 26/32] BackendProtocol -> BackendType --- src/py/reactpy/reactpy/backend/default.py | 6 +++--- src/py/reactpy/reactpy/backend/types.py | 2 +- src/py/reactpy/reactpy/backend/utils.py | 6 +++--- src/py/reactpy/reactpy/testing/backend.py | 4 ++-- src/py/reactpy/reactpy/types.py | 4 ++-- src/py/reactpy/tests/test_backend/test_all.py | 6 +++--- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/py/reactpy/reactpy/backend/default.py b/src/py/reactpy/reactpy/backend/default.py index dbd1ce082..56693a379 100644 --- a/src/py/reactpy/reactpy/backend/default.py +++ b/src/py/reactpy/reactpy/backend/default.py @@ -5,12 +5,12 @@ from sys import exc_info from typing import Any, NoReturn -from reactpy.backend.types import BackendProtocol +from reactpy.backend.types import BackendType from reactpy.backend.utils import SUPPORTED_BACKENDS, all_implementations from reactpy.types import RootComponentConstructor logger = getLogger(__name__) -_DEFAULT_IMPLEMENTATION: BackendProtocol[Any] | None = None +_DEFAULT_IMPLEMENTATION: BackendType[Any] | None = None # BackendProtocol.Options @@ -52,7 +52,7 @@ async def serve_development_app( ) -def _default_implementation() -> BackendProtocol[Any]: +def _default_implementation() -> BackendType[Any]: """Get the first available server implementation""" global _DEFAULT_IMPLEMENTATION # noqa: PLW0603 diff --git a/src/py/reactpy/reactpy/backend/types.py b/src/py/reactpy/reactpy/backend/types.py index a444731f4..b9afdcf09 100644 --- a/src/py/reactpy/reactpy/backend/types.py +++ b/src/py/reactpy/reactpy/backend/types.py @@ -11,7 +11,7 @@ @runtime_checkable -class BackendProtocol(Protocol[_App]): +class BackendType(Protocol[_App]): """Common interface for built-in web server/framework integrations""" Options: Callable[..., Any] diff --git a/src/py/reactpy/reactpy/backend/utils.py b/src/py/reactpy/reactpy/backend/utils.py index be0f3a91b..38521cbd6 100644 --- a/src/py/reactpy/reactpy/backend/utils.py +++ b/src/py/reactpy/reactpy/backend/utils.py @@ -8,7 +8,7 @@ from importlib import import_module from typing import Any -from reactpy.backend.types import BackendProtocol +from reactpy.backend.types import BackendType from reactpy.types import RootComponentConstructor logger = logging.getLogger(__name__) @@ -26,7 +26,7 @@ def run( component: RootComponentConstructor, host: str | None = None, port: int | None = None, - implementation: BackendProtocol[Any] | None = None, + implementation: BackendType[Any] | None = None, ) -> None: """Run a component with a development server""" logger.warning(_DEVELOPMENT_RUN_FUNC_WARNING) @@ -62,7 +62,7 @@ def find_available_port(host: str, port_min: int = 8000, port_max: int = 9000) - raise RuntimeError(msg) -def all_implementations() -> Iterator[BackendProtocol[Any]]: +def all_implementations() -> Iterator[BackendType[Any]]: """Yield all available server implementations""" for name in SUPPORTED_BACKENDS: try: diff --git a/src/py/reactpy/reactpy/testing/backend.py b/src/py/reactpy/reactpy/testing/backend.py index 97b129cf6..b699f3071 100644 --- a/src/py/reactpy/reactpy/testing/backend.py +++ b/src/py/reactpy/reactpy/testing/backend.py @@ -8,7 +8,7 @@ from urllib.parse import urlencode, urlunparse from reactpy.backend import default as default_server -from reactpy.backend.types import BackendProtocol +from reactpy.backend.types import BackendType from reactpy.backend.utils import find_available_port from reactpy.config import REACTPY_TESTING_DEFAULT_TIMEOUT from reactpy.core.component import component @@ -43,7 +43,7 @@ def __init__( host: str = "127.0.0.1", port: int | None = None, app: Any | None = None, - implementation: BackendProtocol[Any] | None = None, + implementation: BackendType[Any] | None = None, options: Any | None = None, timeout: float | None = None, ) -> None: diff --git a/src/py/reactpy/reactpy/types.py b/src/py/reactpy/reactpy/types.py index bac5511ad..4766fe801 100644 --- a/src/py/reactpy/reactpy/types.py +++ b/src/py/reactpy/reactpy/types.py @@ -4,7 +4,7 @@ - :mod:`reactpy.backend.types` """ -from reactpy.backend.types import BackendProtocol, Connection, Location +from reactpy.backend.types import BackendType, Connection, Location from reactpy.core.component import Component from reactpy.core.hooks import Context from reactpy.core.types import ( @@ -27,7 +27,7 @@ ) __all__ = [ - "BackendProtocol", + "BackendType", "Component", "ComponentConstructor", "ComponentType", diff --git a/src/py/reactpy/tests/test_backend/test_all.py b/src/py/reactpy/tests/test_backend/test_all.py index 36e829b14..d697e5d3f 100644 --- a/src/py/reactpy/tests/test_backend/test_all.py +++ b/src/py/reactpy/tests/test_backend/test_all.py @@ -6,7 +6,7 @@ from reactpy import html from reactpy.backend import default as default_implementation from reactpy.backend._common import PATH_PREFIX -from reactpy.backend.types import BackendProtocol, Connection, Location +from reactpy.backend.types import BackendType, Connection, Location from reactpy.backend.utils import all_implementations from reactpy.testing import BackendFixture, DisplayFixture, poll @@ -17,7 +17,7 @@ scope="module", ) async def display(page, request): - imp: BackendProtocol = request.param + imp: BackendType = request.param # we do this to check that route priorities for each backend are correct if imp is default_implementation: @@ -158,7 +158,7 @@ def ShowRoute(): @pytest.mark.parametrize("imp", all_implementations()) -async def test_customized_head(imp: BackendProtocol, page): +async def test_customized_head(imp: BackendType, page): custom_title = f"Custom Title for {imp.__name__}" @reactpy.component From e68cebbb8250695ba1bcfb74252206026f3ad5b1 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 4 Jul 2023 14:33:35 -0700 Subject: [PATCH 27/32] revert host str --- src/py/reactpy/reactpy/backend/utils.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/py/reactpy/reactpy/backend/utils.py b/src/py/reactpy/reactpy/backend/utils.py index 38521cbd6..2b1e34654 100644 --- a/src/py/reactpy/reactpy/backend/utils.py +++ b/src/py/reactpy/reactpy/backend/utils.py @@ -24,7 +24,7 @@ def run( component: RootComponentConstructor, - host: str | None = None, + host: str = "127.0.0.1", port: int | None = None, implementation: BackendType[Any] | None = None, ) -> None: @@ -34,7 +34,6 @@ def run( implementation = implementation or import_module("reactpy.backend.default") app = implementation.create_development_app() implementation.configure(app, component) - host = host or "127.0.0.1" port = port or find_available_port(host) app_cls = type(app) From 1175fbc02b5d6135b9d71eb1dfd4605a25bbe09a Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 5 Jul 2023 18:24:02 -0700 Subject: [PATCH 28/32] options init docstring --- src/py/reactpy/reactpy/backend/default.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/py/reactpy/reactpy/backend/default.py b/src/py/reactpy/reactpy/backend/default.py index 56693a379..96caa6974 100644 --- a/src/py/reactpy/reactpy/backend/default.py +++ b/src/py/reactpy/reactpy/backend/default.py @@ -18,6 +18,8 @@ class Options: # nocov """Create configuration options""" def __init__(self, *args: Any, **kwds: Any) -> NoReturn: + """This should not be used, since default options should never + be constructed. Please override this method.""" msg = "Default implementation has no options." raise ValueError(msg) From 9f31619d8de3b9a568e8e6cb484682b6902e8587 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 6 Jul 2023 15:39:11 -0700 Subject: [PATCH 29/32] fix docstring --- src/py/reactpy/reactpy/backend/default.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/py/reactpy/reactpy/backend/default.py b/src/py/reactpy/reactpy/backend/default.py index 96caa6974..327333fb2 100644 --- a/src/py/reactpy/reactpy/backend/default.py +++ b/src/py/reactpy/reactpy/backend/default.py @@ -18,8 +18,8 @@ class Options: # nocov """Create configuration options""" def __init__(self, *args: Any, **kwds: Any) -> NoReturn: - """This should not be used, since default options should never - be constructed. Please override this method.""" + """This definition should not be used. It exists only for + type hinting purposes.""" msg = "Default implementation has no options." raise ValueError(msg) From a7e4239f4eb85743b5bc5ae538c0089ee1ac8734 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 6 Jul 2023 15:39:22 -0700 Subject: [PATCH 30/32] rename BackendType comments --- docs/source/about/changelog.rst | 2 +- src/py/reactpy/reactpy/backend/default.py | 8 ++++---- src/py/reactpy/reactpy/backend/fastapi.py | 8 ++++---- src/py/reactpy/reactpy/backend/flask.py | 8 ++++---- src/py/reactpy/reactpy/backend/sanic.py | 8 ++++---- src/py/reactpy/reactpy/backend/starlette.py | 8 ++++---- src/py/reactpy/reactpy/backend/tornado.py | 8 ++++---- src/py/reactpy/reactpy/backend/types.py | 2 +- 8 files changed, 26 insertions(+), 26 deletions(-) diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index 3ea1288b9..173fe354b 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -26,7 +26,7 @@ Unreleased **Changed** - :pull:`1050` - Warn and attempt to fix missing mime types, which can result in ``reactpy.run`` not working as expected. -- :pull:`1051` - Rename ``reactpy.backend.BackendImplementation`` to ``reactpy.backend.BackendProtocol`` +- :pull:`1051` - Rename ``reactpy.backend.BackendImplementation`` to ``reactpy.backend.BackendType`` - :pull:`1051` - Allow ``reactpy.run`` to fail in more predictable ways **Fixed** diff --git a/src/py/reactpy/reactpy/backend/default.py b/src/py/reactpy/reactpy/backend/default.py index 327333fb2..b61d46ae2 100644 --- a/src/py/reactpy/reactpy/backend/default.py +++ b/src/py/reactpy/reactpy/backend/default.py @@ -13,7 +13,7 @@ _DEFAULT_IMPLEMENTATION: BackendType[Any] | None = None -# BackendProtocol.Options +# BackendType.Options class Options: # nocov """Create configuration options""" @@ -24,7 +24,7 @@ def __init__(self, *args: Any, **kwds: Any) -> NoReturn: raise ValueError(msg) -# BackendProtocol.configure +# BackendType.configure def configure( app: Any, component: RootComponentConstructor, options: None = None ) -> None: @@ -35,13 +35,13 @@ def configure( return _default_implementation().configure(app, component) -# BackendProtocol.create_development_app +# BackendType.create_development_app def create_development_app() -> Any: """Create an application instance for development purposes""" return _default_implementation().create_development_app() -# BackendProtocol.serve_development_app +# BackendType.serve_development_app async def serve_development_app( app: Any, host: str, diff --git a/src/py/reactpy/reactpy/backend/fastapi.py b/src/py/reactpy/reactpy/backend/fastapi.py index f4312903a..a0137a3dc 100644 --- a/src/py/reactpy/reactpy/backend/fastapi.py +++ b/src/py/reactpy/reactpy/backend/fastapi.py @@ -4,20 +4,20 @@ from reactpy.backend import starlette -# BackendProtocol.Options +# BackendType.Options Options = starlette.Options -# BackendProtocol.configure +# BackendType.configure configure = starlette.configure -# BackendProtocol.create_development_app +# BackendType.create_development_app def create_development_app() -> FastAPI: """Create a development ``FastAPI`` application instance.""" return FastAPI(debug=True) -# BackendProtocol.serve_development_app +# BackendType.serve_development_app serve_development_app = starlette.serve_development_app use_connection = starlette.use_connection diff --git a/src/py/reactpy/reactpy/backend/flask.py b/src/py/reactpy/reactpy/backend/flask.py index d9d3a7e70..2e00e8f64 100644 --- a/src/py/reactpy/reactpy/backend/flask.py +++ b/src/py/reactpy/reactpy/backend/flask.py @@ -45,7 +45,7 @@ logger = logging.getLogger(__name__) -# BackendProtocol.Options +# BackendType.Options @dataclass class Options(CommonOptions): """Render server config for :func:`reactpy.backend.flask.configure`""" @@ -57,7 +57,7 @@ class Options(CommonOptions): """ -# BackendProtocol.configure +# BackendType.configure def configure( app: Flask, component: RootComponentConstructor, options: Options | None = None ) -> None: @@ -82,14 +82,14 @@ def configure( app.register_blueprint(spa_bp) -# BackendProtocol.create_development_app +# BackendType.create_development_app def create_development_app() -> Flask: """Create an application instance for development purposes""" os.environ["FLASK_DEBUG"] = "true" return Flask(__name__) -# BackendProtocol.serve_development_app +# BackendType.serve_development_app async def serve_development_app( app: Flask, host: str, diff --git a/src/py/reactpy/reactpy/backend/sanic.py b/src/py/reactpy/reactpy/backend/sanic.py index 22e88d208..3fd48db85 100644 --- a/src/py/reactpy/reactpy/backend/sanic.py +++ b/src/py/reactpy/reactpy/backend/sanic.py @@ -34,7 +34,7 @@ logger = logging.getLogger(__name__) -# BackendProtocol.Options +# BackendType.Options @dataclass class Options(CommonOptions): """Render server config for :func:`reactpy.backend.sanic.configure`""" @@ -46,7 +46,7 @@ class Options(CommonOptions): """ -# BackendProtocol.configure +# BackendType.configure def configure( app: Sanic, component: RootComponentConstructor, options: Options | None = None ) -> None: @@ -62,7 +62,7 @@ def configure( app.blueprint([spa_bp, api_bp]) -# BackendProtocol.create_development_app +# BackendType.create_development_app def create_development_app() -> Sanic: """Return a :class:`Sanic` app instance in test mode""" Sanic.test_mode = True @@ -70,7 +70,7 @@ def create_development_app() -> Sanic: return Sanic(f"reactpy_development_app_{uuid4().hex}", Config()) -# BackendProtocol.serve_development_app +# BackendType.serve_development_app async def serve_development_app( app: Sanic, host: str, diff --git a/src/py/reactpy/reactpy/backend/starlette.py b/src/py/reactpy/reactpy/backend/starlette.py index 969ed2385..2953b97b3 100644 --- a/src/py/reactpy/reactpy/backend/starlette.py +++ b/src/py/reactpy/reactpy/backend/starlette.py @@ -34,7 +34,7 @@ logger = logging.getLogger(__name__) -# BackendProtocol.Options +# BackendType.Options @dataclass class Options(CommonOptions): """Render server config for :func:`reactpy.backend.starlette.configure`""" @@ -46,7 +46,7 @@ class Options(CommonOptions): """ -# BackendProtocol.configure +# BackendType.configure def configure( app: Starlette, component: RootComponentConstructor, @@ -67,13 +67,13 @@ def configure( _setup_common_routes(options, app) -# BackendProtocol.create_development_app +# BackendType.create_development_app def create_development_app() -> Starlette: """Return a :class:`Starlette` app instance in debug mode""" return Starlette(debug=True) -# BackendProtocol.serve_development_app +# BackendType.serve_development_app async def serve_development_app( app: Starlette, host: str, diff --git a/src/py/reactpy/reactpy/backend/tornado.py b/src/py/reactpy/reactpy/backend/tornado.py index 9bfc12415..8f540ddb4 100644 --- a/src/py/reactpy/reactpy/backend/tornado.py +++ b/src/py/reactpy/reactpy/backend/tornado.py @@ -32,11 +32,11 @@ from reactpy.core.serve import serve_layout from reactpy.core.types import ComponentConstructor -# BackendProtocol.Options +# BackendType.Options Options = CommonOptions -# BackendProtocol.configure +# BackendType.configure def configure( app: Application, component: ComponentConstructor, @@ -61,12 +61,12 @@ def configure( ) -# BackendProtocol.create_development_app +# BackendType.create_development_app def create_development_app() -> Application: return Application(debug=True) -# BackendProtocol.serve_development_app +# BackendType.serve_development_app async def serve_development_app( app: Application, host: str, diff --git a/src/py/reactpy/reactpy/backend/types.py b/src/py/reactpy/reactpy/backend/types.py index b9afdcf09..51e7bef04 100644 --- a/src/py/reactpy/reactpy/backend/types.py +++ b/src/py/reactpy/reactpy/backend/types.py @@ -15,7 +15,7 @@ class BackendType(Protocol[_App]): """Common interface for built-in web server/framework integrations""" Options: Callable[..., Any] - """A constructor for options passed to :meth:`BackendProtocol.configure`""" + """A constructor for options passed to :meth:`BackendType.configure`""" def configure( self, From e1f69fbac6417e3877163c5a1692639722c0da95 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 6 Jul 2023 15:41:49 -0700 Subject: [PATCH 31/32] move comment position --- src/py/reactpy/reactpy/backend/default.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/py/reactpy/reactpy/backend/default.py b/src/py/reactpy/reactpy/backend/default.py index b61d46ae2..37aad31af 100644 --- a/src/py/reactpy/reactpy/backend/default.py +++ b/src/py/reactpy/reactpy/backend/default.py @@ -15,11 +15,11 @@ # BackendType.Options class Options: # nocov - """Create configuration options""" + """Configuration options that can be provided to the backend. + This definition should not be used/instantiated. It exists only for + type hinting purposes.""" def __init__(self, *args: Any, **kwds: Any) -> NoReturn: - """This definition should not be used. It exists only for - type hinting purposes.""" msg = "Default implementation has no options." raise ValueError(msg) From 5429c33897334555fb13aef6f4139e1721271fb1 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 6 Jul 2023 18:12:47 -0700 Subject: [PATCH 32/32] SO_REUSEADDR --- src/py/reactpy/reactpy/backend/utils.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/py/reactpy/reactpy/backend/utils.py b/src/py/reactpy/reactpy/backend/utils.py index 2b1e34654..183e801f5 100644 --- a/src/py/reactpy/reactpy/backend/utils.py +++ b/src/py/reactpy/reactpy/backend/utils.py @@ -3,6 +3,7 @@ import asyncio import logging import socket +import sys from collections.abc import Iterator from contextlib import closing from importlib import import_module @@ -52,6 +53,12 @@ def find_available_port(host: str, port_min: int = 8000, port_max: int = 9000) - for port in range(port_min, port_max): with closing(socket.socket()) as sock: try: + if sys.platform == "linux": + # Fixes bug where every time you restart the server you'll + # get a different port on Linux. This cannot be set on Windows + # otherwise address will always be reused. + # Ref: https://stackoverflow.com/a/19247688/3159288 + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.bind((host, port)) except OSError: pass