vention-communication 0.2.0__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 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
- 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
@@ -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)
@@ -1,14 +1,14 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vention-communication
3
- Version: 0.2.0
4
- Summary: A framework for storing and managing component and application data for machine apps.
3
+ Version: 0.2.2
4
+ Summary: A framework for communication between machine apps and other services.
5
5
  License: Proprietary
6
6
  Author: VentionCo
7
7
  Requires-Python: >=3.10,<3.11
8
8
  Classifier: License :: Other/Proprietary License
9
9
  Classifier: Programming Language :: Python :: 3
10
10
  Classifier: Programming Language :: Python :: 3.10
11
- Requires-Dist: fastapi (>=0.116.1,<0.117.0)
11
+ Requires-Dist: fastapi (==0.121.1)
12
12
  Requires-Dist: uvicorn (>=0.35.0,<0.36.0)
13
13
  Description-Content-Type: text/markdown
14
14
 
@@ -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.0.dist-info/METADATA,sha256=v-bjhhD1ScpMVSk2KyuGJcR11DdWIddHTIOqeA7EmVM,11279
10
- vention_communication-0.2.0.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
11
- vention_communication-0.2.0.dist-info/RECORD,,