pyweb-stack 0.1.0__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.
- pyweb/__init__.py +71 -0
- pyweb/app.py +37 -0
- pyweb/app_loader.py +139 -0
- pyweb/asgi.py +71 -0
- pyweb/auth.py +701 -0
- pyweb/bench.py +101 -0
- pyweb/browser.py +232 -0
- pyweb/build.py +295 -0
- pyweb/cache.py +201 -0
- pyweb/cli/__init__.py +465 -0
- pyweb/cli/__main__.py +3 -0
- pyweb/compiler/__init__.py +5 -0
- pyweb/compiler/ast.py +45 -0
- pyweb/compiler/codegen/__init__.py +9 -0
- pyweb/compiler/codegen/ir.py +50 -0
- pyweb/compiler/errors.py +29 -0
- pyweb/compiler/lower.py +733 -0
- pyweb/compiler/parser.py +532 -0
- pyweb/compiler/pipeline.py +158 -0
- pyweb/compiler/pyjs.py +1087 -0
- pyweb/compiler/rpc.py +35 -0
- pyweb/context.py +193 -0
- pyweb/css.py +115 -0
- pyweb/db/__init__.py +652 -0
- pyweb/db/migrate.py +143 -0
- pyweb/decorators.py +49 -0
- pyweb/deploy.py +102 -0
- pyweb/forms.py +108 -0
- pyweb/hosting.py +139 -0
- pyweb/jobs.py +248 -0
- pyweb/live.py +88 -0
- pyweb/lsp.py +214 -0
- pyweb/models.py +269 -0
- pyweb/npm.py +270 -0
- pyweb/observability.py +313 -0
- pyweb/platform.py +69 -0
- pyweb/plugins.py +112 -0
- pyweb/py.typed +0 -0
- pyweb/realtime.py +189 -0
- pyweb/rpc.py +137 -0
- pyweb/runtime/browser/runtime.js +1151 -0
- pyweb/runtime/server/__init__.py +445 -0
- pyweb/security.py +162 -0
- pyweb/serve.py +335 -0
- pyweb/ssr.py +260 -0
- pyweb/sync.py +118 -0
- pyweb/testing.py +154 -0
- pyweb/uploads.py +94 -0
- pyweb_stack-0.1.0.dist-info/METADATA +198 -0
- pyweb_stack-0.1.0.dist-info/RECORD +54 -0
- pyweb_stack-0.1.0.dist-info/WHEEL +5 -0
- pyweb_stack-0.1.0.dist-info/entry_points.txt +2 -0
- pyweb_stack-0.1.0.dist-info/licenses/LICENSE +21 -0
- pyweb_stack-0.1.0.dist-info/top_level.txt +1 -0
pyweb/observability.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""Observability: structured logs, traces, metrics, error mapping.
|
|
2
|
+
|
|
3
|
+
One logical interaction (button click -> RPC -> DB -> DOM update) shares
|
|
4
|
+
a single trace id end to end: the browser runtime sends
|
|
5
|
+
``X-Request-Id``/``traceparent`` headers (see :mod:`pyweb.rpc`), the
|
|
6
|
+
server joins them via :func:`context_from_headers`, and every span,
|
|
7
|
+
log record and error report carries the trace.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import contextvars
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
import traceback
|
|
18
|
+
import uuid
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Logger:
|
|
22
|
+
"""JSON-lines logger. ``Logger("serve")`` names the component;
|
|
23
|
+
``Logger(stream=fh)`` (or a stream positionally) redirects output."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, name=None, stream=None):
|
|
26
|
+
if name is not None and not isinstance(name, str):
|
|
27
|
+
name, stream = None, name
|
|
28
|
+
self.name = name
|
|
29
|
+
self.stream = stream or sys.stdout
|
|
30
|
+
self._lock = threading.Lock()
|
|
31
|
+
|
|
32
|
+
def log(self, level, message, **fields):
|
|
33
|
+
rec = {"ts": time.time(), "level": level, "msg": message, **fields}
|
|
34
|
+
if self.name:
|
|
35
|
+
rec.setdefault("logger", self.name)
|
|
36
|
+
with self._lock:
|
|
37
|
+
self.stream.write(json.dumps(rec) + "\n")
|
|
38
|
+
self.stream.flush()
|
|
39
|
+
|
|
40
|
+
def info(self, message, **fields):
|
|
41
|
+
self.log("info", message, **fields)
|
|
42
|
+
|
|
43
|
+
def error(self, message, **fields):
|
|
44
|
+
self.log("error", message, **fields)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Tracer:
|
|
48
|
+
def __init__(self):
|
|
49
|
+
self.spans: list[dict] = []
|
|
50
|
+
self._local = threading.local()
|
|
51
|
+
|
|
52
|
+
def start(self, name, trace_id=None, **fields):
|
|
53
|
+
trace_id = trace_id or _current_trace.get() or uuid.uuid4().hex[:16]
|
|
54
|
+
span = {"trace": trace_id, "span": uuid.uuid4().hex[:16],
|
|
55
|
+
"name": name, "start": time.time(), "end": None, **fields}
|
|
56
|
+
self.spans.append(span)
|
|
57
|
+
self._local.current = span
|
|
58
|
+
return span
|
|
59
|
+
|
|
60
|
+
def finish(self, span):
|
|
61
|
+
span["end"] = time.time()
|
|
62
|
+
span["dur_ms"] = round((span["end"] - span["start"]) * 1000, 3)
|
|
63
|
+
|
|
64
|
+
def span(self, name, **fields):
|
|
65
|
+
"""``with tracer.span("db.query", table="users"):`` ..."""
|
|
66
|
+
return Span(self, name, **fields)
|
|
67
|
+
|
|
68
|
+
def trace(self, name, trace_id=None):
|
|
69
|
+
if trace_id is not None:
|
|
70
|
+
_current_trace.set(trace_id)
|
|
71
|
+
return self.span(name)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Metrics:
|
|
75
|
+
def __init__(self):
|
|
76
|
+
self.counters: dict[str, int] = {}
|
|
77
|
+
self.timings: dict[str, list[float]] = {}
|
|
78
|
+
self._lock = threading.Lock()
|
|
79
|
+
|
|
80
|
+
def inc(self, name, amount=1):
|
|
81
|
+
with self._lock:
|
|
82
|
+
self.counters[name] = self.counters.get(name, 0) + amount
|
|
83
|
+
|
|
84
|
+
def observe(self, name, value):
|
|
85
|
+
with self._lock:
|
|
86
|
+
self.timings.setdefault(name, []).append(value)
|
|
87
|
+
|
|
88
|
+
def summary(self):
|
|
89
|
+
with self._lock:
|
|
90
|
+
out = {"counters": dict(self.counters)}
|
|
91
|
+
for k, vals in self.timings.items():
|
|
92
|
+
out[k] = {"n": len(vals), "avg": sum(vals) / len(vals) if vals else 0,
|
|
93
|
+
"max": max(vals) if vals else 0}
|
|
94
|
+
return out
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def format_error(exc, source_lines=None, filename="<pyweb>"):
|
|
98
|
+
"""Map an exception to a PyWeb-style error with source context."""
|
|
99
|
+
tb = traceback.extract_tb(exc.__traceback__)
|
|
100
|
+
frame = tb[-1] if tb else None
|
|
101
|
+
out = [f"{type(exc).__name__}: {exc}"]
|
|
102
|
+
if frame is not None:
|
|
103
|
+
out.append(f"{frame.filename}:{frame.lineno}")
|
|
104
|
+
if source_lines and 0 < frame.lineno <= len(source_lines):
|
|
105
|
+
i = frame.lineno
|
|
106
|
+
start = max(0, i - 3)
|
|
107
|
+
for n, line in enumerate(source_lines[start:i + 1], start=start + 1):
|
|
108
|
+
mark = ">>" if n == i else " "
|
|
109
|
+
out.append(f"{mark} {n:4d} | {line}")
|
|
110
|
+
return "\n".join(out)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class Timer:
|
|
114
|
+
def __enter__(self):
|
|
115
|
+
self._start = time.time()
|
|
116
|
+
self.elapsed_s = 0.0
|
|
117
|
+
return self
|
|
118
|
+
|
|
119
|
+
def __exit__(self, *exc):
|
|
120
|
+
self.elapsed_s = time.time() - self._start
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
# Distributed trace context (W3C traceparent compatible)
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
_current_trace: contextvars.ContextVar = contextvars.ContextVar(
|
|
129
|
+
"pyweb_trace", default=None)
|
|
130
|
+
_current_span: contextvars.ContextVar = contextvars.ContextVar(
|
|
131
|
+
"pyweb_span", default=None)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def new_trace() -> str:
|
|
135
|
+
"""Start a fresh 16-hex-char trace id for an inbound request."""
|
|
136
|
+
trace_id = uuid.uuid4().hex[:16]
|
|
137
|
+
_current_trace.set(trace_id)
|
|
138
|
+
return trace_id
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def context_from_headers(headers: dict) -> str:
|
|
142
|
+
"""Join an incoming trace or start a new one.
|
|
143
|
+
|
|
144
|
+
Accepts ``traceparent`` (``00-<trace>-<span>-<flags>``) and
|
|
145
|
+
``X-Request-Id``; falls back to :func:`new_trace`. Returns the
|
|
146
|
+
active trace id. Why both: browsers that cannot set custom headers
|
|
147
|
+
(plain form posts, SSE) still send X-Request-Id via query or the
|
|
148
|
+
runtime's fetch wrapper.
|
|
149
|
+
"""
|
|
150
|
+
headers = {str(k).lower(): v for k, v in (headers or {}).items()}
|
|
151
|
+
tp = headers.get("traceparent", "")
|
|
152
|
+
parts = tp.split("-")
|
|
153
|
+
if len(parts) == 4 and len(parts[1]) == 32:
|
|
154
|
+
trace_id = parts[1][:16]
|
|
155
|
+
else:
|
|
156
|
+
trace_id = str(headers.get("x-request-id", "")) or ""
|
|
157
|
+
if not trace_id:
|
|
158
|
+
return new_trace()
|
|
159
|
+
_current_trace.set(trace_id[:16])
|
|
160
|
+
return _current_trace.get()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def active_trace() -> str | None:
|
|
164
|
+
return _current_trace.get()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def inject_headers(headers: dict | None = None) -> dict:
|
|
168
|
+
"""Outgoing headers carrying the active trace (client/RPC use)."""
|
|
169
|
+
headers = dict(headers or {})
|
|
170
|
+
trace_id = _current_trace.get()
|
|
171
|
+
if trace_id:
|
|
172
|
+
headers.setdefault("X-Request-Id", trace_id)
|
|
173
|
+
span = _current_span.get() or uuid.uuid4().hex[:16]
|
|
174
|
+
headers.setdefault("traceparent", f"00-{trace_id}-{(span)[:16]}-01")
|
|
175
|
+
return headers
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class Span:
|
|
179
|
+
"""Trace span usable as ``with tracer.span("db.query"):``."""
|
|
180
|
+
|
|
181
|
+
def __init__(self, tracer, name, **fields):
|
|
182
|
+
self._tracer = tracer
|
|
183
|
+
self._name = name
|
|
184
|
+
self._fields = fields
|
|
185
|
+
self.span = None
|
|
186
|
+
|
|
187
|
+
def __enter__(self):
|
|
188
|
+
trace_id = _current_trace.get() or new_trace()
|
|
189
|
+
self.span = self._tracer.start(
|
|
190
|
+
self._name, trace_id=trace_id, **self._fields)
|
|
191
|
+
_current_span.set(self.span.get("span", ""))
|
|
192
|
+
return self.span
|
|
193
|
+
|
|
194
|
+
def __exit__(self, *exc):
|
|
195
|
+
self._tracer.finish(self.span)
|
|
196
|
+
_current_span.set(None)
|
|
197
|
+
return False
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ---------------------------------------------------------------------------
|
|
201
|
+
# Error taxonomy: every failure maps to a stable code + safe message.
|
|
202
|
+
# ---------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
_ERROR_CODES = [
|
|
205
|
+
(ValueError, "bad-request"),
|
|
206
|
+
(KeyError, "not-found"),
|
|
207
|
+
(PermissionError, "forbidden"),
|
|
208
|
+
]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def register_error_code(exc_type, code: str):
|
|
212
|
+
"""Map an exception class to a stable wire code (checked most specific first)."""
|
|
213
|
+
_ERROR_CODES.insert(0, (exc_type, code))
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def error_code(exc: BaseException) -> str:
|
|
217
|
+
for exc_type, code in _ERROR_CODES:
|
|
218
|
+
if isinstance(exc, exc_type):
|
|
219
|
+
return code
|
|
220
|
+
return "internal"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def error_report(exc: BaseException, *, source_lines=None,
|
|
224
|
+
filename="<pyweb>", safe_detail=True) -> dict:
|
|
225
|
+
"""Structured error payload: code, trace, source context.
|
|
226
|
+
|
|
227
|
+
``message`` is safe to send to browsers; the original ``repr`` stays
|
|
228
|
+
server-side in logs. ``safe_detail=False`` is for CLI/dev only.
|
|
229
|
+
"""
|
|
230
|
+
text = format_error(exc, source_lines, filename)
|
|
231
|
+
lines = text.splitlines()
|
|
232
|
+
return {"code": error_code(exc),
|
|
233
|
+
"message": lines[0] if lines else type(exc).__name__,
|
|
234
|
+
"detail": text if not safe_detail else None,
|
|
235
|
+
"trace": active_trace(),
|
|
236
|
+
"type": type(exc).__name__}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# Register framework error codes (import-light: resolved lazily).
|
|
240
|
+
def _register_framework_codes():
|
|
241
|
+
try:
|
|
242
|
+
from pyweb.rpc import RPCError
|
|
243
|
+
register_error_code(RPCError, "rpc")
|
|
244
|
+
except ImportError:
|
|
245
|
+
pass
|
|
246
|
+
try:
|
|
247
|
+
from pyweb import auth as _auth
|
|
248
|
+
register_error_code(_auth.AuthError, "unauthenticated")
|
|
249
|
+
register_error_code(_auth.Forbidden, "forbidden")
|
|
250
|
+
register_error_code(_auth.NotAuthenticated, "unauthenticated")
|
|
251
|
+
except ImportError:
|
|
252
|
+
pass
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
_register_framework_codes()
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
# ---------------------------------------------------------------------------
|
|
259
|
+
# Time-travel event log: deterministic record/replay of state changes.
|
|
260
|
+
# ---------------------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
class EventLog:
|
|
263
|
+
"""Append-only ring buffer of state/RPC/DB events for debugging.
|
|
264
|
+
|
|
265
|
+
``record("signal", name="count", old=2, new=3)`` captures what the
|
|
266
|
+
DevTools timeline shows. ``replay()`` re-applies signal events to a
|
|
267
|
+
namespace dict so a developer can step through what happened.
|
|
268
|
+
Bounded (default 10k events) so it is safe to leave on in staging.
|
|
269
|
+
"""
|
|
270
|
+
|
|
271
|
+
def __init__(self, capacity=10_000):
|
|
272
|
+
self._capacity = capacity
|
|
273
|
+
self._events: list[dict] = []
|
|
274
|
+
self._lock = threading.Lock()
|
|
275
|
+
self._seq = 0
|
|
276
|
+
|
|
277
|
+
def record(self, kind, **fields):
|
|
278
|
+
with self._lock:
|
|
279
|
+
self._seq += 1
|
|
280
|
+
event = {"seq": self._seq, "ts": time.time(), "kind": kind,
|
|
281
|
+
"trace": _current_trace.get(), **fields}
|
|
282
|
+
self._events.append(event)
|
|
283
|
+
del self._events[:-self._capacity]
|
|
284
|
+
return event
|
|
285
|
+
|
|
286
|
+
def since(self, seq=0, kind=None, limit=1000):
|
|
287
|
+
with self._lock:
|
|
288
|
+
events = [e for e in self._events if e["seq"] > seq]
|
|
289
|
+
if kind is not None:
|
|
290
|
+
events = [e for e in events if e["kind"] == kind]
|
|
291
|
+
return events[:limit]
|
|
292
|
+
|
|
293
|
+
def replay(self, namespace: dict, *, up_to=None):
|
|
294
|
+
"""Re-apply ``signal`` events to ``namespace``; returns final seq."""
|
|
295
|
+
last = 0
|
|
296
|
+
for event in self.since(0):
|
|
297
|
+
if up_to is not None and event["seq"] > up_to:
|
|
298
|
+
break
|
|
299
|
+
if event["kind"] == "signal" and "name" in event:
|
|
300
|
+
namespace[event["name"]] = event.get("new")
|
|
301
|
+
last = event["seq"]
|
|
302
|
+
return last
|
|
303
|
+
|
|
304
|
+
def to_json(self):
|
|
305
|
+
with self._lock:
|
|
306
|
+
return json.dumps(self._events)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
_default_log = EventLog()
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def record(kind, **fields):
|
|
313
|
+
return _default_log.record(kind, **fields)
|
pyweb/platform.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Multi-target platform branches: web / mobile / desktop / edge.
|
|
2
|
+
|
|
3
|
+
```python
|
|
4
|
+
from pyweb import platform
|
|
5
|
+
|
|
6
|
+
if platform.web:
|
|
7
|
+
<Article>...</Article>
|
|
8
|
+
|
|
9
|
+
if platform.mobile:
|
|
10
|
+
<NativeList>...</NativeList>
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
On the server all flags except ``server`` are False; the compiler
|
|
14
|
+
evaluates the branch for its target and prunes dead branches per
|
|
15
|
+
output (web bundle drops mobile code and vice versa). Shared business
|
|
16
|
+
logic lives outside the branch. This is intentionally *not* a
|
|
17
|
+
write-once-render-everywhere widget set — each target keeps its
|
|
18
|
+
native controls; PyWeb shares state, RPC, models and routing.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import os
|
|
24
|
+
|
|
25
|
+
_TARGET = os.environ.get("PYWEB_TARGET", "web")
|
|
26
|
+
|
|
27
|
+
web = _TARGET in ("web", "server")
|
|
28
|
+
mobile = _TARGET in ("ios", "android")
|
|
29
|
+
desktop = _TARGET in ("desktop",)
|
|
30
|
+
server = True
|
|
31
|
+
edge = _TARGET == "edge"
|
|
32
|
+
|
|
33
|
+
#: All known targets (used by the compiler to prune branches).
|
|
34
|
+
TARGETS = ("web", "ios", "android", "desktop", "edge", "server")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def target() -> str:
|
|
38
|
+
return _TARGET
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def prune(source: str, keep: str) -> str:
|
|
42
|
+
"""Prune platform branches for ``keep`` from ``source`` text.
|
|
43
|
+
|
|
44
|
+
Keeps ``if platform.<keep>:`` blocks, drops the other platform
|
|
45
|
+
branches, unwraps shared code. Operates on source text with ``ast``
|
|
46
|
+
so pruning is exact, not regex-based.
|
|
47
|
+
"""
|
|
48
|
+
import ast as _ast
|
|
49
|
+
tree = _ast.parse(source)
|
|
50
|
+
keep_attr = {"ios": "mobile", "android": "mobile"}.get(keep, keep)
|
|
51
|
+
|
|
52
|
+
class Pruner(_ast.NodeTransformer):
|
|
53
|
+
def visit_If(self, node): # noqa: N802
|
|
54
|
+
self.generic_visit(node)
|
|
55
|
+
test = node.test
|
|
56
|
+
if (isinstance(test, _ast.Attribute)
|
|
57
|
+
and isinstance(test.value, _ast.Name)
|
|
58
|
+
and test.value.id == "platform"):
|
|
59
|
+
if test.attr == keep_attr or (
|
|
60
|
+
keep_attr == "mobile" and test.attr == "mobile"):
|
|
61
|
+
return node.body
|
|
62
|
+
if test.attr in ("web", "mobile", "desktop", "edge",
|
|
63
|
+
"server"):
|
|
64
|
+
return node.orelse or None
|
|
65
|
+
return node
|
|
66
|
+
|
|
67
|
+
pruned = Pruner().visit(tree)
|
|
68
|
+
_ast.fix_missing_locations(pruned)
|
|
69
|
+
return _ast.unparse(pruned)
|
pyweb/plugins.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Plugin architecture: typed, compiler-visible extensions.
|
|
2
|
+
|
|
3
|
+
A plugin participates in the application graph instead of hiding
|
|
4
|
+
behind stringly-typed hooks — it declares routes, RPC functions,
|
|
5
|
+
models, browser bindings and build steps with types, so placement,
|
|
6
|
+
security checks and codegen keep working across plugin boundaries.
|
|
7
|
+
|
|
8
|
+
```python
|
|
9
|
+
stripe = Plugin("stripe", version="1.0.0")
|
|
10
|
+
|
|
11
|
+
@stripe.server
|
|
12
|
+
def checkout(cart_id: str) -> str:
|
|
13
|
+
...
|
|
14
|
+
|
|
15
|
+
app = App(plugins=[stripe])
|
|
16
|
+
```
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Plugin:
|
|
23
|
+
"""Namespace for plugin-provided routes, RPC, models and assets."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, name, *, version="0.1.0"):
|
|
26
|
+
self.name = name
|
|
27
|
+
self.version = version
|
|
28
|
+
self.routes: list = []
|
|
29
|
+
self.rpc: list = []
|
|
30
|
+
self.models: list = []
|
|
31
|
+
self.browser_bindings: dict = {}
|
|
32
|
+
self.build_steps: list = []
|
|
33
|
+
self.config_schema: dict = {}
|
|
34
|
+
self.config: dict = {}
|
|
35
|
+
|
|
36
|
+
def configure(self, **config):
|
|
37
|
+
"""Validate ``config`` against the plugin's schema, store it."""
|
|
38
|
+
for key, spec in self.config_schema.items():
|
|
39
|
+
if spec.get("required") and key not in config:
|
|
40
|
+
raise ValueError(
|
|
41
|
+
f"plugin {self.name!r} requires config key {key!r}")
|
|
42
|
+
self.config.update(config)
|
|
43
|
+
return self
|
|
44
|
+
|
|
45
|
+
def server(self, fn=None, **opts):
|
|
46
|
+
from pyweb.decorators import server as _server
|
|
47
|
+
if fn is None:
|
|
48
|
+
return lambda f: self.server(f, **opts)
|
|
49
|
+
fn = _server(fn)
|
|
50
|
+
fn.__pyweb_plugin__ = self.name
|
|
51
|
+
self.rpc.append(fn)
|
|
52
|
+
return fn
|
|
53
|
+
|
|
54
|
+
def page(self, route, **opts):
|
|
55
|
+
def deco(fn):
|
|
56
|
+
fn.__pyweb_route__ = route
|
|
57
|
+
fn.__pyweb_plugin__ = self.name
|
|
58
|
+
self.routes.append((route, fn))
|
|
59
|
+
return fn
|
|
60
|
+
return deco
|
|
61
|
+
|
|
62
|
+
def model(self, cls):
|
|
63
|
+
cls.__pyweb_plugin__ = self.name
|
|
64
|
+
self.models.append(cls)
|
|
65
|
+
return cls
|
|
66
|
+
|
|
67
|
+
def browser_binding(self, name, js):
|
|
68
|
+
"""Declare a JS global the plugin's browser code needs."""
|
|
69
|
+
self.browser_bindings[name] = js
|
|
70
|
+
return js
|
|
71
|
+
|
|
72
|
+
def on_build(self, fn):
|
|
73
|
+
"""Register a ``fn(manifest, out_dir)`` build step."""
|
|
74
|
+
self.build_steps.append(fn)
|
|
75
|
+
return fn
|
|
76
|
+
|
|
77
|
+
def describe(self) -> dict:
|
|
78
|
+
return {"name": self.name, "version": self.version,
|
|
79
|
+
"routes": [r for r, _ in self.routes],
|
|
80
|
+
"rpc": [f.__name__ for f in self.rpc],
|
|
81
|
+
"models": [m.__name__ for m in self.models],
|
|
82
|
+
"bindings": dict(self.browser_bindings),
|
|
83
|
+
"config": {k: ("***" if "secret" in k.lower() or
|
|
84
|
+
"key" in k.lower() else v)
|
|
85
|
+
for k, v in self.config.items()}}
|
|
86
|
+
|
|
87
|
+
def __repr__(self):
|
|
88
|
+
return f"Plugin({self.name!r}, version={self.version!r})"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class Registry:
|
|
92
|
+
"""All plugins attached to an app; runs build steps in order."""
|
|
93
|
+
|
|
94
|
+
def __init__(self):
|
|
95
|
+
self._plugins: dict[str, Plugin] = {}
|
|
96
|
+
|
|
97
|
+
def add(self, plugin: Plugin):
|
|
98
|
+
if plugin.name in self._plugins:
|
|
99
|
+
raise ValueError(f"duplicate plugin {plugin.name!r}")
|
|
100
|
+
self._plugins[plugin.name] = plugin
|
|
101
|
+
return plugin
|
|
102
|
+
|
|
103
|
+
def get(self, name) -> Plugin:
|
|
104
|
+
return self._plugins[name]
|
|
105
|
+
|
|
106
|
+
def run_build_steps(self, manifest: dict, out_dir: str):
|
|
107
|
+
for plugin in self._plugins.values():
|
|
108
|
+
for step in plugin.build_steps:
|
|
109
|
+
step(manifest, out_dir)
|
|
110
|
+
|
|
111
|
+
def describe(self):
|
|
112
|
+
return {name: p.describe() for name, p in self._plugins.items()}
|
pyweb/py.typed
ADDED
|
File without changes
|
pyweb/realtime.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Realtime: channels/rooms, pub-sub fanout, presence, live-query bus."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Channel:
|
|
9
|
+
def __init__(self, name):
|
|
10
|
+
self.name = name
|
|
11
|
+
self._subs: list = []
|
|
12
|
+
self._presence: dict[str, dict] = {}
|
|
13
|
+
self._lock = threading.Lock()
|
|
14
|
+
|
|
15
|
+
def subscribe(self, fn):
|
|
16
|
+
with self._lock:
|
|
17
|
+
self._subs.append(fn)
|
|
18
|
+
return lambda: self.unsubscribe(fn)
|
|
19
|
+
|
|
20
|
+
def unsubscribe(self, fn):
|
|
21
|
+
with self._lock:
|
|
22
|
+
if fn in self._subs:
|
|
23
|
+
self._subs.remove(fn)
|
|
24
|
+
|
|
25
|
+
def publish(self, message):
|
|
26
|
+
with self._lock:
|
|
27
|
+
subs = list(self._subs)
|
|
28
|
+
for sub in subs:
|
|
29
|
+
sub(message)
|
|
30
|
+
return len(subs)
|
|
31
|
+
|
|
32
|
+
def join(self, client_id, info=None):
|
|
33
|
+
with self._lock:
|
|
34
|
+
self._presence[client_id] = info or {}
|
|
35
|
+
return len(self._presence)
|
|
36
|
+
|
|
37
|
+
def leave(self, client_id):
|
|
38
|
+
with self._lock:
|
|
39
|
+
self._presence.pop(client_id, None)
|
|
40
|
+
|
|
41
|
+
def members(self):
|
|
42
|
+
with self._lock:
|
|
43
|
+
return dict(self._presence)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Bus:
|
|
47
|
+
def __init__(self):
|
|
48
|
+
self.channels: dict[str, Channel] = {}
|
|
49
|
+
self._lock = threading.Lock()
|
|
50
|
+
self._seq = 0
|
|
51
|
+
self._log: dict[str, list[tuple[int, object]]] = {}
|
|
52
|
+
|
|
53
|
+
def channel(self, name):
|
|
54
|
+
with self._lock:
|
|
55
|
+
if name not in self.channels:
|
|
56
|
+
self.channels[name] = Channel(name)
|
|
57
|
+
return self.channels[name]
|
|
58
|
+
|
|
59
|
+
def publish(self, channel_name, message):
|
|
60
|
+
with self._lock:
|
|
61
|
+
self._seq += 1
|
|
62
|
+
entry = (self._seq, message)
|
|
63
|
+
self._log.setdefault(channel_name, []).append(entry)
|
|
64
|
+
self._log[channel_name] = self._log[channel_name][-100:]
|
|
65
|
+
return self.channel(channel_name).publish(message)
|
|
66
|
+
|
|
67
|
+
def since(self, channel_name, last_id=0, limit=50):
|
|
68
|
+
"""Messages on a channel after last_id (poll fallback)."""
|
|
69
|
+
with self._lock:
|
|
70
|
+
entries = [e for e in self._log.get(channel_name, []) if e[0] > last_id]
|
|
71
|
+
return entries[:limit]
|
|
72
|
+
|
|
73
|
+
def notify_table(self, table, row=None):
|
|
74
|
+
"""Fanout for live DB queries watching a table."""
|
|
75
|
+
return self.publish(f"db:{table}", {"table": table, "row": row})
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def sse_format(seq, channel_name, message):
|
|
79
|
+
"""One SSE frame: id/event/data lines. Client filters by event name."""
|
|
80
|
+
import json as _json
|
|
81
|
+
data = message if isinstance(message, str) else _json.dumps(message)
|
|
82
|
+
return f"id: {seq}\nevent: {channel_name}\ndata: {data}\n\n"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
_default_bus = Bus()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def realtime(fn=None, *, channel=None):
|
|
89
|
+
def deco(f):
|
|
90
|
+
f.__pyweb_realtime__ = channel or f.__name__
|
|
91
|
+
return f
|
|
92
|
+
return deco(fn) if fn else deco
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class RedisBus(Bus):
|
|
96
|
+
"""Cross-process fanout over Redis pub/sub + streams.
|
|
97
|
+
|
|
98
|
+
Local subscribers fire synchronously (same API as :class:`Bus`);
|
|
99
|
+
every publish is also ``XADD``-ed to a per-channel stream
|
|
100
|
+
(``pyweb:stream:<channel>``) so other processes/hosts can catch up
|
|
101
|
+
via ``since()``, and ``PUBLISH``-ed for live wakeups. ``since()``
|
|
102
|
+
merges the local log with the Redis stream. Without ``redis``
|
|
103
|
+
installed (or when the server is unreachable) it degrades to the
|
|
104
|
+
in-memory :class:`Bus` instead of raising at import time; the
|
|
105
|
+
error surfaces on first publish.
|
|
106
|
+
|
|
107
|
+
Why both stream + pubsub: streams give durable history/resume,
|
|
108
|
+
pubsub gives low-latency wakeups without polling. Alternatives
|
|
109
|
+
considered: Postgres LISTEN/NOTIFY (no history), bare pubsub
|
|
110
|
+
(no resume after disconnect).
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
def __init__(self, url="redis://localhost:6379/0", client=None,
|
|
114
|
+
prefix="pyweb:"):
|
|
115
|
+
super().__init__()
|
|
116
|
+
self._prefix = prefix
|
|
117
|
+
if client is not None:
|
|
118
|
+
self._r = client
|
|
119
|
+
else:
|
|
120
|
+
try:
|
|
121
|
+
import redis
|
|
122
|
+
except ImportError as e:
|
|
123
|
+
raise RuntimeError(
|
|
124
|
+
"RedisBus requires the 'redis' package: "
|
|
125
|
+
"pip install redis") from e
|
|
126
|
+
self._r = redis.Redis.from_url(url)
|
|
127
|
+
self._broken = None
|
|
128
|
+
|
|
129
|
+
def _stream_key(self, channel_name):
|
|
130
|
+
return f"{self._prefix}stream:{channel_name}"
|
|
131
|
+
|
|
132
|
+
def _pub_key(self, channel_name):
|
|
133
|
+
return f"{self._prefix}live:{channel_name}"
|
|
134
|
+
|
|
135
|
+
def _seq_key(self, channel_name):
|
|
136
|
+
return f"{self._prefix}seq:{channel_name}"
|
|
137
|
+
|
|
138
|
+
def publish(self, channel_name, message):
|
|
139
|
+
"""Publish to local subscribers and to Redis.
|
|
140
|
+
|
|
141
|
+
Sequence numbers come from a per-channel Redis counter and double
|
|
142
|
+
as stream IDs (``<seq>-1``), so every process agrees on ids and
|
|
143
|
+
``since(last_id)`` resumes exactly where a client left off.
|
|
144
|
+
"""
|
|
145
|
+
import json as _json
|
|
146
|
+
if self._broken:
|
|
147
|
+
return super().publish(channel_name, message)
|
|
148
|
+
try:
|
|
149
|
+
body = message if isinstance(message, str) else _json.dumps(message)
|
|
150
|
+
seq = int(self._r.incr(self._seq_key(channel_name)))
|
|
151
|
+
self._r.xadd(self._stream_key(channel_name), {"body": body},
|
|
152
|
+
id=f"{seq}-1", maxlen=1000, approximate=True)
|
|
153
|
+
self._r.publish(self._pub_key(channel_name), body)
|
|
154
|
+
except Exception as exc: # noqa: BLE001 — degrade, don't crash app
|
|
155
|
+
self._broken = exc
|
|
156
|
+
return super().publish(channel_name, message)
|
|
157
|
+
with self._lock:
|
|
158
|
+
self._seq = max(self._seq, seq)
|
|
159
|
+
log = self._log.setdefault(channel_name, [])
|
|
160
|
+
log.append((seq, message))
|
|
161
|
+
self._log[channel_name] = log[-100:]
|
|
162
|
+
return self.channel(channel_name).publish(message)
|
|
163
|
+
|
|
164
|
+
def since(self, channel_name, last_id=0, limit=50):
|
|
165
|
+
"""Messages after ``last_id`` from the shared Redis stream."""
|
|
166
|
+
if self._broken:
|
|
167
|
+
return super().since(channel_name, last_id, limit)
|
|
168
|
+
try:
|
|
169
|
+
import json as _json
|
|
170
|
+
entries = self._r.xrange(self._stream_key(channel_name),
|
|
171
|
+
min=f"({int(last_id)}-1", max="+", count=limit)
|
|
172
|
+
out = []
|
|
173
|
+
for seq_raw, fields in entries:
|
|
174
|
+
sid = seq_raw.decode() if isinstance(seq_raw, bytes) else seq_raw
|
|
175
|
+
seq = int(sid.split("-")[0])
|
|
176
|
+
raw = fields.get(b"body", fields.get("body", b""))
|
|
177
|
+
body = raw.decode() if isinstance(raw, bytes) else raw
|
|
178
|
+
try:
|
|
179
|
+
out.append((seq, _json.loads(body)))
|
|
180
|
+
except (ValueError, TypeError):
|
|
181
|
+
out.append((seq, body))
|
|
182
|
+
return out
|
|
183
|
+
except Exception as exc: # noqa: BLE001
|
|
184
|
+
self._broken = exc
|
|
185
|
+
return super().since(channel_name, last_id, limit)
|
|
186
|
+
|
|
187
|
+
def history(self, channel_name, limit=50):
|
|
188
|
+
"""Newest-first convenience wrapper used by chat/presence UIs."""
|
|
189
|
+
return list(reversed(self.since(channel_name, 0, limit)))
|