vention-communication 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
File without changes
communication/app.py ADDED
@@ -0,0 +1,88 @@
1
+ from __future__ import annotations
2
+ from typing import Any, List
3
+
4
+ from fastapi import FastAPI
5
+
6
+ from .connect_router import ConnectRouter
7
+ from .decorators import collect_bundle, set_global_app
8
+ from .codegen import generate_proto, sanitize_service_name
9
+ from .entries import RpcBundle
10
+
11
+
12
+ class VentionApp(FastAPI):
13
+ """
14
+ FastAPI app that registers Connect-style RPCs and streams from decorators.
15
+ Can be extended with external RpcBundles (state-machine, storage, etc.).
16
+ """
17
+
18
+ def __init__(
19
+ self,
20
+ name: str = "VentionApp",
21
+ *,
22
+ emit_proto: bool = False,
23
+ proto_path: str = "proto/app.proto",
24
+ **kwargs: Any,
25
+ ) -> None:
26
+ """Initialize the VentionApp.
27
+
28
+ Args:
29
+ name: Application name, also used as service_name for proto generation
30
+ emit_proto: Whether to emit protocol buffer definitions
31
+ proto_path: Path where proto definitions will be written
32
+ **kwargs: Additional arguments passed to FastAPI
33
+ """
34
+ super().__init__(**kwargs)
35
+ self.name = name
36
+ self.emit_proto = emit_proto
37
+ self.proto_path = proto_path
38
+ self.connect_router = ConnectRouter()
39
+ self._extra_bundles: List[RpcBundle] = []
40
+
41
+ def extend_bundle(self, bundle: RpcBundle) -> None:
42
+ """Add RPCs/streams provided by external libraries.
43
+
44
+ Must be called before finalize().
45
+
46
+ Args:
47
+ bundle: RPC bundle containing actions and streams to register
48
+ """
49
+ self._extra_bundles.append(bundle)
50
+
51
+ def finalize(self) -> None:
52
+ """Finalize the app by registering all RPCs and streams.
53
+
54
+ Collects decorator-registered RPCs, merges external bundles,
55
+ registers them with the Connect router, optionally emits proto
56
+ definitions, and makes the app available to stream publishers.
57
+ """
58
+ bundle = collect_bundle()
59
+ for extra_bundle in self._extra_bundles:
60
+ bundle.extend(extra_bundle)
61
+
62
+ service_fully_qualified_name = f"vention.app.v1.{self.service_name}Service"
63
+
64
+ for action_entry in bundle.actions:
65
+ self.connect_router.add_unary(action_entry, service_fully_qualified_name)
66
+ for stream_entry in bundle.streams:
67
+ self.connect_router.add_stream(stream_entry, service_fully_qualified_name)
68
+
69
+ self.include_router(self.connect_router.router, prefix="/rpc")
70
+
71
+ if self.emit_proto:
72
+ proto = generate_proto(self.service_name, bundle=bundle)
73
+ import os
74
+
75
+ os.makedirs(os.path.dirname(self.proto_path), exist_ok=True)
76
+ with open(self.proto_path, "w", encoding="utf-8") as proto_file:
77
+ proto_file.write(proto)
78
+
79
+ set_global_app(self)
80
+
81
+ @property
82
+ def service_name(self) -> str:
83
+ """Get the sanitized service name derived from the app name.
84
+
85
+ Returns:
86
+ Sanitized service name suitable for proto generation
87
+ """
88
+ return sanitize_service_name(self.name)
@@ -0,0 +1,392 @@
1
+ from __future__ import annotations
2
+ from typing import Any, Dict, List, Optional, Type, Union, get_args, get_origin
3
+
4
+ from .decorators import collect_bundle
5
+ from .typing_utils import is_pydantic_model
6
+ from .entries import RpcBundle, StreamEntry
7
+
8
+ _SCALAR_MAP = {
9
+ int: "int64",
10
+ float: "double",
11
+ str: "string",
12
+ bool: "bool",
13
+ }
14
+
15
+ HEADER = """syntax = "proto3";
16
+ package vention.app.v1;
17
+
18
+ import "google/protobuf/empty.proto";
19
+
20
+ """
21
+
22
+
23
+ def _unwrap_optional(type_annotation: Any) -> tuple[Any, bool]:
24
+ """Unwrap Optional[T] or Union[T, None] to T.
25
+
26
+ Args:
27
+ type_annotation: Type annotation that may be Optional
28
+
29
+ Returns:
30
+ Tuple of (inner_type, is_optional)
31
+ """
32
+ origin = get_origin(type_annotation)
33
+ # Check if it's a Union type (Optional is Union[T, None])
34
+ if origin is Union:
35
+ args = get_args(type_annotation)
36
+ # Filter out NoneType
37
+ non_none_args = [arg for arg in args if arg is not type(None)]
38
+ if len(non_none_args) == 1:
39
+ return (non_none_args[0], True)
40
+ return (type_annotation, False)
41
+
42
+
43
+ def _unwrap_list(type_annotation: Any) -> tuple[Any, bool]:
44
+ """Unwrap List[T] or list[T] to T.
45
+
46
+ Args:
47
+ type_annotation: Type annotation that may be a list
48
+
49
+ Returns:
50
+ Tuple of (inner_type, is_list)
51
+ """
52
+ origin = get_origin(type_annotation)
53
+ if origin in (list, List):
54
+ args = get_args(type_annotation)
55
+ if args:
56
+ return (args[0], True)
57
+ return (type_annotation, False)
58
+
59
+
60
+ def _msg_name_for_scalar_stream(stream_name: str) -> str:
61
+ """Generate a message name for a scalar stream payload.
62
+
63
+ Args:
64
+ stream_name: Name of the stream
65
+
66
+ Returns:
67
+ Message name for the scalar wrapper
68
+ """
69
+ return f"{stream_name}Message"
70
+
71
+
72
+ def _determine_proto_type_for_field(
73
+ inner_type: Type[Any],
74
+ seen_models: set[str],
75
+ lines: list[str],
76
+ ) -> str:
77
+ """Determine the proto type name for a field's inner type.
78
+
79
+ Handles scalars, nested Pydantic models, and fallback to string.
80
+ Recursively generates nested model definitions if needed.
81
+
82
+ Args:
83
+ inner_type: The unwrapped inner type (after Optional/List)
84
+ seen_models: Set of already-seen model names to avoid duplicates
85
+ lines: List to append nested message definitions to
86
+
87
+ Returns:
88
+ Proto type name string
89
+ """
90
+ if inner_type in _SCALAR_MAP:
91
+ return _SCALAR_MAP[inner_type]
92
+
93
+ if is_pydantic_model(inner_type):
94
+ model_name = inner_type.__name__
95
+ # Recursively register nested model if not seen before
96
+ if model_name not in seen_models:
97
+ seen_models.add(model_name)
98
+ lines.extend(_generate_pydantic_message(inner_type, seen_models, lines))
99
+ return model_name
100
+
101
+ # Fallback to string for unknown types
102
+ return "string"
103
+
104
+
105
+ def _process_pydantic_field(
106
+ field_name: str,
107
+ field_type: Type[Any],
108
+ field_index: int,
109
+ seen_models: set[str],
110
+ lines: list[str],
111
+ ) -> str:
112
+ """Process a single Pydantic field and generate its proto definition.
113
+
114
+ Args:
115
+ field_name: Name of the field
116
+ field_type: Type annotation of the field
117
+ field_index: Proto field number (1-indexed)
118
+ seen_models: Set of already-seen model names to avoid duplicates
119
+ lines: List to append nested message definitions to
120
+
121
+ Returns:
122
+ Proto field definition line (e.g., " string name = 1;")
123
+ """
124
+ # Unwrap Optional first
125
+ inner_type, _ = _unwrap_optional(field_type)
126
+
127
+ # Unwrap List
128
+ list_inner_type, is_list = _unwrap_list(inner_type)
129
+
130
+ # Determine proto type (handles nested models recursively)
131
+ proto_type = _determine_proto_type_for_field(list_inner_type, seen_models, lines)
132
+
133
+ # Add "repeated" prefix for lists
134
+ if is_list:
135
+ proto_type = f"repeated {proto_type}"
136
+
137
+ return f" {proto_type} {field_name} = {field_index};"
138
+
139
+
140
+ def _generate_pydantic_message(
141
+ type_annotation: Type[Any],
142
+ seen_models: set[str],
143
+ lines: list[str],
144
+ ) -> list[str]:
145
+ """Generate proto message definition for a Pydantic model.
146
+
147
+ Args:
148
+ type_annotation: Pydantic model type
149
+ seen_models: Set of already-seen model names to avoid duplicates
150
+ lines: List to append nested message definitions to
151
+
152
+ Returns:
153
+ List of lines for the message definition
154
+ """
155
+ model_name = type_annotation.__name__
156
+ fields = []
157
+ field_index = 1
158
+
159
+ for field_name, field_def in type_annotation.model_fields.items():
160
+ field_line = _process_pydantic_field(
161
+ field_name,
162
+ field_def.annotation,
163
+ field_index,
164
+ seen_models,
165
+ lines,
166
+ )
167
+ fields.append(field_line)
168
+ field_index += 1
169
+
170
+ lines_result = [f"message {model_name} {{"]
171
+ lines_result.extend(fields)
172
+ lines_result.append("}\n")
173
+ return lines_result
174
+
175
+
176
+ def _generate_scalar_wrapper_message(
177
+ stream_name: str, payload_type: Type[Any]
178
+ ) -> list[str]:
179
+ """Generate proto message definition for a scalar stream payload.
180
+
181
+ Args:
182
+ stream_name: Name of the stream
183
+ payload_type: Scalar type (int, float, str, bool)
184
+
185
+ Returns:
186
+ List of lines for the wrapper message definition
187
+ """
188
+ wrapper_name = _msg_name_for_scalar_stream(stream_name)
189
+ lines = [
190
+ f"message {wrapper_name} {{",
191
+ f" {_SCALAR_MAP[payload_type]} value = 1;",
192
+ "}\n",
193
+ ]
194
+ return lines
195
+
196
+
197
+ def _proto_type_name(
198
+ type_annotation: Optional[Type[Any]],
199
+ scalar_wrappers: Dict[str, str],
200
+ stream_name: Optional[str] = None,
201
+ ) -> str:
202
+ """Map a Python type to its proto type name.
203
+
204
+ Args:
205
+ type_annotation: Type to map
206
+ scalar_wrappers: Dictionary mapping stream names to wrapper message names
207
+ stream_name: Optional stream name for scalar wrapper lookup
208
+
209
+ Returns:
210
+ Proto type name string
211
+ """
212
+ if type_annotation is None:
213
+ return "google.protobuf.Empty"
214
+
215
+ # Unwrap Optional
216
+ inner_type, _ = _unwrap_optional(type_annotation)
217
+
218
+ # Unwrap List (for streams, lists become scalar wrappers)
219
+ list_inner_type, is_list = _unwrap_list(inner_type)
220
+
221
+ if list_inner_type in _SCALAR_MAP:
222
+ if stream_name and stream_name in scalar_wrappers:
223
+ return scalar_wrappers[stream_name]
224
+ # For streams, if it's a list, we don't use "repeated" in the return type
225
+ # The stream itself provides the repetition
226
+ return str(_SCALAR_MAP[list_inner_type])
227
+
228
+ if is_pydantic_model(list_inner_type):
229
+ return str(list_inner_type.__name__)
230
+
231
+ return "google.protobuf.Empty"
232
+
233
+
234
+ def _register_pydantic_model(
235
+ type_annotation: Optional[Type[Any]],
236
+ seen_models: set[str],
237
+ lines: list[str],
238
+ ) -> None:
239
+ """Register a Pydantic model and recursively register nested models.
240
+
241
+ Unwraps Optional and List types to find the underlying Pydantic model,
242
+ then generates its proto definition if not already seen.
243
+
244
+ Args:
245
+ type_annotation: Type annotation that may contain a Pydantic model
246
+ seen_models: Set of already-seen model names to avoid duplicates
247
+ lines: List to append message definitions to
248
+ """
249
+ if type_annotation is None:
250
+ return
251
+
252
+ # Unwrap Optional
253
+ inner_type, _ = _unwrap_optional(type_annotation)
254
+
255
+ # Unwrap List to get inner type
256
+ list_inner_type, _ = _unwrap_list(inner_type)
257
+
258
+ if is_pydantic_model(list_inner_type):
259
+ model_name = list_inner_type.__name__
260
+ if model_name not in seen_models:
261
+ seen_models.add(model_name)
262
+ # This will recursively register nested models via _generate_pydantic_message
263
+ lines.extend(
264
+ _generate_pydantic_message(list_inner_type, seen_models, lines)
265
+ )
266
+
267
+
268
+ def _process_stream_payload(
269
+ stream_entry: StreamEntry,
270
+ seen_models: set[str],
271
+ lines: list[str],
272
+ scalar_wrappers: Dict[str, str],
273
+ ) -> None:
274
+ """Process a stream's payload type and generate necessary message definitions.
275
+
276
+ Args:
277
+ stream_entry: Stream entry containing payload type information
278
+ seen_models: Set of already-seen model names to avoid duplicates
279
+ lines: List to append message definitions to
280
+ scalar_wrappers: Dictionary to populate with scalar wrapper mappings
281
+ """
282
+ # Unwrap Optional and List for stream payloads
283
+ inner_type, _ = _unwrap_optional(stream_entry.payload_type)
284
+ list_inner_type, _ = _unwrap_list(inner_type)
285
+
286
+ if is_pydantic_model(list_inner_type):
287
+ _register_pydantic_model(list_inner_type, seen_models, lines)
288
+ elif list_inner_type in _SCALAR_MAP:
289
+ wrapper_name = _msg_name_for_scalar_stream(stream_entry.name)
290
+ scalar_wrappers[stream_entry.name] = wrapper_name
291
+ lines.extend(
292
+ _generate_scalar_wrapper_message(stream_entry.name, list_inner_type)
293
+ )
294
+
295
+
296
+ def _collect_message_types(
297
+ bundle: RpcBundle,
298
+ lines: list[str],
299
+ seen_models: set[str],
300
+ scalar_wrappers: Dict[str, str],
301
+ ) -> None:
302
+ """Collect and generate all message types needed for the proto.
303
+
304
+ Args:
305
+ bundle: RPC bundle containing actions and streams
306
+ lines: List to append message definitions to
307
+ seen_models: Set of already-seen model names to avoid duplicates
308
+ scalar_wrappers: Dictionary to populate with scalar wrapper mappings
309
+ """
310
+ # Process action input/output types
311
+ for action_entry in bundle.actions:
312
+ _register_pydantic_model(action_entry.input_type, seen_models, lines)
313
+ _register_pydantic_model(action_entry.output_type, seen_models, lines)
314
+
315
+ # Process stream payload types
316
+ for stream_entry in bundle.streams:
317
+ _process_stream_payload(stream_entry, seen_models, lines, scalar_wrappers)
318
+
319
+
320
+ def _generate_service_rpcs(
321
+ bundle: RpcBundle, lines: list[str], scalar_wrappers: Dict[str, str]
322
+ ) -> None:
323
+ """Generate RPC method definitions for actions and streams.
324
+
325
+ Args:
326
+ bundle: RPC bundle containing actions and streams
327
+ lines: List to append RPC definitions to
328
+ scalar_wrappers: Dictionary mapping stream names to wrapper message names
329
+ """
330
+ rpc_prefix = " rpc"
331
+
332
+ for action_entry in bundle.actions:
333
+ input_type = _proto_type_name(action_entry.input_type, scalar_wrappers)
334
+ output_type = _proto_type_name(action_entry.output_type, scalar_wrappers)
335
+ lines.append(
336
+ f"{rpc_prefix} {action_entry.name} ({input_type}) returns ({output_type});"
337
+ )
338
+
339
+ for stream_entry in bundle.streams:
340
+ output_type = _proto_type_name(
341
+ stream_entry.payload_type, scalar_wrappers, stream_entry.name
342
+ )
343
+ lines.append(
344
+ f"{rpc_prefix} {stream_entry.name} (google.protobuf.Empty) returns (stream {output_type});"
345
+ )
346
+
347
+
348
+ def generate_proto(app_name: str, *, bundle: Optional[RpcBundle] = None) -> str:
349
+ """Generate a Protocol Buffer definition from RPC actions and streams.
350
+
351
+ Args:
352
+ app_name: Name of the application (used for service name)
353
+ bundle: Optional RPC bundle. If not provided, collects from decorators.
354
+
355
+ Returns:
356
+ Complete proto3 file content as a string
357
+ """
358
+ if bundle is None:
359
+ bundle = collect_bundle()
360
+
361
+ lines: list[str] = [HEADER]
362
+ seen_models: set[str] = set()
363
+ scalar_wrappers: Dict[str, str] = {}
364
+
365
+ _collect_message_types(bundle, lines, seen_models, scalar_wrappers)
366
+
367
+ service_name = sanitize_service_name(app_name)
368
+ lines.append(f"service {service_name}Service {{")
369
+ _generate_service_rpcs(bundle, lines, scalar_wrappers)
370
+ lines.append("}\n")
371
+
372
+ return "\n".join(lines)
373
+
374
+
375
+ def sanitize_service_name(name: str) -> str:
376
+ """Sanitize an application name for use as a service name.
377
+
378
+ Extracts alphanumeric parts, capitalizes each part, and joins them.
379
+ Invalid characters are dropped.
380
+
381
+ Args:
382
+ name: Application name to sanitize
383
+
384
+ Returns:
385
+ Sanitized service name, or "VentionApp" if no valid parts found
386
+ """
387
+ import re
388
+
389
+ parts = re.findall(r"[A-Za-z0-9]+", name)
390
+ if not parts:
391
+ return "VentionApp"
392
+ return "".join(part[:1].upper() + part[1:] for part in parts)