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.
- communication/__init__.py +0 -0
- communication/app.py +88 -0
- communication/codegen.py +392 -0
- communication/connect_router.py +325 -0
- communication/decorators.py +110 -0
- communication/entries.py +42 -0
- communication/errors.py +59 -0
- communication/typing_utils.py +90 -0
- vention_communication-0.1.0.dist-info/METADATA +302 -0
- vention_communication-0.1.0.dist-info/RECORD +11 -0
- vention_communication-0.1.0.dist-info/WHEEL +4 -0
|
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)
|
communication/codegen.py
ADDED
|
@@ -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)
|