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 +10 -0
- pybridge/bridge.py +214 -0
- pybridge/cli.py +103 -0
- pybridge/codegen.py +384 -0
- pybridge/context.py +29 -0
- pybridge/errors.py +12 -0
- pybridge/integrations.py +128 -0
- pybridge/introspect.py +172 -0
- pybridge/observability.py +36 -0
- pybridge/openapi.py +70 -0
- pybridge/py.typed +0 -0
- pybridge/security.py +136 -0
- pybridge/transport.py +394 -0
- pybridge/uploads.py +60 -0
- pybridge/watcher.py +112 -0
- pybridge_rpc-0.2.1.dist-info/METADATA +146 -0
- pybridge_rpc-0.2.1.dist-info/RECORD +21 -0
- pybridge_rpc-0.2.1.dist-info/WHEEL +5 -0
- pybridge_rpc-0.2.1.dist-info/entry_points.txt +2 -0
- pybridge_rpc-0.2.1.dist-info/licenses/LICENSE +21 -0
- pybridge_rpc-0.2.1.dist-info/top_level.txt +1 -0
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()
|