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.
Files changed (54) hide show
  1. pyweb/__init__.py +71 -0
  2. pyweb/app.py +37 -0
  3. pyweb/app_loader.py +139 -0
  4. pyweb/asgi.py +71 -0
  5. pyweb/auth.py +701 -0
  6. pyweb/bench.py +101 -0
  7. pyweb/browser.py +232 -0
  8. pyweb/build.py +295 -0
  9. pyweb/cache.py +201 -0
  10. pyweb/cli/__init__.py +465 -0
  11. pyweb/cli/__main__.py +3 -0
  12. pyweb/compiler/__init__.py +5 -0
  13. pyweb/compiler/ast.py +45 -0
  14. pyweb/compiler/codegen/__init__.py +9 -0
  15. pyweb/compiler/codegen/ir.py +50 -0
  16. pyweb/compiler/errors.py +29 -0
  17. pyweb/compiler/lower.py +733 -0
  18. pyweb/compiler/parser.py +532 -0
  19. pyweb/compiler/pipeline.py +158 -0
  20. pyweb/compiler/pyjs.py +1087 -0
  21. pyweb/compiler/rpc.py +35 -0
  22. pyweb/context.py +193 -0
  23. pyweb/css.py +115 -0
  24. pyweb/db/__init__.py +652 -0
  25. pyweb/db/migrate.py +143 -0
  26. pyweb/decorators.py +49 -0
  27. pyweb/deploy.py +102 -0
  28. pyweb/forms.py +108 -0
  29. pyweb/hosting.py +139 -0
  30. pyweb/jobs.py +248 -0
  31. pyweb/live.py +88 -0
  32. pyweb/lsp.py +214 -0
  33. pyweb/models.py +269 -0
  34. pyweb/npm.py +270 -0
  35. pyweb/observability.py +313 -0
  36. pyweb/platform.py +69 -0
  37. pyweb/plugins.py +112 -0
  38. pyweb/py.typed +0 -0
  39. pyweb/realtime.py +189 -0
  40. pyweb/rpc.py +137 -0
  41. pyweb/runtime/browser/runtime.js +1151 -0
  42. pyweb/runtime/server/__init__.py +445 -0
  43. pyweb/security.py +162 -0
  44. pyweb/serve.py +335 -0
  45. pyweb/ssr.py +260 -0
  46. pyweb/sync.py +118 -0
  47. pyweb/testing.py +154 -0
  48. pyweb/uploads.py +94 -0
  49. pyweb_stack-0.1.0.dist-info/METADATA +198 -0
  50. pyweb_stack-0.1.0.dist-info/RECORD +54 -0
  51. pyweb_stack-0.1.0.dist-info/WHEEL +5 -0
  52. pyweb_stack-0.1.0.dist-info/entry_points.txt +2 -0
  53. pyweb_stack-0.1.0.dist-info/licenses/LICENSE +21 -0
  54. 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)))