structurize 3.9.2__tar.gz → 3.9.3__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.
Files changed (126) hide show
  1. {structurize-3.9.2/structurize.egg-info → structurize-3.9.3}/PKG-INFO +1 -1
  2. {structurize-3.9.2 → structurize-3.9.3}/avrotize/_version.py +3 -3
  3. {structurize-3.9.2 → structurize-3.9.3}/avrotize/avrotojstruct.py +122 -14
  4. structurize-3.9.3/avrotize/avrotojtd.py +404 -0
  5. {structurize-3.9.2 → structurize-3.9.3}/avrotize/cddltostructure.py +27 -2
  6. {structurize-3.9.2 → structurize-3.9.3}/avrotize/jsonstostructure.py +118 -3
  7. {structurize-3.9.2 → structurize-3.9.3}/avrotize/jstructtoavro.py +18 -5
  8. {structurize-3.9.2 → structurize-3.9.3}/avrotize/jtdtoavro.py +196 -9
  9. structurize-3.9.3/avrotize/jtdtostructure.py +151 -0
  10. {structurize-3.9.2 → structurize-3.9.3}/avrotize/structuretocsharp.py +7 -2
  11. {structurize-3.9.2 → structurize-3.9.3}/avrotize/structuretodatapackage.py +0 -9
  12. {structurize-3.9.2 → structurize-3.9.3}/avrotize/structuretographql.py +297 -103
  13. {structurize-3.9.2 → structurize-3.9.3/structurize.egg-info}/PKG-INFO +1 -1
  14. structurize-3.9.2/avrotize/avrotojtd.py +0 -228
  15. structurize-3.9.2/avrotize/jtdtostructure.py +0 -24
  16. {structurize-3.9.2 → structurize-3.9.3}/.gitignore +0 -0
  17. {structurize-3.9.2 → structurize-3.9.3}/LICENSE +0 -0
  18. {structurize-3.9.2 → structurize-3.9.3}/MANIFEST.in +0 -0
  19. {structurize-3.9.2 → structurize-3.9.3}/README.md +0 -0
  20. {structurize-3.9.2 → structurize-3.9.3}/avrotize/__init__.py +0 -0
  21. {structurize-3.9.2 → structurize-3.9.3}/avrotize/__main__.py +0 -0
  22. {structurize-3.9.2 → structurize-3.9.3}/avrotize/asn1toavro.py +0 -0
  23. {structurize-3.9.2 → structurize-3.9.3}/avrotize/avrotize.py +0 -0
  24. {structurize-3.9.2 → structurize-3.9.3}/avrotize/avrotoasn1.py +0 -0
  25. {structurize-3.9.2 → structurize-3.9.3}/avrotize/avrotocapnproto.py +0 -0
  26. {structurize-3.9.2 → structurize-3.9.3}/avrotize/avrotocpp.py +0 -0
  27. {structurize-3.9.2 → structurize-3.9.3}/avrotize/avrotocsharp.py +0 -0
  28. {structurize-3.9.2 → structurize-3.9.3}/avrotize/avrotocsv.py +0 -0
  29. {structurize-3.9.2 → structurize-3.9.3}/avrotize/avrotocue.py +0 -0
  30. {structurize-3.9.2 → structurize-3.9.3}/avrotize/avrotodatapackage.py +0 -0
  31. {structurize-3.9.2 → structurize-3.9.3}/avrotize/avrotodb.py +0 -0
  32. {structurize-3.9.2 → structurize-3.9.3}/avrotize/avrotoflatbuffers.py +0 -0
  33. {structurize-3.9.2 → structurize-3.9.3}/avrotize/avrotogo.py +0 -0
  34. {structurize-3.9.2 → structurize-3.9.3}/avrotize/avrotographql.py +0 -0
  35. {structurize-3.9.2 → structurize-3.9.3}/avrotize/avrotoiceberg.py +0 -0
  36. {structurize-3.9.2 → structurize-3.9.3}/avrotize/avrotojava.py +0 -0
  37. {structurize-3.9.2 → structurize-3.9.3}/avrotize/avrotojs.py +0 -0
  38. {structurize-3.9.2 → structurize-3.9.3}/avrotize/avrotojsons.py +0 -0
  39. {structurize-3.9.2 → structurize-3.9.3}/avrotize/avrotokusto.py +0 -0
  40. {structurize-3.9.2 → structurize-3.9.3}/avrotize/avrotomd.py +0 -0
  41. {structurize-3.9.2 → structurize-3.9.3}/avrotize/avrotools.py +0 -0
  42. {structurize-3.9.2 → structurize-3.9.3}/avrotize/avrotoparquet.py +0 -0
  43. {structurize-3.9.2 → structurize-3.9.3}/avrotize/avrotoproto.py +0 -0
  44. {structurize-3.9.2 → structurize-3.9.3}/avrotize/avrotopython.py +0 -0
  45. {structurize-3.9.2 → structurize-3.9.3}/avrotize/avrotoraml.py +0 -0
  46. {structurize-3.9.2 → structurize-3.9.3}/avrotize/avrotorust.py +0 -0
  47. {structurize-3.9.2 → structurize-3.9.3}/avrotize/avrotosmithy.py +0 -0
  48. {structurize-3.9.2 → structurize-3.9.3}/avrotize/avrotosurreal.py +0 -0
  49. {structurize-3.9.2 → structurize-3.9.3}/avrotize/avrotothrift.py +0 -0
  50. {structurize-3.9.2 → structurize-3.9.3}/avrotize/avrotots.py +0 -0
  51. {structurize-3.9.2 → structurize-3.9.3}/avrotize/avrototsml.py +0 -0
  52. {structurize-3.9.2 → structurize-3.9.3}/avrotize/avrotoxsd.py +0 -0
  53. {structurize-3.9.2 → structurize-3.9.3}/avrotize/avrovalidator.py +0 -0
  54. {structurize-3.9.2 → structurize-3.9.3}/avrotize/capnprototoavro.py +0 -0
  55. {structurize-3.9.2 → structurize-3.9.3}/avrotize/choice_inference.py +0 -0
  56. {structurize-3.9.2 → structurize-3.9.3}/avrotize/commands.json +0 -0
  57. {structurize-3.9.2 → structurize-3.9.3}/avrotize/common.py +0 -0
  58. {structurize-3.9.2 → structurize-3.9.3}/avrotize/constants.py +0 -0
  59. {structurize-3.9.2 → structurize-3.9.3}/avrotize/csvtoavro.py +0 -0
  60. {structurize-3.9.2 → structurize-3.9.3}/avrotize/cuetoavro.py +0 -0
  61. {structurize-3.9.2 → structurize-3.9.3}/avrotize/cuetostructure.py +0 -0
  62. {structurize-3.9.2 → structurize-3.9.3}/avrotize/datapackagetoavro.py +0 -0
  63. {structurize-3.9.2 → structurize-3.9.3}/avrotize/dependencies/cpp/vcpkg/vcpkg.json +0 -0
  64. {structurize-3.9.2 → structurize-3.9.3}/avrotize/dependencies/typescript/node22/package.json +0 -0
  65. {structurize-3.9.2 → structurize-3.9.3}/avrotize/dependency_resolver.py +0 -0
  66. {structurize-3.9.2 → structurize-3.9.3}/avrotize/dependency_version.py +0 -0
  67. {structurize-3.9.2 → structurize-3.9.3}/avrotize/flatbufferstoavro.py +0 -0
  68. {structurize-3.9.2 → structurize-3.9.3}/avrotize/flatbufferstojstruct.py +0 -0
  69. {structurize-3.9.2 → structurize-3.9.3}/avrotize/jsonstoavro.py +0 -0
  70. {structurize-3.9.2 → structurize-3.9.3}/avrotize/jsontoschema.py +0 -0
  71. {structurize-3.9.2 → structurize-3.9.3}/avrotize/jstructtoflatbuffers.py +0 -0
  72. {structurize-3.9.2 → structurize-3.9.3}/avrotize/jstructtoraml.py +0 -0
  73. {structurize-3.9.2 → structurize-3.9.3}/avrotize/jstructtosmithy.py +0 -0
  74. {structurize-3.9.2 → structurize-3.9.3}/avrotize/kstructtoavro.py +0 -0
  75. {structurize-3.9.2 → structurize-3.9.3}/avrotize/kustotoavro.py +0 -0
  76. {structurize-3.9.2 → structurize-3.9.3}/avrotize/kustotojstruct.py +0 -0
  77. {structurize-3.9.2 → structurize-3.9.3}/avrotize/mcp_server.py +0 -0
  78. {structurize-3.9.2 → structurize-3.9.3}/avrotize/openapitostructure.py +0 -0
  79. {structurize-3.9.2 → structurize-3.9.3}/avrotize/parquettoavro.py +0 -0
  80. {structurize-3.9.2 → structurize-3.9.3}/avrotize/proto2parser.py +0 -0
  81. {structurize-3.9.2 → structurize-3.9.3}/avrotize/proto3parser.py +0 -0
  82. {structurize-3.9.2 → structurize-3.9.3}/avrotize/prototoavro.py +0 -0
  83. {structurize-3.9.2 → structurize-3.9.3}/avrotize/ramltoavro.py +0 -0
  84. {structurize-3.9.2 → structurize-3.9.3}/avrotize/ramltojstruct.py +0 -0
  85. {structurize-3.9.2 → structurize-3.9.3}/avrotize/rust_xml.py +0 -0
  86. {structurize-3.9.2 → structurize-3.9.3}/avrotize/schema_inference.py +0 -0
  87. {structurize-3.9.2 → structurize-3.9.3}/avrotize/smithytoavro.py +0 -0
  88. {structurize-3.9.2 → structurize-3.9.3}/avrotize/smithytojstruct.py +0 -0
  89. {structurize-3.9.2 → structurize-3.9.3}/avrotize/sqltoavro.py +0 -0
  90. {structurize-3.9.2 → structurize-3.9.3}/avrotize/structuretoasn1.py +0 -0
  91. {structurize-3.9.2 → structurize-3.9.3}/avrotize/structuretocddl.py +0 -0
  92. {structurize-3.9.2 → structurize-3.9.3}/avrotize/structuretocpp.py +0 -0
  93. {structurize-3.9.2 → structurize-3.9.3}/avrotize/structuretocsv.py +0 -0
  94. {structurize-3.9.2 → structurize-3.9.3}/avrotize/structuretocue.py +0 -0
  95. {structurize-3.9.2 → structurize-3.9.3}/avrotize/structuretodb.py +0 -0
  96. {structurize-3.9.2 → structurize-3.9.3}/avrotize/structuretogo.py +0 -0
  97. {structurize-3.9.2 → structurize-3.9.3}/avrotize/structuretoiceberg.py +0 -0
  98. {structurize-3.9.2 → structurize-3.9.3}/avrotize/structuretojava.py +0 -0
  99. {structurize-3.9.2 → structurize-3.9.3}/avrotize/structuretojs.py +0 -0
  100. {structurize-3.9.2 → structurize-3.9.3}/avrotize/structuretojsons.py +0 -0
  101. {structurize-3.9.2 → structurize-3.9.3}/avrotize/structuretojtd.py +0 -0
  102. {structurize-3.9.2 → structurize-3.9.3}/avrotize/structuretokusto.py +0 -0
  103. {structurize-3.9.2 → structurize-3.9.3}/avrotize/structuretomd.py +0 -0
  104. {structurize-3.9.2 → structurize-3.9.3}/avrotize/structuretoparquet.py +0 -0
  105. {structurize-3.9.2 → structurize-3.9.3}/avrotize/structuretoproto.py +0 -0
  106. {structurize-3.9.2 → structurize-3.9.3}/avrotize/structuretopython.py +0 -0
  107. {structurize-3.9.2 → structurize-3.9.3}/avrotize/structuretorust.py +0 -0
  108. {structurize-3.9.2 → structurize-3.9.3}/avrotize/structuretots.py +0 -0
  109. {structurize-3.9.2 → structurize-3.9.3}/avrotize/structuretotsml.py +0 -0
  110. {structurize-3.9.2 → structurize-3.9.3}/avrotize/structuretoxsd.py +0 -0
  111. {structurize-3.9.2 → structurize-3.9.3}/avrotize/surrealtoavro.py +0 -0
  112. {structurize-3.9.2 → structurize-3.9.3}/avrotize/thriftstructure.py +0 -0
  113. {structurize-3.9.2 → structurize-3.9.3}/avrotize/thrifttoavro.py +0 -0
  114. {structurize-3.9.2 → structurize-3.9.3}/avrotize/tmslvalidate.py +0 -0
  115. {structurize-3.9.2 → structurize-3.9.3}/avrotize/validate.py +0 -0
  116. {structurize-3.9.2 → structurize-3.9.3}/avrotize/xmltoschema.py +0 -0
  117. {structurize-3.9.2 → structurize-3.9.3}/avrotize/xsdtoavro.py +0 -0
  118. {structurize-3.9.2 → structurize-3.9.3}/build.ps1 +0 -0
  119. {structurize-3.9.2 → structurize-3.9.3}/build.sh +0 -0
  120. {structurize-3.9.2 → structurize-3.9.3}/pyproject.toml +0 -0
  121. {structurize-3.9.2 → structurize-3.9.3}/setup.cfg +0 -0
  122. {structurize-3.9.2 → structurize-3.9.3}/structurize.egg-info/SOURCES.txt +0 -0
  123. {structurize-3.9.2 → structurize-3.9.3}/structurize.egg-info/dependency_links.txt +0 -0
  124. {structurize-3.9.2 → structurize-3.9.3}/structurize.egg-info/entry_points.txt +0 -0
  125. {structurize-3.9.2 → structurize-3.9.3}/structurize.egg-info/requires.txt +0 -0
  126. {structurize-3.9.2 → structurize-3.9.3}/structurize.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: structurize
3
- Version: 3.9.2
3
+ Version: 3.9.3
4
4
  Summary: Tools to convert from and to JSON Structure from various other schema languages.
5
5
  Author-email: Clemens Vasters <clemensv@microsoft.com>
6
6
  Classifier: Programming Language :: Python :: 3
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '3.9.2'
22
- __version_tuple__ = version_tuple = (3, 9, 2)
21
+ __version__ = version = '3.9.3'
22
+ __version_tuple__ = version_tuple = (3, 9, 3)
23
23
 
24
- __commit_id__ = commit_id = 'g8d1c504ec'
24
+ __commit_id__ = commit_id = 'g0848ff37a'
@@ -13,6 +13,7 @@ class AvroToJsonStructure:
13
13
  def __init__(self, avro_encoding: bool = False) -> None:
14
14
  self.known_types: set[str] = set()
15
15
  self.reference_stack: set[str] = set()
16
+ self.definition_paths: dict[str, str] = {}
16
17
  self.avro_encoding: bool = avro_encoding
17
18
 
18
19
  # ------------------------------------------------------------------ TOP-LEVEL
@@ -38,10 +39,29 @@ class AvroToJsonStructure:
38
39
  "definitions": {},
39
40
  }
40
41
 
41
- # TEMPORARY: process only first element
42
42
  first = avro_schema[0]
43
43
  if isinstance(first, dict):
44
- return self.convert(first, namespace)
44
+ self.known_types.clear()
45
+ self.reference_stack.clear()
46
+ self._prepare_definition_paths(avro_schema, namespace)
47
+
48
+ current_namespace, name = self.resolve_full_name(
49
+ first.get("name", f"AnonymousType_{uuid.uuid4().hex}"),
50
+ first.get("namespace", namespace),
51
+ )
52
+ fqn = self.get_fqn(current_namespace, name)
53
+ doc: Dict[str, Any] = {
54
+ "$schema": "https://json-structure.org/meta/core/v0/#",
55
+ "$id": f"https://example.com/schemas/{fqn}",
56
+ "name": name,
57
+ "type": "null",
58
+ "definitions": {},
59
+ }
60
+ for schema in avro_schema:
61
+ if isinstance(schema, dict):
62
+ self.register_definition(schema, namespace, doc["definitions"])
63
+ self._apply_extensions(doc)
64
+ return doc
45
65
 
46
66
  # First element non-dict → return stub
47
67
  bad_name = f"invalid_list_root_{uuid.uuid4().hex[:8]}"
@@ -56,10 +76,11 @@ class AvroToJsonStructure:
56
76
  # Reset caches for each top-level conversion
57
77
  self.known_types.clear()
58
78
  self.reference_stack.clear()
79
+ self._prepare_definition_paths(avro_schema, namespace)
59
80
 
60
- current_namespace = avro_schema.get("namespace", namespace)
61
- name = self.clean_name(
62
- avro_schema.get("name", f"AnonymousType_{uuid.uuid4().hex}")
81
+ current_namespace, name = self.resolve_full_name(
82
+ avro_schema.get("name", f"AnonymousType_{uuid.uuid4().hex}"),
83
+ avro_schema.get("namespace", namespace),
63
84
  )
64
85
  fqn = self.get_fqn(current_namespace, name)
65
86
 
@@ -73,8 +94,26 @@ class AvroToJsonStructure:
73
94
 
74
95
  # Build definitions – do NOT skip root
75
96
  self.register_definition(avro_schema, current_namespace, doc["definitions"])
97
+ self._apply_extensions(doc)
76
98
  return doc
77
99
 
100
+ def _apply_extensions(self, doc: Dict[str, Any]) -> None:
101
+ uses = set(doc.get("$uses", []))
102
+ if self._contains_key(doc, "default"):
103
+ uses.add("JSONStructureValidation")
104
+ if self._contains_key(doc, "altnames"):
105
+ uses.add("JSONStructureAlternateNames")
106
+ if uses:
107
+ doc["$schema"] = "https://json-structure.org/meta/extended/v0/#"
108
+ doc["$uses"] = sorted(uses)
109
+
110
+ def _contains_key(self, value: Any, key: str) -> bool:
111
+ if isinstance(value, dict):
112
+ return key in value or any(self._contains_key(item, key) for item in value.values())
113
+ if isinstance(value, list):
114
+ return any(self._contains_key(item, key) for item in value)
115
+ return False
116
+
78
117
  # ------------------------------------------------------------------ REGISTRATION
79
118
 
80
119
  def register_definition(
@@ -88,9 +127,9 @@ class AvroToJsonStructure:
88
127
  Ensure `avro_schema` has an entry in `definitions`.
89
128
  """
90
129
 
91
- current_namespace = avro_schema.get("namespace", namespace)
92
- name = self.clean_name(
93
- avro_schema.get("name", f"AnonymousType_{uuid.uuid4().hex}")
130
+ current_namespace, name = self.resolve_full_name(
131
+ avro_schema.get("name", f"AnonymousType_{uuid.uuid4().hex}"),
132
+ avro_schema.get("namespace", namespace),
94
133
  )
95
134
  fqn = self.get_fqn(current_namespace, name)
96
135
 
@@ -113,8 +152,10 @@ class AvroToJsonStructure:
113
152
 
114
153
  avro_type = avro_schema.get("type")
115
154
  # Use the schema's own namespace if provided, otherwise fall back to the passed 'namespace'
116
- current_schema_namespace = avro_schema.get("namespace", namespace)
117
- name = self.clean_name(avro_schema.get("name", f"AnonymousType_{uuid.uuid4().hex}"))
155
+ current_schema_namespace, name = self.resolve_full_name(
156
+ avro_schema.get("name", f"AnonymousType_{uuid.uuid4().hex}"),
157
+ avro_schema.get("namespace", namespace),
158
+ )
118
159
  fqn = self.get_fqn(current_schema_namespace, name)
119
160
 
120
161
  if fqn in self.reference_stack:
@@ -202,6 +243,10 @@ class AvroToJsonStructure:
202
243
  # If a definition was constructed, add it to the definitions map with proper nesting.
203
244
  if type_definition_content is not None:
204
245
  parts = fqn.split('/')
246
+ definition_name = parts[-1]
247
+ if definition_name != name:
248
+ type_definition_content["name"] = definition_name
249
+ type_definition_content.setdefault("altnames", {})["avro"] = name
205
250
  current_level_dict = definitions
206
251
  for i, part_name in enumerate(parts):
207
252
  if i == len(parts) - 1: # Last part is the type name itself
@@ -232,7 +277,10 @@ class AvroToJsonStructure:
232
277
  return {"type": self.get_primitive_types()[avro_type_schema]}
233
278
  # Named type reference
234
279
  if "." in avro_type_schema:
235
- ref_fqn = avro_type_schema.replace(".", "/")
280
+ ref_namespace, ref_name = self.resolve_full_name(
281
+ avro_type_schema, context_namespace
282
+ )
283
+ ref_fqn = self.get_fqn(ref_namespace, ref_name)
236
284
  else:
237
285
  ref_fqn = self.get_fqn(context_namespace, self.clean_name(avro_type_schema))
238
286
  # JSON Structure Core requires a type reference to be the value of the
@@ -268,8 +316,10 @@ class AvroToJsonStructure:
268
316
  if category in ("record", "enum", "fixed"):
269
317
  # Ensure definition exists then reference it
270
318
  self.register_definition(avro_type_schema, inline_ns, definitions)
271
- ref_name = self.clean_name(avro_type_schema["name"])
272
- ref_fqn = self.get_fqn(inline_ns, ref_name)
319
+ ref_namespace, ref_name = self.resolve_full_name(
320
+ avro_type_schema["name"], inline_ns
321
+ )
322
+ ref_fqn = self.get_fqn(ref_namespace, ref_name)
273
323
  # Wrap the reference under ``type`` (see note above) so the emitted
274
324
  # property/items/values/choice node is a valid JSON Structure schema.
275
325
  return {"type": {"$ref": f"#/definitions/{ref_fqn}"}}
@@ -350,11 +400,69 @@ class AvroToJsonStructure:
350
400
  def clean_name(self, name: str) -> str:
351
401
  return name.replace(".", "_")
352
402
 
353
- def get_fqn(self, namespace: str | None, name: str) -> str:
403
+ def resolve_full_name(self, name: str, namespace: str | None) -> tuple[str | None, str]:
404
+ if "." in name:
405
+ namespace, name = name.rsplit(".", 1)
406
+ return namespace, self.clean_name(name)
407
+
408
+ def _prepare_definition_paths(
409
+ self, avro_schema: Any, namespace: str | None = None
410
+ ) -> None:
411
+ full_names: set[str] = set()
412
+
413
+ def collect(node: Any, current_namespace: str | None) -> None:
414
+ if isinstance(node, list):
415
+ for item in node:
416
+ collect(item, current_namespace)
417
+ return
418
+ if not isinstance(node, dict):
419
+ return
420
+
421
+ category = node.get("type")
422
+ inline_namespace = node.get("namespace", current_namespace)
423
+ if category in ("record", "enum", "fixed") and node.get("name"):
424
+ type_namespace, type_name = self.resolve_full_name(
425
+ node["name"], inline_namespace
426
+ )
427
+ raw_fqn = self.get_raw_fqn(type_namespace, type_name)
428
+ full_names.add(raw_fqn)
429
+ if category == "record":
430
+ for field in node.get("fields", []):
431
+ collect(field.get("type"), type_namespace)
432
+ return
433
+
434
+ if isinstance(category, (dict, list)):
435
+ collect(category, inline_namespace)
436
+ elif category == "array":
437
+ collect(node.get("items"), inline_namespace)
438
+ elif category == "map":
439
+ collect(node.get("values"), inline_namespace)
440
+
441
+ collect(avro_schema, namespace)
442
+ reserved_names = {"type", "definitions"}
443
+ self.definition_paths.clear()
444
+ for full_name in full_names:
445
+ parts = full_name.split("/")
446
+ encoded_parts = []
447
+ for index, part in enumerate(parts):
448
+ prefix = "/".join(parts[: index + 1])
449
+ if index < len(parts) - 1 and prefix in full_names:
450
+ part += "_"
451
+ elif index == len(parts) - 1 and part in reserved_names:
452
+ part += "_"
453
+ encoded_parts.append(part)
454
+ self.definition_paths[full_name] = "/".join(encoded_parts)
455
+
456
+ @staticmethod
457
+ def get_raw_fqn(namespace: str | None, name: str) -> str:
354
458
  if namespace:
355
459
  return f"{namespace.replace('.', '/')}/{name}"
356
460
  return name
357
461
 
462
+ def get_fqn(self, namespace: str | None, name: str) -> str:
463
+ raw_fqn = self.get_raw_fqn(namespace, name)
464
+ return self.definition_paths.get(raw_fqn, raw_fqn)
465
+
358
466
  @staticmethod
359
467
  def get_primitive_types() -> Dict[str, str]:
360
468
  return {
@@ -0,0 +1,404 @@
1
+ """Avrotize Schema to JSON Type Definition (RFC 8927) converter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ from avrotize.common import altname
9
+
10
+ AvroSchema = dict[str, Any] | list[Any] | str
11
+ JtdSchema = dict[str, Any]
12
+
13
+
14
+ class AvroToJtdConverter:
15
+ """Convert Avrotize/Avro schemas to JSON Type Definition schemas."""
16
+
17
+ PRIMITIVE_TO_JTD: dict[str, str] = {
18
+ "boolean": "boolean",
19
+ "int": "int32",
20
+ "long": "uint32",
21
+ "float": "float32",
22
+ "double": "float64",
23
+ "string": "string",
24
+ }
25
+
26
+ def __init__(self, record_type: str | None = None) -> None:
27
+ """Initialize the converter."""
28
+ self.record_type = record_type
29
+ self.named_types: dict[str, dict[str, Any]] = {}
30
+ self.simple_names: dict[str, str] = {}
31
+ self.inherited_namespaces: dict[int, str] = {}
32
+ self.inline_named_schema_ids: set[int] = set()
33
+ self.definitions: dict[str, JtdSchema] = {}
34
+ self.converting: set[str] = set()
35
+ self.root_key: str | None = None
36
+
37
+ def convert(self, avro_schema: AvroSchema) -> JtdSchema:
38
+ """Convert an Avro schema object to a JTD schema dictionary."""
39
+ self._register_named_types(avro_schema)
40
+ root = self._register_top_level(avro_schema)
41
+ if self.record_type:
42
+ root = self._find_named_type(self.record_type)
43
+ if isinstance(root, dict) and root.get("type") in {"record", "enum"}:
44
+ self.root_key = self._type_key(root)
45
+ root_namespace = self._schema_namespace(root) if isinstance(root, dict) else None
46
+ root_jtd = self._convert_type(root, root_namespace)
47
+ if isinstance(root, dict) and root.get("type") in {"record", "enum"} and root.get("jtdRoot"):
48
+ root_name = self._type_key(root)
49
+ self.definitions[root_name] = root_jtd
50
+ return {"definitions": self.definitions, "ref": root_name}
51
+ if self.definitions:
52
+ if isinstance(root, dict) and root.get("type") in {"record", "enum"}:
53
+ root_name = self._type_key(root)
54
+ self.definitions[root_name] = root_jtd
55
+ return {"definitions": self.definitions, "ref": root_name}
56
+ root_jtd = dict(root_jtd)
57
+ root_jtd["definitions"] = self.definitions
58
+ return root_jtd
59
+
60
+ def _register_top_level(self, avro_schema: AvroSchema) -> AvroSchema:
61
+ if isinstance(avro_schema, list):
62
+ if self._is_discriminator_union(avro_schema):
63
+ for item in avro_schema:
64
+ self._register_named(item)
65
+ return avro_schema
66
+ discriminator_branches = [
67
+ item for item in avro_schema
68
+ if isinstance(item, dict) and item.get("type") == "record" and item.get("jtdDiscriminator")
69
+ ]
70
+ if self._is_discriminator_union(discriminator_branches):
71
+ for item in avro_schema:
72
+ if isinstance(item, dict) and item.get("type") in {"record", "enum"}:
73
+ self._register_named(item)
74
+ helper_dependencies = self._named_dependencies_of(discriminator_branches)
75
+ return [
76
+ item for item in avro_schema
77
+ if not (
78
+ isinstance(item, dict)
79
+ and item.get("type") in {"record", "enum"}
80
+ and not item.get("jtdDiscriminator")
81
+ and self._type_key(item) in helper_dependencies
82
+ )
83
+ ]
84
+ named_items = [
85
+ item for item in avro_schema
86
+ if isinstance(item, dict) and item.get("type") in {"record", "enum"}
87
+ ]
88
+ root_members = [item for item in avro_schema if item not in named_items]
89
+ has_explicit_root = any(item.get("jtdRoot") for item in named_items) or any(
90
+ isinstance(item, dict) and item.get("jtdRoot") for item in root_members
91
+ )
92
+ if named_items and root_members and has_explicit_root:
93
+ for item in named_items:
94
+ self._register_named(item)
95
+ if len(root_members) == 1:
96
+ return root_members[0]
97
+ return root_members
98
+ if not all(isinstance(item, dict) and item.get("type") in {"record", "enum"} for item in avro_schema):
99
+ return avro_schema
100
+ for item in avro_schema:
101
+ self._register_named(item)
102
+ root = next((item for item in avro_schema if item.get("jtdRoot")), avro_schema[-1] if avro_schema else None)
103
+ if root is None:
104
+ raise ValueError("Avro schema list is empty")
105
+ return root
106
+ if isinstance(avro_schema, dict) and avro_schema.get("type") in {"record", "enum"}:
107
+ self._register_named(avro_schema)
108
+ return avro_schema
109
+
110
+ def _register_named_types(
111
+ self,
112
+ avro_type: AvroSchema,
113
+ seen: set[int] | None = None,
114
+ inherited_namespace: str | None = None,
115
+ inline: bool = False,
116
+ ) -> None:
117
+ if seen is None:
118
+ seen = set()
119
+ if isinstance(avro_type, list):
120
+ for member in avro_type:
121
+ self._register_named_types(member, seen, inherited_namespace, inline)
122
+ return
123
+ if not isinstance(avro_type, dict) or id(avro_type) in seen:
124
+ return
125
+ seen.add(id(avro_type))
126
+
127
+ schema_type = avro_type.get("type")
128
+ if schema_type in {"record", "enum"} and avro_type.get("name"):
129
+ namespace = avro_type.get("namespace", inherited_namespace)
130
+ if namespace:
131
+ self.inherited_namespaces[id(avro_type)] = str(namespace)
132
+ if inline:
133
+ self.inline_named_schema_ids.add(id(avro_type))
134
+ self._register_named(avro_type)
135
+ inherited_namespace = str(namespace) if namespace else None
136
+ if schema_type == "record":
137
+ for field in avro_type.get("fields", []):
138
+ self._register_named_types(field.get("type", "string"), seen, inherited_namespace, True)
139
+ elif schema_type == "array":
140
+ self._register_named_types(avro_type.get("items", "string"), seen, inherited_namespace, True)
141
+ elif schema_type == "map":
142
+ self._register_named_types(avro_type.get("values", "string"), seen, inherited_namespace, True)
143
+ elif isinstance(schema_type, (dict, list)):
144
+ self._register_named_types(schema_type, seen, inherited_namespace, True)
145
+
146
+ def _register_named(self, schema: dict[str, Any]) -> None:
147
+ key = self._type_key(schema)
148
+ self.named_types[key] = schema
149
+ self.simple_names[schema["name"]] = key
150
+
151
+ def _named_dependencies_of(self, roots: list[Any]) -> set[str]:
152
+ root_keys = {
153
+ self._type_key(root)
154
+ for root in roots
155
+ if isinstance(root, dict) and root.get("type") in {"record", "enum"}
156
+ }
157
+ dependencies: set[str] = set()
158
+ pending = list(roots)
159
+ while pending:
160
+ for reference in self._named_references(pending.pop()):
161
+ if reference in root_keys or reference in dependencies:
162
+ continue
163
+ dependencies.add(reference)
164
+ pending.append(self.named_types[reference])
165
+ return dependencies
166
+
167
+ def _named_references(self, avro_type: AvroSchema, namespace: str | None = None) -> set[str]:
168
+ if isinstance(avro_type, str):
169
+ key = self._resolve_named_key(avro_type, namespace)
170
+ return {key} if key else set()
171
+ if isinstance(avro_type, list):
172
+ references: set[str] = set()
173
+ for member in avro_type:
174
+ references.update(self._named_references(member, namespace))
175
+ return references
176
+ if not isinstance(avro_type, dict):
177
+ return set()
178
+
179
+ schema_type = avro_type.get("type")
180
+ if schema_type == "record":
181
+ record_namespace = self._schema_namespace(avro_type, namespace)
182
+ references: set[str] = set()
183
+ for field in avro_type.get("fields", []):
184
+ references.update(self._named_references(field.get("type", "string"), record_namespace))
185
+ return references
186
+ if schema_type == "array":
187
+ return self._named_references(avro_type.get("items", "string"), namespace)
188
+ if schema_type == "map":
189
+ return self._named_references(avro_type.get("values", "string"), namespace)
190
+ if isinstance(schema_type, (dict, list, str)):
191
+ return self._named_references(schema_type, namespace)
192
+ return set()
193
+
194
+ def _find_named_type(self, record_type: str) -> dict[str, Any]:
195
+ key = self._resolve_named_key(record_type)
196
+ if key and key in self.named_types:
197
+ return self.named_types[key]
198
+ raise ValueError(f"Avro record type '{record_type}' not found")
199
+
200
+ def _type_key(self, schema: dict[str, Any]) -> str:
201
+ namespace = self._schema_namespace(schema)
202
+ return f"{namespace}.{schema['name']}" if namespace else schema["name"]
203
+
204
+ def _schema_namespace(self, schema: dict[str, Any], fallback: str | None = None) -> str | None:
205
+ if "namespace" in schema:
206
+ namespace = schema.get("namespace")
207
+ else:
208
+ namespace = self.inherited_namespaces.get(id(schema), fallback)
209
+ return str(namespace) if namespace else None
210
+
211
+ def _resolve_named_key(self, reference: str, namespace: str | None = None) -> str | None:
212
+ if reference in self.named_types:
213
+ return reference
214
+ if namespace and "." not in reference:
215
+ relative_key = f"{namespace}.{reference}"
216
+ if relative_key in self.named_types:
217
+ return relative_key
218
+ return self.simple_names.get(reference)
219
+
220
+ def _convert_type(self, avro_type: AvroSchema, namespace: str | None = None) -> JtdSchema:
221
+ nullable, non_null = self._strip_nullable(avro_type)
222
+ if self._is_discriminator_union(non_null):
223
+ jtd = self._convert_discriminator(non_null, namespace) # type: ignore[arg-type]
224
+ elif isinstance(non_null, dict) and non_null.get("type") == "record" and non_null.get("jtdDiscriminator"):
225
+ jtd = self._convert_discriminator([non_null], namespace)
226
+ else:
227
+ jtd = self._convert_non_nullable(non_null, namespace)
228
+ if nullable:
229
+ jtd = dict(jtd)
230
+ jtd["nullable"] = True
231
+ return jtd
232
+
233
+ def _convert_non_nullable(self, avro_type: AvroSchema, namespace: str | None = None) -> JtdSchema:
234
+ if isinstance(avro_type, str):
235
+ if avro_type == "null":
236
+ return {"nullable": True}
237
+ if avro_type in self.PRIMITIVE_TO_JTD:
238
+ return {"type": self.PRIMITIVE_TO_JTD[avro_type]}
239
+ ref_key = self._resolve_named_key(avro_type, namespace)
240
+ if ref_key:
241
+ self._ensure_definition(ref_key)
242
+ return {"ref": ref_key}
243
+ return {"type": "string"}
244
+ if isinstance(avro_type, list):
245
+ return self._convert_union(avro_type, namespace)
246
+ if not isinstance(avro_type, dict):
247
+ return {"type": "string"}
248
+
249
+ if avro_type.get("jtdWrappedDefinition"):
250
+ fields = avro_type.get("fields", [])
251
+ if len(fields) == 1:
252
+ return self._convert_type(fields[0].get("type", "string"), namespace)
253
+ if avro_type.get("jtdType"):
254
+ return {"type": avro_type["jtdType"]}
255
+ if avro_type.get("logicalType") == "timestamp-millis" and avro_type.get("type") == "long":
256
+ return {"type": "timestamp"}
257
+
258
+ schema_type = avro_type.get("type")
259
+ if isinstance(schema_type, dict) or isinstance(schema_type, list):
260
+ return self._convert_type(schema_type, namespace)
261
+ if schema_type in self.PRIMITIVE_TO_JTD:
262
+ return {"type": self.PRIMITIVE_TO_JTD[schema_type]}
263
+ if schema_type == "record":
264
+ key = self._type_key(avro_type)
265
+ if id(avro_type) in self.inline_named_schema_ids and key != self.root_key:
266
+ self._ensure_definition(key)
267
+ return {"ref": key}
268
+ return self._convert_record(avro_type, namespace)
269
+ if schema_type == "enum":
270
+ key = self._type_key(avro_type)
271
+ if id(avro_type) in self.inline_named_schema_ids and key != self.root_key:
272
+ self._ensure_definition(key)
273
+ return {"ref": key}
274
+ return self._convert_enum(avro_type)
275
+ if schema_type == "array":
276
+ return {"elements": self._convert_type(avro_type.get("items", "string"), namespace)}
277
+ if schema_type == "map":
278
+ return {"values": self._convert_type(avro_type.get("values", "string"), namespace)}
279
+ if isinstance(schema_type, str):
280
+ ref_key = self._resolve_named_key(schema_type, namespace)
281
+ if ref_key:
282
+ self._ensure_definition(ref_key)
283
+ return {"ref": ref_key}
284
+ return {"type": "string"}
285
+
286
+ def _convert_record(self, schema: dict[str, Any], parent_namespace: str | None = None) -> JtdSchema:
287
+ key = self._type_key(schema)
288
+ if key in self.converting:
289
+ return {"ref": key}
290
+ namespace = self._schema_namespace(schema, parent_namespace)
291
+ self.converting.add(key)
292
+ properties: dict[str, JtdSchema] = {}
293
+ optional_properties: dict[str, JtdSchema] = {}
294
+ for field in schema.get("fields", []):
295
+ if field.get("name") == schema.get("jtdDiscriminator"):
296
+ continue
297
+ property_name = altname(field, "jtd")
298
+ field_nullable, field_type = self._strip_nullable(field.get("type", "string"))
299
+ converted = self._convert_type(field_type, namespace)
300
+ if field_nullable and field.get("default") is None:
301
+ optional_properties[property_name] = converted
302
+ else:
303
+ if field_nullable:
304
+ converted = dict(converted)
305
+ converted["nullable"] = True
306
+ properties[property_name] = converted
307
+ self.converting.remove(key)
308
+ result: JtdSchema = {}
309
+ if properties:
310
+ result["properties"] = properties
311
+ if optional_properties:
312
+ result["optionalProperties"] = optional_properties
313
+ if "jtdAdditionalProperties" in schema:
314
+ result["additionalProperties"] = bool(schema["jtdAdditionalProperties"])
315
+ return result
316
+
317
+ @staticmethod
318
+ def _convert_enum(schema: dict[str, Any]) -> JtdSchema:
319
+ original_symbols = schema.get("jtdEnumSymbols", {})
320
+ return {"enum": [original_symbols.get(symbol, symbol) for symbol in schema.get("symbols", [])]}
321
+
322
+ def _convert_union(self, union: list[Any], namespace: str | None = None) -> JtdSchema:
323
+ non_null = [item for item in union if item != "null"]
324
+ if len(non_null) == 1:
325
+ return self._convert_type(non_null[0], namespace)
326
+ if self._is_discriminator_union(non_null):
327
+ return self._convert_discriminator(non_null, namespace)
328
+ discriminator_branches = [
329
+ item for item in non_null
330
+ if isinstance(item, dict) and item.get("type") == "record" and item.get("jtdDiscriminator")
331
+ ]
332
+ if self._is_discriminator_union(discriminator_branches):
333
+ tagged_ids = {id(branch) for branch in discriminator_branches}
334
+ converted: list[JtdSchema] = []
335
+ discriminator_added = False
336
+ for item in non_null:
337
+ if id(item) in tagged_ids:
338
+ if not discriminator_added:
339
+ converted.append(self._convert_discriminator(discriminator_branches, namespace))
340
+ discriminator_added = True
341
+ else:
342
+ converted.append(self._convert_type(item, namespace))
343
+ return {"metadata": {"avrotize-union": converted}}
344
+ return {"metadata": {"avrotize-union": [self._convert_type(item, namespace) for item in non_null]}}
345
+
346
+ def _convert_discriminator(self, union: list[Any], namespace: str | None = None) -> JtdSchema:
347
+ discriminator = union[0].get("jtdDiscriminator")
348
+ mapping: dict[str, JtdSchema] = {}
349
+ for branch in union:
350
+ tag = branch.get("jtdMappingKey")
351
+ if tag is None:
352
+ tag = branch.get("name")
353
+ mapping[str(tag)] = self._convert_record(branch, namespace)
354
+ return {"discriminator": discriminator, "mapping": mapping}
355
+
356
+ @staticmethod
357
+ def _strip_nullable(avro_type: AvroSchema) -> tuple[bool, AvroSchema]:
358
+ if isinstance(avro_type, list) and "null" in avro_type:
359
+ non_null = [item for item in avro_type if item != "null"]
360
+ if len(non_null) == 1:
361
+ return True, non_null[0]
362
+ return True, non_null
363
+ return False, avro_type
364
+
365
+ @staticmethod
366
+ def _is_discriminator_union(avro_type: AvroSchema) -> bool:
367
+ return (
368
+ isinstance(avro_type, list)
369
+ and bool(avro_type)
370
+ and all(isinstance(item, dict) and item.get("type") == "record" and item.get("jtdDiscriminator") for item in avro_type)
371
+ and len({item.get("jtdDiscriminator") for item in avro_type if isinstance(item, dict)}) == 1
372
+ )
373
+
374
+ def _ensure_definition(self, key: str) -> None:
375
+ if key in self.definitions:
376
+ return
377
+ if key not in self.named_types:
378
+ return
379
+ if key in self.converting:
380
+ self.definitions[key] = {"ref": key}
381
+ return
382
+ self.definitions[key] = {}
383
+ try:
384
+ schema = self.named_types[key]
385
+ namespace = self._schema_namespace(schema)
386
+ if id(schema) in self.inline_named_schema_ids and schema.get("type") == "record":
387
+ self.definitions[key] = self._convert_record(schema, namespace)
388
+ elif id(schema) in self.inline_named_schema_ids and schema.get("type") == "enum":
389
+ self.definitions[key] = self._convert_enum(schema)
390
+ else:
391
+ self.definitions[key] = self._convert_non_nullable(schema, namespace)
392
+ except Exception:
393
+ self.definitions.pop(key, None)
394
+ raise
395
+
396
+
397
+ def convert_avro_to_jtd(avro_schema_path: str, jtd_file_path: str, record_type: str | None = None) -> None:
398
+ """Convert an Avrotize Schema file to a JSON Type Definition file."""
399
+ with open(avro_schema_path, "r", encoding="utf-8") as avro_file:
400
+ avro_schema = json.load(avro_file)
401
+ converter = AvroToJtdConverter(record_type=record_type)
402
+ jtd_schema = converter.convert(avro_schema)
403
+ with open(jtd_file_path, "w", encoding="utf-8") as jtd_file:
404
+ json.dump(jtd_schema, jtd_file, indent=2)