structurize 2.16.5__py3-none-any.whl → 2.17.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.
@@ -0,0 +1,764 @@
1
+ # pylint: disable=line-too-long
2
+
3
+ """ StructureToProto class for converting JSON Structure schema to Protocol Buffers (.proto files) """
4
+
5
+ import json
6
+ import os
7
+ import argparse
8
+ from typing import Literal, NamedTuple, Dict, Any, List, Optional
9
+
10
+ indent = ' '
11
+
12
+ Comment = NamedTuple('Comment', [('content', str), ('tags', Dict[str, Any])])
13
+ Oneof = NamedTuple('Oneof', [('comment', 'Comment'), ('name', str), ('fields', List['Field'])])
14
+ Field = NamedTuple('Field', [('comment', 'Comment'), ('label', str), ('type', str), ('key_type', str), ('val_type', str), ('name', str), ('number', int), ('dependencies', List[str])])
15
+ Enum = NamedTuple('Enum', [('comment', 'Comment'), ('name', str), ('fields', Dict[str, 'Field'])])
16
+ Message = NamedTuple('Message', [('comment', 'Comment'), ('name', str), ('fields', List['Field']), ('oneofs', List['Oneof']),
17
+ ('messages', Dict[str, 'Message']), ('enums', Dict[str, 'Enum']), ('dependencies', List[str])])
18
+ Service = NamedTuple('Service', [('name', str), ('functions', Dict[str, 'RpcFunc'])])
19
+ RpcFunc = NamedTuple('RpcFunc', [('name', str), ('in_type', str), ('out_type', str), ('uri', str)])
20
+ ProtoFile = NamedTuple('ProtoFile',
21
+ [('messages', Dict[str, 'Message']), ('enums', Dict[str, 'Enum']),
22
+ ('services', Dict[str, 'Service']), ('imports', List[str]),
23
+ ('options', Dict[str, str]), ('package', str)])
24
+ ProtoFiles = NamedTuple('ProtoFiles', [('files', List['ProtoFile'])])
25
+
26
+ class StructureToProto:
27
+
28
+ def __init__(self) -> None:
29
+ self.naming_mode: Literal['snake', 'pascal', 'camel'] = 'pascal'
30
+ self.allow_optional: bool = False
31
+ self.default_namespace: str = ''
32
+ self.schema_registry: Dict[str, Dict] = {}
33
+ self.definitions: Dict[str, Any] = {}
34
+
35
+ def structure_primitive_to_proto_type(self, structure_type: str, dependencies: List[str]) -> str:
36
+ """Map JSON Structure primitive types to Protobuf types."""
37
+ mapping = {
38
+ 'null': 'google.protobuf.Empty',
39
+ 'boolean': 'bool',
40
+ 'string': 'string',
41
+ 'integer': 'int32',
42
+ 'number': 'double',
43
+ 'int8': 'int32', # Proto doesn't have int8, use int32
44
+ 'uint8': 'uint32',
45
+ 'int16': 'int32', # Proto doesn't have int16, use int32
46
+ 'uint16': 'uint32',
47
+ 'int32': 'int32',
48
+ 'uint32': 'uint32',
49
+ 'int64': 'int64',
50
+ 'uint64': 'uint64',
51
+ 'int128': 'string', # Proto doesn't have int128, use string
52
+ 'uint128': 'string',
53
+ 'float8': 'float',
54
+ 'float': 'float',
55
+ 'float32': 'float',
56
+ 'float64': 'double',
57
+ 'double': 'double',
58
+ 'binary32': 'float',
59
+ 'binary64': 'double',
60
+ 'decimal': 'string', # Proto doesn't have native decimal, use string
61
+ 'binary': 'bytes',
62
+ 'bytes': 'bytes',
63
+ 'date': 'string', # Or use google.type.Date
64
+ 'time': 'string', # Or use google.type.TimeOfDay
65
+ 'datetime': 'string', # Or use google.protobuf.Timestamp
66
+ 'timestamp': 'string',
67
+ 'duration': 'string', # Or use google.protobuf.Duration
68
+ 'uuid': 'string',
69
+ 'uri': 'string',
70
+ 'jsonpointer': 'string',
71
+ 'any': 'google.protobuf.Any'
72
+ }
73
+
74
+ type_result = mapping.get(structure_type, '')
75
+ if not type_result:
76
+ dependencies.append(structure_type)
77
+ type_result = structure_type
78
+ return type_result
79
+
80
+ def compose_name(self, prefix: str, name: str, naming_mode: Literal['pascal', 'camel', 'snake', 'default', 'field'] = 'default') -> str:
81
+ if naming_mode == 'default':
82
+ naming_mode = self.naming_mode
83
+ if naming_mode == 'field':
84
+ if self.naming_mode == 'pascal':
85
+ naming_mode = 'camel'
86
+ else:
87
+ naming_mode = self.naming_mode
88
+ if naming_mode == 'snake':
89
+ return f"{prefix}_{name}"
90
+ if naming_mode == 'pascal':
91
+ return f"{prefix[0].upper()+prefix[1:] if prefix else ''}{name[0].upper()+name[1:] if name else ''}"
92
+ if naming_mode == 'camel':
93
+ return f"{prefix[0].lower()+prefix[1:] if prefix else ''}{name[0].upper()+name[1:] if name else ''}"
94
+ return prefix+name
95
+
96
+ def resolve_ref(self, ref: str, context_schema: Optional[Dict] = None) -> Optional[Dict]:
97
+ """ Resolves a $ref to the actual schema definition """
98
+ # Check if it's an absolute URI reference (schema with $id)
99
+ if not ref.startswith('#/'):
100
+ # Try to resolve from schema registry
101
+ if ref in self.schema_registry:
102
+ return self.schema_registry[ref]
103
+ return None
104
+
105
+ # Handle fragment-only references (internal to document)
106
+ path = ref[2:].split('/')
107
+ schema = context_schema if context_schema else None
108
+
109
+ if schema is None:
110
+ return None
111
+
112
+ for part in path:
113
+ if not isinstance(schema, dict) or part not in schema:
114
+ return None
115
+ schema = schema[part]
116
+
117
+ return schema
118
+
119
+ def register_schema_ids(self, schema: Dict, base_uri: str = '') -> None:
120
+ """ Recursively registers schemas with $id keywords """
121
+ if not isinstance(schema, dict):
122
+ return
123
+
124
+ # Register this schema if it has an $id
125
+ if '$id' in schema:
126
+ schema_id = schema['$id']
127
+ # Handle relative URIs
128
+ if base_uri and not schema_id.startswith(('http://', 'https://', 'urn:')):
129
+ from urllib.parse import urljoin
130
+ schema_id = urljoin(base_uri, schema_id)
131
+ self.schema_registry[schema_id] = schema
132
+ base_uri = schema_id # Update base URI for nested schemas
133
+
134
+ # Recursively process definitions
135
+ if 'definitions' in schema:
136
+ for def_name, def_schema in schema['definitions'].items():
137
+ if isinstance(def_schema, dict):
138
+ self.register_schema_ids(def_schema, base_uri)
139
+
140
+ # Recursively process properties
141
+ if 'properties' in schema:
142
+ for prop_name, prop_schema in schema['properties'].items():
143
+ if isinstance(prop_schema, dict):
144
+ self.register_schema_ids(prop_schema, base_uri)
145
+
146
+ # Recursively process items, values, etc.
147
+ for key in ['items', 'values', 'additionalProperties']:
148
+ if key in schema and isinstance(schema[key], dict):
149
+ self.register_schema_ids(schema[key], base_uri)
150
+
151
+ def build_enhanced_comment(self, field_schema: Dict, base_comment: str = '') -> Comment:
152
+ """Build an enhanced comment with type annotations and constraints."""
153
+ annotations = []
154
+ tags = {}
155
+
156
+ # Add base description
157
+ if base_comment:
158
+ annotations.append(base_comment)
159
+
160
+ # Add const value
161
+ if 'const' in field_schema:
162
+ const_val = field_schema['const']
163
+ annotations.append(f"Constant value: {const_val}")
164
+ tags['const'] = const_val
165
+
166
+ # Add access modifiers
167
+ if field_schema.get('readOnly'):
168
+ annotations.append("Read-only")
169
+ tags['readOnly'] = True
170
+ if field_schema.get('writeOnly'):
171
+ annotations.append("Write-only")
172
+ tags['writeOnly'] = True
173
+
174
+ # Add string constraints
175
+ if 'maxLength' in field_schema:
176
+ annotations.append(f"Max length: {field_schema['maxLength']}")
177
+ tags['maxLength'] = field_schema['maxLength']
178
+ if 'minLength' in field_schema:
179
+ annotations.append(f"Min length: {field_schema['minLength']}")
180
+ tags['minLength'] = field_schema['minLength']
181
+ if 'pattern' in field_schema:
182
+ annotations.append(f"Pattern: {field_schema['pattern']}")
183
+ tags['pattern'] = field_schema['pattern']
184
+
185
+ # Add numeric constraints
186
+ if 'minimum' in field_schema:
187
+ annotations.append(f"Minimum: {field_schema['minimum']}")
188
+ tags['minimum'] = field_schema['minimum']
189
+ if 'maximum' in field_schema:
190
+ annotations.append(f"Maximum: {field_schema['maximum']}")
191
+ tags['maximum'] = field_schema['maximum']
192
+ if 'exclusiveMinimum' in field_schema:
193
+ annotations.append(f"Exclusive minimum: {field_schema['exclusiveMinimum']}")
194
+ tags['exclusiveMinimum'] = field_schema['exclusiveMinimum']
195
+ if 'exclusiveMaximum' in field_schema:
196
+ annotations.append(f"Exclusive maximum: {field_schema['exclusiveMaximum']}")
197
+ tags['exclusiveMaximum'] = field_schema['exclusiveMaximum']
198
+
199
+ # Add decimal precision/scale
200
+ if 'precision' in field_schema:
201
+ annotations.append(f"Precision: {field_schema['precision']}")
202
+ tags['precision'] = field_schema['precision']
203
+ if 'scale' in field_schema:
204
+ annotations.append(f"Scale: {field_schema['scale']}")
205
+ tags['scale'] = field_schema['scale']
206
+
207
+ # Add format and encoding
208
+ if 'format' in field_schema:
209
+ annotations.append(f"Format: {field_schema['format']}")
210
+ tags['format'] = field_schema['format']
211
+ if 'contentEncoding' in field_schema:
212
+ annotations.append(f"Encoding: {field_schema['contentEncoding']}")
213
+ tags['contentEncoding'] = field_schema['contentEncoding']
214
+ if 'contentMediaType' in field_schema:
215
+ annotations.append(f"Media type: {field_schema['contentMediaType']}")
216
+ tags['contentMediaType'] = field_schema['contentMediaType']
217
+
218
+ # Add array constraints
219
+ if 'minItems' in field_schema:
220
+ annotations.append(f"Min items: {field_schema['minItems']}")
221
+ tags['minItems'] = field_schema['minItems']
222
+ if 'maxItems' in field_schema:
223
+ annotations.append(f"Max items: {field_schema['maxItems']}")
224
+ tags['maxItems'] = field_schema['maxItems']
225
+ if 'uniqueItems' in field_schema:
226
+ annotations.append("Unique items required")
227
+ tags['uniqueItems'] = field_schema['uniqueItems']
228
+
229
+ # Add deprecated marker
230
+ if field_schema.get('deprecated'):
231
+ annotations.append("DEPRECATED")
232
+ tags['deprecated'] = True
233
+ if 'deprecationMessage' in field_schema:
234
+ annotations.append(f"Deprecation: {field_schema['deprecationMessage']}")
235
+
236
+ # Add abstract marker
237
+ if field_schema.get('abstract'):
238
+ annotations.append("Abstract type - cannot be instantiated directly")
239
+ tags['abstract'] = True
240
+
241
+ comment_text = ' | '.join(annotations) if annotations else ''
242
+ return Comment(comment_text, tags)
243
+
244
+ def resolve_extends(self, structure_schema: Dict, context_schema: Dict) -> Dict:
245
+ """Resolve $extends inheritance by flattening parent properties."""
246
+ if '$extends' not in structure_schema:
247
+ return structure_schema
248
+
249
+ # Resolve the parent schema
250
+ parent_ref = structure_schema['$extends']
251
+ parent_schema = self.resolve_ref(parent_ref, context_schema)
252
+
253
+ if not parent_schema:
254
+ return structure_schema
255
+
256
+ # Recursively resolve parent's extends
257
+ parent_schema = self.resolve_extends(parent_schema, context_schema)
258
+
259
+ # Create a new schema with flattened properties
260
+ flattened = structure_schema.copy()
261
+
262
+ # Merge parent properties first (so child can override)
263
+ parent_props = parent_schema.get('properties', {})
264
+ current_props = flattened.get('properties', {})
265
+ merged_props = parent_props.copy()
266
+ merged_props.update(current_props)
267
+ flattened['properties'] = merged_props
268
+
269
+ # Merge required fields
270
+ parent_required = parent_schema.get('required', [])
271
+ current_required = flattened.get('required', [])
272
+ merged_required = list(set(parent_required + current_required))
273
+ if merged_required:
274
+ flattened['required'] = merged_required
275
+
276
+ # Remove $extends from flattened schema
277
+ if '$extends' in flattened:
278
+ del flattened['$extends']
279
+
280
+ return flattened
281
+
282
+ def resolve_offers_uses(self, structure_schema: Dict, context_schema: Dict) -> Dict:
283
+ """Resolve $offers/$uses add-ins by flattening mixin properties."""
284
+ if '$uses' not in structure_schema:
285
+ return structure_schema
286
+
287
+ uses = structure_schema['$uses']
288
+ if not isinstance(uses, list):
289
+ uses = [uses]
290
+
291
+ # Create a new schema with flattened properties
292
+ flattened = structure_schema.copy()
293
+ current_props = flattened.get('properties', {})
294
+
295
+ # Merge properties from each add-in
296
+ for use_ref in uses:
297
+ use_schema = self.resolve_ref(use_ref, context_schema)
298
+ if use_schema and '$offers' in use_schema:
299
+ offer_props = use_schema['$offers'].get('properties', {})
300
+ # Add offered properties (current props take precedence)
301
+ for prop_name, prop_schema in offer_props.items():
302
+ if prop_name not in current_props:
303
+ current_props[prop_name] = prop_schema
304
+
305
+ flattened['properties'] = current_props
306
+
307
+ # Remove $uses from flattened schema
308
+ if '$uses' in flattened:
309
+ del flattened['$uses']
310
+
311
+ return flattened
312
+
313
+ def convert_field(self, message: Message, structure_field: dict, index: int, proto_files: ProtoFiles, context_schema: Dict) -> Field | Oneof | Enum | Message:
314
+ """Convert a JSON Structure property to a Protobuf field."""
315
+ field_name = structure_field.get('name', f'field{index}')
316
+
317
+ # Build enhanced comment with annotations
318
+ base_desc = structure_field.get('description', structure_field.get('doc', ''))
319
+ comment = self.build_enhanced_comment(structure_field, base_desc)
320
+
321
+ return self.convert_field_type(message, field_name, structure_field, comment, index, proto_files, context_schema)
322
+
323
+ def convert_record_type(self, structure_record: dict, comment: Comment, proto_files: ProtoFiles, context_schema: Dict) -> Message:
324
+ """Convert a JSON Structure object to a Protobuf message."""
325
+ # Resolve $extends inheritance
326
+ structure_record = self.resolve_extends(structure_record, context_schema)
327
+
328
+ # Resolve $offers/$uses add-ins
329
+ structure_record = self.resolve_offers_uses(structure_record, context_schema)
330
+
331
+ # Build enhanced comment with abstract/deprecated markers
332
+ message_comment = self.build_enhanced_comment(structure_record, comment.content)
333
+
334
+ local_message = Message(message_comment, structure_record.get('name', 'UnnamedMessage'), [], [], {}, {}, [])
335
+ properties = structure_record.get('properties', {})
336
+ required_props = structure_record.get('required', [])
337
+
338
+ offs = 1
339
+ for i, (prop_name, prop_schema) in enumerate(properties.items()):
340
+ field_dict = {'name': prop_name}
341
+ if isinstance(prop_schema, dict):
342
+ field_dict.update(prop_schema)
343
+ else:
344
+ field_dict['type'] = prop_schema
345
+
346
+ field = self.convert_field(local_message, field_dict, i+offs, proto_files, context_schema)
347
+ if isinstance(field, Oneof):
348
+ for f in field.fields:
349
+ local_message.dependencies.extend(f.dependencies)
350
+ local_message.oneofs.append(field)
351
+ offs += len(field.fields)-1
352
+ elif isinstance(field, Enum):
353
+ enum = Enum(field.comment, self.compose_name(field.name, 'enum'), field.fields)
354
+ local_message.enums[enum.name] = enum
355
+ local_message.fields.append(Field(field.comment, '', enum.name, '', '', prop_name, i+offs, []))
356
+ elif isinstance(field, Message):
357
+ inner_message = Message(field.comment, self.compose_name(prop_name, 'type'), field.fields, field.oneofs, field.messages, field.enums, [])
358
+ local_message.messages[inner_message.name] = inner_message
359
+ local_message.fields.append(Field(field.comment, '', inner_message.name, '', '', prop_name, i+offs, []))
360
+ local_message.dependencies.extend(field.dependencies)
361
+ else:
362
+ local_message.dependencies.extend(field.dependencies)
363
+ local_message.fields.append(field)
364
+ return local_message
365
+
366
+ def convert_field_type(self, message: Message, field_name: str, field_type_schema: Dict | str | list, comment: Comment, index: int, proto_files: ProtoFiles, context_schema: Dict) -> Field | Oneof | Enum | Message:
367
+ """Convert a JSON Structure field type to a Protobuf field type."""
368
+ label = ''
369
+
370
+ # Handle list of types (union)
371
+ if isinstance(field_type_schema, list):
372
+ # Handling union types (including nullable fields)
373
+ non_null_types = [t for t in field_type_schema if t != 'null']
374
+ if len(non_null_types) == 1:
375
+ if self.allow_optional:
376
+ label = 'optional'
377
+ field_type_schema = non_null_types[0]
378
+ elif len(non_null_types) > 0:
379
+ oneof_fields = []
380
+ for i, t in enumerate(non_null_types):
381
+ field = self.convert_field_type(message, self.compose_name(field_name, 'choice', 'field'), t, comment, i+index, proto_files, context_schema)
382
+ if isinstance(field, Field):
383
+ if field.type == 'map' or field.type == 'array':
384
+ local_message = Message(comment, self.compose_name(field.name, field.type), [], [], {}, {}, field.dependencies)
385
+ local_message.fields.append(field)
386
+ new_field = Field(field.comment, '', local_message.name, '', '', self.compose_name(field.name, field.type, 'field'), i+index, field.dependencies)
387
+ message.messages[local_message.name] = local_message
388
+ oneof_fields.append(new_field)
389
+ else:
390
+ field = Field(field.comment, field.label, field.type, field.key_type, field.val_type, self.compose_name(field_name, (field.type.split('.')[-1]), 'field'), i+index, field.dependencies)
391
+ oneof_fields.append(field)
392
+ elif isinstance(field, Enum):
393
+ enum = Enum(field.comment, self.compose_name(field.name, "options"), field.fields)
394
+ message.enums[enum.name] = enum
395
+ field = Field(field.comment, '', enum.name, '', '', field.name, i+index, [])
396
+ oneof_fields.append(field)
397
+ elif isinstance(field, Message):
398
+ local_message = Message(field.comment, self.compose_name(field.name, 'type'), field.fields, field.oneofs, field.messages, field.enums, field.dependencies)
399
+ message.messages[local_message.name] = local_message
400
+ field = Field(field.comment, '', local_message.name, '', '', field.name, i+index, field.dependencies)
401
+ oneof_fields.append(field)
402
+ oneof = Oneof(comment, field_name, oneof_fields)
403
+ return oneof
404
+ else:
405
+ raise ValueError(f"Field {field_name} is a union type without any non-null types")
406
+
407
+ # Handle dict types (complex structures)
408
+ if isinstance(field_type_schema, dict):
409
+ # Handle $ref
410
+ if '$ref' in field_type_schema:
411
+ ref_schema = self.resolve_ref(field_type_schema['$ref'], context_schema)
412
+ if ref_schema:
413
+ return self.convert_field_type(message, field_name, ref_schema, comment, index, proto_files, context_schema)
414
+ else:
415
+ # Reference not found, use string as fallback
416
+ deps: List[str] = []
417
+ return Field(comment, label, 'string', '', '', field_name, index, deps)
418
+
419
+ # Handle enum keyword
420
+ if 'enum' in field_type_schema:
421
+ enum_values = field_type_schema['enum']
422
+ enum_name = self.compose_name(field_name, 'Enum')
423
+ enum_fields = {str(val): Field(comment, '', str(val), '', '', str(val), i, []) for i, val in enumerate(enum_values)}
424
+ return Enum(comment, enum_name, enum_fields)
425
+
426
+ # Get the type from the schema
427
+ if 'type' not in field_type_schema:
428
+ # No type specified, use Any
429
+ deps1: List[str] = []
430
+ return Field(comment, label, 'google.protobuf.Any', '', '', field_name, index, deps1)
431
+
432
+ struct_type = field_type_schema['type']
433
+
434
+ # Handle type as a list (union type declared inline in "type" field)
435
+ if isinstance(struct_type, list):
436
+ # This is a union type specified as "type": ["string", "null"]
437
+ # Recursively call convert_field_type with the list
438
+ return self.convert_field_type(message, field_name, struct_type, comment, index, proto_files, context_schema)
439
+
440
+ # Handle object type
441
+ if struct_type == 'object':
442
+ return self.convert_record_type(field_type_schema, comment, proto_files, context_schema)
443
+
444
+ # Handle array type
445
+ elif struct_type == 'array':
446
+ items_schema = field_type_schema.get('items', {'type': 'any'})
447
+ converted_field_type = self.convert_field_type(message, self.compose_name(field_name, "item"), items_schema, comment, 1, proto_files, context_schema)
448
+ if isinstance(converted_field_type, Field):
449
+ # If the item is an array or map, we need to wrap it in a message
450
+ if converted_field_type.type in ('array', 'map'):
451
+ item_message = Message(comment, self.compose_name(field_name, 'ItemType'), [converted_field_type], [], {}, {}, converted_field_type.dependencies)
452
+ message.messages[item_message.name] = item_message
453
+ return Field(comment, 'repeated', 'array', '', item_message.name, field_name, index, [])
454
+ else:
455
+ return Field(comment, 'repeated', 'array', '', converted_field_type.type, field_name, index, converted_field_type.dependencies)
456
+ elif isinstance(converted_field_type, Enum):
457
+ enum = Enum(converted_field_type.comment, self.compose_name(converted_field_type.name, 'enum'), converted_field_type.fields)
458
+ message.enums[enum.name] = enum
459
+ return Field(comment, 'repeated', 'array', '', enum.name, field_name, index, [])
460
+ elif isinstance(converted_field_type, Message):
461
+ local_message = Message(converted_field_type.comment, self.compose_name(converted_field_type.name, 'type'), converted_field_type.fields, converted_field_type.oneofs, converted_field_type.messages, converted_field_type.enums, converted_field_type.dependencies)
462
+ message.messages[local_message.name] = local_message
463
+ return Field(comment, 'repeated', 'array', '', local_message.name, field_name, index, [])
464
+
465
+ # Handle set type (same as array in proto)
466
+ elif struct_type == 'set':
467
+ items_schema = field_type_schema.get('items', {'type': 'any'})
468
+ converted_field_type = self.convert_field_type(message, self.compose_name(field_name, "item"), items_schema, comment, index, proto_files, context_schema)
469
+ if isinstance(converted_field_type, Field):
470
+ return Field(comment, 'repeated', 'array', '', converted_field_type.type, field_name, index, converted_field_type.dependencies)
471
+ elif isinstance(converted_field_type, Enum):
472
+ enum = Enum(converted_field_type.comment, self.compose_name(converted_field_type.name, 'enum'), converted_field_type.fields)
473
+ message.enums[enum.name] = enum
474
+ return Field(comment, 'repeated', 'array', '', enum.name, field_name, index, [])
475
+ elif isinstance(converted_field_type, Message):
476
+ local_message = Message(converted_field_type.comment, self.compose_name(converted_field_type.name, 'type'), converted_field_type.fields, converted_field_type.oneofs, converted_field_type.messages, converted_field_type.enums, converted_field_type.dependencies)
477
+ message.messages[local_message.name] = local_message
478
+ return Field(comment, 'repeated', 'array', '', local_message.name, field_name, index, [])
479
+
480
+ # Handle map type
481
+ elif struct_type == 'map':
482
+ values_schema = field_type_schema.get('values', {'type': 'any'})
483
+ converted_field_type = self.convert_field_type(message, self.compose_name(field_name, 'item', 'field'), values_schema, comment, index, proto_files, context_schema)
484
+ if isinstance(converted_field_type, Field):
485
+ return Field(comment, label, 'map', 'string', converted_field_type.type, field_name, index, converted_field_type.dependencies)
486
+ elif isinstance(converted_field_type, Enum):
487
+ enum = Enum(converted_field_type.comment, self.compose_name(converted_field_type.name, 'enum'), converted_field_type.fields)
488
+ message.enums[enum.name] = enum
489
+ return Field(comment, label, 'map', 'string', enum.name, field_name, index, [])
490
+ elif isinstance(converted_field_type, Message):
491
+ local_message = Message(converted_field_type.comment, self.compose_name(converted_field_type.name, 'type'), converted_field_type.fields, converted_field_type.oneofs, converted_field_type.messages, converted_field_type.enums, [])
492
+ message.messages[local_message.name] = local_message
493
+ return Field(comment, label, 'map', 'string', local_message.name, field_name, index, local_message.dependencies)
494
+
495
+ # Handle choice type (discriminated union)
496
+ elif struct_type == 'choice':
497
+ # Choice type becomes a oneof in protobuf
498
+ choices = field_type_schema.get('choices', {})
499
+ oneof_fields = []
500
+ for i, (choice_name, choice_schema) in enumerate(choices.items()):
501
+ choice_field = self.convert_field_type(message, choice_name, choice_schema, comment, i+index, proto_files, context_schema)
502
+ if isinstance(choice_field, Field):
503
+ oneof_fields.append(Field(choice_field.comment, '', choice_field.type, '', '', choice_name, i+index, choice_field.dependencies))
504
+ elif isinstance(choice_field, Message):
505
+ local_message = Message(choice_field.comment, self.compose_name(choice_name, 'type'), choice_field.fields, choice_field.oneofs, choice_field.messages, choice_field.enums, choice_field.dependencies)
506
+ message.messages[local_message.name] = local_message
507
+ oneof_fields.append(Field(choice_field.comment, '', local_message.name, '', '', choice_name, i+index, []))
508
+ return Oneof(comment, field_name, oneof_fields)
509
+
510
+ # Handle tuple type
511
+ elif struct_type == 'tuple':
512
+ # Tuple becomes a message with numbered fields
513
+ tuple_order = field_type_schema.get('tuple', [])
514
+ properties = field_type_schema.get('properties', {})
515
+ tuple_message = Message(comment, self.compose_name(field_name, 'Tuple'), [], [], {}, {}, [])
516
+ for i, prop_name in enumerate(tuple_order):
517
+ if prop_name in properties:
518
+ prop_schema = properties[prop_name]
519
+ tuple_field = self.convert_field_type(tuple_message, prop_name, prop_schema, Comment('', {}), i+1, proto_files, context_schema)
520
+ if isinstance(tuple_field, Field):
521
+ tuple_message.fields.append(tuple_field)
522
+ return tuple_message
523
+
524
+ # Handle primitive types with format specifications
525
+ else:
526
+ deps2: List[str] = []
527
+ proto_type = self.structure_primitive_to_proto_type(struct_type, deps2)
528
+ return Field(comment, label, proto_type, '', '', field_name, index, deps2)
529
+
530
+ # Handle string types (primitive type names)
531
+ elif isinstance(field_type_schema, str):
532
+ deps3: List[str] = []
533
+ proto_type = self.structure_primitive_to_proto_type(field_type_schema, deps3)
534
+ return Field(comment, label, proto_type, '', '', field_name, index, deps3)
535
+
536
+ raise ValueError(f"Unknown field type {field_type_schema}")
537
+
538
+ def structure_schema_to_proto_message(self, structure_schema: dict, proto_files: ProtoFiles) -> str:
539
+ """Convert a JSON Structure schema to a Protobuf message definition."""
540
+ comment = Comment('', {})
541
+ if 'doc' in structure_schema or 'description' in structure_schema:
542
+ comment = Comment(structure_schema.get('description', structure_schema.get('doc', '')), {})
543
+
544
+ namespace = structure_schema.get("namespace", '')
545
+ if not namespace:
546
+ namespace = self.default_namespace
547
+
548
+ struct_type = structure_schema.get('type', 'object')
549
+
550
+ if struct_type == 'object':
551
+ message = self.convert_record_type(structure_schema, comment, proto_files, structure_schema)
552
+ file = next((f for f in proto_files.files if f.package == namespace), None)
553
+ if not file:
554
+ file = ProtoFile({}, {}, {}, [], {}, namespace)
555
+ proto_files.files.append(file)
556
+ file.messages[message.name] = message
557
+ elif struct_type == 'enum' or 'enum' in structure_schema:
558
+ enum_name = structure_schema.get('name', 'UnnamedEnum')
559
+ enum_values = structure_schema.get('enum', [])
560
+ enum_fields = {str(val): Field(comment, '', str(val), '', '', str(val), i, []) for i, val in enumerate(enum_values)}
561
+ enum = Enum(comment, enum_name, enum_fields)
562
+ file = next((f for f in proto_files.files if f.package == namespace), None)
563
+ if not file:
564
+ file = ProtoFile({}, {}, {}, [], {}, namespace)
565
+ proto_files.files.append(file)
566
+ file.enums[enum_name] = enum
567
+
568
+ return structure_schema.get("name", "UnnamedSchema")
569
+
570
+ def structure_schema_to_proto_messages(self, structure_schema_input, proto_files: ProtoFiles):
571
+ """Convert JSON Structure schema(s) to Protobuf message definitions."""
572
+ if not isinstance(structure_schema_input, list):
573
+ structure_schema_list = [structure_schema_input]
574
+ else:
575
+ structure_schema_list = structure_schema_input
576
+
577
+ # Register all schemas first
578
+ for structure_schema in structure_schema_list:
579
+ if isinstance(structure_schema, dict):
580
+ self.register_schema_ids(structure_schema)
581
+
582
+ # Then convert them
583
+ for structure_schema in structure_schema_list:
584
+ if isinstance(structure_schema, dict):
585
+ # Store definitions for later use
586
+ if 'definitions' in structure_schema:
587
+ self.definitions = structure_schema['definitions']
588
+ # Process definitions - pass the full schema as context so $ref can be resolved
589
+ for def_name, def_schema in structure_schema['definitions'].items():
590
+ if isinstance(def_schema, dict):
591
+ def_schema_copy = def_schema.copy()
592
+ if 'name' not in def_schema_copy:
593
+ def_schema_copy['name'] = def_name
594
+ if 'namespace' not in def_schema_copy:
595
+ def_schema_copy['namespace'] = structure_schema.get('namespace', self.default_namespace)
596
+ # Pass structure_schema as context for resolving $ref
597
+ self.structure_schema_to_proto_message_with_context(def_schema_copy, proto_files, structure_schema)
598
+
599
+ # Process root schema
600
+ self.structure_schema_to_proto_message(structure_schema, proto_files)
601
+
602
+ def structure_schema_to_proto_message_with_context(self, structure_schema: dict, proto_files: ProtoFiles, context_schema: Dict):
603
+ """Convert a JSON Structure schema to a Protobuf message definition with context for $ref resolution."""
604
+ comment = Comment('', {})
605
+ if 'doc' in structure_schema or 'description' in structure_schema:
606
+ comment = Comment(structure_schema.get('description', structure_schema.get('doc', '')), {})
607
+
608
+ namespace = structure_schema.get("namespace", '')
609
+ if not namespace:
610
+ namespace = self.default_namespace
611
+
612
+ struct_type = structure_schema.get('type', 'object')
613
+
614
+ if struct_type == 'object':
615
+ message = self.convert_record_type(structure_schema, comment, proto_files, context_schema)
616
+ file = next((f for f in proto_files.files if f.package == namespace), None)
617
+ if not file:
618
+ file = ProtoFile({}, {}, {}, [], {}, namespace)
619
+ proto_files.files.append(file)
620
+ file.messages[message.name] = message
621
+ elif struct_type == 'enum' or 'enum' in structure_schema:
622
+ enum_name = structure_schema.get('name', 'UnnamedEnum')
623
+ enum_values = structure_schema.get('enum', [])
624
+ enum_fields = {str(val): Field(comment, '', str(val), '', '', str(val), i, []) for i, val in enumerate(enum_values)}
625
+ enum = Enum(comment, enum_name, enum_fields)
626
+ file = next((f for f in proto_files.files if f.package == namespace), None)
627
+ if not file:
628
+ file = ProtoFile({}, {}, {}, [], {}, namespace)
629
+ proto_files.files.append(file)
630
+ file.enums[enum_name] = enum
631
+
632
+ return structure_schema.get("name", "UnnamedSchema")
633
+
634
+ def save_proto_to_file(self, proto_files: ProtoFiles, proto_path):
635
+ """Save the Protobuf schema to a file."""
636
+ for proto in proto_files.files:
637
+ # gather dependencies that are within the package
638
+ deps: List[str] = []
639
+ for message in proto.messages.values():
640
+ for dep in message.dependencies:
641
+ if '.' in dep:
642
+ deps.append(dep.rsplit('.', 1)[0])
643
+ deps = list(set(deps))
644
+
645
+ proto.imports.extend([d for d in deps if d != proto.package])
646
+ proto_file_path = os.path.join(proto_path, f"{proto.package if proto.package else 'default'}.proto")
647
+ # create the directory for the proto file if it doesn't exist
648
+ proto_dir = os.path.dirname(proto_file_path)
649
+ if not os.path.exists(proto_dir):
650
+ os.makedirs(proto_dir, exist_ok=True)
651
+ with open(proto_file_path, 'w', encoding='utf-8') as proto_file:
652
+ # dump the ProtoFile structure in proto syntax
653
+ proto_str = 'syntax = "proto3";\n\n'
654
+ if proto.package:
655
+ proto_str += f'package {proto.package};\n\n'
656
+
657
+ for import_package in proto.imports:
658
+ proto_str += f'import "{import_package}.proto";\n'
659
+ if len(proto.imports):
660
+ proto_str += "\n"
661
+ for enum_name, enum in proto.enums.items():
662
+ proto_str += f"enum {enum_name} {{\n"
663
+ for _, field in enum.fields.items():
664
+ proto_str += f"{indent}{field.name} = {field.number};\n"
665
+ proto_str += "}\n\n"
666
+ for message in proto.messages.values():
667
+ proto_str += self.render_message(message)
668
+ for service in proto.services.values():
669
+ proto_str += f"service {service.name} {{\n"
670
+ for function_name, func in service.functions.items():
671
+ proto_str += f"{indent}rpc {func.name} ({func.in_type}) returns ({func.out_type}) {{\n"
672
+ proto_str += f"{indent}{indent}option (google.api.http) = {{\n"
673
+ proto_str += f"{indent}{indent}{indent}post: \"{func.uri}\"\n"
674
+ proto_str += f"{indent}{indent}}};\n"
675
+ proto_str += f"{indent}}};\n"
676
+ proto_str += "}\n\n"
677
+ proto_file.write(proto_str)
678
+
679
+ def render_message(self, message, level=0) -> str:
680
+ proto_str = ''
681
+
682
+ # Add message-level comment if present
683
+ if message.comment.content:
684
+ comment_lines = message.comment.content.split(' | ')
685
+ for line in comment_lines:
686
+ proto_str += f"{indent*level}// {line}\n"
687
+
688
+ proto_str += f"{indent*level}message {message.name} {{\n"
689
+
690
+ # Add deprecated option if message is deprecated
691
+ if message.comment.tags.get('deprecated'):
692
+ proto_str += f"{indent*level}{indent}option deprecated = true;\n"
693
+
694
+ # Render nested messages and enums FIRST (protobuf convention)
695
+ for local_message in message.messages.values():
696
+ proto_str += self.render_message(local_message, level+1)
697
+ for enum in message.enums.values():
698
+ # Add enum-level comment if present
699
+ if enum.comment.content:
700
+ for line in enum.comment.content.split(' | '):
701
+ proto_str += f"{indent*level}{indent}// {line}\n"
702
+ proto_str += f"{indent*level}{indent}enum {enum.name} {{\n"
703
+ if enum.comment.tags.get('deprecated'):
704
+ proto_str += f"{indent*level}{indent}{indent}option deprecated = true;\n"
705
+ for _, field in enum.fields.items():
706
+ proto_str += f"{indent*level}{indent}{indent}{field.label}{' ' if field.label else ''}{field.name} = {field.number};\n"
707
+ proto_str += f"{indent*level}{indent}}}\n"
708
+
709
+ # Then render fields and oneofs
710
+ fieldsAndOneofs = message.fields+message.oneofs
711
+ fieldsAndOneofs.sort(key=lambda f: f.number if isinstance(f, Field) else f.fields[0].number)
712
+ for fo in fieldsAndOneofs:
713
+ if isinstance(fo, Field):
714
+ field = fo
715
+ # Add field-level comment if present
716
+ if field.comment.content:
717
+ for line in field.comment.content.split(' | '):
718
+ proto_str += f"{indent*level}{indent}// {line}\n"
719
+
720
+ # Render field with deprecated option if needed
721
+ deprecated_option = ' [deprecated = true]' if field.comment.tags.get('deprecated') else ''
722
+
723
+ if field.type == "map":
724
+ proto_str += f"{indent*level}{indent}{field.label}{' ' if field.label else ''}map<{field.key_type}, {field.val_type}> {field.name} = {field.number}{deprecated_option};\n"
725
+ elif field.type == "array":
726
+ proto_str += f"{indent*level}{indent}{field.label}{' ' if field.label else ''}{field.val_type} {field.name} = {field.number}{deprecated_option};\n"
727
+ else:
728
+ proto_str += f"{indent*level}{indent}{field.label}{' ' if field.label else ''}{field.type} {field.name} = {field.number}{deprecated_option};\n"
729
+ else:
730
+ oneof = fo
731
+ # Add oneof-level comment if present
732
+ if oneof.comment.content:
733
+ for line in oneof.comment.content.split(' | '):
734
+ proto_str += f"{indent*level}{indent}// {line}\n"
735
+ proto_str += f"{indent*level}{indent}oneof {oneof.name} {{\n"
736
+ for field in oneof.fields:
737
+ # Add field comment in oneof
738
+ if field.comment.content:
739
+ for line in field.comment.content.split(' | '):
740
+ proto_str += f"{indent*level}{indent}{indent}// {line}\n"
741
+ deprecated_option = ' [deprecated = true]' if field.comment.tags.get('deprecated') else ''
742
+ proto_str += f"{indent*level}{indent}{indent}{field.label}{' ' if field.label else ''}{field.type} {field.name} = {field.number}{deprecated_option};\n"
743
+ proto_str += f"{indent*level}{indent}}}\n"
744
+
745
+ proto_str += f"{indent*level}}}\n"
746
+ if level == 0:
747
+ proto_str += "\n"
748
+ return proto_str
749
+
750
+
751
+ def convert_structure_to_proto(self, structure_schema_path, proto_file_path):
752
+ """Convert JSON Structure schema file to Protobuf .proto file."""
753
+ with open(structure_schema_path, 'r', encoding='utf-8') as structure_file:
754
+ structure_schema = json.load(structure_file)
755
+ proto_files = ProtoFiles([])
756
+ self.structure_schema_to_proto_messages(structure_schema, proto_files)
757
+ self.save_proto_to_file(proto_files, proto_file_path)
758
+
759
+ def convert_structure_to_proto(structure_schema_path, proto_file_path, naming_mode: Literal['snake', 'pascal', 'camel'] = 'pascal', allow_optional: bool = False):
760
+ structuretoproto = StructureToProto()
761
+ structuretoproto.naming_mode = naming_mode
762
+ structuretoproto.allow_optional = allow_optional
763
+ structuretoproto.default_namespace = os.path.splitext(os.path.basename(proto_file_path))[0].replace('-', '_')
764
+ structuretoproto.convert_structure_to_proto(structure_schema_path, proto_file_path)