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 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
@@ -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()