reactpy.run
and configure(...)
refactoring (#1051)
- Change `reactpy.backends.utils.find_all_implementations()` to first try to import `<backend_name>` before importing `reactpy.backend.<backend_name>` - Allows for missing sub-dependencies to not cause `reactpy.run` to silently fail - Import `uvicorn` directly within `serve_with_uvicorn` in order to defer import. - Allows for `ModuleNotFound: Could not import uvicorn` exception to tell the user what went wrong - Added `CommonOptions.serve_index_route: bool` - Allows us to not clutter the route patterns when it's not needed - There are real circumstances where a user might want the index route to 404 - Fix bug where in-use ports are being assigned on Windows. - Removes `allow_reuse_waiting_ports` parameter on `find_available_port()` - Rename `BackendImplementation` to `BackendProtocol` - Change load order of `SUPPORTED_PACKAGES` so that `FastAPI` has a chance to run before `starlette` - Rename `SUPPORTED_PACKAGES` to `SUPPORTED_BACKENDS` - Refactor `reactpy.backend.*` code to be more human readable - Use f-strings where possible - Merge `if` statements where possible - Use `contextlib.supress` where possible - Remove defunct `requirements.txt` file
This commit is contained in:
parent
778057d7ab
commit
fb9c57f073
14 changed files with 198 additions and 190 deletions
|
@ -40,11 +40,15 @@ v1.0.1
|
|||
**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.BackendType``
|
||||
- :pull:`1051` - Allow ``reactpy.run`` to fail in more predictable ways
|
||||
|
||||
**Fixed**
|
||||
|
||||
- :issue:`930` - better traceback for JSON serialization errors (via :pull:`1008`)
|
||||
- :issue:`437` - explain that JS component attributes must be JSON (via :pull:`1008`)
|
||||
- :pull:`1051` - Fix ``reactpy.run`` port assignment sometimes attaching to in-use ports on Windows
|
||||
- :pull:`1051` - Fix ``reactpy.run`` not recognizing ``fastapi``
|
||||
|
||||
|
||||
v1.0.0
|
||||
|
|
|
@ -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
|
|
@ -14,53 +14,49 @@ from reactpy.core.types import VdomDict
|
|||
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"
|
||||
|
||||
try:
|
||||
|
||||
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
|
||||
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",
|
||||
)
|
||||
server.config.setup_event_loop()
|
||||
coros: list[Awaitable[Any]] = [server.serve()]
|
||||
)
|
||||
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))
|
||||
# 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)
|
||||
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:
|
||||
|
@ -72,8 +68,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("/")
|
||||
)
|
||||
|
||||
|
||||
|
@ -140,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 '/'"
|
||||
|
|
|
@ -5,13 +5,26 @@ from logging import getLogger
|
|||
from sys import exc_info
|
||||
from typing import Any, NoReturn
|
||||
|
||||
from reactpy.backend.types import BackendImplementation
|
||||
from reactpy.backend.utils import SUPPORTED_PACKAGES, all_implementations
|
||||
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: BackendType[Any] | None = None
|
||||
|
||||
|
||||
# BackendType.Options
|
||||
class Options: # nocov
|
||||
"""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:
|
||||
msg = "Default implementation has no options."
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
# BackendType.configure
|
||||
def configure(
|
||||
app: Any, component: RootComponentConstructor, options: None = None
|
||||
) -> None:
|
||||
|
@ -22,17 +35,13 @@ def configure(
|
|||
return _default_implementation().configure(app, component)
|
||||
|
||||
|
||||
# BackendType.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)
|
||||
|
||||
|
||||
# BackendType.serve_development_app
|
||||
async def serve_development_app(
|
||||
app: Any,
|
||||
host: str,
|
||||
|
@ -45,10 +54,7 @@ async def serve_development_app(
|
|||
)
|
||||
|
||||
|
||||
_DEFAULT_IMPLEMENTATION: BackendImplementation[Any] | None = None
|
||||
|
||||
|
||||
def _default_implementation() -> BackendImplementation[Any]:
|
||||
def _default_implementation() -> BackendType[Any]:
|
||||
"""Get the first available server implementation"""
|
||||
global _DEFAULT_IMPLEMENTATION # noqa: PLW0603
|
||||
|
||||
|
@ -59,7 +65,7 @@ def _default_implementation() -> BackendImplementation[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"
|
||||
|
|
|
@ -4,22 +4,22 @@ from fastapi import FastAPI
|
|||
|
||||
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`"""
|
||||
|
||||
# BackendType.Options
|
||||
Options = starlette.Options
|
||||
"""Alias for :class:`reactpy.backend.starlette.Options`"""
|
||||
|
||||
# BackendType.configure
|
||||
configure = starlette.configure
|
||||
"""Alias for :class:`reactpy.backend.starlette.configure`"""
|
||||
|
||||
|
||||
# BackendType.create_development_app
|
||||
def create_development_app() -> FastAPI:
|
||||
"""Create a development ``FastAPI`` application instance."""
|
||||
return FastAPI(debug=True)
|
||||
|
||||
|
||||
# BackendType.serve_development_app
|
||||
serve_development_app = starlette.serve_development_app
|
||||
|
||||
use_connection = starlette.use_connection
|
||||
|
||||
use_websocket = starlette.use_websocket
|
||||
|
|
|
@ -45,6 +45,19 @@ from reactpy.utils import Ref
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# BackendType.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``
|
||||
"""
|
||||
|
||||
|
||||
# BackendType.configure
|
||||
def configure(
|
||||
app: Flask, component: RootComponentConstructor, options: Options | None = None
|
||||
) -> None:
|
||||
|
@ -69,20 +82,21 @@ def configure(
|
|||
app.register_blueprint(spa_bp)
|
||||
|
||||
|
||||
# BackendType.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__)
|
||||
|
||||
|
||||
# BackendType.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"""
|
||||
"""Run a development server for FastAPI"""
|
||||
loop = asyncio.get_running_loop()
|
||||
stopped = asyncio.Event()
|
||||
|
||||
|
@ -135,17 +149,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,
|
||||
|
@ -166,10 +169,12 @@ def _setup_common_routes(
|
|||
|
||||
index_html = read_client_index_html(options)
|
||||
|
||||
@spa_blueprint.route("/")
|
||||
@spa_blueprint.route("/<path:_>")
|
||||
def send_client_dir(_: str = "") -> Any:
|
||||
return index_html
|
||||
if options.serve_index_route:
|
||||
|
||||
@spa_blueprint.route("/")
|
||||
@spa_blueprint.route("/<path:_>")
|
||||
def send_client_dir(_: str = "") -> Any:
|
||||
return index_html
|
||||
|
||||
|
||||
def _setup_single_view_dispatcher_route(
|
||||
|
|
|
@ -22,7 +22,7 @@ from reactpy.backend._common import (
|
|||
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,19 @@ from reactpy.core.types import RootComponentConstructor
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# BackendType.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``
|
||||
"""
|
||||
|
||||
|
||||
# BackendType.configure
|
||||
def configure(
|
||||
app: Sanic, component: RootComponentConstructor, options: Options | None = None
|
||||
) -> None:
|
||||
|
@ -49,14 +62,15 @@ def configure(
|
|||
app.blueprint([spa_bp, api_bp])
|
||||
|
||||
|
||||
# BackendType.create_development_app
|
||||
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())
|
||||
|
||||
|
||||
# BackendType.serve_development_app
|
||||
async def serve_development_app(
|
||||
app: Sanic,
|
||||
host: str,
|
||||
|
@ -64,7 +78,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 +100,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,
|
||||
|
@ -115,16 +118,17 @@ def _setup_common_routes(
|
|||
) -> 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,
|
||||
|
|
|
@ -21,7 +21,7 @@ from reactpy.backend._common import (
|
|||
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 @@ from reactpy.core.types import RootComponentConstructor
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# BackendType.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``
|
||||
"""
|
||||
|
||||
|
||||
# BackendType.configure
|
||||
def configure(
|
||||
app: Starlette,
|
||||
component: RootComponentConstructor,
|
||||
|
@ -54,11 +67,13 @@ def configure(
|
|||
_setup_common_routes(options, app)
|
||||
|
||||
|
||||
# BackendType.create_development_app
|
||||
def create_development_app() -> Starlette:
|
||||
"""Return a :class:`Starlette` app instance in debug mode"""
|
||||
return Starlette(debug=True)
|
||||
|
||||
|
||||
# BackendType.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
|
||||
|
@ -115,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(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]]:
|
||||
|
|
|
@ -32,10 +32,11 @@ from reactpy.core.layout import Layout
|
|||
from reactpy.core.serve import serve_layout
|
||||
from reactpy.core.types import ComponentConstructor
|
||||
|
||||
# BackendType.Options
|
||||
Options = CommonOptions
|
||||
"""Render server config for :func:`reactpy.backend.tornado.configure`"""
|
||||
|
||||
|
||||
# BackendType.configure
|
||||
def configure(
|
||||
app: Application,
|
||||
component: ComponentConstructor,
|
||||
|
@ -60,10 +61,12 @@ def configure(
|
|||
)
|
||||
|
||||
|
||||
# BackendType.create_development_app
|
||||
def create_development_app() -> Application:
|
||||
return Application(debug=True)
|
||||
|
||||
|
||||
# BackendType.serve_development_app
|
||||
async def serve_development_app(
|
||||
app: Application,
|
||||
host: str,
|
||||
|
@ -119,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(
|
||||
|
|
|
@ -11,11 +11,11 @@ _App = TypeVar("_App")
|
|||
|
||||
|
||||
@runtime_checkable
|
||||
class BackendImplementation(Protocol[_App]):
|
||||
class BackendType(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:`BackendType.configure`"""
|
||||
|
||||
def configure(
|
||||
self,
|
||||
|
|
|
@ -3,22 +3,23 @@ from __future__ import annotations
|
|||
import asyncio
|
||||
import logging
|
||||
import socket
|
||||
import sys
|
||||
from collections.abc import Iterator
|
||||
from contextlib import closing
|
||||
from importlib import import_module
|
||||
from typing import Any
|
||||
|
||||
from reactpy.backend.types import BackendImplementation
|
||||
from reactpy.backend.types import BackendType
|
||||
from reactpy.types import RootComponentConstructor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SUPPORTED_PACKAGES = (
|
||||
"starlette",
|
||||
SUPPORTED_BACKENDS = (
|
||||
"fastapi",
|
||||
"sanic",
|
||||
"tornado",
|
||||
"flask",
|
||||
"starlette",
|
||||
)
|
||||
|
||||
|
||||
|
@ -26,43 +27,37 @@ def run(
|
|||
component: RootComponentConstructor,
|
||||
host: str = "127.0.0.1",
|
||||
port: int | None = None,
|
||||
implementation: BackendImplementation[Any] | None = None,
|
||||
implementation: BackendType[Any] | None = None,
|
||||
) -> None:
|
||||
"""Run a component with a development server"""
|
||||
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}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"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))
|
||||
|
||||
|
||||
def find_available_port(
|
||||
host: str,
|
||||
port_min: int = 8000,
|
||||
port_max: int = 9000,
|
||||
allow_reuse_waiting_ports: bool = True,
|
||||
) -> 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.
|
||||
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:
|
||||
|
@ -73,26 +68,20 @@ def find_available_port(
|
|||
raise RuntimeError(msg)
|
||||
|
||||
|
||||
def all_implementations() -> Iterator[BackendImplementation[Any]]:
|
||||
def all_implementations() -> Iterator[BackendType[Any]]:
|
||||
"""Yield all available server implementations"""
|
||||
for name in SUPPORTED_PACKAGES:
|
||||
for name in SUPPORTED_BACKENDS:
|
||||
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)
|
||||
logger.debug("Failed to import %s", name, 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.<package>` where `<package>` is one of \
|
||||
{list(SUPPORTED_PACKAGES)}. For details refer to the docs on how to run each package.\
|
||||
_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.\
|
||||
"""
|
||||
|
|
|
@ -2,13 +2,13 @@ from __future__ import annotations
|
|||
|
||||
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 BackendType
|
||||
from reactpy.backend.utils import find_available_port
|
||||
from reactpy.config import REACTPY_TESTING_DEFAULT_TIMEOUT
|
||||
from reactpy.core.component import component
|
||||
|
@ -43,21 +43,20 @@ class BackendFixture:
|
|||
host: str = "127.0.0.1",
|
||||
port: int | None = None,
|
||||
app: Any | None = None,
|
||||
implementation: BackendImplementation[Any] | None = None,
|
||||
implementation: BackendType[Any] | None = None,
|
||||
options: Any | None = None,
|
||||
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
|
||||
)
|
||||
|
||||
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 @@ class 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)
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
- :mod:`reactpy.backend.types`
|
||||
"""
|
||||
|
||||
from reactpy.backend.types import BackendImplementation, 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 @@ from reactpy.core.types import (
|
|||
)
|
||||
|
||||
__all__ = [
|
||||
"BackendImplementation",
|
||||
"BackendType",
|
||||
"Component",
|
||||
"ComponentConstructor",
|
||||
"ComponentType",
|
||||
|
|
|
@ -6,7 +6,7 @@ import reactpy
|
|||
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 BackendType, Connection, Location
|
||||
from reactpy.backend.utils import all_implementations
|
||||
from reactpy.testing import BackendFixture, DisplayFixture, poll
|
||||
|
||||
|
@ -17,7 +17,7 @@ from reactpy.testing import BackendFixture, DisplayFixture, poll
|
|||
scope="module",
|
||||
)
|
||||
async def display(page, request):
|
||||
imp: BackendImplementation = 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 @@ async def test_use_request(display: DisplayFixture, hook_name):
|
|||
|
||||
|
||||
@pytest.mark.parametrize("imp", all_implementations())
|
||||
async def test_customized_head(imp: BackendImplementation, page):
|
||||
async def test_customized_head(imp: BackendType, page):
|
||||
custom_title = f"Custom Title for {imp.__name__}"
|
||||
|
||||
@reactpy.component
|
||||
|
|
Loading…
Reference in a new issue