Skip to content

Commit 130bc00

Browse files
committed
Implement ReactPyConfig(TypedDict)
1 parent dbdfa9b commit 130bc00

File tree

24 files changed

+180
-135
lines changed

24 files changed

+180
-135
lines changed

docs/source/about/changelog.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ Unreleased
2929
- :pull:`1256` - Change ``set_state`` comparison method to check equality with ``==`` more consistently.
3030
- :pull:`1257` - Add support for rendering ``@component`` children within ``vdom_to_html``.
3131
- :pull:`1113` - Renamed the ``use_location`` hook's ``search`` attribute to ``query_string``.
32+
- :pull:`1113` - Renamed ``reatpy.config.REACTPY_DEBUG_MODE`` to ``reactpy.config.REACTPY_DEBUG``.
3233

3334
**Removed**
3435

pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,6 @@ commands = [
7474
artifacts = []
7575

7676
[project.optional-dependencies]
77-
# TODO: Nuke backends from the optional deps
7877
all = ["reactpy[jinja,uvicorn,testing]"]
7978
jinja = ["jinja2-simple-tags", "jinja2 >=3"]
8079
uvicorn = ["uvicorn[standard]"]

src/reactpy/asgi/middleware.py

Lines changed: 41 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,14 @@
1313
import orjson
1414
from asgiref.compatibility import guarantee_single_callable
1515
from servestatic import ServeStaticASGI
16+
from typing_extensions import Unpack
1617

17-
from reactpy.asgi.utils import check_path, import_components
18-
from reactpy.config import REACTPY_WEB_MODULES_DIR
18+
from reactpy import config
19+
from reactpy.asgi.utils import check_path, import_components, process_settings
1920
from reactpy.core.hooks import ConnectionContext
2021
from reactpy.core.layout import Layout
2122
from reactpy.core.serve import serve_layout
22-
from reactpy.types import Connection, Location, RootComponentConstructor
23+
from reactpy.types import Connection, Location, ReactPyConfig, RootComponentConstructor
2324

2425
_logger = logging.getLogger(__name__)
2526

@@ -34,14 +35,15 @@ def __init__(
3435
self,
3536
app: Callable[..., Coroutine],
3637
root_components: Iterable[str],
37-
*,
38-
# TODO: Add a setting attribute to this class. Or maybe just put a shit ton of kwargs here. Or add a **kwargs that resolves to a TypedDict?
39-
path_prefix: str = "/reactpy/",
40-
web_modules_dir: Path | None = None,
38+
**settings: Unpack[ReactPyConfig],
4139
) -> None:
42-
"""Configure the ASGI app. Anything initialized in this method will be shared across all future requests."""
40+
"""Configure the ASGI app. Anything initialized in this method will be shared across all future requests.
41+
TODO: Add types in docstring"""
42+
# Process global settings
43+
process_settings(settings)
44+
4345
# URL path attributes
44-
self.path_prefix = path_prefix
46+
self.path_prefix = config.REACTPY_PATH_PREFIX.current
4547
self.dispatcher_path = self.path_prefix
4648
self.web_modules_path = f"{self.path_prefix}modules/"
4749
self.static_path = f"{self.path_prefix}static/"
@@ -56,20 +58,22 @@ def __init__(
5658
self.root_components = import_components(root_components)
5759

5860
# Directory attributes
59-
self.web_modules_dir = web_modules_dir or REACTPY_WEB_MODULES_DIR.current
61+
self.web_modules_dir = config.REACTPY_WEB_MODULES_DIR.current
6062
self.static_dir = Path(__file__).parent.parent / "static"
61-
if self.web_modules_dir != REACTPY_WEB_MODULES_DIR.current:
62-
REACTPY_WEB_MODULES_DIR.set_current(self.web_modules_dir)
6363

6464
# Sub-applications
6565
self.component_dispatch_app = ComponentDispatchApp(parent=self)
6666
self.static_file_app = StaticFileApp(parent=self)
6767
self.web_modules_app = WebModuleApp(parent=self)
6868

6969
# Validate the configuration
70-
reason = check_path(path_prefix)
70+
reason = check_path(self.path_prefix)
7171
if reason:
7272
raise ValueError(f"Invalid `path_prefix`. {reason}")
73+
if not self.web_modules_dir.exists():
74+
raise ValueError(
75+
f"Web modules directory {self.web_modules_dir} does not exist."
76+
)
7377

7478
async def __call__(
7579
self,
@@ -127,12 +131,12 @@ async def __call__(
127131
self.run_dispatcher(scope, receive, send, recv_queue)
128132
)
129133

130-
if event["type"] == "websocket.disconnect":
134+
elif event["type"] == "websocket.disconnect":
131135
if dispatcher:
132136
dispatcher.cancel()
133137
break
134138

135-
if event["type"] == "websocket.receive":
139+
elif event["type"] == "websocket.receive":
136140
queue_put_func = recv_queue.put(orjson.loads(event["text"]))
137141
await queue_put_func
138142

@@ -143,8 +147,9 @@ async def run_dispatcher(
143147
send: Callable[..., Coroutine],
144148
recv_queue: asyncio.Queue,
145149
) -> None:
146-
# Get the component from the URL.
150+
"""Asyncio background task that renders and transmits layout updates of ReactPy components."""
147151
try:
152+
# Determine component to serve by analyzing the URL and/or class parameters.
148153
if self.parent.multiple_root_components:
149154
url_match = re.match(self.parent.dispatcher_pattern, scope["path"])
150155
if not url_match:
@@ -160,42 +165,38 @@ async def run_dispatcher(
160165
else:
161166
raise RuntimeError("No root component provided.")
162167

163-
# TODO: Get HTTP URL from `http_pathname` and `http_query_string`
164-
parsed_url = urllib.parse.urlparse(scope["path"])
165-
pathname = parsed_url.path
166-
query_string = f"?{parsed_url.query}" if parsed_url.query else ""
168+
# Create a connection object by analyzing the websocket's query string.
169+
ws_query_string = urllib.parse.parse_qs(
170+
scope["query_string"].decode(), strict_parsing=True
171+
)
172+
connection = Connection(
173+
scope=scope,
174+
location=Location(
175+
pathname=ws_query_string.get("http_pathname", [""])[0],
176+
query_string=ws_query_string.get("http_search", [""])[0],
177+
),
178+
carrier=self,
179+
)
167180

181+
# Start the ReactPy component rendering loop
168182
await serve_layout(
169-
Layout( # type: ignore
170-
ConnectionContext(
171-
component(),
172-
value=Connection(
173-
scope=scope,
174-
location=Location(
175-
pathname=pathname, query_string=query_string
176-
),
177-
carrier={
178-
"scope": scope,
179-
"send": send,
180-
"receive": receive,
181-
},
182-
),
183-
)
184-
),
185-
self.send_json(send),
183+
Layout(ConnectionContext(component(), value=connection)), # type: ignore
184+
self._send_json(send),
186185
recv_queue.get,
187186
)
187+
188+
# Manually log exceptions since this function is running in a separate asyncio task.
188189
except Exception as error:
189190
await asyncio.to_thread(_logger.error, f"{error}\n{traceback.format_exc()}")
190191

191192
@staticmethod
192-
def send_json(send: Callable) -> Callable[..., Coroutine]:
193+
def _send_json(send: Callable) -> Callable[..., Coroutine]:
193194
"""Use orjson to send JSON over an ASGI websocket."""
194195

195-
async def _send_json(value: Any) -> None:
196+
async def _send(value: Any) -> None:
196197
await send({"type": "websocket.send", "text": orjson.dumps(value).decode()})
197198

198-
return _send_json
199+
return _send
199200

200201

201202
@dataclass
@@ -210,14 +211,6 @@ async def __call__(
210211
send: Callable[..., Coroutine],
211212
) -> None:
212213
"""ASGI app for ReactPy static files."""
213-
# If no static directory is configured, serve the user's application
214-
if not self.parent.static_dir:
215-
await asyncio.to_thread(
216-
_logger.info,
217-
"Tried to serve static file without a configured directory.",
218-
)
219-
return await self.parent.user_app(scope, receive, send)
220-
221214
if not self._static_file_server:
222215
self._static_file_server = ServeStaticASGI(
223216
self.parent.user_app,
@@ -240,13 +233,6 @@ async def __call__(
240233
send: Callable[..., Coroutine],
241234
) -> None:
242235
"""ASGI app for ReactPy web modules."""
243-
if not self.parent.web_modules_dir:
244-
await asyncio.to_thread(
245-
_logger.info,
246-
"Tried to serve web module without a configured directory.",
247-
)
248-
return await self.parent.user_app(scope, receive, send)
249-
250236
if not self._static_file_server:
251237
self._static_file_server = ServeStaticASGI(
252238
self.parent.user_app,

src/reactpy/asgi/standalone.py

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
from pathlib import Path
99
from typing import Any, Callable
1010

11+
from typing_extensions import Unpack
12+
1113
from reactpy import html
1214
from reactpy.asgi.middleware import ReactPyMiddleware
1315
from reactpy.asgi.utils import (
@@ -16,8 +18,7 @@
1618
replace_many,
1719
vdom_head_to_html,
1820
)
19-
from reactpy.core.types import VdomDict
20-
from reactpy.types import RootComponentConstructor
21+
from reactpy.types import ReactPyConfig, RootComponentConstructor, VdomDict
2122

2223
_logger = getLogger(__name__)
2324

@@ -29,18 +30,13 @@ def __init__(
2930
self,
3031
root_component: RootComponentConstructor,
3132
*,
32-
path_prefix: str = "/reactpy/",
33-
web_modules_dir: Path | None = None,
3433
http_headers: dict[str, str | int] | None = None,
3534
html_head: VdomDict | None = None,
3635
html_lang: str = "en",
36+
**settings: Unpack[ReactPyConfig],
3737
) -> None:
38-
super().__init__(
39-
app=ReactPyApp(self),
40-
root_components=[],
41-
path_prefix=path_prefix,
42-
web_modules_dir=web_modules_dir,
43-
)
38+
"""TODO: Add docstring"""
39+
super().__init__(app=ReactPyApp(self), root_components=[], **settings)
4440
self.root_component = root_component
4541
self.extra_headers = http_headers or {}
4642
self.dispatcher_pattern = re.compile(f"^{self.dispatcher_path}?")
@@ -123,7 +119,7 @@ def match_dispatch_path(self, scope: dict) -> bool:
123119
"""Method override to remove `dotted_path` from the dispatcher URL."""
124120
return str(scope["path"]) == self.parent.dispatcher_path
125121

126-
def process_index_html(self):
122+
def process_index_html(self) -> None:
127123
"""Process the index.html and store the results in memory."""
128124
with open(self._index_html_path, encoding="utf-8") as file_handle:
129125
cached_index_html = file_handle.read()

src/reactpy/asgi/utils.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
from importlib import import_module
66
from typing import Any, Callable
77

8-
from reactpy.core.types import VdomDict
8+
from reactpy._option import Option
9+
from reactpy.types import ReactPyConfig, VdomDict
910
from reactpy.utils import vdom_to_html
1011

1112
logger = logging.getLogger(__name__)
@@ -93,3 +94,16 @@ async def http_response(
9394

9495
await send(start_msg)
9596
await send(body_msg)
97+
98+
99+
def process_settings(settings: ReactPyConfig):
100+
"""Process the settings and return the final configuration."""
101+
from reactpy import config
102+
103+
for setting in settings:
104+
config_name = f"REACTPY_{setting.upper()}"
105+
config_object: Option | None = getattr(config, config_name, None)
106+
if config_object:
107+
config_object.set_current(settings[setting])
108+
else:
109+
raise ValueError(f"Unknown ReactPy setting {setting!r}.")

src/reactpy/config.py

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,7 @@ def boolean(value: str | bool | int) -> bool:
3333
)
3434

3535

36-
REACTPY_DEBUG_MODE = Option(
37-
"REACTPY_DEBUG_MODE", default=False, validator=boolean, mutable=True
38-
)
36+
REACTPY_DEBUG = Option("REACTPY_DEBUG", default=False, validator=boolean, mutable=True)
3937
"""Get extra logs and validation checks at the cost of performance.
4038
4139
This will enable the following:
@@ -44,13 +42,13 @@ def boolean(value: str | bool | int) -> bool:
4442
- :data:`REACTPY_CHECK_JSON_ATTRS`
4543
"""
4644

47-
REACTPY_CHECK_VDOM_SPEC = Option("REACTPY_CHECK_VDOM_SPEC", parent=REACTPY_DEBUG_MODE)
45+
REACTPY_CHECK_VDOM_SPEC = Option("REACTPY_CHECK_VDOM_SPEC", parent=REACTPY_DEBUG)
4846
"""Checks which ensure VDOM is rendered to spec
4947
5048
For more info on the VDOM spec, see here: :ref:`VDOM JSON Schema`
5149
"""
5250

53-
REACTPY_CHECK_JSON_ATTRS = Option("REACTPY_CHECK_JSON_ATTRS", parent=REACTPY_DEBUG_MODE)
51+
REACTPY_CHECK_JSON_ATTRS = Option("REACTPY_CHECK_JSON_ATTRS", parent=REACTPY_DEBUG)
5452
"""Checks that all VDOM attributes are JSON serializable
5553
5654
The VDOM spec is not able to enforce this on its own since attributes could anything.
@@ -73,8 +71,8 @@ def boolean(value: str | bool | int) -> bool:
7371
set of publicly available APIs for working with the client.
7472
"""
7573

76-
REACTPY_TESTING_DEFAULT_TIMEOUT = Option(
77-
"REACTPY_TESTING_DEFAULT_TIMEOUT",
74+
REACTPY_TESTS_DEFAULT_TIMEOUT = Option(
75+
"REACTPY_TESTS_DEFAULT_TIMEOUT",
7876
10.0,
7977
mutable=False,
8078
validator=float,
@@ -88,3 +86,43 @@ def boolean(value: str | bool | int) -> bool:
8886
validator=boolean,
8987
)
9088
"""Whether to render components asynchronously. This is currently an experimental feature."""
89+
90+
REACTPY_RECONNECT_INTERVAL = Option(
91+
"REACTPY_RECONNECT_INTERVAL",
92+
default=750,
93+
mutable=True,
94+
validator=int,
95+
)
96+
"""The interval in milliseconds between reconnection attempts for the websocket server"""
97+
98+
REACTPY_RECONNECT_MAX_INTERVAL = Option(
99+
"REACTPY_RECONNECT_MAX_INTERVAL",
100+
default=60000,
101+
mutable=True,
102+
validator=int,
103+
)
104+
"""The maximum interval in milliseconds between reconnection attempts for the websocket server"""
105+
106+
REACTPY_RECONNECT_MAX_RETRIES = Option(
107+
"REACTPY_RECONNECT_MAX_RETRIES",
108+
default=150,
109+
mutable=True,
110+
validator=int,
111+
)
112+
"""The maximum number of reconnection attempts for the websocket server"""
113+
114+
REACTPY_RECONNECT_BACKOFF_MULTIPLIER = Option(
115+
"REACTPY_RECONNECT_BACKOFF_MULTIPLIER",
116+
default=1.25,
117+
mutable=True,
118+
validator=float,
119+
)
120+
"""The multiplier for exponential backoff between reconnection attempts for the websocket server"""
121+
122+
REACTPY_PATH_PREFIX = Option(
123+
"REACTPY_PATH_PREFIX",
124+
default="/reactpy/",
125+
mutable=True,
126+
validator=str,
127+
)
128+
"""The prefix for all ReactPy routes"""

src/reactpy/core/hooks.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919
from typing_extensions import TypeAlias
2020

21-
from reactpy.config import REACTPY_DEBUG_MODE
21+
from reactpy.config import REACTPY_DEBUG
2222
from reactpy.core._life_cycle_hook import current_hook
2323
from reactpy.core.types import Context, Key, State, VdomDict
2424
from reactpy.types import Connection, Location
@@ -204,7 +204,7 @@ def use_debug_value(
204204
memo_func = message if callable(message) else lambda: message
205205
new = use_memo(memo_func, dependencies)
206206

207-
if REACTPY_DEBUG_MODE.current and old.current != new:
207+
if REACTPY_DEBUG.current and old.current != new:
208208
old.current = new
209209
logger.debug(f"{current_hook().component} {new}")
210210

src/reactpy/core/layout.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
from reactpy.config import (
3333
REACTPY_ASYNC_RENDERING,
3434
REACTPY_CHECK_VDOM_SPEC,
35-
REACTPY_DEBUG_MODE,
35+
REACTPY_DEBUG,
3636
)
3737
from reactpy.core._life_cycle_hook import LifeCycleHook
3838
from reactpy.core.types import (
@@ -202,9 +202,7 @@ async def _render_component(
202202
new_state.model.current = {
203203
"tagName": "",
204204
"error": (
205-
f"{type(error).__name__}: {error}"
206-
if REACTPY_DEBUG_MODE.current
207-
else ""
205+
f"{type(error).__name__}: {error}" if REACTPY_DEBUG.current else ""
208206
),
209207
}
210208
finally:

0 commit comments

Comments
 (0)