Skip to content

Commit 4d539fc

Browse files
committed
refactoring
1 parent b2a429f commit 4d539fc

File tree

5 files changed

+142
-144
lines changed

5 files changed

+142
-144
lines changed

src/reactpy/__init__.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
from reactpy import backend, config, html, logging, sample, svg, types, web, widgets
2-
from reactpy.backend.utils import run
32
from reactpy.core import hooks
43
from reactpy.core.component import component
54
from reactpy.core.events import event
@@ -36,7 +35,6 @@
3635
"html",
3736
"html_to_vdom",
3837
"logging",
39-
"run",
4038
"sample",
4139
"svg",
4240
"types",

src/reactpy/backend/middleware.py

Lines changed: 109 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
from __future__ import annotations
2+
13
import asyncio
24
import logging
35
import re
46
import traceback
57
import urllib.parse
68
from collections.abc import Coroutine, Iterable
7-
from concurrent.futures import Future
9+
from dataclasses import dataclass
810
from pathlib import Path
911
from typing import Any, Callable
1012

@@ -18,17 +20,15 @@
1820
from reactpy.core.hooks import ConnectionContext
1921
from reactpy.core.layout import Layout
2022
from reactpy.core.serve import serve_layout
21-
from reactpy.core.types import ComponentType
23+
from reactpy.types import RootComponentConstructor
2224

2325
_logger = logging.getLogger(__name__)
2426

2527

2628
class ReactPyMiddleware:
2729
_asgi_single_callable = True
28-
servestatic_static: ServeStaticASGI | None = None
29-
servestatic_web_modules: ServeStaticASGI | None = None
3030
single_root_component: bool = False
31-
root_component: ComponentType | None = None
31+
root_component: RootComponentConstructor | None = None
3232

3333
def __init__(
3434
self,
@@ -40,7 +40,7 @@ def __init__(
4040
web_modules_dir: Path | None = None,
4141
) -> None:
4242
"""Configure the ASGI app. Anything initialized in this method will be shared across all future requests."""
43-
# Configure class attributes
43+
# URL path attributes
4444
self.path_prefix = normalize_url_path(path_prefix)
4545
self.dispatcher_path = f"/{self.path_prefix}/"
4646
self.web_modules_path = f"/{self.path_prefix}/modules/"
@@ -50,19 +50,27 @@ def __init__(
5050
)
5151
self.js_modules_pattern = re.compile(f"^{self.web_modules_path}.*")
5252
self.static_pattern = re.compile(f"^{self.static_path}.*")
53-
self.web_modules_dir = web_modules_dir or REACTPY_WEB_MODULES_DIR.current
54-
self.static_dir = Path(__file__).parent.parent / "static"
53+
54+
# Component attributes
5555
self.user_app = guarantee_single_callable(app)
5656
self.component_dotted_paths = set(root_components)
57-
self.components: dict[str, ComponentType] = import_components(
57+
self.components: dict[str, RootComponentConstructor] = import_components(
5858
self.component_dotted_paths
5959
)
6060

61+
# Directory attributes
62+
self.web_modules_dir = web_modules_dir or REACTPY_WEB_MODULES_DIR.current
63+
self.static_dir = Path(__file__).parent.parent / "static"
6164
if self.web_modules_dir != REACTPY_WEB_MODULES_DIR.current:
6265
REACTPY_WEB_MODULES_DIR.set_current(self.web_modules_dir)
6366

64-
# Validate the arguments
65-
reason = check_path(self.path_prefix)
67+
# Sub-applications
68+
self.component_dispatch_app = ComponentDispatchApp(parent=self)
69+
self.static_file_app = StaticFileApp(parent=self)
70+
self.web_modules_app = WebModuleApp(parent=self)
71+
72+
# Validate the configuration
73+
reason = check_path(path_prefix)
6674
if reason:
6775
raise ValueError(f"Invalid `path_prefix`. {reason}")
6876

@@ -78,25 +86,39 @@ async def __call__(
7886
if scope["type"] == "websocket" and self.match_dispatch_path(scope):
7987
return await self.component_dispatch_app(scope, receive, send)
8088

81-
# URL routing for ReactPy web modules
82-
if scope["type"] == "http" and re.match(self.js_modules_pattern, scope["path"]):
83-
return await self.web_module_app(scope, receive, send)
84-
8589
# URL routing for ReactPy static files
86-
if scope["type"] == "http" and re.match(self.static_pattern, scope["path"]):
90+
if scope["type"] == "http" and self.match_static_path(scope):
8791
return await self.static_file_app(scope, receive, send)
8892

93+
# URL routing for ReactPy web modules
94+
if scope["type"] == "http" and self.match_web_modules_path(scope):
95+
return await self.web_modules_app(scope, receive, send)
96+
8997
# Serve the user's application
9098
await self.user_app(scope, receive, send)
9199

92-
async def component_dispatch_app(
100+
def match_dispatch_path(self, scope: dict) -> bool:
101+
return bool(re.match(self.dispatcher_pattern, scope["path"]))
102+
103+
def match_static_path(self, scope: dict) -> bool:
104+
return bool(re.match(self.static_pattern, scope["path"]))
105+
106+
def match_web_modules_path(self, scope: dict) -> bool:
107+
return bool(re.match(self.js_modules_pattern, scope["path"]))
108+
109+
110+
@dataclass
111+
class ComponentDispatchApp:
112+
parent: ReactPyMiddleware
113+
114+
async def __call__(
93115
self,
94116
scope: dict[str, Any],
95117
receive: Callable[..., Coroutine],
96118
send: Callable[..., Coroutine],
97119
) -> None:
98120
"""ASGI app for rendering ReactPy Python components."""
99-
dispatcher: Future | asyncio.Task | None = None
121+
dispatcher: asyncio.Task | None = None
100122
recv_queue: asyncio.Queue = asyncio.Queue()
101123

102124
# Start a loop that handles ASGI websocket events
@@ -117,52 +139,6 @@ async def component_dispatch_app(
117139
queue_put_func = recv_queue.put(orjson.loads(event["text"]))
118140
await queue_put_func
119141

120-
async def web_module_app(
121-
self,
122-
scope: dict[str, Any],
123-
receive: Callable[..., Coroutine],
124-
send: Callable[..., Coroutine],
125-
) -> None:
126-
"""ASGI app for ReactPy web modules."""
127-
if not self.web_modules_dir:
128-
await asyncio.to_thread(
129-
_logger.info,
130-
"Tried to serve web module without a configured directory.",
131-
)
132-
return await self.user_app(scope, receive, send)
133-
134-
if not self.servestatic_web_modules:
135-
self.servestatic_web_modules = ServeStaticASGI(
136-
self.user_app,
137-
root=self.web_modules_dir,
138-
prefix=self.web_modules_path,
139-
autorefresh=True,
140-
)
141-
142-
return await self.servestatic_web_modules(scope, receive, send)
143-
144-
async def static_file_app(
145-
self,
146-
scope: dict[str, Any],
147-
receive: Callable[..., Coroutine],
148-
send: Callable[..., Coroutine],
149-
) -> None:
150-
"""ASGI app for ReactPy static files."""
151-
# If no static directory is configured, serve the user's application
152-
if not self.static_dir:
153-
await asyncio.to_thread(
154-
_logger.info,
155-
"Tried to serve static file without a configured directory.",
156-
)
157-
return await self.user_app(scope, receive, send)
158-
159-
if not self.servestatic_static:
160-
self.servestatic_static = ServeStaticASGI(
161-
self.user_app, root=self.static_dir, prefix=self.static_path
162-
)
163-
164-
return await self.servestatic_static(scope, receive, send)
165-
166142
async def run_dispatcher(
167143
self,
168144
scope: dict[str, Any],
@@ -172,20 +148,23 @@ async def run_dispatcher(
172148
) -> None:
173149
# Get the component from the URL.
174150
try:
175-
if not self.single_root_component:
176-
url_match = re.match(self.dispatcher_pattern, scope["path"])
151+
if not self.parent.single_root_component:
152+
url_match = re.match(self.parent.dispatcher_pattern, scope["path"])
177153
if not url_match:
178154
raise RuntimeError("Could not find component in URL path.")
179155
dotted_path = url_match[1]
180-
component = self.components[dotted_path]
156+
component = self.parent.components[dotted_path]
157+
elif self.parent.root_component:
158+
component = self.parent.root_component
181159
else:
182-
component = self.root_component
160+
raise RuntimeError("No root component provided.")
161+
183162
parsed_url = urllib.parse.urlparse(scope["path"])
184163

185164
await serve_layout(
186165
Layout( # type: ignore
187166
ConnectionContext(
188-
component,
167+
component(),
189168
value=Connection(
190169
scope=scope,
191170
location=Location(
@@ -200,24 +179,77 @@ async def run_dispatcher(
200179
),
201180
)
202181
),
203-
self.send_json_ws(send),
182+
self.send_json(send),
204183
recv_queue.get,
205184
)
206185
except Exception as error:
207186
await asyncio.to_thread(_logger.error, f"{error}\n{traceback.format_exc()}")
208187

209188
@staticmethod
210-
def send_json_ws(send: Callable) -> Callable[..., Coroutine]:
189+
def send_json(send: Callable) -> Callable[..., Coroutine]:
211190
"""Use orjson to send JSON over an ASGI websocket."""
212191

213192
async def _send_json(value: Any) -> None:
214193
await send({"type": "websocket.send", "text": orjson.dumps(value).decode()})
215194

216195
return _send_json
217196

218-
def match_dispatch_path(self, scope: dict) -> bool:
219-
match = re.match(self.dispatcher_pattern, scope["path"])
220-
return bool(
221-
match
222-
and match.groupdict().get("dotted_path") in self.component_dotted_paths
223-
)
197+
198+
@dataclass
199+
class StaticFileApp:
200+
parent: ReactPyMiddleware
201+
_static_file_server: ServeStaticASGI | None = None
202+
203+
async def __call__(
204+
self,
205+
scope: dict[str, Any],
206+
receive: Callable[..., Coroutine],
207+
send: Callable[..., Coroutine],
208+
) -> None:
209+
"""ASGI app for ReactPy static files."""
210+
# If no static directory is configured, serve the user's application
211+
if not self.parent.static_dir:
212+
await asyncio.to_thread(
213+
_logger.info,
214+
"Tried to serve static file without a configured directory.",
215+
)
216+
return await self.parent.user_app(scope, receive, send)
217+
218+
if not self._static_file_server:
219+
self._static_file_server = ServeStaticASGI(
220+
self.parent.user_app,
221+
root=self.parent.static_dir,
222+
prefix=self.parent.static_path,
223+
)
224+
225+
return await self._static_file_server(scope, receive, send)
226+
227+
228+
@dataclass
229+
class WebModuleApp:
230+
parent: ReactPyMiddleware
231+
_static_file_server: ServeStaticASGI | None = None
232+
233+
async def __call__(
234+
self,
235+
scope: dict[str, Any],
236+
receive: Callable[..., Coroutine],
237+
send: Callable[..., Coroutine],
238+
) -> None:
239+
"""ASGI app for ReactPy web modules."""
240+
if not self.parent.web_modules_dir:
241+
await asyncio.to_thread(
242+
_logger.info,
243+
"Tried to serve web module without a configured directory.",
244+
)
245+
return await self.parent.user_app(scope, receive, send)
246+
247+
if not self._static_file_server:
248+
self._static_file_server = ServeStaticASGI(
249+
self.parent.user_app,
250+
root=self.parent.web_modules_dir,
251+
prefix=self.parent.web_modules_path,
252+
autorefresh=True,
253+
)
254+
255+
return await self._static_file_server(scope, receive, send)

src/reactpy/backend/standalone.py

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,40 +7,46 @@
77
from pathlib import Path
88
from typing import Any, Callable
99

10+
from reactpy import html
1011
from reactpy.backend.middleware import ReactPyMiddleware
11-
from reactpy.backend.utils import dict_to_byte_list, find_and_replace
12-
from reactpy.core.types import ComponentType
12+
from reactpy.backend.utils import dict_to_byte_list, find_and_replace, vdom_head_to_html
13+
from reactpy.core.types import VdomDict
14+
from reactpy.types import RootComponentConstructor
1315

1416
_logger = getLogger(__name__)
1517

1618

17-
class ReactPyStandalone(ReactPyMiddleware):
18-
cached_index_html: str = ""
19-
etag: str = ""
20-
last_modified: str = ""
19+
class ReactPy(ReactPyMiddleware):
20+
cached_index_html = ""
21+
etag = ""
22+
last_modified = ""
2123
templates_dir = Path(__file__).parent.parent / "templates"
2224
index_html_path = templates_dir / "index.html"
23-
single_root_component: bool = True
25+
single_root_component = True
2426

2527
def __init__(
2628
self,
27-
root_component: ComponentType,
29+
root_component: RootComponentConstructor,
2830
*,
2931
path_prefix: str = "reactpy/",
3032
web_modules_dir: Path | None = None,
3133
http_headers: dict[str, str | int] | None = None,
34+
html_head: VdomDict | None = None,
35+
html_lang: str = "en",
3236
) -> None:
3337
super().__init__(
34-
app=self.standalone_app,
38+
app=self.reactpy_app,
3539
root_components=[],
3640
path_prefix=path_prefix,
3741
web_modules_dir=web_modules_dir,
3842
)
3943
self.root_component = root_component
4044
self.extra_headers = http_headers or {}
4145
self.dispatcher_pattern = re.compile(f"^{self.dispatcher_path}?")
46+
self.html_head = html_head or html.head()
47+
self.html_lang = html_lang
4248

43-
async def standalone_app(
49+
async def reactpy_app(
4450
self,
4551
scope: dict[str, Any],
4652
receive: Callable[..., Coroutine],
@@ -119,6 +125,8 @@ async def process_index_html(self):
119125
cached_index_html,
120126
{
121127
'from "index.ts"': f'from "{self.static_path}index.js"',
128+
'<html lang="en">': f'<html lang="{self.html_lang}">',
129+
"<head></head>": vdom_head_to_html(self.html_head),
122130
"{path_prefix}": self.path_prefix,
123131
"{reconnect_interval}": "750",
124132
"{reconnect_max_interval}": "60000",
@@ -131,9 +139,9 @@ async def process_index_html(self):
131139
self.cached_index_html.encode(), usedforsecurity=False
132140
).hexdigest()
133141
self.etag = f'"{self.etag}"'
134-
135-
last_modified = os.stat(self.index_html_path).st_mtime
136-
self.last_modified = formatdate(last_modified, usegmt=True)
142+
self.last_modified = formatdate(
143+
os.stat(self.index_html_path).st_mtime, usegmt=True
144+
)
137145

138146

139147
async def http_response(

0 commit comments

Comments
 (0)