vention-communication 0.2.1__py3-none-any.whl → 0.3.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.
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 .rpc_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
- self.connect_router = ConnectRouter()
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
- 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
+ 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
- bundle = collect_bundle()
68
+ base_bundle = collect_bundle()
69
+ self._registry.add_bundle(base_bundle)
59
70
  for extra_bundle in self._extra_bundles:
60
- bundle.extend(extra_bundle)
71
+ self._registry.add_bundle(extra_bundle)
61
72
 
62
- service_fully_qualified_name = f"vention.app.v1.{self.service_name}Service"
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 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)
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
- proto = generate_proto(self.service_name, bundle=bundle)
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 proto_file:
77
- proto_file.write(proto)
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
@@ -6,7 +6,7 @@ from .typing_utils import is_pydantic_model
6
6
  from .entries import RpcBundle, StreamEntry
7
7
 
8
8
  _SCALAR_MAP = {
9
- int: "int64",
9
+ int: "int32",
10
10
  float: "double",
11
11
  str: "string",
12
12
  bool: "bool",
@@ -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
@@ -0,0 +1,90 @@
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(
43
+ self, model: Optional[Type[BaseModel]], seen: Set[Type[BaseModel]]
44
+ ) -> None:
45
+ """Recursively normalize a model and all its nested models."""
46
+ if model is None:
47
+ return
48
+ if not is_pydantic_model(model):
49
+ return
50
+ if model in seen:
51
+ return
52
+
53
+ seen.add(model)
54
+ apply_aliases(model)
55
+
56
+ if hasattr(model, "model_fields"):
57
+ for _, field_info in model.model_fields.items():
58
+ nested_models = extract_nested_models(field_info.annotation)
59
+ for nested_model in nested_models:
60
+ self._normalize_model(nested_model, seen)
61
+
62
+ def normalize_models_and_apply_aliases(self) -> None:
63
+ """
64
+ Walk all RPCs in all bundles and apply camelCase JSON aliases
65
+ to every Pydantic model exactly once, including nested models.
66
+ """
67
+ if self._models_normalized:
68
+ return
69
+
70
+ seen: Set[Type[BaseModel]] = set()
71
+
72
+ for bundle in self._bundles:
73
+ for action in bundle.actions:
74
+ self._normalize_model(action.input_type, seen)
75
+ self._normalize_model(action.output_type, seen)
76
+ for stream in bundle.streams:
77
+ self._normalize_model(stream.payload_type, seen)
78
+
79
+ self._models_normalized = True
80
+
81
+ # ------------- Unified, normalized view -------------
82
+
83
+ def get_unified_bundle(self) -> RpcBundle:
84
+ """
85
+ Get the fully merged, normalized RPC bundle.
86
+
87
+ This will apply aliasing exactly once and then return a merged RpcBundle.
88
+ """
89
+ self.normalize_models_and_apply_aliases()
90
+ return self.bundle
@@ -1,8 +1,19 @@
1
1
  from __future__ import annotations
2
2
  import inspect
3
- from typing import Any, Optional, Type, get_type_hints, cast
4
-
5
- from pydantic import BaseModel
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
+ )
15
+
16
+ from pydantic import BaseModel, ConfigDict
6
17
 
7
18
 
8
19
  class TypingError(Exception):
@@ -18,7 +29,7 @@ def _strip_self(params: list[inspect.Parameter]) -> list[inspect.Parameter]:
18
29
  return params
19
30
 
20
31
 
21
- def infer_input_type(function: Any) -> Optional[Type[Any]]:
32
+ def infer_input_type(function: Callable[..., Any]) -> Optional[Type[Any]]:
22
33
  """Infer the input type annotation from a function's first parameter.
23
34
 
24
35
  Args:
@@ -50,7 +61,7 @@ def infer_input_type(function: Any) -> Optional[Type[Any]]:
50
61
  return None
51
62
 
52
63
 
53
- def infer_output_type(function: Any) -> Optional[Type[Any]]:
64
+ def infer_output_type(function: Callable[..., Any]) -> Optional[Type[Any]]:
54
65
  """Infer the return type annotation from a function.
55
66
 
56
67
  Args:
@@ -88,3 +99,74 @@ def is_pydantic_model(type_annotation: Any) -> bool:
88
99
  )
89
100
  except Exception:
90
101
  return False
102
+
103
+
104
+ def camelize(name: str) -> str:
105
+ parts = name.split("_")
106
+ return parts[0] + "".join(p.capitalize() for p in parts[1:])
107
+
108
+
109
+ def apply_aliases(model_cls: Type[BaseModel]) -> None:
110
+ fields = model_cls.model_fields
111
+
112
+ for name, field in fields.items():
113
+ alias = camelize(name)
114
+
115
+ field.alias = alias
116
+ field.validation_alias = alias
117
+
118
+ # Ensure Pydantic accepts both snake_case and camelCase
119
+ existing_config = getattr(model_cls, "model_config", None)
120
+ if existing_config is None:
121
+ existing_dict: dict[str, Any] = {}
122
+ else:
123
+ existing_dict = (
124
+ dict(existing_config) if isinstance(existing_config, dict) else {}
125
+ )
126
+
127
+ merged_config: dict[str, Any] = {
128
+ "populate_by_name": True,
129
+ "from_attributes": True,
130
+ **existing_dict,
131
+ }
132
+ model_cls.model_config = cast(ConfigDict, merged_config)
133
+
134
+ # Force rebuild
135
+ model_cls.model_rebuild(force=True)
136
+
137
+
138
+ def unwrap_optional(field_type: Any) -> Any:
139
+ """Unwrap Optional[Type] or Union[Type, None] to get the non-None type."""
140
+ origin = get_origin(field_type)
141
+ if origin is not Union:
142
+ return field_type
143
+
144
+ args = get_args(field_type)
145
+ non_none_args = [arg for arg in args if arg is not type(None)]
146
+ if len(non_none_args) == 1:
147
+ return non_none_args[0]
148
+ return field_type
149
+
150
+
151
+ def unwrap_list(field_type: Any) -> Any:
152
+ """Unwrap List[Type] to get the inner type, handling Optional inside List."""
153
+ origin = get_origin(field_type)
154
+ if origin not in (list, List):
155
+ return field_type
156
+
157
+ args = get_args(field_type)
158
+ if not args:
159
+ return field_type
160
+
161
+ inner_type = args[0]
162
+ return unwrap_optional(inner_type)
163
+
164
+
165
+ def extract_nested_models(field_type: Any) -> List[Type[BaseModel]]:
166
+ """Extract Pydantic models from a field type, handling Optional, List, etc."""
167
+ field_type = unwrap_optional(field_type)
168
+ field_type = unwrap_list(field_type)
169
+
170
+ if is_pydantic_model(field_type):
171
+ return [field_type]
172
+ return []
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vention-communication
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: A framework for communication between machine apps and other services.
5
5
  License: Proprietary
6
6
  Author: VentionCo
@@ -0,0 +1,13 @@
1
+ communication/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ communication/app.py,sha256=dxbm7oG8-uQSu_dY4S8exHsBXND1N1nC2GdsMthqZE0,3423
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/rpc_registry.py,sha256=90r4YYHKveVGBO-pOD91qAs9GSB3651WPclC6oRnrZo,2986
10
+ communication/typing_utils.py,sha256=6S6LvtjFBZKo-gWPc8fbh4F1rlPWFgrH_mX8esZbzWM,4559
11
+ vention_communication-0.3.0.dist-info/METADATA,sha256=e6h67l5DGs9LSAL_eBan_spH60FDQfBVcmeyoNWrlvg,11255
12
+ vention_communication-0.3.0.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
13
+ vention_communication-0.3.0.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,,