vention-communication 0.2.2__tar.gz → 0.3.4__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.
- vention_communication-0.2.2/README.md → vention_communication-0.3.4/PKG-INFO +91 -10
- vention_communication-0.2.2/PKG-INFO → vention_communication-0.3.4/README.md +62 -25
- vention_communication-0.3.4/pyproject.toml +97 -0
- {vention_communication-0.2.2 → vention_communication-0.3.4}/src/communication/app.py +1 -1
- {vention_communication-0.2.2 → vention_communication-0.3.4}/src/communication/codegen.py +68 -36
- {vention_communication-0.2.2 → vention_communication-0.3.4}/src/communication/connect_router.py +4 -8
- {vention_communication-0.2.2 → vention_communication-0.3.4}/src/communication/decorators.py +2 -6
- {vention_communication-0.2.2 → vention_communication-0.3.4}/src/communication/errors.py +1 -3
- vention_communication-0.3.4/src/communication/rpc_registry.py +88 -0
- {vention_communication-0.2.2 → vention_communication-0.3.4}/src/communication/typing_utils.py +51 -7
- vention_communication-0.2.2/pyproject.toml +0 -17
- {vention_communication-0.2.2 → vention_communication-0.3.4}/src/communication/__init__.py +0 -0
- {vention_communication-0.2.2 → vention_communication-0.3.4}/src/communication/entries.py +0 -0
- {vention_communication-0.2.2 → vention_communication-0.3.4}/src/communication/registry.py +0 -0
|
@@ -1,3 +1,31 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vention-communication
|
|
3
|
+
Version: 0.3.4
|
|
4
|
+
Summary: A framework for communication between machine apps and other services.
|
|
5
|
+
License: Proprietary
|
|
6
|
+
Author: VentionCo
|
|
7
|
+
Requires-Python: >=3.10,<3.11
|
|
8
|
+
Classifier: License :: Other/Proprietary License
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Requires-Dist: annotated-doc (==0.0.4) ; python_version == "3.10"
|
|
12
|
+
Requires-Dist: annotated-types (==0.7.0) ; python_version == "3.10"
|
|
13
|
+
Requires-Dist: anyio (==4.11.0) ; python_version == "3.10"
|
|
14
|
+
Requires-Dist: click (==8.1.8) ; python_version == "3.10"
|
|
15
|
+
Requires-Dist: colorama (==0.4.6) ; python_version == "3.10" and platform_system == "Windows"
|
|
16
|
+
Requires-Dist: exceptiongroup (==1.3.0) ; python_version == "3.10"
|
|
17
|
+
Requires-Dist: fastapi (==0.121.1) ; python_version == "3.10"
|
|
18
|
+
Requires-Dist: h11 (==0.16.0) ; python_version == "3.10"
|
|
19
|
+
Requires-Dist: idna (==3.11) ; python_version == "3.10"
|
|
20
|
+
Requires-Dist: pydantic (==2.12.3) ; python_version == "3.10"
|
|
21
|
+
Requires-Dist: pydantic-core (==2.41.4) ; python_version == "3.10"
|
|
22
|
+
Requires-Dist: sniffio (==1.3.1) ; python_version == "3.10"
|
|
23
|
+
Requires-Dist: starlette (==0.48.0) ; python_version == "3.10"
|
|
24
|
+
Requires-Dist: typing-extensions (==4.15.0) ; python_version == "3.10"
|
|
25
|
+
Requires-Dist: typing-inspection (==0.4.2) ; python_version == "3.10"
|
|
26
|
+
Requires-Dist: uvicorn (==0.35.0) ; python_version == "3.10"
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
1
29
|
# Vention Communication
|
|
2
30
|
|
|
3
31
|
A thin, FastAPI-powered RPC layer for machine-apps that exposes Connect-compatible request-response and server-streaming endpoints — plus .proto generation from Python decorators, allowing typed SDKs to be generated separately via Buf.
|
|
@@ -74,8 +102,10 @@ A complete "hello world" in three steps.
|
|
|
74
102
|
|
|
75
103
|
```python
|
|
76
104
|
from pydantic import BaseModel
|
|
77
|
-
from
|
|
78
|
-
import
|
|
105
|
+
from communication.app import VentionApp
|
|
106
|
+
from communication.decorators import action, stream
|
|
107
|
+
import time
|
|
108
|
+
import random
|
|
79
109
|
|
|
80
110
|
class PingRequest(BaseModel):
|
|
81
111
|
message: str
|
|
@@ -100,6 +130,15 @@ async def heartbeat():
|
|
|
100
130
|
|
|
101
131
|
app.finalize()
|
|
102
132
|
|
|
133
|
+
# Emit heartbeat every second
|
|
134
|
+
@app.on_event("startup")
|
|
135
|
+
async def startup():
|
|
136
|
+
asyncio.create_task(loop())
|
|
137
|
+
|
|
138
|
+
async def loop():
|
|
139
|
+
while True:
|
|
140
|
+
asyncio.create_task(heartbeat())
|
|
141
|
+
await asyncio.sleep(1)
|
|
103
142
|
```
|
|
104
143
|
|
|
105
144
|
**Run:**
|
|
@@ -114,21 +153,59 @@ Endpoints are automatically registered under `/rpc/vention.app.v1.DemoAppService
|
|
|
114
153
|
|
|
115
154
|
After startup, `proto/app.proto` is emitted automatically.
|
|
116
155
|
|
|
117
|
-
You can now use Buf or protoc to generate client SDKs
|
|
156
|
+
You can now use Buf or protoc to generate client SDKs, based on each client stack you desire.
|
|
157
|
+
|
|
158
|
+
The next section will provide an example for Typescript applications, but for any other environment, please refer to the official documentation on how to install and quickstart code generation:
|
|
159
|
+
|
|
160
|
+
https://buf.build/docs/cli/installation/
|
|
161
|
+
|
|
162
|
+
### 3. Example TypeScript Client
|
|
163
|
+
|
|
164
|
+
Make sure you have Node 24+ installed. Use [NVM](https://github.com/nvm-sh/nvm) to easily install and manage different Node versions.
|
|
165
|
+
|
|
166
|
+
1. Create a folder called `client` and `cd` into it.
|
|
167
|
+
|
|
168
|
+
#### Protobuf Javascript/Typescript libraries
|
|
169
|
+
|
|
170
|
+
2. Install the runtime library, code generator, and the Buf CLI:
|
|
118
171
|
|
|
119
172
|
```bash
|
|
120
|
-
|
|
121
|
-
|
|
173
|
+
npm install @bufbuild/protobuf
|
|
174
|
+
npm install --save-dev @bufbuild/protoc-gen-es @bufbuild/buf
|
|
122
175
|
```
|
|
123
176
|
|
|
124
|
-
|
|
177
|
+
3. Create a buf.gen.yaml file that looks like this:
|
|
125
178
|
|
|
126
|
-
|
|
179
|
+
```yaml
|
|
180
|
+
version: v2
|
|
181
|
+
inputs:
|
|
182
|
+
- directory: proto
|
|
183
|
+
plugins:
|
|
184
|
+
- local: protoc-gen-es
|
|
185
|
+
opt: target=ts
|
|
186
|
+
out: src/gen
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
4. Generate the client code, pointing the path to the newly generated *proto* folder:
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
npx buf generate ../proto
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
#### Client Application
|
|
196
|
+
|
|
197
|
+
1. Install the client RPC libraries:
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
npm i @connectrpc/connect @connectrpc/connect-web
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
2. Then create an `index.ts` file in the `client/src` folder, and paste the following code in it:
|
|
127
204
|
|
|
128
205
|
```typescript
|
|
129
206
|
import { createClient } from "@connectrpc/connect";
|
|
130
207
|
import { createConnectTransport } from "@connectrpc/connect-web";
|
|
131
|
-
import { DemoAppService } from "./gen/connect/
|
|
208
|
+
import { DemoAppService } from "./gen/connect/app_pb.ts";
|
|
132
209
|
|
|
133
210
|
const transport = createConnectTransport({
|
|
134
211
|
baseUrl: "http://localhost:8000/rpc",
|
|
@@ -150,8 +227,11 @@ for await (const hb of client.heartbeat({})) {
|
|
|
150
227
|
### Add a new request-response endpoint
|
|
151
228
|
|
|
152
229
|
```python
|
|
230
|
+
class StatusResponse(BaseModel):
|
|
231
|
+
ok: bool
|
|
232
|
+
|
|
153
233
|
@action()
|
|
154
|
-
async def get_status() ->
|
|
234
|
+
async def get_status() -> StatusResponse:
|
|
155
235
|
return {"ok": True}
|
|
156
236
|
```
|
|
157
237
|
|
|
@@ -159,7 +239,7 @@ async def get_status() -> dict:
|
|
|
159
239
|
|
|
160
240
|
```python
|
|
161
241
|
@stream(name="Status", payload=dict)
|
|
162
|
-
async def publish_status() ->
|
|
242
|
+
async def publish_status() -> StatusResponse:
|
|
163
243
|
return {"ok": True}
|
|
164
244
|
```
|
|
165
245
|
|
|
@@ -316,3 +396,4 @@ Ensure `app.finalize()` has been called before publishing or subscribing.
|
|
|
316
396
|
**Q: How do I integrate this with other libraries (state machine, storage, etc.)?**
|
|
317
397
|
|
|
318
398
|
Use `app.register_rpc_plugin()` to merge additional RPC definitions before calling `.finalize()`.
|
|
399
|
+
|
|
@@ -1,17 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: vention-communication
|
|
3
|
-
Version: 0.2.2
|
|
4
|
-
Summary: A framework for communication between machine apps and other services.
|
|
5
|
-
License: Proprietary
|
|
6
|
-
Author: VentionCo
|
|
7
|
-
Requires-Python: >=3.10,<3.11
|
|
8
|
-
Classifier: License :: Other/Proprietary License
|
|
9
|
-
Classifier: Programming Language :: Python :: 3
|
|
10
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
-
Requires-Dist: fastapi (==0.121.1)
|
|
12
|
-
Requires-Dist: uvicorn (>=0.35.0,<0.36.0)
|
|
13
|
-
Description-Content-Type: text/markdown
|
|
14
|
-
|
|
15
1
|
# Vention Communication
|
|
16
2
|
|
|
17
3
|
A thin, FastAPI-powered RPC layer for machine-apps that exposes Connect-compatible request-response and server-streaming endpoints — plus .proto generation from Python decorators, allowing typed SDKs to be generated separately via Buf.
|
|
@@ -88,8 +74,10 @@ A complete "hello world" in three steps.
|
|
|
88
74
|
|
|
89
75
|
```python
|
|
90
76
|
from pydantic import BaseModel
|
|
91
|
-
from
|
|
92
|
-
import
|
|
77
|
+
from communication.app import VentionApp
|
|
78
|
+
from communication.decorators import action, stream
|
|
79
|
+
import time
|
|
80
|
+
import random
|
|
93
81
|
|
|
94
82
|
class PingRequest(BaseModel):
|
|
95
83
|
message: str
|
|
@@ -114,6 +102,15 @@ async def heartbeat():
|
|
|
114
102
|
|
|
115
103
|
app.finalize()
|
|
116
104
|
|
|
105
|
+
# Emit heartbeat every second
|
|
106
|
+
@app.on_event("startup")
|
|
107
|
+
async def startup():
|
|
108
|
+
asyncio.create_task(loop())
|
|
109
|
+
|
|
110
|
+
async def loop():
|
|
111
|
+
while True:
|
|
112
|
+
asyncio.create_task(heartbeat())
|
|
113
|
+
await asyncio.sleep(1)
|
|
117
114
|
```
|
|
118
115
|
|
|
119
116
|
**Run:**
|
|
@@ -128,21 +125,59 @@ Endpoints are automatically registered under `/rpc/vention.app.v1.DemoAppService
|
|
|
128
125
|
|
|
129
126
|
After startup, `proto/app.proto` is emitted automatically.
|
|
130
127
|
|
|
131
|
-
You can now use Buf or protoc to generate client SDKs
|
|
128
|
+
You can now use Buf or protoc to generate client SDKs, based on each client stack you desire.
|
|
129
|
+
|
|
130
|
+
The next section will provide an example for Typescript applications, but for any other environment, please refer to the official documentation on how to install and quickstart code generation:
|
|
131
|
+
|
|
132
|
+
https://buf.build/docs/cli/installation/
|
|
133
|
+
|
|
134
|
+
### 3. Example TypeScript Client
|
|
135
|
+
|
|
136
|
+
Make sure you have Node 24+ installed. Use [NVM](https://github.com/nvm-sh/nvm) to easily install and manage different Node versions.
|
|
137
|
+
|
|
138
|
+
1. Create a folder called `client` and `cd` into it.
|
|
139
|
+
|
|
140
|
+
#### Protobuf Javascript/Typescript libraries
|
|
141
|
+
|
|
142
|
+
2. Install the runtime library, code generator, and the Buf CLI:
|
|
132
143
|
|
|
133
144
|
```bash
|
|
134
|
-
|
|
135
|
-
|
|
145
|
+
npm install @bufbuild/protobuf
|
|
146
|
+
npm install --save-dev @bufbuild/protoc-gen-es @bufbuild/buf
|
|
136
147
|
```
|
|
137
148
|
|
|
138
|
-
|
|
149
|
+
3. Create a buf.gen.yaml file that looks like this:
|
|
139
150
|
|
|
140
|
-
|
|
151
|
+
```yaml
|
|
152
|
+
version: v2
|
|
153
|
+
inputs:
|
|
154
|
+
- directory: proto
|
|
155
|
+
plugins:
|
|
156
|
+
- local: protoc-gen-es
|
|
157
|
+
opt: target=ts
|
|
158
|
+
out: src/gen
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
4. Generate the client code, pointing the path to the newly generated *proto* folder:
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
npx buf generate ../proto
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
#### Client Application
|
|
168
|
+
|
|
169
|
+
1. Install the client RPC libraries:
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
npm i @connectrpc/connect @connectrpc/connect-web
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
2. Then create an `index.ts` file in the `client/src` folder, and paste the following code in it:
|
|
141
176
|
|
|
142
177
|
```typescript
|
|
143
178
|
import { createClient } from "@connectrpc/connect";
|
|
144
179
|
import { createConnectTransport } from "@connectrpc/connect-web";
|
|
145
|
-
import { DemoAppService } from "./gen/connect/
|
|
180
|
+
import { DemoAppService } from "./gen/connect/app_pb.ts";
|
|
146
181
|
|
|
147
182
|
const transport = createConnectTransport({
|
|
148
183
|
baseUrl: "http://localhost:8000/rpc",
|
|
@@ -164,8 +199,11 @@ for await (const hb of client.heartbeat({})) {
|
|
|
164
199
|
### Add a new request-response endpoint
|
|
165
200
|
|
|
166
201
|
```python
|
|
202
|
+
class StatusResponse(BaseModel):
|
|
203
|
+
ok: bool
|
|
204
|
+
|
|
167
205
|
@action()
|
|
168
|
-
async def get_status() ->
|
|
206
|
+
async def get_status() -> StatusResponse:
|
|
169
207
|
return {"ok": True}
|
|
170
208
|
```
|
|
171
209
|
|
|
@@ -173,7 +211,7 @@ async def get_status() -> dict:
|
|
|
173
211
|
|
|
174
212
|
```python
|
|
175
213
|
@stream(name="Status", payload=dict)
|
|
176
|
-
async def publish_status() ->
|
|
214
|
+
async def publish_status() -> StatusResponse:
|
|
177
215
|
return {"ok": True}
|
|
178
216
|
```
|
|
179
217
|
|
|
@@ -330,4 +368,3 @@ Ensure `app.finalize()` has been called before publishing or subscribing.
|
|
|
330
368
|
**Q: How do I integrate this with other libraries (state machine, storage, etc.)?**
|
|
331
369
|
|
|
332
370
|
Use `app.register_rpc_plugin()` to merge additional RPC definitions before calling `.finalize()`.
|
|
333
|
-
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "vention-communication"
|
|
3
|
+
version = "0.3.4"
|
|
4
|
+
description = "A framework for communication between machine apps and other services."
|
|
5
|
+
authors = [ "VentionCo" ]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
license = "Proprietary"
|
|
8
|
+
|
|
9
|
+
[[tool.poetry.packages]]
|
|
10
|
+
include = "communication"
|
|
11
|
+
from = "src"
|
|
12
|
+
|
|
13
|
+
[tool.poetry.dependencies]
|
|
14
|
+
python = ">=3.10,<3.11"
|
|
15
|
+
|
|
16
|
+
[tool.poetry.dependencies.annotated-doc]
|
|
17
|
+
version = "0.0.4"
|
|
18
|
+
markers = 'python_version == "3.10"'
|
|
19
|
+
optional = false
|
|
20
|
+
|
|
21
|
+
[tool.poetry.dependencies.annotated-types]
|
|
22
|
+
version = "0.7.0"
|
|
23
|
+
markers = 'python_version == "3.10"'
|
|
24
|
+
optional = false
|
|
25
|
+
|
|
26
|
+
[tool.poetry.dependencies.anyio]
|
|
27
|
+
version = "4.11.0"
|
|
28
|
+
markers = 'python_version == "3.10"'
|
|
29
|
+
optional = false
|
|
30
|
+
|
|
31
|
+
[tool.poetry.dependencies.click]
|
|
32
|
+
version = "8.1.8"
|
|
33
|
+
markers = 'python_version == "3.10"'
|
|
34
|
+
optional = false
|
|
35
|
+
|
|
36
|
+
[tool.poetry.dependencies.colorama]
|
|
37
|
+
version = "0.4.6"
|
|
38
|
+
markers = 'python_version == "3.10" and platform_system == "Windows"'
|
|
39
|
+
optional = false
|
|
40
|
+
|
|
41
|
+
[tool.poetry.dependencies.exceptiongroup]
|
|
42
|
+
version = "1.3.0"
|
|
43
|
+
markers = 'python_version == "3.10"'
|
|
44
|
+
optional = false
|
|
45
|
+
|
|
46
|
+
[tool.poetry.dependencies.fastapi]
|
|
47
|
+
version = "0.121.1"
|
|
48
|
+
markers = 'python_version == "3.10"'
|
|
49
|
+
optional = false
|
|
50
|
+
|
|
51
|
+
[tool.poetry.dependencies.h11]
|
|
52
|
+
version = "0.16.0"
|
|
53
|
+
markers = 'python_version == "3.10"'
|
|
54
|
+
optional = false
|
|
55
|
+
|
|
56
|
+
[tool.poetry.dependencies.idna]
|
|
57
|
+
version = "3.11"
|
|
58
|
+
markers = 'python_version == "3.10"'
|
|
59
|
+
optional = false
|
|
60
|
+
|
|
61
|
+
[tool.poetry.dependencies.pydantic-core]
|
|
62
|
+
version = "2.41.4"
|
|
63
|
+
markers = 'python_version == "3.10"'
|
|
64
|
+
optional = false
|
|
65
|
+
|
|
66
|
+
[tool.poetry.dependencies.pydantic]
|
|
67
|
+
version = "2.12.3"
|
|
68
|
+
markers = 'python_version == "3.10"'
|
|
69
|
+
optional = false
|
|
70
|
+
|
|
71
|
+
[tool.poetry.dependencies.sniffio]
|
|
72
|
+
version = "1.3.1"
|
|
73
|
+
markers = 'python_version == "3.10"'
|
|
74
|
+
optional = false
|
|
75
|
+
|
|
76
|
+
[tool.poetry.dependencies.starlette]
|
|
77
|
+
version = "0.48.0"
|
|
78
|
+
markers = 'python_version == "3.10"'
|
|
79
|
+
optional = false
|
|
80
|
+
|
|
81
|
+
[tool.poetry.dependencies.typing-extensions]
|
|
82
|
+
version = "4.15.0"
|
|
83
|
+
markers = 'python_version == "3.10"'
|
|
84
|
+
optional = false
|
|
85
|
+
|
|
86
|
+
[tool.poetry.dependencies.typing-inspection]
|
|
87
|
+
version = "0.4.2"
|
|
88
|
+
markers = 'python_version == "3.10"'
|
|
89
|
+
optional = false
|
|
90
|
+
|
|
91
|
+
[tool.poetry.dependencies.uvicorn]
|
|
92
|
+
version = "0.35.0"
|
|
93
|
+
markers = 'python_version == "3.10"'
|
|
94
|
+
optional = false
|
|
95
|
+
|
|
96
|
+
[tool.poetry.group.dev]
|
|
97
|
+
dependencies = { }
|
|
@@ -7,7 +7,7 @@ from .connect_router import ConnectRouter
|
|
|
7
7
|
from .decorators import collect_bundle, set_global_app
|
|
8
8
|
from .codegen import generate_proto, sanitize_service_name
|
|
9
9
|
from .entries import RpcBundle
|
|
10
|
-
from .
|
|
10
|
+
from .rpc_registry import RpcRegistry
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
class VentionApp(FastAPI):
|
|
@@ -1,17 +1,30 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
-
|
|
2
|
+
import sys
|
|
3
|
+
from typing import Any, Dict, List, Optional, Type, Union, get_args, get_origin, Annotated
|
|
3
4
|
|
|
4
5
|
from .decorators import collect_bundle
|
|
5
6
|
from .typing_utils import is_pydantic_model
|
|
6
7
|
from .entries import RpcBundle, StreamEntry
|
|
7
8
|
|
|
9
|
+
|
|
10
|
+
# Handle Python 3.10+ types.UnionType
|
|
11
|
+
if sys.version_info >= (3, 10):
|
|
12
|
+
from types import UnionType
|
|
13
|
+
|
|
14
|
+
_UNION_TYPES = (Union, UnionType)
|
|
15
|
+
else:
|
|
16
|
+
_UNION_TYPES = (Union,)
|
|
17
|
+
|
|
8
18
|
_SCALAR_MAP = {
|
|
9
19
|
int: "int32",
|
|
10
20
|
float: "double",
|
|
11
21
|
str: "string",
|
|
12
22
|
bool: "bool",
|
|
23
|
+
bytes: "bytes",
|
|
13
24
|
}
|
|
14
25
|
|
|
26
|
+
MAX_NESTING_DEPTH = 10
|
|
27
|
+
|
|
15
28
|
HEADER = """syntax = "proto3";
|
|
16
29
|
package vention.app.v1;
|
|
17
30
|
|
|
@@ -20,22 +33,45 @@ import "google/protobuf/empty.proto";
|
|
|
20
33
|
"""
|
|
21
34
|
|
|
22
35
|
|
|
36
|
+
def _unwrap_annotated(type_annotation: Any) -> Any:
|
|
37
|
+
"""Unwrap Annotated[T, ...] to get the base type T."""
|
|
38
|
+
origin = get_origin(type_annotation)
|
|
39
|
+
if origin is Annotated:
|
|
40
|
+
args = get_args(type_annotation)
|
|
41
|
+
if args:
|
|
42
|
+
return args[0]
|
|
43
|
+
return type_annotation
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _filter_non_none_types(args: tuple[Any, ...]) -> list[Any]:
|
|
47
|
+
"""Filter out NoneType from union type arguments."""
|
|
48
|
+
return [arg for arg in args if arg is not type(None)]
|
|
49
|
+
|
|
50
|
+
|
|
23
51
|
def _unwrap_optional(type_annotation: Any) -> tuple[Any, bool]:
|
|
52
|
+
type_annotation = _unwrap_annotated(type_annotation)
|
|
53
|
+
|
|
24
54
|
origin = get_origin(type_annotation)
|
|
25
|
-
|
|
55
|
+
# Handle both typing.Union and types.UnionType (Python 3.10+)
|
|
56
|
+
if origin in _UNION_TYPES:
|
|
26
57
|
args = get_args(type_annotation)
|
|
27
|
-
non_none_args =
|
|
58
|
+
non_none_args = _filter_non_none_types(args)
|
|
28
59
|
if len(non_none_args) == 1:
|
|
29
|
-
|
|
60
|
+
# Recursively unwrap in case the inner type is also Annotated
|
|
61
|
+
return (_unwrap_annotated(non_none_args[0]), True)
|
|
30
62
|
return (type_annotation, False)
|
|
31
63
|
|
|
32
64
|
|
|
33
65
|
def _unwrap_list(type_annotation: Any) -> tuple[Any, bool]:
|
|
66
|
+
# First unwrap Annotated if present
|
|
67
|
+
type_annotation = _unwrap_annotated(type_annotation)
|
|
68
|
+
|
|
34
69
|
origin = get_origin(type_annotation)
|
|
35
70
|
if origin in (list, List):
|
|
36
71
|
args = get_args(type_annotation)
|
|
37
72
|
if args:
|
|
38
|
-
|
|
73
|
+
# Unwrap Annotated from the inner type as well
|
|
74
|
+
return (_unwrap_annotated(args[0]), True)
|
|
39
75
|
return (type_annotation, False)
|
|
40
76
|
|
|
41
77
|
|
|
@@ -47,16 +83,20 @@ def _determine_proto_type_for_field(
|
|
|
47
83
|
inner_type: Type[Any],
|
|
48
84
|
seen_models: set[str],
|
|
49
85
|
lines: list[str],
|
|
86
|
+
depth: int = 0,
|
|
50
87
|
) -> str:
|
|
88
|
+
if depth > MAX_NESTING_DEPTH:
|
|
89
|
+
raise ValueError(f"Maximum nesting depth ({MAX_NESTING_DEPTH}) exceeded in proto generation")
|
|
90
|
+
|
|
51
91
|
if inner_type in _SCALAR_MAP:
|
|
52
92
|
return _SCALAR_MAP[inner_type]
|
|
53
93
|
|
|
54
94
|
if is_pydantic_model(inner_type):
|
|
55
95
|
model_name = inner_type.__name__
|
|
56
|
-
|
|
96
|
+
|
|
57
97
|
if model_name not in seen_models:
|
|
58
98
|
seen_models.add(model_name)
|
|
59
|
-
lines.extend(_generate_pydantic_message(inner_type, seen_models, lines))
|
|
99
|
+
lines.extend(_generate_pydantic_message(inner_type, seen_models, lines, depth + 1))
|
|
60
100
|
return model_name
|
|
61
101
|
|
|
62
102
|
# Fallback to string for unknown types
|
|
@@ -69,14 +109,18 @@ def _process_pydantic_field(
|
|
|
69
109
|
field_index: int,
|
|
70
110
|
seen_models: set[str],
|
|
71
111
|
lines: list[str],
|
|
112
|
+
depth: int = 0,
|
|
72
113
|
) -> str:
|
|
73
|
-
|
|
74
|
-
|
|
114
|
+
unwrapped_type = _unwrap_annotated(field_type)
|
|
115
|
+
unwrapped_type, _ = _unwrap_optional(unwrapped_type)
|
|
116
|
+
list_inner_type, is_list = _unwrap_list(unwrapped_type)
|
|
117
|
+
# One more unwrap in case list contains Optional[Annotated[...]]
|
|
118
|
+
final_inner_type = _unwrap_annotated(list_inner_type)
|
|
75
119
|
|
|
76
|
-
proto_type = _determine_proto_type_for_field(
|
|
120
|
+
proto_type = _determine_proto_type_for_field(final_inner_type, seen_models, lines, depth)
|
|
77
121
|
|
|
78
122
|
if is_list:
|
|
79
|
-
|
|
123
|
+
return f" repeated {proto_type} {field_name} = {field_index};"
|
|
80
124
|
|
|
81
125
|
return f" {proto_type} {field_name} = {field_index};"
|
|
82
126
|
|
|
@@ -85,6 +129,7 @@ def _generate_pydantic_message(
|
|
|
85
129
|
type_annotation: Type[Any],
|
|
86
130
|
seen_models: set[str],
|
|
87
131
|
lines: list[str],
|
|
132
|
+
depth: int = 0,
|
|
88
133
|
) -> list[str]:
|
|
89
134
|
model_name = type_annotation.__name__
|
|
90
135
|
fields = []
|
|
@@ -97,6 +142,7 @@ def _generate_pydantic_message(
|
|
|
97
142
|
field_index,
|
|
98
143
|
seen_models,
|
|
99
144
|
lines,
|
|
145
|
+
depth,
|
|
100
146
|
)
|
|
101
147
|
fields.append(field_line)
|
|
102
148
|
field_index += 1
|
|
@@ -107,9 +153,7 @@ def _generate_pydantic_message(
|
|
|
107
153
|
return lines_result
|
|
108
154
|
|
|
109
155
|
|
|
110
|
-
def _generate_scalar_wrapper_message(
|
|
111
|
-
stream_name: str, payload_type: Type[Any]
|
|
112
|
-
) -> list[str]:
|
|
156
|
+
def _generate_scalar_wrapper_message(stream_name: str, payload_type: Type[Any]) -> list[str]:
|
|
113
157
|
wrapper_name = _msg_name_for_scalar_stream(stream_name)
|
|
114
158
|
lines = [
|
|
115
159
|
f"message {wrapper_name} {{",
|
|
@@ -150,17 +194,15 @@ def _register_pydantic_model(
|
|
|
150
194
|
if type_annotation is None:
|
|
151
195
|
return
|
|
152
196
|
|
|
153
|
-
|
|
197
|
+
unwrapped_type, _ = _unwrap_optional(type_annotation)
|
|
154
198
|
|
|
155
|
-
list_inner_type, _ = _unwrap_list(
|
|
199
|
+
list_inner_type, _ = _unwrap_list(unwrapped_type)
|
|
156
200
|
|
|
157
201
|
if is_pydantic_model(list_inner_type):
|
|
158
202
|
model_name = list_inner_type.__name__
|
|
159
203
|
if model_name not in seen_models:
|
|
160
204
|
seen_models.add(model_name)
|
|
161
|
-
lines.extend(
|
|
162
|
-
_generate_pydantic_message(list_inner_type, seen_models, lines)
|
|
163
|
-
)
|
|
205
|
+
lines.extend(_generate_pydantic_message(list_inner_type, seen_models, lines))
|
|
164
206
|
|
|
165
207
|
|
|
166
208
|
def _process_stream_payload(
|
|
@@ -169,17 +211,15 @@ def _process_stream_payload(
|
|
|
169
211
|
lines: list[str],
|
|
170
212
|
scalar_wrappers: Dict[str, str],
|
|
171
213
|
) -> None:
|
|
172
|
-
|
|
173
|
-
list_inner_type, _ = _unwrap_list(
|
|
214
|
+
unwrapped_type, _ = _unwrap_optional(stream_entry.payload_type)
|
|
215
|
+
list_inner_type, _ = _unwrap_list(unwrapped_type)
|
|
174
216
|
|
|
175
217
|
if is_pydantic_model(list_inner_type):
|
|
176
218
|
_register_pydantic_model(list_inner_type, seen_models, lines)
|
|
177
219
|
elif list_inner_type in _SCALAR_MAP:
|
|
178
220
|
wrapper_name = _msg_name_for_scalar_stream(stream_entry.name)
|
|
179
221
|
scalar_wrappers[stream_entry.name] = wrapper_name
|
|
180
|
-
lines.extend(
|
|
181
|
-
_generate_scalar_wrapper_message(stream_entry.name, list_inner_type)
|
|
182
|
-
)
|
|
222
|
+
lines.extend(_generate_scalar_wrapper_message(stream_entry.name, list_inner_type))
|
|
183
223
|
|
|
184
224
|
|
|
185
225
|
def _collect_message_types(
|
|
@@ -196,25 +236,17 @@ def _collect_message_types(
|
|
|
196
236
|
_process_stream_payload(stream_entry, seen_models, lines, scalar_wrappers)
|
|
197
237
|
|
|
198
238
|
|
|
199
|
-
def _generate_service_rpcs(
|
|
200
|
-
bundle: RpcBundle, lines: list[str], scalar_wrappers: Dict[str, str]
|
|
201
|
-
) -> None:
|
|
239
|
+
def _generate_service_rpcs(bundle: RpcBundle, lines: list[str], scalar_wrappers: Dict[str, str]) -> None:
|
|
202
240
|
rpc_prefix = " rpc"
|
|
203
241
|
|
|
204
242
|
for action_entry in bundle.actions:
|
|
205
243
|
input_type = _proto_type_name(action_entry.input_type, scalar_wrappers)
|
|
206
244
|
output_type = _proto_type_name(action_entry.output_type, scalar_wrappers)
|
|
207
|
-
lines.append(
|
|
208
|
-
f"{rpc_prefix} {action_entry.name} ({input_type}) returns ({output_type});"
|
|
209
|
-
)
|
|
245
|
+
lines.append(f"{rpc_prefix} {action_entry.name} ({input_type}) returns ({output_type});")
|
|
210
246
|
|
|
211
247
|
for stream_entry in bundle.streams:
|
|
212
|
-
output_type = _proto_type_name(
|
|
213
|
-
|
|
214
|
-
)
|
|
215
|
-
lines.append(
|
|
216
|
-
f"{rpc_prefix} {stream_entry.name} (google.protobuf.Empty) returns (stream {output_type});"
|
|
217
|
-
)
|
|
248
|
+
output_type = _proto_type_name(stream_entry.payload_type, scalar_wrappers, stream_entry.name)
|
|
249
|
+
lines.append(f"{rpc_prefix} {stream_entry.name} (google.protobuf.Empty) returns (stream {output_type});")
|
|
218
250
|
|
|
219
251
|
|
|
220
252
|
def generate_proto(app_name: str, *, bundle: Optional[RpcBundle] = None) -> str:
|
{vention_communication-0.2.2 → vention_communication-0.3.4}/src/communication/connect_router.py
RENAMED
|
@@ -18,9 +18,7 @@ CONTENT_TYPE = "application/connect+json"
|
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
def _frame(payload: Dict[str, Any], *, trailer: bool = False) -> bytes:
|
|
21
|
-
body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode(
|
|
22
|
-
"utf-8"
|
|
23
|
-
)
|
|
21
|
+
body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
|
24
22
|
flag = 0x80 if trailer else 0x00
|
|
25
23
|
header = bytes([flag]) + len(body).to_bytes(4, byteorder="big", signed=False)
|
|
26
24
|
return header + body
|
|
@@ -121,9 +119,7 @@ class StreamManager:
|
|
|
121
119
|
"""
|
|
122
120
|
topic = self._topics[stream_name]
|
|
123
121
|
entry: StreamEntry = topic["entry"]
|
|
124
|
-
subscriber_queue: asyncio.Queue[Any] = asyncio.Queue(
|
|
125
|
-
maxsize=entry.queue_maxsize
|
|
126
|
-
)
|
|
122
|
+
subscriber_queue: asyncio.Queue[Any] = asyncio.Queue(maxsize=entry.queue_maxsize)
|
|
127
123
|
subscriber = _Subscriber(queue=subscriber_queue)
|
|
128
124
|
topic["subscribers"].add(subscriber)
|
|
129
125
|
self.start_distributor_if_needed(stream_name)
|
|
@@ -217,7 +213,7 @@ class ConnectRouter:
|
|
|
217
213
|
result = await _maybe_await(entry.func(validated_arg))
|
|
218
214
|
|
|
219
215
|
if hasattr(result, "model_dump"):
|
|
220
|
-
result = result.model_dump(by_alias=True)
|
|
216
|
+
result = result.model_dump(mode="json", by_alias=True)
|
|
221
217
|
return JSONResponse(result or {})
|
|
222
218
|
except Exception as exc:
|
|
223
219
|
return JSONResponse(error_envelope(exc))
|
|
@@ -283,7 +279,7 @@ class ConnectRouter:
|
|
|
283
279
|
|
|
284
280
|
def _serialize_stream_item(item: Any) -> Dict[str, Any]:
|
|
285
281
|
if hasattr(item, "model_dump"):
|
|
286
|
-
dumped = item.model_dump(by_alias=True)
|
|
282
|
+
dumped = item.model_dump(mode="json", by_alias=True)
|
|
287
283
|
if isinstance(dumped, dict):
|
|
288
284
|
return dumped
|
|
289
285
|
return {"value": dumped}
|
|
@@ -38,9 +38,7 @@ def action(
|
|
|
38
38
|
def decorator(function: Callable[..., Any]) -> Callable[..., Any]:
|
|
39
39
|
input_type = infer_input_type(function)
|
|
40
40
|
output_type = infer_output_type(function)
|
|
41
|
-
entry = ActionEntry(
|
|
42
|
-
name or function.__name__, function, input_type, output_type
|
|
43
|
-
)
|
|
41
|
+
entry = ActionEntry(name or function.__name__, function, input_type, output_type)
|
|
44
42
|
_actions.append(entry)
|
|
45
43
|
return function
|
|
46
44
|
|
|
@@ -71,9 +69,7 @@ def stream(
|
|
|
71
69
|
Decorator function that registers the stream
|
|
72
70
|
"""
|
|
73
71
|
if not (is_pydantic_model(payload) or payload in (int, float, str, bool, dict)):
|
|
74
|
-
raise ValueError(
|
|
75
|
-
"payload must be a pydantic BaseModel or a JSON-serializable scalar/dict"
|
|
76
|
-
)
|
|
72
|
+
raise ValueError("payload must be a pydantic BaseModel or a JSON-serializable scalar/dict")
|
|
77
73
|
|
|
78
74
|
def decorator(function: Callable[..., Any]) -> Callable[..., Any]:
|
|
79
75
|
entry = StreamEntry(
|
|
@@ -5,9 +5,7 @@ from typing import Any, Dict, List, Optional
|
|
|
5
5
|
class ConnectError(Exception):
|
|
6
6
|
"""Application-level error to send over Connect transport."""
|
|
7
7
|
|
|
8
|
-
def __init__(
|
|
9
|
-
self, code: str, message: str, *, details: Optional[List[Any]] = None
|
|
10
|
-
) -> None:
|
|
8
|
+
def __init__(self, code: str, message: str, *, details: Optional[List[Any]] = None) -> None:
|
|
11
9
|
super().__init__(message)
|
|
12
10
|
self.code = code
|
|
13
11
|
self.message = message
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import List, Optional, Set, Type
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
from .entries import RpcBundle
|
|
9
|
+
from .typing_utils import extract_nested_models, is_pydantic_model, apply_aliases
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class RpcRegistry:
|
|
14
|
+
"""
|
|
15
|
+
Central registry that collects RPC bundles, applies model normalization
|
|
16
|
+
(Pydantic field aliasing), and exposes a unified bundle.
|
|
17
|
+
|
|
18
|
+
- Plugins and decorators add RpcBundle instances.
|
|
19
|
+
- Registry merges them.
|
|
20
|
+
- Registry applies camelCase aliases to all Pydantic models exactly once.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
service_name: str = "VentionApp"
|
|
24
|
+
_bundles: List[RpcBundle] = field(default_factory=list)
|
|
25
|
+
_models_normalized: bool = False
|
|
26
|
+
|
|
27
|
+
# ------------- Bundle registration -------------
|
|
28
|
+
|
|
29
|
+
def add_bundle(self, bundle: RpcBundle) -> None:
|
|
30
|
+
"""Register a bundle for inclusion in the unified RPC view."""
|
|
31
|
+
self._bundles.append(bundle)
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def bundle(self) -> RpcBundle:
|
|
35
|
+
"""Return a merged RpcBundle (does not mutate stored bundles)."""
|
|
36
|
+
merged = RpcBundle()
|
|
37
|
+
for bundle in self._bundles:
|
|
38
|
+
merged.extend(bundle)
|
|
39
|
+
return merged
|
|
40
|
+
|
|
41
|
+
# ------------- Model normalization / aliasing -------------
|
|
42
|
+
def _normalize_model(self, model: Optional[Type[BaseModel]], seen: Set[Type[BaseModel]]) -> None:
|
|
43
|
+
"""Recursively normalize a model and all its nested models."""
|
|
44
|
+
if model is None:
|
|
45
|
+
return
|
|
46
|
+
if not is_pydantic_model(model):
|
|
47
|
+
return
|
|
48
|
+
if model in seen:
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
seen.add(model)
|
|
52
|
+
apply_aliases(model)
|
|
53
|
+
|
|
54
|
+
if hasattr(model, "model_fields"):
|
|
55
|
+
for _, field_info in model.model_fields.items():
|
|
56
|
+
nested_models = extract_nested_models(field_info.annotation)
|
|
57
|
+
for nested_model in nested_models:
|
|
58
|
+
self._normalize_model(nested_model, seen)
|
|
59
|
+
|
|
60
|
+
def normalize_models_and_apply_aliases(self) -> None:
|
|
61
|
+
"""
|
|
62
|
+
Walk all RPCs in all bundles and apply camelCase JSON aliases
|
|
63
|
+
to every Pydantic model exactly once, including nested models.
|
|
64
|
+
"""
|
|
65
|
+
if self._models_normalized:
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
seen: Set[Type[BaseModel]] = set()
|
|
69
|
+
|
|
70
|
+
for bundle in self._bundles:
|
|
71
|
+
for action in bundle.actions:
|
|
72
|
+
self._normalize_model(action.input_type, seen)
|
|
73
|
+
self._normalize_model(action.output_type, seen)
|
|
74
|
+
for stream in bundle.streams:
|
|
75
|
+
self._normalize_model(stream.payload_type, seen)
|
|
76
|
+
|
|
77
|
+
self._models_normalized = True
|
|
78
|
+
|
|
79
|
+
# ------------- Unified, normalized view -------------
|
|
80
|
+
|
|
81
|
+
def get_unified_bundle(self) -> RpcBundle:
|
|
82
|
+
"""
|
|
83
|
+
Get the fully merged, normalized RPC bundle.
|
|
84
|
+
|
|
85
|
+
This will apply aliasing exactly once and then return a merged RpcBundle.
|
|
86
|
+
"""
|
|
87
|
+
self.normalize_models_and_apply_aliases()
|
|
88
|
+
return self.bundle
|
{vention_communication-0.2.2 → vention_communication-0.3.4}/src/communication/typing_utils.py
RENAMED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
import inspect
|
|
3
|
-
from typing import
|
|
3
|
+
from typing import (
|
|
4
|
+
Any,
|
|
5
|
+
Callable,
|
|
6
|
+
List,
|
|
7
|
+
Optional,
|
|
8
|
+
Type,
|
|
9
|
+
Union,
|
|
10
|
+
get_args,
|
|
11
|
+
get_origin,
|
|
12
|
+
get_type_hints,
|
|
13
|
+
cast,
|
|
14
|
+
)
|
|
4
15
|
|
|
5
16
|
from pydantic import BaseModel, ConfigDict
|
|
6
17
|
|
|
@@ -83,9 +94,7 @@ def is_pydantic_model(type_annotation: Any) -> bool:
|
|
|
83
94
|
True if the type is a Pydantic BaseModel subclass, False otherwise
|
|
84
95
|
"""
|
|
85
96
|
try:
|
|
86
|
-
return isinstance(type_annotation, type) and issubclass(
|
|
87
|
-
type_annotation, BaseModel
|
|
88
|
-
)
|
|
97
|
+
return isinstance(type_annotation, type) and issubclass(type_annotation, BaseModel)
|
|
89
98
|
except Exception:
|
|
90
99
|
return False
|
|
91
100
|
|
|
@@ -109,9 +118,7 @@ def apply_aliases(model_cls: Type[BaseModel]) -> None:
|
|
|
109
118
|
if existing_config is None:
|
|
110
119
|
existing_dict: dict[str, Any] = {}
|
|
111
120
|
else:
|
|
112
|
-
existing_dict = (
|
|
113
|
-
dict(existing_config) if isinstance(existing_config, dict) else {}
|
|
114
|
-
)
|
|
121
|
+
existing_dict = dict(existing_config) if isinstance(existing_config, dict) else {}
|
|
115
122
|
|
|
116
123
|
merged_config: dict[str, Any] = {
|
|
117
124
|
"populate_by_name": True,
|
|
@@ -122,3 +129,40 @@ def apply_aliases(model_cls: Type[BaseModel]) -> None:
|
|
|
122
129
|
|
|
123
130
|
# Force rebuild
|
|
124
131
|
model_cls.model_rebuild(force=True)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def unwrap_optional(field_type: Any) -> Any:
|
|
135
|
+
"""Unwrap Optional[Type] or Union[Type, None] to get the non-None type."""
|
|
136
|
+
origin = get_origin(field_type)
|
|
137
|
+
if origin is not Union:
|
|
138
|
+
return field_type
|
|
139
|
+
|
|
140
|
+
args = get_args(field_type)
|
|
141
|
+
non_none_args = [arg for arg in args if arg is not type(None)]
|
|
142
|
+
if len(non_none_args) == 1:
|
|
143
|
+
return non_none_args[0]
|
|
144
|
+
return field_type
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def unwrap_list(field_type: Any) -> Any:
|
|
148
|
+
"""Unwrap List[Type] to get the inner type, handling Optional inside List."""
|
|
149
|
+
origin = get_origin(field_type)
|
|
150
|
+
if origin not in (list, List):
|
|
151
|
+
return field_type
|
|
152
|
+
|
|
153
|
+
args = get_args(field_type)
|
|
154
|
+
if not args:
|
|
155
|
+
return field_type
|
|
156
|
+
|
|
157
|
+
inner_type = args[0]
|
|
158
|
+
return unwrap_optional(inner_type)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def extract_nested_models(field_type: Any) -> List[Type[BaseModel]]:
|
|
162
|
+
"""Extract Pydantic models from a field type, handling Optional, List, etc."""
|
|
163
|
+
field_type = unwrap_optional(field_type)
|
|
164
|
+
field_type = unwrap_list(field_type)
|
|
165
|
+
|
|
166
|
+
if is_pydantic_model(field_type):
|
|
167
|
+
return [field_type]
|
|
168
|
+
return []
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
[tool.poetry]
|
|
2
|
-
name = "vention-communication"
|
|
3
|
-
version = "0.2.2"
|
|
4
|
-
description = "A framework for communication between machine apps and other services."
|
|
5
|
-
authors = [ "VentionCo" ]
|
|
6
|
-
readme = "README.md"
|
|
7
|
-
license = "Proprietary"
|
|
8
|
-
packages = [{ include = "communication", from = "src" }]
|
|
9
|
-
|
|
10
|
-
[tool.poetry.dependencies]
|
|
11
|
-
python = ">=3.10,<3.11"
|
|
12
|
-
fastapi = "0.121.1"
|
|
13
|
-
uvicorn = "^0.35.0"
|
|
14
|
-
|
|
15
|
-
[tool.poetry.group.dev.dependencies]
|
|
16
|
-
pytest = "^8.3.4"
|
|
17
|
-
ruff = "^0.8.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|