overture-schema-system 0.1.1.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. overture/schema/system/__init__.py +180 -0
  2. overture/schema/system/_json_schema.py +404 -0
  3. overture/schema/system/case.py +26 -0
  4. overture/schema/system/create_model.py +60 -0
  5. overture/schema/system/discovery/__init__.py +29 -0
  6. overture/schema/system/discovery/discovery.py +294 -0
  7. overture/schema/system/discovery/entry_point.py +119 -0
  8. overture/schema/system/discovery/keys.py +41 -0
  9. overture/schema/system/discovery/tag.py +102 -0
  10. overture/schema/system/discovery/tag_providers.py +35 -0
  11. overture/schema/system/discovery/types.py +18 -0
  12. overture/schema/system/doc.py +88 -0
  13. overture/schema/system/feature.py +810 -0
  14. overture/schema/system/field_constraint/__init__.py +44 -0
  15. overture/schema/system/field_constraint/collection.py +91 -0
  16. overture/schema/system/field_constraint/field_constraint.py +87 -0
  17. overture/schema/system/field_constraint/string.py +267 -0
  18. overture/schema/system/field_path.py +485 -0
  19. overture/schema/system/geometric/__init__.py +25 -0
  20. overture/schema/system/geometric/bbox.py +285 -0
  21. overture/schema/system/geometric/geom.py +555 -0
  22. overture/schema/system/json_schema.py +204 -0
  23. overture/schema/system/metadata.py +325 -0
  24. overture/schema/system/model_constraint/__init__.py +40 -0
  25. overture/schema/system/model_constraint/forbid_if.py +150 -0
  26. overture/schema/system/model_constraint/min_fields_set.py +134 -0
  27. overture/schema/system/model_constraint/model_constraint.py +595 -0
  28. overture/schema/system/model_constraint/no_extra_fields.py +81 -0
  29. overture/schema/system/model_constraint/radio_group.py +159 -0
  30. overture/schema/system/model_constraint/require_any_of.py +122 -0
  31. overture/schema/system/model_constraint/require_any_true.py +166 -0
  32. overture/schema/system/model_constraint/require_if.py +159 -0
  33. overture/schema/system/numeric.py +103 -0
  34. overture/schema/system/optionality.py +87 -0
  35. overture/schema/system/py.typed +0 -0
  36. overture/schema/system/ref/__init__.py +4 -0
  37. overture/schema/system/ref/id.py +62 -0
  38. overture/schema/system/ref/ref.py +112 -0
  39. overture/schema/system/string.py +176 -0
  40. overture/schema/system/testing/__init__.py +13 -0
  41. overture/schema/system/testing/golden.py +61 -0
  42. overture/schema/system/testing/plugin.py +23 -0
  43. overture/schema/system/typing_util.py +48 -0
  44. overture_schema_system-0.1.1.dev0.dist-info/METADATA +328 -0
  45. overture_schema_system-0.1.1.dev0.dist-info/RECORD +47 -0
  46. overture_schema_system-0.1.1.dev0.dist-info/WHEEL +4 -0
  47. overture_schema_system-0.1.1.dev0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,180 @@
1
+ r"""
2
+ Foundational types at the base of the Overture schema system.
3
+
4
+ A set of numeric and geometric types, constraint rules, and Pydantic model classes, and annotations
5
+ that can be used to create strongly-typed, predictably validated, data.
6
+
7
+ Subpackages
8
+ -----------
9
+ - :mod:`doc <overture.schema.system.doc>`. Documentation support for things that are hard to
10
+ document in Python, such as enumeration members.
11
+ - :mod:`feature <overture.schema.system.feature>` The `Feature` type, a Pydantic model type for
12
+ geospatial data whose JSON Schema and serialized JSON representation are compatible with GeoJSON.
13
+ - :mod:`field_constraint <overture.schema.system.field_constraint>` Constraints that can be
14
+ annotated onto Pydantic model fields to force them to conform to well-known rules, for example "a
15
+ collection that contains unique items" or "a string that is a valid country code".
16
+ - :mod:`geometric <overture.schema.system.geometric>` Geometric types, including `Geometry` and
17
+ `BBox`.
18
+ - :mod:`json_schema <overture.schema.system.json_schema>` Overture-flavored JSON Schema generation
19
+ for Pydantic models.
20
+ - :mod:`model_constraint <overture.schema.system.model_constraint>` Constraints that can be
21
+ decorated onto Pydantic model classes to add cross-field validation rules, for example "these two
22
+ fields are mutually-exclusive" or "if this field is set, then that field must also be set".
23
+ - :mod:`numeric <overture.schema.system.numeric>` Portable numeric types with specific bit widths,
24
+ for example `int32` and `float64`.
25
+ - :mod:`optionality` <overture.schema.system.optionality>` The `Omitable` type hint, syntax sugar to
26
+ help a Pydantic model's optional fields behave closer to JSON Schema semantics.
27
+ - :mod:`ref <overture.schema.system.ref>` Unique IDs and annotations to describe relationships
28
+ between models based on unique IDs. (*i.e.*, foreign key relationships).
29
+ - :mod:`string <overture.schema.system.string>` String types with built-in validation to conform to
30
+ well-known patterns, for example a country code, a hexadecimal color code, a language tag, or just
31
+ a string that doesn't contain whitespace.
32
+
33
+
34
+ Features
35
+ --------
36
+ - Integrates with Overture code generation tools, making Pydantic models built using these types
37
+ portable across different programming languages (*e.g.*, Java) and serialization formats (*e.g.*,
38
+ Parquet.)
39
+ - Tightly integrated with Pydantic's JSON Schema system, providing rich JSON Schemas and maximum
40
+ parity between Pydantic, generated JSON Schemas, and Overture's code generation tools.
41
+ - First-class support for geospatial data using the geometry types and the
42
+ `overture.schema.system.feature.Feature` class.
43
+ - Conditional fields and validation on relationships between fields (*e.g.*, if the type field
44
+ contains "region", then region code field must also be set).
45
+ - Constraint rules produce detailed and consistent error messages with useful domain knowledge.
46
+ - Reference annotations allow foreign key relationships between models to be described.
47
+
48
+ Examples
49
+ --------
50
+ Make a simple Pydantic model using the fundamental types from this package and verify that it
51
+ rejects invalid input:
52
+
53
+ >>> from pydantic import BaseModel, ValidationError
54
+ >>> from overture.schema.system.numeric import uint32;
55
+ >>> from overture.schema.system.string import SnakeCaseString;
56
+ >>> class MyModel(BaseModel):
57
+ ... index: uint32
58
+ ... id: SnakeCaseString
59
+ >>> try:
60
+ ... MyModel(index=-1, id="FooBar") # Index is not a valid uint32 (negative), "FooBar" is not snake_case
61
+ ... except ValidationError:
62
+ ... print("Validation failed")
63
+ Validation failed
64
+
65
+ Valid inputs to the same model are accepted:
66
+
67
+ >>> from pydantic import BaseModel, ValidationError
68
+ >>> from overture.schema.system.numeric import uint32;
69
+ >>> from overture.schema.system.string import SnakeCaseString;
70
+ >>> class MyModel(BaseModel):
71
+ ... index: uint32
72
+ ... id: SnakeCaseString
73
+ >>> my_model = MyModel(index=42, id="foo_bar")
74
+ >>> assert my_model.index == 42
75
+ >>> assert my_model.id == "foo_bar"
76
+
77
+ Combine Overture and Pydantic constraints on a single field:
78
+
79
+ >>> from typing import Annotated
80
+ >>> from pydantic import BaseModel, Field
81
+ >>> from overture.schema.system.field_constraint import UniqueItemsConstraint
82
+ >>> class MyModel(BaseModel):
83
+ ... # Unique tags: at least one is required, at most 10 are allowed.
84
+ ... tags: Annotated[
85
+ ... list[str],
86
+ ... UniqueItemsConstraint()
87
+ ... ] = Field(..., min_length=1, max_length=10, description="Unique tags")
88
+
89
+ Create a custom regular expression pattern constraint:
90
+
91
+ >>> from overture.schema.system.field_constraint import PatternConstraint
92
+ >>> OsmIdConstraint = PatternConstraint(
93
+ ... pattern=r"^[nwr]\d+$",
94
+ ... error_message="invalid OSM ID format: {value}. Must be n123, w123, or r123."
95
+ ... )
96
+ >>>
97
+ >>> from pydantic import BaseModel, Field
98
+ >>> class MyModel(BaseModel):
99
+ ... osm_id: Annotated[str, OsmIdConstraint] = Field(..., description="OSM entity ID")
100
+ >>>
101
+ >>> from pydantic import ValidationError
102
+ >>> try:
103
+ ... MyModel(**{"osm_id": "foo"})
104
+ ... except ValidationError as e:
105
+ ... assert "invalid OSM ID format: foo. Must be n123, w123, or r123." in str(e)
106
+ ... print("Validation failed")
107
+ Validation failed
108
+
109
+ Use decorators to add complex multi-field constraints. In this example, a validation rule is added
110
+ saying that at least one of the two optional fields is required to have an explicit value, but
111
+ they aren't both required to:
112
+
113
+ >>> from pydantic import BaseModel, ValidationError
114
+ >>> from overture.schema.system.model_constraint import require_any_of
115
+ >>>
116
+ >>> @require_any_of("foo", "bar")
117
+ ... class MyModel(BaseModel):
118
+ ... foo: int | None = None
119
+ ... bar: str | None = None
120
+ ...
121
+ >>> MyModel(foo=42, bar="hello") # validates OK
122
+ MyModel(foo=42, bar='hello')
123
+ >>> MyModel(foo=42) # validates OK
124
+ MyModel(foo=42, bar=None)
125
+ >>> MyModel(bar="hello") # validates OK
126
+ MyModel(foo=None, bar='hello')
127
+ >>>
128
+ >>> try:
129
+ ... MyModel()
130
+ ... except ValidationError as e:
131
+ ... assert "at least one of these fields must be set to a value other than None, but none are: foo, bar" in str(e)
132
+ ... print("Validation failed (no fields set)")
133
+ Validation failed (no fields set)
134
+ >>> try:
135
+ ... MyModel(foo=None, bar=None)
136
+ ... except ValidationError as e:
137
+ ... assert "at least one of these fields must be set to a value other than None, but none are: foo, bar" in str(e)
138
+ ... print("Validation failed (all fields None)")
139
+ Validation failed (all fields None)
140
+
141
+ Describe a foreign key relationship between two models where one model has a field that contains the
142
+ unique identifier of another model.
143
+
144
+ >>> from typing import Annotated
145
+ >>> from overture.schema.system.ref import Id, Identified, Reference, Relationship
146
+ >>> class Park(Identified):
147
+ ... pass
148
+ >>> class ParkBench(Identified):
149
+ ... park_id: Annotated[Id, Reference(Relationship.COMPOSITION, Park, role="located_in")]
150
+ """
151
+
152
+ from . import (
153
+ doc,
154
+ feature,
155
+ field_constraint,
156
+ geometric,
157
+ json_schema,
158
+ metadata,
159
+ model_constraint,
160
+ numeric,
161
+ optionality,
162
+ ref,
163
+ string,
164
+ )
165
+ from .create_model import create_model
166
+
167
+ __all__ = [
168
+ "create_model",
169
+ "doc",
170
+ "feature",
171
+ "field_constraint",
172
+ "geometric",
173
+ "json_schema",
174
+ "metadata",
175
+ "model_constraint",
176
+ "numeric",
177
+ "optionality",
178
+ "ref",
179
+ "string",
180
+ ]
@@ -0,0 +1,404 @@
1
+ from collections.abc import Callable
2
+ from typing import Any, TypeVar, cast, get_origin
3
+
4
+ from pydantic import ConfigDict
5
+ from pydantic.json_schema import JsonSchemaValue, JsonValue
6
+
7
+
8
+ def get_static_json_schema_extra(config: ConfigDict) -> JsonSchemaValue:
9
+ """
10
+ Get the static *extra* JSON Schema from a Pydantic model config dictionary.
11
+
12
+ Parameters
13
+ ----------
14
+ config : ConfigDict
15
+ Config dictionary
16
+
17
+ Returns
18
+ -------
19
+ JsonSchemaValue
20
+ Extra JSON Schema from `config`, or `{}` if `config` has no extra JSON Schema
21
+
22
+ Raises
23
+ ------
24
+ ValueError
25
+ If `config` contains dynamic extra JSON Schema (`JsonSchemaExtraCallable`)
26
+ """
27
+ json_schema: (
28
+ JsonSchemaValue
29
+ | Callable[[JsonSchemaValue], None]
30
+ | Callable[[JsonSchemaValue, type[Any]], None]
31
+ | None
32
+ ) = config.get("json_schema_extra", None)
33
+ if json_schema is None:
34
+ json_schema = {}
35
+ config["json_schema_extra"] = json_schema
36
+ else:
37
+ origin = cast(type, get_origin(JsonSchemaValue))
38
+ if isinstance(json_schema, origin):
39
+ return cast(JsonSchemaValue, json_schema)
40
+ else:
41
+ raise ValueError(
42
+ f'expected value of config\'s "json_schema_extra" key to be a `{origin.__name__}`, but it is a `{type(json_schema).__name__}`'
43
+ )
44
+ return json_schema
45
+
46
+
47
+ def put_all_of(json_schema: JsonSchemaValue, operands: list[JsonSchemaValue]) -> None:
48
+ """
49
+ Insert an `"allOf"` schema composition clause into a JSON Schema.
50
+
51
+ If the target JSON Schema already contains an `"allOf"` clause, the `operands` are added to the
52
+ existing `"allOf"` clause.
53
+
54
+ Parameters
55
+ ----------
56
+ json_schema : JsonSchemaValue
57
+ Target JSON Schema
58
+ operands : list[JsonSchemaValue]
59
+ Non-empty list of operands for the `"allOf"` clause
60
+ """
61
+ _verify_json_schema_value(("json_schema", json_schema))
62
+ _verify_operands_not_empty(JsonSchemaValue, operands)
63
+ if "allOf" not in json_schema:
64
+ json_schema["allOf"] = cast(JsonValue, operands)
65
+ else:
66
+ maybe_list = json_schema["allOf"]
67
+ if isinstance(maybe_list, list):
68
+ maybe_list += operands
69
+ else:
70
+ raise ValueError(
71
+ f'expected value of "allOf" key to be a `list`, but it is a `{type(maybe_list).__name__}` in the JSON Schema {json_schema}'
72
+ )
73
+
74
+
75
+ def put_any_of(json_schema: JsonSchemaValue, operands: list[JsonSchemaValue]) -> None:
76
+ """
77
+ Insert an `"anyOf"` schema composition clause into a JSON Schema.
78
+
79
+ If the target JSON Schema already contains an `"anyOf"` clause, the existing clause is retained
80
+ and the new one is added by adding both new and existing `"anyOf"` clauses to an `"allOf"`
81
+ clause using `put_all_of`.
82
+
83
+ Parameters
84
+ ----------
85
+ json_schema : JsonSchemaValue
86
+ Target JSON Schema
87
+ operands : list[JsonSchemaValue]
88
+ Non-empty list of operands for the `"anyOf"` clause
89
+ """
90
+ _verify_json_schema_value(("json_schema", json_schema))
91
+ _verify_operands_not_empty(JsonSchemaValue, operands)
92
+ prev: JsonSchemaValue = {}
93
+ try_move("anyOf", json_schema, prev)
94
+ if not prev:
95
+ json_schema["anyOf"] = cast(JsonValue, operands)
96
+ else:
97
+ put_all_of(json_schema, [prev, {"anyOf": cast(JsonValue, operands)}])
98
+
99
+
100
+ def put_one_of(json_schema: JsonSchemaValue, operands: list[JsonSchemaValue]) -> None:
101
+ """
102
+ Insert a `"oneOf"` schema composition clause into a JSON Schema.
103
+
104
+ If the target JSON Schema already contains a `"oneOf"` clause, the existing clause is retained
105
+ and the new one is added by adding both new and existing `"oneOf"` clauses to an `"allOf"`
106
+ clause using `put_all_of`.
107
+
108
+ Parameters
109
+ ----------
110
+ json_schema : JsonSchemaValue
111
+ Target JSON Schema
112
+ operands : list[JsonSchemaValue]
113
+ Non-empty list of operands for the `"allOf"` clause
114
+ """
115
+ _verify_json_schema_value(("json_schema", json_schema))
116
+ _verify_operands_not_empty(JsonSchemaValue, operands)
117
+ prev: JsonSchemaValue = {}
118
+ try_move("oneOf", json_schema, prev)
119
+ if not prev:
120
+ json_schema["oneOf"] = cast(JsonValue, operands)
121
+ else:
122
+ put_all_of(json_schema, [prev, {"oneOf": cast(JsonValue, operands)}])
123
+
124
+
125
+ def put_not(json_schema: JsonSchemaValue, operand: JsonSchemaValue) -> None:
126
+ """
127
+ Insert a `"not"` schema composition clause into a JSON Schema.
128
+
129
+ If the target JSON Schema already contains a `"not"` clause, the existing clause is retained
130
+ and the new one is added by refactoring the schema. Several refactorings are possible but in
131
+ all cases the `"not"` clause will remain and will contain an `"anyOf"` clause as its direct
132
+ child, and `operand` as a child of the `"anyOf"` clause.
133
+
134
+ Parameters
135
+ ----------
136
+ json_schema : JsonSchemaValue
137
+ Target JSON Schema
138
+ operand : JsonSchemaValue
139
+ Operand for the `"not"` clause
140
+ """
141
+ _verify_json_schema_value(("json_schema", json_schema), ("operand", operand))
142
+ prev: JsonSchemaValue = {}
143
+ try_move("not", json_schema, prev)
144
+
145
+ # Simple case: if the JSON didn't already have a "not", we just add it.
146
+ if not prev:
147
+ json_schema["not"] = operand
148
+ return
149
+
150
+ not_schema = prev["not"]
151
+ if not isinstance(not_schema, cast(type, get_origin(JsonSchemaValue))):
152
+ raise TypeError(
153
+ f'expected value of "not" key to be a `JsonSchemaValue`, but {repr(not_schema)} has type `{type(not_schema).__name__}` in the JSON Schema {json_schema}'
154
+ )
155
+ not_schema = cast(JsonSchemaValue, not_schema)
156
+
157
+ # Next simplest case: the only child of the "not" is "anyOf".
158
+ if len(not_schema) == 1 and "anyOf" in not_schema:
159
+ not_any_of_schema = not_schema["anyOf"]
160
+ if not isinstance(not_any_of_schema, list):
161
+ raise ValueError(
162
+ f'expected value of "anyOf" key under "not" to be a `list`, but is a {type(not_any_of_schema).__name__} in the JSON Schema {json_schema}'
163
+ )
164
+ not_any_of_schema.append(operand)
165
+ json_schema["not"] = not_schema
166
+ return
167
+
168
+ # Most complex case: "not" either contains multiple keys, or a key that's not "anyOf".
169
+ json_schema["not"] = {
170
+ "anyOf": [
171
+ not_schema,
172
+ operand,
173
+ ]
174
+ }
175
+
176
+
177
+ def put_if(
178
+ json_schema: JsonSchemaValue,
179
+ condition: JsonSchemaValue | None,
180
+ when_true: JsonSchemaValue | None,
181
+ when_false: JsonSchemaValue | None = None,
182
+ ) -> None:
183
+ """
184
+ Insert `"if"`/`"then"` conditional schema application elements with an optional `"else"` clause
185
+ into a JSON Schema.
186
+
187
+ If the target JSON Schema does not already contain an `"if"`/`"then"`/`"else"` elements, the new
188
+ elements are added directly into the JSON Schema. If it does already contain them, then the
189
+ existing `"if"`/`"then"`/`"else"` elements are moved into a separate object, the new ones are
190
+ inserted into a second separate object, and both of these objects are added into the JSON
191
+ Schema using `put_all_of`.
192
+
193
+ Parameters
194
+ ----------
195
+ json_schema : JsonSchemaValue
196
+ Target JSON Schema
197
+ condition : JsonSchemaValue | None
198
+ Operand for the `"if"` clause
199
+ when_true : JsonSchemaValue | None
200
+ Operand for the `"then"` clause
201
+ when_false : JsonSchemaValue | None
202
+ Operand for the `"else"` clause
203
+ """
204
+ _verify_json_schema_value(("json_schema", json_schema))
205
+ if condition is not None:
206
+ _verify_json_schema_value(("condition", condition))
207
+ if when_true is not None:
208
+ _verify_json_schema_value(("when_true", when_true))
209
+ if when_false is not None:
210
+ _verify_json_schema_value(("when_false", when_false))
211
+
212
+ prev: JsonSchemaValue = {}
213
+ try_move("if", json_schema, prev)
214
+ try_move("then", json_schema, prev)
215
+ try_move("else", json_schema, prev)
216
+
217
+ def _put(dst: JsonSchemaValue) -> JsonSchemaValue:
218
+ if condition:
219
+ dst["if"] = condition
220
+ if when_true:
221
+ dst["then"] = when_true
222
+ if when_false:
223
+ dst["else"] = when_false
224
+ return dst
225
+
226
+ if not prev:
227
+ _put(json_schema)
228
+ else:
229
+ put_all_of(json_schema, [prev, _put({})])
230
+
231
+
232
+ def put_required(json_schema: JsonSchemaValue, operands: list[str]) -> None:
233
+ """
234
+ Insert a `"required"` validation clause into a JSON Schema.
235
+
236
+ If the target JSON Schema already contains a `"required"` clause, the existing clause is
237
+ retained and `operands` is merged into it by appending the items that aren't already in the
238
+ `"required"` clause to the end of it, in the order in which they appear in `operands`.
239
+
240
+ Parameters
241
+ ----------
242
+ json_schema : JsonSchemaValue
243
+ Target JSON Schema
244
+ operands : list[str]
245
+ Operands for the `"required"` clause
246
+ """
247
+ _verify_json_schema_value(("json_schema", json_schema))
248
+ _verify_operands_not_empty(str, operands)
249
+ if "required" in json_schema:
250
+ required = json_schema["required"]
251
+ else:
252
+ required = []
253
+ json_schema["required"] = required
254
+ required += [p for p in operands if p not in required]
255
+
256
+
257
+ def put_properties(
258
+ json_schema: JsonSchemaValue,
259
+ new_properties: JsonSchemaValue,
260
+ ) -> None:
261
+ """
262
+ Insert members into the `"properties"` applicator keyword within the schema for a value of type
263
+ `"object"`.
264
+
265
+ If the target JSON Schema already contains a `"properties"` clause, the new properties from
266
+ `new_properties` are merged into it. Otherwise, a new `"properties"` clause is inserted into
267
+ `json_schema` and all properties from `new_properties` are inserted into it.
268
+
269
+ Parameters
270
+ ----------
271
+ json_schema : JsonSchemaValue
272
+ Target JSON Schema
273
+ new_properties : JsonSchemaValue
274
+ New properties to add to the `"properties"` clause within `json_schema`
275
+
276
+ Raises
277
+ ------
278
+ ValueError
279
+ If a property entry in `new_properties` can't be merged into the `"properties"` clause of
280
+ `json_schema` because there's an existing `"properties"` clause that contains a property
281
+ with the same name but a different value
282
+ """
283
+ _verify_json_schema_value(
284
+ ("json_schema", json_schema), ("new_properties", new_properties)
285
+ )
286
+ origin = cast(type, get_origin(JsonSchemaValue))
287
+ if "properties" in json_schema:
288
+ properties = json_schema["properties"]
289
+ if not isinstance(properties, origin):
290
+ raise TypeError(
291
+ f'expected value of "properties" key to be a `JsonSchemaValue`, but {repr(properties)} has type `{type(properties).__name__}` in the JSON Schema {json_schema}'
292
+ )
293
+ already_in = True
294
+ else:
295
+ properties = {}
296
+ already_in = False
297
+ for k, v in new_properties.items():
298
+ if not isinstance(v, origin):
299
+ raise TypeError(
300
+ f"expected property value for {repr(k)} key to be a `JsonSchemaValue`, but {repr(v)} has type `{type(v).__name__}` in the new properties {repr(new_properties)}"
301
+ )
302
+ elif k not in properties:
303
+ properties[k] = v
304
+ else:
305
+ _merge(cast(JsonSchemaValue, v), properties[k], k)
306
+ if not already_in and properties:
307
+ json_schema["properties"] = properties
308
+
309
+
310
+ def try_move(key: str, src: JsonSchemaValue, dst: JsonSchemaValue) -> None:
311
+ """
312
+ Move a key (that may not exist) from one JSON Schema to another one.
313
+
314
+ Removes the key `key` and its value from `src` and inserts them into `dst`. If `src` does not
315
+ contain the key `key`, nothing happens.
316
+
317
+ Parameters
318
+ ----------
319
+ key : str
320
+ Key to move from `src` to `dst`
321
+ src : JsonSchemaValue
322
+ Source JSON Schema from which to move `key` and its value
323
+ dst : JsonSchemaValue
324
+ Destination JSON Schema into which to move `key` and its value
325
+ """
326
+ try:
327
+ value = src[key]
328
+ dst[key] = value
329
+ del src[key]
330
+ except KeyError:
331
+ pass
332
+
333
+
334
+ def required_non_null(aliases: list[str]) -> JsonSchemaValue:
335
+ """
336
+ Build a JSON Schema requiring listed properties to be present and non-null.
337
+
338
+ Combines `"required"` (property must exist) with a per-property
339
+ constraint `{"not": {"type": "null"}}` (value must not be null).
340
+
341
+ Parameters
342
+ ----------
343
+ aliases : list[str]
344
+ Non-empty list of JSON Schema property names to constrain
345
+
346
+ Returns
347
+ -------
348
+ JsonSchemaValue
349
+ Schema requiring each property to be present and non-null
350
+ """
351
+ _verify_operands_not_empty(str, aliases)
352
+ return {
353
+ "required": aliases,
354
+ "properties": {a: {"not": {"type": "null"}} for a in aliases},
355
+ }
356
+
357
+
358
+ T = TypeVar("T", JsonSchemaValue, str)
359
+
360
+
361
+ def _verify_json_schema_value(*candidates: tuple[str, JsonSchemaValue]) -> None:
362
+ origin = cast(type, get_origin(JsonSchemaValue))
363
+ for target in candidates:
364
+ if not isinstance(target[1], origin):
365
+ raise TypeError(
366
+ f"`{target[0]}` must be a `JsonSchemaValue` value, but {repr(target[1])} has type `{type(target[1]).__name__}`"
367
+ )
368
+
369
+
370
+ def _verify_operands_not_empty(tp: type[T], operands: list[T]) -> None:
371
+ if not isinstance(operands, list):
372
+ raise TypeError(
373
+ f"`operands` must be a `list`, but {operands} has type `{type(operands).__name__}`"
374
+ )
375
+ if len(operands) == 0:
376
+ raise ValueError("`operands` cannot be empty, but it is")
377
+ origin = get_origin(tp)
378
+ if origin:
379
+ target = cast(type, origin)
380
+ else:
381
+ target = tp
382
+ mismatches = [a for a in operands if not isinstance(a, target)]
383
+ if mismatches:
384
+ raise TypeError(
385
+ "`operands` items must be `{target.__name__}` values, but these items are not: {mismatches}"
386
+ )
387
+
388
+
389
+ def _merge(src: JsonSchemaValue, dst: JsonValue, *loc: str) -> None:
390
+ origin = cast(type, get_origin(JsonSchemaValue))
391
+ if not isinstance(dst, origin):
392
+ raise TypeError(
393
+ f"`put_properties` merge conflict: `dst` exists but `src` cannot be merged in because `dst` is not a `JsonSchemaValue` (full path: {repr(loc)}) (`dst` value {repr(dst)} has type `{type(dst).__name__}`)"
394
+ )
395
+ dst = cast(JsonSchemaValue, dst)
396
+ for k, v in src.items():
397
+ if k not in dst:
398
+ dst[k] = v
399
+ elif isinstance(v, origin):
400
+ _merge(cast(JsonSchemaValue, v), dst[k], *loc, k)
401
+ elif dst[k] != v:
402
+ raise ValueError(
403
+ f"`put_properties` merge conflict: `dst[{repr(k)}]={repr(dst[k])}` exists and does not equal `src[{repr(k)}]={repr(v)}` (full path: {repr(loc)})"
404
+ )
@@ -0,0 +1,26 @@
1
+ """PascalCase to snake_case conversion."""
2
+
3
+ import re
4
+
5
+ __all__ = ["to_snake_case"]
6
+
7
+ _ACRONYM_BOUNDARY = re.compile(r"([A-Z]+)([A-Z][a-z])")
8
+ _CAMEL_BOUNDARY = re.compile(r"([a-z0-9])([A-Z])")
9
+
10
+
11
+ def to_snake_case(name: str) -> str:
12
+ """Convert PascalCase to snake_case.
13
+
14
+ Handles acronym runs correctly: "HTMLParser" becomes "html_parser",
15
+ not "h_t_m_l_parser".
16
+
17
+ >>> to_snake_case("HTMLParser")
18
+ 'html_parser'
19
+ >>> to_snake_case("BuildingPart")
20
+ 'building_part'
21
+ >>> to_snake_case("simple")
22
+ 'simple'
23
+ """
24
+ name = _ACRONYM_BOUNDARY.sub(r"\1_\2", name)
25
+ name = _CAMEL_BOUNDARY.sub(r"\1_\2", name)
26
+ return name.lower()
@@ -0,0 +1,60 @@
1
+ """
2
+ Dynamic Pydantic model creation with preservation of Overture metadata.
3
+ """
4
+
5
+ from collections.abc import Callable
6
+ from typing import Any, TypeVar
7
+
8
+ import pydantic
9
+
10
+ from .metadata import Metadata
11
+
12
+ ModelT = TypeVar("ModelT", bound=pydantic.BaseModel)
13
+
14
+
15
+ def create_model(
16
+ model_name: str,
17
+ /,
18
+ *,
19
+ __config__: pydantic.ConfigDict | None = None,
20
+ __doc__: str | None = None,
21
+ __base__: type[ModelT] | tuple[type[ModelT], ...] | None = None,
22
+ __module__: str | None = None,
23
+ __validators__: dict[str, Callable[..., Any]] | None = None,
24
+ __cls_kwargs__: dict[str, Any] | None = None,
25
+ __qualname__: str | None = None,
26
+ __metadata__: Metadata | None = None,
27
+ **field_definitions: Any | tuple[str, Any],
28
+ ) -> type[ModelT]:
29
+ """
30
+ Dynamically create and return a new Pydantic model, preserving Overture metadata.
31
+
32
+ Use `create_model` to dynamically create a subclass of any `BaseModel` while preserving Overture
33
+ `Metadata`.
34
+
35
+ ⚠️ Use this function instead of `pydantic.create_model`, as the Pydantic version will not
36
+ preserve the metadata, which may result in your models not behaving as expected with Overture
37
+ schema tooling. ⚠️
38
+
39
+ If `__metadata__` is omitted or `None`, the metadata on the base model, if any, is propagated to
40
+ the new model. If a non-`None` value is provided for `__metadata__`, the new model receives the
41
+ new metadata and the metadata on the base model is not propagated.
42
+ """
43
+ model_class = pydantic.create_model( # type: ignore[misc]
44
+ model_name,
45
+ __config__=__config__,
46
+ __doc__=__doc__,
47
+ __base__=__base__, # type: ignore[arg-type]
48
+ __module__=__module__, # type: ignore[arg-type]
49
+ __validators__=__validators__,
50
+ __cls_kwargs__=__cls_kwargs__,
51
+ __qualname__=__qualname__,
52
+ **field_definitions,
53
+ )
54
+ if __metadata__ is not None:
55
+ __metadata__.attach_to(model_class)
56
+ elif __base__ is not None:
57
+ prev = Metadata.retrieve_from(__base__, None)
58
+ if prev is not None:
59
+ prev.attach_to(model_class)
60
+ return model_class # type: ignore[return-value]