blu-react 0.0.1__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.1.dist-info/METADATA +63 -0
- blu_react-0.0.1.dist-info/RECORD +25 -0
- blu_react-0.0.1.dist-info/WHEEL +4 -0
- blu_react-0.0.1.dist-info/entry_points.txt +2 -0
- blu_react-0.0.1.dist-info/licenses/LICENSE +21 -0
blu/__init__.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""A full stack React framework for Python."""
|
|
2
|
+
|
|
3
|
+
from typing import Callable
|
|
4
|
+
|
|
5
|
+
from blu._utils.client import is_client
|
|
6
|
+
from blu._utils.typing import Protocol, Any
|
|
7
|
+
from blu import html
|
|
8
|
+
from blu._exceptions import WrongEnvironmentError
|
|
9
|
+
from blu._nodes import (
|
|
10
|
+
ClientRenderer,
|
|
11
|
+
HTMLElement,
|
|
12
|
+
Key,
|
|
13
|
+
ClientElement,
|
|
14
|
+
Node,
|
|
15
|
+
client,
|
|
16
|
+
create_html_element as create_rare_html_element,
|
|
17
|
+
)
|
|
18
|
+
from blu._hooks import Ref, use_effect, use_ref, use_state
|
|
19
|
+
from blu._server_functions import server
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ServerOnlyClientInterface:
|
|
23
|
+
_name: str
|
|
24
|
+
|
|
25
|
+
def __init__(self, name: str):
|
|
26
|
+
self._name = name
|
|
27
|
+
|
|
28
|
+
def __call__(self, *args: Any, **kwargs: Any):
|
|
29
|
+
raise WrongEnvironmentError(
|
|
30
|
+
f"Cannot instantiate {self._name} in client environment."
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
def __getattr__(self, name: str) -> Any:
|
|
34
|
+
raise WrongEnvironmentError(f"Cannot use {self._name} in client environment.")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
if is_client:
|
|
38
|
+
Response = ServerOnlyClientInterface("Response")
|
|
39
|
+
app = ServerOnlyClientInterface("app")
|
|
40
|
+
else:
|
|
41
|
+
from blu._http import Response
|
|
42
|
+
from blu._app import app
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"ClientElement",
|
|
47
|
+
"ClientRenderer",
|
|
48
|
+
"HTMLElement",
|
|
49
|
+
"Key",
|
|
50
|
+
"Node",
|
|
51
|
+
"Ref",
|
|
52
|
+
"Response",
|
|
53
|
+
"WrongEnvironmentError",
|
|
54
|
+
"app",
|
|
55
|
+
"client",
|
|
56
|
+
"create_rare_html_element",
|
|
57
|
+
"html",
|
|
58
|
+
"is_client",
|
|
59
|
+
"server",
|
|
60
|
+
"use_effect",
|
|
61
|
+
"use_state",
|
|
62
|
+
"use_ref",
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
noop: Callable[[Any], None] = lambda x: x
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Settings(Protocol):
|
|
70
|
+
"""
|
|
71
|
+
Interface for the app.__settings__ module (app/__settings__.py).
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
CLIENT_REQUIREMENTS: list[str]
|
blu/_app/__init__.py
ADDED
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
from collections.abc import Generator
|
|
3
|
+
from functools import cache
|
|
4
|
+
import mimetypes
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import shutil
|
|
7
|
+
from tempfile import TemporaryDirectory
|
|
8
|
+
from zipfile import ZipFile
|
|
9
|
+
|
|
10
|
+
import aiofiles
|
|
11
|
+
|
|
12
|
+
from blu._exceptions import WrongEnvironmentError
|
|
13
|
+
from blu._utils import asgi
|
|
14
|
+
from blu._app.router import NotFound, router_from_root_package
|
|
15
|
+
from blu._http import QueryParams, Request, Response
|
|
16
|
+
from blu._server_functions import (
|
|
17
|
+
collect_blu_server_import_bindings,
|
|
18
|
+
decorator_is_server,
|
|
19
|
+
)
|
|
20
|
+
from blu._utils import asgi
|
|
21
|
+
from blu._nodes import ClientElement, HTMLElement, Key, Node
|
|
22
|
+
from blu._utils.typing import Iterable
|
|
23
|
+
from blu import _utils
|
|
24
|
+
from .render import render_to_str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def app(scope: asgi.Scope, receive: asgi.Receiver, send: asgi.Sender):
|
|
28
|
+
"""
|
|
29
|
+
An `ASGI <https://asgi.readthedocs.io/en/latest/>`_ app that runs
|
|
30
|
+
the Blu application defined in your current Python environment's
|
|
31
|
+
``app`` package (for most ASGI servers, running the server in the
|
|
32
|
+
parent directory of your project's ``app`` directory will put your
|
|
33
|
+
project's ``app`` package in the Python environment).
|
|
34
|
+
|
|
35
|
+
.. code-block:: console
|
|
36
|
+
|
|
37
|
+
$ uvicorn blu:app
|
|
38
|
+
|
|
39
|
+
.. code-block:: console
|
|
40
|
+
|
|
41
|
+
$ hypercorn blu:app
|
|
42
|
+
|
|
43
|
+
.. code-block:: console
|
|
44
|
+
|
|
45
|
+
$ daphne blu:app
|
|
46
|
+
"""
|
|
47
|
+
if _utils.is_client:
|
|
48
|
+
raise WrongEnvironmentError(
|
|
49
|
+
"Cannot call Blu ASGI app in a client environment.",
|
|
50
|
+
)
|
|
51
|
+
if scope["type"] == "lifespan":
|
|
52
|
+
await _lifespan(scope, receive, send)
|
|
53
|
+
elif scope["type"] == "http":
|
|
54
|
+
await _http(scope, receive, send)
|
|
55
|
+
else:
|
|
56
|
+
await _websocket(scope, receive, send)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def _lifespan(
|
|
60
|
+
scope: asgi.LifespanScope, receive: asgi.Receiver, send: asgi.Sender
|
|
61
|
+
):
|
|
62
|
+
event = await receive()
|
|
63
|
+
assert event["type"] == "lifespan.startup"
|
|
64
|
+
from blu._server_functions import refresh_server_function_registry
|
|
65
|
+
|
|
66
|
+
refresh_server_function_registry()
|
|
67
|
+
await send({"type": "lifespan.startup.complete"})
|
|
68
|
+
event = await receive()
|
|
69
|
+
assert event["type"] == "lifespan.shutdown"
|
|
70
|
+
await send({"type": "lifespan.shutdown.complete"})
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
PYTHON_MIME = "text/x-python"
|
|
74
|
+
ZIP_MIME = "text/zip"
|
|
75
|
+
JS_MIME = "text/javascript"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def _http(
|
|
79
|
+
scope: asgi.HTTPConnectionScope, receive: asgi.Receiver, send: asgi.Sender
|
|
80
|
+
):
|
|
81
|
+
if scope["path"] == "/_blu_internal/app_pkg.zip":
|
|
82
|
+
path = _python_zip_dir() / "app.zip"
|
|
83
|
+
await _serve_file(path, ZIP_MIME, send)
|
|
84
|
+
return
|
|
85
|
+
if scope["path"] == "/_blu_internal/blu_pkg.zip":
|
|
86
|
+
path = _python_zip_dir() / "blu.zip"
|
|
87
|
+
await _serve_file(path, ZIP_MIME, send)
|
|
88
|
+
return
|
|
89
|
+
if scope["path"] == "/_blu_internal/client_main.py":
|
|
90
|
+
path = Path(__file__).parent / "client_main.py"
|
|
91
|
+
await _serve_file(path, PYTHON_MIME, send)
|
|
92
|
+
return
|
|
93
|
+
if scope["path"] == "/_blu_internal/util.js":
|
|
94
|
+
path = Path(__file__).parent.parent / "util.js"
|
|
95
|
+
await _serve_file(path, JS_MIME, send)
|
|
96
|
+
return
|
|
97
|
+
if scope["path"] == "/_blu_internal/server_function":
|
|
98
|
+
if scope["method"] != "POST":
|
|
99
|
+
await _serve_405(send)
|
|
100
|
+
return
|
|
101
|
+
headers = _get_asgi_headers(scope)
|
|
102
|
+
if headers.get("host", None) is None:
|
|
103
|
+
await _serve_400(send)
|
|
104
|
+
return
|
|
105
|
+
if headers.get("origin", None) is None:
|
|
106
|
+
await _serve_400(send)
|
|
107
|
+
return
|
|
108
|
+
if not _host_origin_match(headers["host"], headers["origin"]):
|
|
109
|
+
await _serve_400(send)
|
|
110
|
+
return
|
|
111
|
+
from blu._server_functions import handle_server_function_request
|
|
112
|
+
|
|
113
|
+
await handle_server_function_request(receive, send)
|
|
114
|
+
return
|
|
115
|
+
try:
|
|
116
|
+
await _serve_static(scope, send)
|
|
117
|
+
except NotFound:
|
|
118
|
+
pass
|
|
119
|
+
else:
|
|
120
|
+
return
|
|
121
|
+
request = await _create_request(scope)
|
|
122
|
+
try:
|
|
123
|
+
response = await _get_router().handle(request)
|
|
124
|
+
except NotFound:
|
|
125
|
+
await _serve_404("Not Found: " + scope["path"], send)
|
|
126
|
+
return
|
|
127
|
+
await send(
|
|
128
|
+
{
|
|
129
|
+
"type": "http.response.start",
|
|
130
|
+
"status": response._status,
|
|
131
|
+
"headers": _get_headers(response),
|
|
132
|
+
}
|
|
133
|
+
)
|
|
134
|
+
body_str = await render_to_str(response._body)
|
|
135
|
+
await send(
|
|
136
|
+
{
|
|
137
|
+
"type": "http.response.body",
|
|
138
|
+
"body": body_str.encode("utf-8"),
|
|
139
|
+
}
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _host_origin_match(host: str, origin: str) -> bool:
|
|
144
|
+
if origin.startswith("https://"):
|
|
145
|
+
exp_host = origin.replace("https://", "")
|
|
146
|
+
elif origin.startswith("http://"):
|
|
147
|
+
exp_host = origin.replace("http://", "")
|
|
148
|
+
else:
|
|
149
|
+
return False
|
|
150
|
+
return host == exp_host
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _get_asgi_headers(scope: asgi.HTTPConnectionScope) -> dict[str, str]:
|
|
154
|
+
return {k.decode("utf-8"): v.decode("utf-8") for k, v in scope["headers"]}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
async def _serve_404(message: str, send: asgi.Sender):
|
|
158
|
+
await send(
|
|
159
|
+
{
|
|
160
|
+
"type": "http.response.start",
|
|
161
|
+
"status": 404,
|
|
162
|
+
}
|
|
163
|
+
)
|
|
164
|
+
await send(
|
|
165
|
+
{
|
|
166
|
+
"type": "http.response.body",
|
|
167
|
+
"body": message.encode(),
|
|
168
|
+
}
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
async def _serve_405(send: asgi.Sender):
|
|
173
|
+
await send(
|
|
174
|
+
{
|
|
175
|
+
"type": "http.response.start",
|
|
176
|
+
"status": 405,
|
|
177
|
+
}
|
|
178
|
+
)
|
|
179
|
+
await send(
|
|
180
|
+
{
|
|
181
|
+
"type": "http.response.body",
|
|
182
|
+
"body": b"",
|
|
183
|
+
}
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
async def _serve_400(send: asgi.Sender):
|
|
188
|
+
await send(
|
|
189
|
+
{
|
|
190
|
+
"type": "http.response.start",
|
|
191
|
+
"status": 400,
|
|
192
|
+
}
|
|
193
|
+
)
|
|
194
|
+
await send(
|
|
195
|
+
{
|
|
196
|
+
"type": "http.response.body",
|
|
197
|
+
"body": b"",
|
|
198
|
+
}
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
async def _serve_static(
|
|
203
|
+
scope: asgi.HTTPConnectionScope,
|
|
204
|
+
send: asgi.Sender,
|
|
205
|
+
):
|
|
206
|
+
if not _is_static_file(scope["path"]):
|
|
207
|
+
raise NotFound
|
|
208
|
+
app_def = _get_app_def()
|
|
209
|
+
assert hasattr(app_def, "__path__")
|
|
210
|
+
path_str = app_def.__path__[0]
|
|
211
|
+
app_def_path = Path(path_str)
|
|
212
|
+
file_path = app_def_path / scope["path"].strip("/")
|
|
213
|
+
content_type, _ = mimetypes.guess_type(file_path)
|
|
214
|
+
try:
|
|
215
|
+
await _serve_file(file_path, content_type, send)
|
|
216
|
+
except (FileNotFoundError, IsADirectoryError):
|
|
217
|
+
raise NotFound
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
async def _serve_file(path: Path, content_type: str | None, send: asgi.Sender):
|
|
221
|
+
headers = (
|
|
222
|
+
[]
|
|
223
|
+
if content_type is None
|
|
224
|
+
else [(b"Content-Type", content_type.encode("utf-8"))]
|
|
225
|
+
)
|
|
226
|
+
async with aiofiles.open(path, "rb") as file:
|
|
227
|
+
await send(
|
|
228
|
+
{
|
|
229
|
+
"type": "http.response.start",
|
|
230
|
+
"status": 200,
|
|
231
|
+
"headers": headers,
|
|
232
|
+
"trailers": False,
|
|
233
|
+
}
|
|
234
|
+
)
|
|
235
|
+
async for chunk in file:
|
|
236
|
+
await send(
|
|
237
|
+
{
|
|
238
|
+
"type": "http.response.body",
|
|
239
|
+
"body": chunk,
|
|
240
|
+
"more_body": True,
|
|
241
|
+
}
|
|
242
|
+
)
|
|
243
|
+
await send(
|
|
244
|
+
{
|
|
245
|
+
"type": "http.response.body",
|
|
246
|
+
"body": b"",
|
|
247
|
+
"more_body": False,
|
|
248
|
+
}
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _is_static_file(url_path: str):
|
|
253
|
+
if "__pycache__" in url_path:
|
|
254
|
+
return False
|
|
255
|
+
if url_path.strip().endswith(".py"):
|
|
256
|
+
return False
|
|
257
|
+
return True
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _get_headers(response: Response) -> Iterable[tuple[bytes, bytes]]:
|
|
261
|
+
return [
|
|
262
|
+
(b"Access-Control-Allow-Origin", b"https://micropython.org"),
|
|
263
|
+
*[(k.encode("utf-8"), v.encode("utf-8")) for k, v in response._headers.items()],
|
|
264
|
+
]
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
async def _create_request(scope: asgi.HTTPConnectionScope) -> Request:
|
|
268
|
+
return Request(
|
|
269
|
+
scope["path"],
|
|
270
|
+
query=QueryParams.from_query_string(
|
|
271
|
+
scope["query_string"].decode(),
|
|
272
|
+
),
|
|
273
|
+
headers={k.decode(): v.decode() for k, v in scope["headers"]},
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
async def _websocket(
|
|
278
|
+
scope: asgi.WSConnectionScope, receive: asgi.Receiver, send: asgi.Sender
|
|
279
|
+
): ...
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
async def get_page_node(path: str):
|
|
283
|
+
response = await get_page_response(path)
|
|
284
|
+
return response._body # type: ignore
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
async def get_page_rendered(path: str):
|
|
288
|
+
unrendered = await get_page_node(path)
|
|
289
|
+
return render_page_node(unrendered)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
async def get_page_response(path: str):
|
|
293
|
+
if "?" in path:
|
|
294
|
+
url_path, query_str = path.strip().split("?")
|
|
295
|
+
request = Request(url_path, QueryParams.from_query_string(query_str))
|
|
296
|
+
else:
|
|
297
|
+
request = Request(path)
|
|
298
|
+
return await _get_router().handle(request)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
type RenderedNode = (HTMLElement | str | int | float | bool | None)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def render_page_node(root: Node) -> Node:
|
|
305
|
+
ret_tuple = _render_page_node_rec(root)
|
|
306
|
+
return ret_tuple[0] if len(ret_tuple) == 1 else ret_tuple
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _render_page_node_rec(root: Node) -> tuple[Node, ...]:
|
|
310
|
+
uncollapsed = _render_page_nodes_rec(root)
|
|
311
|
+
return _collapse_nodes(uncollapsed)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _collapse_nodes(uncollapsed: Iterable[Node]) -> tuple[Node, ...]:
|
|
315
|
+
current_str = ""
|
|
316
|
+
collapsed: list[Node] = []
|
|
317
|
+
for item in uncollapsed:
|
|
318
|
+
if isinstance(item, str):
|
|
319
|
+
current_str += item
|
|
320
|
+
else:
|
|
321
|
+
if current_str:
|
|
322
|
+
collapsed.append(current_str)
|
|
323
|
+
current_str = ""
|
|
324
|
+
collapsed.append(item)
|
|
325
|
+
if current_str:
|
|
326
|
+
collapsed.append(current_str)
|
|
327
|
+
return tuple(collapsed)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _render_page_nodes_rec(root: Node) -> tuple[Node, ...]:
|
|
331
|
+
if isinstance(root, ClientElement):
|
|
332
|
+
return _render_client_element(root)
|
|
333
|
+
if isinstance(root, HTMLElement):
|
|
334
|
+
return _render_html_element(root)
|
|
335
|
+
if isinstance(root, Key):
|
|
336
|
+
return _render_key(root)
|
|
337
|
+
if isinstance(root, Iterable) and not isinstance(root, str):
|
|
338
|
+
return _render_iterable(root)
|
|
339
|
+
if root is True:
|
|
340
|
+
return ("true",)
|
|
341
|
+
if root is False:
|
|
342
|
+
return ("false",)
|
|
343
|
+
if root is None:
|
|
344
|
+
return ()
|
|
345
|
+
return (str(root),)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _render_client_element(element: ClientElement) -> tuple[Node, ...]:
|
|
349
|
+
render_return = element._renderer(*element._args, **element._kwargs) # type: ignore
|
|
350
|
+
if isinstance(render_return, Generator):
|
|
351
|
+
next(render_return)
|
|
352
|
+
try:
|
|
353
|
+
render_return.send(element._children)
|
|
354
|
+
except StopIteration as e:
|
|
355
|
+
render_return = e.value
|
|
356
|
+
return _render_page_node_rec(render_return)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _render_html_element(element: HTMLElement) -> tuple[Node, ...]:
|
|
360
|
+
return (element[_render_iterable(element._children)],)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _render_key(key: Key) -> tuple[Node, ...]:
|
|
364
|
+
return _render_iterable(key._children) # type: ignore
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _render_iterable(root: Node) -> tuple[Node, ...]:
|
|
368
|
+
tuples = [_render_page_node_rec(x) for x in root] # type: ignore
|
|
369
|
+
return _collapse_nodes(y for x in tuples for y in x)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
@cache
|
|
373
|
+
def _get_router():
|
|
374
|
+
app_def = _get_app_def()
|
|
375
|
+
return router_from_root_package(app_def)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
@cache
|
|
379
|
+
def _get_app_def():
|
|
380
|
+
import app
|
|
381
|
+
|
|
382
|
+
return app
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
@cache
|
|
386
|
+
def _python_zip_dir():
|
|
387
|
+
ret = Path(TemporaryDirectory().name)
|
|
388
|
+
_blu_pkg_zip(ret)
|
|
389
|
+
_app_pkg_zip(ret)
|
|
390
|
+
return ret
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _blu_pkg_zip(zips_root: Path):
|
|
394
|
+
src_dir = Path(__file__).parent.parent
|
|
395
|
+
base_name = str(zips_root / "blu")
|
|
396
|
+
shutil.make_archive(base_name, "zip", src_dir, src_dir)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _app_pkg_zip(zips_root: Path):
|
|
400
|
+
import app
|
|
401
|
+
|
|
402
|
+
assert app.__spec__ is not None
|
|
403
|
+
app_pkg_locations = app.__spec__.submodule_search_locations
|
|
404
|
+
assert app_pkg_locations
|
|
405
|
+
src_path = Path(app_pkg_locations[0])
|
|
406
|
+
dest_path = zips_root / "app.zip"
|
|
407
|
+
with ZipFile(dest_path, "w") as dest_f:
|
|
408
|
+
added_py: set[str] = set()
|
|
409
|
+
for path in src_path.rglob("*"):
|
|
410
|
+
if not path.is_file() or "__pycache__" in path.parts:
|
|
411
|
+
continue
|
|
412
|
+
rel_posix = path.relative_to(src_path).as_posix()
|
|
413
|
+
if path.suffix == ".py":
|
|
414
|
+
if _is_client_module(path):
|
|
415
|
+
dest_f.write(path, arcname=rel_posix)
|
|
416
|
+
added_py.add(rel_posix)
|
|
417
|
+
for path in src_path.rglob("*.py"):
|
|
418
|
+
if "__pycache__" in path.parts:
|
|
419
|
+
continue
|
|
420
|
+
rel_posix = path.relative_to(src_path).as_posix()
|
|
421
|
+
if rel_posix in added_py:
|
|
422
|
+
continue
|
|
423
|
+
stub_src = _client_stub_source_for_server_module(path)
|
|
424
|
+
if stub_src is not None:
|
|
425
|
+
dest_f.writestr(rel_posix, stub_src.encode("utf-8"))
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _client_stub_source_for_server_module(path: Path) -> str | None:
|
|
429
|
+
try:
|
|
430
|
+
source = path.read_text()
|
|
431
|
+
except OSError:
|
|
432
|
+
return None
|
|
433
|
+
try:
|
|
434
|
+
tree = ast.parse(source)
|
|
435
|
+
except SyntaxError:
|
|
436
|
+
return None
|
|
437
|
+
blu_pkg, server_dec = collect_blu_server_import_bindings(tree.body)
|
|
438
|
+
chunks: list[str] = []
|
|
439
|
+
for node in tree.body:
|
|
440
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
441
|
+
if not any(
|
|
442
|
+
decorator_is_server(d, blu_pkg, server_dec) for d in node.decorator_list
|
|
443
|
+
):
|
|
444
|
+
continue
|
|
445
|
+
async_kw = "async " if isinstance(node, ast.AsyncFunctionDef) else ""
|
|
446
|
+
args_src = ast.unparse(node.args)
|
|
447
|
+
chunks.append(f"@server\n{async_kw}def {node.name}({args_src}):\n ...")
|
|
448
|
+
if not chunks:
|
|
449
|
+
return None
|
|
450
|
+
return "from blu import server\n\n" + "\n\n".join(chunks) + "\n"
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _is_client_module(path: Path) -> bool:
|
|
454
|
+
if path.suffix != ".py":
|
|
455
|
+
return False
|
|
456
|
+
source_code = path.read_text()
|
|
457
|
+
parsed = ast.parse(source_code)
|
|
458
|
+
for stmt in ast.iter_child_nodes(parsed):
|
|
459
|
+
if isinstance(stmt, ast.Assign):
|
|
460
|
+
stmt_source = ast.get_source_segment(source_code, stmt)
|
|
461
|
+
if stmt_source == "__client__ = True":
|
|
462
|
+
return True
|
|
463
|
+
return False
|
blu/_app/client_main.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
from collections.abc import AsyncGenerator, Generator, Iterable
|
|
3
|
+
import importlib
|
|
4
|
+
from importlib import import_module
|
|
5
|
+
import logging
|
|
6
|
+
import pickle
|
|
7
|
+
from typing import Any, TypedDict, cast
|
|
8
|
+
|
|
9
|
+
from js import document # type: ignore
|
|
10
|
+
from pyscript.ffi import create_proxy, to_js # type: ignore
|
|
11
|
+
from pyscript.js_modules import _blu_react as react
|
|
12
|
+
from pyscript.js_modules import _blu_js_utils
|
|
13
|
+
|
|
14
|
+
from blu._nodes import ClientRenderer, ClientElement, HTMLElement, Jsonable, Node, Key
|
|
15
|
+
from blu._utils.typing import Generator
|
|
16
|
+
|
|
17
|
+
logging.basicConfig(level=logging.DEBUG)
|
|
18
|
+
|
|
19
|
+
PYTHON_TYPES = create_proxy( # type: ignore
|
|
20
|
+
{
|
|
21
|
+
"ClientElement": ClientElement,
|
|
22
|
+
"Key": Key,
|
|
23
|
+
"tuple": tuple,
|
|
24
|
+
"HTMLElement": HTMLElement,
|
|
25
|
+
}
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def main():
|
|
30
|
+
_blu_js_utils.init(import_module) # type: ignore
|
|
31
|
+
b64_str: str = cast(str, document.querySelector('script[type="react-data"]').textContent) # type: ignore
|
|
32
|
+
b64_bytes = b64_str.encode("ascii")
|
|
33
|
+
pickled = base64.b64decode(b64_bytes)
|
|
34
|
+
unpickled = pickle.loads(pickled)
|
|
35
|
+
_blu_js_utils.renderRoot(unpickled) # type: ignore
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_node(data: Any):
|
|
39
|
+
if isinstance(data, ClientElement):
|
|
40
|
+
return react.createElement(
|
|
41
|
+
PythonElement,
|
|
42
|
+
to_js(
|
|
43
|
+
{
|
|
44
|
+
"renderer": create_proxy(data._renderer),
|
|
45
|
+
"args": data._args,
|
|
46
|
+
"kwargs": data._kwargs,
|
|
47
|
+
"py_children": data._children,
|
|
48
|
+
}
|
|
49
|
+
),
|
|
50
|
+
)
|
|
51
|
+
elif isinstance(data, Key):
|
|
52
|
+
props = {"key": data["key"]}
|
|
53
|
+
return react.createElement(
|
|
54
|
+
react.Fragment,
|
|
55
|
+
to_js({"key": data._key}),
|
|
56
|
+
*get_array(data._children),
|
|
57
|
+
)
|
|
58
|
+
elif isinstance(data, tuple):
|
|
59
|
+
return react.createElement(
|
|
60
|
+
react.Fragment,
|
|
61
|
+
to_js({}),
|
|
62
|
+
*get_array(data),
|
|
63
|
+
)
|
|
64
|
+
elif isinstance(data, str):
|
|
65
|
+
return data
|
|
66
|
+
elif isinstance(data, Iterable):
|
|
67
|
+
return get_array(data)
|
|
68
|
+
elif isinstance(data, HTMLElement):
|
|
69
|
+
props = {k: v for k, v in data._attrs.items() if k != "ref"}
|
|
70
|
+
if "ref" in data._attrs.keys():
|
|
71
|
+
props["ref"] = data._attrs["ref"]._js_ref
|
|
72
|
+
return react.createElement(
|
|
73
|
+
data._tagname,
|
|
74
|
+
to_js(props),
|
|
75
|
+
*get_array(data._children),
|
|
76
|
+
)
|
|
77
|
+
elif data is None:
|
|
78
|
+
return None
|
|
79
|
+
else:
|
|
80
|
+
return str(data)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def get_obj(obj: dict[str, Any]):
|
|
84
|
+
return {k: get_node(v) for k, v in obj.items()}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def get_dict(my_dict: dict):
|
|
88
|
+
return {k: get_node(v) for k, v in my_dict.items()}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def get_array(array: Any):
|
|
92
|
+
return to_js([get_node(x) for x in array])
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class PythonElementProps(TypedDict):
|
|
96
|
+
renderer: ClientRenderer
|
|
97
|
+
args: tuple[Jsonable, ...]
|
|
98
|
+
kwargs: dict[str, Jsonable]
|
|
99
|
+
py_children: list[Node]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@create_proxy
|
|
103
|
+
def PythonElement(props: PythonElementProps, extra: Any = None):
|
|
104
|
+
result = props.renderer(*props.args, **props.kwargs.as_object_map())
|
|
105
|
+
if isinstance(result, Generator):
|
|
106
|
+
next(result)
|
|
107
|
+
try:
|
|
108
|
+
result.send(props.py_children)
|
|
109
|
+
except StopIteration as e:
|
|
110
|
+
return get_node(e.value)
|
|
111
|
+
elif isinstance(result, AsyncGenerator):
|
|
112
|
+
raise TypeError(
|
|
113
|
+
"ClientElements cannot be created from async rendering functions."
|
|
114
|
+
)
|
|
115
|
+
else:
|
|
116
|
+
return get_node(result)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def py_to_js_node(py_node: Node):
|
|
120
|
+
if isinstance(py_node, ClientElement):
|
|
121
|
+
return react.createElement(
|
|
122
|
+
PythonElement,
|
|
123
|
+
to_js(
|
|
124
|
+
{
|
|
125
|
+
"renderer": create_proxy(py_node.renderer),
|
|
126
|
+
"args": py_node.args,
|
|
127
|
+
"kwargs": py_node.kwargs,
|
|
128
|
+
"py_children": py_node.children,
|
|
129
|
+
}
|
|
130
|
+
),
|
|
131
|
+
)
|
|
132
|
+
elif isinstance(py_node, HTMLElement):
|
|
133
|
+
return react.createElement(
|
|
134
|
+
py_node.tagname,
|
|
135
|
+
to_js(
|
|
136
|
+
{
|
|
137
|
+
k: create_proxy(v) if k.startswith("on") else v
|
|
138
|
+
for k, v in py_node.props.items()
|
|
139
|
+
}
|
|
140
|
+
),
|
|
141
|
+
*py_node.children,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def parse_py_element(data: dict[str, Any]):
|
|
146
|
+
return ClientElement(
|
|
147
|
+
get_renderer(data),
|
|
148
|
+
get_array(data["args"]),
|
|
149
|
+
get_obj(data["kwargs"]),
|
|
150
|
+
get_node(data["children"]),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def get_renderer(data: Any) -> ClientRenderer:
|
|
155
|
+
module = importlib.import_module(data["module"])
|
|
156
|
+
return getattr(module, data["name"])
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
await main()
|