Skip to content

Commit fb9c57f

Browse files
authored
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
1 parent 778057d commit fb9c57f

File tree

14 files changed

+198
-190
lines changed

14 files changed

+198
-190
lines changed

Diff for: docs/source/about/changelog.rst

+4
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,15 @@ v1.0.1
4040
**Changed**
4141

4242
- :pull:`1050` - Warn and attempt to fix missing mime types, which can result in ``reactpy.run`` not working as expected.
43+
- :pull:`1051` - Rename ``reactpy.backend.BackendImplementation`` to ``reactpy.backend.BackendType``
44+
- :pull:`1051` - Allow ``reactpy.run`` to fail in more predictable ways
4345

4446
**Fixed**
4547

4648
- :issue:`930` - better traceback for JSON serialization errors (via :pull:`1008`)
4749
- :issue:`437` - explain that JS component attributes must be JSON (via :pull:`1008`)
50+
- :pull:`1051` - Fix ``reactpy.run`` port assignment sometimes attaching to in-use ports on Windows
51+
- :pull:`1051` - Fix ``reactpy.run`` not recognizing ``fastapi``
4852

4953

5054
v1.0.0

Diff for: requirements.txt

-9
This file was deleted.

Diff for: src/py/reactpy/reactpy/backend/_common.py

+35-37
Original file line numberDiff line numberDiff line change
@@ -14,53 +14,49 @@
1414
from reactpy.utils import vdom_to_html
1515

1616
if TYPE_CHECKING:
17+
import uvicorn
1718
from asgiref.typing import ASGIApplication
1819

1920
PATH_PREFIX = PurePosixPath("/_reactpy")
2021
MODULES_PATH = PATH_PREFIX / "modules"
2122
ASSETS_PATH = PATH_PREFIX / "assets"
2223
STREAM_PATH = PATH_PREFIX / "stream"
23-
2424
CLIENT_BUILD_DIR = Path(_reactpy_file_path).parent / "_static" / "app" / "dist"
2525

26-
try:
26+
27+
async def serve_with_uvicorn(
28+
app: ASGIApplication | Any,
29+
host: str,
30+
port: int,
31+
started: asyncio.Event | None,
32+
) -> None:
33+
"""Run a development server for an ASGI application"""
2734
import uvicorn
28-
except ImportError: # nocov
29-
pass
30-
else:
31-
32-
async def serve_development_asgi(
33-
app: ASGIApplication | Any,
34-
host: str,
35-
port: int,
36-
started: asyncio.Event | None,
37-
) -> None:
38-
"""Run a development server for an ASGI application"""
39-
server = uvicorn.Server(
40-
uvicorn.Config(
41-
app,
42-
host=host,
43-
port=port,
44-
loop="asyncio",
45-
reload=True,
46-
)
35+
36+
server = uvicorn.Server(
37+
uvicorn.Config(
38+
app,
39+
host=host,
40+
port=port,
41+
loop="asyncio",
4742
)
48-
server.config.setup_event_loop()
49-
coros: list[Awaitable[Any]] = [server.serve()]
43+
)
44+
server.config.setup_event_loop()
45+
coros: list[Awaitable[Any]] = [server.serve()]
5046

51-
# If a started event is provided, then use it signal based on `server.started`
52-
if started:
53-
coros.append(_check_if_started(server, started))
47+
# If a started event is provided, then use it signal based on `server.started`
48+
if started:
49+
coros.append(_check_if_started(server, started))
5450

55-
try:
56-
await asyncio.gather(*coros)
57-
finally:
58-
# Since we aren't using the uvicorn's `run()` API, we can't guarantee uvicorn's
59-
# order of operations. So we need to make sure `shutdown()` always has an initialized
60-
# list of `self.servers` to use.
61-
if not hasattr(server, "servers"): # nocov
62-
server.servers = []
63-
await asyncio.wait_for(server.shutdown(), timeout=3)
51+
try:
52+
await asyncio.gather(*coros)
53+
finally:
54+
# Since we aren't using the uvicorn's `run()` API, we can't guarantee uvicorn's
55+
# order of operations. So we need to make sure `shutdown()` always has an initialized
56+
# list of `self.servers` to use.
57+
if not hasattr(server, "servers"): # nocov
58+
server.servers = []
59+
await asyncio.wait_for(server.shutdown(), timeout=3)
6460

6561

6662
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
7268
def safe_client_build_dir_path(path: str) -> Path:
7369
"""Prevent path traversal out of :data:`CLIENT_BUILD_DIR`"""
7470
return traversal_safe_path(
75-
CLIENT_BUILD_DIR,
76-
*("index.html" if path in ("", "/") else path).split("/"),
71+
CLIENT_BUILD_DIR, *("index.html" if path in {"", "/"} else path).split("/")
7772
)
7873

7974

@@ -140,6 +135,9 @@ class CommonOptions:
140135
url_prefix: str = ""
141136
"""The URL prefix where ReactPy resources will be served from"""
142137

138+
serve_index_route: bool = True
139+
"""Automatically generate and serve the index route (``/``)"""
140+
143141
def __post_init__(self) -> None:
144142
if self.url_prefix and not self.url_prefix.startswith("/"):
145143
msg = "Expected 'url_prefix' to start with '/'"

Diff for: src/py/reactpy/reactpy/backend/default.py

+19-13
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,26 @@
55
from sys import exc_info
66
from typing import Any, NoReturn
77

8-
from reactpy.backend.types import BackendImplementation
9-
from reactpy.backend.utils import SUPPORTED_PACKAGES, all_implementations
8+
from reactpy.backend.types import BackendType
9+
from reactpy.backend.utils import SUPPORTED_BACKENDS, all_implementations
1010
from reactpy.types import RootComponentConstructor
1111

1212
logger = getLogger(__name__)
13+
_DEFAULT_IMPLEMENTATION: BackendType[Any] | None = None
1314

1415

16+
# BackendType.Options
17+
class Options: # nocov
18+
"""Configuration options that can be provided to the backend.
19+
This definition should not be used/instantiated. It exists only for
20+
type hinting purposes."""
21+
22+
def __init__(self, *args: Any, **kwds: Any) -> NoReturn:
23+
msg = "Default implementation has no options."
24+
raise ValueError(msg)
25+
26+
27+
# BackendType.configure
1528
def configure(
1629
app: Any, component: RootComponentConstructor, options: None = None
1730
) -> None:
@@ -22,17 +35,13 @@ def configure(
2235
return _default_implementation().configure(app, component)
2336

2437

38+
# BackendType.create_development_app
2539
def create_development_app() -> Any:
2640
"""Create an application instance for development purposes"""
2741
return _default_implementation().create_development_app()
2842

2943

30-
def Options(*args: Any, **kwargs: Any) -> NoReturn: # nocov
31-
"""Create configuration options"""
32-
msg = "Default implementation has no options."
33-
raise ValueError(msg)
34-
35-
44+
# BackendType.serve_development_app
3645
async def serve_development_app(
3746
app: Any,
3847
host: str,
@@ -45,10 +54,7 @@ async def serve_development_app(
4554
)
4655

4756

48-
_DEFAULT_IMPLEMENTATION: BackendImplementation[Any] | None = None
49-
50-
51-
def _default_implementation() -> BackendImplementation[Any]:
57+
def _default_implementation() -> BackendType[Any]:
5258
"""Get the first available server implementation"""
5359
global _DEFAULT_IMPLEMENTATION # noqa: PLW0603
5460

@@ -59,7 +65,7 @@ def _default_implementation() -> BackendImplementation[Any]:
5965
implementation = next(all_implementations())
6066
except StopIteration: # nocov
6167
logger.debug("Backend implementation import failed", exc_info=exc_info())
62-
supported_backends = ", ".join(SUPPORTED_PACKAGES)
68+
supported_backends = ", ".join(SUPPORTED_BACKENDS)
6369
msg = (
6470
"It seems you haven't installed a backend. To resolve this issue, "
6571
"you can install a backend by running:\n\n"

Diff for: src/py/reactpy/reactpy/backend/fastapi.py

+11-11
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,22 @@
44

55
from reactpy.backend import starlette
66

7-
serve_development_app = starlette.serve_development_app
8-
"""Alias for :func:`reactpy.backend.starlette.serve_development_app`"""
9-
10-
use_connection = starlette.use_connection
11-
"""Alias for :func:`reactpy.backend.starlette.use_location`"""
12-
13-
use_websocket = starlette.use_websocket
14-
"""Alias for :func:`reactpy.backend.starlette.use_websocket`"""
15-
7+
# BackendType.Options
168
Options = starlette.Options
17-
"""Alias for :class:`reactpy.backend.starlette.Options`"""
189

10+
# BackendType.configure
1911
configure = starlette.configure
20-
"""Alias for :class:`reactpy.backend.starlette.configure`"""
2112

2213

14+
# BackendType.create_development_app
2315
def create_development_app() -> FastAPI:
2416
"""Create a development ``FastAPI`` application instance."""
2517
return FastAPI(debug=True)
18+
19+
20+
# BackendType.serve_development_app
21+
serve_development_app = starlette.serve_development_app
22+
23+
use_connection = starlette.use_connection
24+
25+
use_websocket = starlette.use_websocket

Diff for: src/py/reactpy/reactpy/backend/flask.py

+23-18
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,19 @@
4545
logger = logging.getLogger(__name__)
4646

4747

48+
# BackendType.Options
49+
@dataclass
50+
class Options(CommonOptions):
51+
"""Render server config for :func:`reactpy.backend.flask.configure`"""
52+
53+
cors: bool | dict[str, Any] = False
54+
"""Enable or configure Cross Origin Resource Sharing (CORS)
55+
56+
For more information see docs for ``flask_cors.CORS``
57+
"""
58+
59+
60+
# BackendType.configure
4861
def configure(
4962
app: Flask, component: RootComponentConstructor, options: Options | None = None
5063
) -> None:
@@ -69,20 +82,21 @@ def configure(
6982
app.register_blueprint(spa_bp)
7083

7184

85+
# BackendType.create_development_app
7286
def create_development_app() -> Flask:
7387
"""Create an application instance for development purposes"""
7488
os.environ["FLASK_DEBUG"] = "true"
75-
app = Flask(__name__)
76-
return app
89+
return Flask(__name__)
7790

7891

92+
# BackendType.serve_development_app
7993
async def serve_development_app(
8094
app: Flask,
8195
host: str,
8296
port: int,
8397
started: asyncio.Event | None = None,
8498
) -> None:
85-
"""Run an application using a development server"""
99+
"""Run a development server for FastAPI"""
86100
loop = asyncio.get_running_loop()
87101
stopped = asyncio.Event()
88102

@@ -135,17 +149,6 @@ def use_connection() -> Connection[_FlaskCarrier]:
135149
return conn
136150

137151

138-
@dataclass
139-
class Options(CommonOptions):
140-
"""Render server config for :func:`reactpy.backend.flask.configure`"""
141-
142-
cors: bool | dict[str, Any] = False
143-
"""Enable or configure Cross Origin Resource Sharing (CORS)
144-
145-
For more information see docs for ``flask_cors.CORS``
146-
"""
147-
148-
149152
def _setup_common_routes(
150153
api_blueprint: Blueprint,
151154
spa_blueprint: Blueprint,
@@ -166,10 +169,12 @@ def send_modules_dir(path: str = "") -> Any:
166169

167170
index_html = read_client_index_html(options)
168171

169-
@spa_blueprint.route("/")
170-
@spa_blueprint.route("/<path:_>")
171-
def send_client_dir(_: str = "") -> Any:
172-
return index_html
172+
if options.serve_index_route:
173+
174+
@spa_blueprint.route("/")
175+
@spa_blueprint.route("/<path:_>")
176+
def send_client_dir(_: str = "") -> Any:
177+
return index_html
173178

174179

175180
def _setup_single_view_dispatcher_route(

0 commit comments

Comments
 (0)