Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit ee2d44f

Browse filesBrowse files
authored
Allow user defined routes in ReactPy() (#1265)
1 parent 6de65ef commit ee2d44f
Copy full SHA for ee2d44f

File tree

6 files changed

+311
-23
lines changed
Filter options

6 files changed

+311
-23
lines changed

‎pyproject.toml

Copy file name to clipboardExpand all lines: pyproject.toml
-12Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,6 @@ exclude_also = [
256256
]
257257

258258
[tool.ruff]
259-
target-version = "py39"
260259
line-length = 88
261260
lint.select = [
262261
"A",
@@ -328,13 +327,6 @@ lint.unfixable = [
328327
[tool.ruff.lint.isort]
329328
known-first-party = ["reactpy"]
330329

331-
[tool.ruff.lint.flake8-tidy-imports]
332-
ban-relative-imports = "all"
333-
334-
[tool.flake8]
335-
select = ["RPY"] # only need to check with reactpy-flake8
336-
exclude = ["**/node_modules/*", ".eggs/*", ".tox/*", "**/venv/*"]
337-
338330
[tool.ruff.lint.per-file-ignores]
339331
# Tests can use magic values, assertions, and relative imports
340332
"**/tests/**/*" = ["PLR2004", "S101", "TID252"]
@@ -350,7 +342,3 @@ exclude = ["**/node_modules/*", ".eggs/*", ".tox/*", "**/venv/*"]
350342
# Allow print
351343
"T201",
352344
]
353-
354-
[tool.black]
355-
target-version = ["py39"]
356-
line-length = 88

‎src/reactpy/asgi/middleware.py

Copy file name to clipboardExpand all lines: src/reactpy/asgi/middleware.py
+29-6Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,21 @@
2121
from reactpy.core.hooks import ConnectionContext
2222
from reactpy.core.layout import Layout
2323
from reactpy.core.serve import serve_layout
24-
from reactpy.types import Connection, Location, ReactPyConfig, RootComponentConstructor
24+
from reactpy.types import (
25+
AsgiApp,
26+
AsgiHttpApp,
27+
AsgiLifespanApp,
28+
AsgiWebsocketApp,
29+
Connection,
30+
Location,
31+
ReactPyConfig,
32+
RootComponentConstructor,
33+
)
2534

2635
_logger = logging.getLogger(__name__)
2736

2837

2938
class ReactPyMiddleware:
30-
_asgi_single_callable: bool = True
3139
root_component: RootComponentConstructor | None = None
3240
root_components: dict[str, RootComponentConstructor]
3341
multiple_root_components: bool = True
@@ -73,8 +81,13 @@ def __init__(
7381
self.js_modules_pattern = re.compile(f"^{self.web_modules_path}.*")
7482
self.static_pattern = re.compile(f"^{self.static_path}.*")
7583

84+
# User defined ASGI apps
85+
self.extra_http_routes: dict[str, AsgiHttpApp] = {}
86+
self.extra_ws_routes: dict[str, AsgiWebsocketApp] = {}
87+
self.extra_lifespan_app: AsgiLifespanApp | None = None
88+
7689
# Component attributes
77-
self.user_app: asgi_types.ASGI3Application = guarantee_single_callable(app) # type: ignore
90+
self.asgi_app: asgi_types.ASGI3Application = guarantee_single_callable(app) # type: ignore
7891
self.root_components = import_components(root_components)
7992

8093
# Directory attributes
@@ -106,8 +119,13 @@ async def __call__(
106119
if scope["type"] == "http" and self.match_web_modules_path(scope):
107120
return await self.web_modules_app(scope, receive, send)
108121

122+
# URL routing for user-defined routes
123+
matched_app = self.match_extra_paths(scope)
124+
if matched_app:
125+
return await matched_app(scope, receive, send) # type: ignore
126+
109127
# Serve the user's application
110-
await self.user_app(scope, receive, send)
128+
await self.asgi_app(scope, receive, send)
111129

112130
def match_dispatch_path(self, scope: asgi_types.WebSocketScope) -> bool:
113131
return bool(re.match(self.dispatcher_pattern, scope["path"]))
@@ -118,6 +136,11 @@ def match_static_path(self, scope: asgi_types.HTTPScope) -> bool:
118136
def match_web_modules_path(self, scope: asgi_types.HTTPScope) -> bool:
119137
return bool(re.match(self.js_modules_pattern, scope["path"]))
120138

139+
def match_extra_paths(self, scope: asgi_types.Scope) -> AsgiApp | None:
140+
# Custom defined routes are unused within middleware to encourage users to handle
141+
# routing within their root ASGI application.
142+
return None
143+
121144

122145
@dataclass
123146
class ComponentDispatchApp:
@@ -223,7 +246,7 @@ async def __call__(
223246
"""ASGI app for ReactPy static files."""
224247
if not self._static_file_server:
225248
self._static_file_server = ServeStaticASGI(
226-
self.parent.user_app,
249+
self.parent.asgi_app,
227250
root=self.parent.static_dir,
228251
prefix=self.parent.static_path,
229252
)
@@ -245,7 +268,7 @@ async def __call__(
245268
"""ASGI app for ReactPy web modules."""
246269
if not self._static_file_server:
247270
self._static_file_server = ServeStaticASGI(
248-
self.parent.user_app,
271+
self.parent.asgi_app,
249272
root=self.parent.web_modules_dir,
250273
prefix=self.parent.web_modules_path,
251274
autorefresh=True,

‎src/reactpy/asgi/standalone.py

Copy file name to clipboardExpand all lines: src/reactpy/asgi/standalone.py
+100-3Lines changed: 100 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,28 @@
66
from datetime import datetime, timezone
77
from email.utils import formatdate
88
from logging import getLogger
9+
from typing import Callable, Literal, cast, overload
910

1011
from asgiref import typing as asgi_types
1112
from typing_extensions import Unpack
1213

1314
from reactpy import html
1415
from reactpy.asgi.middleware import ReactPyMiddleware
15-
from reactpy.asgi.utils import dict_to_byte_list, http_response, vdom_head_to_html
16-
from reactpy.types import ReactPyConfig, RootComponentConstructor, VdomDict
16+
from reactpy.asgi.utils import (
17+
dict_to_byte_list,
18+
http_response,
19+
import_dotted_path,
20+
vdom_head_to_html,
21+
)
22+
from reactpy.types import (
23+
AsgiApp,
24+
AsgiHttpApp,
25+
AsgiLifespanApp,
26+
AsgiWebsocketApp,
27+
ReactPyConfig,
28+
RootComponentConstructor,
29+
VdomDict,
30+
)
1731
from reactpy.utils import render_mount_template
1832

1933
_logger = getLogger(__name__)
@@ -34,7 +48,7 @@ def __init__(
3448
"""ReactPy's standalone ASGI application.
3549
3650
Parameters:
37-
root_component: The root component to render. This component is assumed to be a single page application.
51+
root_component: The root component to render. This app is typically a single page application.
3852
http_headers: Additional headers to include in the HTTP response for the base HTML document.
3953
html_head: Additional head elements to include in the HTML response.
4054
html_lang: The language of the HTML document.
@@ -51,6 +65,89 @@ def match_dispatch_path(self, scope: asgi_types.WebSocketScope) -> bool:
5165
"""Method override to remove `dotted_path` from the dispatcher URL."""
5266
return str(scope["path"]) == self.dispatcher_path
5367

68+
def match_extra_paths(self, scope: asgi_types.Scope) -> AsgiApp | None:
69+
"""Method override to match user-provided HTTP/Websocket routes."""
70+
if scope["type"] == "lifespan":
71+
return self.extra_lifespan_app
72+
73+
if scope["type"] == "http":
74+
routing_dictionary = self.extra_http_routes.items()
75+
76+
if scope["type"] == "websocket":
77+
routing_dictionary = self.extra_ws_routes.items() # type: ignore
78+
79+
return next(
80+
(
81+
app
82+
for route, app in routing_dictionary
83+
if re.match(route, scope["path"])
84+
),
85+
None,
86+
)
87+
88+
@overload
89+
def route(
90+
self,
91+
path: str,
92+
type: Literal["http"] = "http",
93+
) -> Callable[[AsgiHttpApp | str], AsgiApp]: ...
94+
95+
@overload
96+
def route(
97+
self,
98+
path: str,
99+
type: Literal["websocket"],
100+
) -> Callable[[AsgiWebsocketApp | str], AsgiApp]: ...
101+
102+
def route(
103+
self,
104+
path: str,
105+
type: Literal["http", "websocket"] = "http",
106+
) -> (
107+
Callable[[AsgiHttpApp | str], AsgiApp]
108+
| Callable[[AsgiWebsocketApp | str], AsgiApp]
109+
):
110+
"""Interface that allows user to define their own HTTP/Websocket routes
111+
within the current ReactPy application.
112+
113+
Parameters:
114+
path: The URL route to match, using regex format.
115+
type: The protocol to route for. Can be 'http' or 'websocket'.
116+
"""
117+
118+
def decorator(
119+
app: AsgiApp | str,
120+
) -> AsgiApp:
121+
re_path = path
122+
if not re_path.startswith("^"):
123+
re_path = f"^{re_path}"
124+
if not re_path.endswith("$"):
125+
re_path = f"{re_path}$"
126+
127+
asgi_app: AsgiApp = import_dotted_path(app) if isinstance(app, str) else app
128+
if type == "http":
129+
self.extra_http_routes[re_path] = cast(AsgiHttpApp, asgi_app)
130+
elif type == "websocket":
131+
self.extra_ws_routes[re_path] = cast(AsgiWebsocketApp, asgi_app)
132+
133+
return asgi_app
134+
135+
return decorator
136+
137+
def lifespan(self, app: AsgiLifespanApp | str) -> None:
138+
"""Interface that allows user to define their own lifespan app
139+
within the current ReactPy application.
140+
141+
Parameters:
142+
app: The ASGI application to route to.
143+
"""
144+
if self.extra_lifespan_app:
145+
raise ValueError("Only one lifespan app can be defined.")
146+
147+
self.extra_lifespan_app = (
148+
import_dotted_path(app) if isinstance(app, str) else app
149+
)
150+
54151

55152
@dataclass
56153
class ReactPyApp:

‎src/reactpy/testing/backend.py

Copy file name to clipboardExpand all lines: src/reactpy/testing/backend.py
+1-1Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ async def __aexit__(
140140
raise LogAssertionError(msg) from logged_errors[0]
141141

142142
await asyncio.wait_for(
143-
self.webserver.shutdown(), timeout=60 if GITHUB_ACTIONS else 5
143+
self.webserver.shutdown(), timeout=90 if GITHUB_ACTIONS else 5
144144
)
145145

146146
async def restart(self) -> None:

‎src/reactpy/types.py

Copy file name to clipboardExpand all lines: src/reactpy/types.py
+72-1Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import sys
44
from collections import namedtuple
5-
from collections.abc import Mapping, Sequence
5+
from collections.abc import Awaitable, Mapping, Sequence
66
from dataclasses import dataclass
77
from pathlib import Path
88
from types import TracebackType
@@ -15,6 +15,7 @@
1515
NamedTuple,
1616
Protocol,
1717
TypeVar,
18+
Union,
1819
overload,
1920
runtime_checkable,
2021
)
@@ -296,3 +297,73 @@ class ReactPyConfig(TypedDict, total=False):
296297
async_rendering: bool
297298
debug: bool
298299
tests_default_timeout: int
300+
301+
302+
AsgiHttpReceive = Callable[
303+
[],
304+
Awaitable[asgi_types.HTTPRequestEvent | asgi_types.HTTPDisconnectEvent],
305+
]
306+
307+
AsgiHttpSend = Callable[
308+
[
309+
asgi_types.HTTPResponseStartEvent
310+
| asgi_types.HTTPResponseBodyEvent
311+
| asgi_types.HTTPResponseTrailersEvent
312+
| asgi_types.HTTPServerPushEvent
313+
| asgi_types.HTTPDisconnectEvent
314+
],
315+
Awaitable[None],
316+
]
317+
318+
AsgiWebsocketReceive = Callable[
319+
[],
320+
Awaitable[
321+
asgi_types.WebSocketConnectEvent
322+
| asgi_types.WebSocketDisconnectEvent
323+
| asgi_types.WebSocketReceiveEvent
324+
],
325+
]
326+
327+
AsgiWebsocketSend = Callable[
328+
[
329+
asgi_types.WebSocketAcceptEvent
330+
| asgi_types.WebSocketSendEvent
331+
| asgi_types.WebSocketResponseStartEvent
332+
| asgi_types.WebSocketResponseBodyEvent
333+
| asgi_types.WebSocketCloseEvent
334+
],
335+
Awaitable[None],
336+
]
337+
338+
AsgiLifespanReceive = Callable[
339+
[],
340+
Awaitable[asgi_types.LifespanStartupEvent | asgi_types.LifespanShutdownEvent],
341+
]
342+
343+
AsgiLifespanSend = Callable[
344+
[
345+
asgi_types.LifespanStartupCompleteEvent
346+
| asgi_types.LifespanStartupFailedEvent
347+
| asgi_types.LifespanShutdownCompleteEvent
348+
| asgi_types.LifespanShutdownFailedEvent
349+
],
350+
Awaitable[None],
351+
]
352+
353+
AsgiHttpApp = Callable[
354+
[asgi_types.HTTPScope, AsgiHttpReceive, AsgiHttpSend],
355+
Awaitable[None],
356+
]
357+
358+
AsgiWebsocketApp = Callable[
359+
[asgi_types.WebSocketScope, AsgiWebsocketReceive, AsgiWebsocketSend],
360+
Awaitable[None],
361+
]
362+
363+
AsgiLifespanApp = Callable[
364+
[asgi_types.LifespanScope, AsgiLifespanReceive, AsgiLifespanSend],
365+
Awaitable[None],
366+
]
367+
368+
369+
AsgiApp = Union[AsgiHttpApp, AsgiWebsocketApp, AsgiLifespanApp]

0 commit comments

Comments
0 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.