blu-react 0.0.1rc1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
blu/_app/render.py ADDED
@@ -0,0 +1,107 @@
1
+ import base64
2
+ from io import StringIO
3
+ from pathlib import Path
4
+ import pickle
5
+ from blu._settings import settings
6
+ from blu._utils.typing import Optional
7
+ from xml.etree import ElementTree as ET
8
+
9
+ from blu._nodes import Jsonable, Node
10
+ from blu._utils import json
11
+
12
+ type Renamer = dict[tuple[Optional[str], Path], Optional[str]]
13
+
14
+ REACT_LOCATION = "https://esm.sh/react"
15
+ REACT_DOM_LOCATION = "https://esm.sh/react-dom"
16
+ PYSCRIPT_URL = "https://pyscript.net/releases/2026.1.1/core.js"
17
+
18
+
19
+ async def render_to_str(root: Node) -> str:
20
+ """
21
+ Render the given `ReactElement` to a `str`.
22
+
23
+ :param root: The `ReactElement` to render.
24
+
25
+ :returns: An html page as a `str`, with `root` rendered as html,
26
+ as well as the bootstrapping code to hydrate the page.
27
+ """
28
+ tree = await _get_tree(root)
29
+ output = StringIO()
30
+ tree.write(output, encoding="unicode", method="html")
31
+ output_str = output.getvalue()
32
+ start = len("<document>")
33
+ end = -len("</document>")
34
+ no_root = output_str[start:end]
35
+ with_doc_type = "<!DOCTYPE html>" + no_root
36
+ return with_doc_type
37
+
38
+
39
+ async def _get_tree(root: Node) -> ET.ElementTree:
40
+ return ET.ElementTree(await _get_document(root))
41
+
42
+
43
+ async def _get_document(root: Node) -> ET.Element:
44
+ document = ET.Element("document")
45
+ document.append(_get_placeholder())
46
+ document.extend(
47
+ [
48
+ await _get_react_data(root),
49
+ _get_pyscript_include(),
50
+ await _get_python_script(),
51
+ ]
52
+ )
53
+ return document
54
+
55
+
56
+ def _get_placeholder() -> ET.Element:
57
+ html = ET.Element("html")
58
+ html.append(ET.Element("head"))
59
+ html.append(ET.Element("body"))
60
+ return html
61
+
62
+
63
+ async def _get_react_data(root: Node) -> ET.Element:
64
+ pickled = pickle.dumps(root)
65
+ b64 = base64.b64encode(pickled).decode("ascii")
66
+ element = ET.Element("script", type="react-data")
67
+ element.text = b64
68
+ return element
69
+
70
+
71
+ def _get_pyscript_include() -> ET.Element:
72
+ return ET.Element(
73
+ "script",
74
+ {
75
+ "type": "module",
76
+ "src": PYSCRIPT_URL,
77
+ },
78
+ )
79
+
80
+
81
+ async def _get_python_script() -> ET.Element:
82
+ return ET.Element(
83
+ "script",
84
+ {
85
+ "type": "py",
86
+ "config": await json.dumps(await _get_config()),
87
+ "src": "/_blu_internal/client_main.py",
88
+ },
89
+ )
90
+
91
+
92
+ async def _get_config() -> dict[str, Jsonable]:
93
+ return {
94
+ "js_modules": {
95
+ "main": {
96
+ "https://esm.sh/react-dom/client": "_blu_react_dom",
97
+ "https://esm.sh/react": "_blu_react",
98
+ "/_blu_internal/util.js": "_blu_js_utils",
99
+ },
100
+ },
101
+ "debug": True,
102
+ "files": {
103
+ "/_blu_internal/blu_pkg.zip": "./blu/*",
104
+ "/_blu_internal/app_pkg.zip": "./app/*",
105
+ },
106
+ "packages": settings().CLIENT_REQUIREMENTS,
107
+ }
blu/_app/router.py ADDED
@@ -0,0 +1,267 @@
1
+ from types import ModuleType
2
+ from typing import Mapping
3
+ from blu._utils.typing import Callable
4
+ from importlib import import_module
5
+ import inspect
6
+ from inspect import Parameter
7
+ from pathlib import Path
8
+
9
+ from blu._utils.typing import Any, Optional, Protocol
10
+ from blu._http import Request, Response
11
+ from blu._nodes import Node
12
+ from blu._utils.asyncio import awaitable
13
+
14
+
15
+ class Handler(Protocol):
16
+ def __call__(self, *args: Any, **kwargs: Any) -> Response | Node: ...
17
+
18
+
19
+ class RouteNotFound(Exception):
20
+ pass
21
+
22
+
23
+ class Router:
24
+ index_page: Optional[Handler]
25
+ default_page: Optional[Handler]
26
+ static_segments: dict[str, "Router"]
27
+ dynamic_segments: dict[str, "Router"]
28
+
29
+ def __init__(self, dir: Path, package_name: str):
30
+ self.index_page = None
31
+ self.default_page = None
32
+ self.static_segments = {}
33
+ self.dynamic_segments = {}
34
+ for path in dir.iterdir():
35
+ name = path.stem
36
+ full_name = package_name + "." + name
37
+ if path.name == "__index__.py":
38
+ self.index_page = self._get_page_handler(full_name)
39
+ elif path.name == "__default__.py":
40
+ self.default_page = self._get_page_handler(full_name)
41
+ elif is_static_segment(path):
42
+ self.static_segments[name] = Router(path, full_name)
43
+ elif is_dynamic_segment(path):
44
+ nunder = name.strip("_")
45
+ self.dynamic_segments[nunder] = Router(path, full_name)
46
+
47
+ def _get_page_handler(self, module_name: str) -> Handler:
48
+ module = import_module(module_name)
49
+ return module.__page__
50
+
51
+ async def handle(self, request: Request) -> Response:
52
+ path = request.path
53
+ stripped = path.strip("/")
54
+ segments: list[str] = [] if stripped == "" else stripped.split("/")
55
+ response = await self.handle_rec(request, segments)
56
+ assert response is not None
57
+ return response
58
+
59
+ async def handle_rec(
60
+ self,
61
+ request: Request,
62
+ path: list[str],
63
+ route_params: dict[str, str] = {},
64
+ ) -> Optional[Response]:
65
+ response_ret = await self._get_handler_ret(request, path, route_params)
66
+ if isinstance(response_ret, Response):
67
+ return response_ret
68
+ else:
69
+ return Response(response_ret)
70
+
71
+ async def _get_handler_ret(
72
+ self,
73
+ request: Request,
74
+ path: list[str],
75
+ route_params: dict[str, str],
76
+ ) -> Response | Node:
77
+ try:
78
+ return await self._handle_index_page(request, path, route_params)
79
+ except NotFound:
80
+ pass
81
+ try:
82
+ return await self._handle_static(request, path, route_params)
83
+ except NotFound:
84
+ pass
85
+ try:
86
+ return await self._handle_dynamic(request, path, route_params)
87
+ except NotFound:
88
+ pass
89
+ return await self._handle_default_page(request, path, route_params)
90
+
91
+ async def _handle_index_page(
92
+ self,
93
+ request: Request,
94
+ path: list[str],
95
+ route_params: dict[str, str],
96
+ ) -> Response | Node:
97
+ if not self.index_page:
98
+ raise NotFound
99
+ if path:
100
+ raise NotFound
101
+ args = self._get_handler_args(
102
+ self.index_page,
103
+ path,
104
+ route_params,
105
+ )
106
+ kwargs = self._get_handler_kwargs(
107
+ self.index_page,
108
+ route_params,
109
+ request,
110
+ path,
111
+ )
112
+ return await awaitable(self.index_page(*args, **kwargs))
113
+
114
+ def _get_handler_kwargs[
115
+ **P
116
+ ](
117
+ self,
118
+ handler: Callable[P, Response | Node],
119
+ route_params: dict[str, str],
120
+ request: Request,
121
+ path: list[str],
122
+ ) -> dict[str, Any]:
123
+ fn_params = inspect.signature(handler).parameters
124
+ if any(p.kind is p.POSITIONAL_ONLY for p in fn_params.values()):
125
+ if any(p.kind is p.VAR_KEYWORD for p in fn_params.values()):
126
+ return {name: query_list[0] for name, query_list in request.query}
127
+ else:
128
+ ret = {}
129
+ for p in fn_params.values():
130
+ if p.kind is p.POSITIONAL_ONLY:
131
+ continue
132
+ value = request.query.get(p.name, p.default)
133
+ if value is Parameter.empty:
134
+ raise TypeError(
135
+ f"Unable to process query parameter {p.name} for "
136
+ f"URL path {request.path}: __page__ handler has "
137
+ f'no argument named "{p.name}".'
138
+ )
139
+ ret[p.name] = value
140
+ return ret
141
+ ret: dict[str, Any] = {}
142
+ for fn_param in fn_params.values():
143
+ if fn_param.name == "_":
144
+ ret["_"] = None
145
+ elif fn_param.name.endswith("__"):
146
+ ret[fn_param.name] = "/".join(path)
147
+ else:
148
+ try:
149
+ ret[fn_param.name] = route_params[fn_param.name]
150
+ except KeyError:
151
+ raise TypeError(
152
+ f"__page__ handler {handler} has route "
153
+ f'argument "{fn_param.name}", which does not '
154
+ "exist for this route (URL path: "
155
+ f"{request.path})."
156
+ )
157
+ return ret
158
+
159
+ async def _handle_static(
160
+ self,
161
+ request: Request,
162
+ path: list[str],
163
+ route_params: dict[str, str],
164
+ ) -> Response | Node:
165
+ if not path:
166
+ raise NotFound
167
+ for name, segment in self.static_segments.items():
168
+ if name == path[0]:
169
+ response = await segment.handle_rec(
170
+ request,
171
+ path[1:],
172
+ route_params,
173
+ )
174
+ if response is not None:
175
+ return response
176
+ raise NotFound
177
+
178
+ async def _handle_dynamic(
179
+ self,
180
+ request: Request,
181
+ path: list[str],
182
+ route_params: dict[str, str],
183
+ ) -> Optional[Response | Node]:
184
+ if not path:
185
+ raise NotFound
186
+ for param_name, segment in self.dynamic_segments.items():
187
+ new_route_params = {**route_params, param_name: path[0]}
188
+ try:
189
+ response = await segment.handle_rec(
190
+ request,
191
+ path[1:],
192
+ new_route_params,
193
+ )
194
+ except NotFound:
195
+ continue
196
+ return response
197
+ raise NotFound
198
+
199
+ async def _handle_default_page(
200
+ self,
201
+ request: Request,
202
+ path: list[str],
203
+ route_params: dict[str, str],
204
+ ) -> Optional[Response | Node]:
205
+ if not self.default_page:
206
+ raise NotFound
207
+ args = self._get_handler_args(
208
+ self.default_page,
209
+ path,
210
+ route_params,
211
+ )
212
+ kwargs = self._get_handler_kwargs(
213
+ self.default_page,
214
+ route_params,
215
+ request,
216
+ path,
217
+ )
218
+ return await awaitable(self.default_page(*args, **kwargs))
219
+
220
+ def _get_handler_args[
221
+ **P
222
+ ](
223
+ self,
224
+ default_handler: Callable[P, Response | Node],
225
+ path: list[str],
226
+ route_params: Mapping[str, str],
227
+ ) -> tuple[Any, ...]:
228
+ parameters = inspect.signature(default_handler).parameters
229
+ ret: tuple[Any, ...] = ()
230
+ for p in parameters.values():
231
+ if p.kind is not Parameter.POSITIONAL_ONLY:
232
+ break
233
+ if p.name == "_":
234
+ ret += (None,)
235
+ elif p.name.endswith("__"):
236
+ ret += ("/".join(path),)
237
+ else:
238
+ ret += (route_params[p.name],)
239
+ return ret
240
+
241
+
242
+ def is_static_segment(path: Path) -> bool:
243
+ name = path.stem
244
+ return path.is_dir() and len(name) > 0 and name[0] != "_" and name[-1] != "_"
245
+
246
+
247
+ def is_dynamic_segment(path: Path) -> bool:
248
+ name = path.stem
249
+ return (
250
+ path.is_dir()
251
+ and len(name) > 2
252
+ and name[0] == "_"
253
+ and name[1] != "_"
254
+ and name[-1] == "_"
255
+ and name[-2] != "_"
256
+ )
257
+
258
+
259
+ def router_from_root_package(module: ModuleType) -> Router:
260
+ assert hasattr(module, "__path__")
261
+ path_str = module.__path__[0]
262
+ path = Path(path_str)
263
+ return Router(path, module.__name__)
264
+
265
+
266
+ class NotFound(Exception):
267
+ pass
blu/_cli.py ADDED
@@ -0,0 +1,25 @@
1
+ import asyncio
2
+ import subprocess
3
+
4
+ from blu._utils import get_available_port
5
+
6
+
7
+ def cli():
8
+ _run_server()
9
+
10
+
11
+ def _run_server():
12
+ proc = subprocess.Popen(
13
+ [
14
+ "uvicorn",
15
+ "--port",
16
+ str(get_available_port()),
17
+ "--reload",
18
+ "blu:app",
19
+ ],
20
+ )
21
+ return proc
22
+
23
+
24
+ def cli_other():
25
+ asyncio.run(_run_server())
blu/_exceptions.py ADDED
@@ -0,0 +1,5 @@
1
+ class WrongEnvironmentError(Exception):
2
+ """
3
+ Raised when an attempt is made to perform a server-only action on
4
+ the client, or a client-only action on the server.
5
+ """