pybridge-rpc 0.2.1__tar.gz

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 (33) hide show
  1. pybridge_rpc-0.2.1/LICENSE +21 -0
  2. pybridge_rpc-0.2.1/PKG-INFO +146 -0
  3. pybridge_rpc-0.2.1/README.md +89 -0
  4. pybridge_rpc-0.2.1/pybridge/__init__.py +10 -0
  5. pybridge_rpc-0.2.1/pybridge/bridge.py +214 -0
  6. pybridge_rpc-0.2.1/pybridge/cli.py +103 -0
  7. pybridge_rpc-0.2.1/pybridge/codegen.py +384 -0
  8. pybridge_rpc-0.2.1/pybridge/context.py +29 -0
  9. pybridge_rpc-0.2.1/pybridge/errors.py +12 -0
  10. pybridge_rpc-0.2.1/pybridge/integrations.py +128 -0
  11. pybridge_rpc-0.2.1/pybridge/introspect.py +172 -0
  12. pybridge_rpc-0.2.1/pybridge/observability.py +36 -0
  13. pybridge_rpc-0.2.1/pybridge/openapi.py +70 -0
  14. pybridge_rpc-0.2.1/pybridge/py.typed +0 -0
  15. pybridge_rpc-0.2.1/pybridge/security.py +136 -0
  16. pybridge_rpc-0.2.1/pybridge/transport.py +394 -0
  17. pybridge_rpc-0.2.1/pybridge/uploads.py +60 -0
  18. pybridge_rpc-0.2.1/pybridge/watcher.py +112 -0
  19. pybridge_rpc-0.2.1/pybridge_rpc.egg-info/PKG-INFO +146 -0
  20. pybridge_rpc-0.2.1/pybridge_rpc.egg-info/SOURCES.txt +31 -0
  21. pybridge_rpc-0.2.1/pybridge_rpc.egg-info/dependency_links.txt +1 -0
  22. pybridge_rpc-0.2.1/pybridge_rpc.egg-info/entry_points.txt +2 -0
  23. pybridge_rpc-0.2.1/pybridge_rpc.egg-info/requires.txt +6 -0
  24. pybridge_rpc-0.2.1/pybridge_rpc.egg-info/top_level.txt +1 -0
  25. pybridge_rpc-0.2.1/pyproject.toml +54 -0
  26. pybridge_rpc-0.2.1/setup.cfg +4 -0
  27. pybridge_rpc-0.2.1/tests/test_fastapi_integration.py +67 -0
  28. pybridge_rpc-0.2.1/tests/test_litestar_integration.py +42 -0
  29. pybridge_rpc-0.2.1/tests/test_sanic_integration.py +95 -0
  30. pybridge_rpc-0.2.1/tests/test_security.py +89 -0
  31. pybridge_rpc-0.2.1/tests/test_smoke.py +170 -0
  32. pybridge_rpc-0.2.1/tests/test_v02_features.py +111 -0
  33. pybridge_rpc-0.2.1/tests/test_ws_auth.py +128 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 PyBridge contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,146 @@
1
+ Metadata-Version: 2.4
2
+ Name: pybridge-rpc
3
+ Version: 0.2.1
4
+ Summary: End-to-end type safety from Python to TypeScript — typed RPC with automatic TS client codegen.
5
+ Author: PyBridge contributors
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 PyBridge contributors
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Homepage, https://github.com/kowalski21/pybridge-rpc
29
+ Project-URL: Repository, https://github.com/kowalski21/pybridge-rpc
30
+ Project-URL: Issues, https://github.com/kowalski21/pybridge-rpc/issues
31
+ Project-URL: Documentation, https://github.com/kowalski21/pybridge-rpc/tree/main/docs
32
+ Keywords: rpc,typescript,codegen,pydantic,starlette,asgi,trpc,type-safety
33
+ Classifier: Development Status :: 4 - Beta
34
+ Classifier: Intended Audience :: Developers
35
+ Classifier: License :: OSI Approved :: MIT License
36
+ Classifier: Operating System :: OS Independent
37
+ Classifier: Programming Language :: Python
38
+ Classifier: Programming Language :: Python :: 3
39
+ Classifier: Programming Language :: Python :: 3.11
40
+ Classifier: Programming Language :: Python :: 3.12
41
+ Classifier: Programming Language :: Python :: 3.13
42
+ Classifier: Programming Language :: Python :: Implementation :: CPython
43
+ Classifier: Topic :: Internet :: WWW/HTTP
44
+ Classifier: Topic :: Software Development :: Code Generators
45
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
46
+ Classifier: Framework :: AsyncIO
47
+ Classifier: Typing :: Typed
48
+ Requires-Python: >=3.11
49
+ Description-Content-Type: text/markdown
50
+ License-File: LICENSE
51
+ Requires-Dist: pydantic>=2.0
52
+ Requires-Dist: starlette>=0.30
53
+ Requires-Dist: click>=8.0
54
+ Provides-Extra: watch
55
+ Requires-Dist: watchfiles>=0.20; extra == "watch"
56
+ Dynamic: license-file
57
+
58
+ # PyBridge
59
+
60
+ [![PyPI version](https://img.shields.io/pypi/v/pybridge-rpc.svg)](https://pypi.org/project/pybridge-rpc/)
61
+ [![Python versions](https://img.shields.io/pypi/pyversions/pybridge-rpc.svg)](https://pypi.org/project/pybridge-rpc/)
62
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
63
+ [![Status: Beta](https://img.shields.io/badge/status-beta-orange.svg)](#status)
64
+
65
+ End-to-end type safety from Python to TypeScript — no codegen ceremony, no schema drift.
66
+
67
+ Define procedures in Python with standard type hints + Pydantic. Run one command. Get a fully typed TypeScript client.
68
+
69
+ ```bash
70
+ pip install pybridge-rpc
71
+ pybridge generate --bridge examples.basic:bridge --out client/api.ts --hooks
72
+ ```
73
+
74
+ ```python
75
+ # server.py
76
+ from pybridge import Bridge
77
+ from pydantic import BaseModel
78
+
79
+ bridge = Bridge()
80
+
81
+ class User(BaseModel):
82
+ id: str
83
+ name: str
84
+ email: str
85
+
86
+ @bridge.procedure("users.create")
87
+ async def create_user(input: User) -> User: ...
88
+
89
+ app = bridge.asgi()
90
+ ```
91
+
92
+ ```ts
93
+ import { createClient, type AppRouter } from "./api";
94
+ const api = createClient<AppRouter>("http://localhost:8000");
95
+ const user = await api.users.create({ id: "1", name: "Kofi", email: "k@example.com" });
96
+ // ^ fully typed as User
97
+ ```
98
+
99
+ ## Features
100
+
101
+ - **Zero-ceremony typed RPC** — Python function signature *is* the API contract. No `.input()/.output()` wrappers, no separate schema files.
102
+ - **Pydantic → TypeScript** — full type projection: nested models, unions, literals, enums, `datetime`, `UUID`, tuples, `dict`, optional/nullable.
103
+ - **One-command codegen** — `pybridge generate` emits a single `api.ts` with types + proxy client + optional TanStack Query hooks.
104
+ - **ASGI transport** — HTTP, WebSocket **subscriptions**, and **SSE streams** out of the box (built on Starlette).
105
+ - **Framework integrations** — drop into FastAPI, Django, Sanic, or Litestar without rewriting your app.
106
+ - **File uploads, CORS & CSRF helpers, observer protocol** for logging/metrics/tracing hooks.
107
+ - **OpenAPI 3.x exporter** — get a spec for tooling that needs one, without making it the source of truth.
108
+ - **Watch mode** — `watchfiles`-backed regeneration with mtime-polling fallback.
109
+ - **Typed errors** — `ProcedureError` codes propagate to the client.
110
+ - **Fully typed package** — ships `py.typed`.
111
+
112
+ ## Why PyBridge
113
+
114
+ Full-stack teams with a Python backend and a TypeScript frontend live with a constant gap: define models in Pydantic, redefine them as TS interfaces, hope they stay in sync. The usual fixes have rough edges:
115
+
116
+ | Approach | Trade-off |
117
+ |---|---|
118
+ | **FastAPI + openapi-typescript** | Works, but it's a 4-step pipeline (FastAPI → OpenAPI → codegen → wire-up). Generated SDKs have opinions about method names and return shapes that often need wrapping. |
119
+ | **tRPC / oRPC** | Great DX — but your backend has to be TypeScript. |
120
+ | **GraphQL + codegen** | Adds a schema language, a runtime, and a resolver layer for something a function signature already encodes. |
121
+ | **PyBridge** | Python type hints are the source of truth. One command produces a thin, idiomatic TS client whose types are a direct projection of your Pydantic models. |
122
+
123
+ The goal is the same DX tRPC/oRPC developers enjoy — with Python as the source of truth. See [docs/design.md](./docs/design.md) for the full rationale.
124
+
125
+ ## Status
126
+
127
+ Phases 1–3 are complete and tested (33 tests passing). Currently `0.2.x` — beta. API is stable for the documented surface; breaking changes will be called out in release notes.
128
+
129
+ ## Documentation
130
+
131
+ All docs live in [`docs/`](./docs/README.md):
132
+
133
+ - [Design](./docs/design.md) — the problem, approach, architecture.
134
+ - [Quickstart](./docs/quickstart.md) — install, server + client, feature list.
135
+ - [Streaming](./docs/streaming.md) — WebSocket subscriptions and SSE streams.
136
+ - [Authentication](./docs/authentication.md) — bearer tokens, cookie sessions, CSRF.
137
+ - [Observability](./docs/observability.md) — observers, timeouts, body limits.
138
+ - [Framework integrations](./docs/integrations.md) — FastAPI, Django, Sanic, Litestar.
139
+ - [TanStack example](./docs/tanstack.md) — Router + Query end-to-end.
140
+ - [Performance](./docs/performance.md) — benchmarks.
141
+ - [Project layout](./docs/project-layout.md) — module map.
142
+ - [Roadmap](./docs/roadmap.md) — phases, tech stack, status.
143
+
144
+ ## License
145
+
146
+ MIT — see [LICENSE](./LICENSE).
@@ -0,0 +1,89 @@
1
+ # PyBridge
2
+
3
+ [![PyPI version](https://img.shields.io/pypi/v/pybridge-rpc.svg)](https://pypi.org/project/pybridge-rpc/)
4
+ [![Python versions](https://img.shields.io/pypi/pyversions/pybridge-rpc.svg)](https://pypi.org/project/pybridge-rpc/)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
6
+ [![Status: Beta](https://img.shields.io/badge/status-beta-orange.svg)](#status)
7
+
8
+ End-to-end type safety from Python to TypeScript — no codegen ceremony, no schema drift.
9
+
10
+ Define procedures in Python with standard type hints + Pydantic. Run one command. Get a fully typed TypeScript client.
11
+
12
+ ```bash
13
+ pip install pybridge-rpc
14
+ pybridge generate --bridge examples.basic:bridge --out client/api.ts --hooks
15
+ ```
16
+
17
+ ```python
18
+ # server.py
19
+ from pybridge import Bridge
20
+ from pydantic import BaseModel
21
+
22
+ bridge = Bridge()
23
+
24
+ class User(BaseModel):
25
+ id: str
26
+ name: str
27
+ email: str
28
+
29
+ @bridge.procedure("users.create")
30
+ async def create_user(input: User) -> User: ...
31
+
32
+ app = bridge.asgi()
33
+ ```
34
+
35
+ ```ts
36
+ import { createClient, type AppRouter } from "./api";
37
+ const api = createClient<AppRouter>("http://localhost:8000");
38
+ const user = await api.users.create({ id: "1", name: "Kofi", email: "k@example.com" });
39
+ // ^ fully typed as User
40
+ ```
41
+
42
+ ## Features
43
+
44
+ - **Zero-ceremony typed RPC** — Python function signature *is* the API contract. No `.input()/.output()` wrappers, no separate schema files.
45
+ - **Pydantic → TypeScript** — full type projection: nested models, unions, literals, enums, `datetime`, `UUID`, tuples, `dict`, optional/nullable.
46
+ - **One-command codegen** — `pybridge generate` emits a single `api.ts` with types + proxy client + optional TanStack Query hooks.
47
+ - **ASGI transport** — HTTP, WebSocket **subscriptions**, and **SSE streams** out of the box (built on Starlette).
48
+ - **Framework integrations** — drop into FastAPI, Django, Sanic, or Litestar without rewriting your app.
49
+ - **File uploads, CORS & CSRF helpers, observer protocol** for logging/metrics/tracing hooks.
50
+ - **OpenAPI 3.x exporter** — get a spec for tooling that needs one, without making it the source of truth.
51
+ - **Watch mode** — `watchfiles`-backed regeneration with mtime-polling fallback.
52
+ - **Typed errors** — `ProcedureError` codes propagate to the client.
53
+ - **Fully typed package** — ships `py.typed`.
54
+
55
+ ## Why PyBridge
56
+
57
+ Full-stack teams with a Python backend and a TypeScript frontend live with a constant gap: define models in Pydantic, redefine them as TS interfaces, hope they stay in sync. The usual fixes have rough edges:
58
+
59
+ | Approach | Trade-off |
60
+ |---|---|
61
+ | **FastAPI + openapi-typescript** | Works, but it's a 4-step pipeline (FastAPI → OpenAPI → codegen → wire-up). Generated SDKs have opinions about method names and return shapes that often need wrapping. |
62
+ | **tRPC / oRPC** | Great DX — but your backend has to be TypeScript. |
63
+ | **GraphQL + codegen** | Adds a schema language, a runtime, and a resolver layer for something a function signature already encodes. |
64
+ | **PyBridge** | Python type hints are the source of truth. One command produces a thin, idiomatic TS client whose types are a direct projection of your Pydantic models. |
65
+
66
+ The goal is the same DX tRPC/oRPC developers enjoy — with Python as the source of truth. See [docs/design.md](./docs/design.md) for the full rationale.
67
+
68
+ ## Status
69
+
70
+ Phases 1–3 are complete and tested (33 tests passing). Currently `0.2.x` — beta. API is stable for the documented surface; breaking changes will be called out in release notes.
71
+
72
+ ## Documentation
73
+
74
+ All docs live in [`docs/`](./docs/README.md):
75
+
76
+ - [Design](./docs/design.md) — the problem, approach, architecture.
77
+ - [Quickstart](./docs/quickstart.md) — install, server + client, feature list.
78
+ - [Streaming](./docs/streaming.md) — WebSocket subscriptions and SSE streams.
79
+ - [Authentication](./docs/authentication.md) — bearer tokens, cookie sessions, CSRF.
80
+ - [Observability](./docs/observability.md) — observers, timeouts, body limits.
81
+ - [Framework integrations](./docs/integrations.md) — FastAPI, Django, Sanic, Litestar.
82
+ - [TanStack example](./docs/tanstack.md) — Router + Query end-to-end.
83
+ - [Performance](./docs/performance.md) — benchmarks.
84
+ - [Project layout](./docs/project-layout.md) — module map.
85
+ - [Roadmap](./docs/roadmap.md) — phases, tech stack, status.
86
+
87
+ ## License
88
+
89
+ MIT — see [LICENSE](./LICENSE).
@@ -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
+ ]
@@ -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
@@ -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()