pybridge-rpc 0.2.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.
pybridge/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ from .bridge import Bridge, Group
2
+ from .context import Context
3
+ from .errors import ProcedureError
4
+ from .security import cors, csrf
5
+ from .uploads import UploadFile
6
+
7
+ __all__ = [
8
+ "Bridge", "Group", "Context", "ProcedureError", "UploadFile",
9
+ "cors", "csrf",
10
+ ]
pybridge/bridge.py ADDED
@@ -0,0 +1,214 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ import typing as t
5
+ from dataclasses import dataclass, field
6
+
7
+ from .context import Context
8
+
9
+
10
+ Middleware = t.Callable[[Context, t.Callable], t.Awaitable[t.Any]]
11
+
12
+
13
+ @dataclass
14
+ class Procedure:
15
+ path: str
16
+ handler: t.Callable
17
+ input_type: type | None
18
+ output_type: type | None
19
+ is_async: bool
20
+ middlewares: list[Middleware] = field(default_factory=list)
21
+ wants_ctx: bool = False
22
+ error_codes: tuple[str, ...] = ()
23
+ kind: str = "procedure" # or "subscription" / "stream"
24
+ timeout: float | None = None
25
+ max_body: int | None = None
26
+ description: str | None = None
27
+
28
+
29
+ @dataclass
30
+ class Bridge:
31
+ procedures: dict[str, Procedure] = field(default_factory=dict)
32
+ global_middlewares: list[Middleware] = field(default_factory=list)
33
+ type_overrides: dict[type, str] = field(default_factory=dict)
34
+ observers: list[t.Any] = field(default_factory=list)
35
+ connect_handlers: list[t.Callable] = field(default_factory=list)
36
+
37
+ def observer(self, obs):
38
+ """Register an Observer (instance or class — instances are constructed lazily)."""
39
+ self.observers.append(obs() if isinstance(obs, type) else obs)
40
+ return obs
41
+
42
+ def on_connect(self, fn: t.Callable):
43
+ """Register a handler that runs once per WebSocket connection, before
44
+ any subscribe message is processed. Use it for one-shot auth: the
45
+ handler's ``ctx.state`` is inherited by every subscription on the same
46
+ socket, so a DB lookup happens once instead of per message.
47
+
48
+ Raise ``ProcedureError`` from the handler to reject the connection;
49
+ the WS is closed with code 1008 (policy violation).
50
+ """
51
+ self.connect_handlers.append(fn)
52
+ return fn
53
+
54
+ def procedure(
55
+ self,
56
+ path: str,
57
+ *,
58
+ middlewares: list[Middleware] | None = None,
59
+ errors: t.Iterable[str] = (),
60
+ timeout: float | None = None,
61
+ max_body: int | None = None,
62
+ ) -> t.Callable:
63
+ return _register(
64
+ self, path, middlewares or [], tuple(errors),
65
+ kind="procedure", timeout=timeout, max_body=max_body,
66
+ )
67
+
68
+ def subscription(
69
+ self,
70
+ path: str,
71
+ *,
72
+ middlewares: list[Middleware] | None = None,
73
+ ) -> t.Callable:
74
+ return _register(self, path, middlewares or [], (), kind="subscription")
75
+
76
+ def stream(
77
+ self,
78
+ path: str,
79
+ *,
80
+ middlewares: list[Middleware] | None = None,
81
+ errors: t.Iterable[str] = (),
82
+ max_body: int | None = None,
83
+ ) -> t.Callable:
84
+ """HTTP / Server-Sent Events streaming procedure.
85
+
86
+ Same shape as ``@procedure`` but the handler is an async generator
87
+ whose yielded values are streamed to the client as SSE events.
88
+ """
89
+ return _register(
90
+ self, path, middlewares or [], tuple(errors),
91
+ kind="stream", max_body=max_body,
92
+ )
93
+
94
+ def middleware(self, fn: Middleware) -> Middleware:
95
+ self.global_middlewares.append(fn)
96
+ return fn
97
+
98
+ def group(self, prefix: str) -> "Group":
99
+ return Group(self, prefix)
100
+
101
+ def register_type(self, py_type: type, ts: str) -> None:
102
+ """Register a custom Python -> TypeScript type mapping (plugin hook)."""
103
+ self.type_overrides[py_type] = ts
104
+
105
+ def asgi(self, middleware: list | None = None):
106
+ from .transport import build_asgi
107
+ return build_asgi(self, middleware=middleware)
108
+
109
+
110
+ @dataclass
111
+ class Group:
112
+ _bridge: Bridge
113
+ _prefix: str
114
+
115
+ def procedure(
116
+ self,
117
+ path: str,
118
+ *,
119
+ middlewares: list[Middleware] | None = None,
120
+ errors: t.Iterable[str] = (),
121
+ ) -> t.Callable:
122
+ return _register(
123
+ self._bridge,
124
+ f"{self._prefix}.{path}",
125
+ middlewares or [],
126
+ tuple(errors),
127
+ kind="procedure",
128
+ )
129
+
130
+ def subscription(
131
+ self,
132
+ path: str,
133
+ *,
134
+ middlewares: list[Middleware] | None = None,
135
+ ) -> t.Callable:
136
+ return _register(self._bridge, f"{self._prefix}.{path}", middlewares or [], (), kind="subscription")
137
+
138
+ def stream(
139
+ self,
140
+ path: str,
141
+ *,
142
+ middlewares: list[Middleware] | None = None,
143
+ errors: t.Iterable[str] = (),
144
+ ) -> t.Callable:
145
+ return _register(
146
+ self._bridge,
147
+ f"{self._prefix}.{path}",
148
+ middlewares or [],
149
+ tuple(errors),
150
+ kind="stream",
151
+ )
152
+
153
+ def group(self, prefix: str) -> "Group":
154
+ return Group(self._bridge, f"{self._prefix}.{prefix}")
155
+
156
+
157
+ def _register(
158
+ bridge: Bridge,
159
+ path: str,
160
+ middlewares: list[Middleware],
161
+ errors: tuple[str, ...],
162
+ kind: str,
163
+ timeout: float | None = None,
164
+ max_body: int | None = None,
165
+ ) -> t.Callable:
166
+ def decorator(fn: t.Callable) -> t.Callable:
167
+ if path in bridge.procedures:
168
+ raise ValueError(f"procedure {path!r} already registered")
169
+ input_type, output_type, wants_ctx = _extract_signature(fn, kind)
170
+ bridge.procedures[path] = Procedure(
171
+ path=path,
172
+ handler=fn,
173
+ input_type=input_type,
174
+ output_type=output_type,
175
+ is_async=inspect.iscoroutinefunction(fn) or inspect.isasyncgenfunction(fn),
176
+ middlewares=list(middlewares),
177
+ wants_ctx=wants_ctx,
178
+ error_codes=errors,
179
+ kind=kind,
180
+ timeout=timeout,
181
+ max_body=max_body,
182
+ description=(fn.__doc__ or "").strip() or None,
183
+ )
184
+ return fn
185
+ return decorator
186
+
187
+
188
+ def _extract_signature(fn: t.Callable, kind: str) -> tuple[type | None, type | None, bool]:
189
+ hints = t.get_type_hints(fn)
190
+ return_type = hints.pop("return", None)
191
+ if kind in {"subscription", "stream"} and return_type is not None:
192
+ return_type = _strip_async_iterator(return_type)
193
+ sig = inspect.signature(fn)
194
+ input_type: type | None = None
195
+ wants_ctx = False
196
+ for name in sig.parameters:
197
+ if name == "ctx":
198
+ wants_ctx = True
199
+ continue
200
+ if name in hints and input_type is None:
201
+ input_type = hints[name]
202
+ return input_type, return_type, wants_ctx
203
+
204
+
205
+ def _strip_async_iterator(tp: t.Any) -> t.Any:
206
+ origin = t.get_origin(tp)
207
+ if origin in (
208
+ t.AsyncIterator,
209
+ t.AsyncGenerator,
210
+ ) or (origin is not None and getattr(origin, "__name__", "") in {"AsyncIterator", "AsyncGenerator"}):
211
+ args = t.get_args(tp)
212
+ if args:
213
+ return args[0]
214
+ return tp
pybridge/cli.py ADDED
@@ -0,0 +1,103 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib
4
+ import json
5
+ import signal
6
+ import sys
7
+ import threading
8
+ from pathlib import Path
9
+
10
+ import click
11
+
12
+ from .bridge import Bridge
13
+ from .codegen import generate
14
+ from .openapi import generate_openapi
15
+ from .watcher import HAS_WATCHFILES, watch_paths
16
+
17
+
18
+ @click.group()
19
+ def main() -> None:
20
+ """PyBridge CLI."""
21
+
22
+
23
+ @main.command("generate")
24
+ @click.option("--bridge", "bridge_ref", required=True, help="module:attribute reference to a Bridge instance.")
25
+ @click.option("--out", "out_path", required=True, type=click.Path(path_type=Path, dir_okay=False))
26
+ @click.option("--watch", is_flag=True, help="Re-generate on file changes.")
27
+ @click.option("--watch-dir", type=click.Path(path_type=Path, file_okay=False, exists=True), default=None, help="Directory to watch (defaults to cwd).")
28
+ @click.option("--hooks", is_flag=True, help="Emit React Query hook helpers.")
29
+ def generate_cmd(bridge_ref: str, out_path: Path, watch: bool, watch_dir: Path | None, hooks: bool) -> None:
30
+ """Generate the TypeScript client."""
31
+ _emit(bridge_ref, out_path, hooks)
32
+ if not watch:
33
+ return
34
+ root = (watch_dir or Path.cwd()).resolve()
35
+ backend = "watchfiles" if HAS_WATCHFILES else "polling"
36
+ click.echo(f"watching {root} via {backend} (Ctrl+C to stop)")
37
+
38
+ stop = threading.Event()
39
+ signal.signal(signal.SIGINT, lambda *_: stop.set())
40
+ signal.signal(signal.SIGTERM, lambda *_: stop.set())
41
+
42
+ def on_change(paths: list[Path]) -> None:
43
+ click.echo(f"changed: {', '.join(_short(p, root) for p in paths[:3])}{'...' if len(paths) > 3 else ''}")
44
+ _reload_and_emit(bridge_ref, out_path, hooks)
45
+
46
+ watch_paths(root, on_change, stop)
47
+ click.echo("stopped.")
48
+
49
+
50
+ @main.command("openapi")
51
+ @click.option("--bridge", "bridge_ref", required=True)
52
+ @click.option("--out", "out_path", required=True, type=click.Path(path_type=Path, dir_okay=False))
53
+ @click.option("--title", default="PyBridge API")
54
+ @click.option("--version", default="0.1.0")
55
+ def openapi_cmd(bridge_ref: str, out_path: Path, title: str, version: str) -> None:
56
+ """Export an OpenAPI 3.0 spec."""
57
+ bridge = _load_bridge(bridge_ref)
58
+ spec = generate_openapi(bridge, title=title, version=version)
59
+ out_path.parent.mkdir(parents=True, exist_ok=True)
60
+ out_path.write_text(json.dumps(spec, indent=2))
61
+ click.echo(f"wrote {out_path}")
62
+
63
+
64
+ def _emit(bridge_ref: str, out_path: Path, hooks: bool) -> None:
65
+ bridge = _load_bridge(bridge_ref)
66
+ out_path.parent.mkdir(parents=True, exist_ok=True)
67
+ out_path.write_text(generate(bridge, with_hooks=hooks))
68
+ click.echo(f"wrote {out_path} ({len(bridge.procedures)} procedures)")
69
+
70
+
71
+ def _reload_and_emit(bridge_ref: str, out_path: Path, hooks: bool) -> None:
72
+ module_name = bridge_ref.split(":", 1)[0]
73
+ for name in list(sys.modules):
74
+ if name == module_name or name.startswith(module_name + "."):
75
+ del sys.modules[name]
76
+ try:
77
+ _emit(bridge_ref, out_path, hooks)
78
+ except Exception as e:
79
+ click.echo(f"error: {e}", err=True)
80
+
81
+
82
+ def _short(path: Path, root: Path) -> str:
83
+ try:
84
+ return str(path.relative_to(root))
85
+ except ValueError:
86
+ return str(path)
87
+
88
+
89
+ def _load_bridge(ref: str) -> Bridge:
90
+ if ":" not in ref:
91
+ raise click.ClickException(f"--bridge must be 'module:attr', got {ref!r}")
92
+ module_name, attr = ref.split(":", 1)
93
+ if str(Path.cwd()) not in sys.path:
94
+ sys.path.insert(0, str(Path.cwd()))
95
+ module = importlib.import_module(module_name)
96
+ obj = getattr(module, attr)
97
+ if not isinstance(obj, Bridge):
98
+ raise click.ClickException(f"{ref} is not a Bridge instance")
99
+ return obj
100
+
101
+
102
+ if __name__ == "__main__":
103
+ main()