vention-communication 0.2.1__py3-none-any.whl → 0.2.2__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.
- communication/app.py +28 -14
- communication/codegen.py +1 -1
- communication/connect_router.py +2 -2
- communication/registry.py +124 -0
- communication/typing_utils.py +38 -4
- {vention_communication-0.2.1.dist-info → vention_communication-0.2.2.dist-info}/METADATA +1 -1
- vention_communication-0.2.2.dist-info/RECORD +12 -0
- vention_communication-0.2.1.dist-info/RECORD +0 -11
- {vention_communication-0.2.1.dist-info → vention_communication-0.2.2.dist-info}/WHEEL +0 -0
communication/app.py
CHANGED
|
@@ -7,6 +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 .registry import RpcRegistry
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
class VentionApp(FastAPI):
|
|
@@ -35,9 +36,11 @@ class VentionApp(FastAPI):
|
|
|
35
36
|
self.name = name
|
|
36
37
|
self.emit_proto = emit_proto
|
|
37
38
|
self.proto_path = proto_path
|
|
38
|
-
|
|
39
|
+
|
|
39
40
|
self._extra_bundles: List[RpcBundle] = []
|
|
40
41
|
|
|
42
|
+
self._registry = RpcRegistry(service_name=self.service_name)
|
|
43
|
+
|
|
41
44
|
def register_rpc_plugin(self, bundle: RpcBundle) -> None:
|
|
42
45
|
"""Add RPCs/streams provided by external libraries.
|
|
43
46
|
|
|
@@ -51,30 +54,41 @@ class VentionApp(FastAPI):
|
|
|
51
54
|
def finalize(self) -> None:
|
|
52
55
|
"""Finalize the app by registering all RPCs and streams.
|
|
53
56
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
+
Steps:
|
|
58
|
+
- Collect decorator-registered RPCs
|
|
59
|
+
- Merge in external bundles
|
|
60
|
+
- Use RpcRegistry to:
|
|
61
|
+
* apply aliases to all Pydantic models
|
|
62
|
+
* provide a unified RpcBundle
|
|
63
|
+
- Wire actions/streams into ConnectRouter
|
|
64
|
+
- Mount router at /rpc
|
|
65
|
+
- Optionally generate .proto
|
|
66
|
+
- Expose global app for stream publishers
|
|
57
67
|
"""
|
|
58
|
-
|
|
68
|
+
base_bundle = collect_bundle()
|
|
69
|
+
self._registry.add_bundle(base_bundle)
|
|
59
70
|
for extra_bundle in self._extra_bundles:
|
|
60
|
-
|
|
71
|
+
self._registry.add_bundle(extra_bundle)
|
|
61
72
|
|
|
62
|
-
|
|
73
|
+
unified_bundle = self._registry.get_unified_bundle()
|
|
74
|
+
|
|
75
|
+
service_full_name = f"vention.app.v1.{self.service_name}Service"
|
|
76
|
+
self.connect_router = ConnectRouter()
|
|
63
77
|
|
|
64
|
-
for action_entry in
|
|
65
|
-
self.connect_router.add_unary(action_entry,
|
|
66
|
-
for stream_entry in
|
|
67
|
-
self.connect_router.add_stream(stream_entry,
|
|
78
|
+
for action_entry in unified_bundle.actions:
|
|
79
|
+
self.connect_router.add_unary(action_entry, service_full_name)
|
|
80
|
+
for stream_entry in unified_bundle.streams:
|
|
81
|
+
self.connect_router.add_stream(stream_entry, service_full_name)
|
|
68
82
|
|
|
69
83
|
self.include_router(self.connect_router.router, prefix="/rpc")
|
|
70
84
|
|
|
71
85
|
if self.emit_proto:
|
|
72
|
-
|
|
86
|
+
proto_text = generate_proto(self.service_name, bundle=unified_bundle)
|
|
73
87
|
import os
|
|
74
88
|
|
|
75
89
|
os.makedirs(os.path.dirname(self.proto_path), exist_ok=True)
|
|
76
|
-
with open(self.proto_path, "w", encoding="utf-8") as
|
|
77
|
-
|
|
90
|
+
with open(self.proto_path, "w", encoding="utf-8") as f:
|
|
91
|
+
f.write(proto_text)
|
|
78
92
|
|
|
79
93
|
set_global_app(self)
|
|
80
94
|
|
communication/codegen.py
CHANGED
communication/connect_router.py
CHANGED
|
@@ -217,7 +217,7 @@ class ConnectRouter:
|
|
|
217
217
|
result = await _maybe_await(entry.func(validated_arg))
|
|
218
218
|
|
|
219
219
|
if hasattr(result, "model_dump"):
|
|
220
|
-
result = result.model_dump()
|
|
220
|
+
result = result.model_dump(by_alias=True)
|
|
221
221
|
return JSONResponse(result or {})
|
|
222
222
|
except Exception as exc:
|
|
223
223
|
return JSONResponse(error_envelope(exc))
|
|
@@ -283,7 +283,7 @@ class ConnectRouter:
|
|
|
283
283
|
|
|
284
284
|
def _serialize_stream_item(item: Any) -> Dict[str, Any]:
|
|
285
285
|
if hasattr(item, "model_dump"):
|
|
286
|
-
dumped = item.model_dump()
|
|
286
|
+
dumped = item.model_dump(by_alias=True)
|
|
287
287
|
if isinstance(dumped, dict):
|
|
288
288
|
return dumped
|
|
289
289
|
return {"value": dumped}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any, List, Optional, Set, Type, get_args, get_origin, Union
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
from .entries import RpcBundle
|
|
9
|
+
from .typing_utils import 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
|
+
|
|
43
|
+
def normalize_models_and_apply_aliases(self) -> None:
|
|
44
|
+
"""
|
|
45
|
+
Walk all RPCs in all bundles and apply camelCase JSON aliases
|
|
46
|
+
to every Pydantic model exactly once, including nested models.
|
|
47
|
+
|
|
48
|
+
After this runs, nothing else in the system should call apply_aliases_to_model().
|
|
49
|
+
"""
|
|
50
|
+
if self._models_normalized:
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
seen: Set[Type[BaseModel]] = set()
|
|
54
|
+
|
|
55
|
+
def _extract_nested_models(field_type: Any) -> List[Type[BaseModel]]:
|
|
56
|
+
"""Extract Pydantic models from a field type, handling Optional, List, etc."""
|
|
57
|
+
nested: List[Type[BaseModel]] = []
|
|
58
|
+
|
|
59
|
+
# Handle Optional[Type] -> Union[Type, None]
|
|
60
|
+
origin = get_origin(field_type)
|
|
61
|
+
if origin is Union:
|
|
62
|
+
args = get_args(field_type)
|
|
63
|
+
# Filter out None type
|
|
64
|
+
non_none_args = [arg for arg in args if arg is not type(None)]
|
|
65
|
+
if len(non_none_args) == 1:
|
|
66
|
+
field_type = non_none_args[0]
|
|
67
|
+
origin = get_origin(field_type)
|
|
68
|
+
|
|
69
|
+
# Handle List[Type]
|
|
70
|
+
if origin in (list, List):
|
|
71
|
+
args = get_args(field_type)
|
|
72
|
+
if args:
|
|
73
|
+
field_type = args[0]
|
|
74
|
+
origin = get_origin(field_type)
|
|
75
|
+
# Handle Optional inside List
|
|
76
|
+
if origin is Union:
|
|
77
|
+
args = get_args(field_type)
|
|
78
|
+
non_none_args = [arg for arg in args if arg is not type(None)]
|
|
79
|
+
if len(non_none_args) == 1:
|
|
80
|
+
field_type = non_none_args[0]
|
|
81
|
+
|
|
82
|
+
# Check if the final type is a Pydantic model
|
|
83
|
+
if is_pydantic_model(field_type):
|
|
84
|
+
nested.append(field_type)
|
|
85
|
+
|
|
86
|
+
return nested
|
|
87
|
+
|
|
88
|
+
def normalize_model(model: Optional[Type[BaseModel]]) -> None:
|
|
89
|
+
"""Recursively normalize a model and all its nested models."""
|
|
90
|
+
if model is None:
|
|
91
|
+
return
|
|
92
|
+
if not is_pydantic_model(model):
|
|
93
|
+
return
|
|
94
|
+
if model in seen:
|
|
95
|
+
return
|
|
96
|
+
seen.add(model)
|
|
97
|
+
apply_aliases(model)
|
|
98
|
+
|
|
99
|
+
# Recursively normalize nested models
|
|
100
|
+
if hasattr(model, "model_fields"):
|
|
101
|
+
for field_name, field_info in model.model_fields.items():
|
|
102
|
+
nested_models = _extract_nested_models(field_info.annotation)
|
|
103
|
+
for nested_model in nested_models:
|
|
104
|
+
normalize_model(nested_model)
|
|
105
|
+
|
|
106
|
+
for bundle in self._bundles:
|
|
107
|
+
for action in bundle.actions:
|
|
108
|
+
normalize_model(action.input_type)
|
|
109
|
+
normalize_model(action.output_type)
|
|
110
|
+
for stream in bundle.streams:
|
|
111
|
+
normalize_model(stream.payload_type)
|
|
112
|
+
|
|
113
|
+
self._models_normalized = True
|
|
114
|
+
|
|
115
|
+
# ------------- Unified, normalized view -------------
|
|
116
|
+
|
|
117
|
+
def get_unified_bundle(self) -> RpcBundle:
|
|
118
|
+
"""
|
|
119
|
+
Get the fully merged, normalized RPC bundle.
|
|
120
|
+
|
|
121
|
+
This will apply aliasing exactly once and then return a merged RpcBundle.
|
|
122
|
+
"""
|
|
123
|
+
self.normalize_models_and_apply_aliases()
|
|
124
|
+
return self.bundle
|
communication/typing_utils.py
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
import inspect
|
|
3
|
-
from typing import Any, Optional, Type, get_type_hints, cast
|
|
3
|
+
from typing import Any, Callable, Optional, Type, get_type_hints, cast
|
|
4
4
|
|
|
5
|
-
from pydantic import BaseModel
|
|
5
|
+
from pydantic import BaseModel, ConfigDict
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
class TypingError(Exception):
|
|
@@ -18,7 +18,7 @@ def _strip_self(params: list[inspect.Parameter]) -> list[inspect.Parameter]:
|
|
|
18
18
|
return params
|
|
19
19
|
|
|
20
20
|
|
|
21
|
-
def infer_input_type(function: Any) -> Optional[Type[Any]]:
|
|
21
|
+
def infer_input_type(function: Callable[..., Any]) -> Optional[Type[Any]]:
|
|
22
22
|
"""Infer the input type annotation from a function's first parameter.
|
|
23
23
|
|
|
24
24
|
Args:
|
|
@@ -50,7 +50,7 @@ def infer_input_type(function: Any) -> Optional[Type[Any]]:
|
|
|
50
50
|
return None
|
|
51
51
|
|
|
52
52
|
|
|
53
|
-
def infer_output_type(function: Any) -> Optional[Type[Any]]:
|
|
53
|
+
def infer_output_type(function: Callable[..., Any]) -> Optional[Type[Any]]:
|
|
54
54
|
"""Infer the return type annotation from a function.
|
|
55
55
|
|
|
56
56
|
Args:
|
|
@@ -88,3 +88,37 @@ def is_pydantic_model(type_annotation: Any) -> bool:
|
|
|
88
88
|
)
|
|
89
89
|
except Exception:
|
|
90
90
|
return False
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def camelize(name: str) -> str:
|
|
94
|
+
parts = name.split("_")
|
|
95
|
+
return parts[0] + "".join(p.capitalize() for p in parts[1:])
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def apply_aliases(model_cls: Type[BaseModel]) -> None:
|
|
99
|
+
fields = model_cls.model_fields
|
|
100
|
+
|
|
101
|
+
for name, field in fields.items():
|
|
102
|
+
alias = camelize(name)
|
|
103
|
+
|
|
104
|
+
field.alias = alias
|
|
105
|
+
field.validation_alias = alias
|
|
106
|
+
|
|
107
|
+
# Ensure Pydantic accepts both snake_case and camelCase
|
|
108
|
+
existing_config = getattr(model_cls, "model_config", None)
|
|
109
|
+
if existing_config is None:
|
|
110
|
+
existing_dict: dict[str, Any] = {}
|
|
111
|
+
else:
|
|
112
|
+
existing_dict = (
|
|
113
|
+
dict(existing_config) if isinstance(existing_config, dict) else {}
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
merged_config: dict[str, Any] = {
|
|
117
|
+
"populate_by_name": True,
|
|
118
|
+
"from_attributes": True,
|
|
119
|
+
**existing_dict,
|
|
120
|
+
}
|
|
121
|
+
model_cls.model_config = cast(ConfigDict, merged_config)
|
|
122
|
+
|
|
123
|
+
# Force rebuild
|
|
124
|
+
model_cls.model_rebuild(force=True)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
communication/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
communication/app.py,sha256=FPfnjIGMDClTysq6DvHoMudY_s2XnqmNlZCV93zYFgA,3419
|
|
3
|
+
communication/codegen.py,sha256=_1LYma-MXedwRHn_P3CqPs1xIMEZrZgiUAsGSj9yff4,7089
|
|
4
|
+
communication/connect_router.py,sha256=tjNl9dRukjLecZH1y-GYIQXH-0xzz9JESbikSH5XhFo,10527
|
|
5
|
+
communication/decorators.py,sha256=3pVlXUSX4KXSKlweskF0RfD8pST2zisTuFGJQHHIvcI,3419
|
|
6
|
+
communication/entries.py,sha256=vdZc8GAQztRWEiav6R2wM4l35GE-EiEdRH0ZJR4GShM,1065
|
|
7
|
+
communication/errors.py,sha256=hdJBB9jPJNWx8hbxIxwLBNKt2JVpmhZ1YF8q9VKk-dI,1773
|
|
8
|
+
communication/registry.py,sha256=acbhwU0z1iRHqzOahXG47GfJS5VQHjf69UiruoXrr7g,4517
|
|
9
|
+
communication/typing_utils.py,sha256=VHyRyclAcADKXETCklgHUM-k0mehnW8p5v_FmIMtRvk,3393
|
|
10
|
+
vention_communication-0.2.2.dist-info/METADATA,sha256=t0TQRQOIApZjxQ7GTz_k1tPeC_ctuC6KRH0QTfzXnLs,11255
|
|
11
|
+
vention_communication-0.2.2.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
|
|
12
|
+
vention_communication-0.2.2.dist-info/RECORD,,
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
communication/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
-
communication/app.py,sha256=9syaxfb8HJa3x_WBWa3ZRKClBFbaOZkSL_D6L0ZwIfI,3030
|
|
3
|
-
communication/codegen.py,sha256=SwCIYptjmCbnFGPl0zZ0Ahj4UP27xpy7ELftQurMANM,7089
|
|
4
|
-
communication/connect_router.py,sha256=uwFmMKdvVUC0dwepXCTvWcERQV2QlGD1xdTkIHCYl5k,10501
|
|
5
|
-
communication/decorators.py,sha256=3pVlXUSX4KXSKlweskF0RfD8pST2zisTuFGJQHHIvcI,3419
|
|
6
|
-
communication/entries.py,sha256=vdZc8GAQztRWEiav6R2wM4l35GE-EiEdRH0ZJR4GShM,1065
|
|
7
|
-
communication/errors.py,sha256=hdJBB9jPJNWx8hbxIxwLBNKt2JVpmhZ1YF8q9VKk-dI,1773
|
|
8
|
-
communication/typing_utils.py,sha256=M_q_2fKjEG8t2de5nzmHH55CmsGtTNYbKI_AlKkFUN8,2399
|
|
9
|
-
vention_communication-0.2.1.dist-info/METADATA,sha256=380w8HHpQ5GYu23fJcAob7RIRGNOWPNEJt_9kGSCQ1M,11255
|
|
10
|
-
vention_communication-0.2.1.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
|
|
11
|
-
vention_communication-0.2.1.dist-info/RECORD,,
|
|
File without changes
|