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.
- overture/schema/system/__init__.py +180 -0
- overture/schema/system/_json_schema.py +404 -0
- overture/schema/system/case.py +26 -0
- overture/schema/system/create_model.py +60 -0
- overture/schema/system/discovery/__init__.py +29 -0
- overture/schema/system/discovery/discovery.py +294 -0
- overture/schema/system/discovery/entry_point.py +119 -0
- overture/schema/system/discovery/keys.py +41 -0
- overture/schema/system/discovery/tag.py +102 -0
- overture/schema/system/discovery/tag_providers.py +35 -0
- overture/schema/system/discovery/types.py +18 -0
- overture/schema/system/doc.py +88 -0
- overture/schema/system/feature.py +810 -0
- overture/schema/system/field_constraint/__init__.py +44 -0
- overture/schema/system/field_constraint/collection.py +91 -0
- overture/schema/system/field_constraint/field_constraint.py +87 -0
- overture/schema/system/field_constraint/string.py +267 -0
- overture/schema/system/field_path.py +485 -0
- overture/schema/system/geometric/__init__.py +25 -0
- overture/schema/system/geometric/bbox.py +285 -0
- overture/schema/system/geometric/geom.py +555 -0
- overture/schema/system/json_schema.py +204 -0
- overture/schema/system/metadata.py +325 -0
- overture/schema/system/model_constraint/__init__.py +40 -0
- overture/schema/system/model_constraint/forbid_if.py +150 -0
- overture/schema/system/model_constraint/min_fields_set.py +134 -0
- overture/schema/system/model_constraint/model_constraint.py +595 -0
- overture/schema/system/model_constraint/no_extra_fields.py +81 -0
- overture/schema/system/model_constraint/radio_group.py +159 -0
- overture/schema/system/model_constraint/require_any_of.py +122 -0
- overture/schema/system/model_constraint/require_any_true.py +166 -0
- overture/schema/system/model_constraint/require_if.py +159 -0
- overture/schema/system/numeric.py +103 -0
- overture/schema/system/optionality.py +87 -0
- overture/schema/system/py.typed +0 -0
- overture/schema/system/ref/__init__.py +4 -0
- overture/schema/system/ref/id.py +62 -0
- overture/schema/system/ref/ref.py +112 -0
- overture/schema/system/string.py +176 -0
- overture/schema/system/testing/__init__.py +13 -0
- overture/schema/system/testing/golden.py +61 -0
- overture/schema/system/testing/plugin.py +23 -0
- overture/schema/system/typing_util.py +48 -0
- overture_schema_system-0.1.1.dev0.dist-info/METADATA +328 -0
- overture_schema_system-0.1.1.dev0.dist-info/RECORD +47 -0
- overture_schema_system-0.1.1.dev0.dist-info/WHEEL +4 -0
- 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]
|