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.
- pybridge_rpc-0.2.1/LICENSE +21 -0
- pybridge_rpc-0.2.1/PKG-INFO +146 -0
- pybridge_rpc-0.2.1/README.md +89 -0
- pybridge_rpc-0.2.1/pybridge/__init__.py +10 -0
- pybridge_rpc-0.2.1/pybridge/bridge.py +214 -0
- pybridge_rpc-0.2.1/pybridge/cli.py +103 -0
- pybridge_rpc-0.2.1/pybridge/codegen.py +384 -0
- pybridge_rpc-0.2.1/pybridge/context.py +29 -0
- pybridge_rpc-0.2.1/pybridge/errors.py +12 -0
- pybridge_rpc-0.2.1/pybridge/integrations.py +128 -0
- pybridge_rpc-0.2.1/pybridge/introspect.py +172 -0
- pybridge_rpc-0.2.1/pybridge/observability.py +36 -0
- pybridge_rpc-0.2.1/pybridge/openapi.py +70 -0
- pybridge_rpc-0.2.1/pybridge/py.typed +0 -0
- pybridge_rpc-0.2.1/pybridge/security.py +136 -0
- pybridge_rpc-0.2.1/pybridge/transport.py +394 -0
- pybridge_rpc-0.2.1/pybridge/uploads.py +60 -0
- pybridge_rpc-0.2.1/pybridge/watcher.py +112 -0
- pybridge_rpc-0.2.1/pybridge_rpc.egg-info/PKG-INFO +146 -0
- pybridge_rpc-0.2.1/pybridge_rpc.egg-info/SOURCES.txt +31 -0
- pybridge_rpc-0.2.1/pybridge_rpc.egg-info/dependency_links.txt +1 -0
- pybridge_rpc-0.2.1/pybridge_rpc.egg-info/entry_points.txt +2 -0
- pybridge_rpc-0.2.1/pybridge_rpc.egg-info/requires.txt +6 -0
- pybridge_rpc-0.2.1/pybridge_rpc.egg-info/top_level.txt +1 -0
- pybridge_rpc-0.2.1/pyproject.toml +54 -0
- pybridge_rpc-0.2.1/setup.cfg +4 -0
- pybridge_rpc-0.2.1/tests/test_fastapi_integration.py +67 -0
- pybridge_rpc-0.2.1/tests/test_litestar_integration.py +42 -0
- pybridge_rpc-0.2.1/tests/test_sanic_integration.py +95 -0
- pybridge_rpc-0.2.1/tests/test_security.py +89 -0
- pybridge_rpc-0.2.1/tests/test_smoke.py +170 -0
- pybridge_rpc-0.2.1/tests/test_v02_features.py +111 -0
- 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
|
+
[](https://pypi.org/project/pybridge-rpc/)
|
|
61
|
+
[](https://pypi.org/project/pybridge-rpc/)
|
|
62
|
+
[](./LICENSE)
|
|
63
|
+
[](#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
|
+
[](https://pypi.org/project/pybridge-rpc/)
|
|
4
|
+
[](https://pypi.org/project/pybridge-rpc/)
|
|
5
|
+
[](./LICENSE)
|
|
6
|
+
[](#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()
|