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/__init__.py +74 -0
- blu/_app/__init__.py +463 -0
- blu/_app/client_main.py +159 -0
- blu/_app/render.py +107 -0
- blu/_app/router.py +267 -0
- blu/_cli.py +25 -0
- blu/_exceptions.py +5 -0
- blu/_hooks.py +323 -0
- blu/_http.py +219 -0
- blu/_nodes.py +771 -0
- blu/_server_functions.py +423 -0
- blu/_settings.py +30 -0
- blu/_utils/__init__.py +47 -0
- blu/_utils/asgi.py +189 -0
- blu/_utils/asyncio.py +60 -0
- blu/_utils/client.py +3 -0
- blu/_utils/json.py +30 -0
- blu/_utils/typing.py +33 -0
- blu/html.py +91 -0
- blu/util.js +334 -0
- blu_react-0.0.1rc1.dist-info/METADATA +63 -0
- blu_react-0.0.1rc1.dist-info/RECORD +25 -0
- blu_react-0.0.1rc1.dist-info/WHEEL +4 -0
- blu_react-0.0.1rc1.dist-info/entry_points.txt +2 -0
- blu_react-0.0.1rc1.dist-info/licenses/LICENSE +21 -0
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())
|