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.
@@ -0,0 +1,423 @@
1
+ import ast
2
+ import asyncio
3
+ import base64
4
+ import functools
5
+ import importlib
6
+ import inspect
7
+ import json
8
+ import pickle
9
+ from collections.abc import Awaitable, Callable, Sequence
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from blu._utils.client import is_client
14
+
15
+ _BLU_SERVER_FN_ATTR = "_blu_is_server_function"
16
+
17
+
18
+ def _is_registered_server_function(fn: Any) -> bool:
19
+ return getattr(fn, _BLU_SERVER_FN_ATTR, False)
20
+
21
+
22
+ def collect_blu_server_import_bindings(
23
+ body: Sequence[ast.stmt],
24
+ ) -> tuple[frozenset[str], frozenset[str]]:
25
+ """
26
+ From a module's top-level statements, collect local names that refer to the
27
+ ``blu`` package and to :func:`blu.server`.
28
+
29
+ Only absolute imports are considered (``level == 0``). ``from blu import
30
+ server`` or ``from blu.<submodule> import server`` binds the
31
+ decorator name; ``import blu`` / ``import blu as …`` binds the package
32
+ for ``@blu.server`` / ``@alias.server``.
33
+ """
34
+ blu_pkg: set[str] = set()
35
+ server_decorator: set[str] = set()
36
+ for stmt in body:
37
+ if isinstance(stmt, ast.Import):
38
+ for alias in stmt.names:
39
+ if alias.name == "blu":
40
+ blu_pkg.add(alias.asname or "blu")
41
+ elif isinstance(stmt, ast.ImportFrom):
42
+ if stmt.level != 0:
43
+ continue
44
+ mod = stmt.module
45
+ if mod is None or (mod != "blu" and not mod.startswith("blu.")):
46
+ continue
47
+ for alias in stmt.names:
48
+ if alias.name == "*":
49
+ continue
50
+ if alias.name == "server":
51
+ server_decorator.add(alias.asname or "server")
52
+ return frozenset(blu_pkg), frozenset(server_decorator)
53
+
54
+
55
+ def decorator_is_server(
56
+ dec: ast.expr,
57
+ blu_pkg_names: frozenset[str],
58
+ server_decorator_names: frozenset[str],
59
+ ) -> bool:
60
+ """Whether ``dec`` applies :func:`blu.server` given import-derived name sets."""
61
+ if isinstance(dec, ast.Name):
62
+ return dec.id in server_decorator_names
63
+ if isinstance(dec, ast.Call):
64
+ return decorator_is_server(dec.func, blu_pkg_names, server_decorator_names)
65
+ if isinstance(dec, ast.Attribute):
66
+ if dec.attr != "server":
67
+ return False
68
+ if isinstance(dec.value, ast.Name):
69
+ return dec.value.id in blu_pkg_names
70
+ return False
71
+ return False
72
+
73
+
74
+ _server_function_registry: dict[tuple[str, str], Callable[..., Any]] | None = None
75
+
76
+
77
+ def _rpc_key_well_formed(module_name: str, fn_name: str) -> bool:
78
+ if not fn_name.isidentifier():
79
+ return False
80
+ if not module_name.startswith("app.") or module_name == "app":
81
+ return False
82
+ rest = module_name[4:]
83
+ if not rest:
84
+ return False
85
+ return all(p.isidentifier() for p in rest.split("."))
86
+
87
+
88
+ def _app_py_path_to_module_name(app_root: Path, path: Path) -> str:
89
+ rel = path.relative_to(app_root)
90
+ parts = rel.parts
91
+ if parts[-1] == "__init__.py":
92
+ pkg_parts = parts[:-1]
93
+ elif parts[-1].endswith(".py"):
94
+ pkg_parts = (*parts[:-1], parts[-1][:-3])
95
+ else:
96
+ return ""
97
+ if not pkg_parts:
98
+ return "app"
99
+ return "app." + ".".join(pkg_parts)
100
+
101
+
102
+ def _collect_app_module_paths() -> dict[str, Path]:
103
+ import app
104
+
105
+ spec = app.__spec__
106
+ if spec is None or not spec.submodule_search_locations:
107
+ return {}
108
+ seen: dict[str, Path] = {}
109
+ for root_str in spec.submodule_search_locations:
110
+ root = Path(root_str)
111
+ if not root.is_dir():
112
+ continue
113
+ for path in root.rglob("*.py"):
114
+ if "__pycache__" in path.parts:
115
+ continue
116
+ mod_name = _app_py_path_to_module_name(root, path)
117
+ if mod_name and mod_name not in seen:
118
+ seen[mod_name] = path
119
+ return seen
120
+
121
+
122
+ def _discover_server_function_names_from_source(source: str) -> list[str]:
123
+ try:
124
+ tree = ast.parse(source)
125
+ except SyntaxError:
126
+ return []
127
+ blu_pkg, server_dec = collect_blu_server_import_bindings(tree.body)
128
+ names: list[str] = []
129
+ for node in tree.body:
130
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
131
+ continue
132
+ if any(
133
+ decorator_is_server(d, blu_pkg, server_dec) for d in node.decorator_list
134
+ ):
135
+ names.append(node.name)
136
+ return names
137
+
138
+
139
+ def _build_server_function_registry() -> dict[tuple[str, str], Callable[..., Any]]:
140
+ reg: dict[tuple[str, str], Callable[..., Any]] = {}
141
+ for module_name, file_path in _collect_app_module_paths().items():
142
+ try:
143
+ source = file_path.read_text(encoding="utf-8")
144
+ except OSError:
145
+ continue
146
+ fn_names = _discover_server_function_names_from_source(source)
147
+ if not fn_names:
148
+ continue
149
+ try:
150
+ mod = importlib.import_module(module_name)
151
+ except ImportError:
152
+ continue
153
+ for name in fn_names:
154
+ fn = getattr(mod, name, None)
155
+ if fn is not None and _is_registered_server_function(fn):
156
+ reg[(module_name, name)] = fn
157
+ return reg
158
+
159
+
160
+ def refresh_server_function_registry() -> None:
161
+ global _server_function_registry
162
+ _server_function_registry = _build_server_function_registry()
163
+
164
+
165
+ def _ensure_server_function_registry() -> None:
166
+ global _server_function_registry
167
+ if _server_function_registry is None:
168
+ refresh_server_function_registry()
169
+
170
+
171
+ def lookup_server_function(module_name: str, fn_name: str) -> Callable[..., Any] | None:
172
+ """
173
+ Return the registered server callable for ``(module_name, fn_name)`` if any.
174
+ Lazily builds the registry when lifespan has not run (e.g. minimal ASGI hosts).
175
+ """
176
+ _ensure_server_function_registry()
177
+ assert _server_function_registry is not None
178
+ if not _rpc_key_well_formed(module_name, fn_name):
179
+ return None
180
+ return _server_function_registry.get((module_name, fn_name))
181
+
182
+
183
+ def _source_defines_top_level_server_function(source: str, fn_name: str) -> bool:
184
+ try:
185
+ tree = ast.parse(source)
186
+ except SyntaxError:
187
+ return False
188
+ blu_pkg, server_dec = collect_blu_server_import_bindings(tree.body)
189
+ for node in tree.body:
190
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
191
+ continue
192
+ if node.name != fn_name:
193
+ continue
194
+ return any(
195
+ decorator_is_server(d, blu_pkg, server_dec) for d in node.decorator_list
196
+ )
197
+ return False
198
+
199
+
200
+ async def _invoke_server_function_remote(
201
+ module: str,
202
+ name: str,
203
+ args: tuple[Any, ...],
204
+ kwargs: dict[str, Any],
205
+ ) -> Any:
206
+ from pyodide.http import pyfetch # type: ignore
207
+
208
+ payload = json.dumps(
209
+ {"module": module, "name": name, "args": list(args), "kwargs": kwargs}
210
+ )
211
+ response = await pyfetch(
212
+ "/_blu_internal/server_function",
213
+ method="POST",
214
+ body=payload,
215
+ headers={"Content-Type": "application/json"},
216
+ )
217
+ text = await response.string()
218
+ data = json.loads(text)
219
+ if not data.get("ok"):
220
+ raise RuntimeError(data.get("error", "server function call failed"))
221
+ return pickle.loads(base64.b64decode(data["result_b64"]))
222
+
223
+
224
+ def _wrap_client(callable: Callable[..., Any]) -> Callable[..., Awaitable[Any]]:
225
+ module = callable.__module__
226
+ name = callable.__name__
227
+
228
+ @functools.wraps(callable)
229
+ async def client_stub(*args: Any, **kwargs: Any) -> Any:
230
+ return await _invoke_server_function_remote(module, name, args, kwargs)
231
+
232
+ setattr(client_stub, _BLU_SERVER_FN_ATTR, True)
233
+ return client_stub
234
+
235
+
236
+ def _wrap_server(callable: Callable[..., Any]) -> Callable[..., Awaitable[Any]]:
237
+ if inspect.iscoroutinefunction(callable):
238
+ setattr(callable, _BLU_SERVER_FN_ATTR, True)
239
+ return callable
240
+
241
+ @functools.wraps(callable)
242
+ async def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
243
+ return await asyncio.to_thread(callable, *args, **kwargs)
244
+
245
+ setattr(sync_wrapper, _BLU_SERVER_FN_ATTR, True)
246
+ return sync_wrapper
247
+
248
+
249
+ def server[
250
+ **P, R
251
+ ](callable: Callable[P, R | Awaitable[R]],) -> Callable[P, Awaitable[R]]:
252
+ """
253
+ Create a server function that can be called from the client.
254
+
255
+ .. code-block:: python
256
+ :caption: app/server_functions.py
257
+
258
+ from blu import server
259
+
260
+
261
+ @server
262
+ def read_greeting_from_file(name):
263
+ with open('app/hello.txt', 'r') as f:
264
+ return f.read() + name
265
+
266
+ .. code-block:: none
267
+ :caption: app/hello.txt
268
+
269
+ Hello,
270
+
271
+ .. code-block:: python
272
+ :caption: app/__index__.py
273
+
274
+ from blu import client
275
+ from blu.html import div
276
+
277
+ __client__ = True
278
+
279
+
280
+ def __page__():
281
+ return MyClientElement
282
+
283
+
284
+ @client
285
+ def MyClientElement():
286
+ return div[read_greeting_from_file('George')]
287
+
288
+ .. code-block:: html
289
+
290
+ <div>Hello, George!</div>
291
+
292
+ :param callable: A :class:`Callable <collections.abc.Callable>`
293
+ :return: An asynchronous function that, when called in a client
294
+ environment, runs ``callable`` on the server with the provided
295
+ arguments, and returns the return value of ``callable``.
296
+
297
+ When calling the returned function in a client environment, the
298
+ arguments provided must be JSON-serializable and the return value
299
+ must be picklable. Otherwise, the function will be unable to
300
+ complete.
301
+
302
+ The function must be defined at the top level of a module in order
303
+ to be accessible client-side.
304
+
305
+ .. code-block:: python
306
+ :caption: Wrong!
307
+
308
+ class A:
309
+
310
+ @server
311
+ def func():
312
+ return 1
313
+
314
+
315
+ .. code-block:: python
316
+ :caption: Wrong!
317
+
318
+ def func_factory():
319
+
320
+ @server
321
+ def func():
322
+ return 1
323
+
324
+ return func
325
+
326
+ .. code-block:: python
327
+ :caption: Right.
328
+
329
+ @server
330
+ def func():
331
+ return 1
332
+ """
333
+ if is_client:
334
+ return _wrap_client(callable)
335
+ return _wrap_server(callable)
336
+
337
+
338
+ async def handle_server_function_request(
339
+ receive: Callable[[], Awaitable[dict[str, Any]]],
340
+ send: Callable[[dict[str, Any]], Awaitable[None]],
341
+ ) -> None:
342
+ body = await _read_http_request_body(receive)
343
+ try:
344
+ data = json.loads(body.decode("utf-8"))
345
+ module_name = data["module"]
346
+ fn_name = data["name"]
347
+ args = data.get("args") or []
348
+ kwargs = data.get("kwargs") or {}
349
+ except (json.JSONDecodeError, KeyError, UnicodeDecodeError) as e:
350
+ await _send_server_function_json(
351
+ send,
352
+ 400,
353
+ {"ok": False, "error": f"invalid request"},
354
+ )
355
+ return
356
+
357
+ fn = lookup_server_function(module_name, fn_name)
358
+ if fn is None:
359
+ await _send_server_function_json(
360
+ send,
361
+ 404,
362
+ {"ok": False, "error": "Not Found"},
363
+ )
364
+ return
365
+
366
+ try:
367
+ if inspect.iscoroutinefunction(fn):
368
+ result = await fn(*args, **kwargs)
369
+ else:
370
+ result = await asyncio.to_thread(fn, *args, **kwargs)
371
+ except Exception as e:
372
+ await _send_server_function_json(
373
+ send,
374
+ 500,
375
+ {"ok": False, "error": ""},
376
+ )
377
+ return
378
+
379
+ pickled = pickle.dumps(result)
380
+ b64 = base64.b64encode(pickled).decode("ascii")
381
+ await _send_server_function_json(
382
+ send,
383
+ 200,
384
+ {"ok": True, "result_b64": b64},
385
+ )
386
+
387
+
388
+ async def _read_http_request_body(
389
+ receive: Callable[[], Awaitable[dict[str, Any]]],
390
+ ) -> bytes:
391
+ body = b""
392
+ while True:
393
+ message = await receive()
394
+ if message["type"] != "http.request":
395
+ continue
396
+ body += message.get("body", b"")
397
+ if not message.get("more_body", False):
398
+ break
399
+ return body
400
+
401
+
402
+ async def _send_server_function_json(
403
+ send: Callable[[dict[str, Any]], Awaitable[None]],
404
+ status: int,
405
+ payload: dict[str, Any],
406
+ ) -> None:
407
+ raw = json.dumps(payload).encode("utf-8")
408
+ await send(
409
+ {
410
+ "type": "http.response.start",
411
+ "status": status,
412
+ "headers": [
413
+ (b"Content-Type", b"application/json; charset=utf-8"),
414
+ ],
415
+ }
416
+ )
417
+ await send(
418
+ {
419
+ "type": "http.response.body",
420
+ "body": raw,
421
+ "more_body": False,
422
+ }
423
+ )
blu/_settings.py ADDED
@@ -0,0 +1,30 @@
1
+ from collections.abc import Callable
2
+ from dataclasses import dataclass, field, fields
3
+ import functools
4
+ from typing import Any, cast
5
+
6
+
7
+ @functools.cache
8
+ def settings():
9
+ try:
10
+ from app import __settings__ # type: ignore
11
+ except ImportError:
12
+ return Settings()
13
+ else:
14
+ values: dict[str, Any] = {}
15
+ for field in fields(Settings):
16
+ try:
17
+ values[field.name] = getattr(__settings__, field.name) # type: ignore
18
+ except AttributeError:
19
+ pass
20
+ return Settings(**values)
21
+
22
+
23
+ def default[T](factory: Callable[[], Any]):
24
+ return field(default_factory=cast(Callable[[], T], factory))
25
+
26
+
27
+ @dataclass
28
+ class Settings:
29
+
30
+ CLIENT_REQUIREMENTS: list[str] = default(lambda: [])
blu/_utils/__init__.py ADDED
@@ -0,0 +1,47 @@
1
+ from pathlib import Path
2
+
3
+ from blu._utils.asyncio import io_bound
4
+ from blu._utils.client import is_client
5
+
6
+ if not is_client:
7
+ import shutil
8
+ import socket
9
+
10
+
11
+ def get_available_port() -> int:
12
+ s = socket.socket() # type: ignore
13
+ s.bind(("", 0))
14
+ return s.getsockname()[1]
15
+
16
+
17
+ @io_bound
18
+ def copy_file(src: Path, dest: Path):
19
+ shutil.copyfile(src, dest) # type: ignore
20
+
21
+
22
+ @io_bound
23
+ def copy_tree(src: Path, dest: Path):
24
+ shutil.copytree(src, dest) # type: ignore
25
+
26
+
27
+ @io_bound
28
+ def ensure_dir(path: Path):
29
+ path.mkdir(exist_ok=True, parents=True)
30
+
31
+
32
+ @io_bound
33
+ def list_dir(dir: Path) -> list[Path]:
34
+ return list(dir.iterdir())
35
+
36
+
37
+ @io_bound
38
+ def walk_dir_files(dir: Path) -> list[Path]:
39
+ ret: list[Path] = []
40
+ for subdir, _, filenames in dir.walk():
41
+ for filename in filenames:
42
+ file_path = subdir / filename
43
+ ret.append(file_path)
44
+ return ret
45
+
46
+
47
+ blu_package_root = Path(__file__).parent.parent
blu/_utils/asgi.py ADDED
@@ -0,0 +1,189 @@
1
+ """
2
+ Types based on https://asgi.readthedocs.io/en/latest/specs/www.html
3
+ """
4
+
5
+ from collections.abc import Iterable
6
+
7
+ from blu._utils.typing import Any, Literal, NotRequired, Optional, Protocol, TypedDict
8
+
9
+
10
+ class LifespanScopeASGIInfo(TypedDict):
11
+ version: str
12
+ spec_version: NotRequired[str] # default: '1.0'
13
+
14
+
15
+ class LifespanScope(TypedDict):
16
+ type: Literal["lifespan"]
17
+ asgi: LifespanScopeASGIInfo
18
+ state: NotRequired[
19
+ dict[str, Any]
20
+ ] # if missing, the server does not support this feature
21
+
22
+
23
+ class ConnectionScopeASGIInfo(TypedDict):
24
+ version: str
25
+ # default: '2.0'
26
+ spec_version: NotRequired[Literal["2.0", "2.1", "2.2", "2.3"]]
27
+
28
+
29
+ class ConnectionScope(TypedDict):
30
+ asgi: ConnectionScopeASGIInfo
31
+ path: str
32
+ raw_path: NotRequired[Optional[bytes]] # default: None
33
+ root_path: NotRequired[str] # default: ''
34
+ headers: Iterable[tuple[bytes, bytes]]
35
+ client: NotRequired[tuple[str, int]] # default: None
36
+ server: NotRequired[tuple[str, Optional[int]]] # default: None
37
+ state: NotRequired[
38
+ dict[str, Any]
39
+ ] # if missing, the server does not support this feature
40
+
41
+
42
+ class HTTPConnectionScope(ConnectionScope):
43
+ type: Literal["http"]
44
+ http_version: Literal["1.0", "1.1", "2"]
45
+ method: str
46
+ scheme: NotRequired[str] # default: 'http'. Must not be ''.
47
+ query_string: bytes
48
+
49
+
50
+ class WSConnectionScope(ConnectionScope):
51
+ type: Literal["websocket"]
52
+ http_version: NotRequired[Literal["1.1", "2"]] # default: '1.1'
53
+ scheme: NotRequired[str] # default: 'ws'. Must not be ''.
54
+ subprotocols: NotRequired[Iterable[str]] # default: []
55
+ # if missing or None, Default is ''.
56
+ query_string: NotRequired[Optional[bytes]]
57
+
58
+
59
+ type Scope = HTTPConnectionScope | WSConnectionScope | LifespanScope
60
+
61
+
62
+ class LifespanStartupReceiveEvent(TypedDict):
63
+ type: Literal["lifespan.startup"]
64
+
65
+
66
+ class LifespanStartupCompleteSendEvent(TypedDict):
67
+ type: Literal["lifespan.startup.complete"]
68
+
69
+
70
+ class LifespanStartupFailedSendEvent(TypedDict):
71
+ type: Literal["lifespan.startup.failed"]
72
+ message: NotRequired[str] # default: ''
73
+
74
+
75
+ class LifespanShutdownReceiveEvent(TypedDict):
76
+ type: Literal["lifespan.shutdown"]
77
+
78
+
79
+ class LifespanShutdownCompleteSendEvent(TypedDict):
80
+ type: Literal["lifespan.shutdown.complete"]
81
+
82
+
83
+ class LifespanShutdownFailedSendEvent(TypedDict):
84
+ type: Literal["lifespan.shutdown.failed"]
85
+ message: NotRequired[str] # default: ''
86
+
87
+
88
+ type LifespanReceiveEvent = (LifespanStartupReceiveEvent | LifespanShutdownReceiveEvent)
89
+
90
+ type LifespanSendEvent = (
91
+ LifespanStartupCompleteSendEvent
92
+ | LifespanStartupFailedSendEvent
93
+ | LifespanShutdownCompleteSendEvent
94
+ | LifespanShutdownFailedSendEvent
95
+ )
96
+
97
+
98
+ class HTTPRequestReceiveEvent(TypedDict):
99
+ type: Literal["http.request"]
100
+ body: NotRequired[bytes] # default: b''
101
+ more_body: NotRequired[bool] # default: False
102
+
103
+
104
+ class HTTPDisconnectReceiveEvent(TypedDict):
105
+ type: Literal["http.disconnect"]
106
+
107
+
108
+ type HTTPReceiveEvent = (HTTPRequestReceiveEvent | HTTPDisconnectReceiveEvent)
109
+
110
+
111
+ class WSConnectReceiveEvent(TypedDict):
112
+ type: Literal["websocket.connect"]
113
+
114
+
115
+ class WSReceiveReceiveEvent(TypedDict):
116
+ type: Literal["websocket.receive"]
117
+ bytes: NotRequired[Optional[bytes]] # default: None
118
+ test: NotRequired[Optional[str]] # default: None
119
+ # exactly one of bytes or text must be non-None
120
+
121
+
122
+ class WSDisconnectReceiveEvent(TypedDict):
123
+ type: Literal["websocket.disconnect"]
124
+ code: int
125
+
126
+
127
+ type WSReceiveEvent = (
128
+ WSConnectReceiveEvent | WSReceiveReceiveEvent | WSDisconnectReceiveEvent
129
+ )
130
+
131
+
132
+ type ReceiveEvent = LifespanReceiveEvent | HTTPReceiveEvent | WSReceiveEvent
133
+
134
+
135
+ class Receiver(Protocol):
136
+ async def __call__(self) -> ReceiveEvent: ...
137
+
138
+
139
+ class HTTPResponseStartSendEvent(TypedDict):
140
+ type: Literal["http.response.start"]
141
+ status: int
142
+ headers: NotRequired[Iterable[tuple[bytes, bytes]]] # default: []
143
+ trailers: NotRequired[bool] # default: False
144
+
145
+
146
+ class HTTPResponseBodySendEvent(TypedDict):
147
+ type: Literal["http.response.body"]
148
+ body: NotRequired[bytes] # default: b''
149
+ more_body: NotRequired[bool] # default: False
150
+
151
+
152
+ type HTTPSendEvent = HTTPResponseStartSendEvent | HTTPResponseBodySendEvent
153
+
154
+
155
+ class WSAcceptSendEvent(TypedDict):
156
+ type: Literal["websocket.accept"]
157
+ subprotocol: NotRequired[str] # default: None
158
+ headers: NotRequired[Iterable[tuple[bytes, bytes]]] # default: []
159
+
160
+
161
+ class WSSendSendEvent(TypedDict):
162
+ type: Literal["websocket.send"]
163
+ bytes: NotRequired[Optional[bytes]] # default: None
164
+ text: NotRequired[Optional[str]] # default: None
165
+ # exactly one of bytes or text must be non-None
166
+
167
+
168
+ class WSCloseSendEvent(TypedDict):
169
+ type: Literal["websocket.close"]
170
+ code: NotRequired[int] # default: 1000
171
+ reason: NotRequired[str] # if missing or None, default is ''.
172
+
173
+
174
+ type WSSendEvent = WSAcceptSendEvent | WSSendSendEvent | WSCloseSendEvent
175
+
176
+ type SendEvent = LifespanSendEvent | HTTPSendEvent | WSSendEvent
177
+
178
+
179
+ class Sender(Protocol):
180
+ async def __call__(self, event: SendEvent, /): ...
181
+
182
+
183
+ class App(Protocol):
184
+ async def __call__(
185
+ self,
186
+ scope: Scope,
187
+ receive: Receiver,
188
+ send: Sender,
189
+ ): ...