structurize 3.5.10__tar.gz → 3.5.12__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 (93) hide show
  1. {structurize-3.5.10/structurize.egg-info → structurize-3.5.12}/PKG-INFO +1 -1
  2. {structurize-3.5.10 → structurize-3.5.12}/avrotize/_version.py +3 -3
  3. {structurize-3.5.10 → structurize-3.5.12}/avrotize/avrotojsons.py +16 -4
  4. {structurize-3.5.10 → structurize-3.5.12}/avrotize/avrotojstruct.py +10 -0
  5. {structurize-3.5.10 → structurize-3.5.12}/avrotize/avrotokusto.py +13 -3
  6. {structurize-3.5.10 → structurize-3.5.12}/avrotize/avrotopython.py +38 -3
  7. {structurize-3.5.10 → structurize-3.5.12}/avrotize/avrotots.py +21 -5
  8. {structurize-3.5.10 → structurize-3.5.12}/avrotize/avrovalidator.py +27 -0
  9. {structurize-3.5.10 → structurize-3.5.12}/avrotize/jsonstoavro.py +16 -6
  10. {structurize-3.5.10 → structurize-3.5.12}/avrotize/jstructtoavro.py +111 -30
  11. {structurize-3.5.10 → structurize-3.5.12}/avrotize/structuretokusto.py +14 -4
  12. {structurize-3.5.10 → structurize-3.5.12}/avrotize/structuretopython.py +64 -7
  13. {structurize-3.5.10 → structurize-3.5.12/structurize.egg-info}/PKG-INFO +1 -1
  14. {structurize-3.5.10 → structurize-3.5.12}/.gitignore +0 -0
  15. {structurize-3.5.10 → structurize-3.5.12}/LICENSE +0 -0
  16. {structurize-3.5.10 → structurize-3.5.12}/MANIFEST.in +0 -0
  17. {structurize-3.5.10 → structurize-3.5.12}/README.md +0 -0
  18. {structurize-3.5.10 → structurize-3.5.12}/avrotize/__init__.py +0 -0
  19. {structurize-3.5.10 → structurize-3.5.12}/avrotize/__main__.py +0 -0
  20. {structurize-3.5.10 → structurize-3.5.12}/avrotize/asn1toavro.py +0 -0
  21. {structurize-3.5.10 → structurize-3.5.12}/avrotize/avrotize.py +0 -0
  22. {structurize-3.5.10 → structurize-3.5.12}/avrotize/avrotocpp.py +0 -0
  23. {structurize-3.5.10 → structurize-3.5.12}/avrotize/avrotocsharp.py +0 -0
  24. {structurize-3.5.10 → structurize-3.5.12}/avrotize/avrotocsv.py +0 -0
  25. {structurize-3.5.10 → structurize-3.5.12}/avrotize/avrotodatapackage.py +0 -0
  26. {structurize-3.5.10 → structurize-3.5.12}/avrotize/avrotodb.py +0 -0
  27. {structurize-3.5.10 → structurize-3.5.12}/avrotize/avrotogo.py +0 -0
  28. {structurize-3.5.10 → structurize-3.5.12}/avrotize/avrotographql.py +0 -0
  29. {structurize-3.5.10 → structurize-3.5.12}/avrotize/avrotoiceberg.py +0 -0
  30. {structurize-3.5.10 → structurize-3.5.12}/avrotize/avrotojava.py +0 -0
  31. {structurize-3.5.10 → structurize-3.5.12}/avrotize/avrotojs.py +0 -0
  32. {structurize-3.5.10 → structurize-3.5.12}/avrotize/avrotomd.py +0 -0
  33. {structurize-3.5.10 → structurize-3.5.12}/avrotize/avrotools.py +0 -0
  34. {structurize-3.5.10 → structurize-3.5.12}/avrotize/avrotoparquet.py +0 -0
  35. {structurize-3.5.10 → structurize-3.5.12}/avrotize/avrotoproto.py +0 -0
  36. {structurize-3.5.10 → structurize-3.5.12}/avrotize/avrotorust.py +0 -0
  37. {structurize-3.5.10 → structurize-3.5.12}/avrotize/avrototsml.py +0 -0
  38. {structurize-3.5.10 → structurize-3.5.12}/avrotize/avrotoxsd.py +0 -0
  39. {structurize-3.5.10 → structurize-3.5.12}/avrotize/cddltostructure.py +0 -0
  40. {structurize-3.5.10 → structurize-3.5.12}/avrotize/choice_inference.py +0 -0
  41. {structurize-3.5.10 → structurize-3.5.12}/avrotize/commands.json +0 -0
  42. {structurize-3.5.10 → structurize-3.5.12}/avrotize/common.py +0 -0
  43. {structurize-3.5.10 → structurize-3.5.12}/avrotize/constants.py +0 -0
  44. {structurize-3.5.10 → structurize-3.5.12}/avrotize/csvtoavro.py +0 -0
  45. {structurize-3.5.10 → structurize-3.5.12}/avrotize/datapackagetoavro.py +0 -0
  46. {structurize-3.5.10 → structurize-3.5.12}/avrotize/dependencies/cpp/vcpkg/vcpkg.json +0 -0
  47. {structurize-3.5.10 → structurize-3.5.12}/avrotize/dependencies/typescript/node22/package.json +0 -0
  48. {structurize-3.5.10 → structurize-3.5.12}/avrotize/dependency_resolver.py +0 -0
  49. {structurize-3.5.10 → structurize-3.5.12}/avrotize/dependency_version.py +0 -0
  50. {structurize-3.5.10 → structurize-3.5.12}/avrotize/jsonstostructure.py +0 -0
  51. {structurize-3.5.10 → structurize-3.5.12}/avrotize/jsontoschema.py +0 -0
  52. {structurize-3.5.10 → structurize-3.5.12}/avrotize/kstructtoavro.py +0 -0
  53. {structurize-3.5.10 → structurize-3.5.12}/avrotize/kustotoavro.py +0 -0
  54. {structurize-3.5.10 → structurize-3.5.12}/avrotize/kustotojstruct.py +0 -0
  55. {structurize-3.5.10 → structurize-3.5.12}/avrotize/mcp_server.py +0 -0
  56. {structurize-3.5.10 → structurize-3.5.12}/avrotize/openapitostructure.py +0 -0
  57. {structurize-3.5.10 → structurize-3.5.12}/avrotize/parquettoavro.py +0 -0
  58. {structurize-3.5.10 → structurize-3.5.12}/avrotize/proto2parser.py +0 -0
  59. {structurize-3.5.10 → structurize-3.5.12}/avrotize/proto3parser.py +0 -0
  60. {structurize-3.5.10 → structurize-3.5.12}/avrotize/prototoavro.py +0 -0
  61. {structurize-3.5.10 → structurize-3.5.12}/avrotize/schema_inference.py +0 -0
  62. {structurize-3.5.10 → structurize-3.5.12}/avrotize/sqltoavro.py +0 -0
  63. {structurize-3.5.10 → structurize-3.5.12}/avrotize/structuretocddl.py +0 -0
  64. {structurize-3.5.10 → structurize-3.5.12}/avrotize/structuretocpp.py +0 -0
  65. {structurize-3.5.10 → structurize-3.5.12}/avrotize/structuretocsharp.py +0 -0
  66. {structurize-3.5.10 → structurize-3.5.12}/avrotize/structuretocsv.py +0 -0
  67. {structurize-3.5.10 → structurize-3.5.12}/avrotize/structuretodatapackage.py +0 -0
  68. {structurize-3.5.10 → structurize-3.5.12}/avrotize/structuretodb.py +0 -0
  69. {structurize-3.5.10 → structurize-3.5.12}/avrotize/structuretogo.py +0 -0
  70. {structurize-3.5.10 → structurize-3.5.12}/avrotize/structuretographql.py +0 -0
  71. {structurize-3.5.10 → structurize-3.5.12}/avrotize/structuretoiceberg.py +0 -0
  72. {structurize-3.5.10 → structurize-3.5.12}/avrotize/structuretojava.py +0 -0
  73. {structurize-3.5.10 → structurize-3.5.12}/avrotize/structuretojs.py +0 -0
  74. {structurize-3.5.10 → structurize-3.5.12}/avrotize/structuretojsons.py +0 -0
  75. {structurize-3.5.10 → structurize-3.5.12}/avrotize/structuretomd.py +0 -0
  76. {structurize-3.5.10 → structurize-3.5.12}/avrotize/structuretoproto.py +0 -0
  77. {structurize-3.5.10 → structurize-3.5.12}/avrotize/structuretorust.py +0 -0
  78. {structurize-3.5.10 → structurize-3.5.12}/avrotize/structuretots.py +0 -0
  79. {structurize-3.5.10 → structurize-3.5.12}/avrotize/structuretotsml.py +0 -0
  80. {structurize-3.5.10 → structurize-3.5.12}/avrotize/structuretoxsd.py +0 -0
  81. {structurize-3.5.10 → structurize-3.5.12}/avrotize/tmslvalidate.py +0 -0
  82. {structurize-3.5.10 → structurize-3.5.12}/avrotize/validate.py +0 -0
  83. {structurize-3.5.10 → structurize-3.5.12}/avrotize/xmltoschema.py +0 -0
  84. {structurize-3.5.10 → structurize-3.5.12}/avrotize/xsdtoavro.py +0 -0
  85. {structurize-3.5.10 → structurize-3.5.12}/build.ps1 +0 -0
  86. {structurize-3.5.10 → structurize-3.5.12}/build.sh +0 -0
  87. {structurize-3.5.10 → structurize-3.5.12}/pyproject.toml +0 -0
  88. {structurize-3.5.10 → structurize-3.5.12}/setup.cfg +0 -0
  89. {structurize-3.5.10 → structurize-3.5.12}/structurize.egg-info/SOURCES.txt +0 -0
  90. {structurize-3.5.10 → structurize-3.5.12}/structurize.egg-info/dependency_links.txt +0 -0
  91. {structurize-3.5.10 → structurize-3.5.12}/structurize.egg-info/entry_points.txt +0 -0
  92. {structurize-3.5.10 → structurize-3.5.12}/structurize.egg-info/requires.txt +0 -0
  93. {structurize-3.5.10 → structurize-3.5.12}/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.5.10
3
+ Version: 3.5.12
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.5.10'
22
- __version_tuple__ = version_tuple = (3, 5, 10)
21
+ __version__ = version = '3.5.12'
22
+ __version_tuple__ = version_tuple = (3, 5, 12)
23
23
 
24
- __commit_id__ = commit_id = 'g974039ac5'
24
+ __commit_id__ = commit_id = 'g0eb0a7b9d'
@@ -98,22 +98,34 @@ class AvroToJsonSchemaConverter:
98
98
  json_type['type'] = 'integer'
99
99
  json_type['format'] = 'int64'
100
100
  return json_type
101
- # Avrotize schema extensions: string-based logical types
102
- elif base_type == 'string' and logical_type == 'date':
101
+ # Avrotize schema extensions: string-based logical types.
102
+ # The rfc3339-* family is the current emission; the bare reserved
103
+ # names on a string base are still recognized for backward compat.
104
+ elif base_type == 'string' and logical_type in ['date', 'rfc3339-date']:
103
105
  # Avrotize extension: string with date logicalType
104
106
  json_type['type'] = 'string'
105
107
  json_type['format'] = 'date'
106
108
  return json_type
107
- elif base_type == 'string' and logical_type in ['timestamp-millis', 'timestamp-micros', 'datetime']:
109
+ elif base_type == 'string' and logical_type in [
110
+ 'timestamp-millis', 'timestamp-micros', 'datetime',
111
+ 'rfc3339-timestamp-millis', 'rfc3339-timestamp-micros',
112
+ 'rfc3339-local-timestamp-millis', 'rfc3339-local-timestamp-micros']:
108
113
  # Avrotize extension: string with datetime logicalType
109
114
  json_type['type'] = 'string'
110
115
  json_type['format'] = 'date-time'
111
116
  return json_type
112
- elif base_type == 'string' and logical_type in ['time-millis', 'time-micros', 'time']:
117
+ elif base_type == 'string' and logical_type in [
118
+ 'time-millis', 'time-micros', 'time',
119
+ 'rfc3339-time-millis', 'rfc3339-time-micros']:
113
120
  # Avrotize extension: string with time logicalType
114
121
  json_type['type'] = 'string'
115
122
  json_type['format'] = 'time'
116
123
  return json_type
124
+ elif base_type == 'string' and logical_type in ['duration', 'rfc3339-duration']:
125
+ # Avrotize extension: string with duration logicalType (RFC 3339)
126
+ json_type['type'] = 'string'
127
+ json_type['format'] = 'duration'
128
+ return json_type
117
129
  elif logical_type == 'decimal':
118
130
  json_type['type'] = 'number'
119
131
  return json_type
@@ -305,6 +305,16 @@ class AvroToJsonStructure:
305
305
  "timestamp-millis": {"type": "int64", "logicalType": "timestampMillis"},
306
306
  "date": {"type": "int32", "logicalType": "date"},
307
307
  "uuid": {"type": "string", "format": "uuid"},
308
+ # Avrotize Schema rfc3339-* string temporal family -> JSON Structure native
309
+ # temporal types (round-trips with jstructtoavro). See issue #335.
310
+ "rfc3339-date": {"type": "date"},
311
+ "rfc3339-time-millis": {"type": "time"},
312
+ "rfc3339-time-micros": {"type": "time"},
313
+ "rfc3339-timestamp-millis": {"type": "datetime"},
314
+ "rfc3339-timestamp-micros": {"type": "datetime"},
315
+ "rfc3339-local-timestamp-millis": {"type": "datetime"},
316
+ "rfc3339-local-timestamp-micros": {"type": "datetime"},
317
+ "rfc3339-duration": {"type": "duration"},
308
318
  }
309
319
  return mapping.get(logical_type, {"type": "string"})
310
320
 
@@ -7,6 +7,16 @@ from avrotize.common import build_flat_type_dict, inline_avro_references, strip_
7
7
  from azure.kusto.data import KustoClient, KustoConnectionStringBuilder, ClientRequestProperties
8
8
 
9
9
 
10
+ def kusto_string_literal(value: str) -> str:
11
+ """Return a KQL verbatim string literal for a raw Python string."""
12
+ return '@"' + value.replace('"', '""') + '"'
13
+
14
+
15
+ def kusto_json_literal(value: Any) -> str:
16
+ """Return a KQL string literal containing one JSON encoding of value."""
17
+ return kusto_string_literal(json.dumps(value))
18
+
19
+
10
20
  class AvroToKusto:
11
21
  """Converts an Avro schema to a Kusto table schema."""
12
22
 
@@ -60,9 +70,9 @@ class AvroToKusto:
60
70
  if "doc" in recordschema:
61
71
  doc_data = recordschema["doc"]
62
72
  doc_data = (doc_data[:997] + "...") if len(doc_data) > 1000 else doc_data
63
- doc_string = json.dumps(json.dumps({
73
+ doc_string = kusto_json_literal({
64
74
  "description": doc_data
65
- }))
75
+ })
66
76
  kusto.append(
67
77
  f".alter table {table_ref} docstring {doc_string};")
68
78
  kusto.append("")
@@ -89,7 +99,7 @@ class AvroToKusto:
89
99
  doc_content["schema"] = inline_schema
90
100
  else:
91
101
  doc_content["schema"] = inline_schema
92
- doc = json.dumps(json.dumps(doc_content))
102
+ doc = kusto_json_literal(doc_content)
93
103
  doc_string_statement.append(f" [{column_name}]: {doc}")
94
104
  if doc_string_statement and emit_cloudevents_columns:
95
105
  doc_string_statement.extend([
@@ -90,6 +90,38 @@ class AvroToPython:
90
90
  return name + "_"
91
91
  return name
92
92
 
93
+ def safe_enum_symbols(self, symbols: List[str]) -> List[Dict[str, str]]:
94
+ """Converts Avro enum symbols to collision-safe Python enum members."""
95
+ enum_symbols = []
96
+ used_names = set()
97
+
98
+ for symbol in symbols:
99
+ if symbol.isidentifier() and not is_python_reserved_word(symbol):
100
+ member_name = symbol
101
+ else:
102
+ member_name = re.sub(r'[^0-9A-Za-z_]', '_', symbol).strip('_').upper()
103
+ if not member_name:
104
+ member_name = "VALUE"
105
+ if member_name[0].isdigit():
106
+ member_name = f"VALUE_{member_name}"
107
+ if is_python_reserved_word(symbol) or is_python_reserved_word(member_name.lower()) or is_python_reserved_word(member_name):
108
+ member_name = f"{member_name}_"
109
+
110
+ base_member_name = member_name
111
+ suffix = 2
112
+ while member_name in used_names or not member_name.isidentifier() or is_python_reserved_word(member_name):
113
+ member_name = f"{base_member_name}_{suffix}"
114
+ suffix += 1
115
+
116
+ used_names.add(member_name)
117
+ enum_symbols.append({
118
+ 'name': member_name,
119
+ 'value': symbol,
120
+ 'value_literal': repr(symbol)
121
+ })
122
+
123
+ return enum_symbols
124
+
93
125
  def pascal_type_name(self, ref: str) -> str:
94
126
  """Converts a reference to a type name"""
95
127
  return '_'.join([pascal(part) for part in ref.split('.')[-1].split('_')])
@@ -350,9 +382,12 @@ class AvroToPython:
350
382
  if python_qualified_name in self.generated_types:
351
383
  return python_qualified_name
352
384
 
353
- symbols = [symbol if not is_python_reserved_word(
354
- symbol) else symbol + "_" for symbol in avro_schema.get('symbols', [])]
355
- ordinals = avro_schema.get('ordinals', {})
385
+ symbols = self.safe_enum_symbols(avro_schema.get('symbols', []))
386
+ symbol_names_by_value = {symbol['value']: symbol['name'] for symbol in symbols}
387
+ ordinals = {
388
+ symbol_names_by_value[symbol]: ordinal
389
+ for symbol, ordinal in avro_schema.get('ordinals', {}).items()
390
+ }
356
391
 
357
392
  enum_definition = process_template(
358
393
  "avrotopython/enum_core.jinja",
@@ -16,7 +16,6 @@ def is_typescript_reserved_word(word: str) -> bool:
16
16
  'for', 'function', 'if', 'import', 'in', 'instanceof', 'new', 'return',
17
17
  'super', 'switch', 'this', 'throw', 'try', 'typeof', 'var', 'void',
18
18
  'while', 'with', 'yield', 'enum', 'string', 'number', 'boolean', 'symbol',
19
- 'type', 'namespace', 'module', 'declare', 'abstract', 'readonly',
20
19
  ]
21
20
  return word in reserved_words
22
21
 
@@ -32,6 +31,7 @@ class AvroToTypeScript:
32
31
  self.src_dir = os.path.join(self.output_dir, "src")
33
32
  self.generated_types: Dict[str, str] = {}
34
33
  self.generated_type_fields: Dict[str, List[Dict]] = {} # Store fields for test generation
34
+ self.generated_type_imports: Dict[str, List[str]] = {} # Store import type FQNs for test generation
35
35
  self.main_schema = None
36
36
  self.type_dict = None
37
37
  self.INDENT = ' ' * 4
@@ -207,6 +207,10 @@ class AvroToTypeScript:
207
207
  self.write_to_file(namespace, class_name, class_definition)
208
208
  self.generated_types[ts_qualified_name] = 'class'
209
209
  self.generated_type_fields[ts_qualified_name] = fields # Store fields for test generation
210
+ # Store the fully-qualified import names so generated test files can resolve
211
+ # sub-namespaced types (e.g. enums placed in a "<Record>_types" namespace)
212
+ # to their real source path instead of a flattened, non-existent one.
213
+ self.generated_type_imports[ts_qualified_name] = sorted(import_types)
210
214
  return ts_qualified_name
211
215
 
212
216
  def generate_enum(self, avro_schema: Dict, parent_namespace: str, write_file: bool = True) -> str:
@@ -670,17 +674,29 @@ class AvroToTypeScript:
670
674
 
671
675
  fields = self.generated_type_fields.get(qualified_name, [])
672
676
 
673
- # Build imports for nested types
677
+ # Build imports for nested types. Reuse the fully-qualified import names
678
+ # captured during class generation so that types living in sub-namespaces
679
+ # (e.g. enums placed in a "<Record>_types" namespace) resolve to their real
680
+ # source path instead of a flattened, non-existent one (issue #338).
674
681
  imports_with_paths: Dict[str, str] = {}
682
+ import_fqns = self.generated_type_imports.get(qualified_name, [])
683
+ path_by_leaf: Dict[str, str] = {}
684
+ for fqn in import_fqns:
685
+ fqn_parts = fqn.split('.')
686
+ leaf = pascal(fqn_parts[-1])
687
+ path_by_leaf[leaf] = '../src/' + '/'.join(fqn_parts) + '.js'
675
688
  for field in fields:
676
689
  field_type = field.get('type_no_null', '')
677
690
  if not self.is_typescript_primitive(field_type.replace('[]', '')):
678
691
  # It's a reference type, need to import it
679
692
  type_name = field_type.replace('[]', '').replace('?', '')
680
693
  if type_name and type_name not in ['null', 'any', 'Date']:
681
- # Build relative path from test dir to src dir
682
- src_path = '/'.join(parts)
683
- imports_with_paths[type_name] = f'../src/{src_path.rsplit("/", 1)[0]}/{type_name}.js' if '/' in src_path else f'../src/{parts[0]}/{type_name}.js'
694
+ if type_name in path_by_leaf:
695
+ imports_with_paths[type_name] = path_by_leaf[type_name]
696
+ else:
697
+ # Fallback: best-effort path relative to the class namespace
698
+ src_path = '/'.join(parts)
699
+ imports_with_paths[type_name] = f'../src/{src_path.rsplit("/", 1)[0]}/{type_name}.js' if '/' in src_path else f'../src/{parts[0]}/{type_name}.js'
684
700
 
685
701
  # Calculate relative path from test directory to class file
686
702
  class_path_parts = namespace.split('.') if namespace else []
@@ -414,6 +414,33 @@ class AvroValidator:
414
414
  """
415
415
  base_type = schema.get('type')
416
416
 
417
+ # Avrotize Schema rfc3339-* string temporal family (issue #335). These are
418
+ # valid standard Avro because rfc3339-* is not a reserved Avro logical type;
419
+ # the value is an RFC 3339 textual string on a string base.
420
+ if isinstance(logical_type, str) and logical_type.startswith('rfc3339-'):
421
+ if base_type != 'string':
422
+ raise AvroValidationError(
423
+ f"{logical_type} logical type requires string base type", path)
424
+ if not isinstance(instance, str):
425
+ raise AvroValidationError(
426
+ f"Expected string for {logical_type}, got {type(instance).__name__}", path)
427
+ core = logical_type[len('rfc3339-'):]
428
+ if core == 'date':
429
+ pattern = self.DATE_PATTERN
430
+ elif core in ('time-millis', 'time-micros'):
431
+ pattern = self.TIME_PATTERN
432
+ elif core in ('timestamp-millis', 'timestamp-micros',
433
+ 'local-timestamp-millis', 'local-timestamp-micros'):
434
+ pattern = self.DATETIME_PATTERN
435
+ elif core == 'duration':
436
+ pattern = self.DURATION_PATTERN
437
+ else:
438
+ pattern = None
439
+ if pattern is not None and not pattern.match(instance):
440
+ raise AvroValidationError(
441
+ f"Invalid {logical_type} format: {instance}", path)
442
+ return
443
+
417
444
  if logical_type == 'decimal':
418
445
  self._validate_decimal(instance, schema, path)
419
446
 
@@ -591,14 +591,16 @@ class JsonToAvroConverter:
591
591
 
592
592
  # if you've got { 'type': 'string', 'format': ['date-time', 'duration'] }, I'm sorry
593
593
  if format and isinstance(format, str):
594
- if format in ('date-time', 'date'):
594
+ if format == 'date-time':
595
+ avro_primitive = {'type': 'long', 'logicalType': 'timestamp-millis'}
596
+ elif format == 'date':
595
597
  avro_primitive = {'type': 'int', 'logicalType': 'date'}
596
- elif format in ('time'):
598
+ elif format == 'time':
597
599
  avro_primitive = {'type': 'int', 'logicalType': 'time-millis'}
598
- elif format in ('duration'):
600
+ elif format == 'duration':
599
601
  avro_primitive = {'type': 'fixed',
600
602
  'size': 12, 'logicalType': 'duration'}
601
- elif format in ('uuid'):
603
+ elif format == 'uuid':
602
604
  avro_primitive = {'type': 'string', 'logicalType': 'uuid'}
603
605
 
604
606
  return avro_primitive
@@ -1357,7 +1359,14 @@ class JsonToAvroConverter:
1357
1359
  if isinstance(avro_type, dict) and 'type' in avro_type and (isinstance(avro_type, list) or not avro_type['type'] in ['array', 'map', 'record', 'enum', 'fixed']):
1358
1360
  if 'dependencies' in avro_type:
1359
1361
  dependencies.extend(avro_type['dependencies'])
1360
- avro_type = avro_type['type']
1362
+ # Preserve logical-type annotations (e.g. timestamp-millis, date,
1363
+ # time-millis, uuid). Collapsing these to the bare base type would
1364
+ # silently discard the logical meaning (and, for date-time, the
1365
+ # required 64-bit width). See issue #337.
1366
+ if 'logicalType' in avro_type:
1367
+ avro_type = {k: v for k, v in avro_type.items() if k != 'dependencies'}
1368
+ else:
1369
+ avro_type = avro_type['type']
1361
1370
  return avro_type
1362
1371
 
1363
1372
  def register_type(self, avro_schema, avro_type) -> bool:
@@ -1733,8 +1742,9 @@ class JsonToAvroConverter:
1733
1742
  # None type is a problem
1734
1743
  raise ValueError(
1735
1744
  f"avro_field_type is None for field {field_name}")
1736
- if isinstance(avro_field_type, dict) and 'type' in avro_field_type and not self.is_avro_complex_type(avro_field_type):
1745
+ if isinstance(avro_field_type, dict) and 'type' in avro_field_type and 'logicalType' not in avro_field_type and not self.is_avro_complex_type(avro_field_type):
1737
1746
  # if the field type is a basic type, inline it
1747
+ # (logical types are kept intact, see issue #337)
1738
1748
  avro_field_type = avro_field_type['type']
1739
1749
  field_type_list.append(avro_field_type)
1740
1750
  field_ref_type_list.append(avro_field_ref_type)
@@ -20,6 +20,8 @@ class JsonStructureToAvro:
20
20
  """Initialize the converter."""
21
21
  self.structure_doc: Optional[Dict[str, Any]] = None
22
22
  self.converted_types: Dict[str, Dict[str, Any]] = {}
23
+ self._anon_object_counter: int = 0
24
+ self._generated_record_names: set = set()
23
25
 
24
26
  def convert(self, structure_schema: Dict[str, Any]) -> Union[Dict[str, Any], List[Dict[str, Any]]]:
25
27
  """
@@ -33,6 +35,8 @@ class JsonStructureToAvro:
33
35
  """
34
36
  self.structure_doc = structure_schema
35
37
  self.converted_types.clear()
38
+ self._anon_object_counter = 0
39
+ self._generated_record_names = set()
36
40
 
37
41
  # Check if this is an inline type (type at root) or uses $root
38
42
  root_ref = structure_schema.get('$root')
@@ -386,7 +390,7 @@ class JsonStructureToAvro:
386
390
  for prop_name, prop_schema in properties.items():
387
391
  field = {
388
392
  'name': prop_name,
389
- 'type': self._convert_type_reference(prop_schema)
393
+ 'type': self._convert_type_reference(prop_schema, name_hint=prop_name)
390
394
  }
391
395
 
392
396
  # Build documentation with annotations
@@ -678,13 +682,55 @@ class JsonStructureToAvro:
678
682
 
679
683
  return avro_record
680
684
 
681
- def _convert_type_reference(self, schema: Union[Dict[str, Any], str]) -> Union[str, Dict[str, Any], List]:
685
+ def _convert_union_member(self, member: Union[Dict[str, Any], str]) -> Union[str, Dict[str, Any], List]:
686
+ """Convert a single member of a JSON Structure union type.
687
+
688
+ Members are usually primitive type names, but a union may also list the
689
+ loose container names ``"object"`` and ``"array"`` (open, untyped). A
690
+ bare ``"object"``/``"array"`` is not a valid Avro type, so map them to
691
+ their canonical open Avro forms (string-valued map / string-element
692
+ array), consistent with how inline open objects are handled (#336).
693
+ """
694
+ if isinstance(member, str):
695
+ if member == 'object':
696
+ return {'type': 'map', 'values': 'string'}
697
+ if member == 'array':
698
+ return {'type': 'array', 'items': 'string'}
699
+ return self._map_primitive_type(member)
700
+ return self._convert_type_reference(member)
701
+
702
+ def _anonymous_object_name(self, name_hint: Optional[str]) -> str:
703
+ """Generate a valid, unique Avro record name for an inline object.
704
+
705
+ Prefers a name derived from the enclosing property (``name_hint``) so
706
+ that output is readable and round-trips sensibly; falls back to a
707
+ monotonically increasing generated name. A uniqueness suffix is added
708
+ when the same hint occurs more than once (e.g. like-named properties at
709
+ different nesting levels) to avoid Avro name redefinition errors.
710
+ """
711
+ if name_hint:
712
+ candidate = f"{name_hint[0].upper() + name_hint[1:]}Type"
713
+ else:
714
+ self._anon_object_counter += 1
715
+ candidate = f"AnonymousObject{self._anon_object_counter}"
716
+ unique = candidate
717
+ suffix = 2
718
+ while unique in self._generated_record_names:
719
+ unique = f"{candidate}_{suffix}"
720
+ suffix += 1
721
+ self._generated_record_names.add(unique)
722
+ return unique
723
+
724
+ def _convert_type_reference(self, schema: Union[Dict[str, Any], str], name_hint: Optional[str] = None) -> Union[str, Dict[str, Any], List]:
682
725
  """
683
726
  Convert a type reference or inline type definition.
684
727
 
685
728
  Args:
686
729
  schema: Type schema or reference
687
-
730
+ name_hint: Optional name to use when an inline object must be
731
+ materialized as a named Avro record (e.g. the enclosing
732
+ property name). Falls back to a generated unique name.
733
+
688
734
  Returns:
689
735
  Avro type (string, dict, or list for union)
690
736
  """
@@ -712,7 +758,7 @@ class JsonStructureToAvro:
712
758
 
713
759
  # Handle union types (type is an array like ["string", "null"])
714
760
  if isinstance(type_value, list):
715
- return [self._map_primitive_type(t) if isinstance(t, str) else self._convert_type_reference(t) for t in type_value]
761
+ return [self._convert_union_member(t) for t in type_value]
716
762
 
717
763
  # Handle choice types
718
764
  if type_value == 'choice':
@@ -744,7 +790,7 @@ class JsonStructureToAvro:
744
790
  if type_value == 'array':
745
791
  return {
746
792
  'type': 'array',
747
- 'items': self._convert_type_reference(schema['items'])
793
+ 'items': self._convert_type_reference(schema['items'], name_hint=name_hint)
748
794
  }
749
795
 
750
796
  if type_value == 'map':
@@ -753,6 +799,31 @@ class JsonStructureToAvro:
753
799
  'values': self._convert_type_reference(schema['values'])
754
800
  }
755
801
 
802
+ # Handle object types appearing inline (i.e. not as a named $root or
803
+ # definition). Without this, an inline {"type": "object"} fell through
804
+ # to _map_primitive_type and emitted a bare "object", which is not a
805
+ # valid Avro type. See issue #336.
806
+ if type_value == 'object':
807
+ properties = schema.get('properties')
808
+ if properties:
809
+ # Object with a defined shape -> nested Avro record.
810
+ obj_name = schema.get('name') or self._anonymous_object_name(name_hint)
811
+ return self._convert_object(schema, None, obj_name)
812
+ # Open / property-less object -> Avro map. Honor an
813
+ # additionalProperties value type when specified, otherwise default
814
+ # to string (an open object carries arbitrary string-keyed values).
815
+ additional = schema.get('additionalProperties')
816
+ if isinstance(additional, dict):
817
+ values_type: Union[str, Dict[str, Any], List] = self._convert_type_reference(additional)
818
+ elif isinstance(additional, str):
819
+ values_type = self._map_primitive_type(additional)
820
+ else:
821
+ values_type = 'string'
822
+ return {
823
+ 'type': 'map',
824
+ 'values': values_type
825
+ }
826
+
756
827
  # Handle logical types
757
828
  logical_type = schema.get('logicalType')
758
829
  if logical_type:
@@ -767,8 +838,13 @@ class JsonStructureToAvro:
767
838
  def _map_primitive_type(self, struct_type: str) -> Union[str, Dict[str, Any]]:
768
839
  """Map JSON Structure primitive type to Avro primitive type.
769
840
 
770
- For temporal types, returns Avrotize Schema format with string base type
771
- and logical type annotation (RFC 3339 format).
841
+ For temporal types, returns Avrotize Schema format with a string base type
842
+ and an ``rfc3339-*`` logical-type annotation carrying the RFC 3339 textual
843
+ value. The ``rfc3339-*`` names are NOT reserved Avro logical types, so strict
844
+ Avro parsers ignore the annotation and treat the value as a plain string,
845
+ which keeps the emitted schema valid *standard* Avro (see issue #335) while
846
+ Avrotize-aware tools still recognize the temporal semantics.
847
+ See specs/avrotize-schema.md (Logical Types).
772
848
  """
773
849
  # Simple types without logical type annotation
774
850
  simple_type_mapping = {
@@ -802,13 +878,16 @@ class JsonStructureToAvro:
802
878
  'jsonpointer': 'string',
803
879
  }
804
880
 
805
- # Temporal types with Avrotize Schema string-based logical types (RFC 3339 format)
881
+ # Temporal types use the rfc3339-* string-based logical-type family so the
882
+ # output is valid *standard* Avro (rfc3339-* are not reserved Avro logical
883
+ # types, so strict parsers fall back to the string base) while preserving the
884
+ # RFC 3339 textual representation. See issue #335 and specs/avrotize-schema.md.
806
885
  temporal_type_mapping = {
807
- 'date': {'type': 'string', 'logicalType': 'date'}, # RFC 3339 full-date
808
- 'datetime': {'type': 'string', 'logicalType': 'timestamp-millis'}, # RFC 3339 date-time
809
- 'time': {'type': 'string', 'logicalType': 'time-millis'}, # RFC 3339 partial-time
810
- 'duration': {'type': 'string', 'logicalType': 'duration'}, # RFC 3339 duration
811
- 'timestamp': {'type': 'string', 'logicalType': 'timestamp-millis'}, # RFC 3339 date-time
886
+ 'date': {'type': 'string', 'logicalType': 'rfc3339-date'}, # RFC 3339 full-date
887
+ 'datetime': {'type': 'string', 'logicalType': 'rfc3339-timestamp-millis'}, # RFC 3339 date-time
888
+ 'time': {'type': 'string', 'logicalType': 'rfc3339-time-millis'}, # RFC 3339 partial-time
889
+ 'duration': {'type': 'string', 'logicalType': 'rfc3339-duration'}, # RFC 3339 duration
890
+ 'timestamp': {'type': 'string', 'logicalType': 'rfc3339-timestamp-millis'}, # RFC 3339 date-time
812
891
  }
813
892
 
814
893
  # Special types with logical type annotation
@@ -831,29 +910,31 @@ class JsonStructureToAvro:
831
910
  def _map_logical_type(self, logical_type: str, base_type: Optional[str]) -> Dict[str, Any]:
832
911
  """Map JSON Structure logical type to Avro/Avrotize logical type.
833
912
 
834
- Uses Avrotize Schema extensions for string-based temporal types (RFC 3339 format).
913
+ Temporal logical types map to the rfc3339-* string-based family so the output
914
+ is valid standard Avro (see issue #335 and specs/avrotize-schema.md).
835
915
  """
836
- # Avrotize Schema: temporal types on string (RFC 3339 format)
916
+ # Avrotize Schema: temporal types on string carrying RFC 3339 text, using the
917
+ # rfc3339-* logical-type family (not reserved Avro names -> valid standard Avro).
837
918
  logical_mapping = {
838
919
  # Timestamps
839
- 'timestampMicros': {'type': 'string', 'logicalType': 'timestamp-micros'},
840
- 'timestampMillis': {'type': 'string', 'logicalType': 'timestamp-millis'},
841
- 'timestamp-micros': {'type': 'string', 'logicalType': 'timestamp-micros'},
842
- 'timestamp-millis': {'type': 'string', 'logicalType': 'timestamp-millis'},
920
+ 'timestampMicros': {'type': 'string', 'logicalType': 'rfc3339-timestamp-micros'},
921
+ 'timestampMillis': {'type': 'string', 'logicalType': 'rfc3339-timestamp-millis'},
922
+ 'timestamp-micros': {'type': 'string', 'logicalType': 'rfc3339-timestamp-micros'},
923
+ 'timestamp-millis': {'type': 'string', 'logicalType': 'rfc3339-timestamp-millis'},
843
924
  # Local timestamps (no timezone)
844
- 'localTimestampMicros': {'type': 'string', 'logicalType': 'local-timestamp-micros'},
845
- 'localTimestampMillis': {'type': 'string', 'logicalType': 'local-timestamp-millis'},
846
- 'local-timestamp-micros': {'type': 'string', 'logicalType': 'local-timestamp-micros'},
847
- 'local-timestamp-millis': {'type': 'string', 'logicalType': 'local-timestamp-millis'},
925
+ 'localTimestampMicros': {'type': 'string', 'logicalType': 'rfc3339-local-timestamp-micros'},
926
+ 'localTimestampMillis': {'type': 'string', 'logicalType': 'rfc3339-local-timestamp-millis'},
927
+ 'local-timestamp-micros': {'type': 'string', 'logicalType': 'rfc3339-local-timestamp-micros'},
928
+ 'local-timestamp-millis': {'type': 'string', 'logicalType': 'rfc3339-local-timestamp-millis'},
848
929
  # Date and time
849
- 'date': {'type': 'string', 'logicalType': 'date'},
850
- 'time-millis': {'type': 'string', 'logicalType': 'time-millis'},
851
- 'time-micros': {'type': 'string', 'logicalType': 'time-micros'},
852
- 'timeMillis': {'type': 'string', 'logicalType': 'time-millis'},
853
- 'timeMicros': {'type': 'string', 'logicalType': 'time-micros'},
930
+ 'date': {'type': 'string', 'logicalType': 'rfc3339-date'},
931
+ 'time-millis': {'type': 'string', 'logicalType': 'rfc3339-time-millis'},
932
+ 'time-micros': {'type': 'string', 'logicalType': 'rfc3339-time-micros'},
933
+ 'timeMillis': {'type': 'string', 'logicalType': 'rfc3339-time-millis'},
934
+ 'timeMicros': {'type': 'string', 'logicalType': 'rfc3339-time-micros'},
854
935
  # Duration
855
- 'duration': {'type': 'string', 'logicalType': 'duration'},
856
- # UUID
936
+ 'duration': {'type': 'string', 'logicalType': 'rfc3339-duration'},
937
+ # UUID (valid standard Avro on a string base)
857
938
  'uuid': {'type': 'string', 'logicalType': 'uuid'},
858
939
  # Decimal (Avrotize extension: on string)
859
940
  'decimal': {'type': 'string', 'logicalType': 'decimal'},
@@ -7,6 +7,16 @@ from avrotize.common import build_flat_type_dict, inline_avro_references, strip_
7
7
  from azure.kusto.data import KustoClient, KustoConnectionStringBuilder, ClientRequestProperties
8
8
 
9
9
 
10
+ def kusto_string_literal(value: str) -> str:
11
+ """Return a KQL verbatim string literal for a raw Python string."""
12
+ return '@"' + value.replace('"', '""') + '"'
13
+
14
+
15
+ def kusto_json_literal(value: Any) -> str:
16
+ """Return a KQL string literal containing one JSON encoding of value."""
17
+ return kusto_string_literal(json.dumps(value))
18
+
19
+
10
20
  class StructureToKusto:
11
21
  """Converts a JSON Structure schema to a Kusto table schema."""
12
22
 
@@ -284,9 +294,9 @@ class StructureToKusto:
284
294
  if notes:
285
295
  doc_data = doc_data + " " + " ".join(notes)
286
296
 
287
- doc_string = json.dumps(json.dumps({
297
+ doc_string = kusto_json_literal({
288
298
  "description": doc_data
289
- }))
299
+ })
290
300
  kusto.append(
291
301
  f".alter table {table_ref} docstring {doc_string};")
292
302
  kusto.append("")
@@ -304,7 +314,7 @@ class StructureToKusto:
304
314
  else:
305
315
  doc_data = f"Constant field with value: {json.dumps(const_value)}"
306
316
  doc_content = {"description": doc_data}
307
- doc = json.dumps(json.dumps(doc_content))
317
+ doc = kusto_json_literal(doc_content)
308
318
  # Add as comment - const fields are not stored in table
309
319
  kusto.insert(len(kusto) - (2 if kusto and kusto[-1] == "" else 1),
310
320
  f"-- Const field '{column_name}' with value: {json.dumps(const_value)}")
@@ -323,7 +333,7 @@ class StructureToKusto:
323
333
  doc_content["schema"] = '{ "doc": "Schema too large to inline. Please refer to the JSON Structure schema for more details." }'
324
334
  else:
325
335
  doc_content["schema"] = prop_schema
326
- doc = json.dumps(json.dumps(doc_content))
336
+ doc = kusto_json_literal(doc_content)
327
337
  doc_string_statement.append(f" [{column_name}]: {doc}")
328
338
  if doc_string_statement and emit_cloudevents_columns:
329
339
  doc_string_statement.extend([
@@ -497,17 +497,51 @@ class StructureToPython:
497
497
  field_docstring = f"{field_name} ({field_type})"
498
498
  return field_docstring
499
499
 
500
+ # Mapping of special characters to descriptive names for enum member
501
+ # identifiers. Applied *before* the catch-all regex so that symbols
502
+ # like "5+" and "5-" produce distinct identifiers instead of both
503
+ # collapsing to "5_".
504
+ _CHAR_NAME_MAP = {
505
+ '+': '_PLUS',
506
+ '-': '_MINUS',
507
+ '*': '_STAR',
508
+ '/': '_SLASH',
509
+ '&': '_AMP',
510
+ '|': '_PIPE',
511
+ '!': '_BANG',
512
+ '?': '_QMARK',
513
+ '#': '_HASH',
514
+ '%': '_PCT',
515
+ '@': '_AT',
516
+ '^': '_CARET',
517
+ '~': '_TILDE',
518
+ '<': '_LT',
519
+ '>': '_GT',
520
+ '=': '_EQ',
521
+ '.': '_DOT',
522
+ ',': '_COMMA',
523
+ ':': '_COLON',
524
+ ';': '_SEMI',
525
+ '(': '_LPAREN',
526
+ ')': '_RPAREN',
527
+ '[': '_LBRACK',
528
+ ']': '_RBRACK',
529
+ '{': '_LBRACE',
530
+ '}': '_RBRACE',
531
+ }
532
+
500
533
  def _python_enum_member_name(self, value) -> str:
501
534
  """Derives a valid Python identifier for an enum member from a JSON
502
535
  Structure enum value.
503
536
 
504
537
  Numeric values (including negative integers) are prefixed with
505
538
  ``VALUE_`` (with negatives spelled ``VALUE_NEG_n``) so the generated
506
- class body is valid Python. String values that aren't already valid
507
- identifiers — e.g. they start with a digit or contain hyphens/spaces —
508
- are coerced via ``pascal`` and prefixed with ``VALUE_`` if they still
509
- don't start with a letter or underscore. Reserved words get a
510
- trailing underscore.
539
+ class body is valid Python. String values are sanitized by
540
+ replacing known special characters with descriptive names (e.g.
541
+ ``+`` → ``_PLUS``, ``-`` → ``_MINUS``) and then replacing any
542
+ remaining non-identifier characters with underscores. Values that
543
+ start with a digit are prefixed with ``VALUE_``. Reserved words
544
+ get a trailing underscore.
511
545
  """
512
546
  if isinstance(value, bool):
513
547
  return "TRUE_" if value else "FALSE_"
@@ -520,7 +554,18 @@ class StructureToPython:
520
554
  token = str(value)
521
555
  return f"VALUE_{token}"
522
556
  text = str(value)
523
- candidate = re.sub(r'[^0-9A-Za-z_]', '_', text)
557
+ # Replace known special characters with descriptive names
558
+ parts: list[str] = []
559
+ for ch in text:
560
+ if ch in self._CHAR_NAME_MAP:
561
+ parts.append(self._CHAR_NAME_MAP[ch])
562
+ else:
563
+ parts.append(ch)
564
+ candidate = ''.join(parts)
565
+ # Replace any remaining invalid identifier characters
566
+ candidate = re.sub(r'[^0-9A-Za-z_]', '_', candidate)
567
+ # Collapse runs of underscores and strip leading/trailing underscores
568
+ candidate = re.sub(r'_+', '_', candidate).strip('_')
524
569
  if not candidate or not (candidate[0].isalpha() or candidate[0] == '_'):
525
570
  candidate = f"VALUE_{candidate}" if candidate else "VALUE_"
526
571
  if is_python_reserved_word(candidate):
@@ -548,10 +593,22 @@ class StructureToPython:
548
593
  # Build (member_name, repr) pairs. ``repr`` is rendered verbatim into the
549
594
  # class body, so for numeric enums we emit the bare numeric literal and
550
595
  # for string enums a quoted Python string literal.
596
+ # Names that collide with each other or with generated methods are
597
+ # disambiguated with numeric suffixes.
551
598
  members: List[Tuple[str, str]] = []
552
599
  symbols: List[str] = []
600
+ # Pre-seed with names that would clash with generated Enum methods or
601
+ # Python Enum internals.
602
+ used_names: set[str] = {'from_ordinal', 'to_ordinal', 'mro', '_ignore_',
603
+ '_generate_next_value_', '_missing_', '_order_'}
553
604
  for value in raw_values:
554
- member_name = self._python_enum_member_name(value)
605
+ base_name = self._python_enum_member_name(value)
606
+ member_name = base_name
607
+ suffix = 2
608
+ while member_name in used_names:
609
+ member_name = f"{base_name}_{suffix}"
610
+ suffix += 1
611
+ used_names.add(member_name)
555
612
  if is_numeric:
556
613
  value_repr = repr(value)
557
614
  else:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: structurize
3
- Version: 3.5.10
3
+ Version: 3.5.12
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
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes