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/_server_functions.py
ADDED
|
@@ -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
|
+
): ...
|