structurize 3.9.1__tar.gz → 3.9.2__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 (124) hide show
  1. {structurize-3.9.1/structurize.egg-info → structurize-3.9.2}/PKG-INFO +1 -1
  2. {structurize-3.9.1 → structurize-3.9.2}/avrotize/_version.py +3 -3
  3. {structurize-3.9.1 → structurize-3.9.2}/avrotize/structuretopython.py +503 -25
  4. {structurize-3.9.1 → structurize-3.9.2}/avrotize/structuretots.py +73 -13
  5. {structurize-3.9.1 → structurize-3.9.2/structurize.egg-info}/PKG-INFO +1 -1
  6. {structurize-3.9.1 → structurize-3.9.2}/.gitignore +0 -0
  7. {structurize-3.9.1 → structurize-3.9.2}/LICENSE +0 -0
  8. {structurize-3.9.1 → structurize-3.9.2}/MANIFEST.in +0 -0
  9. {structurize-3.9.1 → structurize-3.9.2}/README.md +0 -0
  10. {structurize-3.9.1 → structurize-3.9.2}/avrotize/__init__.py +0 -0
  11. {structurize-3.9.1 → structurize-3.9.2}/avrotize/__main__.py +0 -0
  12. {structurize-3.9.1 → structurize-3.9.2}/avrotize/asn1toavro.py +0 -0
  13. {structurize-3.9.1 → structurize-3.9.2}/avrotize/avrotize.py +0 -0
  14. {structurize-3.9.1 → structurize-3.9.2}/avrotize/avrotoasn1.py +0 -0
  15. {structurize-3.9.1 → structurize-3.9.2}/avrotize/avrotocapnproto.py +0 -0
  16. {structurize-3.9.1 → structurize-3.9.2}/avrotize/avrotocpp.py +0 -0
  17. {structurize-3.9.1 → structurize-3.9.2}/avrotize/avrotocsharp.py +0 -0
  18. {structurize-3.9.1 → structurize-3.9.2}/avrotize/avrotocsv.py +0 -0
  19. {structurize-3.9.1 → structurize-3.9.2}/avrotize/avrotocue.py +0 -0
  20. {structurize-3.9.1 → structurize-3.9.2}/avrotize/avrotodatapackage.py +0 -0
  21. {structurize-3.9.1 → structurize-3.9.2}/avrotize/avrotodb.py +0 -0
  22. {structurize-3.9.1 → structurize-3.9.2}/avrotize/avrotoflatbuffers.py +0 -0
  23. {structurize-3.9.1 → structurize-3.9.2}/avrotize/avrotogo.py +0 -0
  24. {structurize-3.9.1 → structurize-3.9.2}/avrotize/avrotographql.py +0 -0
  25. {structurize-3.9.1 → structurize-3.9.2}/avrotize/avrotoiceberg.py +0 -0
  26. {structurize-3.9.1 → structurize-3.9.2}/avrotize/avrotojava.py +0 -0
  27. {structurize-3.9.1 → structurize-3.9.2}/avrotize/avrotojs.py +0 -0
  28. {structurize-3.9.1 → structurize-3.9.2}/avrotize/avrotojsons.py +0 -0
  29. {structurize-3.9.1 → structurize-3.9.2}/avrotize/avrotojstruct.py +0 -0
  30. {structurize-3.9.1 → structurize-3.9.2}/avrotize/avrotojtd.py +0 -0
  31. {structurize-3.9.1 → structurize-3.9.2}/avrotize/avrotokusto.py +0 -0
  32. {structurize-3.9.1 → structurize-3.9.2}/avrotize/avrotomd.py +0 -0
  33. {structurize-3.9.1 → structurize-3.9.2}/avrotize/avrotools.py +0 -0
  34. {structurize-3.9.1 → structurize-3.9.2}/avrotize/avrotoparquet.py +0 -0
  35. {structurize-3.9.1 → structurize-3.9.2}/avrotize/avrotoproto.py +0 -0
  36. {structurize-3.9.1 → structurize-3.9.2}/avrotize/avrotopython.py +0 -0
  37. {structurize-3.9.1 → structurize-3.9.2}/avrotize/avrotoraml.py +0 -0
  38. {structurize-3.9.1 → structurize-3.9.2}/avrotize/avrotorust.py +0 -0
  39. {structurize-3.9.1 → structurize-3.9.2}/avrotize/avrotosmithy.py +0 -0
  40. {structurize-3.9.1 → structurize-3.9.2}/avrotize/avrotosurreal.py +0 -0
  41. {structurize-3.9.1 → structurize-3.9.2}/avrotize/avrotothrift.py +0 -0
  42. {structurize-3.9.1 → structurize-3.9.2}/avrotize/avrotots.py +0 -0
  43. {structurize-3.9.1 → structurize-3.9.2}/avrotize/avrototsml.py +0 -0
  44. {structurize-3.9.1 → structurize-3.9.2}/avrotize/avrotoxsd.py +0 -0
  45. {structurize-3.9.1 → structurize-3.9.2}/avrotize/avrovalidator.py +0 -0
  46. {structurize-3.9.1 → structurize-3.9.2}/avrotize/capnprototoavro.py +0 -0
  47. {structurize-3.9.1 → structurize-3.9.2}/avrotize/cddltostructure.py +0 -0
  48. {structurize-3.9.1 → structurize-3.9.2}/avrotize/choice_inference.py +0 -0
  49. {structurize-3.9.1 → structurize-3.9.2}/avrotize/commands.json +0 -0
  50. {structurize-3.9.1 → structurize-3.9.2}/avrotize/common.py +0 -0
  51. {structurize-3.9.1 → structurize-3.9.2}/avrotize/constants.py +0 -0
  52. {structurize-3.9.1 → structurize-3.9.2}/avrotize/csvtoavro.py +0 -0
  53. {structurize-3.9.1 → structurize-3.9.2}/avrotize/cuetoavro.py +0 -0
  54. {structurize-3.9.1 → structurize-3.9.2}/avrotize/cuetostructure.py +0 -0
  55. {structurize-3.9.1 → structurize-3.9.2}/avrotize/datapackagetoavro.py +0 -0
  56. {structurize-3.9.1 → structurize-3.9.2}/avrotize/dependencies/cpp/vcpkg/vcpkg.json +0 -0
  57. {structurize-3.9.1 → structurize-3.9.2}/avrotize/dependencies/typescript/node22/package.json +0 -0
  58. {structurize-3.9.1 → structurize-3.9.2}/avrotize/dependency_resolver.py +0 -0
  59. {structurize-3.9.1 → structurize-3.9.2}/avrotize/dependency_version.py +0 -0
  60. {structurize-3.9.1 → structurize-3.9.2}/avrotize/flatbufferstoavro.py +0 -0
  61. {structurize-3.9.1 → structurize-3.9.2}/avrotize/flatbufferstojstruct.py +0 -0
  62. {structurize-3.9.1 → structurize-3.9.2}/avrotize/jsonstoavro.py +0 -0
  63. {structurize-3.9.1 → structurize-3.9.2}/avrotize/jsonstostructure.py +0 -0
  64. {structurize-3.9.1 → structurize-3.9.2}/avrotize/jsontoschema.py +0 -0
  65. {structurize-3.9.1 → structurize-3.9.2}/avrotize/jstructtoavro.py +0 -0
  66. {structurize-3.9.1 → structurize-3.9.2}/avrotize/jstructtoflatbuffers.py +0 -0
  67. {structurize-3.9.1 → structurize-3.9.2}/avrotize/jstructtoraml.py +0 -0
  68. {structurize-3.9.1 → structurize-3.9.2}/avrotize/jstructtosmithy.py +0 -0
  69. {structurize-3.9.1 → structurize-3.9.2}/avrotize/jtdtoavro.py +0 -0
  70. {structurize-3.9.1 → structurize-3.9.2}/avrotize/jtdtostructure.py +0 -0
  71. {structurize-3.9.1 → structurize-3.9.2}/avrotize/kstructtoavro.py +0 -0
  72. {structurize-3.9.1 → structurize-3.9.2}/avrotize/kustotoavro.py +0 -0
  73. {structurize-3.9.1 → structurize-3.9.2}/avrotize/kustotojstruct.py +0 -0
  74. {structurize-3.9.1 → structurize-3.9.2}/avrotize/mcp_server.py +0 -0
  75. {structurize-3.9.1 → structurize-3.9.2}/avrotize/openapitostructure.py +0 -0
  76. {structurize-3.9.1 → structurize-3.9.2}/avrotize/parquettoavro.py +0 -0
  77. {structurize-3.9.1 → structurize-3.9.2}/avrotize/proto2parser.py +0 -0
  78. {structurize-3.9.1 → structurize-3.9.2}/avrotize/proto3parser.py +0 -0
  79. {structurize-3.9.1 → structurize-3.9.2}/avrotize/prototoavro.py +0 -0
  80. {structurize-3.9.1 → structurize-3.9.2}/avrotize/ramltoavro.py +0 -0
  81. {structurize-3.9.1 → structurize-3.9.2}/avrotize/ramltojstruct.py +0 -0
  82. {structurize-3.9.1 → structurize-3.9.2}/avrotize/rust_xml.py +0 -0
  83. {structurize-3.9.1 → structurize-3.9.2}/avrotize/schema_inference.py +0 -0
  84. {structurize-3.9.1 → structurize-3.9.2}/avrotize/smithytoavro.py +0 -0
  85. {structurize-3.9.1 → structurize-3.9.2}/avrotize/smithytojstruct.py +0 -0
  86. {structurize-3.9.1 → structurize-3.9.2}/avrotize/sqltoavro.py +0 -0
  87. {structurize-3.9.1 → structurize-3.9.2}/avrotize/structuretoasn1.py +0 -0
  88. {structurize-3.9.1 → structurize-3.9.2}/avrotize/structuretocddl.py +0 -0
  89. {structurize-3.9.1 → structurize-3.9.2}/avrotize/structuretocpp.py +0 -0
  90. {structurize-3.9.1 → structurize-3.9.2}/avrotize/structuretocsharp.py +0 -0
  91. {structurize-3.9.1 → structurize-3.9.2}/avrotize/structuretocsv.py +0 -0
  92. {structurize-3.9.1 → structurize-3.9.2}/avrotize/structuretocue.py +0 -0
  93. {structurize-3.9.1 → structurize-3.9.2}/avrotize/structuretodatapackage.py +0 -0
  94. {structurize-3.9.1 → structurize-3.9.2}/avrotize/structuretodb.py +0 -0
  95. {structurize-3.9.1 → structurize-3.9.2}/avrotize/structuretogo.py +0 -0
  96. {structurize-3.9.1 → structurize-3.9.2}/avrotize/structuretographql.py +0 -0
  97. {structurize-3.9.1 → structurize-3.9.2}/avrotize/structuretoiceberg.py +0 -0
  98. {structurize-3.9.1 → structurize-3.9.2}/avrotize/structuretojava.py +0 -0
  99. {structurize-3.9.1 → structurize-3.9.2}/avrotize/structuretojs.py +0 -0
  100. {structurize-3.9.1 → structurize-3.9.2}/avrotize/structuretojsons.py +0 -0
  101. {structurize-3.9.1 → structurize-3.9.2}/avrotize/structuretojtd.py +0 -0
  102. {structurize-3.9.1 → structurize-3.9.2}/avrotize/structuretokusto.py +0 -0
  103. {structurize-3.9.1 → structurize-3.9.2}/avrotize/structuretomd.py +0 -0
  104. {structurize-3.9.1 → structurize-3.9.2}/avrotize/structuretoparquet.py +0 -0
  105. {structurize-3.9.1 → structurize-3.9.2}/avrotize/structuretoproto.py +0 -0
  106. {structurize-3.9.1 → structurize-3.9.2}/avrotize/structuretorust.py +0 -0
  107. {structurize-3.9.1 → structurize-3.9.2}/avrotize/structuretotsml.py +0 -0
  108. {structurize-3.9.1 → structurize-3.9.2}/avrotize/structuretoxsd.py +0 -0
  109. {structurize-3.9.1 → structurize-3.9.2}/avrotize/surrealtoavro.py +0 -0
  110. {structurize-3.9.1 → structurize-3.9.2}/avrotize/thriftstructure.py +0 -0
  111. {structurize-3.9.1 → structurize-3.9.2}/avrotize/thrifttoavro.py +0 -0
  112. {structurize-3.9.1 → structurize-3.9.2}/avrotize/tmslvalidate.py +0 -0
  113. {structurize-3.9.1 → structurize-3.9.2}/avrotize/validate.py +0 -0
  114. {structurize-3.9.1 → structurize-3.9.2}/avrotize/xmltoschema.py +0 -0
  115. {structurize-3.9.1 → structurize-3.9.2}/avrotize/xsdtoavro.py +0 -0
  116. {structurize-3.9.1 → structurize-3.9.2}/build.ps1 +0 -0
  117. {structurize-3.9.1 → structurize-3.9.2}/build.sh +0 -0
  118. {structurize-3.9.1 → structurize-3.9.2}/pyproject.toml +0 -0
  119. {structurize-3.9.1 → structurize-3.9.2}/setup.cfg +0 -0
  120. {structurize-3.9.1 → structurize-3.9.2}/structurize.egg-info/SOURCES.txt +0 -0
  121. {structurize-3.9.1 → structurize-3.9.2}/structurize.egg-info/dependency_links.txt +0 -0
  122. {structurize-3.9.1 → structurize-3.9.2}/structurize.egg-info/entry_points.txt +0 -0
  123. {structurize-3.9.1 → structurize-3.9.2}/structurize.egg-info/requires.txt +0 -0
  124. {structurize-3.9.1 → structurize-3.9.2}/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.1
3
+ Version: 3.9.2
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.1'
22
- __version_tuple__ = version_tuple = (3, 9, 1)
21
+ __version__ = version = '3.9.2'
22
+ __version_tuple__ = version_tuple = (3, 9, 2)
23
23
 
24
- __commit_id__ = commit_id = 'gd2cba4eb3'
24
+ __commit_id__ = commit_id = 'g8d1c504ec'
@@ -5,7 +5,8 @@
5
5
  import json
6
6
  import os
7
7
  import re
8
- import random
8
+ import hashlib
9
+ from random import Random
9
10
  from typing import Any, Dict, List, Set, Tuple, Union, Optional
10
11
 
11
12
  from avrotize.common import pascal, process_template, json_wire_name, json_enum_wire_value
@@ -15,6 +16,432 @@ JsonNode = Dict[str, 'JsonNode'] | List['JsonNode'] | str | None
15
16
 
16
17
  INDENT = ' '
17
18
 
19
+ # Python types that need a dataclasses-json encoder/decoder pair.
20
+ # Ordered most-specific first: datetime.datetime is a subclass of datetime.date,
21
+ # so an isinstance chain must test datetime before date.
22
+ JSON_CODEC_SCALARS = (
23
+ 'datetime.datetime',
24
+ 'datetime.date',
25
+ 'datetime.time',
26
+ 'datetime.timedelta',
27
+ 'uuid.UUID',
28
+ 'bytes',
29
+ )
30
+ STRICT_JSON_CODEC_SCALARS = frozenset(
31
+ ('datetime.timedelta', 'uuid.UUID', 'bytes'))
32
+
33
+ # Marshmallow field expression per custom JSON scalar. List, set and string-keyed
34
+ # map fields compose these scalar fields so schema() uses the same wire codec.
35
+ #
36
+ # These name generated subclasses rather than marshmallow's own fields, because
37
+ # dataclasses_json.mm.schema() uses a supplied mm_field verbatim: it neither
38
+ # assigns data_key nor applies the field's decoder. A plain fields.Date would
39
+ # therefore lose the JSON field name and parse with marshmallow's own parser,
40
+ # so schema() would disagree with the other four entry points.
41
+ _MM_FIELDS = {
42
+ 'datetime.datetime': '_IsoDateTimeField',
43
+ 'datetime.date': '_IsoDateField',
44
+ 'datetime.time': '_IsoTimeField',
45
+ 'datetime.timedelta': '_DurationField',
46
+ 'uuid.UUID': '_UuidField',
47
+ 'bytes': '_Base64Field',
48
+ }
49
+
50
+ _JSON_PARSERS = {
51
+ 'datetime.datetime': '_parse_iso_datetime',
52
+ 'datetime.date': '_parse_iso_date',
53
+ 'datetime.time': '_parse_iso_time',
54
+ 'datetime.timedelta': '_parse_duration',
55
+ 'uuid.UUID': '_parse_uuid',
56
+ 'bytes': '_parse_base64',
57
+ }
58
+
59
+ # Each generated marshmallow field delegates to one of the JSON parsers, so
60
+ # emitting the field also requires emitting its parser.
61
+ _MM_FIELD_PARSERS = {
62
+ '_IsoDateField': '_parse_iso_date',
63
+ '_IsoDateTimeField': '_parse_iso_datetime',
64
+ '_IsoTimeField': '_parse_iso_time',
65
+ '_DurationField': '_parse_duration',
66
+ '_UuidField': '_parse_uuid',
67
+ '_Base64Field': '_parse_base64',
68
+ }
69
+
70
+ # Emitted in this order so a module's helper block is stable.
71
+ JSON_PARSER_ORDER = (
72
+ '_parse_iso_date',
73
+ '_parse_iso_datetime',
74
+ '_parse_iso_time',
75
+ '_parse_duration',
76
+ '_parse_uuid',
77
+ '_parse_base64',
78
+ )
79
+ MM_FIELD_ORDER = (
80
+ '_IsoDateField',
81
+ '_IsoDateTimeField',
82
+ '_IsoTimeField',
83
+ '_DurationField',
84
+ '_UuidField',
85
+ '_Base64Field',
86
+ '_SetField',
87
+ )
88
+
89
+ _GENERIC_RE = re.compile(r'^typing\.(Optional|List|Set|Dict|Union|Tuple|FrozenSet)\[(.+)\]$')
90
+
91
+
92
+ def split_type_args(arg_text: str) -> List[str]:
93
+ """ Splits the comma-separated arguments of a typing generic at bracket depth zero. """
94
+ args: List[str] = []
95
+ depth = 0
96
+ current = ''
97
+ for char in arg_text:
98
+ if char == '[':
99
+ depth += 1
100
+ elif char == ']':
101
+ depth -= 1
102
+ if char == ',' and depth == 0:
103
+ args.append(current.strip())
104
+ current = ''
105
+ else:
106
+ current += char
107
+ if current.strip():
108
+ args.append(current.strip())
109
+ return args
110
+
111
+
112
+ def parse_generic_type(type_name: str) -> Optional[Tuple[str, List[str]]]:
113
+ """ Splits a typing generic into its origin and its arguments, or None if it is not generic. """
114
+ match = _GENERIC_RE.match(type_name)
115
+ if not match:
116
+ return None
117
+ origin, arg_text = match.groups()
118
+ return origin, split_type_args(arg_text)
119
+
120
+
121
+ def type_contains_json_codec(type_name: str) -> bool:
122
+ """ Reports whether a Python type annotation contains a custom JSON scalar at any nesting depth. """
123
+ if type_name in JSON_CODEC_SCALARS:
124
+ return True
125
+ generic = parse_generic_type(type_name)
126
+ if not generic:
127
+ return False
128
+ return any(type_contains_json_codec(arg) for arg in generic[1])
129
+
130
+
131
+ NONE_TYPES = ('None', 'NoneType', 'type(None)')
132
+
133
+ # Branch priorities. Lower is tested first, so datetime must precede date: a
134
+ # datetime.datetime satisfies isinstance(v, datetime.date) as well.
135
+ _PRIORITY_DATETIME = 0
136
+ _PRIORITY_DATE_NARROWING = 1
137
+ _PRIORITY_DATE = 2
138
+ _PRIORITY_TIME = 3
139
+ _PRIORITY_DURATION = 4
140
+ _PRIORITY_UUID = 5
141
+ _PRIORITY_BINARY = 6
142
+ _PRIORITY_CONTAINER = 7
143
+
144
+
145
+ def json_codec_scalars_in(type_name: str) -> Set[str]:
146
+ """ Collects the distinct custom JSON scalars a Python type annotation contains at any depth. """
147
+ if type_name in JSON_CODEC_SCALARS:
148
+ return {type_name}
149
+ generic = parse_generic_type(type_name)
150
+ if not generic:
151
+ return set()
152
+ found: Set[str] = set()
153
+ for arg in generic[1]:
154
+ found |= json_codec_scalars_in(arg)
155
+ return found
156
+
157
+
158
+ def union_is_ambiguous(args: List[str]) -> bool:
159
+ """ Reports whether a union mixes custom scalar arms in a way no undiscriminated codec can encode.
160
+
161
+ A union of two custom scalars (``Union[date, datetime]``) cannot be encoded
162
+ without losing the distinction, and a custom scalar beside an unencoded
163
+ arm (``Union[date, str]``) cannot be decoded without coercing values that
164
+ legitimately belong to the other arm. In both cases no codec is emitted, so
165
+ the pre-existing loud failure is preserved rather than replaced by silent
166
+ data loss.
167
+ """
168
+ arms = [arg for arg in args if arg not in NONE_TYPES]
169
+ scalars: Set[str] = set()
170
+ for arm in arms:
171
+ scalars |= json_codec_scalars_in(arm)
172
+ if len(scalars) > 1:
173
+ return True
174
+ return any(not type_contains_json_codec(arm) for arm in arms)
175
+
176
+
177
+ def _codec_branches(type_name: str, var: str, depth: int, encode: bool,
178
+ parsers: Set[str]) -> List[Tuple[int, str, str]]:
179
+ """ Builds the (priority, condition, value) branches of a custom JSON codec expression.
180
+
181
+ Every parser referenced by the emitted branches is recorded in ``parsers``
182
+ so the caller knows exactly which helper definitions the generated module
183
+ needs. Values whose type the branches do not recognize are left untouched;
184
+ strings that should use a custom wire format but cannot be parsed raise from
185
+ the parser.
186
+ """
187
+ if type_name == 'datetime.datetime':
188
+ if encode:
189
+ return [(_PRIORITY_DATETIME, f'isinstance({var}, datetime.datetime)', f'{var}.isoformat()')]
190
+ parsers.add('_parse_iso_datetime')
191
+ return [(_PRIORITY_DATETIME, f'isinstance({var}, str)', f'_parse_iso_datetime({var}, {{field_name}})')]
192
+ if type_name == 'datetime.date':
193
+ if encode:
194
+ # datetime.datetime is a subclass of datetime.date: narrow it to the
195
+ # date component so the emitted string is parseable by the decoder.
196
+ # This branch is deliberately ranked below the datetime branch above
197
+ # so it can never displace it when both appear in one expression.
198
+ return [(_PRIORITY_DATE_NARROWING, f'isinstance({var}, datetime.datetime)',
199
+ f'{var}.date().isoformat()'),
200
+ (_PRIORITY_DATE, f'isinstance({var}, datetime.date)', f'{var}.isoformat()')]
201
+ parsers.add('_parse_iso_date')
202
+ return [(_PRIORITY_DATE, f'isinstance({var}, str)', f'_parse_iso_date({var}, {{field_name}})')]
203
+ if type_name == 'datetime.time':
204
+ if encode:
205
+ return [(_PRIORITY_TIME, f'isinstance({var}, datetime.time)', f'{var}.isoformat()')]
206
+ parsers.add('_parse_iso_time')
207
+ return [(_PRIORITY_TIME, f'isinstance({var}, str)', f'_parse_iso_time({var}, {{field_name}})')]
208
+ if type_name == 'datetime.timedelta':
209
+ if encode:
210
+ return [(_PRIORITY_DURATION, f'isinstance({var}, datetime.timedelta)',
211
+ f'_format_duration({var})')]
212
+ parsers.add('_parse_duration')
213
+ return [(_PRIORITY_DURATION, 'True',
214
+ f'_parse_duration({var}, {{field_name}})')]
215
+ if type_name == 'uuid.UUID':
216
+ if encode:
217
+ return [(_PRIORITY_UUID, f'isinstance({var}, uuid.UUID)', f'str({var})')]
218
+ parsers.add('_parse_uuid')
219
+ return [(_PRIORITY_UUID, 'True',
220
+ f'_parse_uuid({var}, {{field_name}})')]
221
+ if type_name == 'bytes':
222
+ if encode:
223
+ return [(_PRIORITY_BINARY, f'isinstance({var}, bytes)',
224
+ f"base64.b64encode({var}).decode('ascii')")]
225
+ parsers.add('_parse_base64')
226
+ return [(_PRIORITY_BINARY, 'True',
227
+ f'_parse_base64({var}, {{field_name}})')]
228
+
229
+ generic = parse_generic_type(type_name)
230
+ if not generic:
231
+ return []
232
+ origin, args = generic
233
+
234
+ if origin in ('Optional', 'Union'):
235
+ if union_is_ambiguous(args):
236
+ return []
237
+ branches: List[Tuple[int, str, str]] = []
238
+ for arg in args:
239
+ branches.extend(_codec_branches(arg, var, depth, encode, parsers))
240
+ branches.sort(key=lambda branch: branch[0])
241
+ seen: Set[str] = set()
242
+ unique: List[Tuple[int, str, str]] = []
243
+ for branch in branches:
244
+ if branch[1] in seen:
245
+ continue
246
+ seen.add(branch[1])
247
+ unique.append(branch)
248
+ return unique
249
+
250
+ item = f'_item{depth}'
251
+ # Only homogeneous containers are handled. typing.Tuple is deliberately
252
+ # excluded because its arguments are positional, so a single element codec
253
+ # cannot describe it.
254
+ if origin in ('List', 'Set', 'FrozenSet'):
255
+ inner = args[0]
256
+ if not type_contains_json_codec(inner):
257
+ return []
258
+ inner_expr = build_codec_expression(inner, item, depth + 1, encode, parsers)
259
+ comprehension = f'[{inner_expr} for {item} in {var}]'
260
+ if not encode and origin == 'Set':
261
+ comprehension = f'set({comprehension})'
262
+ elif not encode and origin == 'FrozenSet':
263
+ comprehension = f'frozenset({comprehension})'
264
+ return [(_PRIORITY_CONTAINER, f'isinstance({var}, (list, tuple, set, frozenset))', comprehension)]
265
+ if origin == 'Dict':
266
+ value_type = args[-1]
267
+ if not type_contains_json_codec(value_type):
268
+ return []
269
+ key = f'_key{depth}'
270
+ inner_expr = build_codec_expression(value_type, item, depth + 1, encode, parsers)
271
+ return [(_PRIORITY_CONTAINER, f'isinstance({var}, dict)',
272
+ f'{{{key}: {inner_expr} for {key}, {item} in {var}.items()}}')]
273
+ return []
274
+
275
+
276
+ def build_codec_expression(type_name: str, var: str, depth: int, encode: bool,
277
+ parsers: Set[str]) -> str:
278
+ """ Builds a custom JSON encode/decode expression over ``var``, or ``var`` itself when nothing applies. """
279
+ branches = _codec_branches(type_name, var, depth, encode, parsers)
280
+ expression = var
281
+ if not encode and (
282
+ json_codec_scalars_in(type_name) & STRICT_JSON_CODEC_SCALARS):
283
+ expected_container = decoded_container_kind(type_name)
284
+ if expected_container:
285
+ expression = (
286
+ f"_invalid_container({var}, {{field_name}}, "
287
+ f"'{expected_container}')"
288
+ )
289
+ for _priority, condition, value in reversed(branches):
290
+ expression = f'{value} if {condition} else {expression}'
291
+ if not encode and type_allows_none(type_name):
292
+ expression = f'None if {var} is None else {expression}'
293
+ return expression
294
+
295
+
296
+ def type_allows_none(type_name: str) -> bool:
297
+ """ Reports whether this exact annotation node declares nullability. """
298
+ generic = parse_generic_type(type_name)
299
+ if not generic:
300
+ return type_name in NONE_TYPES
301
+ origin, args = generic
302
+ return origin == 'Optional' or (
303
+ origin == 'Union' and any(arg in NONE_TYPES for arg in args))
304
+
305
+
306
+ def decoded_container_kind(type_name: str) -> Optional[str]:
307
+ """ Returns the JSON container kind required by a decoded custom-scalar field. """
308
+ generic = parse_generic_type(type_name)
309
+ if not generic:
310
+ return None
311
+ origin, args = generic
312
+ if origin == 'Optional':
313
+ return decoded_container_kind(args[0])
314
+ if origin in ('List', 'Set', 'FrozenSet'):
315
+ return 'array'
316
+ if origin == 'Dict':
317
+ return 'object'
318
+ return None
319
+
320
+
321
+ def build_mm_field(type_name: str, mm_classes: Set[str],
322
+ options: str = '') -> Optional[str]:
323
+ """ Builds the marshmallow field expression for a custom JSON type, or None when
324
+ marshmallow has no faithful equivalent (e.g. a union of several custom
325
+ scalar types).
326
+
327
+ ``options`` carries the keyword arguments for the outermost field only, so
328
+ ``data_key`` is not repeated on the element field of a list or map.
329
+ """
330
+ if type_name in _MM_FIELDS:
331
+ mm_class = _MM_FIELDS[type_name]
332
+ mm_classes.add(mm_class)
333
+ return f'{mm_class}({options})'
334
+ generic = parse_generic_type(type_name)
335
+ if not generic:
336
+ return None
337
+ origin, args = generic
338
+ if origin == 'Optional':
339
+ optional_options = options
340
+ if 'allow_none=' not in optional_options:
341
+ optional_options += (
342
+ ', ' if optional_options else '') + 'allow_none=True'
343
+ return build_mm_field(args[0], mm_classes, optional_options)
344
+ if origin == 'List':
345
+ inner = build_mm_field(args[0], mm_classes)
346
+ return f'fields.List({inner}{", " + options if options else ""})' if inner else None
347
+ if origin == 'Set':
348
+ inner = build_mm_field(args[0], mm_classes)
349
+ if not inner:
350
+ return None
351
+ mm_classes.add('_SetField')
352
+ return f'_SetField({inner}{", " + options if options else ""})'
353
+ if origin == 'Dict' and len(args) == 2 and args[0] == 'str':
354
+ inner = build_mm_field(args[1], mm_classes)
355
+ if not inner:
356
+ return None
357
+ return (f'fields.Dict(keys=fields.Str(), values={inner}'
358
+ f'{", " + options if options else ""})')
359
+ return None
360
+
361
+
362
+ def build_mm_options(type_name: str, json_name: str) -> str:
363
+ """ Builds the marshmallow keyword arguments that dataclasses_json would have
364
+ supplied itself.
365
+
366
+ ``dataclasses_json.mm.schema()`` only assigns ``data_key``, ``required`` and
367
+ ``allow_none`` on the branch it takes when no ``mm_field`` is configured, so
368
+ a field that supplies one has to carry them. ``data_key`` is the same value
369
+ passed to ``dataclasses_json.config(field_name=...)``, which is what
370
+ dataclasses_json would have computed from the configured letter case.
371
+ """
372
+ # Generated fields are kw_only and carry no default, so dataclasses_json
373
+ # would mark every one of them required.
374
+ options = [f'data_key={json_name!r}', 'required=True']
375
+ generic = parse_generic_type(type_name)
376
+ if generic and generic[0] == 'Optional':
377
+ options.append('allow_none=True')
378
+ return ', '.join(options)
379
+
380
+
381
+ def build_container_rebuild(type_name: str) -> Optional[str]:
382
+ """ Builds an expression rebuilding a declared set container from a JSON list.
383
+
384
+ JSON has no set type, so a ``typing.Set`` field arrives as a list. The
385
+ dataclasses-json ``from_json`` path reconstructs the declared container, and
386
+ the generated ``from_serializer_dict`` has to do the same or the two paths
387
+ hand back different container types for the same payload.
388
+ """
389
+ generic = parse_generic_type(type_name)
390
+ if not generic:
391
+ return None
392
+ origin, args = generic
393
+ if origin == 'Optional':
394
+ inner = build_container_rebuild(args[0])
395
+ return f'None if v is None else {inner}' if inner else None
396
+ if origin in ('Set', 'FrozenSet'):
397
+ return f'{"set" if origin == "Set" else "frozenset"}(v)'
398
+ return None
399
+
400
+
401
+ def build_json_codec(type_name: str, field_name: str) -> Optional[Dict[str, Optional[str]]]:
402
+ """ Builds the dataclasses-json encoder/decoder/mm_field triple for a custom JSON field type.
403
+
404
+ Returns None when the type contains no custom JSON scalar, or when it is a union
405
+ whose arms cannot be told apart on the wire. The encoder and decoder walk
406
+ nested ``Optional``/``Union``/``List``/``Set``/``Dict`` annotations, so
407
+ collections of custom scalars are serialized just like scalar values.
408
+ """
409
+ if not type_contains_json_codec(type_name):
410
+ return None
411
+ encode_parsers: Set[str] = set()
412
+ decode_parsers: Set[str] = set()
413
+ encoder = build_codec_expression(type_name, 'v', 0, True, encode_parsers)
414
+ decoder = build_codec_expression(type_name, 'v', 0, False, decode_parsers)
415
+ if encoder == 'v' and decoder == 'v':
416
+ return None
417
+
418
+ # The parsers name the field that rejected a value, so a malformed payload
419
+ # reports where it came from instead of surfacing a bare isoformat error.
420
+ decoder = decoder.replace('{field_name}', repr(field_name))
421
+
422
+ mm_classes: Set[str] = set()
423
+ mm_field = build_mm_field(type_name, mm_classes,
424
+ build_mm_options(type_name, field_name))
425
+ if not mm_field:
426
+ mm_classes.clear()
427
+
428
+ # A generated marshmallow field parses with the module's own ISO helper, so
429
+ # its parser has to be emitted even when no decoder lambda referenced it.
430
+ parsers = set(decode_parsers)
431
+ for mm_class in mm_classes:
432
+ parser = _MM_FIELD_PARSERS.get(mm_class)
433
+ if parser:
434
+ parsers.add(parser)
435
+
436
+ return {
437
+ 'encoder': f'lambda v: {encoder}',
438
+ 'decoder': f'lambda v: {decoder}',
439
+ 'mm_field': mm_field,
440
+ 'mm_classes': sorted(mm_classes),
441
+ 'parsers': sorted(parsers),
442
+ }
443
+
444
+
18
445
  # Python standard library modules that should not be shadowed by package names
19
446
  PYTHON_STDLIB_MODULES = {
20
447
  'abc', 'aifc', 'argparse', 'array', 'ast', 'asynchat', 'asyncio', 'asyncore',
@@ -407,25 +834,52 @@ class StructureToPython:
407
834
  doc = structure_schema.get('description', structure_schema.get('doc', class_name))
408
835
 
409
836
  # Generate field docstrings
410
- field_docstrings = [{
411
- 'name': self.safe_name(field['name']),
412
- 'original_name': field.get('json_name') or field['name'],
413
- 'type': field['type'],
414
- 'is_primitive': field['is_primitive'],
415
- 'is_enum': field['is_enum'],
416
- 'docstring': self.generate_field_docstring(field, schema_namespace),
417
- 'test_value': self.generate_test_value(field),
418
- 'source_type': field.get('source_type', 'string'),
419
- 'xml_name': field['xml_name'],
420
- 'xml_kind': field['xml_kind'],
421
- 'xml_namespace': field['xml_namespace'],
422
- 'xml_metadata': {
423
- 'type': 'Attribute' if field['xml_kind'] == 'attribute' else 'Element',
424
- 'name': field['xml_name'],
425
- **({'namespace': field['xml_namespace']}
426
- if field['xml_kind'] != 'attribute' and field['xml_namespace'] else {}),
427
- },
428
- } for field in fields]
837
+ field_docstrings = []
838
+ for field in fields:
839
+ codec = build_json_codec(
840
+ field['type'], field.get('json_name') or field['name'])
841
+ field_docstrings.append({
842
+ 'name': self.safe_name(field['name']),
843
+ 'original_name': field.get('json_name') or field['name'],
844
+ 'type': field['type'],
845
+ 'is_primitive': field['is_primitive'],
846
+ 'is_enum': field['is_enum'],
847
+ 'docstring': self.generate_field_docstring(field, schema_namespace),
848
+ 'test_value': self.generate_test_value(field),
849
+ 'source_type': field.get('source_type', 'string'),
850
+ 'json_encoder': codec['encoder'] if codec else None,
851
+ 'json_decoder': codec['decoder'] if codec else None,
852
+ 'mm_field': codec['mm_field'] if codec else None,
853
+ 'mm_classes': codec['mm_classes'] if codec else [],
854
+ 'json_parsers': codec['parsers'] if codec else [],
855
+ 'reject_json_null': (
856
+ bool(codec)
857
+ and bool(json_codec_scalars_in(field['type'])
858
+ & STRICT_JSON_CODEC_SCALARS)
859
+ and not type_allows_none(field['type'])
860
+ ),
861
+ # A set arrives from JSON as a list. Temporal sets are rebuilt by
862
+ # their own decoder; every other set needs an explicit rebuild so
863
+ # from_serializer_dict agrees with the dataclasses-json path.
864
+ 'container_rebuild': (None if self.dataclasses_json_annotation and codec
865
+ else build_container_rebuild(field['type'])),
866
+ 'xml_name': field['xml_name'],
867
+ 'xml_kind': field['xml_kind'],
868
+ 'xml_namespace': field['xml_namespace'],
869
+ 'xml_metadata': {
870
+ 'type': 'Attribute' if field['xml_kind'] == 'attribute' else 'Element',
871
+ 'name': field['xml_name'],
872
+ **({'namespace': field['xml_namespace']}
873
+ if field['xml_kind'] != 'attribute' and field['xml_namespace'] else {}),
874
+ },
875
+ })
876
+
877
+ # JSON parsing helpers required by the emitted decoders, in a stable order.
878
+ json_parsers = [parser for parser in JSON_PARSER_ORDER
879
+ if any(parser in field['json_parsers'] for field in field_docstrings)]
880
+ # Marshmallow field subclasses required by the emitted mm_fields.
881
+ mm_classes = [mm_class for mm_class in MM_FIELD_ORDER
882
+ if any(mm_class in field['mm_classes'] for field in field_docstrings)]
429
883
 
430
884
  # If avro_annotation is enabled, convert JSON Structure schema to Avro schema
431
885
  # This is embedded in the generated class for runtime Avro serialization
@@ -443,7 +897,18 @@ class StructureToPython:
443
897
  class_name=class_name,
444
898
  docstring=doc,
445
899
  fields=field_docstrings,
446
- import_types=import_types,
900
+ json_parsers=json_parsers,
901
+ uses_iso_parser=any(parser.startswith('_parse_iso_') for parser in json_parsers),
902
+ uses_container_validator=any(
903
+ field['json_decoder']
904
+ and '_invalid_container' in field['json_decoder']
905
+ for field in field_docstrings),
906
+ needs_base64='_parse_base64' in json_parsers,
907
+ needs_decimal=('decimal.Decimal' in import_types
908
+ or (self.dataclasses_json_annotation
909
+ and '_parse_duration' in json_parsers)),
910
+ mm_classes=mm_classes,
911
+ import_types=sorted(import_types),
447
912
  base_package=self.base_package,
448
913
  dataclasses_json_annotation=self.dataclasses_json_annotation,
449
914
  avro_annotation=self.avro_annotation,
@@ -765,7 +1230,7 @@ class StructureToPython:
765
1230
  class_name=class_name,
766
1231
  docstring=doc,
767
1232
  values_type=values_type,
768
- import_types=import_types,
1233
+ import_types=sorted(import_types),
769
1234
  base_package=self.base_package
770
1235
  )
771
1236
 
@@ -776,8 +1241,17 @@ class StructureToPython:
776
1241
  return python_qualified_name
777
1242
 
778
1243
  def generate_test_value(self, field: Dict) -> Any:
779
- """Generates a test value for a given field"""
1244
+ """Generates a test value for a given field.
1245
+
1246
+ The value is drawn from a generator seeded with a stable hash of the field
1247
+ name and type, so repeated generation of the same schema produces
1248
+ byte-identical output regardless of interpreter hash randomization.
1249
+ """
780
1250
  field_type = field['type']
1251
+ seed = int.from_bytes(
1252
+ hashlib.sha256(f"{field.get('name', '')}:{field_type}".encode('utf-8')).digest()[:8],
1253
+ 'big')
1254
+ random = Random(seed)
781
1255
 
782
1256
  def generate_value(field_type: str):
783
1257
  test_values = {
@@ -818,9 +1292,13 @@ class StructureToPython:
818
1292
  field_type = resolve(field_type)
819
1293
 
820
1294
  if field_type.startswith('typing.List[') or field_type.startswith('typing.Set['):
1295
+ is_set = field_type.startswith('typing.Set[')
821
1296
  field_type = resolve(field_type)
822
1297
  array_range = random.randint(1, 5)
823
- return f"[{', '.join([generate_value(field_type) for _ in range(array_range)])}]"
1298
+ items = f"[{', '.join([generate_value(field_type) for _ in range(array_range)])}]"
1299
+ # A typing.Set field must be constructed as a set, or the value
1300
+ # would not match the container the deserializers rebuild.
1301
+ return f"set({items})" if is_set else items
824
1302
  elif field_type.startswith('typing.Dict['):
825
1303
  field_type = resolve(field_type)
826
1304
  dict_range = random.randint(1, 5)
@@ -864,7 +1342,7 @@ class StructureToPython:
864
1342
  class_name=class_name,
865
1343
  test_class_name=test_class_name,
866
1344
  fields=fields,
867
- import_types=import_types,
1345
+ import_types=sorted(import_types),
868
1346
  avro_annotation=self.avro_annotation,
869
1347
  dataclasses_json_annotation=self.dataclasses_json_annotation
870
1348
  )
@@ -562,32 +562,92 @@ class StructureToTypeScript:
562
562
 
563
563
  def generate_tuple(self, structure_schema: Dict, parent_namespace: str,
564
564
  write_file: bool = True, explicit_name: str = '') -> str:
565
- """ Generates a TypeScript tuple type from JSON Structure tuple type """
565
+ """Generates a named TypeScript class that serializes as a JSON array."""
566
566
  tuple_name = pascal(explicit_name if explicit_name else structure_schema.get('name', 'Tuple'))
567
567
  namespace = self.concat_namespace(self.base_package, structure_schema.get('namespace', parent_namespace)).lower()
568
568
  schema_namespace = structure_schema.get('namespace', parent_namespace)
569
- typescript_qualified_name = self.typescript_fully_qualified_name_from_structure_type(parent_namespace, tuple_name)
569
+ typescript_qualified_name = self.typescript_fully_qualified_name_from_structure_type(schema_namespace, tuple_name)
570
570
 
571
571
  if typescript_qualified_name in self.generated_types:
572
572
  return typescript_qualified_name
573
573
 
574
574
  import_types: Set[str] = set()
575
- tuple_items = structure_schema.get('items', [])
576
- item_types = []
577
- for idx, item in enumerate(tuple_items):
575
+ properties = structure_schema.get('properties', {})
576
+ tuple_order = structure_schema.get('tuple', [])
577
+ elements = []
578
+ for prop_name in tuple_order:
579
+ prop_schema = properties.get(prop_name, {'type': 'any'})
578
580
  item_type = self.convert_structure_type_to_typescript(
579
- tuple_name, f'item{idx}', item, schema_namespace, import_types)
580
- item_types.append(item_type)
581
+ tuple_name, prop_name, prop_schema, schema_namespace, import_types)
582
+ item_type_no_null = self.strip_nullable(item_type)
583
+ elements.append({
584
+ 'name': self.safe_name(prop_name),
585
+ 'type': item_type_no_null,
586
+ 'test_value': self.generate_test_value({
587
+ 'type_no_null': item_type_no_null,
588
+ 'is_enum': any(
589
+ import_type.endswith('.' + item_type_no_null)
590
+ and self.generated_types.get(import_type) == 'enum'
591
+ for import_type in import_types)
592
+ }),
593
+ 'docstring': prop_schema.get('description', '') if isinstance(prop_schema, dict) else ''
594
+ })
581
595
 
582
- # TypeScript tuples are just arrays with fixed length and types
583
- tuple_type = f"[{', '.join(item_types)}]"
584
-
585
- # Generate type alias
586
- tuple_definition = f"export type {tuple_name} = {tuple_type};\n"
596
+ imports = []
597
+ for import_type in import_types:
598
+ if import_type == typescript_qualified_name:
599
+ continue
600
+ import_type_parts = import_type.split('.')
601
+ import_type_name = pascal(import_type_parts[-1])
602
+ import_path = '/'.join(import_type_parts)
603
+ current_path = '/'.join(namespace.split('.'))
604
+ relative_import_path = os.path.relpath(import_path, current_path).replace(os.sep, '/')
605
+ if not relative_import_path.startswith('.'):
606
+ relative_import_path = f'./{relative_import_path}'
607
+ imports.append(f"import {{ {import_type_name} }} from '{relative_import_path}.js';")
608
+
609
+ tuple_type = f"[{', '.join(element['type'] for element in elements)}]"
610
+ constructor_parameters = ',\n '.join(
611
+ f"public {element['name']}: {element['type']}" for element in elements)
612
+ array_values = ', '.join(f"this.{element['name']}" for element in elements)
613
+ parsed_values = ',\n '.join(
614
+ f"value[{index}] as {element['type']}" for index, element in enumerate(elements))
615
+ test_values = ',\n '.join(element['test_value'] for element in elements)
616
+ docstring = structure_schema.get('description', f'A {tuple_name} tuple.')
617
+ tuple_definition = '\n'.join(imports)
618
+ if imports:
619
+ tuple_definition += '\n'
620
+ tuple_definition += f"""/** {docstring} */
621
+ export class {tuple_name} {{
622
+ constructor(
623
+ {constructor_parameters}
624
+ ) {{}}
625
+
626
+ public toJSON(): {tuple_type} {{
627
+ return [{array_values}];
628
+ }}
629
+
630
+ public static fromJSON(json: string): {tuple_name} {{
631
+ const value: unknown = JSON.parse(json);
632
+ if (!Array.isArray(value) || value.length !== {len(elements)}) {{
633
+ throw new Error('Expected a {tuple_name} JSON array with {len(elements)} elements');
634
+ }}
635
+ return new {tuple_name}(
636
+ {parsed_values}
637
+ );
638
+ }}
639
+
640
+ public static createInstance(): {tuple_name} {{
641
+ return new {tuple_name}(
642
+ {test_values}
643
+ );
644
+ }}
645
+ }}
646
+ """
587
647
 
588
648
  if write_file:
589
649
  self.write_to_file(namespace, tuple_name, tuple_definition)
590
- self.generated_types[typescript_qualified_name] = 'tuple'
650
+ self.generated_types[typescript_qualified_name] = 'class'
591
651
  return typescript_qualified_name
592
652
 
593
653
  def generate_test_value(self, field: Dict) -> str:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: structurize
3
- Version: 3.9.1
3
+ Version: 3.9.2
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
File without changes