vention-communication 0.2.1__tar.gz → 0.3.0__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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "vention-communication"
3
- version = "0.2.1"
3
+ version = "0.3.0"
4
4
  description = "A framework for communication between machine apps and other services."
5
5
  authors = [ "VentionCo" ]
6
6
  readme = "README.md"
@@ -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
 
@@ -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
@@ -0,0 +1,172 @@
1
+ from __future__ import annotations
2
+ import inspect
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
17
+
18
+
19
+ class TypingError(Exception):
20
+ """Raised when type inference fails."""
21
+
22
+
23
+ def _strip_self(params: list[inspect.Parameter]) -> list[inspect.Parameter]:
24
+ if not params:
25
+ return params
26
+ first = params[0]
27
+ if first.name in ("self", "cls"):
28
+ return params[1:]
29
+ return params
30
+
31
+
32
+ def infer_input_type(function: Callable[..., Any]) -> Optional[Type[Any]]:
33
+ """Infer the input type annotation from a function's first parameter.
34
+
35
+ Args:
36
+ function: Function to inspect
37
+
38
+ Returns:
39
+ Type annotation of the first parameter, or None if no parameters or type is Any
40
+
41
+ Raises:
42
+ TypingError: If the first parameter lacks a type annotation
43
+ """
44
+ signature = inspect.signature(function)
45
+ parameters = _strip_self(list(signature.parameters.values()))
46
+ if not parameters:
47
+ return None
48
+
49
+ first_param = parameters[0]
50
+ type_hints = get_type_hints(function)
51
+ if first_param.name not in type_hints:
52
+ raise TypingError(f"First parameter '{first_param.name}' must be annotated")
53
+
54
+ hint_type = type_hints[first_param.name]
55
+ if hint_type is Any:
56
+ return None
57
+
58
+ if isinstance(hint_type, type):
59
+ return cast(Type[Any], hint_type)
60
+
61
+ return None
62
+
63
+
64
+ def infer_output_type(function: Callable[..., Any]) -> Optional[Type[Any]]:
65
+ """Infer the return type annotation from a function.
66
+
67
+ Args:
68
+ function: Function to inspect
69
+
70
+ Returns:
71
+ Return type annotation, or None if not annotated or type is Any
72
+ """
73
+ type_hints = get_type_hints(function)
74
+ return_type = type_hints.get("return")
75
+ if return_type is None or return_type is Any:
76
+ return None
77
+
78
+ if isinstance(return_type, type) and return_type in (type(None),):
79
+ return None
80
+
81
+ if isinstance(return_type, type):
82
+ return cast(Type[Any], return_type)
83
+
84
+ return None
85
+
86
+
87
+ def is_pydantic_model(type_annotation: Any) -> bool:
88
+ """Check if a type annotation is a Pydantic BaseModel.
89
+
90
+ Args:
91
+ type_annotation: Type to check
92
+
93
+ Returns:
94
+ True if the type is a Pydantic BaseModel subclass, False otherwise
95
+ """
96
+ try:
97
+ return isinstance(type_annotation, type) and issubclass(
98
+ type_annotation, BaseModel
99
+ )
100
+ except Exception:
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,90 +0,0 @@
1
- from __future__ import annotations
2
- import inspect
3
- from typing import Any, Optional, Type, get_type_hints, cast
4
-
5
- from pydantic import BaseModel
6
-
7
-
8
- class TypingError(Exception):
9
- """Raised when type inference fails."""
10
-
11
-
12
- def _strip_self(params: list[inspect.Parameter]) -> list[inspect.Parameter]:
13
- if not params:
14
- return params
15
- first = params[0]
16
- if first.name in ("self", "cls"):
17
- return params[1:]
18
- return params
19
-
20
-
21
- def infer_input_type(function: Any) -> Optional[Type[Any]]:
22
- """Infer the input type annotation from a function's first parameter.
23
-
24
- Args:
25
- function: Function to inspect
26
-
27
- Returns:
28
- Type annotation of the first parameter, or None if no parameters or type is Any
29
-
30
- Raises:
31
- TypingError: If the first parameter lacks a type annotation
32
- """
33
- signature = inspect.signature(function)
34
- parameters = _strip_self(list(signature.parameters.values()))
35
- if not parameters:
36
- return None
37
-
38
- first_param = parameters[0]
39
- type_hints = get_type_hints(function)
40
- if first_param.name not in type_hints:
41
- raise TypingError(f"First parameter '{first_param.name}' must be annotated")
42
-
43
- hint_type = type_hints[first_param.name]
44
- if hint_type is Any:
45
- return None
46
-
47
- if isinstance(hint_type, type):
48
- return cast(Type[Any], hint_type)
49
-
50
- return None
51
-
52
-
53
- def infer_output_type(function: Any) -> Optional[Type[Any]]:
54
- """Infer the return type annotation from a function.
55
-
56
- Args:
57
- function: Function to inspect
58
-
59
- Returns:
60
- Return type annotation, or None if not annotated or type is Any
61
- """
62
- type_hints = get_type_hints(function)
63
- return_type = type_hints.get("return")
64
- if return_type is None or return_type is Any:
65
- return None
66
-
67
- if isinstance(return_type, type) and return_type in (type(None),):
68
- return None
69
-
70
- if isinstance(return_type, type):
71
- return cast(Type[Any], return_type)
72
-
73
- return None
74
-
75
-
76
- def is_pydantic_model(type_annotation: Any) -> bool:
77
- """Check if a type annotation is a Pydantic BaseModel.
78
-
79
- Args:
80
- type_annotation: Type to check
81
-
82
- Returns:
83
- True if the type is a Pydantic BaseModel subclass, False otherwise
84
- """
85
- try:
86
- return isinstance(type_annotation, type) and issubclass(
87
- type_annotation, BaseModel
88
- )
89
- except Exception:
90
- return False