From d6ecb831eeb7040282d05c3e0055c2c07e83cad3 Mon Sep 17 00:00:00 2001 From: schapper Date: Thu, 9 Oct 2025 12:21:12 -0700 Subject: [PATCH 01/19] wip - Finished @forbid_fields_if, only to realize it's not right either --- .../src/overture/schema/system/__init__.py | 6 +- .../system/model_constraint/__init__.py | 12 +- .../model_constraint/forbid_fields_if.py | 150 ++++++++++++++++++ .../system/model_constraint/json_schema.py | 43 ++++- .../model_constraint/model_constraint.py | 114 ++++++++++++- .../system/model_constraint/require_any_of.py | 67 +++----- .../test_model_constraint_json_schema.py | 59 +++++++ .../model_constraint/test_require_any_of.py | 30 ++-- .../src/overture/schema/validation/mixin.py | 5 + 9 files changed, 419 insertions(+), 67 deletions(-) create mode 100644 packages/overture-schema-system/src/overture/schema/system/model_constraint/forbid_fields_if.py diff --git a/packages/overture-schema-system/src/overture/schema/system/__init__.py b/packages/overture-schema-system/src/overture/schema/system/__init__.py index da4b2a11b..b7094e35c 100644 --- a/packages/overture-schema-system/src/overture/schema/system/__init__.py +++ b/packages/overture-schema-system/src/overture/schema/system/__init__.py @@ -100,8 +100,8 @@ >>> >>> @require_any_of("foo", "bar") ... class MyModel(BaseModel): -... foo: int | None -... bar: str | None +... foo: int | None = None +... bar: str | None = None ... >>> MyModel(foo=42, bar="hello") # validates OK MyModel(foo=42, bar='hello') @@ -113,7 +113,7 @@ >>> try: ... MyModel(foo=None, bar=None) ... except ValidationError as e: -... assert "at least one of these fields must have a value, but none do: bar, foo" in str(e) +... assert "at least one of these fields must have a value, but none do: foo, bar" in str(e) ... print("Validation failed") Validation failed """ diff --git a/packages/overture-schema-system/src/overture/schema/system/model_constraint/__init__.py b/packages/overture-schema-system/src/overture/schema/system/model_constraint/__init__.py index 09092b1c4..4156da2ae 100644 --- a/packages/overture-schema-system/src/overture/schema/system/model_constraint/__init__.py +++ b/packages/overture-schema-system/src/overture/schema/system/model_constraint/__init__.py @@ -1,12 +1,22 @@ -from .model_constraint import ModelConstraint, apply_alias +from .forbid_fields_if import ForbidFieldsIfConstraint, forbid_fields_if +from .model_constraint import ( + FieldGroupConstraint, + ModelConstraint, + OptionalFieldGroupConstraint, + apply_alias, +) from .no_extra_fields import NoExtraFieldsConstraint, no_extra_fields from .require_any_of import RequireAnyOfConstraint, require_any_of __all__ = [ "apply_alias", + "FieldGroupConstraint", + "forbid_fields_if", + "ForbidFieldsIfConstraint", "ModelConstraint", "no_extra_fields", "NoExtraFieldsConstraint", + "OptionalFieldGroupConstraint", "require_any_of", "RequireAnyOfConstraint", ] diff --git a/packages/overture-schema-system/src/overture/schema/system/model_constraint/forbid_fields_if.py b/packages/overture-schema-system/src/overture/schema/system/model_constraint/forbid_fields_if.py new file mode 100644 index 000000000..278520455 --- /dev/null +++ b/packages/overture-schema-system/src/overture/schema/system/model_constraint/forbid_fields_if.py @@ -0,0 +1,150 @@ +from collections.abc import Callable + +from pydantic import BaseModel, ConfigDict +from pydantic.json_schema import to_jsonable_python +from typing_extensions import override + +from .json_schema import get_static_json_schema, put_if +from .model_constraint import OptionalFieldGroupConstraint, apply_alias + + +def forbid_fields_if( + field_names: list[str] | tuple[str, ...], + condition_field_name: str, + condition_value: object, +) -> Callable[[type[BaseModel]], type[BaseModel]]: + """ + Decorates a Pydantic model class with a constraint forbidding any of the named fields from + a value, but only if the named condition field has a specific value. + + Parameters + ---------- + field_names : list[str] | tuple[str, ...] + List or tuple containing at least two unique field names to be conditionally forbidden. + condition_field_name : + Name of the field whose value determines whether to forbid the other named fields. + condition_field_value : + Value of the conditional field that caused the other named fields to be forbidden. + + Returns + ------- + Callable + Decorator factory + + Example + ------- + >>> from pydantic import BaseModel, ValidationError + >>> + >>> @forbid_fields_if(['bar', 'baz'], 'foo', 'special value') + ... class MyModel(BaseModel): + ... foo: str + ... bar: int | None = None + ... baz: str | None = None + ... + >>> MyModel(foo='something', bar=42, baz='qux') # validates OK + MyModel(foo='something', bar=42, baz='qux') + >>> MyModel(foo='special value') # validates OK because bar/baz are omitted + MyModel(foo='special value', bar=None, baz=None) + >>> + >>> try: + ... MyModel(foo='special value', bar=42) + ... except ValidationError as e: + ... assert ( + ... "at least one field has a value when it should not: bar - these field value(s) " + ... "are forbidden because field foo has value 'special value'" + ... ) in str(e) + ... print('Validation failed') + Validation failed + """ + + model_constraint = ForbidFieldsIfConstraint._create_internal( + f"@{forbid_fields_if.__name__}", + field_names, + condition_field_name, + condition_value, + ) + + return model_constraint.decorate + + +class ForbidFieldsIfConstraint(OptionalFieldGroupConstraint): + """ + Class implementing the `forbid_fields_if` decorator, which can also be used standalone. + """ + + def __init__( + self, + field_names: list[str] | tuple[str, ...], + condition_field_name: str, + condition_value: object, + ): + super().__init__(None, tuple(field_names)) + self.__set_condition(condition_field_name, condition_value) + + @classmethod + def _create_internal( + cls, + name: str, + field_names: list[str] | tuple[str, ...], + condition_field_name: str, + condition_value: object, + ) -> "ForbidFieldsIfConstraint": + instance = cls.__new__(cls) + super(ForbidFieldsIfConstraint, instance).__init__(name, tuple(field_names)) + instance.__set_condition(condition_field_name, condition_value) + return instance + + def __set_condition( + self, condition_field_name: str, condition_value: object + ) -> None: + if not isinstance(condition_field_name, str): + raise TypeError( + f"`condition_field_name` must be a `str`, but {condition_field_name} is a {type(condition_field_name).__name__} (`{self.name}`)" + ) + self.__condition_field_name = condition_field_name + self.__condition_value = condition_value + + @override + def validate_instance(self, model_instance: BaseModel) -> None: + super().validate_instance(model_instance) + + actual_value = getattr(model_instance, self.__condition_field_name) + if actual_value != self.__condition_value: + return + + present_fields = [ + f for f in self.field_names if getattr(model_instance, f) is not None + ] + if present_fields: + raise ValueError( + f"at least one field has a value when it should not: {', '.join(present_fields)} - " + f"these field value(s) are forbidden because field {self.__condition_field_name} " + f"has value {repr(self.__condition_value)} (`{self.name}`)" + ) + + @override + def validate_class(self, model_class: type[BaseModel]) -> None: + super().validate_class(model_class) + + if self.__condition_field_name not in model_class.model_fields: + raise TypeError( + f"`{self.name}` expects the model class `{model_class.__name__}` to contain the condition field {repr(self.__condition_field_name)}, but it does not" + ) + + @override + def edit_config(self, model_class: type[BaseModel], config: ConfigDict) -> None: + super().edit_config(model_class, config) + + json_schema = get_static_json_schema(config) + + put_if( + json_schema, + { + "properties": { + self.__condition_field_name: { + "not": {"const": to_jsonable_python(self.__condition_value)} + } + } + }, + {"required": [apply_alias(model_class, f) for f in self.field_names]}, + ) diff --git a/packages/overture-schema-system/src/overture/schema/system/model_constraint/json_schema.py b/packages/overture-schema-system/src/overture/schema/system/model_constraint/json_schema.py index dc46ba866..d1385a93d 100644 --- a/packages/overture-schema-system/src/overture/schema/system/model_constraint/json_schema.py +++ b/packages/overture-schema-system/src/overture/schema/system/model_constraint/json_schema.py @@ -60,11 +60,52 @@ def put_one_of(json_schema: JsonDict, operands: list[JsonDict]) -> None: put_all_of(json_schema, [prev, {"oneOf": cast(JsonValue, operands)}]) +def put_not(json_schema: JsonDict, operand: JsonDict) -> None: + origin = cast(type, get_origin(JsonDict)) + if not isinstance(operand, origin): + raise TypeError( + f"`operand` must be a `JsonDict` value, but it is not: {operand}" + ) + prev: JsonDict = {} + _try_move("not", json_schema, prev) + + # Simple case: if the JSON didn't already have a "not", we just add it. + if not prev: + json_schema["not"] = operand + return + + not_schema = prev["not"] + if not isinstance(not_schema, origin): + raise ValueError( + f'expected value of "not" key to be a `JsonDict`, but it is a {type(not_schema).__name__} in the JSON Schema {json_schema}' + ) + not_schema = cast(JsonDict, not_schema) + + # Next simplest case: the only child of the "not" is "anyOf". + if len(not_schema) == 1 and "anyOf" in not_schema: + not_any_of_schema = not_schema["anyOf"] + if not isinstance(not_any_of_schema, list): + raise ValueError( + 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}' + ) + not_any_of_schema.append(operand) + json_schema["not"] = not_schema + return + + # Most complex case: "not" either contains multiple keys, or a key that's not "anyOf". + json_schema["not"] = { + "anyOf": [ + not_schema, + operand, + ] + } + + def put_if( json_schema: JsonDict, condition: JsonDict, when_true: JsonDict, - when_false: JsonDict | None, + when_false: JsonDict | None = None, ) -> None: prev: JsonDict = {} _try_move("if", json_schema, prev) diff --git a/packages/overture-schema-system/src/overture/schema/system/model_constraint/model_constraint.py b/packages/overture-schema-system/src/overture/schema/system/model_constraint/model_constraint.py index db9951af4..e76fa2092 100644 --- a/packages/overture-schema-system/src/overture/schema/system/model_constraint/model_constraint.py +++ b/packages/overture-schema-system/src/overture/schema/system/model_constraint/model_constraint.py @@ -1,3 +1,4 @@ +from collections import Counter from collections.abc import Callable from copy import deepcopy from typing import Any, cast, final @@ -8,6 +9,7 @@ create_model, model_validator, ) +from typing_extensions import override class ModelConstraint: @@ -20,6 +22,13 @@ class ModelConstraint: Java code as they do in Pydantic. Second, model constraints have integrated JSON Schema hooks to allow them to describe how the constraint should be applied at the JSON Schema level. The model constraints defined in this package all provide applicable JSON Schema enhancements. + + Parameters + ---------- + name : str | None + Friendly name of the constraint instance for error messaging purposes. This should be set + to `None` if a constraint class was instantiated directly, or to the decorator name if the + constraint was instantiated via decorator function. """ def __init__(self, name: str | None = None): @@ -195,8 +204,8 @@ def get_model_constraints( >>> from overture.schema.system.model_constraint import require_any_of >>> @require_any_of("foo", "bar") ... class MyModel(BaseModel): - ... foo: int | None - ... bar: str | None + ... foo: int | None = None + ... bar: str | None = None ... >>> [c.name for c in ModelConstraint.get_model_constraints(MyModel)] ['@require_any_of'] @@ -220,6 +229,107 @@ def get_model_constraints( _MODEL_CONSTRAINT_PRIVATE_LIST_NAME = "_ModelConstraint__private_list" +class FieldGroupConstraint(ModelConstraint): + """ + A model constraint that constrains a group of fields in the Pydantic model it decorates. + + Use this constraint as a base class when developing model constraints that affect lists of + fields. It takes care of validating the list of field names at construction time (checking for + duplicates, minimum count, and proper types). It then validates the model class being decorated + (to ensure it contains all the expected fields). Subclasses may want to add additional + validation, for example to check the types of the constrained fields. + + Use `OptionalFieldGroupConstraint` rather than `FieldGroupConstraint` if it is important that + the fields in the group are all optional. + + Parameters + ---------- + name : str | None + Friendly name of the constraint instance for error messaging purposes. This should be set + to `None` if a constraint class was instantiated directly, or to the decorator name if the + constraint was instantiated via decorator function. + field_names : tuple[str, ...] + Names of at least two model fields affected by the constraint + + Raises + ------ + ValueError + If `field_names` has fewer than two names in it or contains duplicates + TypeError + If `field_names` is not a `tuple` of `str` + """ + + def __init__(self, name: str | None, field_names: tuple[str, ...]): + super().__init__(name) + self.__set_field_names(field_names) + + @property + def field_names(self) -> tuple[str, ...]: + return self.__field_names + + def __set_field_names(self, field_names: tuple[str, ...]) -> None: + if not isinstance(field_names, tuple): + raise TypeError( + f"`field_names` must be a `tuple`, but {field_names} is a `{type(field_names).__name__}" + ) + elif len(field_names) == 0: + raise ValueError("`field_names` cannot be empty, but it is") + elif not all(isinstance(s, str) for s in field_names): + raise TypeError( + f"`field_names` must contain only `str` values, but {field_names} contains at least one non-string" + ) + dupes = [s for s, count in Counter(field_names).items() if count > 1] + if dupes: + raise ValueError( + f"`field_names` must not contain duplicates, but {field_names} contains at least one repeated value" + ) + self.__field_names = field_names + + @override + def validate_class(self, model_class: type[BaseModel]) -> None: + missing_fields = [ + f for f in self.field_names if f not in model_class.model_fields + ] + if missing_fields: + raise TypeError( + f"`{self.name}` specifies fields that are not in the model class `{model_class.__name__}`: {', '.join(missing_fields)} " + ) + + +class OptionalFieldGroupConstraint(FieldGroupConstraint): + """ + A model constraint that constrains a group of *optional* fields in the Pydantic model it + decorates. + + Inherits all field validation behavior from FieldGroupConstraint and adds an additional check + that all specified fields are optional. + + Parameters + ---------- + name : str | None + Friendly name of the constraint instance for error messaging purposes. This should be set + to `None` if a constraint class was instantiated directly, or to the decorator name if the + constraint was instantiated via decorator function. + field_names : tuple[str, ...] + Names of at least two model fields affected by the constraint + """ + + def __init__(self, name: str | None, field_names: tuple[str, ...]): + super().__init__(name, field_names) + + @override + def validate_class(self, model_class: type[BaseModel]) -> None: + super().validate_class(model_class) + + required_fields = [ + f for f in self.field_names if model_class.model_fields[f].is_required() + ] + if required_fields: + raise TypeError( + f"`{self.name}` expects all the fields to be optional, but at least one is required in the model class `{model_class.__name__}`: {', '.join(required_fields)}" + ) + + def apply_alias(model_class: type[BaseModel], field_name: str) -> str: """ Resolve a field name to its alias if it has one. diff --git a/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_any_of.py b/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_any_of.py index f054b8a6e..7b792d775 100644 --- a/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_any_of.py +++ b/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_any_of.py @@ -1,4 +1,3 @@ -from collections import Counter from collections.abc import Callable from pydantic import BaseModel, ConfigDict @@ -6,7 +5,7 @@ from typing_extensions import override from .json_schema import get_static_json_schema, put_any_of -from .model_constraint import ModelConstraint, apply_alias +from .model_constraint import OptionalFieldGroupConstraint, apply_alias def require_any_of(*field_names: str) -> Callable[[type[BaseModel]], type[BaseModel]]: @@ -32,20 +31,20 @@ def require_any_of(*field_names: str) -> Callable[[type[BaseModel]], type[BaseMo >>> >>> @require_any_of("foo", "bar") ... class MyModel(BaseModel): - ... foo: int | None - ... bar: str | None + ... foo: int | None = None + ... bar: str | None = None ... >>> MyModel(foo=42, bar="hello") # validates OK MyModel(foo=42, bar='hello') - >>> MyModel(foo=42, bar=None) # validates OK + >>> MyModel(foo=42) # validates OK MyModel(foo=42, bar=None) - >>> MyModel(foo=None, bar="hello") # validates OK + >>> MyModel(bar="hello") # validates OK MyModel(foo=None, bar='hello') >>> >>> try: ... MyModel(foo=None, bar=None) ... except ValidationError as e: - ... assert "at least one of these fields must have a value, but none do: bar, foo" in str(e) + ... assert "at least one of these fields must have a value, but none do: foo, bar" in str(e) ... print("Validation failed") Validation failed """ @@ -56,60 +55,36 @@ def require_any_of(*field_names: str) -> Callable[[type[BaseModel]], type[BaseMo return model_constraint.decorate -class RequireAnyOfConstraint(ModelConstraint): +class RequireAnyOfConstraint(OptionalFieldGroupConstraint): """ Class implementing the `require_any_of` decorator, which can also be used standalone. """ def __init__(self, *field_names: str): - super().__init__() - self.__set_field_names(field_names) + super().__init__( + None, RequireAnyOfConstraint.__validate_field_names(field_names) + ) @classmethod def _create_internal(cls, name: str, *field_names: str) -> "RequireAnyOfConstraint": instance = cls.__new__(cls) - super(RequireAnyOfConstraint, instance).__init__(name) - instance.__set_field_names(field_names) + super(RequireAnyOfConstraint, instance).__init__( + name, RequireAnyOfConstraint.__validate_field_names(field_names) + ) return instance - def __set_field_names(self, field_names: tuple[str, ...]) -> None: - if not isinstance(field_names, tuple): - raise TypeError( - f"`field_names` must be a `tuple`, but {field_names} is a `{type(field_names).__name__}" - ) - elif ( - len(field_names) < 2 - ): # Minimum 2 field names: a field constraint is more appropriate if only 1 field. + @staticmethod + def __validate_field_names(field_names: tuple[str, ...]) -> tuple[str, ...]: + if len(field_names) < 2: raise ValueError( - f"`field_names` must contain at least two items, {field_names} does not" - ) - elif not all(isinstance(s, str) for s in field_names): - raise TypeError( - f"`field_names` must contain only `str` values, but {field_names} contains at least one non-string" - ) - dupes = [s for s, count in Counter(field_names).items() if count > 1] - if dupes: - raise ValueError( - f"`field_names` must not contain duplicates, but {field_names} contains at least one repeated value" - ) - self.__field_names = tuple(sorted(field_names)) - - @property - def field_names(self) -> tuple[str, ...]: - return self.__field_names - - @override - def validate_class(self, model_class: type[BaseModel]) -> None: - missing_fields = [ - f for f in self.field_names if f not in model_class.model_fields - ] - if missing_fields: - raise TypeError( - f"`{self.name}` specifies fields that are not in the model class `{model_class.__name__}`: {', '.join(missing_fields)} " + f"`field_names` must contain at least two items, but {field_names} has only {len(field_names)}" ) + return field_names @override def validate_instance(self, model_instance: BaseModel) -> None: + super().validate_instance(model_instance) + if not (any(getattr(model_instance, f) is not None for f in self.field_names)): raise ValueError( f"at least one of these fields must have a value, but none do: {', '.join(self.field_names)} (`{self.name}`)" @@ -117,6 +92,8 @@ def validate_instance(self, model_instance: BaseModel) -> None: @override def edit_config(self, model_class: type[BaseModel], config: ConfigDict) -> None: + super().edit_config(model_class, config) + json_schema = get_static_json_schema(config) def required(field_name: str) -> JsonDict: diff --git a/packages/overture-schema-system/tests/model_constraint/test_model_constraint_json_schema.py b/packages/overture-schema-system/tests/model_constraint/test_model_constraint_json_schema.py index ce9386a4f..90f63fcdd 100644 --- a/packages/overture-schema-system/tests/model_constraint/test_model_constraint_json_schema.py +++ b/packages/overture-schema-system/tests/model_constraint/test_model_constraint_json_schema.py @@ -9,6 +9,7 @@ put_all_of, put_any_of, put_if, + put_not, put_one_of, ) @@ -193,6 +194,64 @@ def test_put_one_of_error_bad_type(operands: list[JsonDict]) -> None: put_one_of({}, operands) +#################################################################################################### +# test_put_not # +#################################################################################################### + + +@pytest.mark.parametrize( + "json_schema,operand,expect", + [ + ({}, {}, {"not": {}}), + ({"foo": "bar"}, {"baz": "qux"}, {"foo": "bar", "not": {"baz": "qux"}}), + ({"not": {"anyOf": []}}, {}, {"not": {"anyOf": [{}]}}), + ( + {"not": {"anyOf": [1, 2]}}, + {"foo": "bar"}, + {"not": {"anyOf": [1, 2, {"foo": "bar"}]}}, + ), + ({"not": {}}, {}, {"not": {"anyOf": [{}, {}]}}), + ( + {"not": {"foo": "bar"}}, + {"baz": "qux"}, + {"not": {"anyOf": [{"foo": "bar"}, {"baz": "qux"}]}}, + ), + ( + {"not": {"foo": "bar", "anyOf": []}}, + {"baz": "qux"}, + {"not": {"anyOf": [{"foo": "bar", "anyOf": []}, {"baz": "qux"}]}}, + ), + ], +) +def test_put_not_success( + json_schema: JsonDict, operand: JsonDict, expect: JsonDict +) -> None: + put_not(json_schema, operand) + + assert expect == json_schema + + +def test_put_not_error_invalid_operand() -> None: + with pytest.raises( + TypeError, match="`operand` must be a `JsonDict` value, but it is not" + ): + put_not({}, cast(JsonDict, 123)) + + +def test_put_not_error_invalid_not_value() -> None: + with pytest.raises( + ValueError, match='expected value of "not" key to be a `JsonDict`' + ): + put_not({"not": []}, {}) + + +def test_put_not_error_invalid_not_any_of_value() -> None: + with pytest.raises( + ValueError, match='expected value of "anyOf" key under "not" to be a `list`' + ): + put_not({"not": {"anyOf": {}}}, {}) + + #################################################################################################### # test_put_if # #################################################################################################### diff --git a/packages/overture-schema-system/tests/model_constraint/test_require_any_of.py b/packages/overture-schema-system/tests/model_constraint/test_require_any_of.py index c478bfa95..785a3a656 100644 --- a/packages/overture-schema-system/tests/model_constraint/test_require_any_of.py +++ b/packages/overture-schema-system/tests/model_constraint/test_require_any_of.py @@ -31,7 +31,7 @@ def test_error_duplicate_field_names(field_names: list[str]): def test_error_invalid_model_class(): - expect = "specifies fields that are not in the model class `TestModel`: bar, foo" + expect = "specifies fields that are not in the model class `TestModel`: foo, bar" with pytest.raises(TypeError, match=expect): @@ -50,12 +50,12 @@ class TestModel(BaseModel): def test_error_invalid_model_instance(): @require_any_of("foo", "bar") class TestModel(BaseModel): - foo: int | None - bar: str | None + foo: int | None = None + bar: str | None = None with pytest.raises( ValidationError, - match="at least one of these fields must have a value, but none do: bar, foo", + match="at least one of these fields must have a value, but none do: foo, bar", ): TestModel(foo=None, bar=None) @@ -64,8 +64,8 @@ class TestModel(BaseModel): def test_valid_model_instance(foo: int | None, bar: str | None): @require_any_of("foo", "bar") class TestModel(BaseModel): - foo: int | None - bar: str | None + foo: int | None = None + bar: str | None = None TestModel(foo=foo, bar=bar) @@ -73,11 +73,11 @@ class TestModel(BaseModel): def test_model_json_schema_no_model_config(): @require_any_of("foo", "bar") class TestModel(BaseModel): - foo: int | None - bar: str | None = Field(alias="baz") + foo: int | None = None + bar: str | None = Field(default=None, alias="baz") actual = TestModel.model_json_schema() - expect = {"anyOf": [{"required": ["baz"]}, {"required": ["foo"]}]} + expect = {"anyOf": [{"required": ["foo"]}, {"required": ["baz"]}]} assert expect == TestModel.model_config["json_schema_extra"] assert_subset(expect, actual, "expect", "actual") @@ -85,13 +85,13 @@ class TestModel(BaseModel): @pytest.mark.parametrize( "base_json_schema,expect", [ - (None, {"anyOf": [{"required": ["baz"]}, {"required": ["foo"]}]}), + (None, {"anyOf": [{"required": ["foo"]}, {"required": ["baz"]}]}), ( {"anyOf": "anything"}, { "allOf": [ {"anyOf": "anything"}, - {"anyOf": [{"required": ["baz"]}, {"required": ["foo"]}]}, + {"anyOf": [{"required": ["foo"]}, {"required": ["baz"]}]}, ] }, ), @@ -104,8 +104,8 @@ def test_model_json_schema_with_model_config( class TestModel(BaseModel): model_config = ConfigDict(json_schema_extra=base_json_schema) - foo: int | None - bar: str | None = Field(alias="baz") + foo: int | None = None + bar: str | None = Field(default=None, alias="baz") actual = TestModel.model_json_schema() assert_subset(expect, actual, "expect", "actual") @@ -115,8 +115,8 @@ def test_model_constraints(): constraint = RequireAnyOfConstraint("foo", "bar") class TestModel(BaseModel): - foo: int | None - bar: str | None + foo: int | None = None + bar: str | None = None assert 0 == len(ModelConstraint.get_model_constraints(TestModel)) diff --git a/packages/overture-schema-validation/src/overture/schema/validation/mixin.py b/packages/overture-schema-validation/src/overture/schema/validation/mixin.py index 2e18c592c..c0e7741fe 100644 --- a/packages/overture-schema-validation/src/overture/schema/validation/mixin.py +++ b/packages/overture-schema-validation/src/overture/schema/validation/mixin.py @@ -168,6 +168,11 @@ def __init__( self.not_required_fields = not_required_fields def validate(self, model_instance: BaseModel) -> None: + # This logic is backward. The meaning of `{"not":{"required":["foo"]}}` in JSON Schema is a + # bit mysterious, but it is "foo" is required NOT to be there, i.e. not allowed. The + # docstring is actually saying the right thing "field should be None when condition is met", + # but that didn't make it into the `validate` logic, which is reversed. + if hasattr(model_instance, self.condition_field): condition_value = getattr(model_instance, self.condition_field) if condition_value != self.condition_value: From 4a6c675e401707915d0777dfe71c08cb3586a920 Mon Sep 17 00:00:00 2001 From: schapper Date: Thu, 9 Oct 2025 15:35:17 -0700 Subject: [PATCH 02/19] wip - CHECKPOINT - fix bug in divisions model and migrate @forbid_if, @require_if - NEED TESTS THOUGH build is passing but there's a unit test deficit growing around all the new classes --- .../src/overture/schema/core/validation.py | 4 - .../divisions/division_boundary/models.py | 11 +- .../division_boundary_baseline_schema.json | 22 +- .../system/model_constraint/__init__.py | 15 +- .../model_constraint/forbid_fields_if.py | 150 ------------ .../system/model_constraint/forbid_if.py | 136 +++++++++++ .../model_constraint/model_constraint.py | 182 ++++++++++++++ .../system/model_constraint/require_if.py | 132 ++++++++++ packages/overture-schema-validation/README.md | 9 - .../overture/schema/validation/__init__.py | 4 - .../src/overture/schema/validation/mixin.py | 176 -------------- .../tests/test_json_schema_generation.py | 19 -- .../tests/test_mixin_constraints.py | 225 ------------------ 13 files changed, 490 insertions(+), 595 deletions(-) delete mode 100644 packages/overture-schema-system/src/overture/schema/system/model_constraint/forbid_fields_if.py create mode 100644 packages/overture-schema-system/src/overture/schema/system/model_constraint/forbid_if.py create mode 100644 packages/overture-schema-system/src/overture/schema/system/model_constraint/require_if.py diff --git a/packages/overture-schema-core/src/overture/schema/core/validation.py b/packages/overture-schema-core/src/overture/schema/core/validation.py index 038536c05..e2d4ef6ab 100644 --- a/packages/overture-schema-core/src/overture/schema/core/validation.py +++ b/packages/overture-schema-core/src/overture/schema/core/validation.py @@ -2,14 +2,10 @@ ConstraintValidatedModel, exactly_one_of, min_properties, - not_required_if, - required_if, ) __all__ = [ "ConstraintValidatedModel", "exactly_one_of", "min_properties", - "not_required_if", - "required_if", ] diff --git a/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division_boundary/models.py b/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division_boundary/models.py index 06b975db5..015cd0307 100644 --- a/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division_boundary/models.py +++ b/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division_boundary/models.py @@ -15,9 +15,13 @@ ) from overture.schema.core.validation import ( exactly_one_of, - not_required_if, ) from overture.schema.system.field_constraint import UniqueItemsConstraint +from overture.schema.system.model_constraint import ( + FieldEqCondition, + forbid_if, + require_if, +) from overture.schema.system.primitive import ( Geometry, GeometryType, @@ -29,9 +33,12 @@ from ..enums import PlaceType from .enums import BoundaryClass +__IS_COUNTRY = FieldEqCondition("subtype", PlaceType.COUNTRY) + @exactly_one_of("is_land", "is_territorial") -@not_required_if("subtype", PlaceType.COUNTRY, ["country"]) +@forbid_if(["country"], __IS_COUNTRY) +@require_if(["country"], ~__IS_COUNTRY) class DivisionBoundary(Feature[Literal["divisions"], Literal["division_boundary"]]): """Boundaries represent borders between divisions of the same subtype. diff --git a/packages/overture-schema-divisions-theme/tests/division_boundary_baseline_schema.json b/packages/overture-schema-divisions-theme/tests/division_boundary_baseline_schema.json index abe41f08e..ac41e802a 100644 --- a/packages/overture-schema-divisions-theme/tests/division_boundary_baseline_schema.json +++ b/packages/overture-schema-divisions-theme/tests/division_boundary_baseline_schema.json @@ -227,9 +227,9 @@ "allOf": [ { "if": { - "properties": { - "subtype": { - "not": { + "not": { + "properties": { + "subtype": { "const": "country" } } @@ -240,6 +240,22 @@ "country" ] } + }, + { + "if": { + "properties": { + "subtype": { + "const": "country" + } + } + }, + "then": { + "not": { + "required": [ + "country" + ] + } + } } ], "oneOf": [ diff --git a/packages/overture-schema-system/src/overture/schema/system/model_constraint/__init__.py b/packages/overture-schema-system/src/overture/schema/system/model_constraint/__init__.py index 4156da2ae..0fb995f6d 100644 --- a/packages/overture-schema-system/src/overture/schema/system/model_constraint/__init__.py +++ b/packages/overture-schema-system/src/overture/schema/system/model_constraint/__init__.py @@ -1,22 +1,31 @@ -from .forbid_fields_if import ForbidFieldsIfConstraint, forbid_fields_if +from .forbid_if import ForbidIfConstraint, forbid_if from .model_constraint import ( + Condition, + FieldEqCondition, FieldGroupConstraint, ModelConstraint, + Not, OptionalFieldGroupConstraint, apply_alias, ) from .no_extra_fields import NoExtraFieldsConstraint, no_extra_fields from .require_any_of import RequireAnyOfConstraint, require_any_of +from .require_if import RequireIfConstraint, require_if __all__ = [ "apply_alias", + "Condition", + "FieldEqCondition", "FieldGroupConstraint", - "forbid_fields_if", - "ForbidFieldsIfConstraint", + "forbid_if", + "ForbidIfConstraint", "ModelConstraint", "no_extra_fields", "NoExtraFieldsConstraint", + "Not", "OptionalFieldGroupConstraint", "require_any_of", + "require_if", "RequireAnyOfConstraint", + "RequireIfConstraint", ] diff --git a/packages/overture-schema-system/src/overture/schema/system/model_constraint/forbid_fields_if.py b/packages/overture-schema-system/src/overture/schema/system/model_constraint/forbid_fields_if.py deleted file mode 100644 index 278520455..000000000 --- a/packages/overture-schema-system/src/overture/schema/system/model_constraint/forbid_fields_if.py +++ /dev/null @@ -1,150 +0,0 @@ -from collections.abc import Callable - -from pydantic import BaseModel, ConfigDict -from pydantic.json_schema import to_jsonable_python -from typing_extensions import override - -from .json_schema import get_static_json_schema, put_if -from .model_constraint import OptionalFieldGroupConstraint, apply_alias - - -def forbid_fields_if( - field_names: list[str] | tuple[str, ...], - condition_field_name: str, - condition_value: object, -) -> Callable[[type[BaseModel]], type[BaseModel]]: - """ - Decorates a Pydantic model class with a constraint forbidding any of the named fields from - a value, but only if the named condition field has a specific value. - - Parameters - ---------- - field_names : list[str] | tuple[str, ...] - List or tuple containing at least two unique field names to be conditionally forbidden. - condition_field_name : - Name of the field whose value determines whether to forbid the other named fields. - condition_field_value : - Value of the conditional field that caused the other named fields to be forbidden. - - Returns - ------- - Callable - Decorator factory - - Example - ------- - >>> from pydantic import BaseModel, ValidationError - >>> - >>> @forbid_fields_if(['bar', 'baz'], 'foo', 'special value') - ... class MyModel(BaseModel): - ... foo: str - ... bar: int | None = None - ... baz: str | None = None - ... - >>> MyModel(foo='something', bar=42, baz='qux') # validates OK - MyModel(foo='something', bar=42, baz='qux') - >>> MyModel(foo='special value') # validates OK because bar/baz are omitted - MyModel(foo='special value', bar=None, baz=None) - >>> - >>> try: - ... MyModel(foo='special value', bar=42) - ... except ValidationError as e: - ... assert ( - ... "at least one field has a value when it should not: bar - these field value(s) " - ... "are forbidden because field foo has value 'special value'" - ... ) in str(e) - ... print('Validation failed') - Validation failed - """ - - model_constraint = ForbidFieldsIfConstraint._create_internal( - f"@{forbid_fields_if.__name__}", - field_names, - condition_field_name, - condition_value, - ) - - return model_constraint.decorate - - -class ForbidFieldsIfConstraint(OptionalFieldGroupConstraint): - """ - Class implementing the `forbid_fields_if` decorator, which can also be used standalone. - """ - - def __init__( - self, - field_names: list[str] | tuple[str, ...], - condition_field_name: str, - condition_value: object, - ): - super().__init__(None, tuple(field_names)) - self.__set_condition(condition_field_name, condition_value) - - @classmethod - def _create_internal( - cls, - name: str, - field_names: list[str] | tuple[str, ...], - condition_field_name: str, - condition_value: object, - ) -> "ForbidFieldsIfConstraint": - instance = cls.__new__(cls) - super(ForbidFieldsIfConstraint, instance).__init__(name, tuple(field_names)) - instance.__set_condition(condition_field_name, condition_value) - return instance - - def __set_condition( - self, condition_field_name: str, condition_value: object - ) -> None: - if not isinstance(condition_field_name, str): - raise TypeError( - f"`condition_field_name` must be a `str`, but {condition_field_name} is a {type(condition_field_name).__name__} (`{self.name}`)" - ) - self.__condition_field_name = condition_field_name - self.__condition_value = condition_value - - @override - def validate_instance(self, model_instance: BaseModel) -> None: - super().validate_instance(model_instance) - - actual_value = getattr(model_instance, self.__condition_field_name) - if actual_value != self.__condition_value: - return - - present_fields = [ - f for f in self.field_names if getattr(model_instance, f) is not None - ] - if present_fields: - raise ValueError( - f"at least one field has a value when it should not: {', '.join(present_fields)} - " - f"these field value(s) are forbidden because field {self.__condition_field_name} " - f"has value {repr(self.__condition_value)} (`{self.name}`)" - ) - - @override - def validate_class(self, model_class: type[BaseModel]) -> None: - super().validate_class(model_class) - - if self.__condition_field_name not in model_class.model_fields: - raise TypeError( - f"`{self.name}` expects the model class `{model_class.__name__}` to contain the condition field {repr(self.__condition_field_name)}, but it does not" - ) - - @override - def edit_config(self, model_class: type[BaseModel], config: ConfigDict) -> None: - super().edit_config(model_class, config) - - json_schema = get_static_json_schema(config) - - put_if( - json_schema, - { - "properties": { - self.__condition_field_name: { - "not": {"const": to_jsonable_python(self.__condition_value)} - } - } - }, - {"required": [apply_alias(model_class, f) for f in self.field_names]}, - ) diff --git a/packages/overture-schema-system/src/overture/schema/system/model_constraint/forbid_if.py b/packages/overture-schema-system/src/overture/schema/system/model_constraint/forbid_if.py new file mode 100644 index 000000000..46e0d7350 --- /dev/null +++ b/packages/overture-schema-system/src/overture/schema/system/model_constraint/forbid_if.py @@ -0,0 +1,136 @@ +from collections.abc import Callable + +from pydantic import BaseModel, ConfigDict +from typing_extensions import override + +from .json_schema import get_static_json_schema, put_if +from .model_constraint import ( + Condition, + OptionalFieldGroupConstraint, + apply_alias, +) + + +def forbid_if( + field_names: list[str] | tuple[str, ...], + condition: Condition, +) -> Callable[[type[BaseModel]], type[BaseModel]]: + """ + Decorates a Pydantic model class with a constraint forbidding any of the named fields from + a value, but only if a field value condition is true. + + Parameters + ---------- + field_names : list[str] | tuple[str, ...] + List or tuple containing at least one unique field name to be conditionally forbidden. + condition : Condition + Condition that must be true to forbid the named fields + + Returns + ------- + Callable + Decorator factory + + Example + ------- + >>> from pydantic import BaseModel, ValidationError + >>> from overture.schema.system.model_constraint import FieldEqCondition + >>> + >>> @forbid_if(['bar', 'baz'], FieldEqCondition('foo', 'special value')) + ... class MyModel(BaseModel): + ... foo: str + ... bar: int | None = None + ... baz: str | None = None + ... + >>> MyModel(foo='something', bar=42, baz='qux') # validates OK + MyModel(foo='something', bar=42, baz='qux') + >>> MyModel(foo='special value') # validates OK because bar/baz are omitted + MyModel(foo='special value', bar=None, baz=None) + >>> + >>> try: + ... MyModel(foo='special value', bar=42) + ... except ValidationError as e: + ... assert 'at least one field has a value when it should not: bar' in str(e) + ... print('Validation failed') + Validation failed + """ + + model_constraint = ForbidIfConstraint._create_internal( + f"@{forbid_if.__name__}", + field_names, + condition, + ) + + return model_constraint.decorate + + +class ForbidIfConstraint(OptionalFieldGroupConstraint): + """ + Class implementing the `forbid_if` decorator, which can also be used standalone. + """ + + def __init__( + self, + field_names: list[str] | tuple[str, ...], + condition: Condition, + ): + super().__init__(None, tuple(field_names)) + self.__set_condition(condition) + + @classmethod + def _create_internal( + cls, + name: str, + field_names: list[str] | tuple[str, ...], + condition: Condition, + ) -> "ForbidIfConstraint": + instance = cls.__new__(cls) + super(ForbidIfConstraint, instance).__init__(name, tuple(field_names)) + instance.__set_condition(condition) + return instance + + def __set_condition(self, condition: Condition) -> None: + if not isinstance(condition, Condition): + raise TypeError( + f"`condition` must be a `{Condition.__name__}`, but {repr(condition)} is a {type(condition).__name__} (`{self.name}`)" + ) + self.__condition = condition + + @override + def validate_instance(self, model_instance: BaseModel) -> None: + super().validate_instance(model_instance) + + if not self.__condition.eval(model_instance): + return + + present_fields = [ + f for f in self.field_names if getattr(model_instance, f) is not None + ] + + if present_fields: + raise ValueError( + f"at least one field has a value when it should not: {', '.join(present_fields)} - " + f"these field value(s) are forbidden because {self.__condition} is true" + ) + + @override + def validate_class(self, model_class: type[BaseModel]) -> None: + super().validate_class(model_class) + + self.__condition.validate_class(model_class) + + @override + def edit_config(self, model_class: type[BaseModel], config: ConfigDict) -> None: + super().edit_config(model_class, config) + + json_schema = get_static_json_schema(config) + + put_if( + json_schema, + self.__condition.json_schema(model_class), + { + "not": { + "required": [apply_alias(model_class, f) for f in self.field_names] + } + }, + ) diff --git a/packages/overture-schema-system/src/overture/schema/system/model_constraint/model_constraint.py b/packages/overture-schema-system/src/overture/schema/system/model_constraint/model_constraint.py index e76fa2092..2931ad6b9 100644 --- a/packages/overture-schema-system/src/overture/schema/system/model_constraint/model_constraint.py +++ b/packages/overture-schema-system/src/overture/schema/system/model_constraint/model_constraint.py @@ -1,6 +1,8 @@ +from abc import ABC, abstractmethod from collections import Counter from collections.abc import Callable from copy import deepcopy +from dataclasses import dataclass from typing import Any, cast, final from pydantic import ( @@ -9,6 +11,7 @@ create_model, model_validator, ) +from pydantic.json_schema import JsonDict, to_jsonable_python from typing_extensions import override @@ -330,6 +333,185 @@ def validate_class(self, model_class: type[BaseModel]) -> None: ) +class Condition(ABC): + @final + def __invert__(self) -> "Condition": + return self.negate() + + @abstractmethod + def validate_class(self, model_class: type[BaseModel]) -> None: + """ + Validates that the constraint is appropriate for the model class. + + Parameters + ---------- + model_class : type[BaseModel] + Pydantic model class being validated + + Raises + ------ + TypeError + If the model class is invalid. + """ + raise NotImplementedError() + + @abstractmethod + def eval(self, model_instance: BaseModel) -> bool: + """ + Evaluates the condition against a Pydantic model instance. + + This method must only be called on model instances where `validate_class` does not raise + an exception on the instance's model class. + + Parameters + ---------- + model_instance : BaseModel + Model to evaluate the condition against + + Returns + ------- + bool + Whether the condition evaluated `true` or not + """ + raise NotImplementedError() + + def negate(self) -> "Condition": + """ + Returns a condition that represents the logical negation of this condition. + + Examples + -------- + >>> FieldEqCondition('foo', 'bar').negate() + Not(FieldEqCondition(field_name='foo', value='bar')) + + The `~` operator can be used as shorthand. + + >>> ~FieldEqCondition('foo', 'bar') + Not(FieldEqCondition(field_name='foo', value='bar')) + """ + return Not(self) + + def json_schema(self, model_class: type[BaseModel]) -> JsonDict: + """ + Returns a JSON Schema that models the condition value with respect to a Pydantic model + class. + + This method must only be called on model classes for which `validate_class` does not raise + an exception. + + Parameters + ---------- + model_class : type[BaseModel] + Pydantic model class being this condition is being evaluated against + + Returns + ------- + JsonDict + JSON Schema for this condition with respect to `model_class` + """ + raise NotImplementedError() + + +@dataclass(frozen=True, slots=True) +class Not(Condition): + inner: Condition + + def __repr__(self) -> str: + return f"Not({repr(self.inner)})" + + @override + def validate_class(self, model_class: type[BaseModel]) -> None: + return self.inner.validate_class(model_class) + + @override + def eval(self, model_instance: BaseModel) -> bool: + return not self.inner.eval(model_instance) + + @override + def negate(self) -> Condition: + return self.inner + + @override + def json_schema(self, model_class: type[BaseModel]) -> JsonDict: + return {"not": self.inner.json_schema(model_class)} + + +@dataclass(frozen=True, slots=True) +class __FieldCondition(Condition): + field_name: str + value: object + + def __post_init__(self) -> None: + if not isinstance(self.field_name, str): + raise TypeError( + f"`field_name` must be a `str`, but {repr(self.field_name)} is a {type(self.field_name).__name__})" + ) + + @override + def validate_class(self, model_class: type[BaseModel]) -> None: + """ + Validates that the constraint is appropriate for the model class. + + Parameters + ---------- + model_class : type[BaseModel] + Pydantic model class being validated + + Raises + ------ + TypeError + If the model class is invalid. + """ + if self.field_name not in model_class.model_fields: + raise TypeError( + f"`model class `{model_class.__name__}` must contain the condition field {repr(self.field_name)}, but it does not" + ) + + +class FieldEqCondition(__FieldCondition): + """ + Represents a condition that is true when a Pydantic field is set to a specific value. + + Attributes + ---------- + field_name : str + Name of the model field to check as part of the condition + value : object + Value the field must have for the condition to be true + + Examples + -------- + >>> from pydantic import BaseModel + >>> + >>> class MyModel(BaseModel): + ... foo: str + ... + >>> condition = FieldEqCondition('foo', 'baz') + >>> condition.validate_class(MyModel) + >>> + >>> condition.eval(MyModel(foo='bar')) + False + >>> condition.eval(MyModel(foo='baz')) + True + >>> condition.negate().eval(MyModel(foo='baz')) + False + >>> (~condition).eval(MyModel(foo='bar')) # ~ is shorthand for `.negate()` + True + """ + + @override + def eval(self, model_instance: BaseModel) -> bool: + actual_value = getattr(model_instance, self.field_name) + return bool(actual_value == self.value) + + @override + def json_schema(self, model_class: type[BaseModel]) -> JsonDict: + property_name = apply_alias(model_class, self.field_name) + return { + "properties": {property_name: {"const": to_jsonable_python(self.value)}} + } + + def apply_alias(model_class: type[BaseModel], field_name: str) -> str: """ Resolve a field name to its alias if it has one. diff --git a/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_if.py b/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_if.py new file mode 100644 index 000000000..387673150 --- /dev/null +++ b/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_if.py @@ -0,0 +1,132 @@ +from collections.abc import Callable + +from pydantic import BaseModel, ConfigDict +from typing_extensions import override + +from .json_schema import get_static_json_schema, put_if +from .model_constraint import ( + Condition, + OptionalFieldGroupConstraint, + apply_alias, +) + + +def require_if( + field_names: list[str] | tuple[str, ...], + condition: Condition, +) -> Callable[[type[BaseModel]], type[BaseModel]]: + """ + Decorates a Pydantic model class with a constraint requiring all of the named fields to have a + value, but only if a field value condition is true. + + Parameters + ---------- + field_names : list[str] | tuple[str, ...] + List or tuple containing at least one unique field name to be conditionally required. + condition : Condition + Condition that must be true to require the named fields + + Returns + ------- + Callable + Decorator factory + + Example + ------- + >>> from pydantic import BaseModel, ValidationError + >>> from overture.schema.system.model_constraint import FieldEqCondition + >>> + >>> @require_if(['bar', 'baz'], FieldEqCondition('foo', 'special value')) + ... class MyModel(BaseModel): + ... foo: str + ... bar: int | None = None + ... baz: str | None = None + ... + >>> MyModel(foo='something') # validates OK + MyModel(foo='something', bar=None, baz=None) + >>> MyModel(foo='special value', bar=42, baz='qux') # validates OK because bar/baz are provided + MyModel(foo='special value', bar=42, baz='qux') + >>> + >>> try: + ... MyModel(foo='special value') + ... except ValidationError as e: + ... assert 'at least one field is missing a value when it should have one: bar, baz' in str(e) + ... print('Validation failed') + Validation failed + """ + + model_constraint = RequireIfConstraint._create_internal( + f"@{require_if.__name__}", + field_names, + condition, + ) + + return model_constraint.decorate + + +class RequireIfConstraint(OptionalFieldGroupConstraint): + """ + Class implementing the `require_if` decorator, which can also be used standalone. + """ + + def __init__( + self, + field_names: list[str] | tuple[str, ...], + condition: Condition, + ): + super().__init__(None, tuple(field_names)) + self.__set_condition(condition) + + @classmethod + def _create_internal( + cls, + name: str, + field_names: list[str] | tuple[str, ...], + condition: Condition, + ) -> "RequireIfConstraint": + instance = cls.__new__(cls) + super(RequireIfConstraint, instance).__init__(name, tuple(field_names)) + instance.__set_condition(condition) + return instance + + def __set_condition(self, condition: Condition) -> None: + if not isinstance(condition, Condition): + raise TypeError( + f"`condition` must be a `{Condition.__name__}`, but {repr(condition)} is a {type(condition).__name__} (`{self.name}`)" + ) + self.__condition = condition + + @override + def validate_instance(self, model_instance: BaseModel) -> None: + super().validate_instance(model_instance) + + if not self.__condition.eval(model_instance): + return + + missing_fields = [ + f for f in self.field_names if getattr(model_instance, f) is None + ] + + if missing_fields: + raise ValueError( + f"at least one field is missing a value when it should have one: {', '.join(missing_fields)} - " + f"these field value(s) are required because {self.__condition} is true`)" + ) + + @override + def validate_class(self, model_class: type[BaseModel]) -> None: + super().validate_class(model_class) + + self.__condition.validate_class(model_class) + + @override + def edit_config(self, model_class: type[BaseModel], config: ConfigDict) -> None: + super().edit_config(model_class, config) + + json_schema = get_static_json_schema(config) + + put_if( + json_schema, + self.__condition.json_schema(model_class), + {"required": [apply_alias(model_class, f) for f in self.field_names]}, + ) diff --git a/packages/overture-schema-validation/README.md b/packages/overture-schema-validation/README.md index 842d44161..7f6a30510 100644 --- a/packages/overture-schema-validation/README.md +++ b/packages/overture-schema-validation/README.md @@ -155,13 +155,4 @@ class OvertureFeatureProperties(BaseModel): dict[LanguageTag, str], Field(json_schema_extra={"additionalProperties": False}) ] - -# Division-specific validation with mixin constraints -@required_if("subtype", "region", ["parent_division_id"]) -class DivisionProperties(ConstraintValidatedModel, OvertureFeatureProperties): - theme: Literal["divisions"] = Field(...) - type: Literal["division"] = Field(...) - - subtype: PlaceType = Field(..., description="Administrative level") - parent_division_id: Optional[str] = Field(None, description="Parent ID") ``` diff --git a/packages/overture-schema-validation/src/overture/schema/validation/__init__.py b/packages/overture-schema-validation/src/overture/schema/validation/__init__.py index 62bf3cd1f..cf1a0a704 100644 --- a/packages/overture-schema-validation/src/overture/schema/validation/__init__.py +++ b/packages/overture-schema-validation/src/overture/schema/validation/__init__.py @@ -31,14 +31,10 @@ class MyModel(BaseModel): ConstraintValidatedModel, exactly_one_of, min_properties, - not_required_if, - required_if, ) __all__ = [ "ConstraintValidatedModel", "exactly_one_of", "min_properties", - "not_required_if", - "required_if", ] diff --git a/packages/overture-schema-validation/src/overture/schema/validation/mixin.py b/packages/overture-schema-validation/src/overture/schema/validation/mixin.py index c0e7741fe..f741dade0 100644 --- a/packages/overture-schema-validation/src/overture/schema/validation/mixin.py +++ b/packages/overture-schema-validation/src/overture/schema/validation/mixin.py @@ -86,152 +86,6 @@ def apply_json_schema_metadata( pass -class RequiredIfValidator(BaseConstraintValidator): - """Validates conditional field requirements.""" - - def __init__( - self, condition_field: str, condition_value: Any, required_fields: list[str] - ): - super().__init__() - self.condition_field = condition_field - self.condition_value = condition_value - self.required_fields = required_fields - - def validate(self, model_instance: BaseModel) -> None: - if hasattr(model_instance, self.condition_field): - condition_value = getattr(model_instance, self.condition_field) - if condition_value == self.condition_value: - for field_name in self.required_fields: - if ( - not hasattr(model_instance, field_name) - or getattr(model_instance, field_name) is None - ): - raise ValueError( - f"Field '{field_name}' is required when " - f"{self.condition_field} = {self.condition_value}" - ) - - def get_metadata( - self, model_class: type[BaseModel] | None = None, by_alias: bool = True - ) -> dict[str, Any]: - # Resolve field names to aliases if needed - condition_field = self.condition_field - required_fields = self.required_fields - - if model_class is not None: - resolved_condition = resolve_field_names( - model_class, [condition_field], by_alias - ) - resolved_required = resolve_field_names( - model_class, required_fields, by_alias - ) - condition_field = resolved_condition[0] - required_fields = resolved_required - - return { - "type": "required_if", - "condition_field": condition_field, - "condition_value": self.condition_value, - "required_fields": required_fields, - } - - def apply_json_schema_metadata( - self, - target_schema: dict[str, Any], - model_class: type[BaseModel] | None = None, - by_alias: bool = True, - ) -> None: - """Apply conditional requirement constraint to the schema.""" - metadata = self.get_metadata(model_class, by_alias) - - conditional_schema = { - "if": { - "properties": { - metadata["condition_field"]: {"const": metadata["condition_value"]} - } - }, - "then": {"required": metadata["required_fields"]}, - } - target_schema.setdefault("allOf", []).append(conditional_schema) - - -class NotRequiredIfValidator(BaseConstraintValidator): - """Validates conditional field NOT requirements (field should be None when condition - is met).""" - - def __init__( - self, condition_field: str, condition_value: Any, not_required_fields: list[str] - ): - super().__init__() - self.condition_field = condition_field - self.condition_value = condition_value - self.not_required_fields = not_required_fields - - def validate(self, model_instance: BaseModel) -> None: - # This logic is backward. The meaning of `{"not":{"required":["foo"]}}` in JSON Schema is a - # bit mysterious, but it is "foo" is required NOT to be there, i.e. not allowed. The - # docstring is actually saying the right thing "field should be None when condition is met", - # but that didn't make it into the `validate` logic, which is reversed. - - if hasattr(model_instance, self.condition_field): - condition_value = getattr(model_instance, self.condition_field) - if condition_value != self.condition_value: - for field_name in self.not_required_fields: - if ( - not hasattr(model_instance, field_name) - or getattr(model_instance, field_name) is None - ): - raise ValueError( - f"Field '{field_name}' is required when " - f"{self.condition_field} != {self.condition_value}" - ) - - def get_metadata( - self, model_class: type[BaseModel] | None = None, by_alias: bool = True - ) -> dict[str, Any]: - # Resolve field names to aliases if needed - condition_field = self.condition_field - not_required_fields = self.not_required_fields - - if model_class is not None: - resolved_condition = resolve_field_names( - model_class, [condition_field], by_alias - ) - resolved_not_required = resolve_field_names( - model_class, not_required_fields, by_alias - ) - condition_field = resolved_condition[0] - not_required_fields = resolved_not_required - - return { - "type": "not_required_if", - "condition_field": condition_field, - "condition_value": self.condition_value, - "not_required_fields": not_required_fields, - } - - def apply_json_schema_metadata( - self, - target_schema: dict[str, Any], - model_class: type[BaseModel] | None = None, - by_alias: bool = True, - ) -> None: - """Apply conditional not-required constraint to the schema.""" - metadata = self.get_metadata(model_class, by_alias) - - conditional_schema = { - "if": { - "properties": { - metadata["condition_field"]: { - "not": {"const": metadata["condition_value"]} - } - } - }, - "then": {"required": metadata["not_required_fields"]}, - } - target_schema.setdefault("allOf", []).append(conditional_schema) - - class ExactlyOneOfValidator(BaseConstraintValidator): """Validates that exactly one of multiple boolean fields is true.""" @@ -356,21 +210,6 @@ def register_constraint( constraints.append(constraint) -def required_if( - condition_field: str, condition_value: Any, required_fields: list[str] -) -> Any: - """Decorator to add conditional required field validation.""" - - def decorator(cls: type[BaseModel]) -> type[BaseModel]: - constraint = RequiredIfValidator( - condition_field, condition_value, required_fields - ) - register_constraint(cls, constraint) - return cls - - return decorator - - def exactly_one_of(*field_names: str) -> Any: """Decorator to add exactly-one-of validation where fields must be true.""" @@ -382,21 +221,6 @@ def decorator(cls: type[BaseModel]) -> type[BaseModel]: return decorator -def not_required_if( - condition_field: str, condition_value: Any, not_required_fields: list[str] -) -> Any: - """Decorator to add conditional not-required field validation.""" - - def decorator(cls: type[BaseModel]) -> type[BaseModel]: - constraint = NotRequiredIfValidator( - condition_field, condition_value, not_required_fields - ) - register_constraint(cls, constraint) - return cls - - return decorator - - def min_properties(min_count: int) -> Any: """Decorator to add minimum properties validation.""" diff --git a/packages/overture-schema-validation/tests/test_json_schema_generation.py b/packages/overture-schema-validation/tests/test_json_schema_generation.py index 8408846c9..7b20bf235 100644 --- a/packages/overture-schema-validation/tests/test_json_schema_generation.py +++ b/packages/overture-schema-validation/tests/test_json_schema_generation.py @@ -24,7 +24,6 @@ from overture.schema.validation.mixin import ( ConstraintValidatedModel, exactly_one_of, - required_if, ) @@ -126,24 +125,6 @@ class TestModel(ConstraintValidatedModel, BaseModel): assert field_a_condition["properties"]["field_a"]["const"] is True assert field_b_condition["properties"]["field_b"]["const"] is True - def test_conditional_required_constraint_json_schema(self) -> None: - """Test JSON Schema generation for conditional required constraint.""" - - @required_if("type_field", "special", ["required_field"]) - class TestModel(ConstraintValidatedModel, BaseModel): - type_field: str - required_field: str | None = None - - schema = TestModel.model_json_schema() - - # Should have conditional in allOf - assert "allOf" in schema - assert len(schema["allOf"]) == 1 - - condition = schema["allOf"][0] - assert "if" in condition - assert "then" in condition - def test_no_constraints_json_schema(self) -> None: """Test JSON Schema generation for models without constraints.""" diff --git a/packages/overture-schema-validation/tests/test_mixin_constraints.py b/packages/overture-schema-validation/tests/test_mixin_constraints.py index 2640e470b..4739683bc 100644 --- a/packages/overture-schema-validation/tests/test_mixin_constraints.py +++ b/packages/overture-schema-validation/tests/test_mixin_constraints.py @@ -10,15 +10,11 @@ ConstraintValidatedModel, exactly_one_of, min_properties, - not_required_if, - required_if, ) from overture.schema.validation.mixin import ( BaseConstraintValidator, ExactlyOneOfValidator, MinPropertiesValidator, - NotRequiredIfValidator, - RequiredIfValidator, ) @@ -262,146 +258,6 @@ class TestModel(ConstraintValidatedModel, BaseModel): assert len(schema["oneOf"]) == 2 -class TestConditionalRequiredValidator: - """Test conditional required constraint validation.""" - - def test_conditional_required_validator_direct(self) -> None: - """Test RequiredIfValidator directly.""" - - class TestModel(BaseModel): - type_field: str - required_field: str | None = None - - validator = RequiredIfValidator("type_field", "special", ["required_field"]) - - # Valid: condition not met, field can be None - model = TestModel(type_field="normal", required_field=None) - validator.validate(model) # Should not raise - - # Valid: condition met, field provided - model = TestModel(type_field="special", required_field="value") - validator.validate(model) # Should not raise - - # Invalid: condition met, field missing - model = TestModel(type_field="special", required_field=None) - with pytest.raises( - ValueError, - match="Field 'required_field' is required when type_field = special", - ): - validator.validate(model) - - def test_conditional_required_constraint_decorator(self) -> None: - """Test conditional required constraint using decorator.""" - - @required_if("subtype", "road", ["class_"]) - @required_if("subtype", "rail", ["class_"]) - class SegmentModel(ConstraintValidatedModel, BaseModel): - subtype: str - class_: str | None = None - - # Valid: subtype doesn't require class_ - model = SegmentModel(subtype="water", class_=None) - assert model.subtype == "water" - assert model.class_ is None - - # Valid: road subtype with class_ - model = SegmentModel(subtype="road", class_="primary") - assert model.subtype == "road" - assert model.class_ == "primary" - - # Valid: rail subtype with class_ - model = SegmentModel(subtype="rail", class_="passenger") - assert model.subtype == "rail" - assert model.class_ == "passenger" - - # Invalid: road subtype without class_ - with pytest.raises(ValidationError) as exc_info: - SegmentModel(subtype="road", class_=None) - assert "Field 'class_' is required when subtype = road" in str(exc_info.value) - - # Invalid: rail subtype without class_ - with pytest.raises(ValidationError) as exc_info: - SegmentModel(subtype="rail", class_=None) - assert "Field 'class_' is required when subtype = rail" in str(exc_info.value) - - def test_conditional_required_multiple_fields(self) -> None: - """Test conditional required constraint with multiple required fields.""" - - @required_if("type", "complex", ["field_a", "field_b"]) - class TestModel(ConstraintValidatedModel, BaseModel): - type: str - field_a: str | None = None - field_b: str | None = None - - # Valid: condition not met - model = TestModel(type="simple", field_a=None, field_b=None) - assert model.type == "simple" - - # Valid: condition met, all fields provided - model = TestModel(type="complex", field_a="value_a", field_b="value_b") - assert model.type == "complex" - assert model.field_a == "value_a" - assert model.field_b == "value_b" - - # Invalid: condition met, field_a missing - with pytest.raises(ValidationError) as exc_info: - TestModel(type="complex", field_a=None, field_b="value_b") - assert "Field 'field_a' is required when type = complex" in str(exc_info.value) - - -class TestConditionalNotRequiredValidator: - """Test conditional not required constraint validation.""" - - def test_conditional_not_required_validator_direct(self) -> None: - """Test NotRequiredIfValidator directly.""" - - class TestModel(BaseModel): - subtype: PlaceType - country: str | None = None - - validator = NotRequiredIfValidator("subtype", PlaceType.COUNTRY, ["country"]) - - # Valid: country subtype, country can be None - model = TestModel(subtype=PlaceType.COUNTRY, country=None) - validator.validate(model) # Should not raise - - # Valid: non-country subtype, country provided - model = TestModel(subtype=PlaceType.REGION, country="US") - validator.validate(model) # Should not raise - - # Invalid: non-country subtype, country missing - model = TestModel(subtype=PlaceType.REGION, country=None) - with pytest.raises( - ValueError, match="Field 'country' is required when subtype != country" - ): - validator.validate(model) - - def test_conditional_not_required_constraint_decorator(self) -> None: - """Test conditional not required constraint using decorator.""" - - @not_required_if("subtype", PlaceType.COUNTRY, ["country"]) - class BoundaryModel(ConstraintValidatedModel, BaseModel): - subtype: PlaceType - country: str | None = None - - # Valid: country subtype, no country field needed - model = BoundaryModel(subtype=PlaceType.COUNTRY, country=None) - assert model.subtype == PlaceType.COUNTRY - assert model.country is None - - # Valid: region subtype, country provided - model = BoundaryModel(subtype=PlaceType.REGION, country="US") - assert model.subtype == PlaceType.REGION - assert model.country == "US" - - # Invalid: region subtype, country missing - with pytest.raises(ValidationError) as exc_info: - BoundaryModel(subtype=PlaceType.REGION, country=None) - assert "Field 'country' is required when subtype != country" in str( - exc_info.value - ) - - class TestMinPropertiesValidator: """Test minimum properties constraint validation.""" @@ -533,68 +389,6 @@ class DerivedModel(BaseTestModel): ) -class TestMultipleConstraints: - """Test models with multiple constraints applied.""" - - def test_multiple_constraint_decorators(self) -> None: - """Test applying multiple constraint decorators to one model.""" - - @exactly_one_of("is_land", "is_territorial") - @required_if("subtype", PlaceType.REGION, ["region_code"]) - class ComplexModel(ConstraintValidatedModel, BaseModel): - subtype: PlaceType - parent_division_id: str | None = None - is_land: bool | None = None - is_territorial: bool | None = None - region_code: str | None = None - - # Valid: country with no parent, land boundary, no region code needed - model = ComplexModel( - subtype=PlaceType.COUNTRY, - parent_division_id=None, - is_land=True, - is_territorial=False, - region_code=None, - ) - assert model.subtype == PlaceType.COUNTRY - assert model.is_land is True - - # Valid: region with parent, territorial boundary, region code provided - model = ComplexModel( - subtype=PlaceType.REGION, - parent_division_id="parent", - is_land=False, - is_territorial=True, - region_code="US-CA", - ) - assert model.subtype == PlaceType.REGION - assert model.region_code == "US-CA" - - # Invalid: violates mutually exclusive constraint - with pytest.raises(ValidationError) as exc_info: - ComplexModel( - subtype=PlaceType.REGION, - parent_division_id="parent", - is_land=True, - is_territorial=True, # Both True - invalid - region_code="US-CA", - ) - assert "Exactly one field must be true, but found 2" in str(exc_info.value) - - # Invalid: violates conditional required constraint - with pytest.raises(ValidationError) as exc_info: - ComplexModel( - subtype=PlaceType.REGION, - parent_division_id="parent", - is_land=True, - is_territorial=False, - region_code=None, # Required when subtype is REGION - ) - assert "Field 'region_code' is required when subtype = region" in str( - exc_info.value - ) - - class TestConstraintErrorHandling: """Test error handling and edge cases.""" @@ -629,25 +423,6 @@ class InvalidModel(BaseModel): # Missing ConstraintValidatedModel ) assert model.field_a is True - def test_constraint_validation_order(self) -> None: - """Test that constraints are validated in the correct order.""" - - # This test ensures that field validation happens before constraint validation - @required_if("type_field", "special", ["required_field"]) - class OrderTestModel(ConstraintValidatedModel, BaseModel): - type_field: str - required_field: str | None = None - - # Field validation should catch invalid type_field before constraint validation - with pytest.raises(ValidationError) as exc_info: - # This should fail due to validation of enum field, not constraint - OrderTestModel(type_field=123, required_field=None) # Invalid type - - # Constraint validation should catch missing required field - with pytest.raises(ValidationError) as exc_info: - OrderTestModel(type_field="special", required_field=None) - assert "Field 'required_field' is required" in str(exc_info.value) - def test_json_schema_with_no_constraints(self) -> None: """Test JSON schema generation when no constraints are registered.""" From 94f943857535351590a8c93f9185a7b6b986e3ee Mon Sep 17 00:00:00 2001 From: schapper Date: Thu, 9 Oct 2025 17:30:47 -0700 Subject: [PATCH 03/19] wip - CHECKPOINT - re-homed `exactly_one_of` as `radio_group`, test deficit continues to incerase though --- .../src/overture/schema/core/validation.py | 2 - .../schema/divisions/division_area/models.py | 6 +- .../divisions/division_boundary/models.py | 6 +- .../system/model_constraint/__init__.py | 3 + .../system/model_constraint/forbid_if.py | 7 +- .../system/model_constraint/radio_group.py | 148 +++++++++ .../system/model_constraint/require_if.py | 6 +- .../overture/schema/validation/__init__.py | 2 - .../src/overture/schema/validation/mixin.py | 75 ----- .../tests/test_json_schema_generation.py | 280 ------------------ .../tests/test_mixin_constraints.py | 214 ------------- 11 files changed, 166 insertions(+), 583 deletions(-) create mode 100644 packages/overture-schema-system/src/overture/schema/system/model_constraint/radio_group.py diff --git a/packages/overture-schema-core/src/overture/schema/core/validation.py b/packages/overture-schema-core/src/overture/schema/core/validation.py index e2d4ef6ab..2b3677c84 100644 --- a/packages/overture-schema-core/src/overture/schema/core/validation.py +++ b/packages/overture-schema-core/src/overture/schema/core/validation.py @@ -1,11 +1,9 @@ from overture.schema.validation import ( ConstraintValidatedModel, - exactly_one_of, min_properties, ) __all__ = [ "ConstraintValidatedModel", - "exactly_one_of", "min_properties", ] diff --git a/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division_area/models.py b/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division_area/models.py index 8d07638f3..0f0d597bc 100644 --- a/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division_area/models.py +++ b/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division_area/models.py @@ -13,9 +13,7 @@ ) from overture.schema.core.ref import Reference, Relationship from overture.schema.core.types import CountryCodeAlpha2, Id -from overture.schema.core.validation import ( - exactly_one_of, -) +from overture.schema.system.model_constraint import radio_group from overture.schema.system.primitive import ( Geometry, GeometryType, @@ -28,7 +26,7 @@ from .enums import AreaClass -@exactly_one_of("is_land", "is_territorial") +@radio_group("is_land", "is_territorial") class DivisionArea(Feature[Literal["divisions"], Literal["division_area"]], Named): """Division areas are polygons that represent the land or maritime area covered by a division. diff --git a/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division_boundary/models.py b/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division_boundary/models.py index 015cd0307..4a1df10a7 100644 --- a/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division_boundary/models.py +++ b/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division_boundary/models.py @@ -13,13 +13,11 @@ CountryCodeAlpha2, Id, ) -from overture.schema.core.validation import ( - exactly_one_of, -) from overture.schema.system.field_constraint import UniqueItemsConstraint from overture.schema.system.model_constraint import ( FieldEqCondition, forbid_if, + radio_group, require_if, ) from overture.schema.system.primitive import ( @@ -36,9 +34,9 @@ __IS_COUNTRY = FieldEqCondition("subtype", PlaceType.COUNTRY) -@exactly_one_of("is_land", "is_territorial") @forbid_if(["country"], __IS_COUNTRY) @require_if(["country"], ~__IS_COUNTRY) +@radio_group("is_land", "is_territorial") class DivisionBoundary(Feature[Literal["divisions"], Literal["division_boundary"]]): """Boundaries represent borders between divisions of the same subtype. diff --git a/packages/overture-schema-system/src/overture/schema/system/model_constraint/__init__.py b/packages/overture-schema-system/src/overture/schema/system/model_constraint/__init__.py index 0fb995f6d..583ae6693 100644 --- a/packages/overture-schema-system/src/overture/schema/system/model_constraint/__init__.py +++ b/packages/overture-schema-system/src/overture/schema/system/model_constraint/__init__.py @@ -9,6 +9,7 @@ apply_alias, ) from .no_extra_fields import NoExtraFieldsConstraint, no_extra_fields +from .radio_group import RadioGroupConstraint, radio_group from .require_any_of import RequireAnyOfConstraint, require_any_of from .require_if import RequireIfConstraint, require_if @@ -24,6 +25,8 @@ "NoExtraFieldsConstraint", "Not", "OptionalFieldGroupConstraint", + "radio_group", + "RadioGroupConstraint", "require_any_of", "require_if", "RequireAnyOfConstraint", diff --git a/packages/overture-schema-system/src/overture/schema/system/model_constraint/forbid_if.py b/packages/overture-schema-system/src/overture/schema/system/model_constraint/forbid_if.py index 46e0d7350..7a6c868cf 100644 --- a/packages/overture-schema-system/src/overture/schema/system/model_constraint/forbid_if.py +++ b/packages/overture-schema-system/src/overture/schema/system/model_constraint/forbid_if.py @@ -96,6 +96,10 @@ def __set_condition(self, condition: Condition) -> None: ) self.__condition = condition + @property + def condition(self) -> Condition: + return self.__condition + @override def validate_instance(self, model_instance: BaseModel) -> None: super().validate_instance(model_instance) @@ -110,7 +114,8 @@ def validate_instance(self, model_instance: BaseModel) -> None: if present_fields: raise ValueError( f"at least one field has a value when it should not: {', '.join(present_fields)} - " - f"these field value(s) are forbidden because {self.__condition} is true" + f"these field value(s) are forbidden because {self.__condition} is true " + f"(`{self.name}`)" ) @override diff --git a/packages/overture-schema-system/src/overture/schema/system/model_constraint/radio_group.py b/packages/overture-schema-system/src/overture/schema/system/model_constraint/radio_group.py new file mode 100644 index 000000000..8cc12df3f --- /dev/null +++ b/packages/overture-schema-system/src/overture/schema/system/model_constraint/radio_group.py @@ -0,0 +1,148 @@ +from collections.abc import Callable +from types import NoneType, UnionType +from typing import Any, Union, get_args, get_origin + +from pydantic import BaseModel, ConfigDict +from pydantic.json_schema import JsonDict +from typing_extensions import override + +from .json_schema import get_static_json_schema, put_one_of +from .model_constraint import OptionalFieldGroupConstraint, apply_alias + + +def radio_group(*field_names: str) -> Callable[[type[BaseModel]], type[BaseModel]]: + """ + Decorates a Pydantic model class with a constraint requiring that exactly one field in a group + of `bool` fields has the value `True`. + + This function is the decorator version of the `RadioGroupConstraint` class. + + Historical node for nerds: the term radio group, meaning a group of radio buttons, was first + used in software user interfaces design as an analogy to the mechanical push buttons on the + physical home and automobile radio sets of the 1950s-1970s. These radio sets had preset station + buttons where pushing one button would physically pop out any other buttons, ensuring only one + station could be selected at a time. + + Parameters + ---------- + *field_names : str + Varargs list of at least two unique field names. + + Returns + ------- + Callable + Decorator factory + + Example + ------- + >>> from pydantic import BaseModel, ValidationError + >>> + >>> @radio_group("foo", "bar") + ... class MyModel(BaseModel): + ... foo: bool | None = None + ... bar: bool = True + ... + >>> MyModel() # validates OK + MyModel(foo=None, bar=True) + >>> MyModel(foo=False) # validates OK + MyModel(foo=False, bar=True) + >>> MyModel(foo=True, bar=False) # validates OK + MyModel(foo=True, bar=False) + >>> + >>> try: + ... MyModel(bar=False) + ... except ValidationError as e: + ... assert ( + ... "exactly one field from the `bool` field group [foo, bar] must be True, " + ... "but both of these fields are True: foo and bar" + ... ) in str(e) + ... print("Validation failed") + Validation failed + """ + model_constraint = RadioGroupConstraint._create_internal( + f"@{radio_group.__name__}", *field_names + ) + + return model_constraint.decorate + + +class RadioGroupConstraint(OptionalFieldGroupConstraint): + """ + Class implementing the `radio_group` decorator, which can also be used standalone. + """ + + def __init__(self, *field_names: str): + super().__init__(None, RadioGroupConstraint.__validate_field_names(field_names)) + + @classmethod + def _create_internal(cls, name: str, *field_names: str) -> "RadioGroupConstraint": + instance = cls.__new__(cls) + super(RadioGroupConstraint, instance).__init__( + name, RadioGroupConstraint.__validate_field_names(field_names) + ) + return instance + + @staticmethod + def __validate_field_names(field_names: tuple[str, ...]) -> tuple[str, ...]: + if len(field_names) < 2: + raise ValueError( + f"`field_names` must contain at least two items, but {field_names} has only {len(field_names)}" + ) + return field_names + + @override + def validate_class(self, model_class: type[BaseModel]) -> None: + super().validate_class(model_class) + + def is_bool(annotation: type[Any] | None) -> bool: + if annotation is bool: + return True + elif get_origin(annotation) in (Union, UnionType): + args = get_args(annotation) + return all(a in (bool, NoneType) for a in args) + else: + return False + + non_bool_fields = [ + f + for f in self.field_names + if not is_bool(model_class.model_fields[f].annotation) + ] + if non_bool_fields: + raise TypeError( + f"`{self.name}` specifies fields that are have a non-`bool` type in the `{model_class.__name__}`: {', '.join(non_bool_fields)} " + ) + + @override + def validate_instance(self, model_instance: BaseModel) -> None: + super().validate_instance(model_instance) + + non_true_fields = [ + f for f in self.field_names if getattr(model_instance, f) is not True + ] + + if len(non_true_fields) == 1: + return + elif len(non_true_fields) == 0: + msg = "none is True" + elif len(non_true_fields) == 2: + msg = f"both of these fields are True: {non_true_fields[0]} and {non_true_fields[1]}" + else: + msg = f"all of these fields are True: {', '.join(non_true_fields)}" + raise ValueError( + f"exactly one field from the `bool` field group [{', '.join(self.field_names)}] " + f"must be True, but {msg} (`{self.name}`)" + ) + + @override + def edit_config(self, model_class: type[BaseModel], config: ConfigDict) -> None: + super().edit_config(model_class, config) + + json_schema = get_static_json_schema(config) + + def has_true_value(field_name: str) -> JsonDict: + return { + "properties": {apply_alias(model_class, field_name): {"const": True}} + } + + put_one_of(json_schema, [has_true_value(f) for f in self.field_names]) diff --git a/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_if.py b/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_if.py index 387673150..d0df3251c 100644 --- a/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_if.py +++ b/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_if.py @@ -96,6 +96,10 @@ def __set_condition(self, condition: Condition) -> None: ) self.__condition = condition + @property + def condition(self) -> Condition: + return self.__condition + @override def validate_instance(self, model_instance: BaseModel) -> None: super().validate_instance(model_instance) @@ -110,7 +114,7 @@ def validate_instance(self, model_instance: BaseModel) -> None: if missing_fields: raise ValueError( f"at least one field is missing a value when it should have one: {', '.join(missing_fields)} - " - f"these field value(s) are required because {self.__condition} is true`)" + f"these field value(s) are required because {self.__condition} is true` (`{self.name}`)" ) @override diff --git a/packages/overture-schema-validation/src/overture/schema/validation/__init__.py b/packages/overture-schema-validation/src/overture/schema/validation/__init__.py index cf1a0a704..1ce1a6c20 100644 --- a/packages/overture-schema-validation/src/overture/schema/validation/__init__.py +++ b/packages/overture-schema-validation/src/overture/schema/validation/__init__.py @@ -29,12 +29,10 @@ class MyModel(BaseModel): from .mixin import ( ConstraintValidatedModel, - exactly_one_of, min_properties, ) __all__ = [ "ConstraintValidatedModel", - "exactly_one_of", "min_properties", ] diff --git a/packages/overture-schema-validation/src/overture/schema/validation/mixin.py b/packages/overture-schema-validation/src/overture/schema/validation/mixin.py index f741dade0..57aae6688 100644 --- a/packages/overture-schema-validation/src/overture/schema/validation/mixin.py +++ b/packages/overture-schema-validation/src/overture/schema/validation/mixin.py @@ -86,70 +86,6 @@ def apply_json_schema_metadata( pass -class ExactlyOneOfValidator(BaseConstraintValidator): - """Validates that exactly one of multiple boolean fields is true.""" - - def __init__(self, *field_names: str): - super().__init__() - self.field_names = field_names - - def validate(self, model_instance: BaseModel) -> None: - true_fields = [] - missing_fields = [] - - for field_name in self.field_names: - if hasattr(model_instance, field_name): - field_value = getattr(model_instance, field_name) - if field_value is True: - true_fields.append(field_name) - else: - missing_fields.append(field_name) - - # If all fields are missing, gracefully handle it (don't validate) - if len(missing_fields) == len(self.field_names): - return - - if len(true_fields) != 1: - if len(true_fields) == 0: - raise ValueError( - f"Exactly one of {', '.join(self.field_names)} must be true" - ) - else: - raise ValueError( - f"Exactly one field must be true, but found {len(true_fields)}: {', '.join(true_fields)}" - ) - - def get_metadata( - self, model_class: type[BaseModel] | None = None, by_alias: bool = True - ) -> dict[str, Any]: - # Resolve field names to aliases if needed - field_names = list(self.field_names) - if model_class is not None: - field_names = resolve_field_names(model_class, field_names, by_alias) - - return { - "type": "exactly_one_of", - "field_names": field_names, - } - - def apply_json_schema_metadata( - self, - target_schema: dict[str, Any], - model_class: type[BaseModel] | None = None, - by_alias: bool = True, - ) -> None: - """Apply oneOf constraint to the schema.""" - metadata = self.get_metadata(model_class, by_alias) - - # Generate oneOf constraint where exactly one field is true - one_of_clauses = [] - for field in metadata["field_names"]: - clause = {"properties": {field: {"const": True}}} - one_of_clauses.append(clause) - - target_schema.setdefault("oneOf", []).extend(one_of_clauses) - - class MinPropertiesValidator(BaseConstraintValidator): """Validates that at least N properties are set on a model.""" @@ -210,17 +146,6 @@ def register_constraint( constraints.append(constraint) -def exactly_one_of(*field_names: str) -> Any: - """Decorator to add exactly-one-of validation where fields must be true.""" - - def decorator(cls: type[BaseModel]) -> type[BaseModel]: - constraint = ExactlyOneOfValidator(*field_names) - register_constraint(cls, constraint) - return cls - - return decorator - - def min_properties(min_count: int) -> Any: """Decorator to add minimum properties validation.""" diff --git a/packages/overture-schema-validation/tests/test_json_schema_generation.py b/packages/overture-schema-validation/tests/test_json_schema_generation.py index 7b20bf235..03317ceb9 100644 --- a/packages/overture-schema-validation/tests/test_json_schema_generation.py +++ b/packages/overture-schema-validation/tests/test_json_schema_generation.py @@ -4,27 +4,6 @@ from typing import Any import pytest -from overture.schema.core.types import ( - ConfidenceScoreConstraint, - LinearReferenceRangeConstraint, -) -from overture.schema.system.field_constraint import UniqueItemsConstraint -from overture.schema.system.field_constraint.string import ( - CountryCodeAlpha2Constraint, - HexColorConstraint, - JsonPointerConstraint, - LanguageTagConstraint, - NoWhitespaceConstraint, - PatternConstraint, - RegionCodeConstraint, - StrippedConstraint, -) -from pydantic import BaseModel, Field - -from overture.schema.validation.mixin import ( - ConstraintValidatedModel, - exactly_one_of, -) class SubtypeEnum(str, Enum): @@ -35,18 +14,6 @@ class SubtypeEnum(str, Enum): LOCALITY = "locality" -def create_field_constraint_model( - field_type: type[Any], constraint_instance: object -) -> type[BaseModel]: - """Create a test model with a single field using the given constraint.""" - from typing import Annotated - - class TestModel(ConstraintValidatedModel, BaseModel): - test_field: Annotated[Any, constraint_instance] - - return TestModel - - def assert_pattern_constraint( schema: dict[str, Any], field_name: str, @@ -101,252 +68,5 @@ def assert_collection_constraint( assert field_schema.get("uniqueItems") == unique_items -class TestJSONSchemaGeneration: - """Test JSON Schema generation for constraint-validated models.""" - - def test_exactly_one_of_constraint_json_schema(self) -> None: - """Test JSON Schema generation for mutually exclusive constraint.""" - - @exactly_one_of("field_a", "field_b") - class TestModel(ConstraintValidatedModel, BaseModel): - field_a: bool | None = None - field_b: bool | None = None - - schema = TestModel.model_json_schema() - - # Should have oneOf constraint at top level (parallel to allOf) - assert "oneOf" in schema - assert len(schema["oneOf"]) == 2 - - # Check that each field appears in oneOf with const: True - field_a_condition = schema["oneOf"][0] - field_b_condition = schema["oneOf"][1] - - assert field_a_condition["properties"]["field_a"]["const"] is True - assert field_b_condition["properties"]["field_b"]["const"] is True - - def test_no_constraints_json_schema(self) -> None: - """Test JSON Schema generation for models without constraints.""" - - class TestModel(ConstraintValidatedModel, BaseModel): - name: str - value: int = 42 - - schema = TestModel.model_json_schema() - - # Should have standard properties but no constraint extensions - assert "properties" in schema - assert "name" in schema["properties"] - assert "value" in schema["properties"] - - # Should not have constraint-specific fields - assert "allOf" not in schema or len(schema.get("allOf", [])) == 0 - assert "anyOf" not in schema or len(schema.get("anyOf", [])) == 0 - assert "oneOf" not in schema or len(schema.get("oneOf", [])) == 0 - - def test_nested_constraint_json_schema(self) -> None: - """Test JSON Schema generation for models with nested properties.""" - - class NestedProperties(BaseModel): - subtype: SubtypeEnum - parent_division_id: str | None = None - - @exactly_one_of("flag_a", "flag_b") - class TestModel(ConstraintValidatedModel, BaseModel): - id: str - properties: NestedProperties - flag_a: bool | None = None - flag_b: bool | None = None - - schema = TestModel.model_json_schema() - - # With the new approach, constraints are applied at the root level - # where the constraint decorator was applied - assert "oneOf" in schema - assert len(schema["oneOf"]) == 2 - - # Verify the constraint is correctly structured - one_of_options = schema["oneOf"] - assert {"properties": {"flag_a": {"const": True}}} in one_of_options - assert {"properties": {"flag_b": {"const": True}}} in one_of_options - - def test_json_schema_structure_validity(self) -> None: - """Test that generated JSON Schema has valid structure.""" - - @exactly_one_of("is_active", "is_inactive") - class TestModel(ConstraintValidatedModel, BaseModel): - subtype: SubtypeEnum - is_active: bool | None = None - is_inactive: bool | None = None - name: str = Field(..., description="Model name") - - schema = TestModel.model_json_schema() - - # Should have standard JSON Schema structure - assert schema["type"] == "object" - assert "properties" in schema - assert "title" in schema - - # Should have required fields - assert "required" in schema - assert "subtype" in schema["required"] - assert "name" in schema["required"] - - # Property definitions should be valid - properties = schema["properties"] - assert "subtype" in properties - assert "name" in properties - - # Enum should be properly defined - if "$defs" in schema: - assert "SubtypeEnum" in schema["$defs"] - enum_def = schema["$defs"]["SubtypeEnum"] - assert enum_def["type"] == "string" - assert "enum" in enum_def - - # Conditional fields should be properly structured - if "allOf" in schema: - for condition in schema["allOf"]: - # Each condition should have proper JSON Schema structure - assert isinstance(condition, dict) - - def test_pattern_constraint_json_schema(self) -> None: - """Test PatternConstraint JSON schema generation.""" - constraint = PatternConstraint( - pattern=r"^[A-Z]{2,4}$", error_message="Must be 2-4 uppercase letters" - ) - TestModel = create_field_constraint_model(str, constraint) - schema = TestModel.model_json_schema() - - assert_pattern_constraint(schema, "test_field", r"^[A-Z]{2,4}$") - - def test_language_tag_constraint_json_schema(self) -> None: - """Test LanguageTagConstraint JSON schema generation.""" - constraint = LanguageTagConstraint() - TestModel = create_field_constraint_model(str, constraint) - schema = TestModel.model_json_schema() - - assert_pattern_constraint( - schema, - "test_field", - r"^(?:(?:[A-Za-z]{2,3}(?:-[A-Za-z]{3}){0,3}?)|(?:[A-Za-z]{4,8}))(?:-[A-Za-z]{4})?(?:-[A-Za-z]{2}|[0-9]{3})?(?:-(?:[A-Za-z0-9]{5,8}|[0-9][A-Za-z0-9]{3}))*(?:-[A-WY-Za-wy-z0-9](?:-[A-Za-z0-9]{2,8})+)*$", - "IETF BCP-47 language tag", - ) - - def test_country_code_constraint_json_schema(self) -> None: - """Test CountryCodeAlpha2Constraint JSON schema generation.""" - constraint = CountryCodeAlpha2Constraint() - TestModel = create_field_constraint_model(str, constraint) - schema = TestModel.model_json_schema() - - assert_pattern_constraint( - schema, "test_field", r"^[A-Z]{2}$", "ISO 3166-1 alpha-2 country code" - ) - - def test_region_code_constraint_json_schema(self) -> None: - """Test RegionCodeConstraint JSON schema generation.""" - constraint = RegionCodeConstraint() - TestModel = create_field_constraint_model(str, constraint) - schema = TestModel.model_json_schema() - - assert_pattern_constraint( - schema, - "test_field", - r"^[A-Z]{2}-[A-Z0-9]{1,3}$", - "ISO 3166-2 subdivision code", - ) - - def test_hex_color_constraint_json_schema(self) -> None: - """Test HexColorConstraint JSON schema generation.""" - constraint = HexColorConstraint() - TestModel = create_field_constraint_model(str, constraint) - schema = TestModel.model_json_schema() - - assert_pattern_constraint( - schema, - "test_field", - r"^#[0-9A-Fa-f]{3}([0-9A-Fa-f]{3})?$", - "Hexadecimal color code in format #RGB or #RRGGBB", - ) - - def test_no_whitespace_constraint_json_schema(self) -> None: - """Test NoWhitespaceConstraint JSON schema generation.""" - constraint = NoWhitespaceConstraint() - TestModel = create_field_constraint_model(str, constraint) - schema = TestModel.model_json_schema() - - assert_pattern_constraint( - schema, "test_field", r"^\S+$", "String without whitespace characters" - ) - - def test_json_pointer_constraint_json_schema(self) -> None: - """Test JsonPointerConstraint JSON schema generation.""" - constraint = JsonPointerConstraint() - TestModel = create_field_constraint_model(str, constraint) - schema = TestModel.model_json_schema() - - assert "properties" in schema - assert "test_field" in schema["properties"] - field_schema = schema["properties"]["test_field"] - assert field_schema.get("description") == "JSON Pointer (RFC 6901)" - - def test_whitespace_constraint_json_schema(self) -> None: - """Test WhitespaceConstraint JSON schema generation.""" - constraint = StrippedConstraint() - TestModel = create_field_constraint_model(str, constraint) - schema = TestModel.model_json_schema() - - assert_pattern_constraint( - schema, - "test_field", - r"^(\S.*)?\S$", - "String with no leading/trailing whitespace", - ) - - def test_unique_items_constraint_json_schema(self) -> None: - """Test UniqueItemsConstraint JSON schema generation.""" - constraint = UniqueItemsConstraint() - TestModel = create_field_constraint_model(list[str], constraint) - schema = TestModel.model_json_schema() - - assert_collection_constraint(schema, "test_field", unique_items=True) - - def test_confidence_score_constraint_json_schema(self) -> None: - """Test ConfidenceScoreConstraint JSON schema generation.""" - constraint = ConfidenceScoreConstraint() - TestModel = create_field_constraint_model(float, constraint) - schema = TestModel.model_json_schema() - - assert_range_constraint( - schema, - "test_field", - min_val=0.0, - max_val=1.0, - description="Confidence score between 0.0 and 1.0", - ) - - def test_linear_reference_range_constraint_json_schema(self) -> None: - """Test LinearReferenceRangeConstraint JSON schema generation.""" - constraint = LinearReferenceRangeConstraint() - TestModel = create_field_constraint_model(list[float], constraint) - schema = TestModel.model_json_schema() - - assert "properties" in schema - assert "test_field" in schema["properties"] - field_schema = schema["properties"]["test_field"] - assert field_schema.get("type") == "array" - assert field_schema.get("minItems") == 2 - assert field_schema.get("maxItems") == 2 - assert field_schema.get("items") == { - "type": "number", - "minimum": 0.0, - "maximum": 1.0, - } - assert ( - field_schema.get("description") - == "Linear reference range [start, end] where 0.0 <= start < end <= 1.0" - ) - - if __name__ == "__main__": pytest.main([__file__]) diff --git a/packages/overture-schema-validation/tests/test_mixin_constraints.py b/packages/overture-schema-validation/tests/test_mixin_constraints.py index 4739683bc..ac8ca46fa 100644 --- a/packages/overture-schema-validation/tests/test_mixin_constraints.py +++ b/packages/overture-schema-validation/tests/test_mixin_constraints.py @@ -8,12 +8,10 @@ from overture.schema.validation import ( ConstraintValidatedModel, - exactly_one_of, min_properties, ) from overture.schema.validation.mixin import ( BaseConstraintValidator, - ExactlyOneOfValidator, MinPropertiesValidator, ) @@ -129,135 +127,6 @@ class TestModel(ConstraintValidatedModel, BaseModel): assert "allOf" not in schema -class TestExactlyOneOfValidator: - """Test exactly one of constraint validation.""" - - def test_exactly_one_of_validator_direct(self) -> None: - """Test ExactlyOneOfValidator directly.""" - - class TestModel(BaseModel): - field_a: bool | None = None - field_b: bool | None = None - - validator = ExactlyOneOfValidator("field_a", "field_b") - - # Valid: exactly one field is True - model = TestModel(field_a=True, field_b=False) - validator.validate(model) # Should not raise - - model = TestModel(field_a=False, field_b=True) - validator.validate(model) # Should not raise - - # Invalid: no fields are True - model = TestModel(field_a=False, field_b=False) - with pytest.raises( - ValueError, match="Exactly one of field_a, field_b must be true" - ): - validator.validate(model) - - # Invalid: fields are None (treated as not True) - model = TestModel(field_a=None, field_b=None) - with pytest.raises( - ValueError, match="Exactly one of field_a, field_b must be true" - ): - validator.validate(model) - - # Invalid: both fields are True - model = TestModel(field_a=True, field_b=True) - with pytest.raises( - ValueError, - match="Exactly one field must be true, but found 2: field_a, field_b", - ): - validator.validate(model) - - def test_exactly_one_of_constraint_decorator(self) -> None: - """Test exactly one of constraint using decorator.""" - - @exactly_one_of("is_land", "is_territorial") - class DivisionModel(ConstraintValidatedModel, BaseModel): - is_land: bool | None = None - is_territorial: bool | None = None - - # Valid cases: exactly one is True - model = DivisionModel(is_land=True, is_territorial=False) - assert model.is_land is True - assert model.is_territorial is False - - model = DivisionModel(is_land=False, is_territorial=True) - assert model.is_land is False - assert model.is_territorial is True - - # Invalid case: neither True - with pytest.raises(ValidationError) as exc_info: - DivisionModel(is_land=False, is_territorial=False) - assert "Exactly one of is_land, is_territorial must be true" in str( - exc_info.value - ) - - # Invalid case: both True - with pytest.raises(ValidationError) as exc_info: - DivisionModel(is_land=True, is_territorial=True) - assert ( - "Exactly one field must be true, but found 2: is_land, is_territorial" - in str(exc_info.value) - ) - - # Invalid case: both None - with pytest.raises(ValidationError) as exc_info: - DivisionModel(is_land=None, is_territorial=None) - assert "Exactly one of is_land, is_territorial must be true" in str( - exc_info.value - ) - - def test_exactly_one_of_multiple_fields(self) -> None: - """Test exactly one of constraint with more than 2 fields.""" - - @exactly_one_of("option_a", "option_b", "option_c") - class OptionsModel(ConstraintValidatedModel, BaseModel): - option_a: bool | None = None - option_b: bool | None = None - option_c: bool | None = None - - # Valid: exactly one option True - model = OptionsModel(option_a=True, option_b=False, option_c=False) - assert model.option_a is True - - model = OptionsModel(option_a=False, option_b=True, option_c=False) - assert model.option_b is True - - model = OptionsModel(option_a=False, option_b=False, option_c=True) - assert model.option_c is True - - # Invalid: no options True - with pytest.raises(ValidationError) as exc_info: - OptionsModel(option_a=False, option_b=False, option_c=False) - assert "Exactly one of option_a, option_b, option_c must be true" in str( - exc_info.value - ) - - # Invalid: multiple options True - with pytest.raises(ValidationError) as exc_info: - OptionsModel(option_a=True, option_b=True, option_c=False) - assert "Exactly one field must be true, but found 2: option_a, option_b" in str( - exc_info.value - ) - - def test_exactly_one_of_json_schema(self) -> None: - """Test JSON schema generation for exactly one of constraint.""" - - @exactly_one_of("field_a", "field_b") - class TestModel(ConstraintValidatedModel, BaseModel): - field_a: bool | None = None - field_b: bool | None = None - - schema = TestModel.model_json_schema() - - # Should have oneOf constraint (ExactlyOneOfValidator generates oneOf) - # The constraint metadata is included by the ConstraintValidatedModel at top level - assert "oneOf" in schema - assert len(schema["oneOf"]) == 2 - - class TestMinPropertiesValidator: """Test minimum properties constraint validation.""" @@ -392,37 +261,6 @@ class DerivedModel(BaseTestModel): class TestConstraintErrorHandling: """Test error handling and edge cases.""" - def test_constraint_with_missing_fields(self) -> None: - """Test constraints when referenced fields don't exist.""" - - @exactly_one_of("field_a", "field_b") - class IncompleteModel(ConstraintValidatedModel, BaseModel): - # Missing subtype and parent_division_id fields - name: str - - # Should not raise validation errors for missing fields - # (constraint should handle missing fields gracefully) - model = IncompleteModel(name="test") - assert model.name == "test" - - def test_constraint_validator_without_mixin(self) -> None: - """Test that decorators work but validation won't be applied without the - mixin.""" - - # This should not raise - decorators can be applied to any class - # but validation won't happen without ConstraintValidatedModel - @exactly_one_of("field_a", "field_b") - class InvalidModel(BaseModel): # Missing ConstraintValidatedModel - field_a: bool | None = None - field_b: bool | None = None - - # This should NOT fail validation since ConstraintValidatedModel isn't mixed in - model = InvalidModel( - field_a=True, - field_b=True, # would fail validation if mixin was present - ) - assert model.field_a is True - def test_json_schema_with_no_constraints(self) -> None: """Test JSON schema generation when no constraints are registered.""" @@ -437,57 +275,5 @@ class PlainModel(ConstraintValidatedModel, BaseModel): assert "allOf" not in schema -class TestRealWorldScenarios: - """Test real-world usage scenarios.""" - - def test_geojson_feature_model(self) -> None: - """Test constraint validation on a GeoJSON-like feature model.""" - - @exactly_one_of("is_land", "is_territorial") - class PropertiesModel(ConstraintValidatedModel, BaseModel): - subtype: PlaceType - parent_division_id: str | None = None - is_land: bool | None = None - is_territorial: bool | None = None - - class FeatureModel(BaseModel): - id: str - type: str = "Feature" - properties: PropertiesModel - geometry: dict - - # Valid feature - feature_data = { - "id": "test-feature", - "properties": { - "subtype": PlaceType.REGION, - "parent_division_id": "US", - "is_land": True, - "is_territorial": False, - }, - "geometry": {"type": "Point", "coordinates": [0, 0]}, - } - - model = FeatureModel(**feature_data) - assert model.id == "test-feature" - assert model.properties.subtype == PlaceType.REGION - - # Invalid feature - violates mutually exclusive constraint - invalid_feature_data = { - "id": "invalid-feature", - "properties": { - "subtype": PlaceType.COUNTRY, - "parent_division_id": "parent", - "is_land": True, - "is_territorial": True, # Both True - violates mutually exclusive - }, - "geometry": {"type": "Point", "coordinates": [0, 0]}, - } - - with pytest.raises(ValidationError) as exc_info: - FeatureModel(**invalid_feature_data) - assert "Exactly one field must be true, but found 2" in str(exc_info.value) - - if __name__ == "__main__": pytest.main([__file__]) From 4f1d2d3b5e6222f2adb338ad847847f2a5df53ed Mon Sep 17 00:00:00 2001 From: schapper Date: Thu, 9 Oct 2025 22:04:33 -0700 Subject: [PATCH 04/19] wip - PASSING - finalize validation -> system (except ExtensibleBaseModel) --- README.pydantic.md | 2 +- .../src/overture/schema/core/ext.py | 51 ++- .../src/overture/schema/core/models.py | 97 ++++- .../src/overture/schema/core/validation.py | 9 - .../pyproject.toml | 4 +- .../schema/divisions/division/models.py | 12 +- .../divisions/division_boundary/models.py | 9 +- .../src/overture/schema/divisions/enums.py | 6 + .../overture/schema/divisions/validation.py | 94 ----- .../tests/division_baseline_schema.json | 15 +- .../tests/test_parent_division_constraints.py | 128 ------- .../pyproject.toml | 4 +- .../system/model_constraint/__init__.py | 3 + .../system/model_constraint/min_fields_set.py | 126 +++++++ .../model_constraint/no_extra_fields.py | 4 + .../overture/schema/transportation/models.py | 23 +- .../tests/test_hashability.py | 2 +- packages/overture-schema-validation/README.md | 158 --------- .../overture-schema-validation/pyproject.toml | 42 --- .../src/overture/__init__.py | 1 - .../src/overture/schema/__init__.py | 1 - .../overture/schema/validation/__about__.py | 1 - .../overture/schema/validation/__init__.py | 38 -- .../src/overture/schema/validation/mixin.py | 215 ------------ .../src/overture/schema/validation/py.typed | 0 .../tests/test_json_schema_generation.py | 72 ---- .../tests/test_mixin_constraints.py | 279 --------------- uv.lock | 330 +++++++++--------- 28 files changed, 494 insertions(+), 1232 deletions(-) delete mode 100644 packages/overture-schema-core/src/overture/schema/core/validation.py delete mode 100644 packages/overture-schema-divisions-theme/src/overture/schema/divisions/validation.py delete mode 100644 packages/overture-schema-divisions-theme/tests/test_parent_division_constraints.py create mode 100644 packages/overture-schema-system/src/overture/schema/system/model_constraint/min_fields_set.py delete mode 100644 packages/overture-schema-validation/README.md delete mode 100644 packages/overture-schema-validation/pyproject.toml delete mode 100644 packages/overture-schema-validation/src/overture/__init__.py delete mode 100644 packages/overture-schema-validation/src/overture/schema/__init__.py delete mode 100644 packages/overture-schema-validation/src/overture/schema/validation/__about__.py delete mode 100644 packages/overture-schema-validation/src/overture/schema/validation/__init__.py delete mode 100644 packages/overture-schema-validation/src/overture/schema/validation/mixin.py delete mode 100644 packages/overture-schema-validation/src/overture/schema/validation/py.typed delete mode 100644 packages/overture-schema-validation/tests/test_json_schema_generation.py delete mode 100644 packages/overture-schema-validation/tests/test_mixin_constraints.py diff --git a/README.pydantic.md b/README.pydantic.md index c15e38486..2d1eedafe 100644 --- a/README.pydantic.md +++ b/README.pydantic.md @@ -90,7 +90,7 @@ This workspace contains the following packages: convenient usage - **`overture-schema-core`** - Base classes, geometry models, and common structures shared across all themes -- **`overture-schema-validation`** - Validation system with constraints and mixins +- **`overture-schema-system`** - Foundational system of primitivef types and constraints ### Theme Packages diff --git a/packages/overture-schema-core/src/overture/schema/core/ext.py b/packages/overture-schema-core/src/overture/schema/core/ext.py index 42e1d7790..c8f850005 100644 --- a/packages/overture-schema-core/src/overture/schema/core/ext.py +++ b/packages/overture-schema-core/src/overture/schema/core/ext.py @@ -1,12 +1,55 @@ +from abc import ABC, abstractmethod from collections.abc import Callable from typing import Any from pydantic import BaseModel -from overture.schema.validation.mixin import ( - BaseConstraintValidator, - register_constraint, -) + +# Temporarily copied in from validation package. +class BaseConstraintValidator(ABC): + """Base class for constraint validators.""" + + def __init__(self, *args: object, **kwargs: object) -> None: + self.args = args + self.kwargs = kwargs + + @abstractmethod + def validate(self, model_instance: BaseModel) -> None: + """Validate the constraint against the model instance.""" + pass + + @abstractmethod + def get_metadata( + self, model_class: type[BaseModel] | None = None, by_alias: bool = True + ) -> dict[str, Any]: + """Return plain constraint metadata.""" + pass + + @abstractmethod + def apply_json_schema_metadata( + self, + target_schema: dict[str, Any], + model_class: type[BaseModel] | None = None, + by_alias: bool = True, + ) -> None: + """Apply this constraint's modifications directly to the target schema.""" + pass + + +# Temporarily copied in from validation package. +def register_constraint( + model_class: type[BaseModel], constraint: BaseConstraintValidator +) -> None: + """Register a constraint for a model class.""" + if not hasattr(model_class, "__constraints__"): + model_class.__constraints__ = [] # type: ignore[attr-defined] + else: + # Ensure we have a copy of the constraints list for this class + # to avoid sharing references between classes + constraints = getattr(model_class, "__constraints__", []) + model_class.__constraints__ = constraints.copy() # type: ignore[attr-defined] + constraints = model_class.__constraints__ # type: ignore[attr-defined] + constraints.append(constraint) def allow_extension_fields() -> Callable: diff --git a/packages/overture-schema-core/src/overture/schema/core/models.py b/packages/overture-schema-core/src/overture/schema/core/models.py index 6d5107893..3ac6929c7 100644 --- a/packages/overture-schema-core/src/overture/schema/core/models.py +++ b/packages/overture-schema-core/src/overture/schema/core/models.py @@ -1,6 +1,6 @@ -from abc import ABC +from abc import ABC, abstractmethod from collections.abc import Callable -from typing import Annotated, Any, Generic, NewType, TypeVar +from typing import Annotated, Any, Generic, NewType, TypeVar, cast from pydantic import ( BaseModel, @@ -9,6 +9,7 @@ GetJsonSchemaHandler, ValidationInfo, model_serializer, + model_validator, ) from pydantic_core import core_schema @@ -43,7 +44,97 @@ Prominence, SortKey, ) -from .validation import ConstraintValidatedModel + + +# Temporarily copied in from validation package. +class BaseConstraintValidator(ABC): + """Base class for constraint validators.""" + + def __init__(self, *args: object, **kwargs: object) -> None: + self.args = args + self.kwargs = kwargs + + @abstractmethod + def validate(self, model_instance: BaseModel) -> None: + """Validate the constraint against the model instance.""" + pass + + @abstractmethod + def get_metadata( + self, model_class: type[BaseModel] | None = None, by_alias: bool = True + ) -> dict[str, Any]: + """Return plain constraint metadata.""" + pass + + @abstractmethod + def apply_json_schema_metadata( + self, + target_schema: dict[str, Any], + model_class: type[BaseModel] | None = None, + by_alias: bool = True, + ) -> None: + """Apply this constraint's modifications directly to the target schema.""" + pass + + +# Temporarily copied in from validation package. +class ConstraintValidatedModel: + """Mixin class that provides constraint validation capabilities. + + This is a true mixin - it doesn't inherit from BaseModel to avoid MRO issues. + Use it like: class MyModel(ConstraintValidatedModel, BaseModel) + """ + + @model_validator(mode="after") + def validate_constraints(self) -> "ConstraintValidatedModel": + """Run all registered constraints for this model and its parent classes.""" + all_constraints: list[BaseConstraintValidator] = [] + + # Collect constraints from this class and all parent classes + # Use a more sophisticated approach to avoid cross-contamination + for cls in self.__class__.__mro__: + # Skip if this class has no constraints of its own + if not hasattr(cls, "__constraints__"): + continue + + # Only include constraints that were explicitly added to this class + # (not inherited from shared base classes) + class_constraints = getattr(cls, "__constraints__", []) + if class_constraints: + all_constraints.extend(class_constraints) + + # Run all constraints + for constraint in all_constraints: + # Cast self to BaseModel for the constraint validator + constraint.validate(self) # type: ignore[arg-type] + return self + + @classmethod + def __get_pydantic_json_schema__( + cls, + core_schema: Any, # noqa: ANN401 + handler: Any, # noqa: ANN401 + ) -> dict[str, Any]: + """Generate JSON Schema with constraints applied.""" + # Get the base schema from Pydantic + schema: dict[str, Any] = handler(core_schema) + + # Apply constraint metadata + all_constraints: list[BaseConstraintValidator] = [] + class_constraints = getattr(cls, "__constraints__", []) + if class_constraints: + all_constraints.extend(class_constraints) + + for constraint in all_constraints: + # Apply constraint modifications directly to the schema + # OvertureFeature will handle moving them to the correct GeoJSON structure if needed + constraint.apply_json_schema_metadata( + target_schema=schema, + model_class=cast(type[BaseModel], cls), + by_alias=True, + ) + + return schema @allow_extension_fields() diff --git a/packages/overture-schema-core/src/overture/schema/core/validation.py b/packages/overture-schema-core/src/overture/schema/core/validation.py deleted file mode 100644 index 2b3677c84..000000000 --- a/packages/overture-schema-core/src/overture/schema/core/validation.py +++ /dev/null @@ -1,9 +0,0 @@ -from overture.schema.validation import ( - ConstraintValidatedModel, - min_properties, -) - -__all__ = [ - "ConstraintValidatedModel", - "min_properties", -] diff --git a/packages/overture-schema-divisions-theme/pyproject.toml b/packages/overture-schema-divisions-theme/pyproject.toml index fe16a8901..8f2442173 100644 --- a/packages/overture-schema-divisions-theme/pyproject.toml +++ b/packages/overture-schema-divisions-theme/pyproject.toml @@ -1,7 +1,7 @@ [project] dependencies = [ "overture-schema-core", - "overture-schema-validation", + "overture-schema-system", "pydantic>=2.0", ] description = "Overture Maps divisions theme shared structures, division, division area and division boundary types" @@ -13,7 +13,7 @@ requires-python = ">=3.10" [tool.uv.sources] overture-schema-core = { workspace = true } -overture-schema-validation = { workspace = true } +overture-schema-system = { workspace = true } [build-system] build-backend = "hatchling.build" diff --git a/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division/models.py b/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division/models.py index e63d35c41..d440576a2 100644 --- a/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division/models.py +++ b/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division/models.py @@ -22,7 +22,11 @@ from overture.schema.system.field_constraint import ( UniqueItemsConstraint, ) -from overture.schema.system.model_constraint import no_extra_fields +from overture.schema.system.model_constraint import ( + forbid_if, + no_extra_fields, + require_if, +) from overture.schema.system.primitive import ( Geometry, GeometryType, @@ -31,10 +35,9 @@ ) from overture.schema.system.string import RegionCode, WikidataId -from ..enums import DivisionClass, PlaceType +from ..enums import IS_COUNTRY, DivisionClass, PlaceType from ..models import CapitalOfDivisionItem from ..types import Hierarchy -from ..validation import parent_division_required_unless @no_extra_fields @@ -51,7 +54,8 @@ class Norms(BaseModel): ] = None -@parent_division_required_unless("subtype", PlaceType.COUNTRY) +@forbid_if(["parent_division_id"], IS_COUNTRY) +@require_if(["parent_division_id"], ~IS_COUNTRY) class Division( Feature[Literal["divisions"], Literal["division"]], Named, CartographicallyHinted ): diff --git a/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division_boundary/models.py b/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division_boundary/models.py index 4a1df10a7..2adef45fb 100644 --- a/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division_boundary/models.py +++ b/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division_boundary/models.py @@ -15,7 +15,6 @@ ) from overture.schema.system.field_constraint import UniqueItemsConstraint from overture.schema.system.model_constraint import ( - FieldEqCondition, forbid_if, radio_group, require_if, @@ -28,14 +27,12 @@ from overture.schema.system.string import RegionCode from ..division import Division -from ..enums import PlaceType +from ..enums import IS_COUNTRY, PlaceType from .enums import BoundaryClass -__IS_COUNTRY = FieldEqCondition("subtype", PlaceType.COUNTRY) - -@forbid_if(["country"], __IS_COUNTRY) -@require_if(["country"], ~__IS_COUNTRY) +@forbid_if(["country"], IS_COUNTRY) +@require_if(["country"], ~IS_COUNTRY) @radio_group("is_land", "is_territorial") class DivisionBoundary(Feature[Literal["divisions"], Literal["division_boundary"]]): """Boundaries represent borders between divisions of the same subtype. diff --git a/packages/overture-schema-divisions-theme/src/overture/schema/divisions/enums.py b/packages/overture-schema-divisions-theme/src/overture/schema/divisions/enums.py index 6f1f5401c..cd594142a 100644 --- a/packages/overture-schema-divisions-theme/src/overture/schema/divisions/enums.py +++ b/packages/overture-schema-divisions-theme/src/overture/schema/divisions/enums.py @@ -1,5 +1,7 @@ from enum import Enum +from overture.schema.system.model_constraint import FieldEqCondition + class PlaceType(str, Enum): """Category of the division from a finite, hierarchical, ordered list of categories @@ -70,3 +72,7 @@ class DivisionClass(str, Enum): # A small, isolated human settlement in a rural area # Example: Tjarnabyggð, Iceland. HAMLET = "hamlet" + + +# TODO - vic - Migrate this into a better home +IS_COUNTRY = FieldEqCondition("subtype", PlaceType.COUNTRY) diff --git a/packages/overture-schema-divisions-theme/src/overture/schema/divisions/validation.py b/packages/overture-schema-divisions-theme/src/overture/schema/divisions/validation.py deleted file mode 100644 index 7b40d630a..000000000 --- a/packages/overture-schema-divisions-theme/src/overture/schema/divisions/validation.py +++ /dev/null @@ -1,94 +0,0 @@ -"""Division-specific validation constraints.""" - -from collections.abc import Callable -from typing import Any, TypeVar - -from pydantic import BaseModel - -from overture.schema.validation.mixin import ( - BaseConstraintValidator, - register_constraint, -) - -T = TypeVar("T", bound=BaseModel) - - -class ParentDivisionValidator(BaseConstraintValidator): - """Validates parent division logic: parent_division_id is required unless field equals exempt value.""" - - def __init__(self, field_name: str, exempt_value: str | int | float | bool) -> None: - super().__init__() - self.field_name = field_name - self.exempt_value = exempt_value - - def validate(self, target: BaseModel) -> None: - if hasattr(target, self.field_name) and hasattr(target, "parent_division_id"): - field_value = getattr(target, self.field_name) - parent_division_id = target.parent_division_id - - if field_value == self.exempt_value and parent_division_id is not None: - raise ValueError( - f"parent_division_id must not be present when {self.field_name} is {self.exempt_value}" - ) - elif field_value != self.exempt_value and parent_division_id is None: - raise ValueError( - f"parent_division_id is required when {self.field_name} is not {self.exempt_value} (current: {field_value})" - ) - - def get_metadata( - self, model_class: type[BaseModel] | None = None, by_alias: bool = True - ) -> dict[str, Any]: - from overture.schema.validation.mixin import resolve_field_names - - # Resolve field names to aliases if needed - field_name = self.field_name - parent_division_id = "parent_division_id" - - if model_class is not None: - resolved_field = resolve_field_names(model_class, [field_name], by_alias) - resolved_parent = resolve_field_names( - model_class, [parent_division_id], by_alias - ) - field_name = resolved_field[0] - parent_division_id = resolved_parent[0] - - return { - "type": "parent_division_required_unless", - "field_name": field_name, - "exempt_value": self.exempt_value, - "parent_division_id": parent_division_id, - } - - def apply_json_schema_metadata( - self, - target_schema: dict[str, Any], - model_class: type[BaseModel] | None = None, - by_alias: bool = True, - ) -> None: - """Apply parent division constraint to the schema.""" - metadata = self.get_metadata(model_class, by_alias) - - conditional_schema = { - "if": { - "properties": { - metadata["field_name"]: {"const": metadata["exempt_value"]} - } - }, - "then": {"not": {"required": [metadata["parent_division_id"]]}}, - "else": {"required": [metadata["parent_division_id"]]}, - } - - target_schema.setdefault("allOf", []).append(conditional_schema) - - -def parent_division_required_unless( - field_name: str, exempt_value: str | int | float | bool -) -> Callable[[type[T]], type[T]]: - """Decorator to add parent division validation: parent_division_id is required unless field equals exempt value.""" - - def decorator(cls: type[T]) -> type[T]: - constraint = ParentDivisionValidator(field_name, exempt_value) - register_constraint(cls, constraint) - return cls - - return decorator diff --git a/packages/overture-schema-divisions-theme/tests/division_baseline_schema.json b/packages/overture-schema-divisions-theme/tests/division_baseline_schema.json index 793198ebd..c60311e5e 100644 --- a/packages/overture-schema-divisions-theme/tests/division_baseline_schema.json +++ b/packages/overture-schema-divisions-theme/tests/division_baseline_schema.json @@ -391,11 +391,22 @@ "additionalProperties": false, "allOf": [ { - "else": { + "if": { + "not": { + "properties": { + "subtype": { + "const": "country" + } + } + } + }, + "then": { "required": [ "parent_division_id" ] - }, + } + }, + { "if": { "properties": { "subtype": { diff --git a/packages/overture-schema-divisions-theme/tests/test_parent_division_constraints.py b/packages/overture-schema-divisions-theme/tests/test_parent_division_constraints.py deleted file mode 100644 index 7dae79b0a..000000000 --- a/packages/overture-schema-divisions-theme/tests/test_parent_division_constraints.py +++ /dev/null @@ -1,128 +0,0 @@ -"""Tests for parent division constraint validation.""" - -from enum import Enum - -import pytest -from overture.schema.core.validation import ConstraintValidatedModel -from overture.schema.divisions.validation import ( - ParentDivisionValidator, - parent_division_required_unless, -) -from overture.schema.system.model_constraint import no_extra_fields -from pydantic import BaseModel, ValidationError - - -class PlaceType(str, Enum): - """Test enum for place types.""" - - COUNTRY = "country" - REGION = "region" - LOCALITY = "locality" - - -class TestParentDivisionValidator: - """Test parent division constraint validation.""" - - def test_parent_division_validator_direct(self) -> None: - """Test ParentDivisionValidator directly.""" - - @no_extra_fields - class TestModel(BaseModel): - subtype: PlaceType - parent_division_id: str | None = None - - validator = ParentDivisionValidator("subtype", PlaceType.COUNTRY) - - # Valid: country without parent - model = TestModel(subtype=PlaceType.COUNTRY, parent_division_id=None) - validator.validate(model) # Should not raise - - # Valid: region with parent - model = TestModel(subtype=PlaceType.REGION, parent_division_id="parent_id") - validator.validate(model) # Should not raise - - # Invalid: country with parent - model = TestModel(subtype=PlaceType.COUNTRY, parent_division_id="parent_id") - with pytest.raises( - ValueError, - match="parent_division_id must not be present when subtype is country", - ): - validator.validate(model) - - # Invalid: region without parent - model = TestModel(subtype=PlaceType.REGION, parent_division_id=None) - with pytest.raises( - ValueError, - match="parent_division_id is required when subtype is not country", - ): - validator.validate(model) - - def test_parent_division_constraint_decorator(self) -> None: - """Test parent division constraint using decorator.""" - - @parent_division_required_unless("subtype", PlaceType.COUNTRY) - class DivisionModel(ConstraintValidatedModel, BaseModel): - subtype: PlaceType - parent_division_id: str | None = None - - # Valid: country without parent - model = DivisionModel(subtype=PlaceType.COUNTRY, parent_division_id=None) - assert model.subtype == PlaceType.COUNTRY - assert model.parent_division_id is None - - # Valid: region with parent - model = DivisionModel(subtype=PlaceType.REGION, parent_division_id="parent_id") - assert model.subtype == PlaceType.REGION - assert model.parent_division_id == "parent_id" - - # Invalid: country with parent - with pytest.raises(ValidationError) as exc_info: - DivisionModel(subtype=PlaceType.COUNTRY, parent_division_id="parent_id") - assert "parent_division_id must not be present when subtype is country" in str( - exc_info.value - ) - - # Invalid: region without parent - with pytest.raises(ValidationError) as exc_info: - DivisionModel(subtype=PlaceType.REGION, parent_division_id=None) - assert "parent_division_id is required when subtype is not country" in str( - exc_info.value - ) - - def test_parent_division_nested_properties(self) -> None: - """Test parent division constraint with nested properties.""" - - @parent_division_required_unless("subtype", PlaceType.COUNTRY) - class FeatureModel(ConstraintValidatedModel, BaseModel): - subtype: PlaceType - parent_division_id: str | None = None - - # Valid: country without parent - model = FeatureModel(subtype=PlaceType.COUNTRY, parent_division_id=None) - assert model.subtype == PlaceType.COUNTRY - - # Invalid: country with parent - with pytest.raises(ValidationError) as exc_info: - FeatureModel(subtype=PlaceType.COUNTRY, parent_division_id="parent") - assert "parent_division_id must not be present when subtype is country" in str( - exc_info.value - ) - - def test_parent_division_json_schema(self) -> None: - """Test JSON schema generation for parent division constraint.""" - - @parent_division_required_unless("subtype", PlaceType.COUNTRY) - class DivisionModel(ConstraintValidatedModel, BaseModel): - subtype: PlaceType - parent_division_id: str | None = None - - schema = DivisionModel.model_json_schema() - - # Should have conditional schema - assert "allOf" in schema - assert len(schema["allOf"]) == 1 - - condition = schema["allOf"][0] - assert "if" in condition - assert "then" in condition - assert "else" in condition diff --git a/packages/overture-schema-places-theme/pyproject.toml b/packages/overture-schema-places-theme/pyproject.toml index 37efb189d..31500f7a5 100644 --- a/packages/overture-schema-places-theme/pyproject.toml +++ b/packages/overture-schema-places-theme/pyproject.toml @@ -1,7 +1,7 @@ [project] dependencies = [ "overture-schema-core", - "overture-schema-validation", + "overture-schema-system", "pydantic>=2.0", "pydantic[email]", ] @@ -14,7 +14,7 @@ requires-python = ">=3.10" [tool.uv.sources] overture-schema-core = { workspace = true } -overture-schema-validation = { workspace = true } +overture-schema-system = { workspace = true } [build-system] build-backend = "hatchling.build" diff --git a/packages/overture-schema-system/src/overture/schema/system/model_constraint/__init__.py b/packages/overture-schema-system/src/overture/schema/system/model_constraint/__init__.py index 583ae6693..f339397da 100644 --- a/packages/overture-schema-system/src/overture/schema/system/model_constraint/__init__.py +++ b/packages/overture-schema-system/src/overture/schema/system/model_constraint/__init__.py @@ -1,4 +1,5 @@ from .forbid_if import ForbidIfConstraint, forbid_if +from .min_fields_set import MinFieldsSetConstraint, min_fields_set from .model_constraint import ( Condition, FieldEqCondition, @@ -20,6 +21,8 @@ "FieldGroupConstraint", "forbid_if", "ForbidIfConstraint", + "min_fields_set", + "MinFieldsSetConstraint", "ModelConstraint", "no_extra_fields", "NoExtraFieldsConstraint", diff --git a/packages/overture-schema-system/src/overture/schema/system/model_constraint/min_fields_set.py b/packages/overture-schema-system/src/overture/schema/system/model_constraint/min_fields_set.py new file mode 100644 index 000000000..f3ab716ea --- /dev/null +++ b/packages/overture-schema-system/src/overture/schema/system/model_constraint/min_fields_set.py @@ -0,0 +1,126 @@ +from collections.abc import Callable + +from pydantic import BaseModel, ConfigDict +from typing_extensions import override + +from .json_schema import get_static_json_schema +from .model_constraint import ModelConstraint + + +def min_fields_set(count: int) -> Callable[[type[BaseModel]], type[BaseModel]]: + """ + Decorates a Pydantic model class with a constraint that requires a minimum number of fields in + the model to be set to a non-`None` value. + + This function is the decorator version of the `MinFieldsSetConstraint` class. + + Parameters + ---------- + count : int + Minimum number of fields that must be set in the model, inclusive of extra fields if they + are allowed + + Returns + ------- + type[BaseModel] + Decorated Pydantic model class + + Example + ------- + >>> from pydantic import BaseModel, ValidationError + >>> + >>> @min_fields_set(1) + ... class MyModel(BaseModel): + ... foo: int | None = None + ... bar: str | None = None + ... + >>> MyModel(foo=42) # validates OK + MyModel(foo=42, bar=None) + >>> MyModel(foo=42, bar='baz') # validates OK + MyModel(foo=42, bar='baz') + >>> try: + ... MyModel() # zero fields are set! + ... except ValidationError as e: + ... print("Validation failed") + Validation failed + """ + model_constraint = MinFieldsSetConstraint._create_internal( + f"@{min_fields_set.__name__}", + count, + ) + + return model_constraint.decorate + + +class MinFieldsSetConstraint(ModelConstraint): + """ + Class implementing the `min_fields_set` decorator, which can also be used standalone. + """ + + def __init__(self, count: int) -> None: + super().__init__() + self.__set_count(count) + + @classmethod + def _create_internal(cls, name: str, count: int) -> "MinFieldsSetConstraint": + instance = cls.__new__(cls) + super(MinFieldsSetConstraint, instance).__init__(name) + instance.__set_count(count) + return instance + + def __set_count(self, count: int) -> None: + if not isinstance(count, int): + raise TypeError( + f"count must be an `int`, but {repr(count)} is a `{type(count).__name__}`" + ) + self.__count = count + + @property + def count(self) -> int: + return self.__count + + @override + def validate_class(self, model_class: type[BaseModel]) -> None: + super().validate_class(model_class) + + num_fields = len(model_class.model_fields) + if num_fields >= self.count: + return + + extra = model_class.model_config.get("extra", None) + if not extra == "allow": + raise TypeError( + f"`{self.name}` requires a minimum of {self.count} fields to be set, but model " + f"`{model_class.__name__}` has only {num_fields} explicit fields and does not " + f"retain extra fields (config 'extra' is to {repr(extra)})" + ) + + @override + def validate_instance(self, model_instance: BaseModel) -> None: + super().validate_instance(model_instance) + + num_fields_set = len(model_instance.model_fields_set) + if num_fields_set < self.count: + raise ValueError( + f"only {num_fields_set} fields are explicitly set, but a minimum of {self.count} " + f"are required (`{self.name})`" + ) + + @override + def edit_config(self, model_class: type[BaseModel], config: ConfigDict) -> None: + super().edit_config(model_class, config) + + json_schema = get_static_json_schema(config) + + try: + prev = json_schema["minProperties"] + if prev == self.count: + return + else: + raise RuntimeError( + f'JSON schema for model class `{model_class.__name__}` has conflicting "minProperties" value {prev}' + ) + except KeyError: + pass + + json_schema["minProperties"] = self.count diff --git a/packages/overture-schema-system/src/overture/schema/system/model_constraint/no_extra_fields.py b/packages/overture-schema-system/src/overture/schema/system/model_constraint/no_extra_fields.py index 67b3384c4..fbd93fcfb 100644 --- a/packages/overture-schema-system/src/overture/schema/system/model_constraint/no_extra_fields.py +++ b/packages/overture-schema-system/src/overture/schema/system/model_constraint/no_extra_fields.py @@ -61,6 +61,8 @@ def _create_internal(cls, name: str) -> "NoExtraFieldsConstraint": @override def validate_class(self, model_class: type[BaseModel]) -> None: + super().validate_class(model_class) + config = model_class.model_config extra = config.get("extra", None) if extra and extra != "forbid": @@ -70,4 +72,6 @@ def validate_class(self, model_class: type[BaseModel]) -> None: @override def edit_config(self, model_class: type[BaseModel], config: ConfigDict) -> None: + super().edit_config(model_class, config) + config["extra"] = "forbid" diff --git a/packages/overture-schema-transportation-theme/src/overture/schema/transportation/models.py b/packages/overture-schema-transportation-theme/src/overture/schema/transportation/models.py index 20babeb39..5c8aaa831 100644 --- a/packages/overture-schema-transportation-theme/src/overture/schema/transportation/models.py +++ b/packages/overture-schema-transportation-theme/src/overture/schema/transportation/models.py @@ -13,12 +13,12 @@ LinearlyReferencedPosition, OpeningHours, ) -from overture.schema.core.validation import ( - ConstraintValidatedModel, - min_properties, -) from overture.schema.system.field_constraint import UniqueItemsConstraint -from overture.schema.system.model_constraint import no_extra_fields, require_any_of +from overture.schema.system.model_constraint import ( + min_fields_set, + no_extra_fields, + require_any_of, +) from overture.schema.system.primitive import float64, int32 from overture.schema.system.string import StrippedString, WikidataId @@ -79,8 +79,8 @@ class HeadingScope(BaseModel): heading: Heading | None = None -@min_properties(1) -class DestinationWhenClause(ConstraintValidatedModel, HeadingScope): +@min_fields_set(1) +class DestinationWhenClause(HeadingScope): pass @@ -445,9 +445,8 @@ def __hash__(self) -> int: return hash((tuple(self.vehicle) if self.vehicle is not None else None,)) -@min_properties(1) +@min_fields_set(1) class SpeedLimitWhenClause( - ConstraintValidatedModel, TemporalScope, HeadingScope, PurposeOfUseScope, @@ -479,9 +478,8 @@ class SpeedLimitRule(GeometricRangeScope): when: SpeedLimitWhenClause | None = None -@min_properties(1) +@min_fields_set(1) class AccessRestrictionWhenClause( - ConstraintValidatedModel, TemporalScope, HeadingScope, PurposeOfUseScope, @@ -521,9 +519,8 @@ def __hash__(self) -> int: return hash((super().__hash__(), self.access_type, self.when)) -@min_properties(1) +@min_fields_set(1) class ProhibitedTransitionWhenClause( - ConstraintValidatedModel, HeadingScope, TemporalScope, PurposeOfUseScope, diff --git a/packages/overture-schema-transportation-theme/tests/test_hashability.py b/packages/overture-schema-transportation-theme/tests/test_hashability.py index ab587d138..6b90b46f3 100644 --- a/packages/overture-schema-transportation-theme/tests/test_hashability.py +++ b/packages/overture-schema-transportation-theme/tests/test_hashability.py @@ -354,7 +354,7 @@ class TestAccessRestrictionWhenClauseHashability: def test_access_restriction_when_clause_empty(self) -> None: """Test AccessRestrictionWhenClause with minimal fields set.""" - # At least one property must be set due to @min_properties(1) + # At least one property must be set due to @min_fields_set(1) clause1 = AccessRestrictionWhenClause(heading=Heading.FORWARD) clause2 = AccessRestrictionWhenClause(heading=Heading.FORWARD) diff --git a/packages/overture-schema-validation/README.md b/packages/overture-schema-validation/README.md deleted file mode 100644 index 7f6a30510..000000000 --- a/packages/overture-schema-validation/README.md +++ /dev/null @@ -1,158 +0,0 @@ -### Differences from Traditional Validators - -#### Replacing @field_validator - -Instead of using Pydantic's `@field_validator` decorator, use constraint annotations. This will -enable support for richer JSON Schema constraints and preservation of constraint logic in generated -code. - -**Before (using @field_validator):** - -```python -class PlaceProperties(BaseModel): - country: str = Field(..., description="Country code") - language: str = Field(..., description="Language tag") - categories: List[str] = Field(..., description="Categories") - wikidata_id: Optional[str] = Field(None, description="Wikidata ID") - - @field_validator("country") - @classmethod - def validate_country_code(cls, v): - if not re.match(r"^[A-Z]{2}$", v): - raise ValueError("Invalid ISO 3166-1 alpha-2 country code") - return v - - @field_validator("language") - @classmethod - def validate_language_tag(cls, v): - if not re.match(r"^[a-z]{2,3}(-[A-Za-z]{2,8})*$", v): - raise ValueError("Invalid IETF BCP-47 language tag") - return v - - @field_validator("categories") - @classmethod - def validate_unique_categories(cls, v): - if len(v) != len(set(v)): - raise ValueError("Categories must be unique") - return v - - @field_validator("wikidata_id") - @classmethod - def validate_wikidata_format(cls, v): - if v is not None and not re.match(r"^Q\d+$", v): - raise ValueError("Invalid Wikidata identifier format") - return v -``` - -**After (using constraints):** - -```python -class PlaceProperties(BaseModel): - country: Annotated[str, CountryCodeAlpha2Constraint()] = Field( - ..., description="Country code" - ) - language: Annotated[str, LanguageTagConstraint()] = Field( - ..., description="Language tag" - ) - categories: Annotated[List[str], UniqueItemsConstraint()] = Field( - ..., description="Categories" - ) - # Domain-specific constraints removed for incremental approach -``` - -#### Using Constraint-Based Validation - -For complex model-level validation, use the mixin-based constraint system: - -```python -from overture.schema.validation.mixin import ConstraintValidatedModel, at_least_one_of - -@at_least_one_of("max_speed", "min_speed") -class SpeedLimitRule(ConstraintValidatedModel, BaseModel): - max_speed: Optional[Speed] = None - min_speed: Optional[Speed] = None -``` - -##### ⚠️ CRITICAL: Inheritance Order Matters - -When using `ConstraintValidatedModel`, it **MUST** come first in the inheritance list: - -```python -# ✅ CORRECT - ConstraintValidatedModel first -class MyModel(ConstraintValidatedModel, BaseModel): - pass - -# ❌ WRONG - Will not generate JSON Schema metadata -class MyModel(BaseModel, ConstraintValidatedModel): - pass -``` - -This is due to Python's Method Resolution Order (MRO). When `ConstraintValidatedModel` comes first, -its `model_json_schema` method is called, which adds constraint metadata to the generated JSON -Schema. - -## Error Messages - -Constraints provide detailed, consistent error messages: - -```python -# Invalid country code -ValidationError: 1 validation error for MyModel -country - Invalid ISO 3166-1 alpha-2 country code: USA [type=value_error] - -# Mutual exclusion violation -ValidationError: 1 validation error for BoundaryModel -is_land, is_territorial - Fields is_land, is_territorial are mutually exclusive and cannot all be true [type=value_error] -``` - -## JSON Schema Generation - -Constraints automatically enhance generated JSON schemas with appropriate metadata: - -```python -model_schema = MyModel.model_json_schema() -# Results in enhanced JSON schema with pattern, format, and constraint information -{ - "properties": { - "language": { - "type": "string", - "pattern": "^[a-z]{2,3}(-[A-Za-z]{2,8})*(-[0-9][A-Za-z0-9]{3})*$", - "description": "IETF BCP-47 language tag" - }, - "categories": { - "type": "array", - "items": {"type": "string"}, - "uniqueItems": true, - "minItems": 1 - } - } -} -``` - -## Integration with Overture Schema - -This validation package integrates with Overture Maps schema packages using a hybrid approach: - -- **Field-level constraints**: Used for single-field validation -- **Mixin-based constraints**: Used for complex model-level validation -- **@model_validator**: Used for custom cross-field validation - -```python -# In your schema models -from typing import Annotated, Literal -from pydantic import model_validator -from overture.schema.validation import CountryCodeAlpha2, LanguageTag -from overture.schema.validation.mixin import ConstraintValidatedModel - -# Base properties with field-level validation -class OvertureFeatureProperties(BaseModel): - theme: str = Field(..., description="Overture theme") - type: str = Field(..., description="Feature type") - country: Optional[CountryCodeAlpha2] = None - names: Annotated[ - dict[LanguageTag, str], - Field(json_schema_extra={"additionalProperties": False}) - ] -``` diff --git a/packages/overture-schema-validation/pyproject.toml b/packages/overture-schema-validation/pyproject.toml deleted file mode 100644 index da692e6aa..000000000 --- a/packages/overture-schema-validation/pyproject.toml +++ /dev/null @@ -1,42 +0,0 @@ -[build-system] -build-backend = "hatchling.build" -requires = ["hatchling"] - -[project] -dependencies = [ - "pydantic>=2.0.0", - "shapely>=2.0.0", -] -description = "Constraint-based validation utilities for Overture Maps schemas" -dynamic = ["version"] -license = "MIT" -name = "overture-schema-validation" -readme = "README.md" -requires-python = ">=3.10" - -[tool.hatch.version] -path = "src/overture/schema/validation/__about__.py" - -[tool.hatch.build.targets.wheel] -packages = ["src/overture"] - -[tool.ruff] -line-length = 88 -target-version = "py310" - -[tool.ruff.lint] -ignore = [ - "E501", # line too long - "B008", # do not perform function calls in argument defaults - "C901", # too complex -] -per-file-ignores = { "__init__.py" = ["F401"] } -select = [ - "E", # pycodestyle errors - "W", # pycodestyle warnings - "F", # pyflakes - "I", # isort - "B", # flake8-bugbear - "C4", # flake8-comprehensions - "UP", # pyupgrade -] diff --git a/packages/overture-schema-validation/src/overture/__init__.py b/packages/overture-schema-validation/src/overture/__init__.py deleted file mode 100644 index 8db66d3d0..000000000 --- a/packages/overture-schema-validation/src/overture/__init__.py +++ /dev/null @@ -1 +0,0 @@ -__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/packages/overture-schema-validation/src/overture/schema/__init__.py b/packages/overture-schema-validation/src/overture/schema/__init__.py deleted file mode 100644 index 8db66d3d0..000000000 --- a/packages/overture-schema-validation/src/overture/schema/__init__.py +++ /dev/null @@ -1 +0,0 @@ -__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/packages/overture-schema-validation/src/overture/schema/validation/__about__.py b/packages/overture-schema-validation/src/overture/schema/validation/__about__.py deleted file mode 100644 index 3dc1f76bc..000000000 --- a/packages/overture-schema-validation/src/overture/schema/validation/__about__.py +++ /dev/null @@ -1 +0,0 @@ -__version__ = "0.1.0" diff --git a/packages/overture-schema-validation/src/overture/schema/validation/__init__.py b/packages/overture-schema-validation/src/overture/schema/validation/__init__.py deleted file mode 100644 index 1ce1a6c20..000000000 --- a/packages/overture-schema-validation/src/overture/schema/validation/__init__.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Constraint-based validation utilities for Overture Maps schemas. - -This package provides a comprehensive set of validation constraints that can be -applied to Pydantic models to enforce data quality and business rules for -Overture Maps feature data. - -Key Features: -- String pattern validation (language tags, country codes, etc.) -- Collection validation (uniqueness, size constraints) -- Numeric constraints (ranges, confidence scores) -- Conditional validation (field dependencies, mutual exclusion) -- Linear referencing validation -- Composite constraint composition - -Usage: - from overture.schema.validation import ( - LanguageTagConstraint, - CountryCodeAlpha2Constraint, - UniqueItemsConstraint, - ) - from typing import Annotated - from pydantic import BaseModel, Field - - class MyModel(BaseModel): - language: Annotated[str, LanguageTagConstraint()] - country: Annotated[str, CountryCodeAlpha2Constraint()] - tags: Annotated[list[str], UniqueItemsConstraint()] -""" - -from .mixin import ( - ConstraintValidatedModel, - min_properties, -) - -__all__ = [ - "ConstraintValidatedModel", - "min_properties", -] diff --git a/packages/overture-schema-validation/src/overture/schema/validation/mixin.py b/packages/overture-schema-validation/src/overture/schema/validation/mixin.py deleted file mode 100644 index 57aae6688..000000000 --- a/packages/overture-schema-validation/src/overture/schema/validation/mixin.py +++ /dev/null @@ -1,215 +0,0 @@ -"""Mixin-based constraint validation system with decorators. - -This module provides a structured approach to model-level validation with proper JSON -Schema generation. -""" - -from abc import ABC, abstractmethod -from typing import Any, cast - -from pydantic import BaseModel, model_validator - - -def resolve_field_names( - model_class: type[BaseModel], field_names: list[str], by_alias: bool = True -) -> list[str]: - """Resolve field names to their aliases when generating JSON schema. - - Args: - model_class: The Pydantic model class to resolve field names for - field_names: List of field names to resolve - by_alias: If True, resolve to field aliases; if False, return original names - - Returns: - List of resolved field names (aliases if by_alias=True, original names otherwise) - - Example: - Given a model with field `class_` aliased to `class`: - resolve_field_names(MyModel, ["class_"], by_alias=True) -> ["class"] - resolve_field_names(MyModel, ["class_"], by_alias=False) -> ["class_"] - """ - if not by_alias: - return field_names - - resolved_names = [] - for field_name in field_names: - resolved_name = _resolve_single_field_name(model_class, field_name) - resolved_names.append(resolved_name) - - return resolved_names - - -def _resolve_single_field_name(model_class: type[BaseModel], field_name: str) -> str: - """Resolve a single field name to its alias if it exists.""" - # Check if the field exists in the model - if not ( - hasattr(model_class, "model_fields") and field_name in model_class.model_fields - ): - return field_name - - field_info = model_class.model_fields[field_name] - - # Return alias if it exists, otherwise return the original field name - if hasattr(field_info, "alias") and field_info.alias is not None: - return field_info.alias - - return field_name - - -class BaseConstraintValidator(ABC): - """Base class for constraint validators.""" - - def __init__(self, *args: Any, **kwargs: Any) -> None: - self.args = args - self.kwargs = kwargs - - @abstractmethod - def validate(self, model_instance: BaseModel) -> None: - """Validate the constraint against the model instance.""" - pass - - @abstractmethod - def get_metadata( - self, model_class: type[BaseModel] | None = None, by_alias: bool = True - ) -> dict[str, Any]: - """Return plain constraint metadata.""" - pass - - @abstractmethod - def apply_json_schema_metadata( - self, - target_schema: dict[str, Any], - model_class: type[BaseModel] | None = None, - by_alias: bool = True, - ) -> None: - """Apply this constraint's modifications directly to the target schema.""" - pass - - -class MinPropertiesValidator(BaseConstraintValidator): - """Validates that at least N properties are set on a model.""" - - def __init__(self, min_count: int): - super().__init__() - self.min_count = min_count - - def validate(self, model_instance: BaseModel) -> None: - # Count all properties that are set (not None) - set_count = 0 - - # Get all field names from the model class - field_names = getattr(model_instance.__class__, "model_fields", {}).keys() - - for field_name in field_names: - if hasattr(model_instance, field_name): - field_value = getattr(model_instance, field_name) - if field_value is not None: - set_count += 1 - - if set_count < self.min_count: - raise ValueError( - f"At least {self.min_count} properties must be set, but only {set_count} are set" - ) - - def get_metadata( - self, model_class: type[BaseModel] | None = None, by_alias: bool = True - ) -> dict[str, Any]: - return { - "type": "min_properties", - "min_count": self.min_count, - } - - def apply_json_schema_metadata( - self, - target_schema: dict[str, Any], - model_class: type[BaseModel] | None = None, - by_alias: bool = True, - ) -> None: - """Apply minProperties constraint to the schema.""" - metadata = self.get_metadata(model_class, by_alias) - - target_schema["minProperties"] = metadata["min_count"] - - -def register_constraint( - model_class: type[BaseModel], constraint: BaseConstraintValidator -) -> None: - """Register a constraint for a model class.""" - if not hasattr(model_class, "__constraints__"): - model_class.__constraints__ = [] # type: ignore[attr-defined] - else: - # Ensure we have a copy of the constraints list for this class - # to avoid sharing references between classes - constraints = getattr(model_class, "__constraints__", []) - model_class.__constraints__ = constraints.copy() # type: ignore[attr-defined] - constraints = model_class.__constraints__ # type: ignore[attr-defined] - constraints.append(constraint) - - -def min_properties(min_count: int) -> Any: - """Decorator to add minimum properties validation.""" - - def decorator(cls: type[BaseModel]) -> type[BaseModel]: - constraint = MinPropertiesValidator(min_count) - register_constraint(cls, constraint) - return cls - - return decorator - - -# Mixin class with constraint validation -class ConstraintValidatedModel: - """Mixin class that provides constraint validation capabilities. - - This is a true mixin - it doesn't inherit from BaseModel to avoid MRO issues. - Use it like: class MyModel(ConstraintValidatedModel, BaseModel) - """ - - @model_validator(mode="after") - def validate_constraints(self) -> "ConstraintValidatedModel": - """Run all registered constraints for this model and its parent classes.""" - all_constraints: list[BaseConstraintValidator] = [] - - # Collect constraints from this class and all parent classes - # Use a more sophisticated approach to avoid cross-contamination - for cls in self.__class__.__mro__: - # Skip if this class has no constraints of its own - if not hasattr(cls, "__constraints__"): - continue - - # Only include constraints that were explicitly added to this class - # (not inherited from shared base classes) - class_constraints = getattr(cls, "__constraints__", []) - if class_constraints: - all_constraints.extend(class_constraints) - - # Run all constraints - for constraint in all_constraints: - # Cast self to BaseModel for the constraint validator - constraint.validate(self) # type: ignore[arg-type] - return self - - @classmethod - def __get_pydantic_json_schema__( - cls, core_schema: Any, handler: Any - ) -> dict[str, Any]: - """Generate JSON Schema with constraints applied.""" - # Get the base schema from Pydantic - schema: dict[str, Any] = handler(core_schema) - - # Apply constraint metadata - all_constraints: list[BaseConstraintValidator] = [] - class_constraints = getattr(cls, "__constraints__", []) - if class_constraints: - all_constraints.extend(class_constraints) - - for constraint in all_constraints: - # Apply constraint modifications directly to the schema - # OvertureFeature will handle moving them to the correct GeoJSON structure if needed - constraint.apply_json_schema_metadata( - target_schema=schema, - model_class=cast(type[BaseModel], cls), - by_alias=True, - ) - - return schema diff --git a/packages/overture-schema-validation/src/overture/schema/validation/py.typed b/packages/overture-schema-validation/src/overture/schema/validation/py.typed deleted file mode 100644 index e69de29bb..000000000 diff --git a/packages/overture-schema-validation/tests/test_json_schema_generation.py b/packages/overture-schema-validation/tests/test_json_schema_generation.py deleted file mode 100644 index 03317ceb9..000000000 --- a/packages/overture-schema-validation/tests/test_json_schema_generation.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Test JSON Schema generation for mixin-based constraint validation.""" - -from enum import Enum -from typing import Any - -import pytest - - -class SubtypeEnum(str, Enum): - """Test enum for subtypes.""" - - COUNTRY = "country" - REGION = "region" - LOCALITY = "locality" - - -def assert_pattern_constraint( - schema: dict[str, Any], - field_name: str, - expected_pattern: str, - expected_description: str | None = None, -) -> None: - """Assert that a field has the expected pattern constraint.""" - assert "properties" in schema - assert field_name in schema["properties"] - field_schema = schema["properties"][field_name] - assert "pattern" in field_schema - assert field_schema["pattern"] == expected_pattern - if expected_description: - assert field_schema.get("description") == expected_description - - -def assert_range_constraint( - schema: dict[str, Any], - field_name: str, - min_val: float | None = None, - max_val: float | None = None, - description: str | None = None, -) -> None: - """Assert that a field has the expected range constraints.""" - assert "properties" in schema - assert field_name in schema["properties"] - field_schema = schema["properties"][field_name] - if min_val is not None: - assert field_schema.get("minimum") == min_val - if max_val is not None: - assert field_schema.get("maximum") == max_val - if description: - assert field_schema.get("description") == description - - -def assert_collection_constraint( - schema: dict[str, Any], - field_name: str, - min_items: int | None = None, - max_items: int | None = None, - unique_items: bool | None = None, -) -> None: - """Assert that a field has the expected collection constraints.""" - assert "properties" in schema - assert field_name in schema["properties"] - field_schema = schema["properties"][field_name] - if min_items is not None: - assert field_schema.get("minItems") == min_items - if max_items is not None: - assert field_schema.get("maxItems") == max_items - if unique_items is not None: - assert field_schema.get("uniqueItems") == unique_items - - -if __name__ == "__main__": - pytest.main([__file__]) diff --git a/packages/overture-schema-validation/tests/test_mixin_constraints.py b/packages/overture-schema-validation/tests/test_mixin_constraints.py deleted file mode 100644 index ac8ca46fa..000000000 --- a/packages/overture-schema-validation/tests/test_mixin_constraints.py +++ /dev/null @@ -1,279 +0,0 @@ -"""Comprehensive tests for mixin-based constraint validation.""" - -from enum import Enum -from typing import Any - -import pytest -from pydantic import BaseModel, ValidationError - -from overture.schema.validation import ( - ConstraintValidatedModel, - min_properties, -) -from overture.schema.validation.mixin import ( - BaseConstraintValidator, - MinPropertiesValidator, -) - - -class PlaceType(str, Enum): - """Test enum for place types.""" - - COUNTRY = "country" - REGION = "region" - LOCALITY = "locality" - - -class TestBaseConstraintValidator: - """Test the base constraint validator functionality.""" - - def test_base_constraint_validator_abstract(self) -> None: - """Test that BaseConstraintValidator is properly abstract.""" - with pytest.raises(TypeError): - BaseConstraintValidator() # type: ignore[abstract] - - def test_custom_constraint_validator(self) -> None: - """Test creating a custom constraint validator.""" - - class CustomValidator(BaseConstraintValidator): - def __init__(self, required_value: str) -> None: - super().__init__() - self.required_value = required_value - - def validate(self, model_instance: BaseModel) -> None: - if hasattr(model_instance, "custom_field"): - if model_instance.custom_field != self.required_value: - raise ValueError(f"custom_field must be {self.required_value}") - - def get_metadata( - self, model_class: type[BaseModel] | None = None, by_alias: bool = True - ) -> dict[str, Any]: - return { - "type": "custom_constraint", - "required_value": self.required_value, - } - - def apply_json_schema_metadata( - self, - target_schema: dict[str, Any], - model_class: type[BaseModel] | None = None, - by_alias: bool = True, - ) -> None: - """Apply custom constraint metadata to the schema.""" - # This is just a test constraint, so we'll add a simple property - metadata = self.get_metadata(model_class, by_alias) - target_schema["custom_constraint"] = metadata - - # Test the custom validator - validator = CustomValidator("expected") - - class TestModel(BaseModel): - custom_field: str - - # Valid case - model = TestModel(custom_field="expected") - validator.validate(model) # Should not raise - - # Invalid case - model = TestModel(custom_field="wrong") - with pytest.raises(ValueError, match="custom_field must be expected"): - validator.validate(model) - - # Test metadata - metadata = validator.get_metadata() - assert metadata["type"] == "custom_constraint" - assert metadata["required_value"] == "expected" - - -class TestConstraintValidatedModel: - """Test the ConstraintValidatedModel base class.""" - - def test_constraint_validated_model_inheritance(self) -> None: - """Test that ConstraintValidatedModel can be inherited.""" - - class TestModel(ConstraintValidatedModel, BaseModel): - name: str - - # Should create without issues - model = TestModel(name="test") - assert model.name == "test" - - def test_constraint_validated_model_with_no_constraints(self) -> None: - """Test that models without constraints work normally.""" - - class TestModel(ConstraintValidatedModel, BaseModel): - name: str - value: int = 42 - - model = TestModel(name="test", value=100) - assert model.name == "test" - assert model.value == 100 - - def test_json_schema_generation_no_constraints(self) -> None: - """Test JSON schema generation for models without constraints.""" - - class TestModel(ConstraintValidatedModel, BaseModel): - name: str - value: int = 42 - - schema = TestModel.model_json_schema() - - # Should have standard properties - assert "properties" in schema - assert "name" in schema["properties"] - assert "value" in schema["properties"] - - # Should not have constraint-specific fields - assert "allOf" not in schema - - -class TestMinPropertiesValidator: - """Test minimum properties constraint validation.""" - - def test_min_properties_validator_direct(self) -> None: - """Test MinPropertiesValidator directly.""" - - class TestModel(BaseModel): - field_a: str | None = None - field_b: str | None = None - field_c: str | None = None - - validator = MinPropertiesValidator(min_count=1) - - # Valid: one property is set - model = TestModel(field_a="value", field_b=None, field_c=None) - validator.validate(model) # Should not raise - - # Valid: multiple properties are set - model = TestModel(field_a="value_a", field_b="value_b", field_c=None) - validator.validate(model) # Should not raise - - # Invalid: no properties are set - model = TestModel(field_a=None, field_b=None, field_c=None) - with pytest.raises( - ValueError, match="At least 1 properties must be set, but only 0 are set" - ): - validator.validate(model) - - def test_min_properties_constraint_decorator(self) -> None: - """Test minimum properties constraint using decorator.""" - - @min_properties(1) - class MinOnePropertyModel(ConstraintValidatedModel, BaseModel): - heading: str | None = None - during: str | None = None - - # Valid: one property is set - model = MinOnePropertyModel(heading="north", during=None) - assert model.heading == "north" - assert model.during is None - - # Valid: both properties are set - model = MinOnePropertyModel(heading="north", during="daytime") - assert model.heading == "north" - assert model.during == "daytime" - - # Invalid: no properties are set - with pytest.raises(ValidationError) as exc_info: - MinOnePropertyModel(heading=None, during=None) - assert "At least 1 properties must be set, but only 0 are set" in str( - exc_info.value - ) - - def test_min_properties_higher_count(self) -> None: - """Test minimum properties constraint with higher minimum count.""" - - @min_properties(2) - class MinTwoPropertiesModel(ConstraintValidatedModel, BaseModel): - field_a: str | None = None - field_b: str | None = None - field_c: str | None = None - - # Valid: exactly 2 properties set - model = MinTwoPropertiesModel( - field_a="value_a", field_b="value_b", field_c=None - ) - assert model.field_a == "value_a" - assert model.field_b == "value_b" - - # Valid: all 3 properties set - model = MinTwoPropertiesModel( - field_a="value_a", field_b="value_b", field_c="value_c" - ) - assert model.field_a == "value_a" - assert model.field_c == "value_c" - - # Invalid: only 1 property set - with pytest.raises(ValidationError) as exc_info: - MinTwoPropertiesModel(field_a="value_a", field_b=None, field_c=None) - assert "At least 2 properties must be set, but only 1 are set" in str( - exc_info.value - ) - - # Invalid: no properties set - with pytest.raises(ValidationError) as exc_info: - MinTwoPropertiesModel(field_a=None, field_b=None, field_c=None) - assert "At least 2 properties must be set, but only 0 are set" in str( - exc_info.value - ) - - def test_min_properties_json_schema(self) -> None: - """Test JSON schema generation for minimum properties constraint.""" - - @min_properties(1) - class TestModel(ConstraintValidatedModel, BaseModel): - field_a: str | None = None - field_b: str | None = None - - schema = TestModel.model_json_schema() - - # Should have minProperties constraint - assert "minProperties" in schema - assert schema["minProperties"] == 1 - - def test_min_properties_with_inheritance(self) -> None: - """Test minimum properties constraint with model inheritance.""" - - @min_properties(1) - class BaseTestModel(ConstraintValidatedModel, BaseModel): - field_a: str | None = None - field_b: str | None = None - - class DerivedModel(BaseTestModel): - field_c: str | None = None - - # Valid: base property set - model = DerivedModel(field_a="value", field_b=None, field_c=None) - assert model.field_a == "value" - - # Valid: derived property set - model = DerivedModel(field_a=None, field_b=None, field_c="value") - assert model.field_c == "value" - - # Invalid: no properties set - with pytest.raises(ValidationError) as exc_info: - DerivedModel(field_a=None, field_b=None, field_c=None) - assert "At least 1 properties must be set, but only 0 are set" in str( - exc_info.value - ) - - -class TestConstraintErrorHandling: - """Test error handling and edge cases.""" - - def test_json_schema_with_no_constraints(self) -> None: - """Test JSON schema generation when no constraints are registered.""" - - class PlainModel(ConstraintValidatedModel, BaseModel): - name: str - value: int - - schema = PlainModel.model_json_schema() - - # Should generate normal schema without constraint extensions - assert "properties" in schema - assert "allOf" not in schema - - -if __name__ == "__main__": - pytest.main([__file__]) diff --git a/uv.lock b/uv.lock index 9e42b8d6d..96e36c3d4 100644 --- a/uv.lock +++ b/uv.lock @@ -18,7 +18,6 @@ members = [ "overture-schema-places-theme", "overture-schema-system", "overture-schema-transportation-theme", - "overture-schema-validation", "overture-schema-workspace", ] @@ -33,11 +32,11 @@ wheels = [ [[package]] name = "attrs" -version = "25.3.0" +version = "25.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload-time = "2025-03-13T11:10:22.779Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, ] [[package]] @@ -748,14 +747,14 @@ name = "overture-schema-divisions-theme" source = { editable = "packages/overture-schema-divisions-theme" } dependencies = [ { name = "overture-schema-core" }, - { name = "overture-schema-validation" }, + { name = "overture-schema-system" }, { name = "pydantic" }, ] [package.metadata] requires-dist = [ { name = "overture-schema-core", editable = "packages/overture-schema-core" }, - { name = "overture-schema-validation", editable = "packages/overture-schema-validation" }, + { name = "overture-schema-system", editable = "packages/overture-schema-system" }, { name = "pydantic", specifier = ">=2.0" }, ] @@ -764,14 +763,14 @@ name = "overture-schema-places-theme" source = { editable = "packages/overture-schema-places-theme" } dependencies = [ { name = "overture-schema-core" }, - { name = "overture-schema-validation" }, + { name = "overture-schema-system" }, { name = "pydantic", extra = ["email"] }, ] [package.metadata] requires-dist = [ { name = "overture-schema-core", editable = "packages/overture-schema-core" }, - { name = "overture-schema-validation", editable = "packages/overture-schema-validation" }, + { name = "overture-schema-system", editable = "packages/overture-schema-system" }, { name = "pydantic", specifier = ">=2.0" }, { name = "pydantic", extras = ["email"] }, ] @@ -818,20 +817,6 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.0" }, ] -[[package]] -name = "overture-schema-validation" -source = { editable = "packages/overture-schema-validation" } -dependencies = [ - { name = "pydantic" }, - { name = "shapely" }, -] - -[package.metadata] -requires-dist = [ - { name = "pydantic", specifier = ">=2.0.0" }, - { name = "shapely", specifier = ">=2.0.0" }, -] - [[package]] name = "overture-schema-workspace" version = "0.0.0" @@ -913,7 +898,7 @@ wheels = [ [[package]] name = "pydantic" -version = "2.11.9" +version = "2.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -921,9 +906,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ff/5d/09a551ba512d7ca404d785072700d3f6727a02f6f3c24ecfd081c7cf0aa8/pydantic-2.11.9.tar.gz", hash = "sha256:6b8ffda597a14812a7975c90b82a8a2e777d9257aba3453f973acd3c032a18e2", size = 788495, upload-time = "2025-09-13T11:26:39.325Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/da/b8a7ee04378a53f6fefefc0c5e05570a3ebfdfa0523a878bcd3b475683ee/pydantic-2.12.0.tar.gz", hash = "sha256:c1a077e6270dbfb37bfd8b498b3981e2bb18f68103720e51fa6c306a5a9af563", size = 814760, upload-time = "2025-10-07T15:58:03.467Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3e/d3/108f2006987c58e76691d5ae5d200dd3e0f532cb4e5fa3560751c3a1feba/pydantic-2.11.9-py3-none-any.whl", hash = "sha256:c42dd626f5cfc1c6950ce6205ea58c93efa406da65f479dcb4029d5934857da2", size = 444855, upload-time = "2025-09-13T11:26:36.909Z" }, + { url = "https://files.pythonhosted.org/packages/f4/9d/d5c855424e2e5b6b626fbc6ec514d8e655a600377ce283008b115abb7445/pydantic-2.12.0-py3-none-any.whl", hash = "sha256:f6a1da352d42790537e95e83a8bdfb91c7efbae63ffd0b86fa823899e807116f", size = 459730, upload-time = "2025-10-07T15:58:01.576Z" }, ] [package.optional-dependencies] @@ -933,89 +918,112 @@ email = [ [[package]] name = "pydantic-core" -version = "2.33.2" +version = "2.41.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/14/12b4a0d2b0b10d8e1d9a24ad94e7bbb43335eaf29c0c4e57860e8a30734a/pydantic_core-2.41.1.tar.gz", hash = "sha256:1ad375859a6d8c356b7704ec0f547a58e82ee80bb41baa811ad710e124bc8f2f", size = 454870, upload-time = "2025-10-07T10:50:45.974Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/92/b31726561b5dae176c2d2c2dc43a9c5bfba5d32f96f8b4c0a600dd492447/pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8", size = 2028817, upload-time = "2025-04-23T18:30:43.919Z" }, - { url = "https://files.pythonhosted.org/packages/a3/44/3f0b95fafdaca04a483c4e685fe437c6891001bf3ce8b2fded82b9ea3aa1/pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d", size = 1861357, upload-time = "2025-04-23T18:30:46.372Z" }, - { url = "https://files.pythonhosted.org/packages/30/97/e8f13b55766234caae05372826e8e4b3b96e7b248be3157f53237682e43c/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d", size = 1898011, upload-time = "2025-04-23T18:30:47.591Z" }, - { url = "https://files.pythonhosted.org/packages/9b/a3/99c48cf7bafc991cc3ee66fd544c0aae8dc907b752f1dad2d79b1b5a471f/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572", size = 1982730, upload-time = "2025-04-23T18:30:49.328Z" }, - { url = "https://files.pythonhosted.org/packages/de/8e/a5b882ec4307010a840fb8b58bd9bf65d1840c92eae7534c7441709bf54b/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02", size = 2136178, upload-time = "2025-04-23T18:30:50.907Z" }, - { url = "https://files.pythonhosted.org/packages/e4/bb/71e35fc3ed05af6834e890edb75968e2802fe98778971ab5cba20a162315/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b", size = 2736462, upload-time = "2025-04-23T18:30:52.083Z" }, - { url = "https://files.pythonhosted.org/packages/31/0d/c8f7593e6bc7066289bbc366f2235701dcbebcd1ff0ef8e64f6f239fb47d/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2", size = 2005652, upload-time = "2025-04-23T18:30:53.389Z" }, - { url = "https://files.pythonhosted.org/packages/d2/7a/996d8bd75f3eda405e3dd219ff5ff0a283cd8e34add39d8ef9157e722867/pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a", size = 2113306, upload-time = "2025-04-23T18:30:54.661Z" }, - { url = "https://files.pythonhosted.org/packages/ff/84/daf2a6fb2db40ffda6578a7e8c5a6e9c8affb251a05c233ae37098118788/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac", size = 2073720, upload-time = "2025-04-23T18:30:56.11Z" }, - { url = "https://files.pythonhosted.org/packages/77/fb/2258da019f4825128445ae79456a5499c032b55849dbd5bed78c95ccf163/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a", size = 2244915, upload-time = "2025-04-23T18:30:57.501Z" }, - { url = "https://files.pythonhosted.org/packages/d8/7a/925ff73756031289468326e355b6fa8316960d0d65f8b5d6b3a3e7866de7/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b", size = 2241884, upload-time = "2025-04-23T18:30:58.867Z" }, - { url = "https://files.pythonhosted.org/packages/0b/b0/249ee6d2646f1cdadcb813805fe76265745c4010cf20a8eba7b0e639d9b2/pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22", size = 1910496, upload-time = "2025-04-23T18:31:00.078Z" }, - { url = "https://files.pythonhosted.org/packages/66/ff/172ba8f12a42d4b552917aa65d1f2328990d3ccfc01d5b7c943ec084299f/pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640", size = 1955019, upload-time = "2025-04-23T18:31:01.335Z" }, - { url = "https://files.pythonhosted.org/packages/3f/8d/71db63483d518cbbf290261a1fc2839d17ff89fce7089e08cad07ccfce67/pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7", size = 2028584, upload-time = "2025-04-23T18:31:03.106Z" }, - { url = "https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246", size = 1855071, upload-time = "2025-04-23T18:31:04.621Z" }, - { url = "https://files.pythonhosted.org/packages/b3/d3/4ae42d33f5e3f50dd467761304be2fa0a9417fbf09735bc2cce003480f2a/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f", size = 1897823, upload-time = "2025-04-23T18:31:06.377Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f3/aa5976e8352b7695ff808599794b1fba2a9ae2ee954a3426855935799488/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc", size = 1983792, upload-time = "2025-04-23T18:31:07.93Z" }, - { url = "https://files.pythonhosted.org/packages/d5/7a/cda9b5a23c552037717f2b2a5257e9b2bfe45e687386df9591eff7b46d28/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de", size = 2136338, upload-time = "2025-04-23T18:31:09.283Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9f/b8f9ec8dd1417eb9da784e91e1667d58a2a4a7b7b34cf4af765ef663a7e5/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a", size = 2730998, upload-time = "2025-04-23T18:31:11.7Z" }, - { url = "https://files.pythonhosted.org/packages/47/bc/cd720e078576bdb8255d5032c5d63ee5c0bf4b7173dd955185a1d658c456/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef", size = 2003200, upload-time = "2025-04-23T18:31:13.536Z" }, - { url = "https://files.pythonhosted.org/packages/ca/22/3602b895ee2cd29d11a2b349372446ae9727c32e78a94b3d588a40fdf187/pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e", size = 2113890, upload-time = "2025-04-23T18:31:15.011Z" }, - { url = "https://files.pythonhosted.org/packages/ff/e6/e3c5908c03cf00d629eb38393a98fccc38ee0ce8ecce32f69fc7d7b558a7/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d", size = 2073359, upload-time = "2025-04-23T18:31:16.393Z" }, - { url = "https://files.pythonhosted.org/packages/12/e7/6a36a07c59ebefc8777d1ffdaf5ae71b06b21952582e4b07eba88a421c79/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30", size = 2245883, upload-time = "2025-04-23T18:31:17.892Z" }, - { url = "https://files.pythonhosted.org/packages/16/3f/59b3187aaa6cc0c1e6616e8045b284de2b6a87b027cce2ffcea073adf1d2/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf", size = 2241074, upload-time = "2025-04-23T18:31:19.205Z" }, - { url = "https://files.pythonhosted.org/packages/e0/ed/55532bb88f674d5d8f67ab121a2a13c385df382de2a1677f30ad385f7438/pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51", size = 1910538, upload-time = "2025-04-23T18:31:20.541Z" }, - { url = "https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab", size = 1952909, upload-time = "2025-04-23T18:31:22.371Z" }, - { url = "https://files.pythonhosted.org/packages/49/a9/d809358e49126438055884c4366a1f6227f0f84f635a9014e2deb9b9de54/pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65", size = 1897786, upload-time = "2025-04-23T18:31:24.161Z" }, - { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" }, - { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" }, - { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" }, - { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" }, - { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" }, - { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" }, - { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" }, - { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" }, - { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" }, - { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" }, - { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" }, - { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" }, - { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" }, - { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" }, - { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" }, - { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" }, - { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" }, - { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" }, - { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" }, - { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" }, - { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" }, - { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" }, - { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" }, - { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" }, - { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" }, - { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" }, - { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" }, - { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" }, - { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" }, - { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, - { url = "https://files.pythonhosted.org/packages/30/68/373d55e58b7e83ce371691f6eaa7175e3a24b956c44628eb25d7da007917/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa", size = 2023982, upload-time = "2025-04-23T18:32:53.14Z" }, - { url = "https://files.pythonhosted.org/packages/a4/16/145f54ac08c96a63d8ed6442f9dec17b2773d19920b627b18d4f10a061ea/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29", size = 1858412, upload-time = "2025-04-23T18:32:55.52Z" }, - { url = "https://files.pythonhosted.org/packages/41/b1/c6dc6c3e2de4516c0bb2c46f6a373b91b5660312342a0cf5826e38ad82fa/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d", size = 1892749, upload-time = "2025-04-23T18:32:57.546Z" }, - { url = "https://files.pythonhosted.org/packages/12/73/8cd57e20afba760b21b742106f9dbdfa6697f1570b189c7457a1af4cd8a0/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e", size = 2067527, upload-time = "2025-04-23T18:32:59.771Z" }, - { url = "https://files.pythonhosted.org/packages/e3/d5/0bb5d988cc019b3cba4a78f2d4b3854427fc47ee8ec8e9eaabf787da239c/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c", size = 2108225, upload-time = "2025-04-23T18:33:04.51Z" }, - { url = "https://files.pythonhosted.org/packages/f1/c5/00c02d1571913d496aabf146106ad8239dc132485ee22efe08085084ff7c/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec", size = 2069490, upload-time = "2025-04-23T18:33:06.391Z" }, - { url = "https://files.pythonhosted.org/packages/22/a8/dccc38768274d3ed3a59b5d06f59ccb845778687652daa71df0cab4040d7/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052", size = 2237525, upload-time = "2025-04-23T18:33:08.44Z" }, - { url = "https://files.pythonhosted.org/packages/d4/e7/4f98c0b125dda7cf7ccd14ba936218397b44f50a56dd8c16a3091df116c3/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c", size = 2238446, upload-time = "2025-04-23T18:33:10.313Z" }, - { url = "https://files.pythonhosted.org/packages/ce/91/2ec36480fdb0b783cd9ef6795753c1dea13882f2e68e73bce76ae8c21e6a/pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808", size = 2066678, upload-time = "2025-04-23T18:33:12.224Z" }, - { url = "https://files.pythonhosted.org/packages/7b/27/d4ae6487d73948d6f20dddcd94be4ea43e74349b56eba82e9bdee2d7494c/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8", size = 2025200, upload-time = "2025-04-23T18:33:14.199Z" }, - { url = "https://files.pythonhosted.org/packages/f1/b8/b3cb95375f05d33801024079b9392a5ab45267a63400bf1866e7ce0f0de4/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593", size = 1859123, upload-time = "2025-04-23T18:33:16.555Z" }, - { url = "https://files.pythonhosted.org/packages/05/bc/0d0b5adeda59a261cd30a1235a445bf55c7e46ae44aea28f7bd6ed46e091/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612", size = 1892852, upload-time = "2025-04-23T18:33:18.513Z" }, - { url = "https://files.pythonhosted.org/packages/3e/11/d37bdebbda2e449cb3f519f6ce950927b56d62f0b84fd9cb9e372a26a3d5/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7", size = 2067484, upload-time = "2025-04-23T18:33:20.475Z" }, - { url = "https://files.pythonhosted.org/packages/8c/55/1f95f0a05ce72ecb02a8a8a1c3be0579bbc29b1d5ab68f1378b7bebc5057/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e", size = 2108896, upload-time = "2025-04-23T18:33:22.501Z" }, - { url = "https://files.pythonhosted.org/packages/53/89/2b2de6c81fa131f423246a9109d7b2a375e83968ad0800d6e57d0574629b/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8", size = 2069475, upload-time = "2025-04-23T18:33:24.528Z" }, - { url = "https://files.pythonhosted.org/packages/b8/e9/1f7efbe20d0b2b10f6718944b5d8ece9152390904f29a78e68d4e7961159/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", size = 2239013, upload-time = "2025-04-23T18:33:26.621Z" }, - { url = "https://files.pythonhosted.org/packages/3c/b2/5309c905a93811524a49b4e031e9851a6b00ff0fb668794472ea7746b448/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", size = 2238715, upload-time = "2025-04-23T18:33:28.656Z" }, - { url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757, upload-time = "2025-04-23T18:33:30.645Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2c/a5c4640dc7132540109f67fe83b566fbc7512ccf2a068cfa22a243df70c7/pydantic_core-2.41.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:e63036298322e9aea1c8b7c0a6c1204d615dbf6ec0668ce5b83ff27f07404a61", size = 2113814, upload-time = "2025-10-06T21:09:50.892Z" }, + { url = "https://files.pythonhosted.org/packages/e3/e7/a8694c3454a57842095d69c7a4ab3cf81c3c7b590f052738eabfdfc2e234/pydantic_core-2.41.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:241299ca91fc77ef64f11ed909d2d9220a01834e8e6f8de61275c4dd16b7c936", size = 1916660, upload-time = "2025-10-06T21:09:52.783Z" }, + { url = "https://files.pythonhosted.org/packages/9c/58/29f12e65b19c1877a0269eb4f23c5d2267eded6120a7d6762501ab843dc9/pydantic_core-2.41.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1ab7e594a2a5c24ab8013a7dc8cfe5f2260e80e490685814122081705c2cf2b0", size = 1975071, upload-time = "2025-10-06T21:09:54.009Z" }, + { url = "https://files.pythonhosted.org/packages/98/26/4e677f2b7ec3fbdd10be6b586a82a814c8ebe3e474024c8df2d4260e564e/pydantic_core-2.41.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b054ef1a78519cb934b58e9c90c09e93b837c935dcd907b891f2b265b129eb6e", size = 2067271, upload-time = "2025-10-06T21:09:55.175Z" }, + { url = "https://files.pythonhosted.org/packages/29/50/50614bd906089904d7ca1be3b9ecf08c00a327143d48f1decfdc21b3c302/pydantic_core-2.41.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f2ab7d10d0ab2ed6da54c757233eb0f48ebfb4f86e9b88ccecb3f92bbd61a538", size = 2253207, upload-time = "2025-10-06T21:09:56.709Z" }, + { url = "https://files.pythonhosted.org/packages/ea/58/b1e640b4ca559273cca7c28e0fe8891d5d8e9a600f5ab4882670ec107549/pydantic_core-2.41.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2757606b7948bb853a27e4040820306eaa0ccb9e8f9f8a0fa40cb674e170f350", size = 2375052, upload-time = "2025-10-06T21:09:57.97Z" }, + { url = "https://files.pythonhosted.org/packages/53/25/cd47df3bfb24350e03835f0950288d1054f1cc9a8023401dabe6d4ff2834/pydantic_core-2.41.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cec0e75eb61f606bad0a32f2be87507087514e26e8c73db6cbdb8371ccd27917", size = 2076834, upload-time = "2025-10-06T21:09:59.58Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b4/71b2c77e5df527fbbc1a03e72c3fd96c44cd10d4241a81befef8c12b9fc4/pydantic_core-2.41.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0234236514f44a5bf552105cfe2543a12f48203397d9d0f866affa569345a5b5", size = 2195374, upload-time = "2025-10-06T21:10:01.18Z" }, + { url = "https://files.pythonhosted.org/packages/aa/08/4b8a50733005865efde284fec45da75fe16a258f706e16323c5ace4004eb/pydantic_core-2.41.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:1b974e41adfbb4ebb0f65fc4ca951347b17463d60893ba7d5f7b9bb087c83897", size = 2156060, upload-time = "2025-10-06T21:10:02.74Z" }, + { url = "https://files.pythonhosted.org/packages/83/c3/1037cb603ef2130c210150a51b1710d86825b5c28df54a55750099f91196/pydantic_core-2.41.1-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:248dafb3204136113c383e91a4d815269f51562b6659b756cf3df14eefc7d0bb", size = 2331640, upload-time = "2025-10-06T21:10:04.39Z" }, + { url = "https://files.pythonhosted.org/packages/56/4c/52d111869610e6b1a46e1f1035abcdc94d0655587e39104433a290e9f377/pydantic_core-2.41.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:678f9d76a91d6bcedd7568bbf6beb77ae8447f85d1aeebaab7e2f0829cfc3a13", size = 2329844, upload-time = "2025-10-06T21:10:05.68Z" }, + { url = "https://files.pythonhosted.org/packages/32/5d/4b435f0b52ab543967761aca66b84ad3f0026e491e57de47693d15d0a8db/pydantic_core-2.41.1-cp310-cp310-win32.whl", hash = "sha256:dff5bee1d21ee58277900692a641925d2dddfde65182c972569b1a276d2ac8fb", size = 1991289, upload-time = "2025-10-06T21:10:07.199Z" }, + { url = "https://files.pythonhosted.org/packages/88/52/31b4deafc1d3cb96d0e7c0af70f0dc05454982d135d07f5117e6336153e8/pydantic_core-2.41.1-cp310-cp310-win_amd64.whl", hash = "sha256:5042da12e5d97d215f91567110fdfa2e2595a25f17c19b9ff024f31c34f9b53e", size = 2027747, upload-time = "2025-10-06T21:10:08.503Z" }, + { url = "https://files.pythonhosted.org/packages/f6/a9/ec440f02e57beabdfd804725ef1e38ac1ba00c49854d298447562e119513/pydantic_core-2.41.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4f276a6134fe1fc1daa692642a3eaa2b7b858599c49a7610816388f5e37566a1", size = 2111456, upload-time = "2025-10-06T21:10:09.824Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f9/6bc15bacfd8dcfc073a1820a564516d9c12a435a9a332d4cbbfd48828ddd/pydantic_core-2.41.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:07588570a805296ece009c59d9a679dc08fab72fb337365afb4f3a14cfbfc176", size = 1915012, upload-time = "2025-10-06T21:10:11.599Z" }, + { url = "https://files.pythonhosted.org/packages/38/8a/d9edcdcdfe80bade17bed424284427c08bea892aaec11438fa52eaeaf79c/pydantic_core-2.41.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28527e4b53400cd60ffbd9812ccb2b5135d042129716d71afd7e45bf42b855c0", size = 1973762, upload-time = "2025-10-06T21:10:13.154Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b3/ff225c6d49fba4279de04677c1c876fc3dc6562fd0c53e9bfd66f58c51a8/pydantic_core-2.41.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46a1c935c9228bad738c8a41de06478770927baedf581d172494ab36a6b96575", size = 2065386, upload-time = "2025-10-06T21:10:14.436Z" }, + { url = "https://files.pythonhosted.org/packages/47/ba/183e8c0be4321314af3fd1ae6bfc7eafdd7a49bdea5da81c56044a207316/pydantic_core-2.41.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:447ddf56e2b7d28d200d3e9eafa936fe40485744b5a824b67039937580b3cb20", size = 2252317, upload-time = "2025-10-06T21:10:15.719Z" }, + { url = "https://files.pythonhosted.org/packages/57/c5/aab61e94fd02f45c65f1f8c9ec38bb3b33fbf001a1837c74870e97462572/pydantic_core-2.41.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63892ead40c1160ac860b5debcc95c95c5a0035e543a8b5a4eac70dd22e995f4", size = 2373405, upload-time = "2025-10-06T21:10:17.017Z" }, + { url = "https://files.pythonhosted.org/packages/e5/4f/3aaa3bd1ea420a15acc42d7d3ccb3b0bbc5444ae2f9dbc1959f8173e16b8/pydantic_core-2.41.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f4a9543ca355e6df8fbe9c83e9faab707701e9103ae857ecb40f1c0cf8b0e94d", size = 2073794, upload-time = "2025-10-06T21:10:18.383Z" }, + { url = "https://files.pythonhosted.org/packages/58/bd/e3975cdebe03ec080ef881648de316c73f2a6be95c14fc4efb2f7bdd0d41/pydantic_core-2.41.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f2611bdb694116c31e551ed82e20e39a90bea9b7ad9e54aaf2d045ad621aa7a1", size = 2194430, upload-time = "2025-10-06T21:10:19.638Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/6b7e7217f147d3b3105b57fb1caec3c4f667581affdfaab6d1d277e1f749/pydantic_core-2.41.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fecc130893a9b5f7bfe230be1bb8c61fe66a19db8ab704f808cb25a82aad0bc9", size = 2154611, upload-time = "2025-10-06T21:10:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/fe/7b/239c2fe76bd8b7eef9ae2140d737368a3c6fea4fd27f8f6b4cde6baa3ce9/pydantic_core-2.41.1-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:1e2df5f8344c99b6ea5219f00fdc8950b8e6f2c422fbc1cc122ec8641fac85a1", size = 2329809, upload-time = "2025-10-06T21:10:22.678Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2e/77a821a67ff0786f2f14856d6bd1348992f695ee90136a145d7a445c1ff6/pydantic_core-2.41.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:35291331e9d8ed94c257bab6be1cb3a380b5eee570a2784bffc055e18040a2ea", size = 2327907, upload-time = "2025-10-06T21:10:24.447Z" }, + { url = "https://files.pythonhosted.org/packages/fd/9a/b54512bb9df7f64c586b369328c30481229b70ca6a5fcbb90b715e15facf/pydantic_core-2.41.1-cp311-cp311-win32.whl", hash = "sha256:2876a095292668d753f1a868c4a57c4ac9f6acbd8edda8debe4218d5848cf42f", size = 1989964, upload-time = "2025-10-06T21:10:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/9d/72/63c9a4f1a5c950e65dd522d7dd67f167681f9d4f6ece3b80085a0329f08f/pydantic_core-2.41.1-cp311-cp311-win_amd64.whl", hash = "sha256:b92d6c628e9a338846a28dfe3fcdc1a3279388624597898b105e078cdfc59298", size = 2025158, upload-time = "2025-10-06T21:10:27.522Z" }, + { url = "https://files.pythonhosted.org/packages/d8/16/4e2706184209f61b50c231529257c12eb6bd9eb36e99ea1272e4815d2200/pydantic_core-2.41.1-cp311-cp311-win_arm64.whl", hash = "sha256:7d82ae99409eb69d507a89835488fb657faa03ff9968a9379567b0d2e2e56bc5", size = 1972297, upload-time = "2025-10-06T21:10:28.814Z" }, + { url = "https://files.pythonhosted.org/packages/ee/bc/5f520319ee1c9e25010412fac4154a72e0a40d0a19eb00281b1f200c0947/pydantic_core-2.41.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:db2f82c0ccbce8f021ad304ce35cbe02aa2f95f215cac388eed542b03b4d5eb4", size = 2099300, upload-time = "2025-10-06T21:10:30.463Z" }, + { url = "https://files.pythonhosted.org/packages/31/14/010cd64c5c3814fb6064786837ec12604be0dd46df3327cf8474e38abbbd/pydantic_core-2.41.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:47694a31c710ced9205d5f1e7e8af3ca57cbb8a503d98cb9e33e27c97a501601", size = 1910179, upload-time = "2025-10-06T21:10:31.782Z" }, + { url = "https://files.pythonhosted.org/packages/8e/2e/23fc2a8a93efad52df302fdade0a60f471ecc0c7aac889801ac24b4c07d6/pydantic_core-2.41.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e9decce94daf47baf9e9d392f5f2557e783085f7c5e522011545d9d6858e00", size = 1957225, upload-time = "2025-10-06T21:10:33.11Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b6/6db08b2725b2432b9390844852e11d320281e5cea8a859c52c68001975fa/pydantic_core-2.41.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ab0adafdf2b89c8b84f847780a119437a0931eca469f7b44d356f2b426dd9741", size = 2053315, upload-time = "2025-10-06T21:10:34.87Z" }, + { url = "https://files.pythonhosted.org/packages/61/d9/4de44600f2d4514b44f3f3aeeda2e14931214b6b5bf52479339e801ce748/pydantic_core-2.41.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5da98cc81873f39fd56882e1569c4677940fbc12bce6213fad1ead784192d7c8", size = 2224298, upload-time = "2025-10-06T21:10:36.233Z" }, + { url = "https://files.pythonhosted.org/packages/7a/ae/dbe51187a7f35fc21b283c5250571a94e36373eb557c1cba9f29a9806dcf/pydantic_core-2.41.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:209910e88afb01fd0fd403947b809ba8dba0e08a095e1f703294fda0a8fdca51", size = 2351797, upload-time = "2025-10-06T21:10:37.601Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a7/975585147457c2e9fb951c7c8dab56deeb6aa313f3aa72c2fc0df3f74a49/pydantic_core-2.41.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:365109d1165d78d98e33c5bfd815a9b5d7d070f578caefaabcc5771825b4ecb5", size = 2074921, upload-time = "2025-10-06T21:10:38.927Z" }, + { url = "https://files.pythonhosted.org/packages/62/37/ea94d1d0c01dec1b7d236c7cec9103baab0021f42500975de3d42522104b/pydantic_core-2.41.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:706abf21e60a2857acdb09502bc853ee5bce732955e7b723b10311114f033115", size = 2187767, upload-time = "2025-10-06T21:10:40.651Z" }, + { url = "https://files.pythonhosted.org/packages/d3/fe/694cf9fdd3a777a618c3afd210dba7b414cb8a72b1bd29b199c2e5765fee/pydantic_core-2.41.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bf0bd5417acf7f6a7ec3b53f2109f587be176cb35f9cf016da87e6017437a72d", size = 2136062, upload-time = "2025-10-06T21:10:42.09Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/174aeabd89916fbd2988cc37b81a59e1186e952afd2a7ed92018c22f31ca/pydantic_core-2.41.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2e71b1c6ceb9c78424ae9f63a07292fb769fb890a4e7efca5554c47f33a60ea5", size = 2317819, upload-time = "2025-10-06T21:10:43.974Z" }, + { url = "https://files.pythonhosted.org/packages/65/e8/e9aecafaebf53fc456314f72886068725d6fba66f11b013532dc21259343/pydantic_core-2.41.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:80745b9770b4a38c25015b517451c817799bfb9d6499b0d13d8227ec941cb513", size = 2312267, upload-time = "2025-10-06T21:10:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/35/2f/1c2e71d2a052f9bb2f2df5a6a05464a0eb800f9e8d9dd800202fe31219e1/pydantic_core-2.41.1-cp312-cp312-win32.whl", hash = "sha256:83b64d70520e7890453f1aa21d66fda44e7b35f1cfea95adf7b4289a51e2b479", size = 1990927, upload-time = "2025-10-06T21:10:46.738Z" }, + { url = "https://files.pythonhosted.org/packages/b1/78/562998301ff2588b9c6dcc5cb21f52fa919d6e1decc75a35055feb973594/pydantic_core-2.41.1-cp312-cp312-win_amd64.whl", hash = "sha256:377defd66ee2003748ee93c52bcef2d14fde48fe28a0b156f88c3dbf9bc49a50", size = 2034703, upload-time = "2025-10-06T21:10:48.524Z" }, + { url = "https://files.pythonhosted.org/packages/b2/53/d95699ce5a5cdb44bb470bd818b848b9beadf51459fd4ea06667e8ede862/pydantic_core-2.41.1-cp312-cp312-win_arm64.whl", hash = "sha256:c95caff279d49c1d6cdfe2996e6c2ad712571d3b9caaa209a404426c326c4bde", size = 1972719, upload-time = "2025-10-06T21:10:50.256Z" }, + { url = "https://files.pythonhosted.org/packages/27/8a/6d54198536a90a37807d31a156642aae7a8e1263ed9fe6fc6245defe9332/pydantic_core-2.41.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:70e790fce5f05204ef4403159857bfcd587779da78627b0babb3654f75361ebf", size = 2105825, upload-time = "2025-10-06T21:10:51.719Z" }, + { url = "https://files.pythonhosted.org/packages/4f/2e/4784fd7b22ac9c8439db25bf98ffed6853d01e7e560a346e8af821776ccc/pydantic_core-2.41.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9cebf1ca35f10930612d60bd0f78adfacee824c30a880e3534ba02c207cceceb", size = 1910126, upload-time = "2025-10-06T21:10:53.145Z" }, + { url = "https://files.pythonhosted.org/packages/f3/92/31eb0748059ba5bd0aa708fb4bab9fcb211461ddcf9e90702a6542f22d0d/pydantic_core-2.41.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:170406a37a5bc82c22c3274616bf6f17cc7df9c4a0a0a50449e559cb755db669", size = 1961472, upload-time = "2025-10-06T21:10:55.754Z" }, + { url = "https://files.pythonhosted.org/packages/ab/91/946527792275b5c4c7dde4cfa3e81241bf6900e9fee74fb1ba43e0c0f1ab/pydantic_core-2.41.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12d4257fc9187a0ccd41b8b327d6a4e57281ab75e11dda66a9148ef2e1fb712f", size = 2063230, upload-time = "2025-10-06T21:10:57.179Z" }, + { url = "https://files.pythonhosted.org/packages/31/5d/a35c5d7b414e5c0749f1d9f0d159ee2ef4bab313f499692896b918014ee3/pydantic_core-2.41.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a75a33b4db105dd1c8d57839e17ee12db8d5ad18209e792fa325dbb4baeb00f4", size = 2229469, upload-time = "2025-10-06T21:10:59.409Z" }, + { url = "https://files.pythonhosted.org/packages/21/4d/8713737c689afa57ecfefe38db78259d4484c97aa494979e6a9d19662584/pydantic_core-2.41.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08a589f850803a74e0fcb16a72081cafb0d72a3cdda500106942b07e76b7bf62", size = 2347986, upload-time = "2025-10-06T21:11:00.847Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ec/929f9a3a5ed5cda767081494bacd32f783e707a690ce6eeb5e0730ec4986/pydantic_core-2.41.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a97939d6ea44763c456bd8a617ceada2c9b96bb5b8ab3dfa0d0827df7619014", size = 2072216, upload-time = "2025-10-06T21:11:02.43Z" }, + { url = "https://files.pythonhosted.org/packages/26/55/a33f459d4f9cc8786d9db42795dbecc84fa724b290d7d71ddc3d7155d46a/pydantic_core-2.41.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d2ae423c65c556f09569524b80ffd11babff61f33055ef9773d7c9fabc11ed8d", size = 2193047, upload-time = "2025-10-06T21:11:03.787Z" }, + { url = "https://files.pythonhosted.org/packages/77/af/d5c6959f8b089f2185760a2779079e3c2c411bfc70ea6111f58367851629/pydantic_core-2.41.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:4dc703015fbf8764d6a8001c327a87f1823b7328d40b47ce6000c65918ad2b4f", size = 2140613, upload-time = "2025-10-06T21:11:05.607Z" }, + { url = "https://files.pythonhosted.org/packages/58/e5/2c19bd2a14bffe7fabcf00efbfbd3ac430aaec5271b504a938ff019ac7be/pydantic_core-2.41.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:968e4ffdfd35698a5fe659e5e44c508b53664870a8e61c8f9d24d3d145d30257", size = 2327641, upload-time = "2025-10-06T21:11:07.143Z" }, + { url = "https://files.pythonhosted.org/packages/93/ef/e0870ccda798c54e6b100aff3c4d49df5458fd64217e860cb9c3b0a403f4/pydantic_core-2.41.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:fff2b76c8e172d34771cd4d4f0ade08072385310f214f823b5a6ad4006890d32", size = 2318229, upload-time = "2025-10-06T21:11:08.73Z" }, + { url = "https://files.pythonhosted.org/packages/b1/4b/c3b991d95f5deb24d0bd52e47bcf716098fa1afe0ce2d4bd3125b38566ba/pydantic_core-2.41.1-cp313-cp313-win32.whl", hash = "sha256:a38a5263185407ceb599f2f035faf4589d57e73c7146d64f10577f6449e8171d", size = 1997911, upload-time = "2025-10-06T21:11:10.329Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ce/5c316fd62e01f8d6be1b7ee6b54273214e871772997dc2c95e204997a055/pydantic_core-2.41.1-cp313-cp313-win_amd64.whl", hash = "sha256:b42ae7fd6760782c975897e1fdc810f483b021b32245b0105d40f6e7a3803e4b", size = 2034301, upload-time = "2025-10-06T21:11:12.113Z" }, + { url = "https://files.pythonhosted.org/packages/29/41/902640cfd6a6523194123e2c3373c60f19006447f2fb06f76de4e8466c5b/pydantic_core-2.41.1-cp313-cp313-win_arm64.whl", hash = "sha256:ad4111acc63b7384e205c27a2f15e23ac0ee21a9d77ad6f2e9cb516ec90965fb", size = 1977238, upload-time = "2025-10-06T21:11:14.1Z" }, + { url = "https://files.pythonhosted.org/packages/04/04/28b040e88c1b89d851278478842f0bdf39c7a05da9e850333c6c8cbe7dfa/pydantic_core-2.41.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:440d0df7415b50084a4ba9d870480c16c5f67c0d1d4d5119e3f70925533a0edc", size = 1875626, upload-time = "2025-10-06T21:11:15.69Z" }, + { url = "https://files.pythonhosted.org/packages/d6/58/b41dd3087505220bb58bc81be8c3e8cbc037f5710cd3c838f44f90bdd704/pydantic_core-2.41.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:71eaa38d342099405dae6484216dcf1e8e4b0bebd9b44a4e08c9b43db6a2ab67", size = 2045708, upload-time = "2025-10-06T21:11:17.258Z" }, + { url = "https://files.pythonhosted.org/packages/d7/b8/760f23754e40bf6c65b94a69b22c394c24058a0ef7e2aa471d2e39219c1a/pydantic_core-2.41.1-cp313-cp313t-win_amd64.whl", hash = "sha256:555ecf7e50f1161d3f693bc49f23c82cf6cdeafc71fa37a06120772a09a38795", size = 1997171, upload-time = "2025-10-06T21:11:18.822Z" }, + { url = "https://files.pythonhosted.org/packages/41/12/cec246429ddfa2778d2d6301eca5362194dc8749ecb19e621f2f65b5090f/pydantic_core-2.41.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:05226894a26f6f27e1deb735d7308f74ef5fa3a6de3e0135bb66cdcaee88f64b", size = 2107836, upload-time = "2025-10-06T21:11:20.432Z" }, + { url = "https://files.pythonhosted.org/packages/20/39/baba47f8d8b87081302498e610aefc37142ce6a1cc98b2ab6b931a162562/pydantic_core-2.41.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:85ff7911c6c3e2fd8d3779c50925f6406d770ea58ea6dde9c230d35b52b16b4a", size = 1904449, upload-time = "2025-10-06T21:11:22.185Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/9a3d87cae2c75a5178334b10358d631bd094b916a00a5993382222dbfd92/pydantic_core-2.41.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:47f1f642a205687d59b52dc1a9a607f45e588f5a2e9eeae05edd80c7a8c47674", size = 1961750, upload-time = "2025-10-06T21:11:24.348Z" }, + { url = "https://files.pythonhosted.org/packages/27/42/a96c9d793a04cf2a9773bff98003bb154087b94f5530a2ce6063ecfec583/pydantic_core-2.41.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:df11c24e138876ace5ec6043e5cae925e34cf38af1a1b3d63589e8f7b5f5cdc4", size = 2063305, upload-time = "2025-10-06T21:11:26.556Z" }, + { url = "https://files.pythonhosted.org/packages/3e/8d/028c4b7d157a005b1f52c086e2d4b0067886b213c86220c1153398dbdf8f/pydantic_core-2.41.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7f0bf7f5c8f7bf345c527e8a0d72d6b26eda99c1227b0c34e7e59e181260de31", size = 2228959, upload-time = "2025-10-06T21:11:28.426Z" }, + { url = "https://files.pythonhosted.org/packages/08/f7/ee64cda8fcc9ca3f4716e6357144f9ee71166775df582a1b6b738bf6da57/pydantic_core-2.41.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82b887a711d341c2c47352375d73b029418f55b20bd7815446d175a70effa706", size = 2345421, upload-time = "2025-10-06T21:11:30.226Z" }, + { url = "https://files.pythonhosted.org/packages/13/c0/e8ec05f0f5ee7a3656973ad9cd3bc73204af99f6512c1a4562f6fb4b3f7d/pydantic_core-2.41.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5f1d5d6bbba484bdf220c72d8ecd0be460f4bd4c5e534a541bb2cd57589fb8b", size = 2065288, upload-time = "2025-10-06T21:11:32.019Z" }, + { url = "https://files.pythonhosted.org/packages/0a/25/d77a73ff24e2e4fcea64472f5e39b0402d836da9b08b5361a734d0153023/pydantic_core-2.41.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2bf1917385ebe0f968dc5c6ab1375886d56992b93ddfe6bf52bff575d03662be", size = 2189759, upload-time = "2025-10-06T21:11:33.753Z" }, + { url = "https://files.pythonhosted.org/packages/66/45/4a4ebaaae12a740552278d06fe71418c0f2869537a369a89c0e6723b341d/pydantic_core-2.41.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:4f94f3ab188f44b9a73f7295663f3ecb8f2e2dd03a69c8f2ead50d37785ecb04", size = 2140747, upload-time = "2025-10-06T21:11:35.781Z" }, + { url = "https://files.pythonhosted.org/packages/da/6d/b727ce1022f143194a36593243ff244ed5a1eb3c9122296bf7e716aa37ba/pydantic_core-2.41.1-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:3925446673641d37c30bd84a9d597e49f72eacee8b43322c8999fa17d5ae5bc4", size = 2327416, upload-time = "2025-10-06T21:11:37.75Z" }, + { url = "https://files.pythonhosted.org/packages/6f/8c/02df9d8506c427787059f87c6c7253435c6895e12472a652d9616ee0fc95/pydantic_core-2.41.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:49bd51cc27adb980c7b97357ae036ce9b3c4d0bb406e84fbe16fb2d368b602a8", size = 2318138, upload-time = "2025-10-06T21:11:39.463Z" }, + { url = "https://files.pythonhosted.org/packages/98/67/0cf429a7d6802536941f430e6e3243f6d4b68f41eeea4b242372f1901794/pydantic_core-2.41.1-cp314-cp314-win32.whl", hash = "sha256:a31ca0cd0e4d12ea0df0077df2d487fc3eb9d7f96bbb13c3c5b88dcc21d05159", size = 1998429, upload-time = "2025-10-06T21:11:41.989Z" }, + { url = "https://files.pythonhosted.org/packages/38/60/742fef93de5d085022d2302a6317a2b34dbfe15258e9396a535c8a100ae7/pydantic_core-2.41.1-cp314-cp314-win_amd64.whl", hash = "sha256:1b5c4374a152e10a22175d7790e644fbd8ff58418890e07e2073ff9d4414efae", size = 2028870, upload-time = "2025-10-06T21:11:43.66Z" }, + { url = "https://files.pythonhosted.org/packages/31/38/cdd8ccb8555ef7720bd7715899bd6cfbe3c29198332710e1b61b8f5dd8b8/pydantic_core-2.41.1-cp314-cp314-win_arm64.whl", hash = "sha256:4fee76d757639b493eb600fba668f1e17475af34c17dd61db7a47e824d464ca9", size = 1974275, upload-time = "2025-10-06T21:11:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/e7/7e/8ac10ccb047dc0221aa2530ec3c7c05ab4656d4d4bd984ee85da7f3d5525/pydantic_core-2.41.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f9b9c968cfe5cd576fdd7361f47f27adeb120517e637d1b189eea1c3ece573f4", size = 1875124, upload-time = "2025-10-06T21:11:47.591Z" }, + { url = "https://files.pythonhosted.org/packages/c3/e4/7d9791efeb9c7d97e7268f8d20e0da24d03438a7fa7163ab58f1073ba968/pydantic_core-2.41.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f1ebc7ab67b856384aba09ed74e3e977dded40e693de18a4f197c67d0d4e6d8e", size = 2043075, upload-time = "2025-10-06T21:11:49.542Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c3/3f6e6b2342ac11ac8cd5cb56e24c7b14afa27c010e82a765ffa5f771884a/pydantic_core-2.41.1-cp314-cp314t-win_amd64.whl", hash = "sha256:8ae0dc57b62a762985bc7fbf636be3412394acc0ddb4ade07fe104230f1b9762", size = 1995341, upload-time = "2025-10-06T21:11:51.497Z" }, + { url = "https://files.pythonhosted.org/packages/16/89/d0afad37ba25f5801735af1472e650b86baad9fe807a42076508e4824a2a/pydantic_core-2.41.1-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:68f2251559b8efa99041bb63571ec7cdd2d715ba74cc82b3bc9eff824ebc8bf0", size = 2124001, upload-time = "2025-10-07T10:49:54.369Z" }, + { url = "https://files.pythonhosted.org/packages/8e/c4/08609134b34520568ddebb084d9ed0a2a3f5f52b45739e6e22cb3a7112eb/pydantic_core-2.41.1-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:c7bc140c596097cb53b30546ca257dbe3f19282283190b1b5142928e5d5d3a20", size = 1941841, upload-time = "2025-10-07T10:49:56.248Z" }, + { url = "https://files.pythonhosted.org/packages/2a/43/94a4877094e5fe19a3f37e7e817772263e2c573c94f1e3fa2b1eee56ef3b/pydantic_core-2.41.1-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2896510fce8f4725ec518f8b9d7f015a00db249d2fd40788f442af303480063d", size = 1961129, upload-time = "2025-10-07T10:49:58.298Z" }, + { url = "https://files.pythonhosted.org/packages/a2/30/23a224d7e25260eb5f69783a63667453037e07eb91ff0e62dabaadd47128/pydantic_core-2.41.1-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ced20e62cfa0f496ba68fa5d6c7ee71114ea67e2a5da3114d6450d7f4683572a", size = 2148770, upload-time = "2025-10-07T10:49:59.959Z" }, + { url = "https://files.pythonhosted.org/packages/2b/3e/a51c5f5d37b9288ba30683d6e96f10fa8f1defad1623ff09f1020973b577/pydantic_core-2.41.1-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:b04fa9ed049461a7398138c604b00550bc89e3e1151d84b81ad6dc93e39c4c06", size = 2115344, upload-time = "2025-10-07T10:50:02.466Z" }, + { url = "https://files.pythonhosted.org/packages/5a/bd/389504c9e0600ef4502cd5238396b527afe6ef8981a6a15cd1814fc7b434/pydantic_core-2.41.1-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:b3b7d9cfbfdc43c80a16638c6dc2768e3956e73031fca64e8e1a3ae744d1faeb", size = 1927994, upload-time = "2025-10-07T10:50:04.379Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9c/5111c6b128861cb792a4c082677e90dac4f2e090bb2e2fe06aa5b2d39027/pydantic_core-2.41.1-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eec83fc6abef04c7f9bec616e2d76ee9a6a4ae2a359b10c21d0f680e24a247ca", size = 1959394, upload-time = "2025-10-07T10:50:06.335Z" }, + { url = "https://files.pythonhosted.org/packages/14/3f/cfec8b9a0c48ce5d64409ec5e1903cb0b7363da38f14b41de2fcb3712700/pydantic_core-2.41.1-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6771a2d9f83c4038dfad5970a3eef215940682b2175e32bcc817bdc639019b28", size = 2147365, upload-time = "2025-10-07T10:50:07.978Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/f403d7ca8352e3e4df352ccacd200f5f7f7fe81cef8e458515f015091625/pydantic_core-2.41.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:fabcbdb12de6eada8d6e9a759097adb3c15440fafc675b3e94ae5c9cb8d678a0", size = 2114268, upload-time = "2025-10-07T10:50:10.257Z" }, + { url = "https://files.pythonhosted.org/packages/6e/b5/334473b6d2810df84db67f03d4f666acacfc538512c2d2a254074fee0889/pydantic_core-2.41.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:80e97ccfaf0aaf67d55de5085b0ed0d994f57747d9d03f2de5cc9847ca737b08", size = 1935786, upload-time = "2025-10-07T10:50:12.333Z" }, + { url = "https://files.pythonhosted.org/packages/ea/5e/45513e4dc621f47397cfa5fef12ba8fa5e8b1c4c07f2ff2a5fef8ff81b25/pydantic_core-2.41.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34df1fe8fea5d332484a763702e8b6a54048a9d4fe6ccf41e34a128238e01f52", size = 1971995, upload-time = "2025-10-07T10:50:14.071Z" }, + { url = "https://files.pythonhosted.org/packages/22/e3/f1797c168e5f52b973bed1c585e99827a22d5e579d1ed57d51bc15b14633/pydantic_core-2.41.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:421b5595f845842fc093f7250e24ee395f54ca62d494fdde96f43ecf9228ae01", size = 2191264, upload-time = "2025-10-07T10:50:15.788Z" }, + { url = "https://files.pythonhosted.org/packages/bb/e1/24ef4c3b4ab91c21c3a09a966c7d2cffe101058a7bfe5cc8b2c7c7d574e2/pydantic_core-2.41.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:dce8b22663c134583aaad24827863306a933f576c79da450be3984924e2031d1", size = 2152430, upload-time = "2025-10-07T10:50:18.018Z" }, + { url = "https://files.pythonhosted.org/packages/35/74/70c1e225d67f7ef3fdba02c506d9011efaf734020914920b2aa3d1a45e61/pydantic_core-2.41.1-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:300a9c162fea9906cc5c103893ca2602afd84f0ec90d3be36f4cc360125d22e1", size = 2324691, upload-time = "2025-10-07T10:50:19.801Z" }, + { url = "https://files.pythonhosted.org/packages/c8/bf/dd4d21037c8bef0d8cce90a86a3f2dcb011c30086db2a10113c3eea23eba/pydantic_core-2.41.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e019167628f6e6161ae7ab9fb70f6d076a0bf0d55aa9b20833f86a320c70dd65", size = 2324493, upload-time = "2025-10-07T10:50:21.568Z" }, + { url = "https://files.pythonhosted.org/packages/7e/78/3093b334e9c9796c8236a4701cd2ddef1c56fb0928fe282a10c797644380/pydantic_core-2.41.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:13ab9cc2de6f9d4ab645a050ae5aee61a2424ac4d3a16ba23d4c2027705e0301", size = 2146156, upload-time = "2025-10-07T10:50:23.475Z" }, + { url = "https://files.pythonhosted.org/packages/e6/6c/fa3e45c2b054a1e627a89a364917f12cbe3abc3e91b9004edaae16e7b3c5/pydantic_core-2.41.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:af2385d3f98243fb733862f806c5bb9122e5fba05b373e3af40e3c82d711cef1", size = 2112094, upload-time = "2025-10-07T10:50:25.513Z" }, + { url = "https://files.pythonhosted.org/packages/e5/17/7eebc38b4658cc8e6902d0befc26388e4c2a5f2e179c561eeb43e1922c7b/pydantic_core-2.41.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:6550617a0c2115be56f90c31a5370261d8ce9dbf051c3ed53b51172dd34da696", size = 1935300, upload-time = "2025-10-07T10:50:27.715Z" }, + { url = "https://files.pythonhosted.org/packages/2b/00/9fe640194a1717a464ab861d43595c268830f98cb1e2705aa134b3544b70/pydantic_core-2.41.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc17b6ecf4983d298686014c92ebc955a9f9baf9f57dad4065e7906e7bee6222", size = 1970417, upload-time = "2025-10-07T10:50:29.573Z" }, + { url = "https://files.pythonhosted.org/packages/b2/ad/f4cdfaf483b78ee65362363e73b6b40c48e067078d7b146e8816d5945ad6/pydantic_core-2.41.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:42ae9352cf211f08b04ea110563d6b1e415878eea5b4c70f6bdb17dca3b932d2", size = 2190745, upload-time = "2025-10-07T10:50:31.48Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c1/18f416d40a10f44e9387497ba449f40fdb1478c61ba05c4b6bdb82300362/pydantic_core-2.41.1-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:e82947de92068b0a21681a13dd2102387197092fbe7defcfb8453e0913866506", size = 2150888, upload-time = "2025-10-07T10:50:33.477Z" }, + { url = "https://files.pythonhosted.org/packages/42/30/134c8a921630d8a88d6f905a562495a6421e959a23c19b0f49b660801d67/pydantic_core-2.41.1-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:e244c37d5471c9acdcd282890c6c4c83747b77238bfa19429b8473586c907656", size = 2324489, upload-time = "2025-10-07T10:50:36.48Z" }, + { url = "https://files.pythonhosted.org/packages/9c/48/a9263aeaebdec81e941198525b43edb3b44f27cfa4cb8005b8d3eb8dec72/pydantic_core-2.41.1-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:1e798b4b304a995110d41ec93653e57975620ccb2842ba9420037985e7d7284e", size = 2322763, upload-time = "2025-10-07T10:50:38.751Z" }, + { url = "https://files.pythonhosted.org/packages/1d/62/755d2bd2593f701c5839fc084e9c2c5e2418f460383ad04e3b5d0befc3ca/pydantic_core-2.41.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f1fc716c0eb1663c59699b024428ad5ec2bcc6b928527b8fe28de6cb89f47efb", size = 2144046, upload-time = "2025-10-07T10:50:40.686Z" }, ] [[package]] @@ -1150,28 +1158,28 @@ wheels = [ [[package]] name = "ruff" -version = "0.13.3" +version = "0.14.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/8e/f9f9ca747fea8e3ac954e3690d4698c9737c23b51731d02df999c150b1c9/ruff-0.13.3.tar.gz", hash = "sha256:5b0ba0db740eefdfbcce4299f49e9eaefc643d4d007749d77d047c2bab19908e", size = 5438533, upload-time = "2025-10-02T19:29:31.582Z" } +sdist = { url = "https://files.pythonhosted.org/packages/41/b9/9bd84453ed6dd04688de9b3f3a4146a1698e8faae2ceeccce4e14c67ae17/ruff-0.14.0.tar.gz", hash = "sha256:62ec8969b7510f77945df916de15da55311fade8d6050995ff7f680afe582c57", size = 5452071, upload-time = "2025-10-07T18:21:55.763Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/33/8f7163553481466a92656d35dea9331095122bb84cf98210bef597dd2ecd/ruff-0.13.3-py3-none-linux_armv6l.whl", hash = "sha256:311860a4c5e19189c89d035638f500c1e191d283d0cc2f1600c8c80d6dcd430c", size = 12484040, upload-time = "2025-10-02T19:28:49.199Z" }, - { url = "https://files.pythonhosted.org/packages/b0/b5/4a21a4922e5dd6845e91896b0d9ef493574cbe061ef7d00a73c61db531af/ruff-0.13.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2bdad6512fb666b40fcadb65e33add2b040fc18a24997d2e47fee7d66f7fcae2", size = 13122975, upload-time = "2025-10-02T19:28:52.446Z" }, - { url = "https://files.pythonhosted.org/packages/40/90/15649af836d88c9f154e5be87e64ae7d2b1baa5a3ef317cb0c8fafcd882d/ruff-0.13.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fc6fa4637284708d6ed4e5e970d52fc3b76a557d7b4e85a53013d9d201d93286", size = 12346621, upload-time = "2025-10-02T19:28:54.712Z" }, - { url = "https://files.pythonhosted.org/packages/a5/42/bcbccb8141305f9a6d3f72549dd82d1134299177cc7eaf832599700f95a7/ruff-0.13.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c9e6469864f94a98f412f20ea143d547e4c652f45e44f369d7b74ee78185838", size = 12574408, upload-time = "2025-10-02T19:28:56.679Z" }, - { url = "https://files.pythonhosted.org/packages/ce/19/0f3681c941cdcfa2d110ce4515624c07a964dc315d3100d889fcad3bfc9e/ruff-0.13.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5bf62b705f319476c78891e0e97e965b21db468b3c999086de8ffb0d40fd2822", size = 12285330, upload-time = "2025-10-02T19:28:58.79Z" }, - { url = "https://files.pythonhosted.org/packages/10/f8/387976bf00d126b907bbd7725219257feea58650e6b055b29b224d8cb731/ruff-0.13.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78cc1abed87ce40cb07ee0667ce99dbc766c9f519eabfd948ed87295d8737c60", size = 13980815, upload-time = "2025-10-02T19:29:01.577Z" }, - { url = "https://files.pythonhosted.org/packages/0c/a6/7c8ec09d62d5a406e2b17d159e4817b63c945a8b9188a771193b7e1cc0b5/ruff-0.13.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:4fb75e7c402d504f7a9a259e0442b96403fa4a7310ffe3588d11d7e170d2b1e3", size = 14987733, upload-time = "2025-10-02T19:29:04.036Z" }, - { url = "https://files.pythonhosted.org/packages/97/e5/f403a60a12258e0fd0c2195341cfa170726f254c788673495d86ab5a9a9d/ruff-0.13.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:17b951f9d9afb39330b2bdd2dd144ce1c1335881c277837ac1b50bfd99985ed3", size = 14439848, upload-time = "2025-10-02T19:29:06.684Z" }, - { url = "https://files.pythonhosted.org/packages/39/49/3de381343e89364c2334c9f3268b0349dc734fc18b2d99a302d0935c8345/ruff-0.13.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6052f8088728898e0a449f0dde8fafc7ed47e4d878168b211977e3e7e854f662", size = 13421890, upload-time = "2025-10-02T19:29:08.767Z" }, - { url = "https://files.pythonhosted.org/packages/ab/b5/c0feca27d45ae74185a6bacc399f5d8920ab82df2d732a17213fb86a2c4c/ruff-0.13.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc742c50f4ba72ce2a3be362bd359aef7d0d302bf7637a6f942eaa763bd292af", size = 13444870, upload-time = "2025-10-02T19:29:11.234Z" }, - { url = "https://files.pythonhosted.org/packages/50/a1/b655298a1f3fda4fdc7340c3f671a4b260b009068fbeb3e4e151e9e3e1bf/ruff-0.13.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:8e5640349493b378431637019366bbd73c927e515c9c1babfea3e932f5e68e1d", size = 13691599, upload-time = "2025-10-02T19:29:13.353Z" }, - { url = "https://files.pythonhosted.org/packages/32/b0/a8705065b2dafae007bcae21354e6e2e832e03eb077bb6c8e523c2becb92/ruff-0.13.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6b139f638a80eae7073c691a5dd8d581e0ba319540be97c343d60fb12949c8d0", size = 12421893, upload-time = "2025-10-02T19:29:15.668Z" }, - { url = "https://files.pythonhosted.org/packages/0d/1e/cbe7082588d025cddbb2f23e6dfef08b1a2ef6d6f8328584ad3015b5cebd/ruff-0.13.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6b547def0a40054825de7cfa341039ebdfa51f3d4bfa6a0772940ed351d2746c", size = 12267220, upload-time = "2025-10-02T19:29:17.583Z" }, - { url = "https://files.pythonhosted.org/packages/a5/99/4086f9c43f85e0755996d09bdcb334b6fee9b1eabdf34e7d8b877fadf964/ruff-0.13.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9cc48a3564423915c93573f1981d57d101e617839bef38504f85f3677b3a0a3e", size = 13177818, upload-time = "2025-10-02T19:29:19.943Z" }, - { url = "https://files.pythonhosted.org/packages/9b/de/7b5db7e39947d9dc1c5f9f17b838ad6e680527d45288eeb568e860467010/ruff-0.13.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1a993b17ec03719c502881cb2d5f91771e8742f2ca6de740034433a97c561989", size = 13618715, upload-time = "2025-10-02T19:29:22.527Z" }, - { url = "https://files.pythonhosted.org/packages/28/d3/bb25ee567ce2f61ac52430cf99f446b0e6d49bdfa4188699ad005fdd16aa/ruff-0.13.3-py3-none-win32.whl", hash = "sha256:f14e0d1fe6460f07814d03c6e32e815bff411505178a1f539a38f6097d3e8ee3", size = 12334488, upload-time = "2025-10-02T19:29:24.782Z" }, - { url = "https://files.pythonhosted.org/packages/cf/49/12f5955818a1139eed288753479ba9d996f6ea0b101784bb1fe6977ec128/ruff-0.13.3-py3-none-win_amd64.whl", hash = "sha256:621e2e5812b691d4f244638d693e640f188bacbb9bc793ddd46837cea0503dd2", size = 13455262, upload-time = "2025-10-02T19:29:26.882Z" }, - { url = "https://files.pythonhosted.org/packages/fe/72/7b83242b26627a00e3af70d0394d68f8f02750d642567af12983031777fc/ruff-0.13.3-py3-none-win_arm64.whl", hash = "sha256:9e9e9d699841eaf4c2c798fa783df2fabc680b72059a02ca0ed81c460bc58330", size = 12538484, upload-time = "2025-10-02T19:29:28.951Z" }, + { url = "https://files.pythonhosted.org/packages/3a/4e/79d463a5f80654e93fa653ebfb98e0becc3f0e7cf6219c9ddedf1e197072/ruff-0.14.0-py3-none-linux_armv6l.whl", hash = "sha256:58e15bffa7054299becf4bab8a1187062c6f8cafbe9f6e39e0d5aface455d6b3", size = 12494532, upload-time = "2025-10-07T18:21:00.373Z" }, + { url = "https://files.pythonhosted.org/packages/ee/40/e2392f445ed8e02aa6105d49db4bfff01957379064c30f4811c3bf38aece/ruff-0.14.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:838d1b065f4df676b7c9957992f2304e41ead7a50a568185efd404297d5701e8", size = 13160768, upload-time = "2025-10-07T18:21:04.73Z" }, + { url = "https://files.pythonhosted.org/packages/75/da/2a656ea7c6b9bd14c7209918268dd40e1e6cea65f4bb9880eaaa43b055cd/ruff-0.14.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:703799d059ba50f745605b04638fa7e9682cc3da084b2092feee63500ff3d9b8", size = 12363376, upload-time = "2025-10-07T18:21:07.833Z" }, + { url = "https://files.pythonhosted.org/packages/42/e2/1ffef5a1875add82416ff388fcb7ea8b22a53be67a638487937aea81af27/ruff-0.14.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ba9a8925e90f861502f7d974cc60e18ca29c72bb0ee8bfeabb6ade35a3abde7", size = 12608055, upload-time = "2025-10-07T18:21:10.72Z" }, + { url = "https://files.pythonhosted.org/packages/4a/32/986725199d7cee510d9f1dfdf95bf1efc5fa9dd714d0d85c1fb1f6be3bc3/ruff-0.14.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e41f785498bd200ffc276eb9e1570c019c1d907b07cfb081092c8ad51975bbe7", size = 12318544, upload-time = "2025-10-07T18:21:13.741Z" }, + { url = "https://files.pythonhosted.org/packages/9a/ed/4969cefd53315164c94eaf4da7cfba1f267dc275b0abdd593d11c90829a3/ruff-0.14.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30a58c087aef4584c193aebf2700f0fbcfc1e77b89c7385e3139956fa90434e2", size = 14001280, upload-time = "2025-10-07T18:21:16.411Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ad/96c1fc9f8854c37681c9613d825925c7f24ca1acfc62a4eb3896b50bacd2/ruff-0.14.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:f8d07350bc7af0a5ce8812b7d5c1a7293cf02476752f23fdfc500d24b79b783c", size = 15027286, upload-time = "2025-10-07T18:21:19.577Z" }, + { url = "https://files.pythonhosted.org/packages/b3/00/1426978f97df4fe331074baf69615f579dc4e7c37bb4c6f57c2aad80c87f/ruff-0.14.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eec3bbbf3a7d5482b5c1f42d5fc972774d71d107d447919fca620b0be3e3b75e", size = 14451506, upload-time = "2025-10-07T18:21:22.779Z" }, + { url = "https://files.pythonhosted.org/packages/58/d5/9c1cea6e493c0cf0647674cca26b579ea9d2a213b74b5c195fbeb9678e15/ruff-0.14.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:16b68e183a0e28e5c176d51004aaa40559e8f90065a10a559176713fcf435206", size = 13437384, upload-time = "2025-10-07T18:21:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/29/b4/4cd6a4331e999fc05d9d77729c95503f99eae3ba1160469f2b64866964e3/ruff-0.14.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb732d17db2e945cfcbbc52af0143eda1da36ca8ae25083dd4f66f1542fdf82e", size = 13447976, upload-time = "2025-10-07T18:21:28.83Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c0/ac42f546d07e4f49f62332576cb845d45c67cf5610d1851254e341d563b6/ruff-0.14.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:c958f66ab884b7873e72df38dcabee03d556a8f2ee1b8538ee1c2bbd619883dd", size = 13682850, upload-time = "2025-10-07T18:21:31.842Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c4/4b0c9bcadd45b4c29fe1af9c5d1dc0ca87b4021665dfbe1c4688d407aa20/ruff-0.14.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7eb0499a2e01f6e0c285afc5bac43ab380cbfc17cd43a2e1dd10ec97d6f2c42d", size = 12449825, upload-time = "2025-10-07T18:21:35.074Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a8/e2e76288e6c16540fa820d148d83e55f15e994d852485f221b9524514730/ruff-0.14.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4c63b2d99fafa05efca0ab198fd48fa6030d57e4423df3f18e03aa62518c565f", size = 12272599, upload-time = "2025-10-07T18:21:38.08Z" }, + { url = "https://files.pythonhosted.org/packages/18/14/e2815d8eff847391af632b22422b8207704222ff575dec8d044f9ab779b2/ruff-0.14.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:668fce701b7a222f3f5327f86909db2bbe99c30877c8001ff934c5413812ac02", size = 13193828, upload-time = "2025-10-07T18:21:41.216Z" }, + { url = "https://files.pythonhosted.org/packages/44/c6/61ccc2987cf0aecc588ff8f3212dea64840770e60d78f5606cd7dc34de32/ruff-0.14.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a86bf575e05cb68dcb34e4c7dfe1064d44d3f0c04bbc0491949092192b515296", size = 13628617, upload-time = "2025-10-07T18:21:44.04Z" }, + { url = "https://files.pythonhosted.org/packages/73/e6/03b882225a1b0627e75339b420883dc3c90707a8917d2284abef7a58d317/ruff-0.14.0-py3-none-win32.whl", hash = "sha256:7450a243d7125d1c032cb4b93d9625dea46c8c42b4f06c6b709baac168e10543", size = 12367872, upload-time = "2025-10-07T18:21:46.67Z" }, + { url = "https://files.pythonhosted.org/packages/41/77/56cf9cf01ea0bfcc662de72540812e5ba8e9563f33ef3d37ab2174892c47/ruff-0.14.0-py3-none-win_amd64.whl", hash = "sha256:ea95da28cd874c4d9c922b39381cbd69cb7e7b49c21b8152b014bd4f52acddc2", size = 13464628, upload-time = "2025-10-07T18:21:50.318Z" }, + { url = "https://files.pythonhosted.org/packages/c6/2a/65880dfd0e13f7f13a775998f34703674a4554906167dce02daf7865b954/ruff-0.14.0-py3-none-win_arm64.whl", hash = "sha256:f42c9495f5c13ff841b1da4cb3c2a42075409592825dada7c5885c2c844ac730", size = 12565142, upload-time = "2025-10-07T18:21:53.577Z" }, ] [[package]] @@ -1253,41 +1261,51 @@ wheels = [ [[package]] name = "tomli" -version = "2.2.1" +version = "2.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload-time = "2024-11-27T22:38:36.873Z" } +sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077, upload-time = "2024-11-27T22:37:54.956Z" }, - { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429, upload-time = "2024-11-27T22:37:56.698Z" }, - { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067, upload-time = "2024-11-27T22:37:57.63Z" }, - { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030, upload-time = "2024-11-27T22:37:59.344Z" }, - { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898, upload-time = "2024-11-27T22:38:00.429Z" }, - { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894, upload-time = "2024-11-27T22:38:02.094Z" }, - { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319, upload-time = "2024-11-27T22:38:03.206Z" }, - { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273, upload-time = "2024-11-27T22:38:04.217Z" }, - { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310, upload-time = "2024-11-27T22:38:05.908Z" }, - { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309, upload-time = "2024-11-27T22:38:06.812Z" }, - { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762, upload-time = "2024-11-27T22:38:07.731Z" }, - { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453, upload-time = "2024-11-27T22:38:09.384Z" }, - { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486, upload-time = "2024-11-27T22:38:10.329Z" }, - { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349, upload-time = "2024-11-27T22:38:11.443Z" }, - { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159, upload-time = "2024-11-27T22:38:13.099Z" }, - { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243, upload-time = "2024-11-27T22:38:14.766Z" }, - { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645, upload-time = "2024-11-27T22:38:15.843Z" }, - { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584, upload-time = "2024-11-27T22:38:17.645Z" }, - { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875, upload-time = "2024-11-27T22:38:19.159Z" }, - { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418, upload-time = "2024-11-27T22:38:20.064Z" }, - { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708, upload-time = "2024-11-27T22:38:21.659Z" }, - { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582, upload-time = "2024-11-27T22:38:22.693Z" }, - { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543, upload-time = "2024-11-27T22:38:24.367Z" }, - { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691, upload-time = "2024-11-27T22:38:26.081Z" }, - { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170, upload-time = "2024-11-27T22:38:27.921Z" }, - { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530, upload-time = "2024-11-27T22:38:29.591Z" }, - { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666, upload-time = "2024-11-27T22:38:30.639Z" }, - { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954, upload-time = "2024-11-27T22:38:31.702Z" }, - { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload-time = "2024-11-27T22:38:32.837Z" }, - { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload-time = "2024-11-27T22:38:34.455Z" }, - { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" }, + { url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" }, + { url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" }, + { url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" }, + { url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" }, + { url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" }, + { url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" }, + { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" }, + { url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" }, + { url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" }, + { url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" }, + { url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" }, + { url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" }, + { url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" }, + { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" }, + { url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" }, + { url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" }, + { url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" }, + { url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" }, + { url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" }, + { url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" }, + { url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" }, + { url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" }, + { url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" }, + { url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" }, + { url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" }, + { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, ] [[package]] From c809b4f6d03ca849f9c5e0b00eaba0ea75686f34 Mon Sep 17 00:00:00 2001 From: schapper Date: Thu, 9 Oct 2025 22:08:51 -0700 Subject: [PATCH 05/19] wip - Record FIXME for Pydantic warning --- packages/overture-schema-core/src/overture/schema/core/types.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/overture-schema-core/src/overture/schema/core/types.py b/packages/overture-schema-core/src/overture/schema/core/types.py index 69866041e..043870867 100644 --- a/packages/overture-schema-core/src/overture/schema/core/types.py +++ b/packages/overture-schema-core/src/overture/schema/core/types.py @@ -281,6 +281,8 @@ def __get_pydantic_json_schema__( ], ) +# FIXME: Use of `default` on this "floating" type declaration results in a Pydantic warning that the +# default has no effect. Default value should be migrated to site usage in the actual models. SortKey = NewType( "SortKey", Annotated[ From 999e4c01ed45e471cc0c8747d3399bc7d1a65800 Mon Sep 17 00:00:00 2001 From: schapper Date: Fri, 10 Oct 2025 07:45:31 -0700 Subject: [PATCH 06/19] wip - ensure JSON Schema / Pydantic validation parity on required/not required --- .../src/overture/schema/system/__init__.py | 13 +++++++----- .../system/model_constraint/forbid_if.py | 13 ++++++++---- .../system/model_constraint/require_any_of.py | 20 ++++++++++++++----- .../system/model_constraint/require_if.py | 15 ++++++++++---- .../model_constraint/test_require_any_of.py | 4 ++-- 5 files changed, 45 insertions(+), 20 deletions(-) diff --git a/packages/overture-schema-system/src/overture/schema/system/__init__.py b/packages/overture-schema-system/src/overture/schema/system/__init__.py index b7094e35c..c94c8ec06 100644 --- a/packages/overture-schema-system/src/overture/schema/system/__init__.py +++ b/packages/overture-schema-system/src/overture/schema/system/__init__.py @@ -93,7 +93,8 @@ Validation failed Use decorators to add complex multi-field constraints. In this example, a validation rule is added -saying that at least one of the two optional fields is required, but they aren't both required: +saying that at least one of the two optional fields is required to have an explicit value, but +they aren't both required to: >>> from pydantic import BaseModel, ValidationError >>> from overture.schema.system.model_constraint import require_any_of @@ -105,15 +106,17 @@ ... >>> MyModel(foo=42, bar="hello") # validates OK MyModel(foo=42, bar='hello') ->>> MyModel(foo=42, bar=None) # validates OK +>>> MyModel(foo=42) # validates OK MyModel(foo=42, bar=None) ->>> MyModel(foo=None, bar="hello") # validates OK +>>> MyModel(bar="hello") # validates OK MyModel(foo=None, bar='hello') +>>> MyModel(foo=None, bar=None) # validates OK because foo and bar are explicitly set to `None` +MyModel(foo=None, bar=None) >>> >>> try: -... MyModel(foo=None, bar=None) +... MyModel() ... except ValidationError as e: -... assert "at least one of these fields must have a value, but none do: foo, bar" in str(e) +... assert "at least one of these fields must be explicitly set, but none are: foo, bar" in str(e) ... print("Validation failed") Validation failed """ diff --git a/packages/overture-schema-system/src/overture/schema/system/model_constraint/forbid_if.py b/packages/overture-schema-system/src/overture/schema/system/model_constraint/forbid_if.py index 7a6c868cf..b68c4a794 100644 --- a/packages/overture-schema-system/src/overture/schema/system/model_constraint/forbid_if.py +++ b/packages/overture-schema-system/src/overture/schema/system/model_constraint/forbid_if.py @@ -17,7 +17,12 @@ def forbid_if( ) -> Callable[[type[BaseModel]], type[BaseModel]]: """ Decorates a Pydantic model class with a constraint forbidding any of the named fields from - a value, but only if a field value condition is true. + holding an explicitly-assigned value, but only if a field value condition is true. + + To ensure parity between Python and JSON Schema validation, a field's value must be explicitly + set to violate the constraint. This means in particular that fields whose value was set by + Pydantic using a default value do not count as having a set value, and fields containing the + value `None`, if this value was explicitly set rather than being inherited by default, do count. Parameters ---------- @@ -50,7 +55,7 @@ def forbid_if( >>> try: ... MyModel(foo='special value', bar=42) ... except ValidationError as e: - ... assert 'at least one field has a value when it should not: bar' in str(e) + ... assert 'at least one field has an explicit value when it should not: bar' in str(e) ... print('Validation failed') Validation failed """ @@ -108,12 +113,12 @@ def validate_instance(self, model_instance: BaseModel) -> None: return present_fields = [ - f for f in self.field_names if getattr(model_instance, f) is not None + f for f in self.field_names if f in model_instance.model_fields_set ] if present_fields: raise ValueError( - f"at least one field has a value when it should not: {', '.join(present_fields)} - " + f"at least one field has an explicit value when it should not: {', '.join(present_fields)} - " f"these field value(s) are forbidden because {self.__condition} is true " f"(`{self.name}`)" ) diff --git a/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_any_of.py b/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_any_of.py index 7b792d775..34d7ef2d5 100644 --- a/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_any_of.py +++ b/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_any_of.py @@ -11,10 +11,15 @@ def require_any_of(*field_names: str) -> Callable[[type[BaseModel]], type[BaseModel]]: """ Decorates a Pydantic model class with a constraint requiring that at least one of the named - fields has a value. + fields has a value explicitly set. This function is the decorator version of the `RequireAnyOfConstraint` class. + To ensure parity between Python and JSON Schema validation, a field's value must be explicitly + set to satisfy the constraint. This means in particular that fields whose value was set by + Pydantic using a default value do not count as having a set value, and fields containing the + value `None`, if this value was explicitly set rather than being inherited by default, do count. + Parameters ---------- *field_names : str @@ -40,11 +45,14 @@ def require_any_of(*field_names: str) -> Callable[[type[BaseModel]], type[BaseMo MyModel(foo=42, bar=None) >>> MyModel(bar="hello") # validates OK MyModel(foo=None, bar='hello') + >>> MyModel(foo=None, bar=None) # validates OK + MyModel(foo=None, bar=None) >>> >>> try: - ... MyModel(foo=None, bar=None) + ... MyModel() ... except ValidationError as e: - ... assert "at least one of these fields must have a value, but none do: foo, bar" in str(e) + ... assert "at least one of these fields must be explicitly set, but none are: foo, bar" \ + in str(e) ... print("Validation failed") Validation failed """ @@ -85,9 +93,11 @@ def __validate_field_names(field_names: tuple[str, ...]) -> tuple[str, ...]: def validate_instance(self, model_instance: BaseModel) -> None: super().validate_instance(model_instance) - if not (any(getattr(model_instance, f) is not None for f in self.field_names)): + if not ( + any(f for f in self.field_names if f in model_instance.model_fields_set) + ): raise ValueError( - f"at least one of these fields must have a value, but none do: {', '.join(self.field_names)} (`{self.name}`)" + f"at least one of these fields must be explicitly set, but none are: {', '.join(self.field_names)} (`{self.name}`)" ) @override diff --git a/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_if.py b/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_if.py index d0df3251c..7c868e545 100644 --- a/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_if.py +++ b/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_if.py @@ -17,7 +17,12 @@ def require_if( ) -> Callable[[type[BaseModel]], type[BaseModel]]: """ Decorates a Pydantic model class with a constraint requiring all of the named fields to have a - value, but only if a field value condition is true. + value explicitly set, but only if a field value condition is true. + + To ensure parity between Python and JSON Schema validation, a field's value must be explicitly + set to satisfy the constraint. This means in particular that fields whose value was set by + Pydantic using a default value do not count as having a set value, and fields containing the + value `None`, if this value was explicitly set rather than being inherited by default, do count. Parameters ---------- @@ -50,7 +55,9 @@ def require_if( >>> try: ... MyModel(foo='special value') ... except ValidationError as e: - ... assert 'at least one field is missing a value when it should have one: bar, baz' in str(e) + ... assert ( + ... 'at least one field is missing an explicit value when it should have one: bar, baz' + ... ) in str(e) ... print('Validation failed') Validation failed """ @@ -108,12 +115,12 @@ def validate_instance(self, model_instance: BaseModel) -> None: return missing_fields = [ - f for f in self.field_names if getattr(model_instance, f) is None + f for f in self.field_names if f not in model_instance.model_fields_set ] if missing_fields: raise ValueError( - f"at least one field is missing a value when it should have one: {', '.join(missing_fields)} - " + f"at least one field is missing an explicit value when it should have one: {', '.join(missing_fields)} - " f"these field value(s) are required because {self.__condition} is true` (`{self.name}`)" ) diff --git a/packages/overture-schema-system/tests/model_constraint/test_require_any_of.py b/packages/overture-schema-system/tests/model_constraint/test_require_any_of.py index 785a3a656..21995bd13 100644 --- a/packages/overture-schema-system/tests/model_constraint/test_require_any_of.py +++ b/packages/overture-schema-system/tests/model_constraint/test_require_any_of.py @@ -55,9 +55,9 @@ class TestModel(BaseModel): with pytest.raises( ValidationError, - match="at least one of these fields must have a value, but none do: foo, bar", + match="at least one of these fields must be explicitly set, but none are: foo, bar", ): - TestModel(foo=None, bar=None) + TestModel() @pytest.mark.parametrize("foo,bar", [(42, "hello"), (42, None), (None, "hello")]) From ca1c80d26d36f9f84c9b22a4b26660eb3d5b2a9f Mon Sep 17 00:00:00 2001 From: schapper Date: Fri, 10 Oct 2025 17:30:51 -0700 Subject: [PATCH 07/19] wip - SEMI-GOOD | all unit tests done, system is passing pytest & ruff, but likely mypy issues still to fix --- .../src/overture/schema/system/__init__.py | 12 + .../overture/schema/system/create_model.py | 56 +++ .../src/overture/schema/system/metadata.py | 313 +++++++++++++++ .../system/model_constraint/forbid_if.py | 14 +- .../system/model_constraint/min_fields_set.py | 6 +- .../model_constraint/model_constraint.py | 50 +-- .../system/model_constraint/radio_group.py | 31 +- .../system/model_constraint/require_if.py | 14 +- .../tests/model_constraint/test_forbid_if.py | 198 ++++++++++ .../model_constraint/test_min_fields_set.py | 232 ++++++++++++ .../model_constraint/test_model_constraint.py | 356 +++++++++++++++++- .../model_constraint/test_multi_constraint.py | 89 +++++ .../model_constraint/test_radio_group.py | 195 ++++++++++ .../model_constraint/test_require_any_of.py | 20 +- .../tests/model_constraint/test_require_if.py | 199 ++++++++++ 15 files changed, 1720 insertions(+), 65 deletions(-) create mode 100644 packages/overture-schema-system/src/overture/schema/system/create_model.py create mode 100644 packages/overture-schema-system/src/overture/schema/system/metadata.py create mode 100644 packages/overture-schema-system/tests/model_constraint/test_forbid_if.py create mode 100644 packages/overture-schema-system/tests/model_constraint/test_min_fields_set.py create mode 100644 packages/overture-schema-system/tests/model_constraint/test_multi_constraint.py create mode 100644 packages/overture-schema-system/tests/model_constraint/test_radio_group.py create mode 100644 packages/overture-schema-system/tests/model_constraint/test_require_if.py diff --git a/packages/overture-schema-system/src/overture/schema/system/__init__.py b/packages/overture-schema-system/src/overture/schema/system/__init__.py index c94c8ec06..d0fb3394d 100644 --- a/packages/overture-schema-system/src/overture/schema/system/__init__.py +++ b/packages/overture-schema-system/src/overture/schema/system/__init__.py @@ -120,3 +120,15 @@ ... print("Validation failed") Validation failed """ + +from . import field_constraint, metadata, model_constraint, primitive, string +from .create_model import create_model + +__all__ = [ + "create_model", + "field_constraint", + "metadata", + "model_constraint", + "primitive", + "string", +] diff --git a/packages/overture-schema-system/src/overture/schema/system/create_model.py b/packages/overture-schema-system/src/overture/schema/system/create_model.py new file mode 100644 index 000000000..34a5d3182 --- /dev/null +++ b/packages/overture-schema-system/src/overture/schema/system/create_model.py @@ -0,0 +1,56 @@ +from collections.abc import Callable +from typing import Any, TypeVar + +import pydantic + +from .metadata import Metadata + +ModelT = TypeVar("ModelT", bound=pydantic.BaseModel) + + +def create_model( + model_name: str, + /, + *, + __config__: pydantic.ConfigDict | None = None, + __doc__: str | None = None, + __base__: type[ModelT] | tuple[type[ModelT], ...] | None = None, + __module__: str | None = None, + __validators__: dict[str, Callable[..., Any]] | None = None, + __cls_kwargs__: dict[str, Any] | None = None, + __qualname__: str | None = None, + __metadata__: Metadata | None = None, + **field_definitions: Any | tuple[str, Any], +) -> type[ModelT]: + """ + Dynamically creates and returns a new Pydantic model, preserving Overture metadata. + + Use `create_model` to dynamically create a subclass of any `BaseModel` while preserving Overture + `Metadata`. + + ⚠️ Use this function instead of `pydantic.create_model`, as the Pydantic version will not + preserve the metadata, which may result in your models not behaving as expected with Overture + schema tooling. ⚠️ + + If `__metadata__` is omitted or `None`, the metadata on the base model, if any, is propagated to + the new model. If a non-`None` value is provided for `__metadata__`, the new model receives the + new metadata and the metadata on the base model is not propagated. + """ + model_class = pydantic.create_model( + model_name, + __config__=__config__, + __doc__=__doc__, + __base__=__base__, + __module__=__module__, + __validators__=__validators__, + __cls_kwargs__=__cls_kwargs__, + __qualname__=__qualname__, + **field_definitions, + ) + if __metadata__ is not None: + __metadata__.attach_to(model_class) + elif __base__ is not None: + prev = Metadata.retrieve_from(__base__, None) + if prev is not None: + prev.attach_to(model_class) + return model_class diff --git a/packages/overture-schema-system/src/overture/schema/system/metadata.py b/packages/overture-schema-system/src/overture/schema/system/metadata.py new file mode 100644 index 000000000..6d0d3d57e --- /dev/null +++ b/packages/overture-schema-system/src/overture/schema/system/metadata.py @@ -0,0 +1,313 @@ +from collections.abc import ( + Hashable, + ItemsView, + Iterable, + KeysView, + Mapping, + MutableMapping, + ValuesView, +) +from typing import ( + Union, + cast, +) + +from typing_extensions import override + + +class Key: + """ + Opaque, immutable, key into a `Metadata` dictionary. + + The purpose of `Key` is to allow metadata owners to minimize the probability of a collision + between their module's metadata and some foreign module's metadata. + + Parameters + ---------- + name : str + Name of the metadata key. + + 📌 As a best practice, set `name` to the fully-qualified name of the Python module or + class that owns your metadata, *e.g.* `mypkg.mymodule` or `mypkg.mymodule.MyClass`. + private: Hashable | None + Opaque private data to reduce the changes of key collisions. + + 📌 As a best practice, use a type value that is private to your module as the value for + `private`. + + Attributes + ---------- + name : str + Name of the metadata key + + Example + ------- + >>> class _private: pass # Private data + >>> + >>> Key("mypkg.mymodule", _private) + Key(name="mypkg.mymodule", ...) + """ + + __slots__ = ("name", "_Key__private") + + def __init__(self, name: str, private: Hashable | None = None) -> None: + object.__setattr__(self, "name", name) + object.__setattr__(self, "_Key__private", private) + + def __eq__(self, other: object) -> bool: + if isinstance(other, Key): + return self.name == other.name and self.__private == other.__private + return False + + def __hash__(self) -> int: + return hash((self.name, self.__private)) + + def __str__(self) -> str: + return self.name + + def __repr__(self) -> str: + return f"Key(name={repr(self.name)}, ...)" + + def __setattr__(self, name, value): + raise AttributeError("cannot modify a `Key`") + + def __delattr__(self, name): + raise AttributeError("cannot modify a `Key`") + + +_METADATA_PRIVATE_KEY_NAME = "_[overture.system.system.Metadata]__private_key" + + +class Metadata(MutableMapping[Key, object]): + """ + Metadata dictionary that can be attached to an arbitrary Python value. + + The Overture schema system attaches uses metadata attached to Pydantic model classes to + register model-level constraints. The metadata system is fully reusable and can be used to + attach your own custom metadata to your models as well. + + A `Metadata` instance behaves like a specialization of `dict` where the key type is always an + instance of `Key`. + + Parameters + ---------- + data : Mapping[Key, object] | Iterable[tuple[Key, object] | list[Key, object]] | None = None + Source data to build the `Metadata` dictionary + + Examples + -------- + Create some metadata: + + >>> key = Key('mypkg.mymodule') + >>> metadata = Metadata({key: 42}) + + Attach the metadata to a class and retrieve it again: + + >>> class Foo: pass + >>> metadata.copy().attach_to(Foo) + >>> Metadata.retrieve_from(Foo) + Metadata({Key('mypkg.mymodule', ...): 42}) + + Attach the metadata to an instance of a class and retrieve it again: + + >>> class Bar: pass + >>> bar = Bar() + >>> metadata.copy().attach_to(bar) + >>> Metadata.retrieve_from(bar) + Metadata({Key('mypkg.mymodule', ...): 42}) + """ + + __slots__ = "_Metadata__wrapped" + + def __init__( + self, + data: Mapping[Key, object] + | Iterable[tuple[Key, object] | list[Key, object]] + | None = None, + ): + if data is None: + self.__wrapped = {} + else: + self.__wrapped = dict(Metadata.__validate_data(data)) + + def __str__(self) -> str: + return str(self.__wrapped) + + def __repr__(self) -> str: + if len(self.__wrapped) > 0: + return f"Metadata({repr(self.__wrapped)})" + else: + return "Metadata()" + + def __eq__(self, other: object) -> bool: + if isinstance(other, Metadata): + return self.__wrapped == other.__wrapped + elif isinstance(other, Mapping): + return self.__wrapped == other + else: + return False + + def __len__(self) -> int: + return len(self.__wrapped) + + def __getitem__(self, key: Key) -> object: + return self.__wrapped[key] + + def __iter__(self) -> Iterable[Key]: + return iter(self.__wrapped) + + def __contains__(self, key: Key) -> bool: + return key in self.__wrapped + + def __delitem__(self, key: Key) -> None: + del self.__wrapped[key] + + def __setitem__(self, key: Key, value: object) -> None: + if not isinstance(key, Key): + raise TypeError( + f"key must be a `Key`, but {key} has type `{type(key).__name__}`" + ) + self.__wrapped.__setitem__(key, value) + + def __ior__( + self, other: Mapping[Key, object] | Iterable[tuple[Key, object]] + ) -> "Metadata": + self.update(other) + return self + + def __or__( + self, other: Mapping[Key, object] | Iterable[tuple[Key, object]] + ) -> "Metadata": + result = Metadata(self) + result.update(other) + return result + + def __ror__( + self, other: Mapping[Key, object] | Iterable[tuple[Key, object]] + ) -> "Metadata": + result = Metadata(other) + result.update(self) + return result + + @override + def get(self, key: Key, default=None) -> object: + return self.__wrapped.get(key, default) + + @override + def keys(self) -> KeysView[Key]: + return self.__wrapped.keys() + + @override + def values(self) -> ValuesView[object]: + return self.__wrapped.values() + + @override + def items(self) -> ItemsView[Key, object]: + return self.__wrapped.items() + + def copy(self) -> "Metadata": + """ + Returns a shallow copy of this metadata. + """ + return Metadata(self.__wrapped.copy()) + + def update( + self, + data: Mapping[Key, object] + | Iterable[tuple[Key, object] | list[Key, object]] + | None = None, + ) -> None: + """ + Updates this metadata by inserting values from `data`. In the case of a key conflict, the + new value from `data` replaces the old value. + + Parameters + ---------- + data : Mapping[Key, object] | Iterable[tuple[Key, object] | list[Key, object]] | None = None + New data to insert (ignored if the value is `None`) + """ + if data is not None: + self.__wrapped.update(Metadata.__validate_data(data)) + + def attach_to(self, target: object) -> None: + """ + Attaches this metadata to the given target value. The metadata can be retrieved by calling + `retrieve_from`. + + Parameters + ---------- + target : object + Value to attach this metadata to + + Raises + ------ + AttributeError + If `target` will not accept new attributes, which can happen if it is a builtin type or + value, a frozen value, *etc.* + """ + setattr(target, _METADATA_PRIVATE_KEY_NAME, self) + + @staticmethod + def retrieve_from( + source: object, + default: Mapping[Key, object] + | Iterable[tuple[Key, object] | list[Key, object]] + | None = None, + ) -> Union["Metadata", None]: + """ + Retrieves the metadata attached go a given source value, if it exists. Metadata can be + attached by calling `attach_to`. + + Parameters + ---------- + source : object + Value to retrieve the metadata from + default : default: Mapping[Key, object] | Iterable[tuple[Key, object] | list[Key, object]] | None = None + Default value to return if `source` has no attached metadata + + Returns + ------- + Union["Metadata", None] + The metadata attached to `source`, if it exists, or `default` otherwise + """ + maybe_metadata = getattr(source, _METADATA_PRIVATE_KEY_NAME, None) + if not maybe_metadata and default is None: + return None + elif not maybe_metadata: + return Metadata(default) + elif not isinstance(maybe_metadata, Metadata): + raise TypeError( + f"attribute {_METADATA_PRIVATE_KEY_NAME} must be a `Metadata` instance, but {maybe_metadata} has type `{type(maybe_metadata).__name__}`" + ) + else: + return cast(Metadata, maybe_metadata) + + @staticmethod + def __validate_data( + data: Mapping[Key, object] | Iterable[tuple[Key, object] | list[Key, object]], + ) -> Mapping[Key, object] | Iterable[tuple[Key, object] | list[Key, object]]: + if isinstance(data, Mapping): + for k, _ in data.items(): + if not isinstance(k, Key): + raise TypeError( + f"key must be a `Key`, but {k} has type `{type(k).__name__}`" + ) + elif isinstance(data, Iterable): + for i, item in enumerate(data): + if not isinstance(item, (tuple, list)): + raise TypeError( + f"items must be pairs (`tuple` or `list`), but item index {i} has type `{type(item).__name}`" + ) + elif len(item) != 2: + raise ValueError( + f"items must be pairs, but item index {i} has len = {len(item)}" + ) + elif not isinstance(item[0], Key): + raise TypeError( + f"each item's first element must be a `Key`, but first element for item index {i} has type `{type(item[0]).__name__}`" + ) + else: + raise TypeError( + f"`data` must be a `Mapping` or `Iterable`, but {data} has type `{type(data).__name__}`" + ) + return data diff --git a/packages/overture-schema-system/src/overture/schema/system/model_constraint/forbid_if.py b/packages/overture-schema-system/src/overture/schema/system/model_constraint/forbid_if.py index b68c4a794..4f6b81c41 100644 --- a/packages/overture-schema-system/src/overture/schema/system/model_constraint/forbid_if.py +++ b/packages/overture-schema-system/src/overture/schema/system/model_constraint/forbid_if.py @@ -97,7 +97,7 @@ def _create_internal( def __set_condition(self, condition: Condition) -> None: if not isinstance(condition, Condition): raise TypeError( - f"`condition` must be a `{Condition.__name__}`, but {repr(condition)} is a {type(condition).__name__} (`{self.name}`)" + f"`condition` must be a `{Condition.__name__}`, but {repr(condition)} has type `{type(condition).__name__}` (`{self.name}`)" ) self.__condition = condition @@ -105,6 +105,12 @@ def __set_condition(self, condition: Condition) -> None: def condition(self) -> Condition: return self.__condition + @override + def validate_class(self, model_class: type[BaseModel]) -> None: + super().validate_class(model_class) + + self.__condition.validate_class(model_class) + @override def validate_instance(self, model_instance: BaseModel) -> None: super().validate_instance(model_instance) @@ -123,12 +129,6 @@ def validate_instance(self, model_instance: BaseModel) -> None: f"(`{self.name}`)" ) - @override - def validate_class(self, model_class: type[BaseModel]) -> None: - super().validate_class(model_class) - - self.__condition.validate_class(model_class) - @override def edit_config(self, model_class: type[BaseModel], config: ConfigDict) -> None: super().edit_config(model_class, config) diff --git a/packages/overture-schema-system/src/overture/schema/system/model_constraint/min_fields_set.py b/packages/overture-schema-system/src/overture/schema/system/model_constraint/min_fields_set.py index f3ab716ea..f50bbbd53 100644 --- a/packages/overture-schema-system/src/overture/schema/system/model_constraint/min_fields_set.py +++ b/packages/overture-schema-system/src/overture/schema/system/model_constraint/min_fields_set.py @@ -71,7 +71,11 @@ def _create_internal(cls, name: str, count: int) -> "MinFieldsSetConstraint": def __set_count(self, count: int) -> None: if not isinstance(count, int): raise TypeError( - f"count must be an `int`, but {repr(count)} is a `{type(count).__name__}`" + f"`count` must be an `int`, but {repr(count)} is a `{type(count).__name__}`" + ) + elif count < 1: + raise ValueError( + f"`count` must be a positive number, but {count} is less than 1" ) self.__count = count diff --git a/packages/overture-schema-system/src/overture/schema/system/model_constraint/model_constraint.py b/packages/overture-schema-system/src/overture/schema/system/model_constraint/model_constraint.py index 2931ad6b9..5bd0266d4 100644 --- a/packages/overture-schema-system/src/overture/schema/system/model_constraint/model_constraint.py +++ b/packages/overture-schema-system/src/overture/schema/system/model_constraint/model_constraint.py @@ -8,12 +8,14 @@ from pydantic import ( BaseModel, ConfigDict, - create_model, model_validator, ) from pydantic.json_schema import JsonDict, to_jsonable_python from typing_extensions import override +from ..create_model import create_model +from ..metadata import Key, Metadata + class ModelConstraint: """ @@ -39,7 +41,7 @@ def __init__(self, name: str | None = None): name = type(self).__name__ elif not isinstance(name, str): raise TypeError( - f"`name` must be a str, but {name} is a `{type(name).__name__}`" + f"`name` must be a `str`, but {name} has type `{type(name).__name__}`" ) self.__name = name @@ -111,6 +113,9 @@ def decorate(self, model_class: type[BaseModel]) -> type[BaseModel]: self.validate_class(model_class) config = deepcopy(model_class.model_config) self.edit_config(model_class, config) + metadata = Metadata.retrieve_from(model_class, Metadata()).copy() + model_constraints = (*ModelConstraint.get_model_constraints(model_class), self) + metadata[_MODEL_CONSTRAINT_KEY] = model_constraints new_model_class = create_model( model_class.__name__, __config__=config, @@ -123,9 +128,8 @@ def decorate(self, model_class: type[BaseModel]) -> type[BaseModel]: model_validator(mode="after")(self.__validate_instance), ) }, + __metadata__=metadata, ) - model_constraints = (*ModelConstraint.get_model_constraints(model_class), self) - setattr(new_model_class, _MODEL_CONSTRAINT_PRIVATE_LIST_NAME, model_constraints) return new_model_class def validate_class(self, model_class: type[BaseModel]) -> None: @@ -213,23 +217,23 @@ def get_model_constraints( >>> [c.name for c in ModelConstraint.get_model_constraints(MyModel)] ['@require_any_of'] """ + return cast( + tuple[ModelConstraint, ...], + Metadata.retrieve_from(model_class, Metadata()).get( + _MODEL_CONSTRAINT_KEY, () + ), + ) - maybe_tuple = getattr(model_class, _MODEL_CONSTRAINT_PRIVATE_LIST_NAME, None) - if not maybe_tuple: - return () - elif not isinstance(maybe_tuple, tuple): - raise TypeError( - f"attribute {_MODEL_CONSTRAINT_PRIVATE_LIST_NAME} must be a tuple, but {maybe_tuple} is a `{type(maybe_tuple).__name__}`" - ) - elif not all(isinstance(x, ModelConstraint) for x in maybe_tuple): - raise TypeError( - f"attribute {_MODEL_CONSTRAINT_PRIVATE_LIST_NAME} may only contain `{str.__name__}` values" - ) - else: - return maybe_tuple +# Private: Used to construct the opaque metadata key. +class _ModelKeyClass: + pass -_MODEL_CONSTRAINT_PRIVATE_LIST_NAME = "_ModelConstraint__private_list" + +# Private: Opaque metadata key. +_MODEL_CONSTRAINT_KEY = Key( + f"{ModelConstraint.__module__}.{ModelConstraint.__qualname__}", _ModelKeyClass +) class FieldGroupConstraint(ModelConstraint): @@ -273,13 +277,13 @@ def field_names(self) -> tuple[str, ...]: def __set_field_names(self, field_names: tuple[str, ...]) -> None: if not isinstance(field_names, tuple): raise TypeError( - f"`field_names` must be a `tuple`, but {field_names} is a `{type(field_names).__name__}" + f"`field_names` must be a `tuple`, but {field_names} has type `{type(field_names).__name__}`" ) elif len(field_names) == 0: raise ValueError("`field_names` cannot be empty, but it is") elif not all(isinstance(s, str) for s in field_names): raise TypeError( - f"`field_names` must contain only `str` values, but {field_names} contains at least one non-string" + f"`field_names` must contain only `str` values, but {field_names} contains at least one non-`str` value" ) dupes = [s for s, count in Counter(field_names).items() if count > 1] if dupes: @@ -295,7 +299,7 @@ def validate_class(self, model_class: type[BaseModel]) -> None: ] if missing_fields: raise TypeError( - f"`{self.name}` specifies fields that are not in the model class `{model_class.__name__}`: {', '.join(missing_fields)} " + f"`{self.name}` specifies one or more fields that are not in the model class `{model_class.__name__}`: {', '.join(missing_fields)}" ) @@ -444,7 +448,7 @@ class __FieldCondition(Condition): def __post_init__(self) -> None: if not isinstance(self.field_name, str): raise TypeError( - f"`field_name` must be a `str`, but {repr(self.field_name)} is a {type(self.field_name).__name__})" + f"`field_name` must be a `str`, but {repr(self.field_name)} is a {type(self.field_name).__name__}" ) @override @@ -464,7 +468,7 @@ def validate_class(self, model_class: type[BaseModel]) -> None: """ if self.field_name not in model_class.model_fields: raise TypeError( - f"`model class `{model_class.__name__}` must contain the condition field {repr(self.field_name)}, but it does not" + f"model class `{model_class.__name__}` must contain the condition field {repr(self.field_name)}, but it does not" ) diff --git a/packages/overture-schema-system/src/overture/schema/system/model_constraint/radio_group.py b/packages/overture-schema-system/src/overture/schema/system/model_constraint/radio_group.py index 8cc12df3f..d3a0c9f23 100644 --- a/packages/overture-schema-system/src/overture/schema/system/model_constraint/radio_group.py +++ b/packages/overture-schema-system/src/overture/schema/system/model_constraint/radio_group.py @@ -1,13 +1,13 @@ from collections.abc import Callable from types import NoneType, UnionType -from typing import Any, Union, get_args, get_origin +from typing import Annotated, Any, Union, get_args, get_origin from pydantic import BaseModel, ConfigDict from pydantic.json_schema import JsonDict from typing_extensions import override from .json_schema import get_static_json_schema, put_one_of -from .model_constraint import OptionalFieldGroupConstraint, apply_alias +from .model_constraint import FieldGroupConstraint, apply_alias def radio_group(*field_names: str) -> Callable[[type[BaseModel]], type[BaseModel]]: @@ -66,7 +66,7 @@ def radio_group(*field_names: str) -> Callable[[type[BaseModel]], type[BaseModel return model_constraint.decorate -class RadioGroupConstraint(OptionalFieldGroupConstraint): +class RadioGroupConstraint(FieldGroupConstraint): """ Class implementing the `radio_group` decorator, which can also be used standalone. """ @@ -97,9 +97,14 @@ def validate_class(self, model_class: type[BaseModel]) -> None: def is_bool(annotation: type[Any] | None) -> bool: if annotation is bool: return True + origin = get_origin(annotation) + if origin is Annotated: + return is_bool(get_args(annotation)[0]) elif get_origin(annotation) in (Union, UnionType): args = get_args(annotation) - return all(a in (bool, NoneType) for a in args) + return any(is_bool(a) for a in args) and all( + is_bool(a) or a in (None, NoneType) for a in args + ) else: return False @@ -110,25 +115,27 @@ def is_bool(annotation: type[Any] | None) -> bool: ] if non_bool_fields: raise TypeError( - f"`{self.name}` specifies fields that are have a non-`bool` type in the `{model_class.__name__}`: {', '.join(non_bool_fields)} " + f"`{self.name}` specifies fields that are have a non-`bool` type in the model class `{model_class.__name__}`: {', '.join(non_bool_fields)} " ) @override def validate_instance(self, model_instance: BaseModel) -> None: super().validate_instance(model_instance) - non_true_fields = [ - f for f in self.field_names if getattr(model_instance, f) is not True + true_fields = [ + f for f in self.field_names if getattr(model_instance, f) is True ] - if len(non_true_fields) == 1: + if len(true_fields) == 1: return - elif len(non_true_fields) == 0: + elif len(true_fields) == 0: msg = "none is True" - elif len(non_true_fields) == 2: - msg = f"both of these fields are True: {non_true_fields[0]} and {non_true_fields[1]}" + elif len(true_fields) == 2: + msg = ( + f"both of these fields are True: {true_fields[0]} and {true_fields[1]}" + ) else: - msg = f"all of these fields are True: {', '.join(non_true_fields)}" + msg = f"all of these fields are True: {', '.join(true_fields)}" raise ValueError( f"exactly one field from the `bool` field group [{', '.join(self.field_names)}] " f"must be True, but {msg} (`{self.name}`)" diff --git a/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_if.py b/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_if.py index 7c868e545..cde193d13 100644 --- a/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_if.py +++ b/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_if.py @@ -99,7 +99,7 @@ def _create_internal( def __set_condition(self, condition: Condition) -> None: if not isinstance(condition, Condition): raise TypeError( - f"`condition` must be a `{Condition.__name__}`, but {repr(condition)} is a {type(condition).__name__} (`{self.name}`)" + f"`condition` must be a `{Condition.__name__}`, but {repr(condition)} has type `{type(condition).__name__}` (`{self.name}`)" ) self.__condition = condition @@ -107,6 +107,12 @@ def __set_condition(self, condition: Condition) -> None: def condition(self) -> Condition: return self.__condition + @override + def validate_class(self, model_class: type[BaseModel]) -> None: + super().validate_class(model_class) + + self.__condition.validate_class(model_class) + @override def validate_instance(self, model_instance: BaseModel) -> None: super().validate_instance(model_instance) @@ -124,12 +130,6 @@ def validate_instance(self, model_instance: BaseModel) -> None: f"these field value(s) are required because {self.__condition} is true` (`{self.name}`)" ) - @override - def validate_class(self, model_class: type[BaseModel]) -> None: - super().validate_class(model_class) - - self.__condition.validate_class(model_class) - @override def edit_config(self, model_class: type[BaseModel], config: ConfigDict) -> None: super().edit_config(model_class, config) diff --git a/packages/overture-schema-system/tests/model_constraint/test_forbid_if.py b/packages/overture-schema-system/tests/model_constraint/test_forbid_if.py new file mode 100644 index 000000000..e065d5fe6 --- /dev/null +++ b/packages/overture-schema-system/tests/model_constraint/test_forbid_if.py @@ -0,0 +1,198 @@ +import sys +from pathlib import Path +from typing import cast + +import pytest +from pydantic import BaseModel, ConfigDict, Field +from pydantic.json_schema import JsonDict + +from overture.schema.system import create_model +from overture.schema.system.model_constraint import ( + Condition, + FieldEqCondition, + ForbidIfConstraint, + ModelConstraint, + forbid_if, +) + +sys.path.insert(0, str(Path(__file__).parent)) # Needed to import `util` peer module. + +from util import assert_subset + + +@pytest.mark.parametrize("field_names", [[], ()]) +def test_error_not_enough_field_names(field_names: list[str]): + with pytest.raises(ValueError, match="`field_names` cannot be empty, but it is"): + forbid_if(field_names, FieldEqCondition("foo", 42)) + + +def test_error_invalid_condition() -> None: + with pytest.raises( + TypeError, match="`condition` must be a `Condition`, but 42 has type `int`" + ): + ForbidIfConstraint(["foo"], cast(Condition, 42)) + + +@pytest.mark.parametrize( + "constraint,model_class", + [ + ( + ForbidIfConstraint(["foo"], FieldEqCondition("bar", 42)), + create_model("case1", foo=(int, ...), bar=(int, ...)), + ), + ( + ForbidIfConstraint(["bar"], FieldEqCondition("foo", 42)), + create_model("case2", foo=(int | None, None)), + ), + ( + ForbidIfConstraint(["bar"], FieldEqCondition("foo", 42)), + create_model("case3", bar=(int | None, None)), + ), + ], +) +def test_error_invalid_model_class( + constraint: ForbidIfConstraint, model_class: type[BaseModel] +) -> None: + with pytest.raises(TypeError): + constraint.validate_class(model_class) + + with pytest.raises(TypeError): + constraint.decorate(model_class) + + +@pytest.mark.parametrize( + "constraint", + [ + (ForbidIfConstraint(["foo"], FieldEqCondition("qux", 42))), + (ForbidIfConstraint(["bar"], FieldEqCondition("qux", 42))), + (ForbidIfConstraint(["foo", "bar"], FieldEqCondition("qux", 42))), + (ForbidIfConstraint(["foo", "baz"], FieldEqCondition("qux", 42))), + (ForbidIfConstraint(["bar", "baz"], FieldEqCondition("qux", 42))), + (ForbidIfConstraint(["foo", "bar", "baz"], FieldEqCondition("qux", 42))), + ], +) +def test_error_invalid_model_instance(constraint: ForbidIfConstraint) -> None: + class TestModel(BaseModel): + foo: int | None = None + bar: int | None = None + baz: int | None = None + qux: int + + model_instance = TestModel(foo=40, bar=41, qux=42) + + constraint.validate_class(TestModel) + with pytest.raises( + ValueError, match="at least one field has an explicit value when it should not" + ): + constraint.validate_instance(model_instance) + + +def test_create_success() -> None: + condition = FieldEqCondition("foo", 42) + constraint = ForbidIfConstraint(("bar",), condition) + assert constraint.field_names == ("bar",) + assert constraint.condition is condition + + not_condition = ~FieldEqCondition("foo", 42) + not_constraint = ForbidIfConstraint(["baz", "qux"], not_condition) + assert not_constraint.field_names == ("baz", "qux") + assert not_constraint.condition is not_condition + + +@pytest.mark.parametrize("field_names", [["foo"], ["bar"], ["foo", "bar"]]) +def test_valid_model_instance_condition_true(field_names: list[str]) -> None: + class TestModel(BaseModel): + foo: int | None = None + bar: int | None = None + baz: int + + constraint = ForbidIfConstraint(field_names, FieldEqCondition("baz", 42)) + constraint.validate_instance(TestModel(baz=42)) + + +@pytest.mark.parametrize("field_names", [["foo"], ["bar"], ["foo", "bar"]]) +def test_valid_model_instance_condition_false(field_names: list[str]) -> None: + class TestModel(BaseModel): + foo: int | None = None + bar: int | None = None + baz: int | None = None + + constraint = ForbidIfConstraint(field_names, FieldEqCondition("baz", 42)) + constraint.validate_instance(TestModel(foo=42)) + + +def test_model_json_schema_no_model_config() -> None: + @forbid_if(["foo", "bar"], FieldEqCondition("qux", 42)) + class TestModel(BaseModel): + foo: int | None = None + bar: str | None = Field(default=None, alias="baz") + qux: int + + actual = TestModel.model_json_schema() + expect = { + "if": {"properties": {"qux": {"const": 42}}}, + "then": {"not": {"required": ["foo", "baz"]}}, + } + assert expect == TestModel.model_config["json_schema_extra"] + assert_subset(expect, actual, "expect", "actual") + + +@pytest.mark.parametrize( + "base_json_schema,expect", + [ + ( + None, + { + "if": {"not": {"properties": {"corge": {"const": 42}}}}, + "then": {"not": {"required": ["bar", "baz"]}}, + }, + ), + ( + {"random": "value"}, + { + "random": "value", + "if": {"not": {"properties": {"corge": {"const": 42}}}}, + "then": {"not": {"required": ["bar", "baz"]}}, + }, + ), + ( + {"if": 123}, + { + "allOf": [ + {"if": 123}, + { + "if": {"not": {"properties": {"corge": {"const": 42}}}}, + "then": {"not": {"required": ["bar", "baz"]}}, + }, + ] + }, + ), + ], +) +def test_model_json_schema_with_model_config( + base_json_schema: JsonDict | None, expect: JsonDict +) -> None: + @forbid_if(["foo", "baz"], ~FieldEqCondition("qux", 42)) + class TestModel(BaseModel): + model_config = ConfigDict(json_schema_extra=base_json_schema) + + foo: int | None = Field(default=None, alias="bar") + baz: str | None = None + qux: int = Field(alias="corge") + + actual = TestModel.model_json_schema() + assert_subset(expect, actual, "expect", "actual") + + +def test_model_constraints() -> None: + constraint = ForbidIfConstraint(["foo"], FieldEqCondition("bar", "baz")) + + class TestModel(BaseModel): + foo: int | None = None + bar: str + + assert 0 == len(ModelConstraint.get_model_constraints(TestModel)) + + new_model_class = constraint.decorate(TestModel) + + assert (constraint,) == ModelConstraint.get_model_constraints(new_model_class) diff --git a/packages/overture-schema-system/tests/model_constraint/test_min_fields_set.py b/packages/overture-schema-system/tests/model_constraint/test_min_fields_set.py new file mode 100644 index 000000000..94e6f0b29 --- /dev/null +++ b/packages/overture-schema-system/tests/model_constraint/test_min_fields_set.py @@ -0,0 +1,232 @@ +import sys +from collections.abc import Callable +from pathlib import Path +from typing import cast + +import pytest +from pydantic import BaseModel, ConfigDict + +from overture.schema.system import create_model +from overture.schema.system.model_constraint import ( + MinFieldsSetConstraint, + min_fields_set, +) + +sys.path.insert(0, str(Path(__file__).parent)) # Needed to import `util` peer module. + +from util import assert_subset + + +def test_error_invalid_count_type() -> None: + with pytest.raises( + TypeError, match="`count` must be an `int`, but 'foo' is a `str`" + ): + min_fields_set(cast(int, "foo")) + + +@pytest.mark.parametrize("count", [-1, 0]) +def test_error_invalid_count_value(count: int) -> None: + with pytest.raises(ValueError, match="`count` must be a positive number"): + min_fields_set(count) + + +@pytest.mark.parametrize( + "count,model_class", + [ + (1, create_model("case11")), + (1, create_model("case12", __config__=ConfigDict(extra="forbid"))), + (1, create_model("case13", __config__=ConfigDict(extra="ignore"))), + (2, create_model("case21", foo=(int | None, ...))), + ( + 2, + create_model( + "case22", foo=(int | None, ...), __config__=ConfigDict(extra="forbid") + ), + ), + ( + 2, + create_model( + "case23", foo=(int | None, ...), __config__=ConfigDict(extra="ignore") + ), + ), + ], +) +def test_error_invalid_model_class(count: int, model_class: type[BaseModel]) -> None: + constraint = MinFieldsSetConstraint(count) + + with pytest.raises(TypeError): + constraint.validate_class(model_class) + + with pytest.raises(TypeError): + constraint.decorate(model_class) + + +@pytest.mark.parametrize( + "count,model_class,factory", + [ + (1, create_model("case11", foo=(int | None, None)), lambda x: x()), + ( + 1, + create_model( + "case12", foo=(int | None, None), __config__=ConfigDict(extra="allow") + ), + lambda x: x(), + ), + ( + 2, + create_model( + "case21", foo=(int | None, None), __config__=ConfigDict(extra="allow") + ), + lambda x: x(foo=42), + ), + ( + 2, + create_model( + "case22", foo=(int | None, None), __config__=ConfigDict(extra="allow") + ), + lambda x: x(bar=42), + ), + ( + 2, + create_model("case23", foo=(int | None, None), bar=(str | None, None)), + lambda x: x(), + ), + ( + 2, + create_model("case23", foo=(int | None, None), bar=(str | None, None)), + lambda x: x(foo=42), + ), + ( + 2, + create_model("case23", foo=(int | None, None), bar=(str | None, None)), + lambda x: x(bar="baz"), + ), + ], +) +def test_error_invalid_model_instance( + count: int, + model_class: type[BaseModel], + factory: Callable[[type[BaseModel]], BaseModel], +) -> None: + constraint = MinFieldsSetConstraint(count) + + constraint.validate_class(model_class) + + model_instance = factory(model_class) + + with pytest.raises( + ValueError, + match=r"only \d+ fields are explicitly set, but a minimum of \d+ are required", + ): + constraint.validate_instance(model_instance) + + +@pytest.mark.parametrize( + "count,model_class,factory", + [ + (1, create_model("case11", foo=(int | None, None)), lambda x: x(foo=None)), + (1, create_model("case12", foo=(int | None, None)), lambda x: x(foo=42)), + ( + 1, + create_model( + "case13", foo=(int | None, None), __config__=ConfigDict(extra="allow") + ), + lambda x: x(hello=None), + ), + ( + 1, + create_model( + "case14", foo=(int | None, None), __config__=ConfigDict(extra="allow") + ), + lambda x: x(hello="world"), + ), + ( + 1, + create_model( + "case15", foo=(int | None, None), __config__=ConfigDict(extra="allow") + ), + lambda x: x(foo=13, hello="world"), + ), + ( + 2, + create_model( + "case21", foo=(int | None, None), __config__=ConfigDict(extra="allow") + ), + lambda x: x(foo=None, hello="world"), + ), + ( + 2, + create_model( + "case22", foo=(int | None, None), __config__=ConfigDict(extra="allow") + ), + lambda x: x(foo=42, hello="world"), + ), + ( + 2, + create_model( + "case23", foo=(int | None, None), __config__=ConfigDict(extra="allow") + ), + lambda x: x(foo=None, hello=None), + ), + ( + 2, + create_model("case24", foo=(int | None, None), bar=(str | None, None)), + lambda x: x(foo=13, bar="baz"), + ), + ], +) +def test_valid_model_instance( + count: int, + model_class: type[BaseModel], + factory: Callable[[type[BaseModel]], BaseModel], +) -> None: + constraint = MinFieldsSetConstraint(count) + constraint.validate_class(model_class) + model_instance = factory(model_class) + constraint.validate_instance(model_instance) + + +def test_model_json_schema_no_model_config() -> None: + @min_fields_set(2) + class TestModel(BaseModel): + foo: bool | None = None + baz: bool | None = None + qux: bool | None = None + + actual = TestModel.model_json_schema() + expect = {"minProperties": 2} + assert expect == TestModel.model_config["json_schema_extra"] + assert_subset(expect, actual, "expect", "actual") + + +def test_model_json_schema_already_set_same() -> None: + expect = {"minProperties": 3, "hello": "world"} + + @min_fields_set(3) + class TestModel(BaseModel): + model_config = ConfigDict(json_schema_extra=expect, extra="allow") + + foo: bool | None = None + baz: bool | None = None + qux: bool | None = None + + actual = TestModel.model_json_schema() + assert expect == TestModel.model_config["json_schema_extra"] + assert_subset(expect, actual, "expect", "actual") + + +def test_model_json_schema_error_already_set_different() -> None: + expect = {"minProperties": 1, "hello": "world"} + + with pytest.raises( + RuntimeError, + match='JSON schema for model class `TestModel` has conflicting "minProperties" value 1', + ): + + @min_fields_set(3) + class TestModel(BaseModel): + model_config = ConfigDict(json_schema_extra=expect, extra="allow") + + foo: bool | None = None + baz: bool | None = None + qux: bool | None = None diff --git a/packages/overture-schema-system/tests/model_constraint/test_model_constraint.py b/packages/overture-schema-system/tests/model_constraint/test_model_constraint.py index e612b2dae..f60871b44 100644 --- a/packages/overture-schema-system/tests/model_constraint/test_model_constraint.py +++ b/packages/overture-schema-system/tests/model_constraint/test_model_constraint.py @@ -1,7 +1,356 @@ +import re +from typing import cast + import pytest -from pydantic import BaseModel, Field, create_model +from pydantic import BaseModel, ConfigDict, Field +from pydantic.json_schema import JsonDict +from typing_extensions import override + +from overture.schema.system import create_model +from overture.schema.system.metadata import Key, Metadata +from overture.schema.system.model_constraint import ( + Condition, + FieldEqCondition, + FieldGroupConstraint, + ModelConstraint, + Not, + OptionalFieldGroupConstraint, + apply_alias, +) +from overture.schema.system.model_constraint.model_constraint import ( + _MODEL_CONSTRAINT_KEY, +) + +################################################################################ +# ModelConstraint # +################################################################################ + + +class TestModelConstraint: + def test_init_error_invalid_name(self) -> None: + with pytest.raises( + TypeError, match="`name` must be a `str`, but 42 has type `int`" + ): + ModelConstraint(name=cast(str, 42)) + + def test_init_valid_name(self) -> None: + foo = ModelConstraint("foo") + + assert "foo" == foo.name + + def test_decorate_basic(self) -> None: + class TestModel(BaseModel): + pass + + constraint = ModelConstraint("foo") + + new_model = constraint.decorate(TestModel) + assert new_model is not TestModel + assert new_model.__name__ == TestModel.__name__ + assert new_model.__module__ == TestModel.__module__ + + assert new_model.model_config == ConfigDict() + + metadata = Metadata.retrieve_from(new_model) + assert metadata == {_MODEL_CONSTRAINT_KEY: (constraint,)} + + def test_decorate_prev_config(self) -> None: + prev_config = ConfigDict(json_schema_extra={"foo": "bar"}, extra="allow") + + class TestModel(BaseModel): + model_config = prev_config + + new_model = ModelConstraint("baz").decorate(TestModel) + assert new_model is not TestModel + assert new_model.__name__ == TestModel.__name__ + assert new_model.__module__ == TestModel.__module__ + assert new_model.model_config == prev_config + assert new_model.model_config is not prev_config + + def test_decorate_prev_metadata(self) -> None: + class TestModel(BaseModel): + pass + + constraint = ModelConstraint("foo") + + extra_key = Key("foo") + prev_metadata = Metadata({extra_key: "bar"}) + prev_metadata.attach_to(TestModel) + + new_model = constraint.decorate(TestModel) + assert new_model is not TestModel + assert new_model.__name__ == TestModel.__name__ + assert new_model.__module__ == TestModel.__module__ + + new_metadata = Metadata.retrieve_from(new_model) + assert {_MODEL_CONSTRAINT_KEY: (constraint,), extra_key: "bar"} == new_metadata + + @pytest.mark.parametrize( + "model_class,expect", + [ + (42, "`foo` can only be applied to classes"), + ( + int, + "`foo` target class must inherit from `pydantic.main.BaseModel`, but `int` does not", + ), + ], + ) + def test_decorate_error_invalid_model_class_type( + self, model_class: object, expect: str + ) -> None: + with pytest.raises(TypeError, match=expect): + ModelConstraint("foo").decorate(cast(type[BaseModel], model_class)) + + def test_decorate_error_invalid_model_class(self) -> None: + class TestModel(BaseModel): + pass + + class TestConstraint(ModelConstraint): + @override + def validate_class(self, model_class: type[BaseModel]) -> None: + raise TypeError("bar!") + + with pytest.raises(TypeError, match="bar!"): + TestConstraint().decorate(cast(type[BaseModel], TestModel)) + + +################################################################################ +# FieldGroupConstraint # +################################################################################ + + +class TestFieldGroupConstraint: + def test_init_error_field_names_not_tuple(self) -> None: + with pytest.raises( + TypeError, match="`field_names` must be a `tuple`, but 42 has type `int`" + ): + FieldGroupConstraint("foo", cast(tuple[str, ...], 42)) + + def test_init_error_field_empty(self) -> None: + with pytest.raises( + ValueError, match="`field_names` cannot be empty, but it is" + ): + FieldGroupConstraint("foo", ()) + + def test_init_error_field_names_not_all_str(self) -> None: + with pytest.raises( + TypeError, + match=re.escape( + "`field_names` must contain only `str` values, but ('bar', 42) contains at least one non-`str` value" + ), + ): + FieldGroupConstraint("foo", cast(tuple[str, ...], ("bar", 42))) + + def test_init_error_field_names_duplicated(self) -> None: + with pytest.raises( + ValueError, + match=re.escape( + "`field_names` must not contain duplicates, but ('bar', 'bar') contains at least one repeated value" + ), + ): + FieldGroupConstraint("foo", ("bar", "bar")) + + @pytest.mark.parametrize( + "field_names", + [ + ("bar",), + ("baz", "bar"), + ], + ) + def test_init_valid_field_names(self, field_names: tuple[str, ...]) -> None: + constraint = FieldGroupConstraint("foo", field_names) + + assert field_names == constraint.field_names + + @pytest.mark.parametrize( + "field_names", + [ + ("baz",), + ("foo", "baz"), + ("bar", "foo", "qux"), + ], + ) + def test_validate_class_error_field_name_not_in_model( + self, field_names: tuple[str, ...] + ) -> None: + class TestModel(BaseModel): + foo: int + bar: int -from overture.schema.system.model_constraint import apply_alias + constraint = FieldGroupConstraint("Hello", field_names) + with pytest.raises( + TypeError, + match="`Hello` specifies one or more fields that are not in the model class `TestModel`", + ): + constraint.decorate(TestModel) + + @pytest.mark.parametrize( + "field_names", + [ + ("foo",), + ("foo", "bar"), + ("bar",), + ("bar", "foo"), + ], + ) + def test_validate_class_success(self, field_names: tuple[str, ...]) -> None: + class TestModel(BaseModel): + foo: int + bar: int + + FieldGroupConstraint("Hello", field_names).decorate(TestModel) + + +################################################################################ +# OptionalFieldGroupConstraint # +################################################################################ + + +class TestOptionalFieldGroupConstraint: + @pytest.mark.parametrize( + "field_names", + [ + ("foo",), + ("foo", "bar"), + ("foo", "baz"), + ("foo", "bar", "baz"), + ], + ) + def test_validate_class_error_field_name_not_optional_in_model( + self, field_names: tuple[str, ...] + ) -> None: + class TestModel(BaseModel): + foo: int + bar: int | None = None + baz: str | None = None + + constraint = OptionalFieldGroupConstraint("Hello", field_names) + + with pytest.raises( + TypeError, + match="`Hello` expects all the fields to be optional, but at least one is required in the model class `TestModel`", + ): + constraint.decorate(TestModel) + + @pytest.mark.parametrize( + "field_names", + [ + ("foo",), + ("foo", "bar"), + ("bar",), + ("bar", "foo"), + ], + ) + def test_validate_class_success(self, field_names: tuple[str, ...]) -> None: + class TestModel(BaseModel): + foo: int | None = None + bar: str | None = None + + OptionalFieldGroupConstraint("Hello", field_names).decorate(TestModel) + + +################################################################################ +# Not # +################################################################################ + + +class TestNot: + @pytest.mark.parametrize( + "condition", + [ + (FieldEqCondition("foo", 42),), + (FieldEqCondition("foo", "bar"),), + ], + ) + def test_repr(self, condition: Condition) -> None: + not_condition = Not(condition) + + assert repr(not_condition) == "Not(" + repr(condition) + ")" + + @pytest.mark.parametrize( + "condition", + [ + (FieldEqCondition("foo", 42),), + (FieldEqCondition("foo", "bar"),), + ], + ) + def test_negate(self, condition: Condition) -> None: + not_condition = Not(condition) + + assert not_condition.negate() is condition + assert ~not_condition is condition + + +################################################################################ +# FieldEqCondition # +################################################################################ + + +class TestFieldEqCondition: + def test_init_error_field_name_not_str(self) -> None: + with pytest.raises( + TypeError, match="`field_name` must be a `str`, but 42 is a int" + ): + FieldEqCondition(cast(str, 42), "foo") + + def test_validate_class_error_field_name_not_in_model(self) -> None: + class TestModel(BaseModel): + foo: int + + condition = FieldEqCondition("bar", 42) + + with pytest.raises( + TypeError, + match="model class `TestModel` must contain the condition field 'bar', but it does not", + ): + condition.validate_class(TestModel) + + def test_validate_class_success(self) -> None: + class TestModel(BaseModel): + foo: int + + FieldEqCondition("foo", 42).validate_class(TestModel) + + @pytest.mark.parametrize( + "condition,expect", + [ + (FieldEqCondition("foo", 42), True), + (FieldEqCondition("foo", "bar"), False), + ], + ) + def test_eval(self, condition: FieldEqCondition, expect: bool) -> None: + class TestModel(BaseModel): + foo: int + + model_instance = TestModel(foo=42) + + assert condition.eval(model_instance) is expect + assert (~condition).eval(model_instance) is not expect + assert condition.negate().eval(model_instance) is not expect + + @pytest.mark.parametrize( + "condition,expect", + [ + (FieldEqCondition("foo", 42), {"properties": {"foo": {"const": 42}}}), + ( + Not(FieldEqCondition("bar", "qux")), + {"not": {"properties": {"baz": {"const": "qux"}}}}, + ), + ], + ) + def test_json_schema(self, condition: Condition, expect: JsonDict) -> None: + class TestModel(BaseModel): + foo: int + bar: str = Field(alias="baz") + + actual = condition.json_schema(TestModel) + + assert expect == actual + + +################################################################################ +# apply_alias # +################################################################################ @pytest.mark.parametrize( @@ -43,6 +392,3 @@ def test_apply_alias_error_no_such_field( ValueError, match=f"does not contain a field named '{field_name}'" ): apply_alias(model_class, field_name) - - -# TODO - vic - In the next round, add back multi-constraint test cases diff --git a/packages/overture-schema-system/tests/model_constraint/test_multi_constraint.py b/packages/overture-schema-system/tests/model_constraint/test_multi_constraint.py new file mode 100644 index 000000000..9686c493b --- /dev/null +++ b/packages/overture-schema-system/tests/model_constraint/test_multi_constraint.py @@ -0,0 +1,89 @@ +import sys +from pathlib import Path + +from pydantic import BaseModel + +from overture.schema.system.model_constraint import ( + FieldEqCondition, + ForbidIfConstraint, + MinFieldsSetConstraint, + ModelConstraint, + NoExtraFieldsConstraint, + RadioGroupConstraint, + RequireAnyOfConstraint, + RequireIfConstraint, + forbid_if, + min_fields_set, + no_extra_fields, + radio_group, + require_any_of, + require_if, +) + +sys.path.insert(0, str(Path(__file__).parent)) # Needed to import `util` peer module. + +from util import assert_subset + + +def test_many_constraints(): + @forbid_if(["corge", "garply"], FieldEqCondition("qux", "hello")) + @min_fields_set(3) + @no_extra_fields + @radio_group("foo", "bar", "baz") + @require_any_of("foo", "corge") + @require_if(["qux", "baz"], FieldEqCondition("corge", 42)) + class TestModel(BaseModel): + foo: bool | None = None + bar: bool | None = None + baz: bool | None = None + qux: str | None = None + corge: int | None = None + garply: float | None = None + + # Verify that all the constraints are annotated. + constraints = ModelConstraint.get_model_constraints(TestModel) + expect_types = [ + ForbidIfConstraint, + MinFieldsSetConstraint, + NoExtraFieldsConstraint, + RadioGroupConstraint, + RequireAnyOfConstraint, + RequireIfConstraint, + ] + assert len(expect_types) == len(constraints) + for i, t in enumerate(reversed(expect_types)): + assert isinstance(constraints[i], t) + + # Verify the JSON Schema. + expect_json_schema = { + "additionalProperties": False, + "allOf": [ + { + "if": {"properties": {"corge": {"const": 42}}}, + "then": {"required": ["qux", "baz"]}, + }, + { + "if": {"properties": {"qux": {"const": "hello"}}}, + "then": {"not": {"required": ["corge", "garply"]}}, + }, + ], + "anyOf": [{"required": ["foo"]}, {"required": ["corge"]}], + "minProperties": 3, + "oneOf": [ + {"properties": {"foo": {"const": True}}}, + {"properties": {"bar": {"const": True}}}, + {"properties": {"baz": {"const": True}}}, + ], + } + + actual_json_schema = TestModel.model_json_schema() + + assert_subset( + expect_json_schema, + actual_json_schema, + "expect_json_schema", + "actual_json_schema", + ) + + # Verify a valid instance. + TestModel(foo=None, bar=True, baz=False, qux="world", corge=42, garply=0.25) diff --git a/packages/overture-schema-system/tests/model_constraint/test_radio_group.py b/packages/overture-schema-system/tests/model_constraint/test_radio_group.py new file mode 100644 index 000000000..456a54492 --- /dev/null +++ b/packages/overture-schema-system/tests/model_constraint/test_radio_group.py @@ -0,0 +1,195 @@ +import sys +from pathlib import Path +from typing import Annotated, Union + +import pytest +from pydantic import BaseModel, ConfigDict, Field +from pydantic.json_schema import JsonDict + +from overture.schema.system import create_model +from overture.schema.system.model_constraint import ( + ModelConstraint, + RadioGroupConstraint, + radio_group, +) + +sys.path.insert(0, str(Path(__file__).parent)) # Needed to import `util` peer module. + +from util import assert_subset + + +@pytest.mark.parametrize("field_names", [[], (), ["foo"], ("bar",)]) +def test_error_not_enough_field_names(field_names: list[str]): + with pytest.raises( + ValueError, match="`field_names` must contain at least two items" + ): + radio_group(*field_names) + + +@pytest.mark.parametrize( + "constraint,model_class", + [ + ( + RadioGroupConstraint("foo", "bar"), + create_model("case1", foo=(bool, ...), bar=(int, ...)), + ), + ( + RadioGroupConstraint("bar", "baz"), + create_model( + "case2", + bar=(bool | None, ...), + baz=(Union[bool, int, None], ...), # noqa: UP007 + ), + ), + ( + RadioGroupConstraint("foo", "bar", "baz"), + create_model( + "case3", foo=(bool | None, ...), bar=(Annotated[bool, "qux"], ...) + ), + ), + ], +) +def test_error_invalid_model_class( + constraint: RadioGroupConstraint, model_class: type[BaseModel] +) -> None: + with pytest.raises(TypeError): + constraint.validate_class(model_class) + + with pytest.raises(TypeError): + constraint.decorate(model_class) + + +@pytest.mark.parametrize( + "constraint", + [ + RadioGroupConstraint("foo", "bar"), + RadioGroupConstraint("foo", "baz"), + RadioGroupConstraint("bar", "baz"), + RadioGroupConstraint("foo", "bar", "baz"), + RadioGroupConstraint("qux", "corge"), + ], +) +def test_error_invalid_model_instance(constraint: RadioGroupConstraint) -> None: + class TestModel(BaseModel): + foo: bool = False + bar: bool | None = None + baz: Annotated[Annotated[bool, "inner"] | None, "outer"] + qux: bool = True + corge: bool = True + + model_instance = TestModel(baz="False") + + constraint.validate_class(TestModel) + + with pytest.raises( + ValueError, + match=r"exactly one field from the `bool` field group \[[\w, ]+\] must be True, but", + ): + constraint.validate_instance(model_instance) + + +@pytest.mark.parametrize( + "field_names", + [ + ["foo", "bar"], + ["foo", "baz"], + ["foo", "bar", "baz"], + ["bar", "qux"], + ["baz", "qux"], + ["bar", "baz", "qux"], + ], +) +def test_valid_model_instance(field_names: list[str]) -> None: + class TestModel(BaseModel): + foo: bool + bar: bool + baz: bool | None + qux: bool | None + + constraint = RadioGroupConstraint(*field_names) + constraint.validate_instance(TestModel(foo=True, bar=False, baz=None, qux=True)) + + +def test_model_json_schema_no_model_config() -> None: + @radio_group("foo", "baz", "qux") + class TestModel(BaseModel): + foo: bool = Field(default=None, alias="bar") + baz: bool + qux: bool = Field(alias="corge") + + actual = TestModel.model_json_schema() + expect = { + "oneOf": [ + {"properties": {"bar": {"const": True}}}, + {"properties": {"baz": {"const": True}}}, + {"properties": {"corge": {"const": True}}}, + ] + } + assert expect == TestModel.model_config["json_schema_extra"] + assert_subset(expect, actual, "expect", "actual") + + +@pytest.mark.parametrize( + "base_json_schema,expect", + [ + ( + None, + { + "oneOf": [ + {"properties": {"foo": {"const": True}}}, + {"properties": {"baz": {"const": True}}}, + ] + }, + ), + ( + {"random": "value"}, + { + "random": "value", + "oneOf": [ + {"properties": {"foo": {"const": True}}}, + {"properties": {"baz": {"const": True}}}, + ], + }, + ), + ( + {"oneOf": 123}, + { + "allOf": [ + {"oneOf": 123}, + { + "oneOf": [ + {"properties": {"foo": {"const": True}}}, + {"properties": {"baz": {"const": True}}}, + ] + }, + ] + }, + ), + ], +) +def test_model_json_schema_with_model_config( + base_json_schema: JsonDict | None, expect: JsonDict +) -> None: + @radio_group("foo", "bar") + class TestModel(BaseModel): + model_config = ConfigDict(json_schema_extra=base_json_schema) + + foo: bool | None = None + bar: bool | None = Field(default=True, alias="baz") + + actual = TestModel.model_json_schema() + assert_subset(expect, actual, "expect", "actual") + + +def test_model_constraints() -> None: + constraint = RadioGroupConstraint("foo", "bar") + + class TestModel(BaseModel): + foo: bool + bar: bool + + assert 0 == len(ModelConstraint.get_model_constraints(TestModel)) + + new_model_class = constraint.decorate(TestModel) + + assert (constraint,) == ModelConstraint.get_model_constraints(new_model_class) diff --git a/packages/overture-schema-system/tests/model_constraint/test_require_any_of.py b/packages/overture-schema-system/tests/model_constraint/test_require_any_of.py index 21995bd13..230dd29d0 100644 --- a/packages/overture-schema-system/tests/model_constraint/test_require_any_of.py +++ b/packages/overture-schema-system/tests/model_constraint/test_require_any_of.py @@ -17,21 +17,21 @@ @pytest.mark.parametrize("field_names", [[], ["foo"]]) -def test_error_not_enough_field_names(field_names: list[str]): +def test_error_not_enough_field_names(field_names: list[str]) -> None: with pytest.raises( - ValueError, match="field_names` must contain at least two items" + ValueError, match="`field_names` must contain at least two items" ): require_any_of(*field_names) @pytest.mark.parametrize("field_names", [["foo", "foo"], ["bar", "foo", "bar"]]) -def test_error_duplicate_field_names(field_names: list[str]): +def test_error_duplicate_field_names(field_names: list[str]) -> None: with pytest.raises(ValueError, match="`field_names` must not contain duplicates"): require_any_of(*field_names) -def test_error_invalid_model_class(): - expect = "specifies fields that are not in the model class `TestModel`: foo, bar" +def test_error_invalid_model_class() -> None: + expect = "specifies one or more fields that are not in the model class `TestModel`: foo, bar" with pytest.raises(TypeError, match=expect): @@ -47,7 +47,7 @@ class TestModel(BaseModel): RequireAnyOfConstraint("foo", "bar").validate_class(TestModel) -def test_error_invalid_model_instance(): +def test_error_invalid_model_instance() -> None: @require_any_of("foo", "bar") class TestModel(BaseModel): foo: int | None = None @@ -61,7 +61,7 @@ class TestModel(BaseModel): @pytest.mark.parametrize("foo,bar", [(42, "hello"), (42, None), (None, "hello")]) -def test_valid_model_instance(foo: int | None, bar: str | None): +def test_valid_model_instance(foo: int | None, bar: str | None) -> None: @require_any_of("foo", "bar") class TestModel(BaseModel): foo: int | None = None @@ -70,7 +70,7 @@ class TestModel(BaseModel): TestModel(foo=foo, bar=bar) -def test_model_json_schema_no_model_config(): +def test_model_json_schema_no_model_config() -> None: @require_any_of("foo", "bar") class TestModel(BaseModel): foo: int | None = None @@ -99,7 +99,7 @@ class TestModel(BaseModel): ) def test_model_json_schema_with_model_config( base_json_schema: JsonDict | None, expect: JsonDict -): +) -> None: @require_any_of("foo", "bar") class TestModel(BaseModel): model_config = ConfigDict(json_schema_extra=base_json_schema) @@ -111,7 +111,7 @@ class TestModel(BaseModel): assert_subset(expect, actual, "expect", "actual") -def test_model_constraints(): +def test_model_constraints() -> None: constraint = RequireAnyOfConstraint("foo", "bar") class TestModel(BaseModel): diff --git a/packages/overture-schema-system/tests/model_constraint/test_require_if.py b/packages/overture-schema-system/tests/model_constraint/test_require_if.py new file mode 100644 index 000000000..e9b4584e2 --- /dev/null +++ b/packages/overture-schema-system/tests/model_constraint/test_require_if.py @@ -0,0 +1,199 @@ +import sys +from pathlib import Path +from typing import cast + +import pytest +from pydantic import BaseModel, ConfigDict, Field +from pydantic.json_schema import JsonDict + +from overture.schema.system import create_model +from overture.schema.system.model_constraint import ( + Condition, + FieldEqCondition, + ModelConstraint, + RequireIfConstraint, + require_if, +) + +sys.path.insert(0, str(Path(__file__).parent)) # Needed to import `util` peer module. + +from util import assert_subset + + +@pytest.mark.parametrize("field_names", [[], ()]) +def test_error_not_enough_field_names(field_names: list[str]): + with pytest.raises(ValueError, match="`field_names` cannot be empty, but it is"): + require_if(field_names, FieldEqCondition("foo", 42)) + + +def test_error_invalid_condition() -> None: + with pytest.raises( + TypeError, match="`condition` must be a `Condition`, but 42 has type `int`" + ): + RequireIfConstraint(["foo"], cast(Condition, 42)) + + +@pytest.mark.parametrize( + "constraint,model_class", + [ + ( + RequireIfConstraint(["foo"], FieldEqCondition("bar", 42)), + create_model("case1", foo=(int, ...), bar=(int, ...)), + ), + ( + RequireIfConstraint(["bar"], FieldEqCondition("foo", 42)), + create_model("case2", foo=(int | None, None)), + ), + ( + RequireIfConstraint(["bar"], FieldEqCondition("foo", 42)), + create_model("case3", bar=(int | None, None)), + ), + ], +) +def test_error_invalid_model_class( + constraint: RequireIfConstraint, model_class: type[BaseModel] +) -> None: + with pytest.raises(TypeError): + constraint.validate_class(model_class) + + with pytest.raises(TypeError): + constraint.decorate(model_class) + + +@pytest.mark.parametrize( + "constraint", + [ + (RequireIfConstraint(["foo"], FieldEqCondition("qux", 42))), + (RequireIfConstraint(["bar"], FieldEqCondition("qux", 42))), + (RequireIfConstraint(["foo", "bar"], FieldEqCondition("qux", 42))), + (RequireIfConstraint(["foo", "baz"], FieldEqCondition("qux", 42))), + (RequireIfConstraint(["bar", "baz"], FieldEqCondition("qux", 42))), + (RequireIfConstraint(["foo", "bar", "baz"], FieldEqCondition("qux", 42))), + ], +) +def test_error_invalid_model_instance(constraint: RequireIfConstraint) -> None: + class TestModel(BaseModel): + foo: int | None = None + bar: int | None = None + baz: int | None = None + qux: int + + model_instance = TestModel(baz=41, qux=42) + + constraint.validate_class(TestModel) + with pytest.raises( + ValueError, + match="at least one field is missing an explicit value when it should have one:", + ): + constraint.validate_instance(model_instance) + + +def test_create_success() -> None: + condition = FieldEqCondition("foo", 42) + constraint = RequireIfConstraint(["bar"], condition) + assert constraint.field_names == ("bar",) + assert constraint.condition is condition + + not_condition = ~FieldEqCondition("foo", 42) + not_constraint = RequireIfConstraint(("baz", "qux"), not_condition) + assert not_constraint.field_names == ("baz", "qux") + assert not_constraint.condition is not_condition + + +@pytest.mark.parametrize("field_names", [["foo"], ["bar"], ["foo", "bar"]]) +def test_valid_model_instance_condition_true(field_names: list[str]) -> None: + class TestModel(BaseModel): + foo: int | None = None + bar: int | None = None + baz: int + + constraint = RequireIfConstraint(field_names, FieldEqCondition("baz", 42)) + constraint.validate_instance(TestModel(foo=40, bar=41, baz=42)) + + +@pytest.mark.parametrize("field_names", [["foo"], ["bar"], ["foo", "bar"]]) +def test_valid_model_instance_condition_false(field_names) -> None: + class TestModel(BaseModel): + foo: int | None = None + bar: int | None = None + baz: int + + constraint = RequireIfConstraint(field_names, ~FieldEqCondition("baz", 42)) + constraint.validate_instance(TestModel(bar=41, baz=42)) + + +def test_model_json_schema_no_model_config() -> None: + @require_if(["foo", "baz"], FieldEqCondition("qux", 42)) + class TestModel(BaseModel): + foo: int | None = Field(default=None, alias="bar") + baz: str | None = None + qux: int = Field(alias="corge") + + actual = TestModel.model_json_schema() + expect = { + "if": {"properties": {"corge": {"const": 42}}}, + "then": {"required": ["bar", "baz"]}, + } + assert expect == TestModel.model_config["json_schema_extra"] + assert_subset(expect, actual, "expect", "actual") + + +@pytest.mark.parametrize( + "base_json_schema,expect", + [ + ( + None, + { + "if": {"not": {"properties": {"qux": {"const": 42}}}}, + "then": {"required": ["foo", "baz"]}, + }, + ), + ( + {"random": "value"}, + { + "random": "value", + "if": {"not": {"properties": {"qux": {"const": 42}}}}, + "then": {"required": ["foo", "baz"]}, + }, + ), + ( + {"if": 123}, + { + "allOf": [ + {"if": 123}, + { + "if": {"not": {"properties": {"qux": {"const": 42}}}}, + "then": {"required": ["foo", "baz"]}, + }, + ] + }, + ), + ], +) +def test_model_json_schema_with_model_config( + base_json_schema: JsonDict | None, expect: JsonDict +) -> None: + @require_if(["foo", "bar"], ~FieldEqCondition("qux", 42)) + class TestModel(BaseModel): + model_config = ConfigDict(json_schema_extra=base_json_schema) + + foo: int | None = None + bar: str | None = Field(default=None, alias="baz") + qux: int + + actual = TestModel.model_json_schema() + assert_subset(expect, actual, "expect", "actual") + + +def test_model_constraints() -> None: + constraint = RequireIfConstraint(["foo"], FieldEqCondition("bar", "baz")) + + class TestModel(BaseModel): + foo: int | None = None + bar: str + + assert 0 == len(ModelConstraint.get_model_constraints(TestModel)) + + new_model_class = constraint.decorate(TestModel) + + assert (constraint,) == ModelConstraint.get_model_constraints(new_model_class) From 67ab3ffab2067e351a23af9ba3b5f15e80bcfc23 Mon Sep 17 00:00:00 2001 From: schapper Date: Tue, 14 Oct 2025 09:50:12 -0700 Subject: [PATCH 08/19] wip [PASSING] - fix weird doctest regressions --- .../overture/schema/system/create_model.py | 8 +-- .../src/overture/schema/system/metadata.py | 56 ++++++++++--------- .../model_constraint/model_constraint.py | 4 +- .../system/model_constraint/radio_group.py | 2 +- 4 files changed, 38 insertions(+), 32 deletions(-) diff --git a/packages/overture-schema-system/src/overture/schema/system/create_model.py b/packages/overture-schema-system/src/overture/schema/system/create_model.py index 34a5d3182..c7022ea54 100644 --- a/packages/overture-schema-system/src/overture/schema/system/create_model.py +++ b/packages/overture-schema-system/src/overture/schema/system/create_model.py @@ -36,12 +36,12 @@ def create_model( the new model. If a non-`None` value is provided for `__metadata__`, the new model receives the new metadata and the metadata on the base model is not propagated. """ - model_class = pydantic.create_model( + model_class = pydantic.create_model( # type: ignore[misc] model_name, __config__=__config__, __doc__=__doc__, - __base__=__base__, - __module__=__module__, + __base__=__base__, # type: ignore[arg-type] + __module__=__module__, # type: ignore[arg-type] __validators__=__validators__, __cls_kwargs__=__cls_kwargs__, __qualname__=__qualname__, @@ -53,4 +53,4 @@ def create_model( prev = Metadata.retrieve_from(__base__, None) if prev is not None: prev.attach_to(model_class) - return model_class + return model_class # type: ignore[return-value] diff --git a/packages/overture-schema-system/src/overture/schema/system/metadata.py b/packages/overture-schema-system/src/overture/schema/system/metadata.py index 6d0d3d57e..6c2abd75d 100644 --- a/packages/overture-schema-system/src/overture/schema/system/metadata.py +++ b/packages/overture-schema-system/src/overture/schema/system/metadata.py @@ -2,6 +2,7 @@ Hashable, ItemsView, Iterable, + Iterator, KeysView, Mapping, MutableMapping, @@ -10,6 +11,7 @@ from typing import ( Union, cast, + overload, ) from typing_extensions import override @@ -44,12 +46,15 @@ class that owns your metadata, *e.g.* `mypkg.mymodule` or `mypkg.mymodule.MyClas ------- >>> class _private: pass # Private data >>> - >>> Key("mypkg.mymodule", _private) - Key(name="mypkg.mymodule", ...) + >>> Key('mypkg.mymodule', _private) + Key('mypkg.mymodule', ...) """ __slots__ = ("name", "_Key__private") + name: str + __private: Hashable | None + def __init__(self, name: str, private: Hashable | None = None) -> None: object.__setattr__(self, "name", name) object.__setattr__(self, "_Key__private", private) @@ -66,12 +71,12 @@ def __str__(self) -> str: return self.name def __repr__(self) -> str: - return f"Key(name={repr(self.name)}, ...)" + return f"Key({repr(self.name)}, ...)" - def __setattr__(self, name, value): + def __setattr__(self, name: str, value: object) -> None: raise AttributeError("cannot modify a `Key`") - def __delattr__(self, name): + def __delattr__(self, name: str) -> None: raise AttributeError("cannot modify a `Key`") @@ -91,7 +96,7 @@ class Metadata(MutableMapping[Key, object]): Parameters ---------- - data : Mapping[Key, object] | Iterable[tuple[Key, object] | list[Key, object]] | None = None + data : Mapping[Key, object] | Iterable[tuple[Key, object]] | None = None Source data to build the `Metadata` dictionary Examples @@ -121,11 +126,9 @@ class Metadata(MutableMapping[Key, object]): def __init__( self, - data: Mapping[Key, object] - | Iterable[tuple[Key, object] | list[Key, object]] - | None = None, + data: Mapping[Key, object] | Iterable[tuple[Key, object]] | None = None, ): - if data is None: + if not data: self.__wrapped = {} else: self.__wrapped = dict(Metadata.__validate_data(data)) @@ -153,10 +156,10 @@ def __len__(self) -> int: def __getitem__(self, key: Key) -> object: return self.__wrapped[key] - def __iter__(self) -> Iterable[Key]: + def __iter__(self) -> Iterator[Key]: return iter(self.__wrapped) - def __contains__(self, key: Key) -> bool: + def __contains__(self, key: object) -> bool: return key in self.__wrapped def __delitem__(self, key: Key) -> None: @@ -190,7 +193,7 @@ def __ror__( return result @override - def get(self, key: Key, default=None) -> object: + def get(self, key: Key, default: object = None) -> object: return self.__wrapped.get(key, default) @override @@ -211,11 +214,15 @@ def copy(self) -> "Metadata": """ return Metadata(self.__wrapped.copy()) + @overload # type: ignore[override] + def update(self, other: Mapping[Key, object], /) -> None: ... + + @overload + def update(self, other: Iterable[tuple[Key, object]], /) -> None: ... + def update( self, - data: Mapping[Key, object] - | Iterable[tuple[Key, object] | list[Key, object]] - | None = None, + data: Mapping[Key, object] | Iterable[tuple[Key, object]] | None = None, ) -> None: """ Updates this metadata by inserting values from `data`. In the case of a key conflict, the @@ -223,10 +230,10 @@ def update( Parameters ---------- - data : Mapping[Key, object] | Iterable[tuple[Key, object] | list[Key, object]] | None = None + data : Mapping[Key, object] | Iterable[tuple[Key, object]] | None = None New data to insert (ignored if the value is `None`) """ - if data is not None: + if data: self.__wrapped.update(Metadata.__validate_data(data)) def attach_to(self, target: object) -> None: @@ -250,9 +257,7 @@ def attach_to(self, target: object) -> None: @staticmethod def retrieve_from( source: object, - default: Mapping[Key, object] - | Iterable[tuple[Key, object] | list[Key, object]] - | None = None, + default: Mapping[Key, object] | Iterable[tuple[Key, object]] | None = None, ) -> Union["Metadata", None]: """ Retrieves the metadata attached go a given source value, if it exists. Metadata can be @@ -262,7 +267,7 @@ def retrieve_from( ---------- source : object Value to retrieve the metadata from - default : default: Mapping[Key, object] | Iterable[tuple[Key, object] | list[Key, object]] | None = None + default : default: Mapping[Key, object] | Iterable[tuple[Key, object]] | None = None Default value to return if `source` has no attached metadata Returns @@ -284,8 +289,8 @@ def retrieve_from( @staticmethod def __validate_data( - data: Mapping[Key, object] | Iterable[tuple[Key, object] | list[Key, object]], - ) -> Mapping[Key, object] | Iterable[tuple[Key, object] | list[Key, object]]: + data: Mapping[Key, object] | Iterable[tuple[Key, object]], + ) -> Mapping[Key, object] | Iterable[tuple[Key, object]]: if isinstance(data, Mapping): for k, _ in data.items(): if not isinstance(k, Key): @@ -296,7 +301,7 @@ def __validate_data( for i, item in enumerate(data): if not isinstance(item, (tuple, list)): raise TypeError( - f"items must be pairs (`tuple` or `list`), but item index {i} has type `{type(item).__name}`" + f"items must be pairs (`tuple` or `list`), but item index {i} has type `{type(item).__name__}`" ) elif len(item) != 2: raise ValueError( @@ -306,6 +311,7 @@ def __validate_data( raise TypeError( f"each item's first element must be a `Key`, but first element for item index {i} has type `{type(item[0]).__name__}`" ) + return cast(Iterable[tuple[Key, object]], data) else: raise TypeError( f"`data` must be a `Mapping` or `Iterable`, but {data} has type `{type(data).__name__}`" diff --git a/packages/overture-schema-system/src/overture/schema/system/model_constraint/model_constraint.py b/packages/overture-schema-system/src/overture/schema/system/model_constraint/model_constraint.py index 5bd0266d4..64def571a 100644 --- a/packages/overture-schema-system/src/overture/schema/system/model_constraint/model_constraint.py +++ b/packages/overture-schema-system/src/overture/schema/system/model_constraint/model_constraint.py @@ -113,7 +113,7 @@ def decorate(self, model_class: type[BaseModel]) -> type[BaseModel]: self.validate_class(model_class) config = deepcopy(model_class.model_config) self.edit_config(model_class, config) - metadata = Metadata.retrieve_from(model_class, Metadata()).copy() + metadata = Metadata.retrieve_from(model_class, Metadata()).copy() # type: ignore[union-attr] model_constraints = (*ModelConstraint.get_model_constraints(model_class), self) metadata[_MODEL_CONSTRAINT_KEY] = model_constraints new_model_class = create_model( @@ -219,7 +219,7 @@ def get_model_constraints( """ return cast( tuple[ModelConstraint, ...], - Metadata.retrieve_from(model_class, Metadata()).get( + Metadata.retrieve_from(model_class, Metadata()).get( # type: ignore[union-attr] _MODEL_CONSTRAINT_KEY, () ), ) diff --git a/packages/overture-schema-system/src/overture/schema/system/model_constraint/radio_group.py b/packages/overture-schema-system/src/overture/schema/system/model_constraint/radio_group.py index d3a0c9f23..570800e0b 100644 --- a/packages/overture-schema-system/src/overture/schema/system/model_constraint/radio_group.py +++ b/packages/overture-schema-system/src/overture/schema/system/model_constraint/radio_group.py @@ -54,7 +54,7 @@ def radio_group(*field_names: str) -> Callable[[type[BaseModel]], type[BaseModel ... except ValidationError as e: ... assert ( ... "exactly one field from the `bool` field group [foo, bar] must be True, " - ... "but both of these fields are True: foo and bar" + ... "but none is True" ... ) in str(e) ... print("Validation failed") Validation failed From babec5842f31dd9c4d798ab4954cc539006faa72 Mon Sep 17 00:00:00 2001 From: schapper Date: Tue, 14 Oct 2025 09:55:45 -0700 Subject: [PATCH 09/19] wip - Delete unused core primitives module --- .../src/overture/schema/core/primitives/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 packages/overture-schema-core/src/overture/schema/core/primitives/__init__.py diff --git a/packages/overture-schema-core/src/overture/schema/core/primitives/__init__.py b/packages/overture-schema-core/src/overture/schema/core/primitives/__init__.py deleted file mode 100644 index e69de29bb..000000000 From dea80b4465919b5982544028bce137fc24f55d9c Mon Sep 17 00:00:00 2001 From: schapper Date: Tue, 14 Oct 2025 20:41:00 -0700 Subject: [PATCH 10/19] wip: [MESS] middle of Feature factoring --- .../src/overture/schema/system/__init__.py | 14 +- .../json_schema.py => _json_schema.py} | 0 .../src/overture/schema/system/feature.py | 211 +++++++++ .../system/model_constraint/forbid_if.py | 2 +- .../system/model_constraint/min_fields_set.py | 2 +- .../system/model_constraint/radio_group.py | 2 +- .../system/model_constraint/require_any_of.py | 2 +- .../system/model_constraint/require_if.py | 2 +- .../src/overture/schema/system/optionality.py | 92 ++++ .../overture/schema/system/ref/__init__.py | 3 + .../src/overture/schema/system/ref/id.py | 17 + .../tests/model_constraint/test_forbid_if.py | 2 +- .../model_constraint/test_min_fields_set.py | 2 +- .../model_constraint/test_multi_constraint.py | 2 +- .../model_constraint/test_no_extra_fields.py | 2 +- .../model_constraint/test_radio_group.py | 2 +- .../model_constraint/test_require_any_of.py | 2 +- .../tests/model_constraint/test_require_if.py | 2 +- ...t_json_schema.py => test___json_schema.py} | 2 +- .../tests/test_feature.py | 411 ++++++++++++++++++ .../tests/test_optionality.py | 107 +++++ .../tests/{model_constraint => }/util.py | 0 22 files changed, 867 insertions(+), 14 deletions(-) rename packages/overture-schema-system/src/overture/schema/system/{model_constraint/json_schema.py => _json_schema.py} (100%) create mode 100644 packages/overture-schema-system/src/overture/schema/system/feature.py create mode 100644 packages/overture-schema-system/src/overture/schema/system/optionality.py create mode 100644 packages/overture-schema-system/src/overture/schema/system/ref/__init__.py create mode 100644 packages/overture-schema-system/src/overture/schema/system/ref/id.py rename packages/overture-schema-system/tests/{model_constraint/test_model_constraint_json_schema.py => test___json_schema.py} (99%) create mode 100644 packages/overture-schema-system/tests/test_feature.py create mode 100644 packages/overture-schema-system/tests/test_optionality.py rename packages/overture-schema-system/tests/{model_constraint => }/util.py (100%) diff --git a/packages/overture-schema-system/src/overture/schema/system/__init__.py b/packages/overture-schema-system/src/overture/schema/system/__init__.py index d0fb3394d..f4a18ed28 100644 --- a/packages/overture-schema-system/src/overture/schema/system/__init__.py +++ b/packages/overture-schema-system/src/overture/schema/system/__init__.py @@ -121,14 +121,26 @@ Validation failed """ -from . import field_constraint, metadata, model_constraint, primitive, string +from . import ( + feature, + field_constraint, + metadata, + model_constraint, + optionality, + primitive, + ref, + string, +) from .create_model import create_model __all__ = [ "create_model", + "feature", "field_constraint", "metadata", "model_constraint", + "optionality", "primitive", + "ref", "string", ] diff --git a/packages/overture-schema-system/src/overture/schema/system/model_constraint/json_schema.py b/packages/overture-schema-system/src/overture/schema/system/_json_schema.py similarity index 100% rename from packages/overture-schema-system/src/overture/schema/system/model_constraint/json_schema.py rename to packages/overture-schema-system/src/overture/schema/system/_json_schema.py diff --git a/packages/overture-schema-system/src/overture/schema/system/feature.py b/packages/overture-schema-system/src/overture/schema/system/feature.py new file mode 100644 index 000000000..ac0f176b8 --- /dev/null +++ b/packages/overture-schema-system/src/overture/schema/system/feature.py @@ -0,0 +1,211 @@ +from typing import Any, Literal + +from pydantic import ( + BaseModel, + GetJsonSchemaHandler, + ModelWrapValidatorHandler, + SerializerFunctionWrapHandler, + ValidationError, + ValidationInfo, + model_serializer, + model_validator, +) +from pydantic.json_schema import JsonSchemaValue +from pydantic_core import InitErrorDetails, core_schema +from typing_extensions import Self + +from overture.schema.system._json_schema import put_not +from overture.schema.system.optionality import Omitable +from overture.schema.system.primitive import BBox, Geometry +from overture.schema.system.ref import Id + + +class Feature(BaseModel): + type: Literal["Feature"] + id: Omitable[Id] + bbox: Omitable[BBox] + geometry: Geometry + + @model_serializer(mode="wrap") + def serialize_model( + self, serializer: SerializerFunctionWrapHandler, info: ValidationInfo + ) -> dict[str, object]: + """ + Serializes to GeoJSON when the mode is JSON, otherwise to Pydantic's standard Python mode. + """ + data = serializer(self) + + if info.mode == "json": + return { + "type": "Feature", + **({"id": data.pop("id")} if "id" in data else {}), + **({"bbox": data.pop("bbox")} if "bbox" in data else {}), + "geometry": data.pop("geometry"), + "properties": data, + } + + return data + + @model_validator(mode="wrap") + @classmethod + def validate_model( + cls, data: Any, handler: ModelWrapValidatorHandler[Self], info: ValidationInfo + ) -> Self: + """ + Validates the model as GeoJSON when the mode is JSON, otherwise applies Pydantic's standard + validation. + """ + if not isinstance(data, dict): + raise TypeError( + f"feature data must be a `dict`, but {repr(data)} is a `{type(data).__name__}`" + ) + + if info.mode == "json": + def validation_error(type: str, input: object, error: str, *loc: str) -> ValidationError: + context = info.context or {} + loc = context.get("loc_prefix", ()) + loc + return ValidationError.from_exception_data( + title=cls.__name__, + line_errors=[ + InitErrorDetails( + type=type, + loc=loc, + input=input, + ctx={"error": error}, + ) + ] + ) + + def type_property_error(input: object, problem: str) -> ValidationError: + return validation_error('geo_json_type', input, f"{problem} (it should have value 'Feature')", "type") + + def properties_property_error(input: object, problem: str) -> ValidationError: + + + # GeoJSON features require `type=Feature` at the top level. + try: + t = data.pop("type") + except KeyError: + raise type_property_error(None, "'type' property is missing") from None + if t != "Feature": + raise type_property_error(t, f"'type' property has a wrong value, {repr(t)}") + + # Remove the properties sub-dictionary so we can flatten it. + try: + properties = data.pop("properties") + except KeyError: + properties = None + if not isinstance(properties, dict | None): + raise TypeError( + f"'properties' key in feature JSON must be a `dict`, but {repr(properties)} is a `{type(properties).__name__}" + ) + + # Ensure there's nothing in data root level that repeats a valid model field. + conflicts = [ + f + for f in data.keys() + if f not in {"id", "bbox", "geometry", "properties"} + ] + if conflicts: + raise ValueError( + "illegal root-level properties in feature JSON: these properties may only be children of the 'properties' object: {repr(conflicts)}" + ) + + if properties: + # Check for field conflicts within the 'properties' sub-dictionary. + conflicts = [ + f for f in properties.keys() if f in {"id", "bbox", "geometry"} + ] + if conflicts: + raise ValueError( + "illegal properties in feature JSON: these properties may only appear in the root, but they are in the 'properties' object: {repr(conficts)}" + ) + + # Spread the 'properties' sub-dictionary across the root-level data object. + data |= properties + + return handler(data) + + @classmethod + def __get_pydantic_json_schema__( + cls, schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler + ) -> JsonSchemaValue: + """ + Generates a JSON Schema that validates the feature as GeoJSON. + """ + json_schema = super().__get_pydantic_json_schema__(core_schema, handler) + + # Move all non-GeoJSON properties down into the GeoJSON 'properties' object. + try: + json_schema_top_level_required = json_schema["required"] + except KeyError: + json_schema_top_level_required = [] + json_schema["required"] = json_schema_top_level_required + json_schema_top_level_properties = json_schema["properties"] + geo_json_properties = {} + geo_json_required = [] + + for name in json_schema_top_level_properties.keys(): + if name not in ["id", "bbox", "geometry"]: + value = json_schema_top_level_properties[name] + geo_json_properties[name] = value + del json_schema_top_level_properties[name] + if name in json_schema_top_level_required: + json_schema_top_level_required.remove(name) + geo_json_required.append(name) + + # Create the sub-schema for the GeoJSON 'properties' sub-object. + geo_json_properties_schema = { + "type": "object", + "properties": geo_json_properties, + **({"required": geo_json_required} if geo_json_required else {}), + } + + # Preserve the relevant constraints from the original schema by migrating them into the + # 'properties' sub-schema. + for key in [ + "anyOf", + "allOf", + "oneOf", + "not", + "additionalProperteis", + "unevaluatedProperties", + "patternProperties", + ]: + try: + geo_json_properties_schema[key] = json_schema.pop(key) + except KeyError: + pass + + # Prohibit the core top-level properties from being replicated in the 'properties' + # sub-object. + put_not(geo_json_properties_schema, {"required": ["id", "bbox", "geometry"]}) + + # Insert the sub-schema for the 'properties' sub-object. If 'properties' has no required + # members then we allow it to be `null` in conformance with the GeoJSON specification. + # Otherwise, it must be an object. + if geo_json_required: + json_schema_top_level_properties["properties"] = geo_json_properties_schema + else: + json_schema_top_level_properties["properties"] = { + "anyOf": { + geo_json_properties_schema, + {"type": "null"}, + } + } + json_schema_top_level_required.append("properties") + + # Add `type=Feature` at the top level. + json_schema_top_level_properties["type"] = { + "type": "string", + "const": "Feature", + } + json_schema_top_level_required.append("type") + + # Do not allow any extra properties in the root JSON object: we want to restrict it only to + # the core GeoJSON properties. Any extra fields, if they are allowed by the Pydantic model, + # are allowed within the 'properties' sub-object. + json_schema["additionalProperties"] = False + + # Return the completed GeoJSON-flavored JSON Schema. + return json_schema diff --git a/packages/overture-schema-system/src/overture/schema/system/model_constraint/forbid_if.py b/packages/overture-schema-system/src/overture/schema/system/model_constraint/forbid_if.py index 4f6b81c41..0dc04af1f 100644 --- a/packages/overture-schema-system/src/overture/schema/system/model_constraint/forbid_if.py +++ b/packages/overture-schema-system/src/overture/schema/system/model_constraint/forbid_if.py @@ -3,7 +3,7 @@ from pydantic import BaseModel, ConfigDict from typing_extensions import override -from .json_schema import get_static_json_schema, put_if +from .._json_schema import get_static_json_schema, put_if from .model_constraint import ( Condition, OptionalFieldGroupConstraint, diff --git a/packages/overture-schema-system/src/overture/schema/system/model_constraint/min_fields_set.py b/packages/overture-schema-system/src/overture/schema/system/model_constraint/min_fields_set.py index f50bbbd53..915623040 100644 --- a/packages/overture-schema-system/src/overture/schema/system/model_constraint/min_fields_set.py +++ b/packages/overture-schema-system/src/overture/schema/system/model_constraint/min_fields_set.py @@ -3,7 +3,7 @@ from pydantic import BaseModel, ConfigDict from typing_extensions import override -from .json_schema import get_static_json_schema +from .._json_schema import get_static_json_schema from .model_constraint import ModelConstraint diff --git a/packages/overture-schema-system/src/overture/schema/system/model_constraint/radio_group.py b/packages/overture-schema-system/src/overture/schema/system/model_constraint/radio_group.py index 570800e0b..c9e1bcaa1 100644 --- a/packages/overture-schema-system/src/overture/schema/system/model_constraint/radio_group.py +++ b/packages/overture-schema-system/src/overture/schema/system/model_constraint/radio_group.py @@ -6,7 +6,7 @@ from pydantic.json_schema import JsonDict from typing_extensions import override -from .json_schema import get_static_json_schema, put_one_of +from .._json_schema import get_static_json_schema, put_one_of from .model_constraint import FieldGroupConstraint, apply_alias diff --git a/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_any_of.py b/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_any_of.py index 34d7ef2d5..988358a8b 100644 --- a/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_any_of.py +++ b/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_any_of.py @@ -4,7 +4,7 @@ from pydantic.json_schema import JsonDict from typing_extensions import override -from .json_schema import get_static_json_schema, put_any_of +from .._json_schema import get_static_json_schema, put_any_of from .model_constraint import OptionalFieldGroupConstraint, apply_alias diff --git a/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_if.py b/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_if.py index cde193d13..72f6d2520 100644 --- a/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_if.py +++ b/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_if.py @@ -3,7 +3,7 @@ from pydantic import BaseModel, ConfigDict from typing_extensions import override -from .json_schema import get_static_json_schema, put_if +from .._json_schema import get_static_json_schema, put_if from .model_constraint import ( Condition, OptionalFieldGroupConstraint, diff --git a/packages/overture-schema-system/src/overture/schema/system/optionality.py b/packages/overture-schema-system/src/overture/schema/system/optionality.py new file mode 100644 index 000000000..180988a87 --- /dev/null +++ b/packages/overture-schema-system/src/overture/schema/system/optionality.py @@ -0,0 +1,92 @@ +from enum import Enum +from types import NoneType, UnionType +from typing import Annotated, Any, Generic, TypeVar, Union, get_args, get_origin + +from pydantic import Field +from pydantic.experimental.missing_sentinel import MISSING + +T = TypeVar("T") + + +class Omitable(Generic[T]): + """ + Type hint representing a value that can be omitted. + + Use this type in preference to `None` if you need your Pydantic model to use JSON Schema + optionality semantics instead of Pydantic optionality semantics. + + By default, Pydantic conflates "not there" with "nullable" when generating JSON Schemas. This + means that a field that is marked as optional using the standard Pydantic approach of creating a + union with `None` will get a JSON Schema that is the union of the JSON Schema `null` type with + the main type, for example: + + >>> from pydantic import BaseModel + >>> class MyModel(BaseModel): + ... my_optional_field: int | None = None + >>> let json_schema = MyModel.model_json_schema() + >>> let any_of = json_schema['properties']['my_optional_field']['anyOf'] + >>> assert [{'type': 'integer'}, {'type': 'null'}] == any_of + + Although this approach works well in many scenarios, it can't represent JSON Schemas that allow + values to be omitted but do not allow them to be explicitly set to the JSON value `null`, for + example a schema such as: + + ```json + { + "type": "object", + "required": ["foo"], + "properties": { + "foo": { + "type": "string" + }, + "bar": { + "type": "integer" + } + } + } + ``` + + In the above JSON Schema, the property `"bar"` is allowed to be omitted, but if present it must + be an integer. Under no circumstances can it contain the value `null`. The `Omitable` type + allows this to be modeled in Pydantic: + + >>> from pydantic import BaseModel + >>> class MyModel(BaseModel): + ... foo: str + ... bar: Omitable[int] + >>> let json_schema = MyModel.model_json_schema() + >>> let bar_type = json_schema['properties']['bar']['type'] + >>> assert 'integer' == bar_type + """ + + def __class_getitem__(cls, item) -> type[Any]: + if _has_none(item): + raise TypeError( + f"`None` not allowed in `{Omitable.__name__}` args, but found `None` in {item}" + ) + return Annotated[item | MISSING, Field(default=MISSING)] + + +# todo - Vic - finish this +class Optionality(str, Enum): + # This is for implementing the model constraints more cleverly. + # Noneable -> It is allowed to be `null` in JSON Schema. + # To make it @required, need to take away the right to set `null`. + # + # + OMITABLE = ("omitable",) + NONEABLE = ("noneable",) + NOT_OPTIONAL = ("not_optional",) + + +def _has_none(value: Any) -> bool: + if value is None or value is NoneType: + return True + origin = get_origin(value) + if origin is Annotated: + return _has_none(get_args(value)[0]) + elif get_origin(value) in (Union, UnionType): + args = get_args(value) + return any(_has_none(a) for a in args) + else: + return False diff --git a/packages/overture-schema-system/src/overture/schema/system/ref/__init__.py b/packages/overture-schema-system/src/overture/schema/system/ref/__init__.py new file mode 100644 index 000000000..d895e6fe3 --- /dev/null +++ b/packages/overture-schema-system/src/overture/schema/system/ref/__init__.py @@ -0,0 +1,3 @@ +from .id import Id + +__all__ = ["Id"] diff --git a/packages/overture-schema-system/src/overture/schema/system/ref/id.py b/packages/overture-schema-system/src/overture/schema/system/ref/id.py new file mode 100644 index 000000000..033b243c0 --- /dev/null +++ b/packages/overture-schema-system/src/overture/schema/system/ref/id.py @@ -0,0 +1,17 @@ +from typing import Annotated, NewType + +from pydantic import Field + +from overture.schema.system.string import NoWhitespaceString + +Id = NewType( + "Id", + Annotated[ + NoWhitespaceString, + Field( + min_length=1, + description="A unique identifier", + ), + ], +) +# todo - Vic - Pdoc string diff --git a/packages/overture-schema-system/tests/model_constraint/test_forbid_if.py b/packages/overture-schema-system/tests/model_constraint/test_forbid_if.py index e065d5fe6..e048f54b9 100644 --- a/packages/overture-schema-system/tests/model_constraint/test_forbid_if.py +++ b/packages/overture-schema-system/tests/model_constraint/test_forbid_if.py @@ -15,7 +15,7 @@ forbid_if, ) -sys.path.insert(0, str(Path(__file__).parent)) # Needed to import `util` peer module. +sys.path.insert(0, str(Path(__file__).parent.parent)) # Needed to import `util` module. from util import assert_subset diff --git a/packages/overture-schema-system/tests/model_constraint/test_min_fields_set.py b/packages/overture-schema-system/tests/model_constraint/test_min_fields_set.py index 94e6f0b29..798792108 100644 --- a/packages/overture-schema-system/tests/model_constraint/test_min_fields_set.py +++ b/packages/overture-schema-system/tests/model_constraint/test_min_fields_set.py @@ -12,7 +12,7 @@ min_fields_set, ) -sys.path.insert(0, str(Path(__file__).parent)) # Needed to import `util` peer module. +sys.path.insert(0, str(Path(__file__).parent.parent)) # Needed to import `util` module. from util import assert_subset diff --git a/packages/overture-schema-system/tests/model_constraint/test_multi_constraint.py b/packages/overture-schema-system/tests/model_constraint/test_multi_constraint.py index 9686c493b..c58dda9ed 100644 --- a/packages/overture-schema-system/tests/model_constraint/test_multi_constraint.py +++ b/packages/overture-schema-system/tests/model_constraint/test_multi_constraint.py @@ -20,7 +20,7 @@ require_if, ) -sys.path.insert(0, str(Path(__file__).parent)) # Needed to import `util` peer module. +sys.path.insert(0, str(Path(__file__).parent.parent)) # Needed to import `util` module. from util import assert_subset diff --git a/packages/overture-schema-system/tests/model_constraint/test_no_extra_fields.py b/packages/overture-schema-system/tests/model_constraint/test_no_extra_fields.py index 8f981b432..a3fc2cea7 100644 --- a/packages/overture-schema-system/tests/model_constraint/test_no_extra_fields.py +++ b/packages/overture-schema-system/tests/model_constraint/test_no_extra_fields.py @@ -9,7 +9,7 @@ no_extra_fields, ) -sys.path.insert(0, str(Path(__file__).parent)) # Needed to import `util` peer module. +sys.path.insert(0, str(Path(__file__).parent.parent)) # Needed to import `util` module. from util import assert_subset diff --git a/packages/overture-schema-system/tests/model_constraint/test_radio_group.py b/packages/overture-schema-system/tests/model_constraint/test_radio_group.py index 456a54492..67f5ef029 100644 --- a/packages/overture-schema-system/tests/model_constraint/test_radio_group.py +++ b/packages/overture-schema-system/tests/model_constraint/test_radio_group.py @@ -13,7 +13,7 @@ radio_group, ) -sys.path.insert(0, str(Path(__file__).parent)) # Needed to import `util` peer module. +sys.path.insert(0, str(Path(__file__).parent.parent)) # Needed to import `util` module. from util import assert_subset diff --git a/packages/overture-schema-system/tests/model_constraint/test_require_any_of.py b/packages/overture-schema-system/tests/model_constraint/test_require_any_of.py index 230dd29d0..f72c06cb5 100644 --- a/packages/overture-schema-system/tests/model_constraint/test_require_any_of.py +++ b/packages/overture-schema-system/tests/model_constraint/test_require_any_of.py @@ -11,7 +11,7 @@ require_any_of, ) -sys.path.insert(0, str(Path(__file__).parent)) # Needed to import `util` peer module. +sys.path.insert(0, str(Path(__file__).parent.parent)) # Needed to import `util` module. from util import assert_subset diff --git a/packages/overture-schema-system/tests/model_constraint/test_require_if.py b/packages/overture-schema-system/tests/model_constraint/test_require_if.py index e9b4584e2..7694889bb 100644 --- a/packages/overture-schema-system/tests/model_constraint/test_require_if.py +++ b/packages/overture-schema-system/tests/model_constraint/test_require_if.py @@ -15,7 +15,7 @@ require_if, ) -sys.path.insert(0, str(Path(__file__).parent)) # Needed to import `util` peer module. +sys.path.insert(0, str(Path(__file__).parent.parent)) # Needed to import `util` module. from util import assert_subset diff --git a/packages/overture-schema-system/tests/model_constraint/test_model_constraint_json_schema.py b/packages/overture-schema-system/tests/test___json_schema.py similarity index 99% rename from packages/overture-schema-system/tests/model_constraint/test_model_constraint_json_schema.py rename to packages/overture-schema-system/tests/test___json_schema.py index 90f63fcdd..7a898cc97 100644 --- a/packages/overture-schema-system/tests/model_constraint/test_model_constraint_json_schema.py +++ b/packages/overture-schema-system/tests/test___json_schema.py @@ -4,7 +4,7 @@ from pydantic import ConfigDict from pydantic.json_schema import JsonDict -from overture.schema.system.model_constraint.json_schema import ( +from overture.schema.system._json_schema import ( get_static_json_schema, put_all_of, put_any_of, diff --git a/packages/overture-schema-system/tests/test_feature.py b/packages/overture-schema-system/tests/test_feature.py new file mode 100644 index 000000000..0bcdbb844 --- /dev/null +++ b/packages/overture-schema-system/tests/test_feature.py @@ -0,0 +1,411 @@ +import json + +import pytest +from pydantic import ValidationError, create_model + +from overture.schema.system.feature import Feature +from overture.schema.system.optionality import Omitable +from overture.schema.system.primitive import BBox, Geometry + + +class TestSerializeModel: + @pytest.mark.parametrize( + "feature,expect", + [ + ( + Feature(geometry=Geometry.from_wkt("POINT(1 2)")), + { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [1.0, 2.0]}, + "properties": {}, + }, + ), + ( + Feature(id="foo", geometry=Geometry.from_wkt("POINT(1 2)")), + { + "type": "Feature", + "id": "foo", + "geometry": {"type": "Point", "coordinates": [1.0, 2.0]}, + "properties": {}, + }, + ), + ( + Feature( + bbox=BBox(0, 1, 0, 2), geometry=Geometry.from_wkt("POINT(1 2)") + ), + { + "type": "Feature", + "bbox": [0, 1, 0, 2], + "geometry": {"type": "Point", "coordinates": [1.0, 2.0]}, + "properties": {}, + }, + ), + ( + Feature( + id="bar", + bbox=BBox(0, 1, 0, 2), + geometry=Geometry.from_wkt("POINT(1 2)"), + ), + { + "type": "Feature", + "id": "bar", + "bbox": [0, 1, 0, 2], + "geometry": {"type": "Point", "coordinates": [1.0, 2.0]}, + "properties": {}, + }, + ), + ], + ) + def test_simple_json(self, feature: Feature, expect: dict[str, object]) -> None: + actual = json.loads(feature.model_dump_json()) + + assert expect == actual + + @pytest.mark.parametrize( + "feature,expect", + [ + ( + Feature(geometry=Geometry.from_wkt("POINT(1 2)")), + { + "geometry": Geometry.from_wkt("POINT(1 2)"), + }, + ), + ( + Feature(id="foo", geometry=Geometry.from_wkt("POINT(1 2)")), + { + "id": "foo", + "geometry": Geometry.from_wkt("POINT(1 2)"), + }, + ), + ( + Feature( + bbox=BBox(0, 1, 0, 2), geometry=Geometry.from_wkt("POINT(1 2)") + ), + { + "bbox": BBox(0, 1, 0, 2), + "geometry": Geometry.from_wkt("POINT(1 2)"), + }, + ), + ( + Feature( + id="bar", + bbox=BBox(0, 1, 0, 2), + geometry=Geometry.from_wkt("POINT(1 2)"), + ), + { + "id": "bar", + "bbox": BBox(0, 1, 0, 2), + "geometry": Geometry.from_wkt("POINT(1 2)"), + }, + ), + ], + ) + def test_simple_python(self, feature: Feature, expect: dict[str, object]) -> None: + actual = feature.model_dump() + + assert expect == actual + + def test_subclass(self) -> None: + class SubFeature(Feature): + foo: int + bar: Omitable[str] + baz: bool | None = None + + geometry = Geometry.from_wkt("LINESTRING(0 1, 0 2)") + sub_feature = SubFeature(id="foo", foo=42, geometry=geometry) + + actual_json = json.loads(sub_feature.model_dump_json()) + assert { + "type": "Feature", + "id": "foo", + "geometry": {"type": "LineString", "coordinates": [[0, 1], [0, 2]]}, + "properties": { + "foo": 42, + "baz": None, + }, + } == actual_json + + actual_python = sub_feature.model_dump() + assert { + "id": "foo", + "geometry": geometry, + "foo": 42, + "baz": None, + } == actual_python + + +class TestValidateModel: + @pytest.mark.parametrize( + "json_dict,expect", + [ + ( + { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [1.0, 2.0]}, + "properties": {}, + }, + Feature(geometry=Geometry.from_wkt("POINT(1 2)")), + ), + ( + { + "type": "Feature", + "id": "foo", + "geometry": {"type": "Point", "coordinates": [1.0, 2.0]}, + "properties": {}, + }, + Feature(id="foo", geometry=Geometry.from_wkt("POINT(1 2)")), + ), + ( + { + "type": "Feature", + "bbox": [0, 1, 0, 2], + "geometry": {"type": "Point", "coordinates": [1.0, 2.0]}, + "properties": {}, + }, + Feature( + bbox=BBox(0, 1, 0, 2), geometry=Geometry.from_wkt("POINT(1 2)") + ), + ), + ( + { + "type": "Feature", + "id": "bar", + "bbox": [0, 1, 0, 2], + "geometry": {"type": "Point", "coordinates": [1.0, 2.0]}, + "properties": {}, + }, + Feature( + id="bar", + bbox=BBox(0, 1, 0, 2), + geometry=Geometry.from_wkt("POINT(1 2)"), + ), + ), + ], + ) + def test_simple_json(self, json_dict: dict[str, object], expect: Feature) -> None: + actual = Feature.model_validate_json(json.dumps(json_dict)) + + assert expect == actual + + @pytest.mark.parametrize( + "python_dict,expect", + [ + ( + { + "geometry": Geometry.from_wkt("POINT(1 2)"), + }, + Feature(geometry=Geometry.from_wkt("POINT(1 2)")), + ), + ( + { + "id": "foo", + "geometry": Geometry.from_wkt("POINT(1 2)"), + }, + Feature(id="foo", geometry=Geometry.from_wkt("POINT(1 2)")), + ), + ( + { + "bbox": BBox(0, 1, 0, 2), + "geometry": Geometry.from_wkt("POINT(1 2)"), + }, + Feature( + bbox=BBox(0, 1, 0, 2), geometry=Geometry.from_wkt("POINT(1 2)") + ), + ), + ( + { + "id": "bar", + "bbox": BBox(0, 1, 0, 2), + "geometry": Geometry.from_wkt("POINT(1 2)"), + }, + Feature( + id="bar", + bbox=BBox(0, 1, 0, 2), + geometry=Geometry.from_wkt("POINT(1 2)"), + ), + ), + ], + ) + def test_simple_python( + self, python_dict: dict[str, object], expect: Feature + ) -> None: + actual = Feature.model_validate(python_dict) + + assert expect == actual + + def test_subclass(self) -> None: + class SubFeature(Feature): + foo: int + bar: Omitable[str] + baz: bool | None = None + + bbox = BBox(0, 1, 0, 2) + geometry = Geometry.from_wkt("LINESTRING(0 1, 0 2)") + expect = SubFeature(id="Hello", foo=42, baz=None, bbox=bbox, geometry=geometry) + + actual_from_json = SubFeature.model_validate_json( + json.dumps( + { + "type": "Feature", + "id": "Hello", + "bbox": [0, 1, 0, 2], + "geometry": { + "type": "LineString", + "coordinates": [[0, 1], [0, 2]], + }, + "properties": { + "foo": 42, + "baz": None, + }, + } + ) + ) + assert expect == actual_from_json + + actual_from_python = SubFeature.model_validate( + { + "id": "Hello", + "bbox": bbox, + "geometry": geometry, + "foo": 42, + "baz": None, + } + ) + assert expect == actual_from_python + + def test_error_data_not_dict(self) -> None: + with pytest.raises( + TypeError, match="feature data must be a `dict`, but 'foo' is a `str`" + ): + Feature.model_validate("foo") + + with pytest.raises( + TypeError, match="feature data must be a `dict`, but 'bar' is a `str`" + ): + Feature.model_validate_json('"bar"') + + @pytest.mark.parametrize( + "feature_class,missing_field,json_input,python_input", + [ + ( + Feature, + "geometry", + {"type": "Feature"}, + {}, + ), + ( + Feature, + "geometry", + {"type": "Feature", "id": "foo", "bbox": [1, 2, 3, 4]}, + {"id": "foo", "bbox": BBox(1, 2, 3, 4)}, + ), + ( + create_model("SubFeature", __base__=Feature, foo=int), + "foo", + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [0, 0], + }, + }, + { + "geometry": Geometry.from_wkt("POINT(0 0)"), + }, + ), + ], + ) + def test_error_type_property_missing( + self, + feature_class: type[Feature], + missing_field: str, + json_input: dict[str, object], + python_input: dict[str, object], + ) -> None: + def assert_missing(error_info: pytest.ExceptionInfo) -> None: + validation_error = error_info.value + assert 1 == validation_error.error_count() + first_error = validation_error.errors()[0] + assert "missing" == first_error["type"] + assert missing_field == first_error["loc"][0] + + with pytest.raises(ValidationError) as json_error_info: + feature_class.model_validate_json(json.dumps(json_input)) + + assert_missing(json_error_info) + + with pytest.raises(ValidationError) as python_error_info: + feature_class.model_validate(python_input) + + assert_missing(python_error_info) + + @pytest.mark.parametrize( + "feature_class,invalid_field,json_input,python_input", + [ + ( + Feature, + "geometry", + {"type": "Feature", "geometry": "foo"}, + {"geometry": "foo"}, + ), + ( + Feature, + "id", + {"type": "Feature", "geometry": {"type":"Point","coordinates":[0,0]}, "id": 1.5}, + {"geometry": Geometry.from_wkt("POINT(0 0)"), "id": 1.5}, + ), + ( + Feature, + "bbox", + {"type": "Feature", "geometry": {"type":"Point","coordinates":[0,0]}, "id": "foo", "bbox": "bar"}, + {"geometry": Geometry.from_wkt("POINT(0 0)"), "id": "foo", "bbox": "bar"}, + ), + ( + create_model('SubFeature', __base__=Feature, foo=int), + 'foo', + {"type": "Feature", "geometry": {"type":"Point","coordinates":[0,0]}, "properties":{"foo":"bar"}}, + {"geometry": Geometry.from_wkt("POINT(0 0)"), "foo": "bar"}, + ) + ], + ) + def test_error_type_property_wrong_value( + self, + feature_class: type[Feature], + invalid_field: str, + json_input: dict[str, object], + python_input: dict[str, object], + ) -> None: + def assert_wrong_value(kind: str, input: dict[str, object], error_info: pytest.ExceptionInfo) -> None: + validation_error = error_info.value + first_error = validation_error.errors()[0] + assert first_error["type"] in { 'value_error', 'string_type', 'int_parsing' }, f"unexpected error type {repr(first_error['type'])} for {kind} input {repr(input)}" + assert invalid_field == first_error["loc"][0], f"unexpected field name {repr(first_error['loc'][0])} for {kind} input {repr(input)}" + + with pytest.raises(ValidationError) as json_error_info: + feature_class.model_validate_json(json.dumps(json_input)) + + assert_wrong_value('json', json_input, json_error_info) + + with pytest.raises(ValidationError) as python_error_info: + feature_class.model_validate(python_input) + + assert_wrong_value('python', python_input, python_error_info) + + def test_error_properties_missing(self) -> None: + pass + + def test_error_properties_not_object_or_null(self) -> None: + pass + + def test_error_illegal_root_properties(self) -> None: + pass + + def test_error_illegal_properties(self) -> None: + pass + + def test_error_missing_required_property(self) -> None: + # TODO: both basic and advanced subclass + pass + + def test_error_extra_property_not_allowed(self) -> None: + pass diff --git a/packages/overture-schema-system/tests/test_optionality.py b/packages/overture-schema-system/tests/test_optionality.py new file mode 100644 index 000000000..9abf12534 --- /dev/null +++ b/packages/overture-schema-system/tests/test_optionality.py @@ -0,0 +1,107 @@ +import json +import sys +from pathlib import Path +from types import NoneType +from typing import Annotated, Any + +import pytest +from pydantic import BaseModel, create_model +from pydantic.json_schema import JsonDict + +from overture.schema.system.optionality import Omitable + +sys.path.insert(0, str(Path(__file__).parent.parent)) # Needed to import `util` module. + +from util import assert_subset + + +@pytest.mark.parametrize( + "model,expect_json,expect_json_schema", + [ + ( + create_model("case1", foo=Omitable[int])(), + {}, + { + "properties": { + "foo": { + "type": "integer", + } + }, + }, + ), + ( + create_model("case2", foo=Omitable[int])(foo=42), + {"foo": 42}, + { + "properties": { + "foo": { + "type": "integer", + } + }, + }, + ), + ( + create_model("case3", foo=Omitable[int | str])(), + {}, + { + "properties": { + "foo": { + "anyOf": [ + {"type": "integer"}, + {"type": "string"}, + ] + } + }, + }, + ), + ( + create_model("case3", foo=Omitable[int | str])(foo="bar"), + {"foo": "bar"}, + { + "properties": { + "foo": { + "anyOf": [ + {"type": "integer"}, + {"type": "string"}, + ] + } + }, + }, + ), + ], +) +def test_omitable_model( + model: type[BaseModel], expect_json: JsonDict, expect_json_schema: JsonDict +) -> None: + actual_json = json.loads(model.model_dump_json()) + assert expect_json == actual_json + + actual_json_schema = model.model_json_schema() + assert_subset( + expect_json_schema, + actual_json_schema, + "expect_json_schema", + "actual_json_schema", + ) + + assert not model.__class__.model_fields["foo"].is_required() + + +@pytest.mark.parametrize( + "item", + [ + None, + None | int, + str | None, + Annotated[None, "something"], + Annotated[int | str | None, "something"], + int | bool | Annotated[None, "something"], + Annotated[ + int | bool | Annotated[Annotated[float | NoneType, "innermost"], "middle"], + "outermost", + ], + ], +) +def test_omitable_type_error(item: Any) -> None: + with pytest.raises(TypeError, match="`None` not allowed in `Omitable` args"): + Omitable[item] diff --git a/packages/overture-schema-system/tests/model_constraint/util.py b/packages/overture-schema-system/tests/util.py similarity index 100% rename from packages/overture-schema-system/tests/model_constraint/util.py rename to packages/overture-schema-system/tests/util.py From aaf06462c0fc9c4098fd7d6deb746c99c7bc6325 Mon Sep 17 00:00:00 2001 From: schapper Date: Tue, 14 Oct 2025 22:45:23 -0700 Subject: [PATCH 11/19] wip - [CHECKPOINT] doctests passing for Omitable and Feature --- .../src/overture/schema/core/models.py | 2 +- .../src/overture/schema/system/feature.py | 151 ++++++++-- .../src/overture/schema/system/optionality.py | 8 +- .../tests/test_feature.py | 262 +++++++++++++++--- 4 files changed, 366 insertions(+), 57 deletions(-) diff --git a/packages/overture-schema-core/src/overture/schema/core/models.py b/packages/overture-schema-core/src/overture/schema/core/models.py index 3ac6929c7..55c599f7a 100644 --- a/packages/overture-schema-core/src/overture/schema/core/models.py +++ b/packages/overture-schema-core/src/overture/schema/core/models.py @@ -292,7 +292,7 @@ def __get_pydantic_json_schema__( "type": "object", "properties": geo_json_properties, # always reject properties that aren't defined in the schema - "unevaluatedProperties": False, + "unevaluatedProperties": False, # FIXME: We don't want this unless extra='forbid' } if geo_json_required: diff --git a/packages/overture-schema-system/src/overture/schema/system/feature.py b/packages/overture-schema-system/src/overture/schema/system/feature.py index ac0f176b8..0e5e9df61 100644 --- a/packages/overture-schema-system/src/overture/schema/system/feature.py +++ b/packages/overture-schema-system/src/overture/schema/system/feature.py @@ -1,7 +1,8 @@ -from typing import Any, Literal +from typing import Any from pydantic import ( BaseModel, + Field, GetJsonSchemaHandler, ModelWrapValidatorHandler, SerializerFunctionWrapHandler, @@ -21,10 +22,80 @@ class Feature(BaseModel): - type: Literal["Feature"] - id: Omitable[Id] - bbox: Omitable[BBox] - geometry: Geometry + """ + A feature is something you can point to on a map—like a building, road, lake, or park—with the + facts about that thing. + + Every feature has a geometry that describes where it is and what it looks like. In addition, a + feature may have an `id` field that uniquely identifies it. It may also have a bounding box, + which is a simplified geometry that facilitates efficient spatial operations. + + Derive a subclass of `Feature` to add new fields with facts about your feature type. + + >>> from typing import Annotated + >>> from pydantic import Field + >>> from overture.schema.system.primitive import Geometry, float32 + ... + >>> class Mountain(Feature): + ... name: str + ... max_elevation: Annotated[ + ... float32 , + ... Field(description='Maximum elevation above sea level in meters') + ... ] + ... + >>> mount_everest = Mountain( + ... geometry=Geometry.from_wkt('POINT(86.9252 27.9888)'), + ... name='Mount Everest', + ... max_elevation=8_848.86 + ... ) + + A feature has a special JSON representation that conforms to the `GeoJSON format`_ + specification. + + >>> print(mount_everest.model_dump_json(indent=2)) + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 86.9252, + 27.9888 + ] + }, + "properties": { + "name": "Mount Everest", + "max_elevation": 8848.86 + } + } + + Use a geometry type constraint to limit the geometry types allowed on your feature subclass. + This can help maximize validation and data integrity by preventing geometries that do not make + sense from being stored. + + >>> from overture.schema.system.primitive import GeometryType, GeometryTypeConstraint + ... + >>> class River(Feature): + ... geometry: Annotated[ + ... Geometry, + ... GeometryTypeConstraint(GeometryType.LINE_STRING) + ... ] + + .. _GeoJSON format: https://datatracker.ietf.org/doc/html/rfc7946 + """ + + id: Omitable[Id] = Field(description="An optional unique ID for the feature") + """An optional unique ID for the feature.""" + + bbox: Omitable[BBox] = Field(description="An optional bounding box for the feature") + """An optional bounding box for the feature.""" + + geometry: Geometry = Field(description="The feature's geometry") + """ + The feature's geometry. + + Subclasses of `Feature` may limit the geometry types allowed on `geometry` by repeating this + field and annotating it with a `GeometryTypeConstraint`. + """ @model_serializer(mode="wrap") def serialize_model( @@ -61,7 +132,10 @@ def validate_model( ) if info.mode == "json": - def validation_error(type: str, input: object, error: str, *loc: str) -> ValidationError: + + def validation_error( + type: str, input: object, error: str, *loc: str + ) -> ValidationError: context = info.context or {} loc = context.get("loc_prefix", ()) + loc return ValidationError.from_exception_data( @@ -73,31 +147,61 @@ def validation_error(type: str, input: object, error: str, *loc: str) -> Validat input=input, ctx={"error": error}, ) - ] + ], ) - def type_property_error(input: object, problem: str) -> ValidationError: - return validation_error('geo_json_type', input, f"{problem} (it should have value 'Feature')", "type") - - def properties_property_error(input: object, problem: str) -> ValidationError: + def type_property_error( + type: str, input: object, problem: str + ) -> ValidationError: + return validation_error( + type, + input, + f"{problem} feature JSON (it should have value 'Feature')", + "type", + ) + def properties_property_type_error( + type: str, input: object, prefix: str, suffix: str, *loc: str + ) -> ValidationError: + return validation_error( + type, + input, + f"{prefix} feature JSON (it must be a `dict` or an explicitly preset `None` value){suffix}", + "properties", + ) - # GeoJSON features require `type=Feature` at the top level. + # GeoJSON features require `type=Feature` at the top level. Note that this validation + # *could* be done as a `Literal["Feature"]` field, but that approach would have two + # shortcomings. First (minor), it would force the non-JSON Python representation to + # have the "type" field. Second (major) it would make it trickier for the perfectly + # valid use case of a property under "properties" named "type", since our approach to + # "properties" is to lift them up into the root level object before calling the + # provided handler. Lifting up an inner "type" variable would overwrite the outer "type" + # and cause a validation failure. try: t = data.pop("type") except KeyError: - raise type_property_error(None, "'type' property is missing") from None + raise type_property_error( + "missing", None, "'type' property is missing from" + ) from None if t != "Feature": - raise type_property_error(t, f"'type' property has a wrong value, {repr(t)}") + raise type_property_error( + "value_error", t, f"'type' property has wrong value {repr(t)} in" + ) # Remove the properties sub-dictionary so we can flatten it. try: properties = data.pop("properties") except KeyError: - properties = None + raise properties_property_type_error( + "missing", None, "'properties' property is missing from", "" + ) from None if not isinstance(properties, dict | None): - raise TypeError( - f"'properties' key in feature JSON must be a `dict`, but {repr(properties)} is a `{type(properties).__name__}" + raise properties_property_type_error( + "value_error", + None, + "'properties' property has wrong type in", + f", but {repr(properties)} is a `{type(properties).__name__}`", ) # Ensure there's nothing in data root level that repeats a valid model field. @@ -107,8 +211,10 @@ def properties_property_error(input: object, problem: str) -> ValidationError: if f not in {"id", "bbox", "geometry", "properties"} ] if conflicts: - raise ValueError( - "illegal root-level properties in feature JSON: these properties may only be children of the 'properties' object: {repr(conflicts)}" + raise validation_error( + "value_error", + properties, + f"illegal top-level properties in feature JSON: {repr(conflicts)} (these properties may only be children of the 'properties' object)", ) if properties: @@ -117,8 +223,11 @@ def properties_property_error(input: object, problem: str) -> ValidationError: f for f in properties.keys() if f in {"id", "bbox", "geometry"} ] if conflicts: - raise ValueError( - "illegal properties in feature JSON: these properties may only appear in the root, but they are in the 'properties' object: {repr(conficts)}" + raise validation_error( + "value_error", + properties, + f"illegal properties in feature JSON: {repr(conflicts)} (these properties may only appear at the top level, but they are in the 'properties' object)", + "properties", ) # Spread the 'properties' sub-dictionary across the root-level data object. diff --git a/packages/overture-schema-system/src/overture/schema/system/optionality.py b/packages/overture-schema-system/src/overture/schema/system/optionality.py index 180988a87..ba423e608 100644 --- a/packages/overture-schema-system/src/overture/schema/system/optionality.py +++ b/packages/overture-schema-system/src/overture/schema/system/optionality.py @@ -23,8 +23,8 @@ class Omitable(Generic[T]): >>> from pydantic import BaseModel >>> class MyModel(BaseModel): ... my_optional_field: int | None = None - >>> let json_schema = MyModel.model_json_schema() - >>> let any_of = json_schema['properties']['my_optional_field']['anyOf'] + >>> json_schema = MyModel.model_json_schema() + >>> any_of = json_schema['properties']['my_optional_field']['anyOf'] >>> assert [{'type': 'integer'}, {'type': 'null'}] == any_of Although this approach works well in many scenarios, it can't represent JSON Schemas that allow @@ -54,8 +54,8 @@ class Omitable(Generic[T]): >>> class MyModel(BaseModel): ... foo: str ... bar: Omitable[int] - >>> let json_schema = MyModel.model_json_schema() - >>> let bar_type = json_schema['properties']['bar']['type'] + >>> json_schema = MyModel.model_json_schema() + >>> bar_type = json_schema['properties']['bar']['type'] >>> assert 'integer' == bar_type """ diff --git a/packages/overture-schema-system/tests/test_feature.py b/packages/overture-schema-system/tests/test_feature.py index 0bcdbb844..ca92e1d4d 100644 --- a/packages/overture-schema-system/tests/test_feature.py +++ b/packages/overture-schema-system/tests/test_feature.py @@ -1,7 +1,8 @@ import json +import re import pytest -from pydantic import ValidationError, create_model +from pydantic import ConfigDict, ValidationError, create_model from overture.schema.system.feature import Feature from overture.schema.system.optionality import Omitable @@ -273,6 +274,25 @@ class SubFeature(Feature): ) assert expect == actual_from_python + def test_extra_properties(self) -> None: + class SubFeature(Feature): + model_config = ConfigDict(extra="allow") + + extra = { + "foo": "bar", + "baz": [42], + } + + input_json = { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [0, 0]}, + "properties": extra, + } + + sub_feature = SubFeature.model_validate_json(json.dumps(input_json)) + + assert extra == sub_feature.model_extra + def test_error_data_not_dict(self) -> None: with pytest.raises( TypeError, match="feature data must be a `dict`, but 'foo' is a `str`" @@ -284,19 +304,159 @@ def test_error_data_not_dict(self) -> None: ): Feature.model_validate_json('"bar"') + def test_error_type_property_missing(self) -> None: + input_json = { + "geometry": {"type": "Point", "coordinates": [0, 0]}, + "properties": {}, + } + + with pytest.raises(ValidationError) as error_info: + Feature.model_validate_json(json.dumps(input_json)) + + validation_error = error_info.value + assert 1 == validation_error.error_count() + first_error = validation_error.errors()[0] + assert "missing" == first_error["type"] + assert "type" == first_error["loc"][0] + + def test_error_type_property_wrong_value(self) -> None: + input_json = { + "type": 42, + "geometry": {"type": "Point", "coordinates": [0, 0]}, + "properties": None, + } + + with pytest.raises( + ValidationError, match="'type' property has wrong value 42 in feature JSON" + ) as error_info: + Feature.model_validate_json(json.dumps(input_json)) + + validation_error = error_info.value + assert 1 == validation_error.error_count() + first_error = validation_error.errors()[0] + assert "value_error" == first_error["type"] + assert "type" == first_error["loc"][0] + + def test_error_properties_missing(self) -> None: + input_json = { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [0, 0]}, + } + + with pytest.raises(ValidationError) as error_info: + Feature.model_validate_json(json.dumps(input_json)) + + validation_error = error_info.value + assert 1 == validation_error.error_count() + first_error = validation_error.errors()[0] + assert "missing" == first_error["type"] + assert "properties" == first_error["loc"][0] + + def test_error_properties_not_object_or_null(self) -> None: + input_json = { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [0, 0]}, + "properties": 3.14159, + } + + with pytest.raises( + ValidationError, + match="'properties' property has wrong type in feature JSON", + ) as error_info: + Feature.model_validate_json(json.dumps(input_json)) + + validation_error = error_info.value + assert 1 == validation_error.error_count() + first_error = validation_error.errors()[0] + assert "value_error" == first_error["type"] + assert "properties" == first_error["loc"][0] + + def test_error_illegal_root_properties(self) -> None: + input_json = { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [0, 0]}, + "properties": {}, + "foo": 42, + "bar": "baz", + } + + with pytest.raises( + ValidationError, + match=r"illegal top-level properties in feature JSON: \['foo', 'bar'\]", + ) as error_info: + Feature.model_validate_json(json.dumps(input_json)) + + validation_error = error_info.value + assert 1 == validation_error.error_count() + first_error = validation_error.errors()[0] + assert "value_error" == first_error["type"] + + @pytest.mark.parametrize( + "illegal_properties", + [ + (("bbox",)), + ( + ( + "bbox", + "geometry", + ) + ), + ( + ( + "bbox", + "geometry", + "id", + ) + ), + (("geometry",)), + ( + ( + "geometry", + "id", + ) + ), + (("id",)), + ], + ) + def test_error_illegal_properties( + self, illegal_properties: tuple[str, ...] + ) -> None: + input_json = { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [0, 0]}, + "properties": dict.fromkeys(illegal_properties, "foo"), + } + + with pytest.raises( + ValidationError, + match=f"illegal properties in feature JSON: {re.escape(repr(list(illegal_properties)))}", + ) as error_info: + Feature.model_validate_json(json.dumps(input_json)) + + validation_error = error_info.value + assert 1 == validation_error.error_count() + first_error = validation_error.errors()[0] + assert "value_error" == first_error["type"] + assert "properties" == first_error["loc"][0] + @pytest.mark.parametrize( "feature_class,missing_field,json_input,python_input", [ ( Feature, "geometry", - {"type": "Feature"}, + {"type": "Feature", "properties": None}, {}, ), ( Feature, "geometry", - {"type": "Feature", "id": "foo", "bbox": [1, 2, 3, 4]}, + { + "type": "Feature", + "id": "foo", + "bbox": [1, 2, 3, 4], + "properties": {}, + }, {"id": "foo", "bbox": BBox(1, 2, 3, 4)}, ), ( @@ -308,6 +468,7 @@ def test_error_data_not_dict(self) -> None: "type": "Point", "coordinates": [0, 0], }, + "properties": None, }, { "geometry": Geometry.from_wkt("POINT(0 0)"), @@ -315,7 +476,7 @@ def test_error_data_not_dict(self) -> None: ), ], ) - def test_error_type_property_missing( + def test_error_required_property_missing( self, feature_class: type[Feature], missing_field: str, @@ -345,67 +506,106 @@ def assert_missing(error_info: pytest.ExceptionInfo) -> None: ( Feature, "geometry", - {"type": "Feature", "geometry": "foo"}, + {"type": "Feature", "geometry": "foo", "properties": {}}, {"geometry": "foo"}, ), ( Feature, "id", - {"type": "Feature", "geometry": {"type":"Point","coordinates":[0,0]}, "id": 1.5}, + { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [0, 0]}, + "id": 1.5, + "properties": None, + }, {"geometry": Geometry.from_wkt("POINT(0 0)"), "id": 1.5}, ), ( Feature, "bbox", - {"type": "Feature", "geometry": {"type":"Point","coordinates":[0,0]}, "id": "foo", "bbox": "bar"}, - {"geometry": Geometry.from_wkt("POINT(0 0)"), "id": "foo", "bbox": "bar"}, + { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [0, 0]}, + "id": "foo", + "bbox": "bar", + "properties": None, + }, + { + "geometry": Geometry.from_wkt("POINT(0 0)"), + "id": "foo", + "bbox": "bar", + }, ), ( - create_model('SubFeature', __base__=Feature, foo=int), - 'foo', - {"type": "Feature", "geometry": {"type":"Point","coordinates":[0,0]}, "properties":{"foo":"bar"}}, + create_model("SubFeature", __base__=Feature, foo=int), + "foo", + { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [0, 0]}, + "properties": {"foo": "bar"}, + }, {"geometry": Geometry.from_wkt("POINT(0 0)"), "foo": "bar"}, - ) + ), ], ) - def test_error_type_property_wrong_value( + def test_error_required_property_wrong_value( self, feature_class: type[Feature], invalid_field: str, json_input: dict[str, object], python_input: dict[str, object], ) -> None: - def assert_wrong_value(kind: str, input: dict[str, object], error_info: pytest.ExceptionInfo) -> None: + def assert_wrong_value( + kind: str, input: dict[str, object], error_info: pytest.ExceptionInfo + ) -> None: validation_error = error_info.value first_error = validation_error.errors()[0] - assert first_error["type"] in { 'value_error', 'string_type', 'int_parsing' }, f"unexpected error type {repr(first_error['type'])} for {kind} input {repr(input)}" - assert invalid_field == first_error["loc"][0], f"unexpected field name {repr(first_error['loc'][0])} for {kind} input {repr(input)}" + + assert first_error["type"] in { + "value_error", + "string_type", + "int_parsing", + }, ( + f"unexpected error type {repr(first_error['type'])} for {kind} input {repr(input)}" + ) + assert invalid_field == first_error["loc"][0], ( + f"unexpected field name {repr(first_error['loc'][0])} for {kind} input {repr(input)}" + ) with pytest.raises(ValidationError) as json_error_info: feature_class.model_validate_json(json.dumps(json_input)) - assert_wrong_value('json', json_input, json_error_info) + assert_wrong_value("json", json_input, json_error_info) with pytest.raises(ValidationError) as python_error_info: feature_class.model_validate(python_input) - assert_wrong_value('python', python_input, python_error_info) + assert_wrong_value("python", python_input, python_error_info) - def test_error_properties_missing(self) -> None: - pass + def test_error_extra_property_not_allowed(self) -> None: + class SubFeature(Feature): + model_config = ConfigDict(extra="forbid") - def test_error_properties_not_object_or_null(self) -> None: - pass + extra = { + "foo": "bar", + } - def test_error_illegal_root_properties(self) -> None: - pass + input_json = { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [0, 0]}, + "properties": extra, + } - def test_error_illegal_properties(self) -> None: - pass + with pytest.raises(ValidationError) as error_info: + SubFeature.model_validate_json(json.dumps(input_json)) - def test_error_missing_required_property(self) -> None: - # TODO: both basic and advanced subclass - pass + validation_error = error_info.value + assert 1 == validation_error.error_count() + first_error = validation_error.errors()[0] + assert "extra_forbidden" == first_error["type"] + assert "foo" == first_error["loc"][0] - def test_error_extra_property_not_allowed(self) -> None: - pass + +class TestJsonSchema: + def test_json_schema(self): + assert False, "todo: this" From dcb75ad17821029ad5dc4108b9f4623cfcb01c53 Mon Sep 17 00:00:00 2001 From: schapper Date: Thu, 16 Oct 2025 08:18:06 -0700 Subject: [PATCH 12/19] wip - [BROKEN] - Closing innings of getting mixed-level feature JSON Schema constraints working? --- .../overture/schema/system/_json_schema.py | 176 +++-- .../src/overture/schema/system/feature.py | 395 ++++++++-- .../tests/test___json_schema.py | 136 +++- .../tests/test_feature.py | 688 +++++++++++++++++- 4 files changed, 1233 insertions(+), 162 deletions(-) diff --git a/packages/overture-schema-system/src/overture/schema/system/_json_schema.py b/packages/overture-schema-system/src/overture/schema/system/_json_schema.py index d1385a93d..e920d9daa 100644 --- a/packages/overture-schema-system/src/overture/schema/system/_json_schema.py +++ b/packages/overture-schema-system/src/overture/schema/system/_json_schema.py @@ -1,24 +1,24 @@ from collections.abc import Callable -from typing import Any, cast, get_origin +from typing import Any, TypeVar, cast, get_origin from pydantic import ConfigDict -from pydantic.json_schema import JsonDict, JsonValue +from pydantic.json_schema import JsonSchemaValue, JsonValue -def get_static_json_schema(config: ConfigDict) -> JsonDict: +def get_static_json_schema(config: ConfigDict) -> JsonSchemaValue: json_schema: ( - JsonDict - | Callable[[JsonDict], None] - | Callable[[JsonDict, type[Any]], None] + JsonSchemaValue + | Callable[[JsonSchemaValue], None] + | Callable[[JsonSchemaValue, type[Any]], None] | None ) = config.get("json_schema_extra", None) if json_schema is None: json_schema = {} config["json_schema_extra"] = json_schema else: - origin = cast(type, get_origin(JsonDict)) + origin = cast(type, get_origin(JsonSchemaValue)) if isinstance(json_schema, origin): - return cast(JsonDict, json_schema) + return cast(JsonSchemaValue, json_schema) else: raise ValueError( f'expected value of config\'s "json_schema_extra" key to be a `{origin.__name__}`, but it is a `{type(json_schema).__name__}`' @@ -26,8 +26,9 @@ def get_static_json_schema(config: ConfigDict) -> JsonDict: return json_schema -def put_all_of(json_schema: JsonDict, operands: list[JsonDict]) -> None: - _verify_operands_len_2(operands) +def put_all_of(json_schema: JsonSchemaValue, operands: list[JsonSchemaValue]) -> None: + _verify_json_schema_value(('json_schema', json_schema)) + _verify_operands_not_empty(JsonSchemaValue, operands) if "allOf" not in json_schema: json_schema["allOf"] = cast(JsonValue, operands) else: @@ -40,34 +41,32 @@ def put_all_of(json_schema: JsonDict, operands: list[JsonDict]) -> None: ) -def put_any_of(json_schema: JsonDict, operands: list[JsonDict]) -> None: - _verify_operands_len_2(operands) - prev: JsonDict = {} - _try_move("anyOf", json_schema, prev) +def put_any_of(json_schema: JsonSchemaValue, operands: list[JsonSchemaValue]) -> None: + _verify_json_schema_value(('json_schema', json_schema)) + _verify_operands_not_empty(JsonSchemaValue, operands) + prev: JsonSchemaValue = {} + try_move("anyOf", json_schema, prev) if not prev: json_schema["anyOf"] = cast(JsonValue, operands) else: put_all_of(json_schema, [prev, {"anyOf": cast(JsonValue, operands)}]) -def put_one_of(json_schema: JsonDict, operands: list[JsonDict]) -> None: - _verify_operands_len_2(operands) - prev: JsonDict = {} - _try_move("oneOf", json_schema, prev) +def put_one_of(json_schema: JsonSchemaValue, operands: list[JsonSchemaValue]) -> None: + _verify_json_schema_value(('json_schema', json_schema)) + _verify_operands_not_empty(JsonSchemaValue, operands) + prev: JsonSchemaValue = {} + try_move("oneOf", json_schema, prev) if not prev: json_schema["oneOf"] = cast(JsonValue, operands) else: put_all_of(json_schema, [prev, {"oneOf": cast(JsonValue, operands)}]) -def put_not(json_schema: JsonDict, operand: JsonDict) -> None: - origin = cast(type, get_origin(JsonDict)) - if not isinstance(operand, origin): - raise TypeError( - f"`operand` must be a `JsonDict` value, but it is not: {operand}" - ) - prev: JsonDict = {} - _try_move("not", json_schema, prev) +def put_not(json_schema: JsonSchemaValue, operand: JsonSchemaValue) -> None: + _verify_json_schema_value(('json_schema', json_schema), ('operand', operand)) + prev: JsonSchemaValue = {} + try_move("not", json_schema, prev) # Simple case: if the JSON didn't already have a "not", we just add it. if not prev: @@ -75,11 +74,11 @@ def put_not(json_schema: JsonDict, operand: JsonDict) -> None: return not_schema = prev["not"] - if not isinstance(not_schema, origin): + if not isinstance(not_schema, get_origin(JsonSchemaValue)): raise ValueError( - f'expected value of "not" key to be a `JsonDict`, but it is a {type(not_schema).__name__} in the JSON Schema {json_schema}' + f'expected value of "not" key to be a `JsonSchemaValue`, but it is a {type(not_schema).__name__} in the JSON Schema {json_schema}' ) - not_schema = cast(JsonDict, not_schema) + not_schema = cast(JsonSchemaValue, not_schema) # Next simplest case: the only child of the "not" is "anyOf". if len(not_schema) == 1 and "anyOf" in not_schema: @@ -102,19 +101,29 @@ def put_not(json_schema: JsonDict, operand: JsonDict) -> None: def put_if( - json_schema: JsonDict, - condition: JsonDict, - when_true: JsonDict, - when_false: JsonDict | None = None, + json_schema: JsonSchemaValue, + condition: JsonSchemaValue | None, + when_true: JsonSchemaValue | None, + when_false: JsonSchemaValue | None = None, ) -> None: - prev: JsonDict = {} - _try_move("if", json_schema, prev) - _try_move("then", json_schema, prev) - _try_move("else", json_schema, prev) - - def _put(dst: JsonDict) -> JsonDict: - dst["if"] = condition - dst["then"] = when_true + _verify_json_schema_value(('json_schema', json_schema)) + if condition is not None: + _verify_json_schema_value(('condition', condition)) + if when_true is not None: + _verify_json_schema_value(('when_true', when_true)) + if when_false is not None: + _verify_json_schema_value(('when_false', when_false)) + + prev: JsonSchemaValue = {} + try_move("if", json_schema, prev) + try_move("then", json_schema, prev) + try_move("else", json_schema, prev) + + def _put(dst: JsonSchemaValue) -> JsonSchemaValue: + if condition: + dst["if"] = condition + if when_true: + dst["then"] = when_true if when_false: dst["else"] = when_false return dst @@ -125,27 +134,80 @@ def _put(dst: JsonDict) -> JsonDict: put_all_of(json_schema, [prev, _put({})]) -def _verify_operands_len_2(operands: list[JsonDict]) -> None: - if not isinstance(operands, list): - raise TypeError( - f"`operands` must be a `list`, but {operands} is a {type(operands).__name__}" - ) - if len(operands) < 2: - raise ValueError( - f"`operands` must have length at least 2, but {operands} only has length {len(operands)}" - ) - origin = cast(type, get_origin(JsonDict)) - mismatches = [a for a in operands if not isinstance(a, origin)] - if mismatches: - raise TypeError( - "`operands` items must be `JsonDict` values, but these items are not: {mismatches}" - ) +def put_required( + json_schema: JsonSchemaValue, + operands: list[str] +) -> None: + _verify_json_schema_value(('json_schema', json_schema)) + _verify_operands_not_empty(str, operands) + if "required" in json_schema: + required = json_schema["required"] + else: + required = [] + json_schema["required"] = required + required += [p for p in operands if p not in required] -def _try_move(key: str, src: JsonDict, dst: JsonDict) -> None: +def put_properties( + json_schema: JsonSchemaValue, + new_properties: JsonSchemaValue, +) -> None: + _verify_json_schema_value(('json_schema', json_schema), ('new_properties', new_properties)) + if "properties" in json_schema: + properties = json_schema["properties"] + else: + properties = {} + json_schema["properties"] = properties + for k, v in new_properties.items(): + if k not in properties: + properties[k] = v + else: + _merge(v, properties[k], k) + + +def try_move(key: str, src: JsonSchemaValue, dst: JsonSchemaValue) -> None: try: value = src[key] dst[key] = value del src[key] except KeyError: pass + +T = TypeVar('T', JsonSchemaValue, str) + +def _verify_json_schema_value(*candidates: tuple[str, JsonSchemaValue]) -> None: + origin = get_origin(JsonSchemaValue) + for target in candidates: + if not isinstance(target[1], origin): + raise TypeError(f"`{target[0]}` must be a `JsonSchemaValue` value, but {repr(target[1])} has type `{type(target[1]).__name__}`") + +def _verify_operands_not_empty(tp: T, operands: list[T]) -> None: + if not isinstance(operands, list): + raise TypeError( + f"`operands` must be a `list`, but {operands} has type `{type(operands).__name__}`" + ) + if len(operands) == 0: + raise ValueError("`operands` cannot be empty, but it is") + origin = get_origin(tp) + if origin: + target = cast(type, origin) + else: + target = tp + mismatches = [a for a in operands if not isinstance(a, target)] + if mismatches: + raise TypeError( + "`operands` items must be `{target.__name__}` values, but these items are not: {mismatches}" + ) + + +def _merge(src: JsonSchemaValue, dst: JsonValue, *loc: str) -> None: + origin = get_origin(JsonSchemaValue) + if not isinstance(dst, origin): + raise ValueError(f"`put_properties` merge conflict: `dst[{repr(k)}]` exists but `src` cannot be merged in because `dst` is not a `JsonSchemaValue` (full path: {repr(loc)})") + for k, v in src.items(): + if k not in dst: + dst[k] = v + elif isinstance(v, origin): + _merge(v, dst[k], *loc, k) + elif dst[k] != v: + ValueError(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)})") diff --git a/packages/overture-schema-system/src/overture/schema/system/feature.py b/packages/overture-schema-system/src/overture/schema/system/feature.py index 0e5e9df61..1ba96ee03 100644 --- a/packages/overture-schema-system/src/overture/schema/system/feature.py +++ b/packages/overture-schema-system/src/overture/schema/system/feature.py @@ -1,3 +1,5 @@ +from enum import Enum +from functools import reduce from typing import Any from pydantic import ( @@ -11,11 +13,11 @@ model_serializer, model_validator, ) -from pydantic.json_schema import JsonSchemaValue +from pydantic.json_schema import JsonSchemaValue, JsonValue from pydantic_core import InitErrorDetails, core_schema from typing_extensions import Self -from overture.schema.system._json_schema import put_not +from overture.schema.system import _json_schema from overture.schema.system.optionality import Omitable from overture.schema.system.primitive import BBox, Geometry from overture.schema.system.ref import Id @@ -30,7 +32,7 @@ class Feature(BaseModel): feature may have an `id` field that uniquely identifies it. It may also have a bounding box, which is a simplified geometry that facilitates efficient spatial operations. - Derive a subclass of `Feature` to add new fields with facts about your feature type. + To add new fields with facts about your own feature type, derive a subclass of `Feature`: >>> from typing import Annotated >>> from pydantic import Field @@ -68,7 +70,7 @@ class Feature(BaseModel): } } - Use a geometry type constraint to limit the geometry types allowed on your feature subclass. + To limit the geometry types allowed on your feature subclass, use a geometry type constraint. This can help maximize validation and data integrity by preventing geometries that do not make sense from being stored. @@ -237,79 +239,130 @@ def properties_property_type_error( @classmethod def __get_pydantic_json_schema__( - cls, schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler + cls: type["Feature"], + schema: core_schema.CoreSchema, + handler: GetJsonSchemaHandler, ) -> JsonSchemaValue: """ Generates a JSON Schema that validates the feature as GeoJSON. """ - json_schema = super().__get_pydantic_json_schema__(core_schema, handler) - - # Move all non-GeoJSON properties down into the GeoJSON 'properties' object. - try: - json_schema_top_level_required = json_schema["required"] - except KeyError: - json_schema_top_level_required = [] - json_schema["required"] = json_schema_top_level_required - json_schema_top_level_properties = json_schema["properties"] - geo_json_properties = {} - geo_json_required = [] - - for name in json_schema_top_level_properties.keys(): - if name not in ["id", "bbox", "geometry"]: - value = json_schema_top_level_properties[name] - geo_json_properties[name] = value - del json_schema_top_level_properties[name] - if name in json_schema_top_level_required: - json_schema_top_level_required.remove(name) - geo_json_required.append(name) - - # Create the sub-schema for the GeoJSON 'properties' sub-object. - geo_json_properties_schema = { - "type": "object", - "properties": geo_json_properties, - **({"required": geo_json_required} if geo_json_required else {}), - } - - # Preserve the relevant constraints from the original schema by migrating them into the - # 'properties' sub-schema. - for key in [ - "anyOf", - "allOf", - "oneOf", - "not", - "additionalProperteis", - "unevaluatedProperties", - "patternProperties", - ]: - try: - geo_json_properties_schema[key] = json_schema.pop(key) - except KeyError: - pass - - # Prohibit the core top-level properties from being replicated in the 'properties' + json_schema = handler(schema) + + # FIXME: TODO: vic - delete + # for name in list(top_level_properties_schema.keys()): + # if name not in ["id", "bbox", "geometry"]: + # value = top_level_properties_schema[name] + # properties_object_properties_schema[name] = value + # del top_level_properties_schema[name] + # if name in top_level_required: + # top_level_required.remove(name) + # properties_object_required.append(name) + + # Determine if the schema allows any additional properties apart from the three basic ones, + # "id", "bbox", and "geometry". + may_have_properties = len(cls.model_fields) > 3 or cls.model_config.get("extra", None) != "forbid" + + # If additional properties may be allowed, we have to factor the schema to ensure that all + # properties apart from the basic ones are homed in the Feature object's "properties" # sub-object. - put_not(geo_json_properties_schema, {"required": ["id", "bbox", "geometry"]}) + if may_have_properties: + # Start the schema for the properties sub-object. Ensure the three basic properties, + # "id", "bbox", and "geometry", cannot appear in the properties sub-object. + properties_object_schema = { + "type": "object", + "not": {"required": ["id", "bbox", "geometry"]}, + } + _json_schema.put_properties(json_schema, { "properties": properties_object_schema }) + + # Preserve simple constraints from the original schema by migrating them down into the + # 'properties' sub-schema. + for key in [ + "additionalProperties", + "unevaluatedProperties", + "patternProperties", + ]: + if key in json_schema: + properties_object_schema[key] = json_schema.pop(key) + + # Migrate the remaining sub-schemas into the properties sub-object. + # + # More complex constraints may require factoring the constraint using "JSON Schema + # algebra" if the constraint mixes he three top-level fields ("id", etc.) with fields + # that belong in the properties sub-object. + for key in ["required", "properties", "allOf", "anyOf", "oneOf", "not"]: + if key in json_schema: + _maybe_refactor_schema( + cls, + {key: json_schema.pop(key)}, + json_schema, + properties_object_schema, + ) + if_then_else = {} + _json_schema.try_move("if", json_schema, if_then_else) + _json_schema.try_move("then", json_schema, if_then_else) + _json_schema.try_move("else", json_schema, if_then_else) + if if_then_else: + _maybe_refactor_schema( + cls, if_then_else, json_schema, properties_object_schema + ) - # Insert the sub-schema for the 'properties' sub-object. If 'properties' has no required - # members then we allow it to be `null` in conformance with the GeoJSON specification. - # Otherwise, it must be an object. - if geo_json_required: - json_schema_top_level_properties["properties"] = geo_json_properties_schema - else: - json_schema_top_level_properties["properties"] = { - "anyOf": { - geo_json_properties_schema, - {"type": "null"}, + # Determine if the properties object is allowed to be null or not. We only allow null + # if there are no explicitly required fields. Note that even if we allow null here, it + # might be blocked by conditional schemas, for example if a field is conditionally + # required. + may_null_properties = len(properties_object_schema.get("required", [])) == 0 + + if may_null_properties: + properties_object_schema = { + "anyOf": [ + properties_object_schema, + { "type": "null" }, + ] } + else: + properties_object_schema = { + "anyOf": [ + { + "type": "object", + "maxProperties": 0, + }, + { "type": "null" }, + ] } - json_schema_top_level_required.append("properties") + + # Insert the Feature's properties sub-object schema into the top-level object properties. + top_level_properties_schema = json_schema["properties"] + top_level_properties_schema["properties"] = properties_object_schema + + # Create the sub-schema for the GeoJSON 'properties' sub-object. + # properties_object_schema = { + # "type": "object", + # "properties": properties_object_properties_schema, + # **( + # {"required": properties_object_required} + # if properties_object_required + # else {} + # ), + # "not": {"required": ["id", "bbox", "geometry"]}, + # } + + # Get the top-level required schema. This may not exist, because subclasses of Feature can + # technically eliminate the mandatoriness of the basic fields by redefining them. + try: + top_level_required = json_schema["required"] + except KeyError: + top_level_required = [] + json_schema["required"] = top_level_required # Add `type=Feature` at the top level. - json_schema_top_level_properties["type"] = { + top_level_properties_schema["type"] = { "type": "string", "const": "Feature", } - json_schema_top_level_required.append("type") + top_level_required.insert(0, "type") + + # Make the properties sub-object required, consistent with the GeoJSON format spec. + top_level_required.append("properties") # Do not allow any extra properties in the root JSON object: we want to restrict it only to # the core GeoJSON properties. Any extra fields, if they are allowed by the Pydantic model, @@ -318,3 +371,217 @@ def __get_pydantic_json_schema__( # Return the completed GeoJSON-flavored JSON Schema. return json_schema + + +class _FieldLevel(str, Enum): + UNKNOWN = "unknown" + MIXED = "mixed" + PROPERTIES_OBJECT = "properties" + TOP_LEVEL_OBJECT = "top_level" + + @staticmethod + def classify( + cls: type[Feature], + value: JsonValue, + in_object_properties: bool = False, + *loc: int | str, + ) -> "_FieldLevel": + if isinstance(value, list | tuple): + return reduce( + lambda acc, x: _FieldLevel.combine(acc, x), + [ + _FieldLevel.classify(cls, v, in_object_properties, *loc, i) + for i, v in enumerate(value) + ], + _FieldLevel.UNKNOWN, + ) + elif isinstance(value, str): + return ( + _FieldLevel.TOP_LEVEL_OBJECT + if value in ["id", "bbox", "geometry"] + else _FieldLevel.PROPERTIES_OBJECT + ) + elif not isinstance(value, dict): + return _FieldLevel.UNKNOWN + else: + field_level = _FieldLevel.UNKNOWN + for k, v in value.items(): + if k.startswith("$") or k in { + "default", + "deprecated", + "description", + "examples", + "readOnly", + "writeOnly", + "title", + }: + continue + elif in_object_properties: + new_level = _FieldLevel.classify(cls, k, True, *loc, k) + elif k in ["minProperties"]: + raise ValueError( + f"unsupported JSON Schema keyword {repr(k)} at path {repr(loc)}: the keyword cannot be used at the top level of the `{cls.__name__}` schema" + ) + elif k in { + "allOf", + "anyOf", + "oneOf", + "not", + "if", + "then", + "else", + "required", + }: + new_level = _FieldLevel.classify( + cls, v, in_object_properties, *loc, k + ) + elif k == "properties": + new_level = _FieldLevel.classify(cls, v, True, *loc, "properties") + else: + new_level = _FieldLevel.UNKNOWN + field_level = _FieldLevel.combine(field_level, new_level) + if field_level == _FieldLevel.MIXED: + break + return field_level + + @staticmethod + def combine(a: "_FieldLevel", b: "_FieldLevel") -> "_FieldLevel": + if a == b: + return a + elif a == _FieldLevel.UNKNOWN: + return b + elif b == _FieldLevel.UNKNOWN: + return a + else: + return _FieldLevel.MIXED + + +def _maybe_refactor_schema( + cls: type[Feature], + sub_schema: JsonSchemaValue, + top_level_schema: JsonSchemaValue, + properties_object_schema: JsonSchemaValue, +) -> None: + field_level = _FieldLevel.classify(cls, sub_schema) + + print(f"field_level => {field_level} for sub_schema => {sub_schema}") # TODO - delete - vic + + if field_level == _FieldLevel.PROPERTIES_OBJECT: + _merge_schemas(properties_object_schema, sub_schema) + elif field_level != _FieldLevel.MIXED: + # This is safe because it was taken out of the top level and we are just putting it back now. + top_level_schema |= sub_schema + else: + _refactor_schema(sub_schema) + + print(f"refactored sub_schema => {sub_schema}") # TODO - delete - vic + + _merge_schemas(top_level_schema, sub_schema) + + print(f"merged schema => {top_level_schema}") # TODO - delete - vic + + +def _refactor_schema(schema: JsonSchemaValue) -> None: + for k, v in list(schema.items()): + if k == "properties": + _refactor_properties(schema) + elif k == "required": + _refactor_required(schema) + elif isinstance(v, dict): + _refactor_schema(v) + elif isinstance(v, list): + for item in v: + _refactor_schema(item) + + +def _refactor_properties(schema: JsonSchemaValue) -> None: + properties = schema["properties"] + + lower_properties = {} + for k, v in list(properties.items()): + if k not in [ "id", "bbox", "geometry"]: + lower_properties[k] = v + del properties[k] + + # This is a conceptual nightmare. "k not in the 3 main props" but by this point, I have already + # added the properties object schema so now there are 4. This is going to lead to a mess. The + # properties object schema somehow has to be kept separate. + print(f"LOWER PROPS => {lower_properties}") + + if len(lower_properties) > 0: + try: + properties_object_schema = properties["properties"] + except KeyError: + properties_object_schema = { "type": "object" } + properties["properties"] = properties_object_schema + _json_schema.put_properties(properties_object_schema, lower_properties) + + +def _refactor_required(schema: JsonSchemaValue) -> None: + required = schema.pop("required") + + upper_required = [p for p in required if p in ["id", "bbox", "geometry"]] + if len(upper_required) > 0: + schema["required"] = upper_required + + if len(upper_required) < len(required): + schema_properties = schema.get("properties", {}) + properties_schema = schema_properties.get( + "properties", + { + "type": "object", + }, + ) + properties_schema["required"] = [ + p for p in required if p not in {"id", "bbox", "geometry"} + ] + schema_properties["properties"] = properties_schema + schema["properties"] = schema_properties + + print(f"TODO - Vic - delete - _refactore_required schema => {schema}") + + +def _merge_schemas( + target_schema: JsonSchemaValue, source_schema: JsonSchemaValue +) -> None: + if_then_else = {} + _json_schema.try_move("if", source_schema, if_then_else) + _json_schema.try_move("then", source_schema, if_then_else) + _json_schema.try_move("else", source_schema, if_then_else) + if if_then_else: + _json_schema.put_if(target_schema, if_then_else.get("if", None), if_then_else.get("then", None), if_then_else.get("else", None)) + + table = { + "allOf": lambda json_schema, operand: _json_schema.put_all_of( + json_schema, operand + ), + "anyOf": lambda json_schema, operand: _json_schema.put_any_of( + json_schema, operand + ), + "oneOf": lambda json_schema, operand: _json_schema.put_one_of( + json_schema, operand + ), + "not": lambda json_schema, operand: _json_schema.put_not( + json_schema, operand + ), + "required": lambda json_schema, operand: _json_schema.put_required( + json_schema, operand + ), + "properties": lambda json_schema, operand: _json_schema.put_properties( + json_schema, operand + ) + } + + for k, v in source_schema.items(): + try: + f = table[k] + except KeyError as e: + raise RuntimeError(f"no schema merge mapping for key {repr(k)}") from e + + if k == "properties": + print(f"_merge_schemas BEFORE properties WITH source {source_schema} => ...") # TODO: vic - delete + + f(target_schema, v) + + if k == "properties": + print(f"_merge_schemas AFTER properties => {target_schema['properties']}") # TODO: vic - delete diff --git a/packages/overture-schema-system/tests/test___json_schema.py b/packages/overture-schema-system/tests/test___json_schema.py index 7a898cc97..598b00152 100644 --- a/packages/overture-schema-system/tests/test___json_schema.py +++ b/packages/overture-schema-system/tests/test___json_schema.py @@ -2,7 +2,7 @@ import pytest from pydantic import ConfigDict -from pydantic.json_schema import JsonDict +from pydantic.json_schema import JsonSchemaValue from overture.schema.system._json_schema import ( get_static_json_schema, @@ -11,6 +11,8 @@ put_if, put_not, put_one_of, + put_required, + try_move, ) #################################################################################################### @@ -27,7 +29,7 @@ (ConfigDict(json_schema_extra={"foo": "bar"}), {"foo": "bar"}), ], ) -def test_get_static_json_schema_success(config: ConfigDict, expect: JsonDict) -> None: +def test_get_static_json_schema_success(config: ConfigDict, expect: JsonSchemaValue) -> None: actual = get_static_json_schema(config) assert expect == actual @@ -43,7 +45,7 @@ def test_get_static_json_schema_error_invalid_type() -> None: #################################################################################################### -# test_put_all_of # +# put_all_of # #################################################################################################### @@ -64,23 +66,22 @@ def test_get_static_json_schema_error_invalid_type() -> None: ], ) def test_put_all_of_success( - json_schema: JsonDict, operands: list[JsonDict], expect: JsonDict + json_schema: JsonSchemaValue, operands: list[JsonSchemaValue], expect: JsonSchemaValue ) -> None: put_all_of(json_schema, operands) assert expect == json_schema -@pytest.mark.parametrize("operands", [[], [{}]]) -def test_put_all_of_error_too_few_operands(operands: list[JsonDict]) -> None: - with pytest.raises(ValueError, match="`operands` must have length at least 2"): - put_all_of({}, operands) +def test_put_all_of_error_too_few_operands() -> None: + with pytest.raises(ValueError, match="`operands` cannot be empty"): + put_all_of({}, []) @pytest.mark.parametrize( - "operands", [cast(list[JsonDict], False), [{}, cast(JsonDict, False)]] + "operands", [cast(list[JsonSchemaValue], False), [{}, cast(JsonSchemaValue, False)]] ) -def test_put_all_of_error_bad_type(operands: list[JsonDict]) -> None: +def test_put_all_of_error_bad_type(operands: list[JsonSchemaValue]) -> None: with pytest.raises(TypeError): put_all_of({}, operands) @@ -93,7 +94,7 @@ def test_put_any_of_error_existing_all_of_not_list(): #################################################################################################### -# test_put_any_of # +# put_any_of # #################################################################################################### @@ -122,29 +123,28 @@ def test_put_any_of_error_existing_all_of_not_list(): ], ) def test_put_any_of_success( - json_schema: JsonDict, operands: list[JsonDict], expect: JsonDict + json_schema: JsonSchemaValue, operands: list[JsonSchemaValue], expect: JsonSchemaValue ) -> None: put_any_of(json_schema, operands) assert expect == json_schema -@pytest.mark.parametrize("operands", [[], [{}]]) -def test_put_any_of_error_too_few_operands(operands: list[JsonDict]) -> None: - with pytest.raises(ValueError, match="`operands` must have length at least 2"): - put_any_of({}, operands) +def test_put_any_of_error_too_few_operands() -> None: + with pytest.raises(ValueError, match="`operands` cannot be empty"): + put_any_of({}, []) @pytest.mark.parametrize( - "operands", [cast(list[JsonDict], False), [{}, cast(JsonDict, False)]] + "operands", [cast(list[JsonSchemaValue], False), [{}, cast(JsonSchemaValue, False)]] ) -def test_put_any_of_error_bad_type(operands: list[JsonDict]) -> None: +def test_put_any_of_error_bad_type(operands: list[JsonSchemaValue]) -> None: with pytest.raises(TypeError): put_any_of({}, operands) #################################################################################################### -# test_put_one_of # +# put_one_of # #################################################################################################### @@ -173,29 +173,28 @@ def test_put_any_of_error_bad_type(operands: list[JsonDict]) -> None: ], ) def test_put_one_of_success( - json_schema: JsonDict, operands: list[JsonDict], expect: JsonDict + json_schema: JsonSchemaValue, operands: list[JsonSchemaValue], expect: JsonSchemaValue ) -> None: put_one_of(json_schema, operands) assert expect == json_schema -@pytest.mark.parametrize("operands", [[], [{}]]) -def test_put_one_of_error_too_few_operands(operands: list[JsonDict]) -> None: - with pytest.raises(ValueError, match="`operands` must have length at least 2"): - put_one_of({}, operands) +def test_put_one_of_error_too_few_operands() -> None: + with pytest.raises(ValueError, match="`operands` cannot be empty"): + put_one_of({}, []) @pytest.mark.parametrize( - "operands", [cast(list[JsonDict], False), [{}, cast(JsonDict, False)]] + "operands", [cast(list[JsonSchemaValue], False), [{}, cast(JsonSchemaValue, False)]] ) -def test_put_one_of_error_bad_type(operands: list[JsonDict]) -> None: +def test_put_one_of_error_bad_type(operands: list[JsonSchemaValue]) -> None: with pytest.raises(TypeError): put_one_of({}, operands) #################################################################################################### -# test_put_not # +# put_not # #################################################################################################### @@ -224,7 +223,7 @@ def test_put_one_of_error_bad_type(operands: list[JsonDict]) -> None: ], ) def test_put_not_success( - json_schema: JsonDict, operand: JsonDict, expect: JsonDict + json_schema: JsonSchemaValue, operand: JsonSchemaValue, expect: JsonSchemaValue ) -> None: put_not(json_schema, operand) @@ -233,14 +232,14 @@ def test_put_not_success( def test_put_not_error_invalid_operand() -> None: with pytest.raises( - TypeError, match="`operand` must be a `JsonDict` value, but it is not" + TypeError, match="`operand` must be a `JsonSchemaValue` value, but 123 has type `int`" ): - put_not({}, cast(JsonDict, 123)) + put_not({}, cast(JsonSchemaValue, 123)) def test_put_not_error_invalid_not_value() -> None: with pytest.raises( - ValueError, match='expected value of "not" key to be a `JsonDict`' + ValueError, match='expected value of "not" key to be a `JsonSchemaValue`' ): put_not({"not": []}, {}) @@ -253,7 +252,7 @@ def test_put_not_error_invalid_not_any_of_value() -> None: #################################################################################################### -# test_put_if # +# put_if # #################################################################################################### @@ -331,12 +330,75 @@ def test_put_not_error_invalid_not_any_of_value() -> None: ], ) def test_put_if_success( - json_schema: JsonDict, - condition: JsonDict, - when_true: JsonDict, - when_false: JsonDict, - expect: JsonDict, + json_schema: JsonSchemaValue, + condition: JsonSchemaValue, + when_true: JsonSchemaValue, + when_false: JsonSchemaValue, + expect: JsonSchemaValue, ) -> None: put_if(json_schema, condition, when_true, when_false) assert expect == json_schema + + +#################################################################################################### +# put_required # +#################################################################################################### + +def test_put_required_error_invalid_json_schema(): + with pytest.raises(TypeError, match="`json_schema` must be a `JsonSchemaValue` value, but True has type `bool`"): + put_required(True, ["foo"]) + +@pytest.mark.parametrize("operands,expect_error_type",[ + (42, TypeError), + ([42], TypeError), + ([], ValueError), +]) +def test_put_required_error_invalid_operands(operands: object, expect_error_type: type[Exception]): + with pytest.raises(expect_error_type): + put_required({}, cast(list[str], operands)) + +@pytest.mark.parametrize("json_schema,operands,expect",[ + ({}, ["foo"], {"required":["foo"]}), + ({}, ["foo", "bar"], {"required":["foo", "bar"]}), + ({"required":[]}, ["foo"], {"required":["foo"]}), + ({"required":[]}, ["bar", "foo"], {"required":["bar", "foo"]}), + ({"required":["baz"]}, ["foo"], {"required":["baz", "foo"]}), + ({"required":["baz"]}, ["foo", "baz", "bar", "qux"], {"required":["baz", "foo", "bar", "qux"]}), + ({"required":["qux", "corge"]}, ["baz", "bar", "qux"], {"required":["qux", "corge", "baz", "bar"]}), +]) +def test_put_required_success(json_schema: JsonSchemaValue, operands: list[str], expect: JsonSchemaValue): + put_required(json_schema, operands) + + assert expect == json_schema + + +#################################################################################################### +# put_properties # +#################################################################################################### + +# todo - vic + +#################################################################################################### +# try_move # +#################################################################################################### + + +def test_try_move_existing_key(): + src = {"foo": "bar"} + dst = {} + + try_move("foo", src, dst) + + assert {} == src + assert {"foo": "bar"} == dst + + +def test_try_move_missing_key(): + src = {"foo": "bar"} + dst = {} + + try_move("baz", src, dst) + + assert {"foo": "bar"} == src + assert {} == dst diff --git a/packages/overture-schema-system/tests/test_feature.py b/packages/overture-schema-system/tests/test_feature.py index ca92e1d4d..4b10e54f7 100644 --- a/packages/overture-schema-system/tests/test_feature.py +++ b/packages/overture-schema-system/tests/test_feature.py @@ -1,12 +1,33 @@ import json import re +import sys +from copy import deepcopy +from pathlib import Path +from typing import Annotated import pytest from pydantic import ConfigDict, ValidationError, create_model +from pydantic.json_schema import JsonSchemaValue, JsonValue -from overture.schema.system.feature import Feature +from overture.schema.system.feature import Feature, _FieldLevel, _maybe_refactor_schema +from overture.schema.system.model_constraint import ( + FieldEqCondition, + forbid_if, + min_fields_set, + require_any_of, + require_if, +) from overture.schema.system.optionality import Omitable -from overture.schema.system.primitive import BBox, Geometry +from overture.schema.system.primitive import ( + BBox, + Geometry, + GeometryType, + GeometryTypeConstraint, +) + +sys.path.insert(0, str(Path(__file__).parent.parent)) # Needed to import `util` module. + +from util import assert_subset class TestSerializeModel: @@ -607,5 +628,664 @@ class SubFeature(Feature): class TestJsonSchema: - def test_json_schema(self): - assert False, "todo: this" + def test_simple_json_schema(self): + expect = { + "title": "Feature", + "type": "object", + "required": [ + "type", + "geometry", + "properties", + ], + "additionalProperties": False, + "properties": { + "id": { + "type": "string", + }, + "bbox": { + "type": "array", + }, + "geometry": {}, + "properties": { + "anyOf": [ + { + "type": "object", + "not": {"required": ["id", "bbox", "geometry"]}, + }, + { + "type": "null", + }, + ], + }, + "type": {"const": "Feature", "type": "string"}, + }, + } + + actual = Feature.model_json_schema() + print(json.dumps(actual, indent=2)) + + assert_subset(expect, actual, "expect", "actual") + + def test_subclass_new_fields(self): + class SubFeature(Feature): + foo: Omitable[int] + bar: str | None = None + baz: float + + expect = { + "title": "SubFeature", + "type": "object", + "required": [ + "type", + "geometry", + "properties", + ], + "additionalProperties": False, + "properties": { + "id": { + "type": "string", + }, + "bbox": { + "type": "array", + }, + "geometry": {}, + "properties": { + "type": "object", + "required": ["baz"], + "not": {"required": ["id", "bbox", "geometry"]}, + "properties": { + "foo": { + "type": "integer", + }, + "bar": { + "anyOf": [ + {"type": "string"}, + {"type": "null"}, + ], + "default": None, + }, + "baz": {"type": "number"}, + }, + }, + "type": {"const": "Feature", "type": "string"}, + }, + } + + actual = SubFeature.model_json_schema() + print(json.dumps(actual, indent=2)) + + assert_subset(expect, actual, "expect", "actual") + + def test_subclass_make_required_fields_not_required(self): + assert False + + def test_subclass_geometry_type_constraint(self): + class PointFeature(Feature): + geometry: Annotated[Geometry, GeometryTypeConstraint(GeometryType.POINT)] + + expect = { + "title": "PointFeature", + "type": "object", + "required": [ + "geometry", + "properties", + "type", + ], + "additionalProperties": False, + "properties": { + "id": { + "type": "string", + }, + "bbox": { + "type": "array", + }, + "geometry": { + "type": "object", + "required": ["type", "coordinates"], + "properties": { + "type": { + "type": "string", + "const": "Point", + }, + "coordinates": { + "type": "array", + "items": { + "type": "number", + }, + "minItems": 2, + "maxItems": 3, + }, + }, + }, + "properties": { + "anyOf": [ + { + "type": "object", + "not": {"required": ["id", "bbox", "geometry"]}, + "properties": {}, + }, + { + "type": "null", + }, + ], + }, + "type": {"const": "Feature", "type": "string"}, + }, + } + + actual = PointFeature.model_json_schema() + print(json.dumps(actual, indent=2)) + + assert_subset(expect, actual, "expect", "actual") + + def test_forbid_extra_fields(self): + class SubFeature(Feature): + model_config = ConfigDict(extra="forbid") + + expect = { + "title": "SubFeature", + "type": "object", + "required": [ + "geometry", + "properties", + "type", + ], + "properties": { + "id": { + "type": "string", + }, + "bbox": { + "type": "array", + }, + "geometry": {}, + "properties": { + "anyOf": [ + { + "type": "object", + "not": {"required": ["id", "bbox", "geometry"]}, + "additionalProperties": False, + "properties": {}, + }, + { + "type": "null", + }, + ], + }, + "type": {"const": "Feature", "type": "string"}, + }, + } + + actual = SubFeature.model_json_schema() + print(json.dumps(actual, indent=2)) + + assert_subset(expect, actual, "expect", "actual") + + def test_unsupported_keyword(self): + assert False + + def test_model_constraint_top_level_only(self): + assert False + + def test_model_constraint_properties_object_only(self): + assert False + + def test_model_constraint_mixed(self): + @forbid_if(["foo"], FieldEqCondition("qux", "ban.foo")) + @require_if(["id", "foo", "qux"], FieldEqCondition("corge", 42)) + @require_any_of("bbox", "foo", "garply") + class SubFeature(Feature): + foo: Omitable[bool] + bar: bool + baz: bool + qux: Omitable[str] + corge: int + garply: Omitable[bool] + + expect = { + "type": "object", + "required": [ + "geometry", + "properties", + "type", + ], + "anyOf": [ + {"required": ["bbox"]}, + { + "properties": { + "properties": { + "type": "object", + "required": ["foo"], + } + }, + }, + { + "properties": { + "properties": { + "type": "object", + "required": ["garply"], + }, + }, + }, + ], + "allOf": [ + { + "if": { + "properties": { + "properties": { + "type": "object", + "properties": { + "corge": { + "const": 42, + }, + }, + } + } + }, + "then": { + "required": ["id"], + "properties": { + "properties": { + "type": "object", + "required": ["foo", "qux"], + }, + }, + }, + }, + { + "if": { + "properties": { + "properties": { + "type": "object", + "properties": { + "qux": { + "const": "ban.foo", + }, + }, + } + }, + }, + "then": { + "not": { + "properties": { + "properties": { + "type": "object", + "required": ["foo"], + } + } + }, + }, + }, + ], + "properties": { + "id": { + "type": "string", + }, + "bbox": { + "type": "array", + }, + "geometry": {}, + "properties": { + "type": "object", + "required": ["bar", "baz", "corge"], + "properties": { + "foo": { + "type": "boolean", + }, + "bar": { + "type": "boolean", + }, + "baz": { + "type": "boolean", + }, + "qux": { + "type": "string", + }, + "corge": { + "type": "integer", + }, + "garply": { + "type": "boolean", + }, + }, + }, + "type": {"const": "Feature", "type": "string"}, + }, + } + + actual = SubFeature.model_json_schema() + print(json.dumps(actual, indent=2)) + + assert_subset(expect, actual, "expect", "actual") + assert False + + +class Test_FieldLevel: + @pytest.mark.parametrize( + "value", + [ + True, + False, + {}, + {"$comment": "foo"}, + {"default": "bar"}, + {"deprecated": "baz"}, + {"description": "qux"}, + {"examples": "corge"}, + {"examples": {}}, + {"readOnly": True}, + {"writeOnly": True}, + {"title": "garply"}, + {"properties": {}}, + {"required": []}, + {"allOf": []}, + {"anyOf": []}, + {"oneOf": []}, + {"not": {}}, + {"if": {}}, + {"then": {}}, + {"else": {}}, + { + "allOf": [ + {"required": []}, + {"properties": {}}, + ] + }, + { + "anyOf": [ + {"required": []}, + {"properties": {}}, + ] + }, + { + "anyOf": [ + {"required": []}, + {"properties": {}}, + ] + }, + { + "oneOf": [ + {"required": []}, + {"properties": {}}, + ] + }, + { + "not": [ + {"required": []}, + {"properties": {}}, + ] + }, + { + "if": [ + {"required": []}, + {"properties": {}}, + ] + }, + { + "then": [ + {"required": []}, + {"properties": {}}, + ] + }, + { + "else": [ + {"required": []}, + {"properties": {}}, + ] + }, + ], + ) + def test_classify_unknown(self, value: JsonValue) -> None: + actual = _FieldLevel.classify(Feature, value) + + assert _FieldLevel.UNKNOWN == actual + + @pytest.mark.parametrize( + "value", + [ + ["id", "foo"], + ["bbox", "foo"], + ["geometry", "foo"], + ["bar", "baz", "id", "qux", "geometry", "bbox"], + {"required": ["id", "foo"]}, + { + "properties": { + "foo": {}, + "bbox": {}, + } + }, + { + "required": ["id"], + "properties": { + "foo": {}, + }, + }, + {"allOf": [{"required": ["id", "foo"]}]}, + {"anyOf": [{"required": ["id", "foo"]}]}, + {"oneOf": [{"required": ["id", "foo"]}]}, + {"not": {"required": ["id", "foo"]}}, + {"if": {"required": ["id", "foo"]}}, + {"then": {"required": ["id", "foo"]}}, + {"else": {"required": ["id", "foo"]}}, + ], + ) + def test_classify_mixed(self, value: JsonValue) -> None: + actual = _FieldLevel.classify(Feature, value) + + assert _FieldLevel.MIXED == actual + + @pytest.mark.parametrize( + "value", + [ + "foo", + "properties", + ["foo"], + ("properties",), + ["foo", "bar", "properties"], + {"required": ["foo"]}, + {"required": ["properties"]}, + {"required": ["foo", "properties"]}, + { + "properties": { + "foo": {"type": "object", "required": ["id", "bbox"]}, + } + }, + { + "required": ["foo", "bar"], + "properties": { + "properties": {}, + "baz": {}, + }, + }, + {"allOf": [{"required": ["foo"]}]}, # This one? + {"anyOf": [{"required": ["foo"]}]}, + {"oneOf": [{"required": ["foo"]}]}, + {"not": {"required": ["foo"]}}, + {"if": {"required": ["foo"]}}, + {"then": {"required": ["foo"]}}, + {"else": {"required": ["foo"]}}, + ], + ) + def test_classify_properties_object(self, value: JsonValue) -> None: + actual = _FieldLevel.classify(Feature, value) + + assert _FieldLevel.PROPERTIES_OBJECT == actual + + @pytest.mark.parametrize( + "value", + [ + "id", + "bbox", + "geometry", + ["id"], + ("bbox",), + ["geometry"], + ("id", "bbox", "geometry"), + {"required": ["id"]}, + {"required": ["bbox"]}, + {"required": ["bbox", "geometry"]}, + { + "properties": { + "bbox": {"type": "object", "required": ["foo", "bar"]}, + } + }, + { + "required": ["id", "bbox"], + "properties": { + "id": {}, + "geometry": {}, + }, + }, + {"allOf": [{"required": ["id"]}]}, + {"anyOf": [{"required": ["bbox"]}]}, + {"oneOf": [{"required": ["geometry"]}]}, + {"not": {"required": ["id", "bbox"]}}, + {"if": {"required": ["bbox", "geometry"]}}, + {"then": {"required": ["id"]}}, + {"else": {"required": ["id"]}}, + ], + ) + def test_classify_top_level_object(self, value: JsonValue) -> None: + actual = _FieldLevel.classify(Feature, value) + + assert _FieldLevel.TOP_LEVEL_OBJECT == actual + + def test_classify_error_unsupported_keyword_deep(self) -> None: + _ = _FieldLevel.classify( + Feature, + { + "properties": { + "foo": { + "type": "object", + "minProperties": 1, + }, + "bbox": { + "type": "object", + "minProperties": 2, + }, + } + }, + ) + + @pytest.mark.parametrize( + "value", + [ + {"minProperties": 1}, + {"allOf": [{"minProperties": 2}]}, + {"anyOf": [{"minProperties": 2}]}, + {"oneOf": [{"minProperties": 2}]}, + {"not": {"minProperties": 2}}, + {"allOf": [{"not": {"minProperties": 2}}]}, + {"anyOf": [{"not": {"minProperties": 2}}]}, + {"oneOf": [{"not": {"minProperties": 2}}]}, + ], + ) + def test_classify_error_unsupported_keyword_shallow(self, value: JsonValue) -> None: + with pytest.raises(ValueError, match="unsupported JSON Schema keyword '\\w+'"): + _FieldLevel.classify(Feature, value) + + +class TestRefactoring: + @pytest.mark.parametrize( + "sub_schema,top_level_schema,properties_object_schema,expect_top_level_schema,expect_properties_object_schema", + [ + ({}, {}, {}, None, None), + ( + { "required": ["id"] }, + { }, + { }, + { "required": ["id"] }, + None, + ), + ( + { "required": ["foo"] }, + { }, + { }, + None, + { "required": ["foo"] }, + ), + ( + { "required": ["id", "foo"] }, + { }, + { }, + { + "required": ["id"], + "properties": { + "properties": { + "type": "object", + "required": ["foo"] + } + } + }, + None, + ), + ( + { + "anyOf": [ + { "required": ["foo"] }, + { "required": ["bar"] }, + ] + }, + { }, + { }, + None, + { + "anyOf": [ + { "required": ["foo"] }, + { "required": ["bar"] }, + ] + }, + ), + ( + { + "anyOf": [ + { "required": ["id"] }, + { "required": ["foo"] }, + ] + }, + { }, + { }, + { + "anyOf": [ + { "required": ["id"] }, + { + "properties": { + "properties": { + "type": "object", + "required": ["foo"], + } + } + } + ] + }, + None, + ), + ], + ) + def test_maybe_refactor_schema( + self, + sub_schema: JsonSchemaValue, + top_level_schema: JsonSchemaValue, + properties_object_schema: JsonSchemaValue, + expect_top_level_schema: JsonSchemaValue | None, + expect_properties_object_schema: JsonSchemaValue | None, + ) -> None: + _sub_schema = deepcopy(sub_schema) + _top_level_schema = deepcopy(top_level_schema) + _properties_object_schema = deepcopy(properties_object_schema) + + _maybe_refactor_schema( + Feature, _sub_schema, _top_level_schema, _properties_object_schema + ) + + print(f"_top_level_schema => {repr(_top_level_schema)}") + print(f"_properties_object_schema => {repr(_properties_object_schema)}") + + if expect_top_level_schema: + assert expect_top_level_schema == _top_level_schema + else: + assert top_level_schema == _top_level_schema + + if expect_properties_object_schema: + assert expect_properties_object_schema == _properties_object_schema + else: + assert properties_object_schema == _properties_object_schema From af29a355b404afff4d3f9a94ff890933fe35cb53 Mon Sep 17 00:00:00 2001 From: schapper Date: Thu, 16 Oct 2025 10:51:08 -0700 Subject: [PATCH 13/19] wip - [CHECKPOINT] - Feature tests finally passing but make check still messed --- .../overture/schema/system/_json_schema.py | 48 +- .../src/overture/schema/system/feature.py | 139 +++-- .../tests/test___json_schema.py | 77 ++- .../tests/test_feature.py | 497 ++++++++++++++++-- packages/overture-schema-system/tests/util.py | 41 +- 5 files changed, 630 insertions(+), 172 deletions(-) diff --git a/packages/overture-schema-system/src/overture/schema/system/_json_schema.py b/packages/overture-schema-system/src/overture/schema/system/_json_schema.py index e920d9daa..9c4340db4 100644 --- a/packages/overture-schema-system/src/overture/schema/system/_json_schema.py +++ b/packages/overture-schema-system/src/overture/schema/system/_json_schema.py @@ -27,7 +27,7 @@ def get_static_json_schema(config: ConfigDict) -> JsonSchemaValue: def put_all_of(json_schema: JsonSchemaValue, operands: list[JsonSchemaValue]) -> None: - _verify_json_schema_value(('json_schema', json_schema)) + _verify_json_schema_value(("json_schema", json_schema)) _verify_operands_not_empty(JsonSchemaValue, operands) if "allOf" not in json_schema: json_schema["allOf"] = cast(JsonValue, operands) @@ -42,7 +42,7 @@ def put_all_of(json_schema: JsonSchemaValue, operands: list[JsonSchemaValue]) -> def put_any_of(json_schema: JsonSchemaValue, operands: list[JsonSchemaValue]) -> None: - _verify_json_schema_value(('json_schema', json_schema)) + _verify_json_schema_value(("json_schema", json_schema)) _verify_operands_not_empty(JsonSchemaValue, operands) prev: JsonSchemaValue = {} try_move("anyOf", json_schema, prev) @@ -53,7 +53,7 @@ def put_any_of(json_schema: JsonSchemaValue, operands: list[JsonSchemaValue]) -> def put_one_of(json_schema: JsonSchemaValue, operands: list[JsonSchemaValue]) -> None: - _verify_json_schema_value(('json_schema', json_schema)) + _verify_json_schema_value(("json_schema", json_schema)) _verify_operands_not_empty(JsonSchemaValue, operands) prev: JsonSchemaValue = {} try_move("oneOf", json_schema, prev) @@ -64,7 +64,7 @@ def put_one_of(json_schema: JsonSchemaValue, operands: list[JsonSchemaValue]) -> def put_not(json_schema: JsonSchemaValue, operand: JsonSchemaValue) -> None: - _verify_json_schema_value(('json_schema', json_schema), ('operand', operand)) + _verify_json_schema_value(("json_schema", json_schema), ("operand", operand)) prev: JsonSchemaValue = {} try_move("not", json_schema, prev) @@ -106,13 +106,13 @@ def put_if( when_true: JsonSchemaValue | None, when_false: JsonSchemaValue | None = None, ) -> None: - _verify_json_schema_value(('json_schema', json_schema)) + _verify_json_schema_value(("json_schema", json_schema)) if condition is not None: - _verify_json_schema_value(('condition', condition)) + _verify_json_schema_value(("condition", condition)) if when_true is not None: - _verify_json_schema_value(('when_true', when_true)) + _verify_json_schema_value(("when_true", when_true)) if when_false is not None: - _verify_json_schema_value(('when_false', when_false)) + _verify_json_schema_value(("when_false", when_false)) prev: JsonSchemaValue = {} try_move("if", json_schema, prev) @@ -134,11 +134,8 @@ def _put(dst: JsonSchemaValue) -> JsonSchemaValue: put_all_of(json_schema, [prev, _put({})]) -def put_required( - json_schema: JsonSchemaValue, - operands: list[str] -) -> None: - _verify_json_schema_value(('json_schema', json_schema)) +def put_required(json_schema: JsonSchemaValue, operands: list[str]) -> None: + _verify_json_schema_value(("json_schema", json_schema)) _verify_operands_not_empty(str, operands) if "required" in json_schema: required = json_schema["required"] @@ -149,10 +146,12 @@ def put_required( def put_properties( - json_schema: JsonSchemaValue, - new_properties: JsonSchemaValue, + json_schema: JsonSchemaValue, + new_properties: JsonSchemaValue, ) -> None: - _verify_json_schema_value(('json_schema', json_schema), ('new_properties', new_properties)) + _verify_json_schema_value( + ("json_schema", json_schema), ("new_properties", new_properties) + ) if "properties" in json_schema: properties = json_schema["properties"] else: @@ -173,13 +172,18 @@ def try_move(key: str, src: JsonSchemaValue, dst: JsonSchemaValue) -> None: except KeyError: pass -T = TypeVar('T', JsonSchemaValue, str) + +T = TypeVar("T", JsonSchemaValue, str) + def _verify_json_schema_value(*candidates: tuple[str, JsonSchemaValue]) -> None: origin = get_origin(JsonSchemaValue) for target in candidates: if not isinstance(target[1], origin): - raise TypeError(f"`{target[0]}` must be a `JsonSchemaValue` value, but {repr(target[1])} has type `{type(target[1]).__name__}`") + raise TypeError( + f"`{target[0]}` must be a `JsonSchemaValue` value, but {repr(target[1])} has type `{type(target[1]).__name__}`" + ) + def _verify_operands_not_empty(tp: T, operands: list[T]) -> None: if not isinstance(operands, list): @@ -203,11 +207,15 @@ def _verify_operands_not_empty(tp: T, operands: list[T]) -> None: def _merge(src: JsonSchemaValue, dst: JsonValue, *loc: str) -> None: origin = get_origin(JsonSchemaValue) if not isinstance(dst, origin): - raise ValueError(f"`put_properties` merge conflict: `dst[{repr(k)}]` exists but `src` cannot be merged in because `dst` is not a `JsonSchemaValue` (full path: {repr(loc)})") + raise ValueError( + 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__}`)" + ) for k, v in src.items(): if k not in dst: dst[k] = v elif isinstance(v, origin): _merge(v, dst[k], *loc, k) elif dst[k] != v: - ValueError(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)})") + ValueError( + 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)})" + ) diff --git a/packages/overture-schema-system/src/overture/schema/system/feature.py b/packages/overture-schema-system/src/overture/schema/system/feature.py index 1ba96ee03..d1bfc4666 100644 --- a/packages/overture-schema-system/src/overture/schema/system/feature.py +++ b/packages/overture-schema-system/src/overture/schema/system/feature.py @@ -248,19 +248,38 @@ def __get_pydantic_json_schema__( """ json_schema = handler(schema) - # FIXME: TODO: vic - delete - # for name in list(top_level_properties_schema.keys()): - # if name not in ["id", "bbox", "geometry"]: - # value = top_level_properties_schema[name] - # properties_object_properties_schema[name] = value - # del top_level_properties_schema[name] - # if name in top_level_required: - # top_level_required.remove(name) - # properties_object_required.append(name) + top_level_required = json_schema.get("required", []) + top_level_properties = json_schema["properties"] # Determine if the schema allows any additional properties apart from the three basic ones, # "id", "bbox", and "geometry". - may_have_properties = len(cls.model_fields) > 3 or cls.model_config.get("extra", None) != "forbid" + may_have_properties = ( + any( + f + for f in cls.model_fields.keys() + if f not in ["id", "bbox", "geometry"] + ) + or cls.model_config.get("extra", None) != "forbid" + ) + + # Migrate any top-level properties that aren't part of the three basic ones, "id", "bbox", + # and "geometry", down into the GeoJSON feature's "properties" object. + if may_have_properties: + properties_object_properties = {} + properties_object_required = [] + for name in list(top_level_properties.keys()): + if name not in ["id", "bbox", "geometry"]: + properties_object_properties[name] = top_level_properties.pop(name) + if name in top_level_required: + top_level_required.remove(name) + properties_object_required.append(name) + + # Add `type=Feature` at the top level. + top_level_properties["type"] = { + "type": "string", + "const": "Feature", + } + top_level_required.insert(0, "type") # If additional properties may be allowed, we have to factor the schema to ensure that all # properties apart from the basic ones are homed in the Feature object's "properties" @@ -270,9 +289,18 @@ def __get_pydantic_json_schema__( # "id", "bbox", and "geometry", cannot appear in the properties sub-object. properties_object_schema = { "type": "object", + **( + {"required": properties_object_required} + if properties_object_required + else {} + ), "not": {"required": ["id", "bbox", "geometry"]}, + **( + {"properties": properties_object_properties} + if properties_object_properties + else {} + ), } - _json_schema.put_properties(json_schema, { "properties": properties_object_schema }) # Preserve simple constraints from the original schema by migrating them down into the # 'properties' sub-schema. @@ -289,7 +317,14 @@ def __get_pydantic_json_schema__( # More complex constraints may require factoring the constraint using "JSON Schema # algebra" if the constraint mixes he three top-level fields ("id", etc.) with fields # that belong in the properties sub-object. - for key in ["required", "properties", "allOf", "anyOf", "oneOf", "not"]: + for key in [ + "allOf", + "anyOf", + "oneOf", + "not", + "minProperties", + "maxProperties", + ]: if key in json_schema: _maybe_refactor_schema( cls, @@ -311,12 +346,11 @@ def __get_pydantic_json_schema__( # might be blocked by conditional schemas, for example if a field is conditionally # required. may_null_properties = len(properties_object_schema.get("required", [])) == 0 - if may_null_properties: properties_object_schema = { "anyOf": [ properties_object_schema, - { "type": "null" }, + {"type": "null"}, ] } else: @@ -326,44 +360,21 @@ def __get_pydantic_json_schema__( "type": "object", "maxProperties": 0, }, - { "type": "null" }, + {"type": "null"}, ] } # Insert the Feature's properties sub-object schema into the top-level object properties. - top_level_properties_schema = json_schema["properties"] - top_level_properties_schema["properties"] = properties_object_schema - - # Create the sub-schema for the GeoJSON 'properties' sub-object. - # properties_object_schema = { - # "type": "object", - # "properties": properties_object_properties_schema, - # **( - # {"required": properties_object_required} - # if properties_object_required - # else {} - # ), - # "not": {"required": ["id", "bbox", "geometry"]}, - # } - - # Get the top-level required schema. This may not exist, because subclasses of Feature can - # technically eliminate the mandatoriness of the basic fields by redefining them. - try: - top_level_required = json_schema["required"] - except KeyError: - top_level_required = [] - json_schema["required"] = top_level_required - - # Add `type=Feature` at the top level. - top_level_properties_schema["type"] = { - "type": "string", - "const": "Feature", - } - top_level_required.insert(0, "type") + top_level_properties["properties"] = properties_object_schema # Make the properties sub-object required, consistent with the GeoJSON format spec. top_level_required.append("properties") + # Store the top-level required schema. This may be empty, because subclasses of Feature can + # technically eliminate the mandatoriness of the basic fields by redefining them. + if top_level_required: + json_schema["required"] = top_level_required + # Do not allow any extra properties in the root JSON object: we want to restrict it only to # the core GeoJSON properties. Any extra fields, if they are allowed by the Pydantic model, # are allowed within the 'properties' sub-object. @@ -418,7 +429,7 @@ def classify( continue elif in_object_properties: new_level = _FieldLevel.classify(cls, k, True, *loc, k) - elif k in ["minProperties"]: + elif k in ["minProperties", "maxProperties"]: raise ValueError( f"unsupported JSON Schema keyword {repr(k)} at path {repr(loc)}: the keyword cannot be used at the top level of the `{cls.__name__}` schema" ) @@ -464,8 +475,6 @@ def _maybe_refactor_schema( ) -> None: field_level = _FieldLevel.classify(cls, sub_schema) - print(f"field_level => {field_level} for sub_schema => {sub_schema}") # TODO - delete - vic - if field_level == _FieldLevel.PROPERTIES_OBJECT: _merge_schemas(properties_object_schema, sub_schema) elif field_level != _FieldLevel.MIXED: @@ -473,13 +482,8 @@ def _maybe_refactor_schema( top_level_schema |= sub_schema else: _refactor_schema(sub_schema) - - print(f"refactored sub_schema => {sub_schema}") # TODO - delete - vic - _merge_schemas(top_level_schema, sub_schema) - print(f"merged schema => {top_level_schema}") # TODO - delete - vic - def _refactor_schema(schema: JsonSchemaValue) -> None: for k, v in list(schema.items()): @@ -499,20 +503,15 @@ def _refactor_properties(schema: JsonSchemaValue) -> None: lower_properties = {} for k, v in list(properties.items()): - if k not in [ "id", "bbox", "geometry"]: + if k not in ["id", "bbox", "geometry"]: lower_properties[k] = v del properties[k] - # This is a conceptual nightmare. "k not in the 3 main props" but by this point, I have already - # added the properties object schema so now there are 4. This is going to lead to a mess. The - # properties object schema somehow has to be kept separate. - print(f"LOWER PROPS => {lower_properties}") - if len(lower_properties) > 0: try: properties_object_schema = properties["properties"] except KeyError: - properties_object_schema = { "type": "object" } + properties_object_schema = {"type": "object"} properties["properties"] = properties_object_schema _json_schema.put_properties(properties_object_schema, lower_properties) @@ -538,8 +537,6 @@ def _refactor_required(schema: JsonSchemaValue) -> None: schema_properties["properties"] = properties_schema schema["properties"] = schema_properties - print(f"TODO - Vic - delete - _refactore_required schema => {schema}") - def _merge_schemas( target_schema: JsonSchemaValue, source_schema: JsonSchemaValue @@ -549,7 +546,12 @@ def _merge_schemas( _json_schema.try_move("then", source_schema, if_then_else) _json_schema.try_move("else", source_schema, if_then_else) if if_then_else: - _json_schema.put_if(target_schema, if_then_else.get("if", None), if_then_else.get("then", None), if_then_else.get("else", None)) + _json_schema.put_if( + target_schema, + if_then_else.get("if", None), + if_then_else.get("then", None), + if_then_else.get("else", None), + ) table = { "allOf": lambda json_schema, operand: _json_schema.put_all_of( @@ -561,15 +563,13 @@ def _merge_schemas( "oneOf": lambda json_schema, operand: _json_schema.put_one_of( json_schema, operand ), - "not": lambda json_schema, operand: _json_schema.put_not( - json_schema, operand - ), + "not": lambda json_schema, operand: _json_schema.put_not(json_schema, operand), "required": lambda json_schema, operand: _json_schema.put_required( json_schema, operand ), "properties": lambda json_schema, operand: _json_schema.put_properties( json_schema, operand - ) + ), } for k, v in source_schema.items(): @@ -577,11 +577,4 @@ def _merge_schemas( f = table[k] except KeyError as e: raise RuntimeError(f"no schema merge mapping for key {repr(k)}") from e - - if k == "properties": - print(f"_merge_schemas BEFORE properties WITH source {source_schema} => ...") # TODO: vic - delete - f(target_schema, v) - - if k == "properties": - print(f"_merge_schemas AFTER properties => {target_schema['properties']}") # TODO: vic - delete diff --git a/packages/overture-schema-system/tests/test___json_schema.py b/packages/overture-schema-system/tests/test___json_schema.py index 598b00152..d7ac810d2 100644 --- a/packages/overture-schema-system/tests/test___json_schema.py +++ b/packages/overture-schema-system/tests/test___json_schema.py @@ -29,7 +29,9 @@ (ConfigDict(json_schema_extra={"foo": "bar"}), {"foo": "bar"}), ], ) -def test_get_static_json_schema_success(config: ConfigDict, expect: JsonSchemaValue) -> None: +def test_get_static_json_schema_success( + config: ConfigDict, expect: JsonSchemaValue +) -> None: actual = get_static_json_schema(config) assert expect == actual @@ -66,7 +68,9 @@ def test_get_static_json_schema_error_invalid_type() -> None: ], ) def test_put_all_of_success( - json_schema: JsonSchemaValue, operands: list[JsonSchemaValue], expect: JsonSchemaValue + json_schema: JsonSchemaValue, + operands: list[JsonSchemaValue], + expect: JsonSchemaValue, ) -> None: put_all_of(json_schema, operands) @@ -123,7 +127,9 @@ def test_put_any_of_error_existing_all_of_not_list(): ], ) def test_put_any_of_success( - json_schema: JsonSchemaValue, operands: list[JsonSchemaValue], expect: JsonSchemaValue + json_schema: JsonSchemaValue, + operands: list[JsonSchemaValue], + expect: JsonSchemaValue, ) -> None: put_any_of(json_schema, operands) @@ -173,7 +179,9 @@ def test_put_any_of_error_bad_type(operands: list[JsonSchemaValue]) -> None: ], ) def test_put_one_of_success( - json_schema: JsonSchemaValue, operands: list[JsonSchemaValue], expect: JsonSchemaValue + json_schema: JsonSchemaValue, + operands: list[JsonSchemaValue], + expect: JsonSchemaValue, ) -> None: put_one_of(json_schema, operands) @@ -232,7 +240,8 @@ def test_put_not_success( def test_put_not_error_invalid_operand() -> None: with pytest.raises( - TypeError, match="`operand` must be a `JsonSchemaValue` value, but 123 has type `int`" + TypeError, + match="`operand` must be a `JsonSchemaValue` value, but 123 has type `int`", ): put_not({}, cast(JsonSchemaValue, 123)) @@ -345,29 +354,53 @@ def test_put_if_success( # put_required # #################################################################################################### + def test_put_required_error_invalid_json_schema(): - with pytest.raises(TypeError, match="`json_schema` must be a `JsonSchemaValue` value, but True has type `bool`"): + with pytest.raises( + TypeError, + match="`json_schema` must be a `JsonSchemaValue` value, but True has type `bool`", + ): put_required(True, ["foo"]) -@pytest.mark.parametrize("operands,expect_error_type",[ - (42, TypeError), - ([42], TypeError), - ([], ValueError), -]) -def test_put_required_error_invalid_operands(operands: object, expect_error_type: type[Exception]): + +@pytest.mark.parametrize( + "operands,expect_error_type", + [ + (42, TypeError), + ([42], TypeError), + ([], ValueError), + ], +) +def test_put_required_error_invalid_operands( + operands: object, expect_error_type: type[Exception] +): with pytest.raises(expect_error_type): put_required({}, cast(list[str], operands)) -@pytest.mark.parametrize("json_schema,operands,expect",[ - ({}, ["foo"], {"required":["foo"]}), - ({}, ["foo", "bar"], {"required":["foo", "bar"]}), - ({"required":[]}, ["foo"], {"required":["foo"]}), - ({"required":[]}, ["bar", "foo"], {"required":["bar", "foo"]}), - ({"required":["baz"]}, ["foo"], {"required":["baz", "foo"]}), - ({"required":["baz"]}, ["foo", "baz", "bar", "qux"], {"required":["baz", "foo", "bar", "qux"]}), - ({"required":["qux", "corge"]}, ["baz", "bar", "qux"], {"required":["qux", "corge", "baz", "bar"]}), -]) -def test_put_required_success(json_schema: JsonSchemaValue, operands: list[str], expect: JsonSchemaValue): + +@pytest.mark.parametrize( + "json_schema,operands,expect", + [ + ({}, ["foo"], {"required": ["foo"]}), + ({}, ["foo", "bar"], {"required": ["foo", "bar"]}), + ({"required": []}, ["foo"], {"required": ["foo"]}), + ({"required": []}, ["bar", "foo"], {"required": ["bar", "foo"]}), + ({"required": ["baz"]}, ["foo"], {"required": ["baz", "foo"]}), + ( + {"required": ["baz"]}, + ["foo", "baz", "bar", "qux"], + {"required": ["baz", "foo", "bar", "qux"]}, + ), + ( + {"required": ["qux", "corge"]}, + ["baz", "bar", "qux"], + {"required": ["qux", "corge", "baz", "bar"]}, + ), + ], +) +def test_put_required_success( + json_schema: JsonSchemaValue, operands: list[str], expect: JsonSchemaValue +): put_required(json_schema, operands) assert expect == json_schema diff --git a/packages/overture-schema-system/tests/test_feature.py b/packages/overture-schema-system/tests/test_feature.py index 4b10e54f7..383814ac0 100644 --- a/packages/overture-schema-system/tests/test_feature.py +++ b/packages/overture-schema-system/tests/test_feature.py @@ -632,13 +632,14 @@ def test_simple_json_schema(self): expect = { "title": "Feature", "type": "object", + "additionalProperties": False, "required": [ "type", "geometry", "properties", ], - "additionalProperties": False, "properties": { + "type": {"const": "Feature", "type": "string"}, "id": { "type": "string", }, @@ -657,7 +658,6 @@ def test_simple_json_schema(self): }, ], }, - "type": {"const": "Feature", "type": "string"}, }, } @@ -675,13 +675,14 @@ class SubFeature(Feature): expect = { "title": "SubFeature", "type": "object", + "additionalProperties": False, "required": [ "type", "geometry", "properties", ], - "additionalProperties": False, "properties": { + "type": {"const": "Feature", "type": "string"}, "id": { "type": "string", }, @@ -707,7 +708,6 @@ class SubFeature(Feature): "baz": {"type": "number"}, }, }, - "type": {"const": "Feature", "type": "string"}, }, } @@ -717,7 +717,49 @@ class SubFeature(Feature): assert_subset(expect, actual, "expect", "actual") def test_subclass_make_required_fields_not_required(self): - assert False + """ + A subclass can technically redefine a required field to make it not required. This test + verifies that the JSON Schema generation works as expected in this scenario. + """ + + class SubFeature(Feature): + geometry: Omitable[Geometry] + + expect = { + "title": "SubFeature", + "type": "object", + "additionalProperties": False, + "required": [ + "type", + "properties", + ], + "properties": { + "type": {"const": "Feature", "type": "string"}, + "id": { + "type": "string", + }, + "bbox": { + "type": "array", + }, + "geometry": {}, + "properties": { + "anyOf": [ + { + "type": "object", + "not": {"required": ["id", "bbox", "geometry"]}, + }, + { + "type": "null", + }, + ], + }, + }, + } + + actual = SubFeature.model_json_schema() + print(json.dumps(actual, indent=2)) + + assert_subset(expect, actual, "expect", "actual") def test_subclass_geometry_type_constraint(self): class PointFeature(Feature): @@ -726,13 +768,14 @@ class PointFeature(Feature): expect = { "title": "PointFeature", "type": "object", + "additionalProperties": False, "required": [ + "type", "geometry", "properties", - "type", ], - "additionalProperties": False, "properties": { + "type": {"const": "Feature", "type": "string"}, "id": { "type": "string", }, @@ -762,14 +805,12 @@ class PointFeature(Feature): { "type": "object", "not": {"required": ["id", "bbox", "geometry"]}, - "properties": {}, }, { "type": "null", }, ], }, - "type": {"const": "Feature", "type": "string"}, }, } @@ -778,19 +819,64 @@ class PointFeature(Feature): assert_subset(expect, actual, "expect", "actual") - def test_forbid_extra_fields(self): + def test_forbid_extra_fields_without_adding_fields(self): class SubFeature(Feature): model_config = ConfigDict(extra="forbid") expect = { "title": "SubFeature", "type": "object", + "additionalProperties": False, "required": [ + "type", "geometry", "properties", + ], + "properties": { + "type": {"const": "Feature", "type": "string"}, + "id": { + "type": "string", + }, + "bbox": { + "type": "array", + }, + "geometry": {}, + "properties": { + "anyOf": [ + { + "type": "object", + "maxProperties": 0, + }, + { + "type": "null", + }, + ], + }, + }, + } + + actual = SubFeature.model_json_schema() + print(json.dumps(actual, indent=2)) + + assert_subset(expect, actual, "expect", "actual") + + def test_forbid_extra_fields_with_added_optional_field(self): + class SubFeature(Feature): + model_config = ConfigDict(extra="forbid") + + added_field: Omitable[int] + + expect = { + "title": "SubFeature", + "type": "object", + "additionalProperties": False, + "required": [ "type", + "geometry", + "properties", ], "properties": { + "type": {"const": "Feature", "type": "string"}, "id": { "type": "string", }, @@ -802,16 +888,61 @@ class SubFeature(Feature): "anyOf": [ { "type": "object", - "not": {"required": ["id", "bbox", "geometry"]}, "additionalProperties": False, - "properties": {}, + "not": {"required": ["id", "bbox", "geometry"]}, + "properties": { + "added_field": { + "type": "integer", + }, + }, }, { "type": "null", }, ], }, + }, + } + + actual = SubFeature.model_json_schema() + print(json.dumps(actual, indent=2)) + + assert_subset(expect, actual, "expect", "actual") + + def test_forbid_extra_fields_with_added_required_field(self): + class SubFeature(Feature): + model_config = ConfigDict(extra="forbid") + + added_field: float + + expect = { + "title": "SubFeature", + "type": "object", + "additionalProperties": False, + "required": [ + "type", + "geometry", + "properties", + ], + "properties": { "type": {"const": "Feature", "type": "string"}, + "id": { + "type": "string", + }, + "bbox": { + "type": "array", + }, + "geometry": {}, + "properties": { + "type": "object", + "required": ["added_field"], + "not": {"required": ["id", "bbox", "geometry"]}, + "properties": { + "added_field": { + "type": "number", + }, + }, + }, }, } @@ -820,33 +951,202 @@ class SubFeature(Feature): assert_subset(expect, actual, "expect", "actual") - def test_unsupported_keyword(self): - assert False + def test_unsupported_keyword_min_properties(self): + """ + We don't have a clean way to port the JSON Schema "minProperties" keyword to the GeoJSON + Schema in a way that respects the fact that the "logical" properties of the Feature get + split, due to the quirks of GeoJSON, between to levels of the Feature object: the top level, + and the Feature's properties object. Therefore we prohibit this keyword in the Feature + JSON Schema. + """ + + @min_fields_set(1) + class MinFieldsFeature(Feature): + pass + + with pytest.raises( + ValueError, match="unsupported JSON Schema keyword 'minProperties'" + ): + actual = MinFieldsFeature.model_json_schema() + print(json.dumps(actual, indent=2)) + + def test_reuse_synthetic_field_names(self): + """ + GeoJSON introduces two artificial field names, "type" and "properties". Since these aren't + really part of the "logical" structure of a GeoJSON Feature (they are just "physical" + artifacts of the structure chosen), there is no reason why a model shouldn't be allowed to + use these field names. This test verifies that every thing works as expected at the JSON + Schema level when these synthetic field names are used. + """ + + class SyntheticFieldNamesModel(Feature): + type: int + properties: str | None = None + + expect = { + "properties": { + "properties": { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "integer", + }, + "properties": { + "anyOf": [ + {"type": "string"}, + {"type": "null"}, + ] + }, + }, + } + } + } + + actual = SyntheticFieldNamesModel.model_json_schema() + print(json.dumps(actual, indent=2)) + + assert_subset(expect, actual, "expect", "actual") def test_model_constraint_top_level_only(self): - assert False + @forbid_if(["bbox"], FieldEqCondition("id", "hello")) + @require_if(["id"], FieldEqCondition("bbox", [0, 0, 0, 0])) + @require_any_of("id", "bbox") + class TopLevelConstraintFeature(Feature): + pass + + expect = { + "title": "TopLevelConstraintFeature", + "type": "object", + "additionalProperties": False, + "required": [ + "type", + "geometry", + "properties", + ], + "anyOf": [ + {"required": ["id"]}, + {"required": ["bbox"]}, + ], + "allOf": [ + { + "if": { + "properties": { + "bbox": { + "const": [0, 0, 0, 0], + }, + }, + }, + "then": { + "required": ["id"], + }, + }, + { + "if": { + "properties": { + "id": { + "const": "hello", + } + } + }, + "then": { + "not": { + "required": ["bbox"], + }, + }, + }, + ], + "properties": { + "type": {"const": "Feature", "type": "string"}, + "id": {}, + "bbox": {}, + "geometry": {}, + "properties": { + "anyOf": [ + { + "type": "object", + "not": {"required": ["id", "bbox", "geometry"]}, + }, + {"type": "null"}, + ] + }, + }, + } + + actual = TopLevelConstraintFeature.model_json_schema() + print(json.dumps(actual, indent=2)) + + assert_subset(expect, actual, "expect", "actual") def test_model_constraint_properties_object_only(self): - assert False + @forbid_if(["bar"], FieldEqCondition("baz", 42)) + @require_any_of("foo", "bar") + class PropertiesObjectConstraintFeature(Feature): + foo: Omitable[str] + bar: Omitable[bool] + baz: int + + expect = { + "title": "PropertiesObjectConstraintFeature", + "type": "object", + "additionalProperties": False, + "required": [ + "type", + "geometry", + "properties", + ], + "properties": { + "type": {"const": "Feature", "type": "string"}, + "id": {}, + "bbox": {}, + "geometry": {}, + "properties": { + "required": ["baz"], + "anyOf": [ + {"required": ["foo"]}, + {"required": ["bar"]}, + ], + "if": { + "properties": { + "baz": { + "const": 42, + }, + }, + }, + "then": { + "not": {"required": ["bar"]}, + }, + }, + }, + } + + actual = PropertiesObjectConstraintFeature.model_json_schema() + print(json.dumps(actual, indent=2)) + + assert_subset(expect, actual, "expect", "actual") def test_model_constraint_mixed(self): - @forbid_if(["foo"], FieldEqCondition("qux", "ban.foo")) + @forbid_if(["foo", "type"], FieldEqCondition("properties", "ban.foo")) @require_if(["id", "foo", "qux"], FieldEqCondition("corge", 42)) @require_any_of("bbox", "foo", "garply") - class SubFeature(Feature): + class MixedConstraintFeature(Feature): foo: Omitable[bool] bar: bool baz: bool qux: Omitable[str] corge: int garply: Omitable[bool] + type: Omitable[str] + properties: str expect = { + "title": "MixedConstraintFeature", "type": "object", + "additionalProperties": False, "required": [ + "type", "geometry", "properties", - "type", ], "anyOf": [ {"required": ["bbox"]}, @@ -897,7 +1197,7 @@ class SubFeature(Feature): "properties": { "type": "object", "properties": { - "qux": { + "properties": { "const": "ban.foo", }, }, @@ -909,7 +1209,7 @@ class SubFeature(Feature): "properties": { "properties": { "type": "object", - "required": ["foo"], + "required": ["foo", "type"], } } }, @@ -917,6 +1217,7 @@ class SubFeature(Feature): }, ], "properties": { + "type": {"const": "Feature", "type": "string"}, "id": { "type": "string", }, @@ -926,7 +1227,7 @@ class SubFeature(Feature): "geometry": {}, "properties": { "type": "object", - "required": ["bar", "baz", "corge"], + "required": ["bar", "baz", "corge", "properties"], "properties": { "foo": { "type": "boolean", @@ -946,17 +1247,21 @@ class SubFeature(Feature): "garply": { "type": "boolean", }, + "type": { + "type": "string", + }, + "properties": { + "type": "string", + }, }, }, - "type": {"const": "Feature", "type": "string"}, }, } - actual = SubFeature.model_json_schema() + actual = MixedConstraintFeature.model_json_schema() print(json.dumps(actual, indent=2)) assert_subset(expect, actual, "expect", "actual") - assert False class Test_FieldLevel: @@ -1190,72 +1495,152 @@ class TestRefactoring: [ ({}, {}, {}, None, None), ( - { "required": ["id"] }, - { }, - { }, - { "required": ["id"] }, + {"required": ["id"]}, + {}, + {}, + {"required": ["id"]}, None, ), ( - { "required": ["foo"] }, - { }, - { }, + {"required": ["foo"]}, + {}, + {}, None, - { "required": ["foo"] }, + {"required": ["foo"]}, ), ( - { "required": ["id", "foo"] }, - { }, - { }, + {"required": ["id", "foo"]}, + {}, + {}, { "required": ["id"], "properties": { - "properties": { - "type": "object", - "required": ["foo"] - } - } + "properties": {"type": "object", "required": ["foo"]} + }, }, None, ), ( { "anyOf": [ - { "required": ["foo"] }, - { "required": ["bar"] }, + {"required": ["foo"]}, + {"required": ["bar"]}, ] }, - { }, - { }, + {}, + {}, None, { "anyOf": [ - { "required": ["foo"] }, - { "required": ["bar"] }, + {"required": ["foo"]}, + {"required": ["bar"]}, ] }, ), ( { "anyOf": [ - { "required": ["id"] }, - { "required": ["foo"] }, + {"required": ["id"]}, + {"required": ["foo"]}, ] }, - { }, - { }, + {}, + {}, { "anyOf": [ - { "required": ["id"] }, + {"required": ["id"]}, { "properties": { "properties": { "type": "object", "required": ["foo"], - } - } - } - ] + }, + }, + }, + ], + }, + None, + ), + ( + { + "if": { + "required": ["id", "foo"], + }, + "then": { + "allOf": [ + { + "not": { + "required": ["bbox", "bar"], + }, + }, + { + "properties": { + "id": {"const": "hello"}, + "baz": {"const": 123}, + }, + }, + ], + }, + "else": { + "anyOf": [ + {"required": ["bbox"]}, + {"required": ["qux"]}, + ], + }, + }, + {}, + {}, + { + "if": { + "properties": { + "properties": { + "type": "object", + "required": ["foo"], + }, + }, + "required": ["id"], + }, + "then": { + "allOf": [ + { + "not": { + "required": ["bbox"], + "properties": { + "properties": { + "type": "object", + "required": ["bar"], + }, + }, + }, + }, + { + "properties": { + "id": { + "const": "hello", + }, + "properties": { + "type": "object", + "properties": { + "baz": {"const": 123}, + }, + }, + }, + }, + ], + }, + "else": { + "anyOf": [ + {"required": ["bbox"]}, + { + "properties": { + "properties": { + "type": "object", + "required": ["qux"], + }, + }, + }, + ], + }, }, None, ), diff --git a/packages/overture-schema-system/tests/util.py b/packages/overture-schema-system/tests/util.py index 8b0455013..0ceb287dd 100644 --- a/packages/overture-schema-system/tests/util.py +++ b/packages/overture-schema-system/tests/util.py @@ -34,8 +34,12 @@ def subset_conflicts(a: JsonDict, b: JsonDict) -> JsonDict: sub_conflicts = subset_conflicts(av, bv) if sub_conflicts: conflicts[k] = sub_conflicts + elif isinstance(av, list | tuple) and isinstance(bv, list | tuple): + sub_conflicts = _array_conflicts(av, bv) + if sub_conflicts: + conflicts[k] = sub_conflicts else: - conflicts[k] = av + conflicts[k] = _type_mismatch(av, bv) or _value_mismatch(av, bv) return conflicts @@ -47,3 +51,38 @@ def assert_subset(a: JsonDict, b: JsonDict, a_name: str = "a", b_name: str = "b" f"but the following parts of `{a_name}` were missing or different in `{b_name}`: {conflicts} " f"(full context: `{a_name}` = {a}, `{b_name}` = {b})" ) + + +def _type_mismatch(a: object, b: object) -> str | None: + if type(a) is type(b): + return None + return f"type mismatch: {type(a).__name__} vs. {type(b).__name__} (values {repr(a)} vs. {repr(b)})" + + +def _value_mismatch(a: object, b: object) -> str: + assert a != b + return f"value mismatch: {repr(a)} vs. {repr(b)}" + + +def _array_conflicts( + a: list[object] | tuple[object, ...], b: list[object] | tuple[object, ...] +) -> list[object]: + conflicts = [] + for i, (av, bv) in enumerate(zip(a, b, strict=False)): + origin = get_origin(JsonDict) + if isinstance(av, origin) and isinstance(bv, origin): + sub_conflicts = subset_conflicts(av, bv) + if sub_conflicts: + conflicts.append((i, sub_conflicts)) + elif av != bv: + conflicts.append((i, _type_mismatch(av, bv) or _value_mismatch(av, bv))) + + if len(a) != len(b): + conflicts.append( + f"length mismatch: {len(a)} vs. {len(b)}, additional items follow" + ) + if len(a) < len(b): + conflicts += b[len(a) :] + else: + conflicts += a[len(b) :] + return conflicts From 51d77412feb9e776a2674bf245f1aefd86812918 Mon Sep 17 00:00:00 2001 From: schapper Date: Thu, 16 Oct 2025 11:35:40 -0700 Subject: [PATCH 14/19] wip - Add `make coverage` with HTML report --- Makefile | 3 + pyproject.toml | 2 +- uv.lock | 703 +++++++++++++++++++++++++------------------------ 3 files changed, 370 insertions(+), 338 deletions(-) diff --git a/Makefile b/Makefile index e267a26c1..c882bf059 100644 --- a/Makefile +++ b/Makefile @@ -16,6 +16,9 @@ test-all: uv-sync test: uv-sync @uv run pytest packages/ -x +coverage: uv-sync + @uv run pytest packages/ --cov overture.schema --cov-report=term --cov-report=html && open htmlcov/index.html + docformat: @find packages/*/src -name "*.py" -type f -not -name "__*" \ | xargs uv run pydocstyle --convention=numpy --add-ignore=D105 diff --git a/pyproject.toml b/pyproject.toml index 1b26b2a0c..81580deae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,7 +52,7 @@ dev = [ "pdoc>=15.0.4", "pydocstyle>=6.3.0", "pytest>=8.4.1", - "pytest-cov", + "pytest-cov>=7.0.0", "ruff>=0.12.4", ] diff --git a/uv.lock b/uv.lock index 96e36c3d4..eb396415a 100644 --- a/uv.lock +++ b/uv.lock @@ -41,66 +41,91 @@ wheels = [ [[package]] name = "charset-normalizer" -version = "3.4.3" +version = "3.4.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/98/f3b8013223728a99b908c9344da3aa04ee6e3fa235f19409033eda92fb78/charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72", size = 207695, upload-time = "2025-08-09T07:55:36.452Z" }, - { url = "https://files.pythonhosted.org/packages/21/40/5188be1e3118c82dcb7c2a5ba101b783822cfb413a0268ed3be0468532de/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe", size = 147153, upload-time = "2025-08-09T07:55:38.467Z" }, - { url = "https://files.pythonhosted.org/packages/37/60/5d0d74bc1e1380f0b72c327948d9c2aca14b46a9efd87604e724260f384c/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601", size = 160428, upload-time = "2025-08-09T07:55:40.072Z" }, - { url = "https://files.pythonhosted.org/packages/85/9a/d891f63722d9158688de58d050c59dc3da560ea7f04f4c53e769de5140f5/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c", size = 157627, upload-time = "2025-08-09T07:55:41.706Z" }, - { url = "https://files.pythonhosted.org/packages/65/1a/7425c952944a6521a9cfa7e675343f83fd82085b8af2b1373a2409c683dc/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2", size = 152388, upload-time = "2025-08-09T07:55:43.262Z" }, - { url = "https://files.pythonhosted.org/packages/f0/c9/a2c9c2a355a8594ce2446085e2ec97fd44d323c684ff32042e2a6b718e1d/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0", size = 150077, upload-time = "2025-08-09T07:55:44.903Z" }, - { url = "https://files.pythonhosted.org/packages/3b/38/20a1f44e4851aa1c9105d6e7110c9d020e093dfa5836d712a5f074a12bf7/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0", size = 161631, upload-time = "2025-08-09T07:55:46.346Z" }, - { url = "https://files.pythonhosted.org/packages/a4/fa/384d2c0f57edad03d7bec3ebefb462090d8905b4ff5a2d2525f3bb711fac/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0", size = 159210, upload-time = "2025-08-09T07:55:47.539Z" }, - { url = "https://files.pythonhosted.org/packages/33/9e/eca49d35867ca2db336b6ca27617deed4653b97ebf45dfc21311ce473c37/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a", size = 153739, upload-time = "2025-08-09T07:55:48.744Z" }, - { url = "https://files.pythonhosted.org/packages/2a/91/26c3036e62dfe8de8061182d33be5025e2424002125c9500faff74a6735e/charset_normalizer-3.4.3-cp310-cp310-win32.whl", hash = "sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f", size = 99825, upload-time = "2025-08-09T07:55:50.305Z" }, - { url = "https://files.pythonhosted.org/packages/e2/c6/f05db471f81af1fa01839d44ae2a8bfeec8d2a8b4590f16c4e7393afd323/charset_normalizer-3.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669", size = 107452, upload-time = "2025-08-09T07:55:51.461Z" }, - { url = "https://files.pythonhosted.org/packages/7f/b5/991245018615474a60965a7c9cd2b4efbaabd16d582a5547c47ee1c7730b/charset_normalizer-3.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b", size = 204483, upload-time = "2025-08-09T07:55:53.12Z" }, - { url = "https://files.pythonhosted.org/packages/c7/2a/ae245c41c06299ec18262825c1569c5d3298fc920e4ddf56ab011b417efd/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64", size = 145520, upload-time = "2025-08-09T07:55:54.712Z" }, - { url = "https://files.pythonhosted.org/packages/3a/a4/b3b6c76e7a635748c4421d2b92c7b8f90a432f98bda5082049af37ffc8e3/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91", size = 158876, upload-time = "2025-08-09T07:55:56.024Z" }, - { url = "https://files.pythonhosted.org/packages/e2/e6/63bb0e10f90a8243c5def74b5b105b3bbbfb3e7bb753915fe333fb0c11ea/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f", size = 156083, upload-time = "2025-08-09T07:55:57.582Z" }, - { url = "https://files.pythonhosted.org/packages/87/df/b7737ff046c974b183ea9aa111b74185ac8c3a326c6262d413bd5a1b8c69/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07", size = 150295, upload-time = "2025-08-09T07:55:59.147Z" }, - { url = "https://files.pythonhosted.org/packages/61/f1/190d9977e0084d3f1dc169acd060d479bbbc71b90bf3e7bf7b9927dec3eb/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30", size = 148379, upload-time = "2025-08-09T07:56:00.364Z" }, - { url = "https://files.pythonhosted.org/packages/4c/92/27dbe365d34c68cfe0ca76f1edd70e8705d82b378cb54ebbaeabc2e3029d/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14", size = 160018, upload-time = "2025-08-09T07:56:01.678Z" }, - { url = "https://files.pythonhosted.org/packages/99/04/baae2a1ea1893a01635d475b9261c889a18fd48393634b6270827869fa34/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c", size = 157430, upload-time = "2025-08-09T07:56:02.87Z" }, - { url = "https://files.pythonhosted.org/packages/2f/36/77da9c6a328c54d17b960c89eccacfab8271fdaaa228305330915b88afa9/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae", size = 151600, upload-time = "2025-08-09T07:56:04.089Z" }, - { url = "https://files.pythonhosted.org/packages/64/d4/9eb4ff2c167edbbf08cdd28e19078bf195762e9bd63371689cab5ecd3d0d/charset_normalizer-3.4.3-cp311-cp311-win32.whl", hash = "sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849", size = 99616, upload-time = "2025-08-09T07:56:05.658Z" }, - { url = "https://files.pythonhosted.org/packages/f4/9c/996a4a028222e7761a96634d1820de8a744ff4327a00ada9c8942033089b/charset_normalizer-3.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c", size = 107108, upload-time = "2025-08-09T07:56:07.176Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655, upload-time = "2025-08-09T07:56:08.475Z" }, - { url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223, upload-time = "2025-08-09T07:56:09.708Z" }, - { url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366, upload-time = "2025-08-09T07:56:11.326Z" }, - { url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104, upload-time = "2025-08-09T07:56:13.014Z" }, - { url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830, upload-time = "2025-08-09T07:56:14.428Z" }, - { url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854, upload-time = "2025-08-09T07:56:16.051Z" }, - { url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670, upload-time = "2025-08-09T07:56:17.314Z" }, - { url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501, upload-time = "2025-08-09T07:56:18.641Z" }, - { url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173, upload-time = "2025-08-09T07:56:20.289Z" }, - { url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822, upload-time = "2025-08-09T07:56:21.551Z" }, - { url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543, upload-time = "2025-08-09T07:56:23.115Z" }, - { url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326, upload-time = "2025-08-09T07:56:24.721Z" }, - { url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008, upload-time = "2025-08-09T07:56:26.004Z" }, - { url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196, upload-time = "2025-08-09T07:56:27.25Z" }, - { url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819, upload-time = "2025-08-09T07:56:28.515Z" }, - { url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350, upload-time = "2025-08-09T07:56:29.716Z" }, - { url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644, upload-time = "2025-08-09T07:56:30.984Z" }, - { url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468, upload-time = "2025-08-09T07:56:32.252Z" }, - { url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187, upload-time = "2025-08-09T07:56:33.481Z" }, - { url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699, upload-time = "2025-08-09T07:56:34.739Z" }, - { url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580, upload-time = "2025-08-09T07:56:35.981Z" }, - { url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366, upload-time = "2025-08-09T07:56:37.339Z" }, - { url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342, upload-time = "2025-08-09T07:56:38.687Z" }, - { url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995, upload-time = "2025-08-09T07:56:40.048Z" }, - { url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640, upload-time = "2025-08-09T07:56:41.311Z" }, - { url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636, upload-time = "2025-08-09T07:56:43.195Z" }, - { url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939, upload-time = "2025-08-09T07:56:44.819Z" }, - { url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580, upload-time = "2025-08-09T07:56:46.684Z" }, - { url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870, upload-time = "2025-08-09T07:56:47.941Z" }, - { url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797, upload-time = "2025-08-09T07:56:49.756Z" }, - { url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224, upload-time = "2025-08-09T07:56:51.369Z" }, - { url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086, upload-time = "2025-08-09T07:56:52.722Z" }, - { url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400, upload-time = "2025-08-09T07:56:55.172Z" }, - { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" }, + { url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" }, + { url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" }, + { url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" }, + { url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" }, + { url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" }, + { url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" }, + { url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" }, + { url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" }, + { url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792, upload-time = "2025-10-14T04:40:29.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198, upload-time = "2025-10-14T04:40:30.644Z" }, + { url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262, upload-time = "2025-10-14T04:40:32.108Z" }, + { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, + { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, + { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, + { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, + { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, + { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, + { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, + { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, ] [[package]] @@ -114,101 +139,101 @@ wheels = [ [[package]] name = "coverage" -version = "7.10.7" +version = "7.11.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/51/26/d22c300112504f5f9a9fd2297ce33c35f3d353e4aeb987c8419453b2a7c2/coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239", size = 827704, upload-time = "2025-09-21T20:03:56.815Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/38/ee22495420457259d2f3390309505ea98f98a5eed40901cf62196abad006/coverage-7.11.0.tar.gz", hash = "sha256:167bd504ac1ca2af7ff3b81d245dfea0292c5032ebef9d66cc08a7d28c1b8050", size = 811905, upload-time = "2025-10-15T15:15:08.542Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/6c/3a3f7a46888e69d18abe3ccc6fe4cb16cccb1e6a2f99698931dafca489e6/coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a", size = 217987, upload-time = "2025-09-21T20:00:57.218Z" }, - { url = "https://files.pythonhosted.org/packages/03/94/952d30f180b1a916c11a56f5c22d3535e943aa22430e9e3322447e520e1c/coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5", size = 218388, upload-time = "2025-09-21T20:01:00.081Z" }, - { url = "https://files.pythonhosted.org/packages/50/2b/9e0cf8ded1e114bcd8b2fd42792b57f1c4e9e4ea1824cde2af93a67305be/coverage-7.10.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:240af60539987ced2c399809bd34f7c78e8abe0736af91c3d7d0e795df633d17", size = 245148, upload-time = "2025-09-21T20:01:01.768Z" }, - { url = "https://files.pythonhosted.org/packages/19/20/d0384ac06a6f908783d9b6aa6135e41b093971499ec488e47279f5b846e6/coverage-7.10.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8421e088bc051361b01c4b3a50fd39a4b9133079a2229978d9d30511fd05231b", size = 246958, upload-time = "2025-09-21T20:01:03.355Z" }, - { url = "https://files.pythonhosted.org/packages/60/83/5c283cff3d41285f8eab897651585db908a909c572bdc014bcfaf8a8b6ae/coverage-7.10.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be8ed3039ae7f7ac5ce058c308484787c86e8437e72b30bf5e88b8ea10f3c87", size = 248819, upload-time = "2025-09-21T20:01:04.968Z" }, - { url = "https://files.pythonhosted.org/packages/60/22/02eb98fdc5ff79f423e990d877693e5310ae1eab6cb20ae0b0b9ac45b23b/coverage-7.10.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e28299d9f2e889e6d51b1f043f58d5f997c373cc12e6403b90df95b8b047c13e", size = 245754, upload-time = "2025-09-21T20:01:06.321Z" }, - { url = "https://files.pythonhosted.org/packages/b4/bc/25c83bcf3ad141b32cd7dc45485ef3c01a776ca3aa8ef0a93e77e8b5bc43/coverage-7.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4e16bd7761c5e454f4efd36f345286d6f7c5fa111623c355691e2755cae3b9e", size = 246860, upload-time = "2025-09-21T20:01:07.605Z" }, - { url = "https://files.pythonhosted.org/packages/3c/b7/95574702888b58c0928a6e982038c596f9c34d52c5e5107f1eef729399b5/coverage-7.10.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b1c81d0e5e160651879755c9c675b974276f135558cf4ba79fee7b8413a515df", size = 244877, upload-time = "2025-09-21T20:01:08.829Z" }, - { url = "https://files.pythonhosted.org/packages/47/b6/40095c185f235e085df0e0b158f6bd68cc6e1d80ba6c7721dc81d97ec318/coverage-7.10.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:606cc265adc9aaedcc84f1f064f0e8736bc45814f15a357e30fca7ecc01504e0", size = 245108, upload-time = "2025-09-21T20:01:10.527Z" }, - { url = "https://files.pythonhosted.org/packages/c8/50/4aea0556da7a4b93ec9168420d170b55e2eb50ae21b25062513d020c6861/coverage-7.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:10b24412692df990dbc34f8fb1b6b13d236ace9dfdd68df5b28c2e39cafbba13", size = 245752, upload-time = "2025-09-21T20:01:11.857Z" }, - { url = "https://files.pythonhosted.org/packages/6a/28/ea1a84a60828177ae3b100cb6723838523369a44ec5742313ed7db3da160/coverage-7.10.7-cp310-cp310-win32.whl", hash = "sha256:b51dcd060f18c19290d9b8a9dd1e0181538df2ce0717f562fff6cf74d9fc0b5b", size = 220497, upload-time = "2025-09-21T20:01:13.459Z" }, - { url = "https://files.pythonhosted.org/packages/fc/1a/a81d46bbeb3c3fd97b9602ebaa411e076219a150489bcc2c025f151bd52d/coverage-7.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:3a622ac801b17198020f09af3eaf45666b344a0d69fc2a6ffe2ea83aeef1d807", size = 221392, upload-time = "2025-09-21T20:01:14.722Z" }, - { url = "https://files.pythonhosted.org/packages/d2/5d/c1a17867b0456f2e9ce2d8d4708a4c3a089947d0bec9c66cdf60c9e7739f/coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59", size = 218102, upload-time = "2025-09-21T20:01:16.089Z" }, - { url = "https://files.pythonhosted.org/packages/54/f0/514dcf4b4e3698b9a9077f084429681bf3aad2b4a72578f89d7f643eb506/coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a", size = 218505, upload-time = "2025-09-21T20:01:17.788Z" }, - { url = "https://files.pythonhosted.org/packages/20/f6/9626b81d17e2a4b25c63ac1b425ff307ecdeef03d67c9a147673ae40dc36/coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699", size = 248898, upload-time = "2025-09-21T20:01:19.488Z" }, - { url = "https://files.pythonhosted.org/packages/b0/ef/bd8e719c2f7417ba03239052e099b76ea1130ac0cbb183ee1fcaa58aaff3/coverage-7.10.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f5e3f9e455bb17831876048355dca0f758b6df22f49258cb5a91da23ef437d", size = 250831, upload-time = "2025-09-21T20:01:20.817Z" }, - { url = "https://files.pythonhosted.org/packages/a5/b6/bf054de41ec948b151ae2b79a55c107f5760979538f5fb80c195f2517718/coverage-7.10.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e", size = 252937, upload-time = "2025-09-21T20:01:22.171Z" }, - { url = "https://files.pythonhosted.org/packages/0f/e5/3860756aa6f9318227443c6ce4ed7bf9e70bb7f1447a0353f45ac5c7974b/coverage-7.10.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23", size = 249021, upload-time = "2025-09-21T20:01:23.907Z" }, - { url = "https://files.pythonhosted.org/packages/26/0f/bd08bd042854f7fd07b45808927ebcce99a7ed0f2f412d11629883517ac2/coverage-7.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab", size = 250626, upload-time = "2025-09-21T20:01:25.721Z" }, - { url = "https://files.pythonhosted.org/packages/8e/a7/4777b14de4abcc2e80c6b1d430f5d51eb18ed1d75fca56cbce5f2db9b36e/coverage-7.10.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82", size = 248682, upload-time = "2025-09-21T20:01:27.105Z" }, - { url = "https://files.pythonhosted.org/packages/34/72/17d082b00b53cd45679bad682fac058b87f011fd8b9fe31d77f5f8d3a4e4/coverage-7.10.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2", size = 248402, upload-time = "2025-09-21T20:01:28.629Z" }, - { url = "https://files.pythonhosted.org/packages/81/7a/92367572eb5bdd6a84bfa278cc7e97db192f9f45b28c94a9ca1a921c3577/coverage-7.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61", size = 249320, upload-time = "2025-09-21T20:01:30.004Z" }, - { url = "https://files.pythonhosted.org/packages/2f/88/a23cc185f6a805dfc4fdf14a94016835eeb85e22ac3a0e66d5e89acd6462/coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14", size = 220536, upload-time = "2025-09-21T20:01:32.184Z" }, - { url = "https://files.pythonhosted.org/packages/fe/ef/0b510a399dfca17cec7bc2f05ad8bd78cf55f15c8bc9a73ab20c5c913c2e/coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2", size = 221425, upload-time = "2025-09-21T20:01:33.557Z" }, - { url = "https://files.pythonhosted.org/packages/51/7f/023657f301a276e4ba1850f82749bc136f5a7e8768060c2e5d9744a22951/coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a", size = 220103, upload-time = "2025-09-21T20:01:34.929Z" }, - { url = "https://files.pythonhosted.org/packages/13/e4/eb12450f71b542a53972d19117ea5a5cea1cab3ac9e31b0b5d498df1bd5a/coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417", size = 218290, upload-time = "2025-09-21T20:01:36.455Z" }, - { url = "https://files.pythonhosted.org/packages/37/66/593f9be12fc19fb36711f19a5371af79a718537204d16ea1d36f16bd78d2/coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973", size = 218515, upload-time = "2025-09-21T20:01:37.982Z" }, - { url = "https://files.pythonhosted.org/packages/66/80/4c49f7ae09cafdacc73fbc30949ffe77359635c168f4e9ff33c9ebb07838/coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c", size = 250020, upload-time = "2025-09-21T20:01:39.617Z" }, - { url = "https://files.pythonhosted.org/packages/a6/90/a64aaacab3b37a17aaedd83e8000142561a29eb262cede42d94a67f7556b/coverage-7.10.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7", size = 252769, upload-time = "2025-09-21T20:01:41.341Z" }, - { url = "https://files.pythonhosted.org/packages/98/2e/2dda59afd6103b342e096f246ebc5f87a3363b5412609946c120f4e7750d/coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6", size = 253901, upload-time = "2025-09-21T20:01:43.042Z" }, - { url = "https://files.pythonhosted.org/packages/53/dc/8d8119c9051d50f3119bb4a75f29f1e4a6ab9415cd1fa8bf22fcc3fb3b5f/coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59", size = 250413, upload-time = "2025-09-21T20:01:44.469Z" }, - { url = "https://files.pythonhosted.org/packages/98/b3/edaff9c5d79ee4d4b6d3fe046f2b1d799850425695b789d491a64225d493/coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b", size = 251820, upload-time = "2025-09-21T20:01:45.915Z" }, - { url = "https://files.pythonhosted.org/packages/11/25/9a0728564bb05863f7e513e5a594fe5ffef091b325437f5430e8cfb0d530/coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a", size = 249941, upload-time = "2025-09-21T20:01:47.296Z" }, - { url = "https://files.pythonhosted.org/packages/e0/fd/ca2650443bfbef5b0e74373aac4df67b08180d2f184b482c41499668e258/coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb", size = 249519, upload-time = "2025-09-21T20:01:48.73Z" }, - { url = "https://files.pythonhosted.org/packages/24/79/f692f125fb4299b6f963b0745124998ebb8e73ecdfce4ceceb06a8c6bec5/coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1", size = 251375, upload-time = "2025-09-21T20:01:50.529Z" }, - { url = "https://files.pythonhosted.org/packages/5e/75/61b9bbd6c7d24d896bfeec57acba78e0f8deac68e6baf2d4804f7aae1f88/coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256", size = 220699, upload-time = "2025-09-21T20:01:51.941Z" }, - { url = "https://files.pythonhosted.org/packages/ca/f3/3bf7905288b45b075918d372498f1cf845b5b579b723c8fd17168018d5f5/coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba", size = 221512, upload-time = "2025-09-21T20:01:53.481Z" }, - { url = "https://files.pythonhosted.org/packages/5c/44/3e32dbe933979d05cf2dac5e697c8599cfe038aaf51223ab901e208d5a62/coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf", size = 220147, upload-time = "2025-09-21T20:01:55.2Z" }, - { url = "https://files.pythonhosted.org/packages/9a/94/b765c1abcb613d103b64fcf10395f54d69b0ef8be6a0dd9c524384892cc7/coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d", size = 218320, upload-time = "2025-09-21T20:01:56.629Z" }, - { url = "https://files.pythonhosted.org/packages/72/4f/732fff31c119bb73b35236dd333030f32c4bfe909f445b423e6c7594f9a2/coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b", size = 218575, upload-time = "2025-09-21T20:01:58.203Z" }, - { url = "https://files.pythonhosted.org/packages/87/02/ae7e0af4b674be47566707777db1aa375474f02a1d64b9323e5813a6cdd5/coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e", size = 249568, upload-time = "2025-09-21T20:01:59.748Z" }, - { url = "https://files.pythonhosted.org/packages/a2/77/8c6d22bf61921a59bce5471c2f1f7ac30cd4ac50aadde72b8c48d5727902/coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b", size = 252174, upload-time = "2025-09-21T20:02:01.192Z" }, - { url = "https://files.pythonhosted.org/packages/b1/20/b6ea4f69bbb52dac0aebd62157ba6a9dddbfe664f5af8122dac296c3ee15/coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49", size = 253447, upload-time = "2025-09-21T20:02:02.701Z" }, - { url = "https://files.pythonhosted.org/packages/f9/28/4831523ba483a7f90f7b259d2018fef02cb4d5b90bc7c1505d6e5a84883c/coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911", size = 249779, upload-time = "2025-09-21T20:02:04.185Z" }, - { url = "https://files.pythonhosted.org/packages/a7/9f/4331142bc98c10ca6436d2d620c3e165f31e6c58d43479985afce6f3191c/coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0", size = 251604, upload-time = "2025-09-21T20:02:06.034Z" }, - { url = "https://files.pythonhosted.org/packages/ce/60/bda83b96602036b77ecf34e6393a3836365481b69f7ed7079ab85048202b/coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f", size = 249497, upload-time = "2025-09-21T20:02:07.619Z" }, - { url = "https://files.pythonhosted.org/packages/5f/af/152633ff35b2af63977edd835d8e6430f0caef27d171edf2fc76c270ef31/coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c", size = 249350, upload-time = "2025-09-21T20:02:10.34Z" }, - { url = "https://files.pythonhosted.org/packages/9d/71/d92105d122bd21cebba877228990e1646d862e34a98bb3374d3fece5a794/coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f", size = 251111, upload-time = "2025-09-21T20:02:12.122Z" }, - { url = "https://files.pythonhosted.org/packages/a2/9e/9fdb08f4bf476c912f0c3ca292e019aab6712c93c9344a1653986c3fd305/coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698", size = 220746, upload-time = "2025-09-21T20:02:13.919Z" }, - { url = "https://files.pythonhosted.org/packages/b1/b1/a75fd25df44eab52d1931e89980d1ada46824c7a3210be0d3c88a44aaa99/coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843", size = 221541, upload-time = "2025-09-21T20:02:15.57Z" }, - { url = "https://files.pythonhosted.org/packages/14/3a/d720d7c989562a6e9a14b2c9f5f2876bdb38e9367126d118495b89c99c37/coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546", size = 220170, upload-time = "2025-09-21T20:02:17.395Z" }, - { url = "https://files.pythonhosted.org/packages/bb/22/e04514bf2a735d8b0add31d2b4ab636fc02370730787c576bb995390d2d5/coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c", size = 219029, upload-time = "2025-09-21T20:02:18.936Z" }, - { url = "https://files.pythonhosted.org/packages/11/0b/91128e099035ece15da3445d9015e4b4153a6059403452d324cbb0a575fa/coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15", size = 219259, upload-time = "2025-09-21T20:02:20.44Z" }, - { url = "https://files.pythonhosted.org/packages/8b/51/66420081e72801536a091a0c8f8c1f88a5c4bf7b9b1bdc6222c7afe6dc9b/coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4", size = 260592, upload-time = "2025-09-21T20:02:22.313Z" }, - { url = "https://files.pythonhosted.org/packages/5d/22/9b8d458c2881b22df3db5bb3e7369e63d527d986decb6c11a591ba2364f7/coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0", size = 262768, upload-time = "2025-09-21T20:02:24.287Z" }, - { url = "https://files.pythonhosted.org/packages/f7/08/16bee2c433e60913c610ea200b276e8eeef084b0d200bdcff69920bd5828/coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0", size = 264995, upload-time = "2025-09-21T20:02:26.133Z" }, - { url = "https://files.pythonhosted.org/packages/20/9d/e53eb9771d154859b084b90201e5221bca7674ba449a17c101a5031d4054/coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65", size = 259546, upload-time = "2025-09-21T20:02:27.716Z" }, - { url = "https://files.pythonhosted.org/packages/ad/b0/69bc7050f8d4e56a89fb550a1577d5d0d1db2278106f6f626464067b3817/coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541", size = 262544, upload-time = "2025-09-21T20:02:29.216Z" }, - { url = "https://files.pythonhosted.org/packages/ef/4b/2514b060dbd1bc0aaf23b852c14bb5818f244c664cb16517feff6bb3a5ab/coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6", size = 260308, upload-time = "2025-09-21T20:02:31.226Z" }, - { url = "https://files.pythonhosted.org/packages/54/78/7ba2175007c246d75e496f64c06e94122bdb914790a1285d627a918bd271/coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999", size = 258920, upload-time = "2025-09-21T20:02:32.823Z" }, - { url = "https://files.pythonhosted.org/packages/c0/b3/fac9f7abbc841409b9a410309d73bfa6cfb2e51c3fada738cb607ce174f8/coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2", size = 261434, upload-time = "2025-09-21T20:02:34.86Z" }, - { url = "https://files.pythonhosted.org/packages/ee/51/a03bec00d37faaa891b3ff7387192cef20f01604e5283a5fabc95346befa/coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a", size = 221403, upload-time = "2025-09-21T20:02:37.034Z" }, - { url = "https://files.pythonhosted.org/packages/53/22/3cf25d614e64bf6d8e59c7c669b20d6d940bb337bdee5900b9ca41c820bb/coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb", size = 222469, upload-time = "2025-09-21T20:02:39.011Z" }, - { url = "https://files.pythonhosted.org/packages/49/a1/00164f6d30d8a01c3c9c48418a7a5be394de5349b421b9ee019f380df2a0/coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb", size = 220731, upload-time = "2025-09-21T20:02:40.939Z" }, - { url = "https://files.pythonhosted.org/packages/23/9c/5844ab4ca6a4dd97a1850e030a15ec7d292b5c5cb93082979225126e35dd/coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520", size = 218302, upload-time = "2025-09-21T20:02:42.527Z" }, - { url = "https://files.pythonhosted.org/packages/f0/89/673f6514b0961d1f0e20ddc242e9342f6da21eaba3489901b565c0689f34/coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32", size = 218578, upload-time = "2025-09-21T20:02:44.468Z" }, - { url = "https://files.pythonhosted.org/packages/05/e8/261cae479e85232828fb17ad536765c88dd818c8470aca690b0ac6feeaa3/coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f", size = 249629, upload-time = "2025-09-21T20:02:46.503Z" }, - { url = "https://files.pythonhosted.org/packages/82/62/14ed6546d0207e6eda876434e3e8475a3e9adbe32110ce896c9e0c06bb9a/coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a", size = 252162, upload-time = "2025-09-21T20:02:48.689Z" }, - { url = "https://files.pythonhosted.org/packages/ff/49/07f00db9ac6478e4358165a08fb41b469a1b053212e8a00cb02f0d27a05f/coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360", size = 253517, upload-time = "2025-09-21T20:02:50.31Z" }, - { url = "https://files.pythonhosted.org/packages/a2/59/c5201c62dbf165dfbc91460f6dbbaa85a8b82cfa6131ac45d6c1bfb52deb/coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69", size = 249632, upload-time = "2025-09-21T20:02:51.971Z" }, - { url = "https://files.pythonhosted.org/packages/07/ae/5920097195291a51fb00b3a70b9bbd2edbfe3c84876a1762bd1ef1565ebc/coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14", size = 251520, upload-time = "2025-09-21T20:02:53.858Z" }, - { url = "https://files.pythonhosted.org/packages/b9/3c/a815dde77a2981f5743a60b63df31cb322c944843e57dbd579326625a413/coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe", size = 249455, upload-time = "2025-09-21T20:02:55.807Z" }, - { url = "https://files.pythonhosted.org/packages/aa/99/f5cdd8421ea656abefb6c0ce92556709db2265c41e8f9fc6c8ae0f7824c9/coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e", size = 249287, upload-time = "2025-09-21T20:02:57.784Z" }, - { url = "https://files.pythonhosted.org/packages/c3/7a/e9a2da6a1fc5d007dd51fca083a663ab930a8c4d149c087732a5dbaa0029/coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd", size = 250946, upload-time = "2025-09-21T20:02:59.431Z" }, - { url = "https://files.pythonhosted.org/packages/ef/5b/0b5799aa30380a949005a353715095d6d1da81927d6dbed5def2200a4e25/coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2", size = 221009, upload-time = "2025-09-21T20:03:01.324Z" }, - { url = "https://files.pythonhosted.org/packages/da/b0/e802fbb6eb746de006490abc9bb554b708918b6774b722bb3a0e6aa1b7de/coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681", size = 221804, upload-time = "2025-09-21T20:03:03.4Z" }, - { url = "https://files.pythonhosted.org/packages/9e/e8/71d0c8e374e31f39e3389bb0bd19e527d46f00ea8571ec7ec8fd261d8b44/coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880", size = 220384, upload-time = "2025-09-21T20:03:05.111Z" }, - { url = "https://files.pythonhosted.org/packages/62/09/9a5608d319fa3eba7a2019addeacb8c746fb50872b57a724c9f79f146969/coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63", size = 219047, upload-time = "2025-09-21T20:03:06.795Z" }, - { url = "https://files.pythonhosted.org/packages/f5/6f/f58d46f33db9f2e3647b2d0764704548c184e6f5e014bef528b7f979ef84/coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2", size = 219266, upload-time = "2025-09-21T20:03:08.495Z" }, - { url = "https://files.pythonhosted.org/packages/74/5c/183ffc817ba68e0b443b8c934c8795553eb0c14573813415bd59941ee165/coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d", size = 260767, upload-time = "2025-09-21T20:03:10.172Z" }, - { url = "https://files.pythonhosted.org/packages/0f/48/71a8abe9c1ad7e97548835e3cc1adbf361e743e9d60310c5f75c9e7bf847/coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0", size = 262931, upload-time = "2025-09-21T20:03:11.861Z" }, - { url = "https://files.pythonhosted.org/packages/84/fd/193a8fb132acfc0a901f72020e54be5e48021e1575bb327d8ee1097a28fd/coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699", size = 265186, upload-time = "2025-09-21T20:03:13.539Z" }, - { url = "https://files.pythonhosted.org/packages/b1/8f/74ecc30607dd95ad50e3034221113ccb1c6d4e8085cc761134782995daae/coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9", size = 259470, upload-time = "2025-09-21T20:03:15.584Z" }, - { url = "https://files.pythonhosted.org/packages/0f/55/79ff53a769f20d71b07023ea115c9167c0bb56f281320520cf64c5298a96/coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f", size = 262626, upload-time = "2025-09-21T20:03:17.673Z" }, - { url = "https://files.pythonhosted.org/packages/88/e2/dac66c140009b61ac3fc13af673a574b00c16efdf04f9b5c740703e953c0/coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1", size = 260386, upload-time = "2025-09-21T20:03:19.36Z" }, - { url = "https://files.pythonhosted.org/packages/a2/f1/f48f645e3f33bb9ca8a496bc4a9671b52f2f353146233ebd7c1df6160440/coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0", size = 258852, upload-time = "2025-09-21T20:03:21.007Z" }, - { url = "https://files.pythonhosted.org/packages/bb/3b/8442618972c51a7affeead957995cfa8323c0c9bcf8fa5a027421f720ff4/coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399", size = 261534, upload-time = "2025-09-21T20:03:23.12Z" }, - { url = "https://files.pythonhosted.org/packages/b2/dc/101f3fa3a45146db0cb03f5b4376e24c0aac818309da23e2de0c75295a91/coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235", size = 221784, upload-time = "2025-09-21T20:03:24.769Z" }, - { url = "https://files.pythonhosted.org/packages/4c/a1/74c51803fc70a8a40d7346660379e144be772bab4ac7bb6e6b905152345c/coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d", size = 222905, upload-time = "2025-09-21T20:03:26.93Z" }, - { url = "https://files.pythonhosted.org/packages/12/65/f116a6d2127df30bcafbceef0302d8a64ba87488bf6f73a6d8eebf060873/coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a", size = 220922, upload-time = "2025-09-21T20:03:28.672Z" }, - { url = "https://files.pythonhosted.org/packages/ec/16/114df1c291c22cac3b0c127a73e0af5c12ed7bbb6558d310429a0ae24023/coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260", size = 209952, upload-time = "2025-09-21T20:03:53.918Z" }, + { url = "https://files.pythonhosted.org/packages/12/95/c49df0aceb5507a80b9fe5172d3d39bf23f05be40c23c8d77d556df96cec/coverage-7.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:eb53f1e8adeeb2e78962bade0c08bfdc461853c7969706ed901821e009b35e31", size = 215800, upload-time = "2025-10-15T15:12:19.824Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c6/7bb46ce01ed634fff1d7bb53a54049f539971862cc388b304ff3c51b4f66/coverage-7.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d9a03ec6cb9f40a5c360f138b88266fd8f58408d71e89f536b4f91d85721d075", size = 216198, upload-time = "2025-10-15T15:12:22.549Z" }, + { url = "https://files.pythonhosted.org/packages/94/b2/75d9d8fbf2900268aca5de29cd0a0fe671b0f69ef88be16767cc3c828b85/coverage-7.11.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0d7f0616c557cbc3d1c2090334eddcbb70e1ae3a40b07222d62b3aa47f608fab", size = 242953, upload-time = "2025-10-15T15:12:24.139Z" }, + { url = "https://files.pythonhosted.org/packages/65/ac/acaa984c18f440170525a8743eb4b6c960ace2dbad80dc22056a437fc3c6/coverage-7.11.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e44a86a47bbdf83b0a3ea4d7df5410d6b1a0de984fbd805fa5101f3624b9abe0", size = 244766, upload-time = "2025-10-15T15:12:25.974Z" }, + { url = "https://files.pythonhosted.org/packages/d8/0d/938d0bff76dfa4a6b228c3fc4b3e1c0e2ad4aa6200c141fcda2bd1170227/coverage-7.11.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:596763d2f9a0ee7eec6e643e29660def2eef297e1de0d334c78c08706f1cb785", size = 246625, upload-time = "2025-10-15T15:12:27.387Z" }, + { url = "https://files.pythonhosted.org/packages/38/54/8f5f5e84bfa268df98f46b2cb396b1009734cfb1e5d6adb663d284893b32/coverage-7.11.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ef55537ff511b5e0a43edb4c50a7bf7ba1c3eea20b4f49b1490f1e8e0e42c591", size = 243568, upload-time = "2025-10-15T15:12:28.799Z" }, + { url = "https://files.pythonhosted.org/packages/68/30/8ba337c2877fe3f2e1af0ed7ff4be0c0c4aca44d6f4007040f3ca2255e99/coverage-7.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9cbabd8f4d0d3dc571d77ae5bdbfa6afe5061e679a9d74b6797c48d143307088", size = 244665, upload-time = "2025-10-15T15:12:30.297Z" }, + { url = "https://files.pythonhosted.org/packages/cc/fb/c6f1d6d9a665536b7dde2333346f0cc41dc6a60bd1ffc10cd5c33e7eb000/coverage-7.11.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e24045453384e0ae2a587d562df2a04d852672eb63051d16096d3f08aa4c7c2f", size = 242681, upload-time = "2025-10-15T15:12:32.326Z" }, + { url = "https://files.pythonhosted.org/packages/be/38/1b532319af5f991fa153c20373291dc65c2bf532af7dbcffdeef745c8f79/coverage-7.11.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:7161edd3426c8d19bdccde7d49e6f27f748f3c31cc350c5de7c633fea445d866", size = 242912, upload-time = "2025-10-15T15:12:34.079Z" }, + { url = "https://files.pythonhosted.org/packages/67/3d/f39331c60ef6050d2a861dc1b514fa78f85f792820b68e8c04196ad733d6/coverage-7.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d4ed4de17e692ba6415b0587bc7f12bc80915031fc9db46a23ce70fc88c9841", size = 243559, upload-time = "2025-10-15T15:12:35.809Z" }, + { url = "https://files.pythonhosted.org/packages/4b/55/cb7c9df9d0495036ce582a8a2958d50c23cd73f84a23284bc23bd4711a6f/coverage-7.11.0-cp310-cp310-win32.whl", hash = "sha256:765c0bc8fe46f48e341ef737c91c715bd2a53a12792592296a095f0c237e09cf", size = 218266, upload-time = "2025-10-15T15:12:37.429Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/b79cb275fa7bd0208767f89d57a1b5f6ba830813875738599741b97c2e04/coverage-7.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:24d6f3128f1b2d20d84b24f4074475457faedc3d4613a7e66b5e769939c7d969", size = 219169, upload-time = "2025-10-15T15:12:39.25Z" }, + { url = "https://files.pythonhosted.org/packages/49/3a/ee1074c15c408ddddddb1db7dd904f6b81bc524e01f5a1c5920e13dbde23/coverage-7.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3d58ecaa865c5b9fa56e35efc51d1014d4c0d22838815b9fce57a27dd9576847", size = 215912, upload-time = "2025-10-15T15:12:40.665Z" }, + { url = "https://files.pythonhosted.org/packages/70/c4/9f44bebe5cb15f31608597b037d78799cc5f450044465bcd1ae8cb222fe1/coverage-7.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b679e171f1c104a5668550ada700e3c4937110dbdd153b7ef9055c4f1a1ee3cc", size = 216310, upload-time = "2025-10-15T15:12:42.461Z" }, + { url = "https://files.pythonhosted.org/packages/42/01/5e06077cfef92d8af926bdd86b84fb28bf9bc6ad27343d68be9b501d89f2/coverage-7.11.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ca61691ba8c5b6797deb221a0d09d7470364733ea9c69425a640f1f01b7c5bf0", size = 246706, upload-time = "2025-10-15T15:12:44.001Z" }, + { url = "https://files.pythonhosted.org/packages/40/b8/7a3f1f33b35cc4a6c37e759137533119560d06c0cc14753d1a803be0cd4a/coverage-7.11.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:aef1747ede4bd8ca9cfc04cc3011516500c6891f1b33a94add3253f6f876b7b7", size = 248634, upload-time = "2025-10-15T15:12:45.768Z" }, + { url = "https://files.pythonhosted.org/packages/7a/41/7f987eb33de386bc4c665ab0bf98d15fcf203369d6aacae74f5dd8ec489a/coverage-7.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1839d08406e4cba2953dcc0ffb312252f14d7c4c96919f70167611f4dee2623", size = 250741, upload-time = "2025-10-15T15:12:47.222Z" }, + { url = "https://files.pythonhosted.org/packages/23/c1/a4e0ca6a4e83069fb8216b49b30a7352061ca0cb38654bd2dc96b7b3b7da/coverage-7.11.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e0eb0a2dcc62478eb5b4cbb80b97bdee852d7e280b90e81f11b407d0b81c4287", size = 246837, upload-time = "2025-10-15T15:12:48.904Z" }, + { url = "https://files.pythonhosted.org/packages/5d/03/ced062a17f7c38b4728ff76c3acb40d8465634b20b4833cdb3cc3a74e115/coverage-7.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bc1fbea96343b53f65d5351d8fd3b34fd415a2670d7c300b06d3e14a5af4f552", size = 248429, upload-time = "2025-10-15T15:12:50.73Z" }, + { url = "https://files.pythonhosted.org/packages/97/af/a7c6f194bb8c5a2705ae019036b8fe7f49ea818d638eedb15fdb7bed227c/coverage-7.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:214b622259dd0cf435f10241f1333d32caa64dbc27f8790ab693428a141723de", size = 246490, upload-time = "2025-10-15T15:12:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/ab/c3/aab4df02b04a8fde79068c3c41ad7a622b0ef2b12e1ed154da986a727c3f/coverage-7.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:258d9967520cca899695d4eb7ea38be03f06951d6ca2f21fb48b1235f791e601", size = 246208, upload-time = "2025-10-15T15:12:54.586Z" }, + { url = "https://files.pythonhosted.org/packages/30/d8/e282ec19cd658238d60ed404f99ef2e45eed52e81b866ab1518c0d4163cf/coverage-7.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cf9e6ff4ca908ca15c157c409d608da77a56a09877b97c889b98fb2c32b6465e", size = 247126, upload-time = "2025-10-15T15:12:56.485Z" }, + { url = "https://files.pythonhosted.org/packages/d1/17/a635fa07fac23adb1a5451ec756216768c2767efaed2e4331710342a3399/coverage-7.11.0-cp311-cp311-win32.whl", hash = "sha256:fcc15fc462707b0680cff6242c48625da7f9a16a28a41bb8fd7a4280920e676c", size = 218314, upload-time = "2025-10-15T15:12:58.365Z" }, + { url = "https://files.pythonhosted.org/packages/2a/29/2ac1dfcdd4ab9a70026edc8d715ece9b4be9a1653075c658ee6f271f394d/coverage-7.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:865965bf955d92790f1facd64fe7ff73551bd2c1e7e6b26443934e9701ba30b9", size = 219203, upload-time = "2025-10-15T15:12:59.902Z" }, + { url = "https://files.pythonhosted.org/packages/03/21/5ce8b3a0133179115af4c041abf2ee652395837cb896614beb8ce8ddcfd9/coverage-7.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:5693e57a065760dcbeb292d60cc4d0231a6d4b6b6f6a3191561e1d5e8820b745", size = 217879, upload-time = "2025-10-15T15:13:01.35Z" }, + { url = "https://files.pythonhosted.org/packages/c4/db/86f6906a7c7edc1a52b2c6682d6dd9be775d73c0dfe2b84f8923dfea5784/coverage-7.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9c49e77811cf9d024b95faf86c3f059b11c0c9be0b0d61bc598f453703bd6fd1", size = 216098, upload-time = "2025-10-15T15:13:02.916Z" }, + { url = "https://files.pythonhosted.org/packages/21/54/e7b26157048c7ba555596aad8569ff903d6cd67867d41b75287323678ede/coverage-7.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a61e37a403a778e2cda2a6a39abcc895f1d984071942a41074b5c7ee31642007", size = 216331, upload-time = "2025-10-15T15:13:04.403Z" }, + { url = "https://files.pythonhosted.org/packages/b9/19/1ce6bf444f858b83a733171306134a0544eaddf1ca8851ede6540a55b2ad/coverage-7.11.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c79cae102bb3b1801e2ef1511fb50e91ec83a1ce466b2c7c25010d884336de46", size = 247825, upload-time = "2025-10-15T15:13:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/71/0b/d3bcbbc259fcced5fb67c5d78f6e7ee965f49760c14afd931e9e663a83b2/coverage-7.11.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:16ce17ceb5d211f320b62df002fa7016b7442ea0fd260c11cec8ce7730954893", size = 250573, upload-time = "2025-10-15T15:13:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/58/8d/b0ff3641a320abb047258d36ed1c21d16be33beed4152628331a1baf3365/coverage-7.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:80027673e9d0bd6aef86134b0771845e2da85755cf686e7c7c59566cf5a89115", size = 251706, upload-time = "2025-10-15T15:13:09.4Z" }, + { url = "https://files.pythonhosted.org/packages/59/c8/5a586fe8c7b0458053d9c687f5cff515a74b66c85931f7fe17a1c958b4ac/coverage-7.11.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d3ffa07a08657306cd2215b0da53761c4d73cb54d9143b9303a6481ec0cd415", size = 248221, upload-time = "2025-10-15T15:13:10.964Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ff/3a25e3132804ba44cfa9a778cdf2b73dbbe63ef4b0945e39602fc896ba52/coverage-7.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a3b6a5f8b2524fd6c1066bc85bfd97e78709bb5e37b5b94911a6506b65f47186", size = 249624, upload-time = "2025-10-15T15:13:12.5Z" }, + { url = "https://files.pythonhosted.org/packages/c5/12/ff10c8ce3895e1b17a73485ea79ebc1896a9e466a9d0f4aef63e0d17b718/coverage-7.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:fcc0a4aa589de34bc56e1a80a740ee0f8c47611bdfb28cd1849de60660f3799d", size = 247744, upload-time = "2025-10-15T15:13:14.554Z" }, + { url = "https://files.pythonhosted.org/packages/16/02/d500b91f5471b2975947e0629b8980e5e90786fe316b6d7299852c1d793d/coverage-7.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dba82204769d78c3fd31b35c3d5f46e06511936c5019c39f98320e05b08f794d", size = 247325, upload-time = "2025-10-15T15:13:16.438Z" }, + { url = "https://files.pythonhosted.org/packages/77/11/dee0284fbbd9cd64cfce806b827452c6df3f100d9e66188e82dfe771d4af/coverage-7.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:81b335f03ba67309a95210caf3eb43bd6fe75a4e22ba653ef97b4696c56c7ec2", size = 249180, upload-time = "2025-10-15T15:13:17.959Z" }, + { url = "https://files.pythonhosted.org/packages/59/1b/cdf1def928f0a150a057cab03286774e73e29c2395f0d30ce3d9e9f8e697/coverage-7.11.0-cp312-cp312-win32.whl", hash = "sha256:037b2d064c2f8cc8716fe4d39cb705779af3fbf1ba318dc96a1af858888c7bb5", size = 218479, upload-time = "2025-10-15T15:13:19.608Z" }, + { url = "https://files.pythonhosted.org/packages/ff/55/e5884d55e031da9c15b94b90a23beccc9d6beee65e9835cd6da0a79e4f3a/coverage-7.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:d66c0104aec3b75e5fd897e7940188ea1892ca1d0235316bf89286d6a22568c0", size = 219290, upload-time = "2025-10-15T15:13:21.593Z" }, + { url = "https://files.pythonhosted.org/packages/23/a8/faa930cfc71c1d16bc78f9a19bb73700464f9c331d9e547bfbc1dbd3a108/coverage-7.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:d91ebeac603812a09cf6a886ba6e464f3bbb367411904ae3790dfe28311b15ad", size = 217924, upload-time = "2025-10-15T15:13:23.39Z" }, + { url = "https://files.pythonhosted.org/packages/60/7f/85e4dfe65e400645464b25c036a26ac226cf3a69d4a50c3934c532491cdd/coverage-7.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cc3f49e65ea6e0d5d9bd60368684fe52a704d46f9e7fc413918f18d046ec40e1", size = 216129, upload-time = "2025-10-15T15:13:25.371Z" }, + { url = "https://files.pythonhosted.org/packages/96/5d/dc5fa98fea3c175caf9d360649cb1aa3715e391ab00dc78c4c66fabd7356/coverage-7.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f39ae2f63f37472c17b4990f794035c9890418b1b8cca75c01193f3c8d3e01be", size = 216380, upload-time = "2025-10-15T15:13:26.976Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f5/3da9cc9596708273385189289c0e4d8197d37a386bdf17619013554b3447/coverage-7.11.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7db53b5cdd2917b6eaadd0b1251cf4e7d96f4a8d24e174bdbdf2f65b5ea7994d", size = 247375, upload-time = "2025-10-15T15:13:28.923Z" }, + { url = "https://files.pythonhosted.org/packages/65/6c/f7f59c342359a235559d2bc76b0c73cfc4bac7d61bb0df210965cb1ecffd/coverage-7.11.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10ad04ac3a122048688387828b4537bc9cf60c0bf4869c1e9989c46e45690b82", size = 249978, upload-time = "2025-10-15T15:13:30.525Z" }, + { url = "https://files.pythonhosted.org/packages/e7/8c/042dede2e23525e863bf1ccd2b92689692a148d8b5fd37c37899ba882645/coverage-7.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4036cc9c7983a2b1f2556d574d2eb2154ac6ed55114761685657e38782b23f52", size = 251253, upload-time = "2025-10-15T15:13:32.174Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a9/3c58df67bfa809a7bddd786356d9c5283e45d693edb5f3f55d0986dd905a/coverage-7.11.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7ab934dd13b1c5e94b692b1e01bd87e4488cb746e3a50f798cb9464fd128374b", size = 247591, upload-time = "2025-10-15T15:13:34.147Z" }, + { url = "https://files.pythonhosted.org/packages/26/5b/c7f32efd862ee0477a18c41e4761305de6ddd2d49cdeda0c1116227570fd/coverage-7.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:59a6e5a265f7cfc05f76e3bb53eca2e0dfe90f05e07e849930fecd6abb8f40b4", size = 249411, upload-time = "2025-10-15T15:13:38.425Z" }, + { url = "https://files.pythonhosted.org/packages/76/b5/78cb4f1e86c1611431c990423ec0768122905b03837e1b4c6a6f388a858b/coverage-7.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:df01d6c4c81e15a7c88337b795bb7595a8596e92310266b5072c7e301168efbd", size = 247303, upload-time = "2025-10-15T15:13:40.464Z" }, + { url = "https://files.pythonhosted.org/packages/87/c9/23c753a8641a330f45f221286e707c427e46d0ffd1719b080cedc984ec40/coverage-7.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8c934bd088eed6174210942761e38ee81d28c46de0132ebb1801dbe36a390dcc", size = 247157, upload-time = "2025-10-15T15:13:42.087Z" }, + { url = "https://files.pythonhosted.org/packages/c5/42/6e0cc71dc8a464486e944a4fa0d85bdec031cc2969e98ed41532a98336b9/coverage-7.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5a03eaf7ec24078ad64a07f02e30060aaf22b91dedf31a6b24d0d98d2bba7f48", size = 248921, upload-time = "2025-10-15T15:13:43.715Z" }, + { url = "https://files.pythonhosted.org/packages/e8/1c/743c2ef665e6858cccb0f84377dfe3a4c25add51e8c7ef19249be92465b6/coverage-7.11.0-cp313-cp313-win32.whl", hash = "sha256:695340f698a5f56f795b2836abe6fb576e7c53d48cd155ad2f80fd24bc63a040", size = 218526, upload-time = "2025-10-15T15:13:45.336Z" }, + { url = "https://files.pythonhosted.org/packages/ff/d5/226daadfd1bf8ddbccefbd3aa3547d7b960fb48e1bdac124e2dd13a2b71a/coverage-7.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:2727d47fce3ee2bac648528e41455d1b0c46395a087a229deac75e9f88ba5a05", size = 219317, upload-time = "2025-10-15T15:13:47.401Z" }, + { url = "https://files.pythonhosted.org/packages/97/54/47db81dcbe571a48a298f206183ba8a7ba79200a37cd0d9f4788fcd2af4a/coverage-7.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:0efa742f431529699712b92ecdf22de8ff198df41e43aeaaadf69973eb93f17a", size = 217948, upload-time = "2025-10-15T15:13:49.096Z" }, + { url = "https://files.pythonhosted.org/packages/e5/8b/cb68425420154e7e2a82fd779a8cc01549b6fa83c2ad3679cd6c088ebd07/coverage-7.11.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:587c38849b853b157706407e9ebdca8fd12f45869edb56defbef2daa5fb0812b", size = 216837, upload-time = "2025-10-15T15:13:51.09Z" }, + { url = "https://files.pythonhosted.org/packages/33/55/9d61b5765a025685e14659c8d07037247de6383c0385757544ffe4606475/coverage-7.11.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b971bdefdd75096163dd4261c74be813c4508477e39ff7b92191dea19f24cd37", size = 217061, upload-time = "2025-10-15T15:13:52.747Z" }, + { url = "https://files.pythonhosted.org/packages/52/85/292459c9186d70dcec6538f06ea251bc968046922497377bf4a1dc9a71de/coverage-7.11.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:269bfe913b7d5be12ab13a95f3a76da23cf147be7fa043933320ba5625f0a8de", size = 258398, upload-time = "2025-10-15T15:13:54.45Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e2/46edd73fb8bf51446c41148d81944c54ed224854812b6ca549be25113ee0/coverage-7.11.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:dadbcce51a10c07b7c72b0ce4a25e4b6dcb0c0372846afb8e5b6307a121eb99f", size = 260574, upload-time = "2025-10-15T15:13:56.145Z" }, + { url = "https://files.pythonhosted.org/packages/07/5e/1df469a19007ff82e2ca8fe509822820a31e251f80ee7344c34f6cd2ec43/coverage-7.11.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ed43fa22c6436f7957df036331f8fe4efa7af132054e1844918866cd228af6c", size = 262797, upload-time = "2025-10-15T15:13:58.635Z" }, + { url = "https://files.pythonhosted.org/packages/f9/50/de216b31a1434b94d9b34a964c09943c6be45069ec704bfc379d8d89a649/coverage-7.11.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9516add7256b6713ec08359b7b05aeff8850c98d357784c7205b2e60aa2513fa", size = 257361, upload-time = "2025-10-15T15:14:00.409Z" }, + { url = "https://files.pythonhosted.org/packages/82/1e/3f9f8344a48111e152e0fd495b6fff13cc743e771a6050abf1627a7ba918/coverage-7.11.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb92e47c92fcbcdc692f428da67db33337fa213756f7adb6a011f7b5a7a20740", size = 260349, upload-time = "2025-10-15T15:14:02.188Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/3f52741f9e7d82124272f3070bbe316006a7de1bad1093f88d59bfc6c548/coverage-7.11.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d06f4fc7acf3cabd6d74941d53329e06bab00a8fe10e4df2714f0b134bfc64ef", size = 258114, upload-time = "2025-10-15T15:14:03.907Z" }, + { url = "https://files.pythonhosted.org/packages/0b/8b/918f0e15f0365d50d3986bbd3338ca01178717ac5678301f3f547b6619e6/coverage-7.11.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:6fbcee1a8f056af07ecd344482f711f563a9eb1c2cad192e87df00338ec3cdb0", size = 256723, upload-time = "2025-10-15T15:14:06.324Z" }, + { url = "https://files.pythonhosted.org/packages/44/9e/7776829f82d3cf630878a7965a7d70cc6ca94f22c7d20ec4944f7148cb46/coverage-7.11.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dbbf012be5f32533a490709ad597ad8a8ff80c582a95adc8d62af664e532f9ca", size = 259238, upload-time = "2025-10-15T15:14:08.002Z" }, + { url = "https://files.pythonhosted.org/packages/9a/b8/49cf253e1e7a3bedb85199b201862dd7ca4859f75b6cf25ffa7298aa0760/coverage-7.11.0-cp313-cp313t-win32.whl", hash = "sha256:cee6291bb4fed184f1c2b663606a115c743df98a537c969c3c64b49989da96c2", size = 219180, upload-time = "2025-10-15T15:14:09.786Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e1/1a541703826be7ae2125a0fb7f821af5729d56bb71e946e7b933cc7a89a4/coverage-7.11.0-cp313-cp313t-win_amd64.whl", hash = "sha256:a386c1061bf98e7ea4758e4313c0ab5ecf57af341ef0f43a0bf26c2477b5c268", size = 220241, upload-time = "2025-10-15T15:14:11.471Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d1/5ee0e0a08621140fd418ec4020f595b4d52d7eb429ae6a0c6542b4ba6f14/coverage-7.11.0-cp313-cp313t-win_arm64.whl", hash = "sha256:f9ea02ef40bb83823b2b04964459d281688fe173e20643870bb5d2edf68bc836", size = 218510, upload-time = "2025-10-15T15:14:13.46Z" }, + { url = "https://files.pythonhosted.org/packages/f4/06/e923830c1985ce808e40a3fa3eb46c13350b3224b7da59757d37b6ce12b8/coverage-7.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c770885b28fb399aaf2a65bbd1c12bf6f307ffd112d6a76c5231a94276f0c497", size = 216110, upload-time = "2025-10-15T15:14:15.157Z" }, + { url = "https://files.pythonhosted.org/packages/42/82/cdeed03bfead45203fb651ed756dfb5266028f5f939e7f06efac4041dad5/coverage-7.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a3d0e2087dba64c86a6b254f43e12d264b636a39e88c5cc0a01a7c71bcfdab7e", size = 216395, upload-time = "2025-10-15T15:14:16.863Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ba/e1c80caffc3199aa699813f73ff097bc2df7b31642bdbc7493600a8f1de5/coverage-7.11.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:73feb83bb41c32811973b8565f3705caf01d928d972b72042b44e97c71fd70d1", size = 247433, upload-time = "2025-10-15T15:14:18.589Z" }, + { url = "https://files.pythonhosted.org/packages/80/c0/5b259b029694ce0a5bbc1548834c7ba3db41d3efd3474489d7efce4ceb18/coverage-7.11.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c6f31f281012235ad08f9a560976cc2fc9c95c17604ff3ab20120fe480169bca", size = 249970, upload-time = "2025-10-15T15:14:20.307Z" }, + { url = "https://files.pythonhosted.org/packages/8c/86/171b2b5e1aac7e2fd9b43f7158b987dbeb95f06d1fbecad54ad8163ae3e8/coverage-7.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9570ad567f880ef675673992222746a124b9595506826b210fbe0ce3f0499cd", size = 251324, upload-time = "2025-10-15T15:14:22.419Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/7e10414d343385b92024af3932a27a1caf75c6e27ee88ba211221ff1a145/coverage-7.11.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8badf70446042553a773547a61fecaa734b55dc738cacf20c56ab04b77425e43", size = 247445, upload-time = "2025-10-15T15:14:24.205Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3b/e4f966b21f5be8c4bf86ad75ae94efa0de4c99c7bbb8114476323102e345/coverage-7.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a09c1211959903a479e389685b7feb8a17f59ec5a4ef9afde7650bd5eabc2777", size = 249324, upload-time = "2025-10-15T15:14:26.234Z" }, + { url = "https://files.pythonhosted.org/packages/00/a2/8479325576dfcd909244d0df215f077f47437ab852ab778cfa2f8bf4d954/coverage-7.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:5ef83b107f50db3f9ae40f69e34b3bd9337456c5a7fe3461c7abf8b75dd666a2", size = 247261, upload-time = "2025-10-15T15:14:28.42Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d8/3a9e2db19d94d65771d0f2e21a9ea587d11b831332a73622f901157cc24b/coverage-7.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f91f927a3215b8907e214af77200250bb6aae36eca3f760f89780d13e495388d", size = 247092, upload-time = "2025-10-15T15:14:30.784Z" }, + { url = "https://files.pythonhosted.org/packages/b3/b1/bbca3c472544f9e2ad2d5116b2379732957048be4b93a9c543fcd0207e5f/coverage-7.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cdbcd376716d6b7fbfeedd687a6c4be019c5a5671b35f804ba76a4c0a778cba4", size = 248755, upload-time = "2025-10-15T15:14:32.585Z" }, + { url = "https://files.pythonhosted.org/packages/89/49/638d5a45a6a0f00af53d6b637c87007eb2297042186334e9923a61aa8854/coverage-7.11.0-cp314-cp314-win32.whl", hash = "sha256:bab7ec4bb501743edc63609320aaec8cd9188b396354f482f4de4d40a9d10721", size = 218793, upload-time = "2025-10-15T15:14:34.972Z" }, + { url = "https://files.pythonhosted.org/packages/30/cc/b675a51f2d068adb3cdf3799212c662239b0ca27f4691d1fff81b92ea850/coverage-7.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:3d4ba9a449e9364a936a27322b20d32d8b166553bfe63059bd21527e681e2fad", size = 219587, upload-time = "2025-10-15T15:14:37.047Z" }, + { url = "https://files.pythonhosted.org/packages/93/98/5ac886876026de04f00820e5094fe22166b98dcb8b426bf6827aaf67048c/coverage-7.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:ce37f215223af94ef0f75ac68ea096f9f8e8c8ec7d6e8c346ee45c0d363f0479", size = 218168, upload-time = "2025-10-15T15:14:38.861Z" }, + { url = "https://files.pythonhosted.org/packages/14/d1/b4145d35b3e3ecf4d917e97fc8895bcf027d854879ba401d9ff0f533f997/coverage-7.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:f413ce6e07e0d0dc9c433228727b619871532674b45165abafe201f200cc215f", size = 216850, upload-time = "2025-10-15T15:14:40.651Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d1/7f645fc2eccd318369a8a9948acc447bb7c1ade2911e31d3c5620544c22b/coverage-7.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:05791e528a18f7072bf5998ba772fe29db4da1234c45c2087866b5ba4dea710e", size = 217071, upload-time = "2025-10-15T15:14:42.755Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/64d124649db2737ceced1dfcbdcb79898d5868d311730f622f8ecae84250/coverage-7.11.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cacb29f420cfeb9283b803263c3b9a068924474ff19ca126ba9103e1278dfa44", size = 258570, upload-time = "2025-10-15T15:14:44.542Z" }, + { url = "https://files.pythonhosted.org/packages/6c/3f/6f5922f80dc6f2d8b2c6f974835c43f53eb4257a7797727e6ca5b7b2ec1f/coverage-7.11.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314c24e700d7027ae3ab0d95fbf8d53544fca1f20345fd30cd219b737c6e58d3", size = 260738, upload-time = "2025-10-15T15:14:46.436Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5f/9e883523c4647c860b3812b417a2017e361eca5b635ee658387dc11b13c1/coverage-7.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:630d0bd7a293ad2fc8b4b94e5758c8b2536fdf36c05f1681270203e463cbfa9b", size = 262994, upload-time = "2025-10-15T15:14:48.3Z" }, + { url = "https://files.pythonhosted.org/packages/07/bb/43b5a8e94c09c8bf51743ffc65c4c841a4ca5d3ed191d0a6919c379a1b83/coverage-7.11.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e89641f5175d65e2dbb44db15fe4ea48fade5d5bbb9868fdc2b4fce22f4a469d", size = 257282, upload-time = "2025-10-15T15:14:50.236Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e5/0ead8af411411330b928733e1d201384b39251a5f043c1612970310e8283/coverage-7.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c9f08ea03114a637dab06cedb2e914da9dc67fa52c6015c018ff43fdde25b9c2", size = 260430, upload-time = "2025-10-15T15:14:52.413Z" }, + { url = "https://files.pythonhosted.org/packages/ae/66/03dd8bb0ba5b971620dcaac145461950f6d8204953e535d2b20c6b65d729/coverage-7.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce9f3bde4e9b031eaf1eb61df95c1401427029ea1bfddb8621c1161dcb0fa02e", size = 258190, upload-time = "2025-10-15T15:14:54.268Z" }, + { url = "https://files.pythonhosted.org/packages/45/ae/28a9cce40bf3174426cb2f7e71ee172d98e7f6446dff936a7ccecee34b14/coverage-7.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:e4dc07e95495923d6fd4d6c27bf70769425b71c89053083843fd78f378558996", size = 256658, upload-time = "2025-10-15T15:14:56.436Z" }, + { url = "https://files.pythonhosted.org/packages/5c/7c/3a44234a8599513684bfc8684878fd7b126c2760f79712bb78c56f19efc4/coverage-7.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:424538266794db2861db4922b05d729ade0940ee69dcf0591ce8f69784db0e11", size = 259342, upload-time = "2025-10-15T15:14:58.538Z" }, + { url = "https://files.pythonhosted.org/packages/e1/e6/0108519cba871af0351725ebdb8660fd7a0fe2ba3850d56d32490c7d9b4b/coverage-7.11.0-cp314-cp314t-win32.whl", hash = "sha256:4c1eeb3fb8eb9e0190bebafd0462936f75717687117339f708f395fe455acc73", size = 219568, upload-time = "2025-10-15T15:15:00.382Z" }, + { url = "https://files.pythonhosted.org/packages/c9/76/44ba876e0942b4e62fdde23ccb029ddb16d19ba1bef081edd00857ba0b16/coverage-7.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b56efee146c98dbf2cf5cffc61b9829d1e94442df4d7398b26892a53992d3547", size = 220687, upload-time = "2025-10-15T15:15:02.322Z" }, + { url = "https://files.pythonhosted.org/packages/b9/0c/0df55ecb20d0d0ed5c322e10a441775e1a3a5d78c60f0c4e1abfe6fcf949/coverage-7.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b5c2705afa83f49bd91962a4094b6b082f94aef7626365ab3f8f4bd159c5acf3", size = 218711, upload-time = "2025-10-15T15:15:04.575Z" }, + { url = "https://files.pythonhosted.org/packages/5f/04/642c1d8a448ae5ea1369eac8495740a79eb4e581a9fb0cbdce56bbf56da1/coverage-7.11.0-py3-none-any.whl", hash = "sha256:4b7589765348d78fb4e5fb6ea35d07564e387da2fc5efff62e0222971f155f68", size = 207761, upload-time = "2025-10-15T15:15:06.439Z" }, ] [package.optional-dependencies] @@ -268,7 +293,7 @@ name = "exceptiongroup" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } wheels = [ @@ -277,11 +302,11 @@ wheels = [ [[package]] name = "idna" -version = "3.10" +version = "3.11" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] [[package]] @@ -523,86 +548,86 @@ wheels = [ [[package]] name = "numpy" -version = "2.3.3" +version = "2.3.4" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.11'", ] -sdist = { url = "https://files.pythonhosted.org/packages/d0/19/95b3d357407220ed24c139018d2518fab0a61a948e68286a25f1a4d049ff/numpy-2.3.3.tar.gz", hash = "sha256:ddc7c39727ba62b80dfdbedf400d1c10ddfa8eefbd7ec8dcb118be8b56d31029", size = 20576648, upload-time = "2025-09-09T16:54:12.543Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/f4/098d2270d52b41f1bd7db9fc288aaa0400cb48c2a3e2af6fa365d9720947/numpy-2.3.4.tar.gz", hash = "sha256:a7d018bfedb375a8d979ac758b120ba846a7fe764911a64465fd87b8729f4a6a", size = 20582187, upload-time = "2025-10-15T16:18:11.77Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/45/e80d203ef6b267aa29b22714fb558930b27960a0c5ce3c19c999232bb3eb/numpy-2.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0ffc4f5caba7dfcbe944ed674b7eef683c7e94874046454bb79ed7ee0236f59d", size = 21259253, upload-time = "2025-09-09T15:56:02.094Z" }, - { url = "https://files.pythonhosted.org/packages/52/18/cf2c648fccf339e59302e00e5f2bc87725a3ce1992f30f3f78c9044d7c43/numpy-2.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e7e946c7170858a0295f79a60214424caac2ffdb0063d4d79cb681f9aa0aa569", size = 14450980, upload-time = "2025-09-09T15:56:05.926Z" }, - { url = "https://files.pythonhosted.org/packages/93/fb/9af1082bec870188c42a1c239839915b74a5099c392389ff04215dcee812/numpy-2.3.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:cd4260f64bc794c3390a63bf0728220dd1a68170c169088a1e0dfa2fde1be12f", size = 5379709, upload-time = "2025-09-09T15:56:07.95Z" }, - { url = "https://files.pythonhosted.org/packages/75/0f/bfd7abca52bcbf9a4a65abc83fe18ef01ccdeb37bfb28bbd6ad613447c79/numpy-2.3.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:f0ddb4b96a87b6728df9362135e764eac3cfa674499943ebc44ce96c478ab125", size = 6913923, upload-time = "2025-09-09T15:56:09.443Z" }, - { url = "https://files.pythonhosted.org/packages/79/55/d69adad255e87ab7afda1caf93ca997859092afeb697703e2f010f7c2e55/numpy-2.3.3-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:afd07d377f478344ec6ca2b8d4ca08ae8bd44706763d1efb56397de606393f48", size = 14589591, upload-time = "2025-09-09T15:56:11.234Z" }, - { url = "https://files.pythonhosted.org/packages/10/a2/010b0e27ddeacab7839957d7a8f00e91206e0c2c47abbb5f35a2630e5387/numpy-2.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bc92a5dedcc53857249ca51ef29f5e5f2f8c513e22cfb90faeb20343b8c6f7a6", size = 16938714, upload-time = "2025-09-09T15:56:14.637Z" }, - { url = "https://files.pythonhosted.org/packages/1c/6b/12ce8ede632c7126eb2762b9e15e18e204b81725b81f35176eac14dc5b82/numpy-2.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7af05ed4dc19f308e1d9fc759f36f21921eb7bbfc82843eeec6b2a2863a0aefa", size = 16370592, upload-time = "2025-09-09T15:56:17.285Z" }, - { url = "https://files.pythonhosted.org/packages/b4/35/aba8568b2593067bb6a8fe4c52babb23b4c3b9c80e1b49dff03a09925e4a/numpy-2.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:433bf137e338677cebdd5beac0199ac84712ad9d630b74eceeb759eaa45ddf30", size = 18884474, upload-time = "2025-09-09T15:56:20.943Z" }, - { url = "https://files.pythonhosted.org/packages/45/fa/7f43ba10c77575e8be7b0138d107e4f44ca4a1ef322cd16980ea3e8b8222/numpy-2.3.3-cp311-cp311-win32.whl", hash = "sha256:eb63d443d7b4ffd1e873f8155260d7f58e7e4b095961b01c91062935c2491e57", size = 6599794, upload-time = "2025-09-09T15:56:23.258Z" }, - { url = "https://files.pythonhosted.org/packages/0a/a2/a4f78cb2241fe5664a22a10332f2be886dcdea8784c9f6a01c272da9b426/numpy-2.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:ec9d249840f6a565f58d8f913bccac2444235025bbb13e9a4681783572ee3caa", size = 13088104, upload-time = "2025-09-09T15:56:25.476Z" }, - { url = "https://files.pythonhosted.org/packages/79/64/e424e975adbd38282ebcd4891661965b78783de893b381cbc4832fb9beb2/numpy-2.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:74c2a948d02f88c11a3c075d9733f1ae67d97c6bdb97f2bb542f980458b257e7", size = 10460772, upload-time = "2025-09-09T15:56:27.679Z" }, - { url = "https://files.pythonhosted.org/packages/51/5d/bb7fc075b762c96329147799e1bcc9176ab07ca6375ea976c475482ad5b3/numpy-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cfdd09f9c84a1a934cde1eec2267f0a43a7cd44b2cca4ff95b7c0d14d144b0bf", size = 20957014, upload-time = "2025-09-09T15:56:29.966Z" }, - { url = "https://files.pythonhosted.org/packages/6b/0e/c6211bb92af26517acd52125a237a92afe9c3124c6a68d3b9f81b62a0568/numpy-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cb32e3cf0f762aee47ad1ddc6672988f7f27045b0783c887190545baba73aa25", size = 14185220, upload-time = "2025-09-09T15:56:32.175Z" }, - { url = "https://files.pythonhosted.org/packages/22/f2/07bb754eb2ede9073f4054f7c0286b0d9d2e23982e090a80d478b26d35ca/numpy-2.3.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:396b254daeb0a57b1fe0ecb5e3cff6fa79a380fa97c8f7781a6d08cd429418fe", size = 5113918, upload-time = "2025-09-09T15:56:34.175Z" }, - { url = "https://files.pythonhosted.org/packages/81/0a/afa51697e9fb74642f231ea36aca80fa17c8fb89f7a82abd5174023c3960/numpy-2.3.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:067e3d7159a5d8f8a0b46ee11148fc35ca9b21f61e3c49fbd0a027450e65a33b", size = 6647922, upload-time = "2025-09-09T15:56:36.149Z" }, - { url = "https://files.pythonhosted.org/packages/5d/f5/122d9cdb3f51c520d150fef6e87df9279e33d19a9611a87c0d2cf78a89f4/numpy-2.3.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c02d0629d25d426585fb2e45a66154081b9fa677bc92a881ff1d216bc9919a8", size = 14281991, upload-time = "2025-09-09T15:56:40.548Z" }, - { url = "https://files.pythonhosted.org/packages/51/64/7de3c91e821a2debf77c92962ea3fe6ac2bc45d0778c1cbe15d4fce2fd94/numpy-2.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9192da52b9745f7f0766531dcfa978b7763916f158bb63bdb8a1eca0068ab20", size = 16641643, upload-time = "2025-09-09T15:56:43.343Z" }, - { url = "https://files.pythonhosted.org/packages/30/e4/961a5fa681502cd0d68907818b69f67542695b74e3ceaa513918103b7e80/numpy-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cd7de500a5b66319db419dc3c345244404a164beae0d0937283b907d8152e6ea", size = 16056787, upload-time = "2025-09-09T15:56:46.141Z" }, - { url = "https://files.pythonhosted.org/packages/99/26/92c912b966e47fbbdf2ad556cb17e3a3088e2e1292b9833be1dfa5361a1a/numpy-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:93d4962d8f82af58f0b2eb85daaf1b3ca23fe0a85d0be8f1f2b7bb46034e56d7", size = 18579598, upload-time = "2025-09-09T15:56:49.844Z" }, - { url = "https://files.pythonhosted.org/packages/17/b6/fc8f82cb3520768718834f310c37d96380d9dc61bfdaf05fe5c0b7653e01/numpy-2.3.3-cp312-cp312-win32.whl", hash = "sha256:5534ed6b92f9b7dca6c0a19d6df12d41c68b991cef051d108f6dbff3babc4ebf", size = 6320800, upload-time = "2025-09-09T15:56:52.499Z" }, - { url = "https://files.pythonhosted.org/packages/32/ee/de999f2625b80d043d6d2d628c07d0d5555a677a3cf78fdf868d409b8766/numpy-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:497d7cad08e7092dba36e3d296fe4c97708c93daf26643a1ae4b03f6294d30eb", size = 12786615, upload-time = "2025-09-09T15:56:54.422Z" }, - { url = "https://files.pythonhosted.org/packages/49/6e/b479032f8a43559c383acb20816644f5f91c88f633d9271ee84f3b3a996c/numpy-2.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:ca0309a18d4dfea6fc6262a66d06c26cfe4640c3926ceec90e57791a82b6eee5", size = 10195936, upload-time = "2025-09-09T15:56:56.541Z" }, - { url = "https://files.pythonhosted.org/packages/7d/b9/984c2b1ee61a8b803bf63582b4ac4242cf76e2dbd663efeafcb620cc0ccb/numpy-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f5415fb78995644253370985342cd03572ef8620b934da27d77377a2285955bf", size = 20949588, upload-time = "2025-09-09T15:56:59.087Z" }, - { url = "https://files.pythonhosted.org/packages/a6/e4/07970e3bed0b1384d22af1e9912527ecbeb47d3b26e9b6a3bced068b3bea/numpy-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d00de139a3324e26ed5b95870ce63be7ec7352171bc69a4cf1f157a48e3eb6b7", size = 14177802, upload-time = "2025-09-09T15:57:01.73Z" }, - { url = "https://files.pythonhosted.org/packages/35/c7/477a83887f9de61f1203bad89cf208b7c19cc9fef0cebef65d5a1a0619f2/numpy-2.3.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:9dc13c6a5829610cc07422bc74d3ac083bd8323f14e2827d992f9e52e22cd6a6", size = 5106537, upload-time = "2025-09-09T15:57:03.765Z" }, - { url = "https://files.pythonhosted.org/packages/52/47/93b953bd5866a6f6986344d045a207d3f1cfbad99db29f534ea9cee5108c/numpy-2.3.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d79715d95f1894771eb4e60fb23f065663b2298f7d22945d66877aadf33d00c7", size = 6640743, upload-time = "2025-09-09T15:57:07.921Z" }, - { url = "https://files.pythonhosted.org/packages/23/83/377f84aaeb800b64c0ef4de58b08769e782edcefa4fea712910b6f0afd3c/numpy-2.3.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:952cfd0748514ea7c3afc729a0fc639e61655ce4c55ab9acfab14bda4f402b4c", size = 14278881, upload-time = "2025-09-09T15:57:11.349Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a5/bf3db6e66c4b160d6ea10b534c381a1955dfab34cb1017ea93aa33c70ed3/numpy-2.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5b83648633d46f77039c29078751f80da65aa64d5622a3cd62aaef9d835b6c93", size = 16636301, upload-time = "2025-09-09T15:57:14.245Z" }, - { url = "https://files.pythonhosted.org/packages/a2/59/1287924242eb4fa3f9b3a2c30400f2e17eb2707020d1c5e3086fe7330717/numpy-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b001bae8cea1c7dfdb2ae2b017ed0a6f2102d7a70059df1e338e307a4c78a8ae", size = 16053645, upload-time = "2025-09-09T15:57:16.534Z" }, - { url = "https://files.pythonhosted.org/packages/e6/93/b3d47ed882027c35e94ac2320c37e452a549f582a5e801f2d34b56973c97/numpy-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8e9aced64054739037d42fb84c54dd38b81ee238816c948c8f3ed134665dcd86", size = 18578179, upload-time = "2025-09-09T15:57:18.883Z" }, - { url = "https://files.pythonhosted.org/packages/20/d9/487a2bccbf7cc9d4bfc5f0f197761a5ef27ba870f1e3bbb9afc4bbe3fcc2/numpy-2.3.3-cp313-cp313-win32.whl", hash = "sha256:9591e1221db3f37751e6442850429b3aabf7026d3b05542d102944ca7f00c8a8", size = 6312250, upload-time = "2025-09-09T15:57:21.296Z" }, - { url = "https://files.pythonhosted.org/packages/1b/b5/263ebbbbcede85028f30047eab3d58028d7ebe389d6493fc95ae66c636ab/numpy-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f0dadeb302887f07431910f67a14d57209ed91130be0adea2f9793f1a4f817cf", size = 12783269, upload-time = "2025-09-09T15:57:23.034Z" }, - { url = "https://files.pythonhosted.org/packages/fa/75/67b8ca554bbeaaeb3fac2e8bce46967a5a06544c9108ec0cf5cece559b6c/numpy-2.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:3c7cf302ac6e0b76a64c4aecf1a09e51abd9b01fc7feee80f6c43e3ab1b1dbc5", size = 10195314, upload-time = "2025-09-09T15:57:25.045Z" }, - { url = "https://files.pythonhosted.org/packages/11/d0/0d1ddec56b162042ddfafeeb293bac672de9b0cfd688383590090963720a/numpy-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:eda59e44957d272846bb407aad19f89dc6f58fecf3504bd144f4c5cf81a7eacc", size = 21048025, upload-time = "2025-09-09T15:57:27.257Z" }, - { url = "https://files.pythonhosted.org/packages/36/9e/1996ca6b6d00415b6acbdd3c42f7f03ea256e2c3f158f80bd7436a8a19f3/numpy-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:823d04112bc85ef5c4fda73ba24e6096c8f869931405a80aa8b0e604510a26bc", size = 14301053, upload-time = "2025-09-09T15:57:30.077Z" }, - { url = "https://files.pythonhosted.org/packages/05/24/43da09aa764c68694b76e84b3d3f0c44cb7c18cdc1ba80e48b0ac1d2cd39/numpy-2.3.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:40051003e03db4041aa325da2a0971ba41cf65714e65d296397cc0e32de6018b", size = 5229444, upload-time = "2025-09-09T15:57:32.733Z" }, - { url = "https://files.pythonhosted.org/packages/bc/14/50ffb0f22f7218ef8af28dd089f79f68289a7a05a208db9a2c5dcbe123c1/numpy-2.3.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:6ee9086235dd6ab7ae75aba5662f582a81ced49f0f1c6de4260a78d8f2d91a19", size = 6738039, upload-time = "2025-09-09T15:57:34.328Z" }, - { url = "https://files.pythonhosted.org/packages/55/52/af46ac0795e09657d45a7f4db961917314377edecf66db0e39fa7ab5c3d3/numpy-2.3.3-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94fcaa68757c3e2e668ddadeaa86ab05499a70725811e582b6a9858dd472fb30", size = 14352314, upload-time = "2025-09-09T15:57:36.255Z" }, - { url = "https://files.pythonhosted.org/packages/a7/b1/dc226b4c90eb9f07a3fff95c2f0db3268e2e54e5cce97c4ac91518aee71b/numpy-2.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da1a74b90e7483d6ce5244053399a614b1d6b7bc30a60d2f570e5071f8959d3e", size = 16701722, upload-time = "2025-09-09T15:57:38.622Z" }, - { url = "https://files.pythonhosted.org/packages/9d/9d/9d8d358f2eb5eced14dba99f110d83b5cd9a4460895230f3b396ad19a323/numpy-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2990adf06d1ecee3b3dcbb4977dfab6e9f09807598d647f04d385d29e7a3c3d3", size = 16132755, upload-time = "2025-09-09T15:57:41.16Z" }, - { url = "https://files.pythonhosted.org/packages/b6/27/b3922660c45513f9377b3fb42240bec63f203c71416093476ec9aa0719dc/numpy-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ed635ff692483b8e3f0fcaa8e7eb8a75ee71aa6d975388224f70821421800cea", size = 18651560, upload-time = "2025-09-09T15:57:43.459Z" }, - { url = "https://files.pythonhosted.org/packages/5b/8e/3ab61a730bdbbc201bb245a71102aa609f0008b9ed15255500a99cd7f780/numpy-2.3.3-cp313-cp313t-win32.whl", hash = "sha256:a333b4ed33d8dc2b373cc955ca57babc00cd6f9009991d9edc5ddbc1bac36bcd", size = 6442776, upload-time = "2025-09-09T15:57:45.793Z" }, - { url = "https://files.pythonhosted.org/packages/1c/3a/e22b766b11f6030dc2decdeff5c2fb1610768055603f9f3be88b6d192fb2/numpy-2.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:4384a169c4d8f97195980815d6fcad04933a7e1ab3b530921c3fef7a1c63426d", size = 12927281, upload-time = "2025-09-09T15:57:47.492Z" }, - { url = "https://files.pythonhosted.org/packages/7b/42/c2e2bc48c5e9b2a83423f99733950fbefd86f165b468a3d85d52b30bf782/numpy-2.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:75370986cc0bc66f4ce5110ad35aae6d182cc4ce6433c40ad151f53690130bf1", size = 10265275, upload-time = "2025-09-09T15:57:49.647Z" }, - { url = "https://files.pythonhosted.org/packages/6b/01/342ad585ad82419b99bcf7cebe99e61da6bedb89e213c5fd71acc467faee/numpy-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cd052f1fa6a78dee696b58a914b7229ecfa41f0a6d96dc663c1220a55e137593", size = 20951527, upload-time = "2025-09-09T15:57:52.006Z" }, - { url = "https://files.pythonhosted.org/packages/ef/d8/204e0d73fc1b7a9ee80ab1fe1983dd33a4d64a4e30a05364b0208e9a241a/numpy-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:414a97499480067d305fcac9716c29cf4d0d76db6ebf0bf3cbce666677f12652", size = 14186159, upload-time = "2025-09-09T15:57:54.407Z" }, - { url = "https://files.pythonhosted.org/packages/22/af/f11c916d08f3a18fb8ba81ab72b5b74a6e42ead4c2846d270eb19845bf74/numpy-2.3.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:50a5fe69f135f88a2be9b6ca0481a68a136f6febe1916e4920e12f1a34e708a7", size = 5114624, upload-time = "2025-09-09T15:57:56.5Z" }, - { url = "https://files.pythonhosted.org/packages/fb/11/0ed919c8381ac9d2ffacd63fd1f0c34d27e99cab650f0eb6f110e6ae4858/numpy-2.3.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:b912f2ed2b67a129e6a601e9d93d4fa37bef67e54cac442a2f588a54afe5c67a", size = 6642627, upload-time = "2025-09-09T15:57:58.206Z" }, - { url = "https://files.pythonhosted.org/packages/ee/83/deb5f77cb0f7ba6cb52b91ed388b47f8f3c2e9930d4665c600408d9b90b9/numpy-2.3.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e318ee0596d76d4cb3d78535dc005fa60e5ea348cd131a51e99d0bdbe0b54fe", size = 14296926, upload-time = "2025-09-09T15:58:00.035Z" }, - { url = "https://files.pythonhosted.org/packages/77/cc/70e59dcb84f2b005d4f306310ff0a892518cc0c8000a33d0e6faf7ca8d80/numpy-2.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce020080e4a52426202bdb6f7691c65bb55e49f261f31a8f506c9f6bc7450421", size = 16638958, upload-time = "2025-09-09T15:58:02.738Z" }, - { url = "https://files.pythonhosted.org/packages/b6/5a/b2ab6c18b4257e099587d5b7f903317bd7115333ad8d4ec4874278eafa61/numpy-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e6687dc183aa55dae4a705b35f9c0f8cb178bcaa2f029b241ac5356221d5c021", size = 16071920, upload-time = "2025-09-09T15:58:05.029Z" }, - { url = "https://files.pythonhosted.org/packages/b8/f1/8b3fdc44324a259298520dd82147ff648979bed085feeacc1250ef1656c0/numpy-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d8f3b1080782469fdc1718c4ed1d22549b5fb12af0d57d35e992158a772a37cf", size = 18577076, upload-time = "2025-09-09T15:58:07.745Z" }, - { url = "https://files.pythonhosted.org/packages/f0/a1/b87a284fb15a42e9274e7fcea0dad259d12ddbf07c1595b26883151ca3b4/numpy-2.3.3-cp314-cp314-win32.whl", hash = "sha256:cb248499b0bc3be66ebd6578b83e5acacf1d6cb2a77f2248ce0e40fbec5a76d0", size = 6366952, upload-time = "2025-09-09T15:58:10.096Z" }, - { url = "https://files.pythonhosted.org/packages/70/5f/1816f4d08f3b8f66576d8433a66f8fa35a5acfb3bbd0bf6c31183b003f3d/numpy-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:691808c2b26b0f002a032c73255d0bd89751425f379f7bcd22d140db593a96e8", size = 12919322, upload-time = "2025-09-09T15:58:12.138Z" }, - { url = "https://files.pythonhosted.org/packages/8c/de/072420342e46a8ea41c324a555fa90fcc11637583fb8df722936aed1736d/numpy-2.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:9ad12e976ca7b10f1774b03615a2a4bab8addce37ecc77394d8e986927dc0dfe", size = 10478630, upload-time = "2025-09-09T15:58:14.64Z" }, - { url = "https://files.pythonhosted.org/packages/d5/df/ee2f1c0a9de7347f14da5dd3cd3c3b034d1b8607ccb6883d7dd5c035d631/numpy-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9cc48e09feb11e1db00b320e9d30a4151f7369afb96bd0e48d942d09da3a0d00", size = 21047987, upload-time = "2025-09-09T15:58:16.889Z" }, - { url = "https://files.pythonhosted.org/packages/d6/92/9453bdc5a4e9e69cf4358463f25e8260e2ffc126d52e10038b9077815989/numpy-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:901bf6123879b7f251d3631967fd574690734236075082078e0571977c6a8e6a", size = 14301076, upload-time = "2025-09-09T15:58:20.343Z" }, - { url = "https://files.pythonhosted.org/packages/13/77/1447b9eb500f028bb44253105bd67534af60499588a5149a94f18f2ca917/numpy-2.3.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:7f025652034199c301049296b59fa7d52c7e625017cae4c75d8662e377bf487d", size = 5229491, upload-time = "2025-09-09T15:58:22.481Z" }, - { url = "https://files.pythonhosted.org/packages/3d/f9/d72221b6ca205f9736cb4b2ce3b002f6e45cd67cd6a6d1c8af11a2f0b649/numpy-2.3.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:533ca5f6d325c80b6007d4d7fb1984c303553534191024ec6a524a4c92a5935a", size = 6737913, upload-time = "2025-09-09T15:58:24.569Z" }, - { url = "https://files.pythonhosted.org/packages/3c/5f/d12834711962ad9c46af72f79bb31e73e416ee49d17f4c797f72c96b6ca5/numpy-2.3.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0edd58682a399824633b66885d699d7de982800053acf20be1eaa46d92009c54", size = 14352811, upload-time = "2025-09-09T15:58:26.416Z" }, - { url = "https://files.pythonhosted.org/packages/a1/0d/fdbec6629d97fd1bebed56cd742884e4eead593611bbe1abc3eb40d304b2/numpy-2.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:367ad5d8fbec5d9296d18478804a530f1191e24ab4d75ab408346ae88045d25e", size = 16702689, upload-time = "2025-09-09T15:58:28.831Z" }, - { url = "https://files.pythonhosted.org/packages/9b/09/0a35196dc5575adde1eb97ddfbc3e1687a814f905377621d18ca9bc2b7dd/numpy-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8f6ac61a217437946a1fa48d24c47c91a0c4f725237871117dea264982128097", size = 16133855, upload-time = "2025-09-09T15:58:31.349Z" }, - { url = "https://files.pythonhosted.org/packages/7a/ca/c9de3ea397d576f1b6753eaa906d4cdef1bf97589a6d9825a349b4729cc2/numpy-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:179a42101b845a816d464b6fe9a845dfaf308fdfc7925387195570789bb2c970", size = 18652520, upload-time = "2025-09-09T15:58:33.762Z" }, - { url = "https://files.pythonhosted.org/packages/fd/c2/e5ed830e08cd0196351db55db82f65bc0ab05da6ef2b72a836dcf1936d2f/numpy-2.3.3-cp314-cp314t-win32.whl", hash = "sha256:1250c5d3d2562ec4174bce2e3a1523041595f9b651065e4a4473f5f48a6bc8a5", size = 6515371, upload-time = "2025-09-09T15:58:36.04Z" }, - { url = "https://files.pythonhosted.org/packages/47/c7/b0f6b5b67f6788a0725f744496badbb604d226bf233ba716683ebb47b570/numpy-2.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b37a0b2e5935409daebe82c1e42274d30d9dd355852529eab91dab8dcca7419f", size = 13112576, upload-time = "2025-09-09T15:58:37.927Z" }, - { url = "https://files.pythonhosted.org/packages/06/b9/33bba5ff6fb679aa0b1f8a07e853f002a6b04b9394db3069a1270a7784ca/numpy-2.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:78c9f6560dc7e6b3990e32df7ea1a50bbd0e2a111e05209963f5ddcab7073b0b", size = 10545953, upload-time = "2025-09-09T15:58:40.576Z" }, - { url = "https://files.pythonhosted.org/packages/b8/f2/7e0a37cfced2644c9563c529f29fa28acbd0960dde32ece683aafa6f4949/numpy-2.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1e02c7159791cd481e1e6d5ddd766b62a4d5acf8df4d4d1afe35ee9c5c33a41e", size = 21131019, upload-time = "2025-09-09T15:58:42.838Z" }, - { url = "https://files.pythonhosted.org/packages/1a/7e/3291f505297ed63831135a6cc0f474da0c868a1f31b0dd9a9f03a7a0d2ed/numpy-2.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:dca2d0fc80b3893ae72197b39f69d55a3cd8b17ea1b50aa4c62de82419936150", size = 14376288, upload-time = "2025-09-09T15:58:45.425Z" }, - { url = "https://files.pythonhosted.org/packages/bf/4b/ae02e985bdeee73d7b5abdefeb98aef1207e96d4c0621ee0cf228ddfac3c/numpy-2.3.3-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:99683cbe0658f8271b333a1b1b4bb3173750ad59c0c61f5bbdc5b318918fffe3", size = 5305425, upload-time = "2025-09-09T15:58:48.6Z" }, - { url = "https://files.pythonhosted.org/packages/8b/eb/9df215d6d7250db32007941500dc51c48190be25f2401d5b2b564e467247/numpy-2.3.3-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:d9d537a39cc9de668e5cd0e25affb17aec17b577c6b3ae8a3d866b479fbe88d0", size = 6819053, upload-time = "2025-09-09T15:58:50.401Z" }, - { url = "https://files.pythonhosted.org/packages/57/62/208293d7d6b2a8998a4a1f23ac758648c3c32182d4ce4346062018362e29/numpy-2.3.3-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8596ba2f8af5f93b01d97563832686d20206d303024777f6dfc2e7c7c3f1850e", size = 14420354, upload-time = "2025-09-09T15:58:52.704Z" }, - { url = "https://files.pythonhosted.org/packages/ed/0c/8e86e0ff7072e14a71b4c6af63175e40d1e7e933ce9b9e9f765a95b4e0c3/numpy-2.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1ec5615b05369925bd1125f27df33f3b6c8bc10d788d5999ecd8769a1fa04db", size = 16760413, upload-time = "2025-09-09T15:58:55.027Z" }, - { url = "https://files.pythonhosted.org/packages/af/11/0cc63f9f321ccf63886ac203336777140011fb669e739da36d8db3c53b98/numpy-2.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2e267c7da5bf7309670523896df97f93f6e469fb931161f483cd6882b3b1a5dc", size = 12971844, upload-time = "2025-09-09T15:58:57.359Z" }, + { url = "https://files.pythonhosted.org/packages/60/e7/0e07379944aa8afb49a556a2b54587b828eb41dc9adc56fb7615b678ca53/numpy-2.3.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e78aecd2800b32e8347ce49316d3eaf04aed849cd5b38e0af39f829a4e59f5eb", size = 21259519, upload-time = "2025-10-15T16:15:19.012Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cb/5a69293561e8819b09e34ed9e873b9a82b5f2ade23dce4c51dc507f6cfe1/numpy-2.3.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7fd09cc5d65bda1e79432859c40978010622112e9194e581e3415a3eccc7f43f", size = 14452796, upload-time = "2025-10-15T16:15:23.094Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/ff11611200acd602a1e5129e36cfd25bf01ad8e5cf927baf2e90236eb02e/numpy-2.3.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:1b219560ae2c1de48ead517d085bc2d05b9433f8e49d0955c82e8cd37bd7bf36", size = 5381639, upload-time = "2025-10-15T16:15:25.572Z" }, + { url = "https://files.pythonhosted.org/packages/ea/77/e95c757a6fe7a48d28a009267408e8aa382630cc1ad1db7451b3bc21dbb4/numpy-2.3.4-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:bafa7d87d4c99752d07815ed7a2c0964f8ab311eb8168f41b910bd01d15b6032", size = 6914296, upload-time = "2025-10-15T16:15:27.079Z" }, + { url = "https://files.pythonhosted.org/packages/a3/d2/137c7b6841c942124eae921279e5c41b1c34bab0e6fc60c7348e69afd165/numpy-2.3.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36dc13af226aeab72b7abad501d370d606326a0029b9f435eacb3b8c94b8a8b7", size = 14591904, upload-time = "2025-10-15T16:15:29.044Z" }, + { url = "https://files.pythonhosted.org/packages/bb/32/67e3b0f07b0aba57a078c4ab777a9e8e6bc62f24fb53a2337f75f9691699/numpy-2.3.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7b2f9a18b5ff9824a6af80de4f37f4ec3c2aab05ef08f51c77a093f5b89adda", size = 16939602, upload-time = "2025-10-15T16:15:31.106Z" }, + { url = "https://files.pythonhosted.org/packages/95/22/9639c30e32c93c4cee3ccdb4b09c2d0fbff4dcd06d36b357da06146530fb/numpy-2.3.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9984bd645a8db6ca15d850ff996856d8762c51a2239225288f08f9050ca240a0", size = 16372661, upload-time = "2025-10-15T16:15:33.546Z" }, + { url = "https://files.pythonhosted.org/packages/12/e9/a685079529be2b0156ae0c11b13d6be647743095bb51d46589e95be88086/numpy-2.3.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:64c5825affc76942973a70acf438a8ab618dbd692b84cd5ec40a0a0509edc09a", size = 18884682, upload-time = "2025-10-15T16:15:36.105Z" }, + { url = "https://files.pythonhosted.org/packages/cf/85/f6f00d019b0cc741e64b4e00ce865a57b6bed945d1bbeb1ccadbc647959b/numpy-2.3.4-cp311-cp311-win32.whl", hash = "sha256:ed759bf7a70342f7817d88376eb7142fab9fef8320d6019ef87fae05a99874e1", size = 6570076, upload-time = "2025-10-15T16:15:38.225Z" }, + { url = "https://files.pythonhosted.org/packages/7d/10/f8850982021cb90e2ec31990291f9e830ce7d94eef432b15066e7cbe0bec/numpy-2.3.4-cp311-cp311-win_amd64.whl", hash = "sha256:faba246fb30ea2a526c2e9645f61612341de1a83fb1e0c5edf4ddda5a9c10996", size = 13089358, upload-time = "2025-10-15T16:15:40.404Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ad/afdd8351385edf0b3445f9e24210a9c3971ef4de8fd85155462fc4321d79/numpy-2.3.4-cp311-cp311-win_arm64.whl", hash = "sha256:4c01835e718bcebe80394fd0ac66c07cbb90147ebbdad3dcecd3f25de2ae7e2c", size = 10462292, upload-time = "2025-10-15T16:15:42.896Z" }, + { url = "https://files.pythonhosted.org/packages/96/7a/02420400b736f84317e759291b8edaeee9dc921f72b045475a9cbdb26b17/numpy-2.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ef1b5a3e808bc40827b5fa2c8196151a4c5abe110e1726949d7abddfe5c7ae11", size = 20957727, upload-time = "2025-10-15T16:15:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/18/90/a014805d627aa5750f6f0e878172afb6454552da929144b3c07fcae1bb13/numpy-2.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c2f91f496a87235c6aaf6d3f3d89b17dba64996abadccb289f48456cff931ca9", size = 14187262, upload-time = "2025-10-15T16:15:47.761Z" }, + { url = "https://files.pythonhosted.org/packages/c7/e4/0a94b09abe89e500dc748e7515f21a13e30c5c3fe3396e6d4ac108c25fca/numpy-2.3.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:f77e5b3d3da652b474cc80a14084927a5e86a5eccf54ca8ca5cbd697bf7f2667", size = 5115992, upload-time = "2025-10-15T16:15:50.144Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/db77c75b055c6157cbd4f9c92c4458daef0dd9cbe6d8d2fe7f803cb64c37/numpy-2.3.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:8ab1c5f5ee40d6e01cbe96de5863e39b215a4d24e7d007cad56c7184fdf4aeef", size = 6648672, upload-time = "2025-10-15T16:15:52.442Z" }, + { url = "https://files.pythonhosted.org/packages/e1/e6/e31b0d713719610e406c0ea3ae0d90760465b086da8783e2fd835ad59027/numpy-2.3.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77b84453f3adcb994ddbd0d1c5d11db2d6bda1a2b7fd5ac5bd4649d6f5dc682e", size = 14284156, upload-time = "2025-10-15T16:15:54.351Z" }, + { url = "https://files.pythonhosted.org/packages/f9/58/30a85127bfee6f108282107caf8e06a1f0cc997cb6b52cdee699276fcce4/numpy-2.3.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4121c5beb58a7f9e6dfdee612cb24f4df5cd4db6e8261d7f4d7450a997a65d6a", size = 16641271, upload-time = "2025-10-15T16:15:56.67Z" }, + { url = "https://files.pythonhosted.org/packages/06/f2/2e06a0f2adf23e3ae29283ad96959267938d0efd20a2e25353b70065bfec/numpy-2.3.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:65611ecbb00ac9846efe04db15cbe6186f562f6bb7e5e05f077e53a599225d16", size = 16059531, upload-time = "2025-10-15T16:15:59.412Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e7/b106253c7c0d5dc352b9c8fab91afd76a93950998167fa3e5afe4ef3a18f/numpy-2.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dabc42f9c6577bcc13001b8810d300fe814b4cfbe8a92c873f269484594f9786", size = 18578983, upload-time = "2025-10-15T16:16:01.804Z" }, + { url = "https://files.pythonhosted.org/packages/73/e3/04ecc41e71462276ee867ccbef26a4448638eadecf1bc56772c9ed6d0255/numpy-2.3.4-cp312-cp312-win32.whl", hash = "sha256:a49d797192a8d950ca59ee2d0337a4d804f713bb5c3c50e8db26d49666e351dc", size = 6291380, upload-time = "2025-10-15T16:16:03.938Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a8/566578b10d8d0e9955b1b6cd5db4e9d4592dd0026a941ff7994cedda030a/numpy-2.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:985f1e46358f06c2a09921e8921e2c98168ed4ae12ccd6e5e87a4f1857923f32", size = 12787999, upload-time = "2025-10-15T16:16:05.801Z" }, + { url = "https://files.pythonhosted.org/packages/58/22/9c903a957d0a8071b607f5b1bff0761d6e608b9a965945411f867d515db1/numpy-2.3.4-cp312-cp312-win_arm64.whl", hash = "sha256:4635239814149e06e2cb9db3dd584b2fa64316c96f10656983b8026a82e6e4db", size = 10197412, upload-time = "2025-10-15T16:16:07.854Z" }, + { url = "https://files.pythonhosted.org/packages/57/7e/b72610cc91edf138bc588df5150957a4937221ca6058b825b4725c27be62/numpy-2.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c090d4860032b857d94144d1a9976b8e36709e40386db289aaf6672de2a81966", size = 20950335, upload-time = "2025-10-15T16:16:10.304Z" }, + { url = "https://files.pythonhosted.org/packages/3e/46/bdd3370dcea2f95ef14af79dbf81e6927102ddf1cc54adc0024d61252fd9/numpy-2.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a13fc473b6db0be619e45f11f9e81260f7302f8d180c49a22b6e6120022596b3", size = 14179878, upload-time = "2025-10-15T16:16:12.595Z" }, + { url = "https://files.pythonhosted.org/packages/ac/01/5a67cb785bda60f45415d09c2bc245433f1c68dd82eef9c9002c508b5a65/numpy-2.3.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:3634093d0b428e6c32c3a69b78e554f0cd20ee420dcad5a9f3b2a63762ce4197", size = 5108673, upload-time = "2025-10-15T16:16:14.877Z" }, + { url = "https://files.pythonhosted.org/packages/c2/cd/8428e23a9fcebd33988f4cb61208fda832800ca03781f471f3727a820704/numpy-2.3.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:043885b4f7e6e232d7df4f51ffdef8c36320ee9d5f227b380ea636722c7ed12e", size = 6641438, upload-time = "2025-10-15T16:16:16.805Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d1/913fe563820f3c6b079f992458f7331278dcd7ba8427e8e745af37ddb44f/numpy-2.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4ee6a571d1e4f0ea6d5f22d6e5fbd6ed1dc2b18542848e1e7301bd190500c9d7", size = 14281290, upload-time = "2025-10-15T16:16:18.764Z" }, + { url = "https://files.pythonhosted.org/packages/9e/7e/7d306ff7cb143e6d975cfa7eb98a93e73495c4deabb7d1b5ecf09ea0fd69/numpy-2.3.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc8a63918b04b8571789688b2780ab2b4a33ab44bfe8ccea36d3eba51228c953", size = 16636543, upload-time = "2025-10-15T16:16:21.072Z" }, + { url = "https://files.pythonhosted.org/packages/47/6a/8cfc486237e56ccfb0db234945552a557ca266f022d281a2f577b98e955c/numpy-2.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:40cc556d5abbc54aabe2b1ae287042d7bdb80c08edede19f0c0afb36ae586f37", size = 16056117, upload-time = "2025-10-15T16:16:23.369Z" }, + { url = "https://files.pythonhosted.org/packages/b1/0e/42cb5e69ea901e06ce24bfcc4b5664a56f950a70efdcf221f30d9615f3f3/numpy-2.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ecb63014bb7f4ce653f8be7f1df8cbc6093a5a2811211770f6606cc92b5a78fd", size = 18577788, upload-time = "2025-10-15T16:16:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/86/92/41c3d5157d3177559ef0a35da50f0cda7fa071f4ba2306dd36818591a5bc/numpy-2.3.4-cp313-cp313-win32.whl", hash = "sha256:e8370eb6925bb8c1c4264fec52b0384b44f675f191df91cbe0140ec9f0955646", size = 6282620, upload-time = "2025-10-15T16:16:29.811Z" }, + { url = "https://files.pythonhosted.org/packages/09/97/fd421e8bc50766665ad35536c2bb4ef916533ba1fdd053a62d96cc7c8b95/numpy-2.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:56209416e81a7893036eea03abcb91c130643eb14233b2515c90dcac963fe99d", size = 12784672, upload-time = "2025-10-15T16:16:31.589Z" }, + { url = "https://files.pythonhosted.org/packages/ad/df/5474fb2f74970ca8eb978093969b125a84cc3d30e47f82191f981f13a8a0/numpy-2.3.4-cp313-cp313-win_arm64.whl", hash = "sha256:a700a4031bc0fd6936e78a752eefb79092cecad2599ea9c8039c548bc097f9bc", size = 10196702, upload-time = "2025-10-15T16:16:33.902Z" }, + { url = "https://files.pythonhosted.org/packages/11/83/66ac031464ec1767ea3ed48ce40f615eb441072945e98693bec0bcd056cc/numpy-2.3.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:86966db35c4040fdca64f0816a1c1dd8dbd027d90fca5a57e00e1ca4cd41b879", size = 21049003, upload-time = "2025-10-15T16:16:36.101Z" }, + { url = "https://files.pythonhosted.org/packages/5f/99/5b14e0e686e61371659a1d5bebd04596b1d72227ce36eed121bb0aeab798/numpy-2.3.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:838f045478638b26c375ee96ea89464d38428c69170360b23a1a50fa4baa3562", size = 14302980, upload-time = "2025-10-15T16:16:39.124Z" }, + { url = "https://files.pythonhosted.org/packages/2c/44/e9486649cd087d9fc6920e3fc3ac2aba10838d10804b1e179fb7cbc4e634/numpy-2.3.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d7315ed1dab0286adca467377c8381cd748f3dc92235f22a7dfc42745644a96a", size = 5231472, upload-time = "2025-10-15T16:16:41.168Z" }, + { url = "https://files.pythonhosted.org/packages/3e/51/902b24fa8887e5fe2063fd61b1895a476d0bbf46811ab0c7fdf4bd127345/numpy-2.3.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:84f01a4d18b2cc4ade1814a08e5f3c907b079c847051d720fad15ce37aa930b6", size = 6739342, upload-time = "2025-10-15T16:16:43.777Z" }, + { url = "https://files.pythonhosted.org/packages/34/f1/4de9586d05b1962acdcdb1dc4af6646361a643f8c864cef7c852bf509740/numpy-2.3.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:817e719a868f0dacde4abdfc5c1910b301877970195db9ab6a5e2c4bd5b121f7", size = 14354338, upload-time = "2025-10-15T16:16:46.081Z" }, + { url = "https://files.pythonhosted.org/packages/1f/06/1c16103b425de7969d5a76bdf5ada0804b476fed05d5f9e17b777f1cbefd/numpy-2.3.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85e071da78d92a214212cacea81c6da557cab307f2c34b5f85b628e94803f9c0", size = 16702392, upload-time = "2025-10-15T16:16:48.455Z" }, + { url = "https://files.pythonhosted.org/packages/34/b2/65f4dc1b89b5322093572b6e55161bb42e3e0487067af73627f795cc9d47/numpy-2.3.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2ec646892819370cf3558f518797f16597b4e4669894a2ba712caccc9da53f1f", size = 16134998, upload-time = "2025-10-15T16:16:51.114Z" }, + { url = "https://files.pythonhosted.org/packages/d4/11/94ec578896cdb973aaf56425d6c7f2aff4186a5c00fac15ff2ec46998b46/numpy-2.3.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:035796aaaddfe2f9664b9a9372f089cfc88bd795a67bd1bfe15e6e770934cf64", size = 18651574, upload-time = "2025-10-15T16:16:53.429Z" }, + { url = "https://files.pythonhosted.org/packages/62/b7/7efa763ab33dbccf56dade36938a77345ce8e8192d6b39e470ca25ff3cd0/numpy-2.3.4-cp313-cp313t-win32.whl", hash = "sha256:fea80f4f4cf83b54c3a051f2f727870ee51e22f0248d3114b8e755d160b38cfb", size = 6413135, upload-time = "2025-10-15T16:16:55.992Z" }, + { url = "https://files.pythonhosted.org/packages/43/70/aba4c38e8400abcc2f345e13d972fb36c26409b3e644366db7649015f291/numpy-2.3.4-cp313-cp313t-win_amd64.whl", hash = "sha256:15eea9f306b98e0be91eb344a94c0e630689ef302e10c2ce5f7e11905c704f9c", size = 12928582, upload-time = "2025-10-15T16:16:57.943Z" }, + { url = "https://files.pythonhosted.org/packages/67/63/871fad5f0073fc00fbbdd7232962ea1ac40eeaae2bba66c76214f7954236/numpy-2.3.4-cp313-cp313t-win_arm64.whl", hash = "sha256:b6c231c9c2fadbae4011ca5e7e83e12dc4a5072f1a1d85a0a7b3ed754d145a40", size = 10266691, upload-time = "2025-10-15T16:17:00.048Z" }, + { url = "https://files.pythonhosted.org/packages/72/71/ae6170143c115732470ae3a2d01512870dd16e0953f8a6dc89525696069b/numpy-2.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:81c3e6d8c97295a7360d367f9f8553973651b76907988bb6066376bc2252f24e", size = 20955580, upload-time = "2025-10-15T16:17:02.509Z" }, + { url = "https://files.pythonhosted.org/packages/af/39/4be9222ffd6ca8a30eda033d5f753276a9c3426c397bb137d8e19dedd200/numpy-2.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7c26b0b2bf58009ed1f38a641f3db4be8d960a417ca96d14e5b06df1506d41ff", size = 14188056, upload-time = "2025-10-15T16:17:04.873Z" }, + { url = "https://files.pythonhosted.org/packages/6c/3d/d85f6700d0a4aa4f9491030e1021c2b2b7421b2b38d01acd16734a2bfdc7/numpy-2.3.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:62b2198c438058a20b6704351b35a1d7db881812d8512d67a69c9de1f18ca05f", size = 5116555, upload-time = "2025-10-15T16:17:07.499Z" }, + { url = "https://files.pythonhosted.org/packages/bf/04/82c1467d86f47eee8a19a464c92f90a9bb68ccf14a54c5224d7031241ffb/numpy-2.3.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:9d729d60f8d53a7361707f4b68a9663c968882dd4f09e0d58c044c8bf5faee7b", size = 6643581, upload-time = "2025-10-15T16:17:09.774Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d3/c79841741b837e293f48bd7db89d0ac7a4f2503b382b78a790ef1dc778a5/numpy-2.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd0c630cf256b0a7fd9d0a11c9413b42fef5101219ce6ed5a09624f5a65392c7", size = 14299186, upload-time = "2025-10-15T16:17:11.937Z" }, + { url = "https://files.pythonhosted.org/packages/e8/7e/4a14a769741fbf237eec5a12a2cbc7a4c4e061852b6533bcb9e9a796c908/numpy-2.3.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5e081bc082825f8b139f9e9fe42942cb4054524598aaeb177ff476cc76d09d2", size = 16638601, upload-time = "2025-10-15T16:17:14.391Z" }, + { url = "https://files.pythonhosted.org/packages/93/87/1c1de269f002ff0a41173fe01dcc925f4ecff59264cd8f96cf3b60d12c9b/numpy-2.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15fb27364ed84114438fff8aaf998c9e19adbeba08c0b75409f8c452a8692c52", size = 16074219, upload-time = "2025-10-15T16:17:17.058Z" }, + { url = "https://files.pythonhosted.org/packages/cd/28/18f72ee77408e40a76d691001ae599e712ca2a47ddd2c4f695b16c65f077/numpy-2.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:85d9fb2d8cd998c84d13a79a09cc0c1091648e848e4e6249b0ccd7f6b487fa26", size = 18576702, upload-time = "2025-10-15T16:17:19.379Z" }, + { url = "https://files.pythonhosted.org/packages/c3/76/95650169b465ececa8cf4b2e8f6df255d4bf662775e797ade2025cc51ae6/numpy-2.3.4-cp314-cp314-win32.whl", hash = "sha256:e73d63fd04e3a9d6bc187f5455d81abfad05660b212c8804bf3b407e984cd2bc", size = 6337136, upload-time = "2025-10-15T16:17:22.886Z" }, + { url = "https://files.pythonhosted.org/packages/dc/89/a231a5c43ede5d6f77ba4a91e915a87dea4aeea76560ba4d2bf185c683f0/numpy-2.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:3da3491cee49cf16157e70f607c03a217ea6647b1cea4819c4f48e53d49139b9", size = 12920542, upload-time = "2025-10-15T16:17:24.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/0c/ae9434a888f717c5ed2ff2393b3f344f0ff6f1c793519fa0c540461dc530/numpy-2.3.4-cp314-cp314-win_arm64.whl", hash = "sha256:6d9cd732068e8288dbe2717177320723ccec4fb064123f0caf9bbd90ab5be868", size = 10480213, upload-time = "2025-10-15T16:17:26.935Z" }, + { url = "https://files.pythonhosted.org/packages/83/4b/c4a5f0841f92536f6b9592694a5b5f68c9ab37b775ff342649eadf9055d3/numpy-2.3.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:22758999b256b595cf0b1d102b133bb61866ba5ceecf15f759623b64c020c9ec", size = 21052280, upload-time = "2025-10-15T16:17:29.638Z" }, + { url = "https://files.pythonhosted.org/packages/3e/80/90308845fc93b984d2cc96d83e2324ce8ad1fd6efea81b324cba4b673854/numpy-2.3.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9cb177bc55b010b19798dc5497d540dea67fd13a8d9e882b2dae71de0cf09eb3", size = 14302930, upload-time = "2025-10-15T16:17:32.384Z" }, + { url = "https://files.pythonhosted.org/packages/3d/4e/07439f22f2a3b247cec4d63a713faae55e1141a36e77fb212881f7cda3fb/numpy-2.3.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:0f2bcc76f1e05e5ab58893407c63d90b2029908fa41f9f1cc51eecce936c3365", size = 5231504, upload-time = "2025-10-15T16:17:34.515Z" }, + { url = "https://files.pythonhosted.org/packages/ab/de/1e11f2547e2fe3d00482b19721855348b94ada8359aef5d40dd57bfae9df/numpy-2.3.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:8dc20bde86802df2ed8397a08d793da0ad7a5fd4ea3ac85d757bf5dd4ad7c252", size = 6739405, upload-time = "2025-10-15T16:17:36.128Z" }, + { url = "https://files.pythonhosted.org/packages/3b/40/8cd57393a26cebe2e923005db5134a946c62fa56a1087dc7c478f3e30837/numpy-2.3.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e199c087e2aa71c8f9ce1cb7a8e10677dc12457e7cc1be4798632da37c3e86e", size = 14354866, upload-time = "2025-10-15T16:17:38.884Z" }, + { url = "https://files.pythonhosted.org/packages/93/39/5b3510f023f96874ee6fea2e40dfa99313a00bf3ab779f3c92978f34aace/numpy-2.3.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85597b2d25ddf655495e2363fe044b0ae999b75bc4d630dc0d886484b03a5eb0", size = 16703296, upload-time = "2025-10-15T16:17:41.564Z" }, + { url = "https://files.pythonhosted.org/packages/41/0d/19bb163617c8045209c1996c4e427bccbc4bbff1e2c711f39203c8ddbb4a/numpy-2.3.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:04a69abe45b49c5955923cf2c407843d1c85013b424ae8a560bba16c92fe44a0", size = 16136046, upload-time = "2025-10-15T16:17:43.901Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c1/6dba12fdf68b02a21ac411c9df19afa66bed2540f467150ca64d246b463d/numpy-2.3.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e1708fac43ef8b419c975926ce1eaf793b0c13b7356cfab6ab0dc34c0a02ac0f", size = 18652691, upload-time = "2025-10-15T16:17:46.247Z" }, + { url = "https://files.pythonhosted.org/packages/f8/73/f85056701dbbbb910c51d846c58d29fd46b30eecd2b6ba760fc8b8a1641b/numpy-2.3.4-cp314-cp314t-win32.whl", hash = "sha256:863e3b5f4d9915aaf1b8ec79ae560ad21f0b8d5e3adc31e73126491bb86dee1d", size = 6485782, upload-time = "2025-10-15T16:17:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/17/90/28fa6f9865181cb817c2471ee65678afa8a7e2a1fb16141473d5fa6bacc3/numpy-2.3.4-cp314-cp314t-win_amd64.whl", hash = "sha256:962064de37b9aef801d33bc579690f8bfe6c5e70e29b61783f60bcba838a14d6", size = 13113301, upload-time = "2025-10-15T16:17:50.938Z" }, + { url = "https://files.pythonhosted.org/packages/54/23/08c002201a8e7e1f9afba93b97deceb813252d9cfd0d3351caed123dcf97/numpy-2.3.4-cp314-cp314t-win_arm64.whl", hash = "sha256:8b5a9a39c45d852b62693d9b3f3e0fe052541f804296ff401a72a1b60edafb29", size = 10547532, upload-time = "2025-10-15T16:17:53.48Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b6/64898f51a86ec88ca1257a59c1d7fd077b60082a119affefcdf1dd0df8ca/numpy-2.3.4-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6e274603039f924c0fe5cb73438fa9246699c78a6df1bd3decef9ae592ae1c05", size = 21131552, upload-time = "2025-10-15T16:17:55.845Z" }, + { url = "https://files.pythonhosted.org/packages/ce/4c/f135dc6ebe2b6a3c77f4e4838fa63d350f85c99462012306ada1bd4bc460/numpy-2.3.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d149aee5c72176d9ddbc6803aef9c0f6d2ceeea7626574fc68518da5476fa346", size = 14377796, upload-time = "2025-10-15T16:17:58.308Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a4/f33f9c23fcc13dd8412fc8614559b5b797e0aba9d8e01dfa8bae10c84004/numpy-2.3.4-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:6d34ed9db9e6395bb6cd33286035f73a59b058169733a9db9f85e650b88df37e", size = 5306904, upload-time = "2025-10-15T16:18:00.596Z" }, + { url = "https://files.pythonhosted.org/packages/28/af/c44097f25f834360f9fb960fa082863e0bad14a42f36527b2a121abdec56/numpy-2.3.4-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:fdebe771ca06bb8d6abce84e51dca9f7921fe6ad34a0c914541b063e9a68928b", size = 6819682, upload-time = "2025-10-15T16:18:02.32Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8c/cd283b54c3c2b77e188f63e23039844f56b23bba1712318288c13fe86baf/numpy-2.3.4-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e92defe6c08211eb77902253b14fe5b480ebc5112bc741fd5e9cd0608f847", size = 14422300, upload-time = "2025-10-15T16:18:04.271Z" }, + { url = "https://files.pythonhosted.org/packages/b0/f0/8404db5098d92446b3e3695cf41c6f0ecb703d701cb0b7566ee2177f2eee/numpy-2.3.4-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13b9062e4f5c7ee5c7e5be96f29ba71bc5a37fed3d1d77c37390ae00724d296d", size = 16760806, upload-time = "2025-10-15T16:18:06.668Z" }, + { url = "https://files.pythonhosted.org/packages/95/8e/2844c3959ce9a63acc7c8e50881133d86666f0420bcde695e115ced0920f/numpy-2.3.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:81b3a59793523e552c4a96109dde028aa4448ae06ccac5a76ff6532a85558a7f", size = 12973130, upload-time = "2025-10-15T16:18:09.397Z" }, ] [[package]] @@ -842,7 +867,7 @@ dev = [ { name = "pdoc", specifier = ">=15.0.4" }, { name = "pydocstyle", specifier = ">=6.3.0" }, { name = "pytest", specifier = ">=8.4.1" }, - { name = "pytest-cov" }, + { name = "pytest-cov", specifier = ">=7.0.0" }, { name = "ruff", specifier = ">=0.12.4" }, ] @@ -898,7 +923,7 @@ wheels = [ [[package]] name = "pydantic" -version = "2.12.0" +version = "2.12.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -906,9 +931,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c3/da/b8a7ee04378a53f6fefefc0c5e05570a3ebfdfa0523a878bcd3b475683ee/pydantic-2.12.0.tar.gz", hash = "sha256:c1a077e6270dbfb37bfd8b498b3981e2bb18f68103720e51fa6c306a5a9af563", size = 814760, upload-time = "2025-10-07T15:58:03.467Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8d/35/d319ed522433215526689bad428a94058b6dd12190ce7ddd78618ac14b28/pydantic-2.12.2.tar.gz", hash = "sha256:7b8fa15b831a4bbde9d5b84028641ac3080a4ca2cbd4a621a661687e741624fd", size = 816358, upload-time = "2025-10-14T15:02:21.842Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/9d/d5c855424e2e5b6b626fbc6ec514d8e655a600377ce283008b115abb7445/pydantic-2.12.0-py3-none-any.whl", hash = "sha256:f6a1da352d42790537e95e83a8bdfb91c7efbae63ffd0b86fa823899e807116f", size = 459730, upload-time = "2025-10-07T15:58:01.576Z" }, + { url = "https://files.pythonhosted.org/packages/6c/98/468cb649f208a6f1279448e6e5247b37ae79cf5e4041186f1e2ef3d16345/pydantic-2.12.2-py3-none-any.whl", hash = "sha256:25ff718ee909acd82f1ff9b1a4acfd781bb23ab3739adaa7144f19a6a4e231ae", size = 460628, upload-time = "2025-10-14T15:02:19.623Z" }, ] [package.optional-dependencies] @@ -918,112 +943,116 @@ email = [ [[package]] name = "pydantic-core" -version = "2.41.1" +version = "2.41.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/14/12b4a0d2b0b10d8e1d9a24ad94e7bbb43335eaf29c0c4e57860e8a30734a/pydantic_core-2.41.1.tar.gz", hash = "sha256:1ad375859a6d8c356b7704ec0f547a58e82ee80bb41baa811ad710e124bc8f2f", size = 454870, upload-time = "2025-10-07T10:50:45.974Z" } +sdist = { url = "https://files.pythonhosted.org/packages/df/18/d0944e8eaaa3efd0a91b0f1fc537d3be55ad35091b6a87638211ba691964/pydantic_core-2.41.4.tar.gz", hash = "sha256:70e47929a9d4a1905a67e4b687d5946026390568a8e952b92824118063cee4d5", size = 457557, upload-time = "2025-10-14T10:23:47.909Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/2c/a5c4640dc7132540109f67fe83b566fbc7512ccf2a068cfa22a243df70c7/pydantic_core-2.41.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:e63036298322e9aea1c8b7c0a6c1204d615dbf6ec0668ce5b83ff27f07404a61", size = 2113814, upload-time = "2025-10-06T21:09:50.892Z" }, - { url = "https://files.pythonhosted.org/packages/e3/e7/a8694c3454a57842095d69c7a4ab3cf81c3c7b590f052738eabfdfc2e234/pydantic_core-2.41.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:241299ca91fc77ef64f11ed909d2d9220a01834e8e6f8de61275c4dd16b7c936", size = 1916660, upload-time = "2025-10-06T21:09:52.783Z" }, - { url = "https://files.pythonhosted.org/packages/9c/58/29f12e65b19c1877a0269eb4f23c5d2267eded6120a7d6762501ab843dc9/pydantic_core-2.41.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1ab7e594a2a5c24ab8013a7dc8cfe5f2260e80e490685814122081705c2cf2b0", size = 1975071, upload-time = "2025-10-06T21:09:54.009Z" }, - { url = "https://files.pythonhosted.org/packages/98/26/4e677f2b7ec3fbdd10be6b586a82a814c8ebe3e474024c8df2d4260e564e/pydantic_core-2.41.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b054ef1a78519cb934b58e9c90c09e93b837c935dcd907b891f2b265b129eb6e", size = 2067271, upload-time = "2025-10-06T21:09:55.175Z" }, - { url = "https://files.pythonhosted.org/packages/29/50/50614bd906089904d7ca1be3b9ecf08c00a327143d48f1decfdc21b3c302/pydantic_core-2.41.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f2ab7d10d0ab2ed6da54c757233eb0f48ebfb4f86e9b88ccecb3f92bbd61a538", size = 2253207, upload-time = "2025-10-06T21:09:56.709Z" }, - { url = "https://files.pythonhosted.org/packages/ea/58/b1e640b4ca559273cca7c28e0fe8891d5d8e9a600f5ab4882670ec107549/pydantic_core-2.41.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2757606b7948bb853a27e4040820306eaa0ccb9e8f9f8a0fa40cb674e170f350", size = 2375052, upload-time = "2025-10-06T21:09:57.97Z" }, - { url = "https://files.pythonhosted.org/packages/53/25/cd47df3bfb24350e03835f0950288d1054f1cc9a8023401dabe6d4ff2834/pydantic_core-2.41.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cec0e75eb61f606bad0a32f2be87507087514e26e8c73db6cbdb8371ccd27917", size = 2076834, upload-time = "2025-10-06T21:09:59.58Z" }, - { url = "https://files.pythonhosted.org/packages/ec/b4/71b2c77e5df527fbbc1a03e72c3fd96c44cd10d4241a81befef8c12b9fc4/pydantic_core-2.41.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0234236514f44a5bf552105cfe2543a12f48203397d9d0f866affa569345a5b5", size = 2195374, upload-time = "2025-10-06T21:10:01.18Z" }, - { url = "https://files.pythonhosted.org/packages/aa/08/4b8a50733005865efde284fec45da75fe16a258f706e16323c5ace4004eb/pydantic_core-2.41.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:1b974e41adfbb4ebb0f65fc4ca951347b17463d60893ba7d5f7b9bb087c83897", size = 2156060, upload-time = "2025-10-06T21:10:02.74Z" }, - { url = "https://files.pythonhosted.org/packages/83/c3/1037cb603ef2130c210150a51b1710d86825b5c28df54a55750099f91196/pydantic_core-2.41.1-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:248dafb3204136113c383e91a4d815269f51562b6659b756cf3df14eefc7d0bb", size = 2331640, upload-time = "2025-10-06T21:10:04.39Z" }, - { url = "https://files.pythonhosted.org/packages/56/4c/52d111869610e6b1a46e1f1035abcdc94d0655587e39104433a290e9f377/pydantic_core-2.41.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:678f9d76a91d6bcedd7568bbf6beb77ae8447f85d1aeebaab7e2f0829cfc3a13", size = 2329844, upload-time = "2025-10-06T21:10:05.68Z" }, - { url = "https://files.pythonhosted.org/packages/32/5d/4b435f0b52ab543967761aca66b84ad3f0026e491e57de47693d15d0a8db/pydantic_core-2.41.1-cp310-cp310-win32.whl", hash = "sha256:dff5bee1d21ee58277900692a641925d2dddfde65182c972569b1a276d2ac8fb", size = 1991289, upload-time = "2025-10-06T21:10:07.199Z" }, - { url = "https://files.pythonhosted.org/packages/88/52/31b4deafc1d3cb96d0e7c0af70f0dc05454982d135d07f5117e6336153e8/pydantic_core-2.41.1-cp310-cp310-win_amd64.whl", hash = "sha256:5042da12e5d97d215f91567110fdfa2e2595a25f17c19b9ff024f31c34f9b53e", size = 2027747, upload-time = "2025-10-06T21:10:08.503Z" }, - { url = "https://files.pythonhosted.org/packages/f6/a9/ec440f02e57beabdfd804725ef1e38ac1ba00c49854d298447562e119513/pydantic_core-2.41.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4f276a6134fe1fc1daa692642a3eaa2b7b858599c49a7610816388f5e37566a1", size = 2111456, upload-time = "2025-10-06T21:10:09.824Z" }, - { url = "https://files.pythonhosted.org/packages/f0/f9/6bc15bacfd8dcfc073a1820a564516d9c12a435a9a332d4cbbfd48828ddd/pydantic_core-2.41.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:07588570a805296ece009c59d9a679dc08fab72fb337365afb4f3a14cfbfc176", size = 1915012, upload-time = "2025-10-06T21:10:11.599Z" }, - { url = "https://files.pythonhosted.org/packages/38/8a/d9edcdcdfe80bade17bed424284427c08bea892aaec11438fa52eaeaf79c/pydantic_core-2.41.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28527e4b53400cd60ffbd9812ccb2b5135d042129716d71afd7e45bf42b855c0", size = 1973762, upload-time = "2025-10-06T21:10:13.154Z" }, - { url = "https://files.pythonhosted.org/packages/d5/b3/ff225c6d49fba4279de04677c1c876fc3dc6562fd0c53e9bfd66f58c51a8/pydantic_core-2.41.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46a1c935c9228bad738c8a41de06478770927baedf581d172494ab36a6b96575", size = 2065386, upload-time = "2025-10-06T21:10:14.436Z" }, - { url = "https://files.pythonhosted.org/packages/47/ba/183e8c0be4321314af3fd1ae6bfc7eafdd7a49bdea5da81c56044a207316/pydantic_core-2.41.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:447ddf56e2b7d28d200d3e9eafa936fe40485744b5a824b67039937580b3cb20", size = 2252317, upload-time = "2025-10-06T21:10:15.719Z" }, - { url = "https://files.pythonhosted.org/packages/57/c5/aab61e94fd02f45c65f1f8c9ec38bb3b33fbf001a1837c74870e97462572/pydantic_core-2.41.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63892ead40c1160ac860b5debcc95c95c5a0035e543a8b5a4eac70dd22e995f4", size = 2373405, upload-time = "2025-10-06T21:10:17.017Z" }, - { url = "https://files.pythonhosted.org/packages/e5/4f/3aaa3bd1ea420a15acc42d7d3ccb3b0bbc5444ae2f9dbc1959f8173e16b8/pydantic_core-2.41.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f4a9543ca355e6df8fbe9c83e9faab707701e9103ae857ecb40f1c0cf8b0e94d", size = 2073794, upload-time = "2025-10-06T21:10:18.383Z" }, - { url = "https://files.pythonhosted.org/packages/58/bd/e3975cdebe03ec080ef881648de316c73f2a6be95c14fc4efb2f7bdd0d41/pydantic_core-2.41.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f2611bdb694116c31e551ed82e20e39a90bea9b7ad9e54aaf2d045ad621aa7a1", size = 2194430, upload-time = "2025-10-06T21:10:19.638Z" }, - { url = "https://files.pythonhosted.org/packages/2b/b8/6b7e7217f147d3b3105b57fb1caec3c4f667581affdfaab6d1d277e1f749/pydantic_core-2.41.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fecc130893a9b5f7bfe230be1bb8c61fe66a19db8ab704f808cb25a82aad0bc9", size = 2154611, upload-time = "2025-10-06T21:10:21.28Z" }, - { url = "https://files.pythonhosted.org/packages/fe/7b/239c2fe76bd8b7eef9ae2140d737368a3c6fea4fd27f8f6b4cde6baa3ce9/pydantic_core-2.41.1-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:1e2df5f8344c99b6ea5219f00fdc8950b8e6f2c422fbc1cc122ec8641fac85a1", size = 2329809, upload-time = "2025-10-06T21:10:22.678Z" }, - { url = "https://files.pythonhosted.org/packages/bd/2e/77a821a67ff0786f2f14856d6bd1348992f695ee90136a145d7a445c1ff6/pydantic_core-2.41.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:35291331e9d8ed94c257bab6be1cb3a380b5eee570a2784bffc055e18040a2ea", size = 2327907, upload-time = "2025-10-06T21:10:24.447Z" }, - { url = "https://files.pythonhosted.org/packages/fd/9a/b54512bb9df7f64c586b369328c30481229b70ca6a5fcbb90b715e15facf/pydantic_core-2.41.1-cp311-cp311-win32.whl", hash = "sha256:2876a095292668d753f1a868c4a57c4ac9f6acbd8edda8debe4218d5848cf42f", size = 1989964, upload-time = "2025-10-06T21:10:25.676Z" }, - { url = "https://files.pythonhosted.org/packages/9d/72/63c9a4f1a5c950e65dd522d7dd67f167681f9d4f6ece3b80085a0329f08f/pydantic_core-2.41.1-cp311-cp311-win_amd64.whl", hash = "sha256:b92d6c628e9a338846a28dfe3fcdc1a3279388624597898b105e078cdfc59298", size = 2025158, upload-time = "2025-10-06T21:10:27.522Z" }, - { url = "https://files.pythonhosted.org/packages/d8/16/4e2706184209f61b50c231529257c12eb6bd9eb36e99ea1272e4815d2200/pydantic_core-2.41.1-cp311-cp311-win_arm64.whl", hash = "sha256:7d82ae99409eb69d507a89835488fb657faa03ff9968a9379567b0d2e2e56bc5", size = 1972297, upload-time = "2025-10-06T21:10:28.814Z" }, - { url = "https://files.pythonhosted.org/packages/ee/bc/5f520319ee1c9e25010412fac4154a72e0a40d0a19eb00281b1f200c0947/pydantic_core-2.41.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:db2f82c0ccbce8f021ad304ce35cbe02aa2f95f215cac388eed542b03b4d5eb4", size = 2099300, upload-time = "2025-10-06T21:10:30.463Z" }, - { url = "https://files.pythonhosted.org/packages/31/14/010cd64c5c3814fb6064786837ec12604be0dd46df3327cf8474e38abbbd/pydantic_core-2.41.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:47694a31c710ced9205d5f1e7e8af3ca57cbb8a503d98cb9e33e27c97a501601", size = 1910179, upload-time = "2025-10-06T21:10:31.782Z" }, - { url = "https://files.pythonhosted.org/packages/8e/2e/23fc2a8a93efad52df302fdade0a60f471ecc0c7aac889801ac24b4c07d6/pydantic_core-2.41.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e9decce94daf47baf9e9d392f5f2557e783085f7c5e522011545d9d6858e00", size = 1957225, upload-time = "2025-10-06T21:10:33.11Z" }, - { url = "https://files.pythonhosted.org/packages/b9/b6/6db08b2725b2432b9390844852e11d320281e5cea8a859c52c68001975fa/pydantic_core-2.41.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ab0adafdf2b89c8b84f847780a119437a0931eca469f7b44d356f2b426dd9741", size = 2053315, upload-time = "2025-10-06T21:10:34.87Z" }, - { url = "https://files.pythonhosted.org/packages/61/d9/4de44600f2d4514b44f3f3aeeda2e14931214b6b5bf52479339e801ce748/pydantic_core-2.41.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5da98cc81873f39fd56882e1569c4677940fbc12bce6213fad1ead784192d7c8", size = 2224298, upload-time = "2025-10-06T21:10:36.233Z" }, - { url = "https://files.pythonhosted.org/packages/7a/ae/dbe51187a7f35fc21b283c5250571a94e36373eb557c1cba9f29a9806dcf/pydantic_core-2.41.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:209910e88afb01fd0fd403947b809ba8dba0e08a095e1f703294fda0a8fdca51", size = 2351797, upload-time = "2025-10-06T21:10:37.601Z" }, - { url = "https://files.pythonhosted.org/packages/b5/a7/975585147457c2e9fb951c7c8dab56deeb6aa313f3aa72c2fc0df3f74a49/pydantic_core-2.41.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:365109d1165d78d98e33c5bfd815a9b5d7d070f578caefaabcc5771825b4ecb5", size = 2074921, upload-time = "2025-10-06T21:10:38.927Z" }, - { url = "https://files.pythonhosted.org/packages/62/37/ea94d1d0c01dec1b7d236c7cec9103baab0021f42500975de3d42522104b/pydantic_core-2.41.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:706abf21e60a2857acdb09502bc853ee5bce732955e7b723b10311114f033115", size = 2187767, upload-time = "2025-10-06T21:10:40.651Z" }, - { url = "https://files.pythonhosted.org/packages/d3/fe/694cf9fdd3a777a618c3afd210dba7b414cb8a72b1bd29b199c2e5765fee/pydantic_core-2.41.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bf0bd5417acf7f6a7ec3b53f2109f587be176cb35f9cf016da87e6017437a72d", size = 2136062, upload-time = "2025-10-06T21:10:42.09Z" }, - { url = "https://files.pythonhosted.org/packages/0f/ae/174aeabd89916fbd2988cc37b81a59e1186e952afd2a7ed92018c22f31ca/pydantic_core-2.41.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2e71b1c6ceb9c78424ae9f63a07292fb769fb890a4e7efca5554c47f33a60ea5", size = 2317819, upload-time = "2025-10-06T21:10:43.974Z" }, - { url = "https://files.pythonhosted.org/packages/65/e8/e9aecafaebf53fc456314f72886068725d6fba66f11b013532dc21259343/pydantic_core-2.41.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:80745b9770b4a38c25015b517451c817799bfb9d6499b0d13d8227ec941cb513", size = 2312267, upload-time = "2025-10-06T21:10:45.34Z" }, - { url = "https://files.pythonhosted.org/packages/35/2f/1c2e71d2a052f9bb2f2df5a6a05464a0eb800f9e8d9dd800202fe31219e1/pydantic_core-2.41.1-cp312-cp312-win32.whl", hash = "sha256:83b64d70520e7890453f1aa21d66fda44e7b35f1cfea95adf7b4289a51e2b479", size = 1990927, upload-time = "2025-10-06T21:10:46.738Z" }, - { url = "https://files.pythonhosted.org/packages/b1/78/562998301ff2588b9c6dcc5cb21f52fa919d6e1decc75a35055feb973594/pydantic_core-2.41.1-cp312-cp312-win_amd64.whl", hash = "sha256:377defd66ee2003748ee93c52bcef2d14fde48fe28a0b156f88c3dbf9bc49a50", size = 2034703, upload-time = "2025-10-06T21:10:48.524Z" }, - { url = "https://files.pythonhosted.org/packages/b2/53/d95699ce5a5cdb44bb470bd818b848b9beadf51459fd4ea06667e8ede862/pydantic_core-2.41.1-cp312-cp312-win_arm64.whl", hash = "sha256:c95caff279d49c1d6cdfe2996e6c2ad712571d3b9caaa209a404426c326c4bde", size = 1972719, upload-time = "2025-10-06T21:10:50.256Z" }, - { url = "https://files.pythonhosted.org/packages/27/8a/6d54198536a90a37807d31a156642aae7a8e1263ed9fe6fc6245defe9332/pydantic_core-2.41.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:70e790fce5f05204ef4403159857bfcd587779da78627b0babb3654f75361ebf", size = 2105825, upload-time = "2025-10-06T21:10:51.719Z" }, - { url = "https://files.pythonhosted.org/packages/4f/2e/4784fd7b22ac9c8439db25bf98ffed6853d01e7e560a346e8af821776ccc/pydantic_core-2.41.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9cebf1ca35f10930612d60bd0f78adfacee824c30a880e3534ba02c207cceceb", size = 1910126, upload-time = "2025-10-06T21:10:53.145Z" }, - { url = "https://files.pythonhosted.org/packages/f3/92/31eb0748059ba5bd0aa708fb4bab9fcb211461ddcf9e90702a6542f22d0d/pydantic_core-2.41.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:170406a37a5bc82c22c3274616bf6f17cc7df9c4a0a0a50449e559cb755db669", size = 1961472, upload-time = "2025-10-06T21:10:55.754Z" }, - { url = "https://files.pythonhosted.org/packages/ab/91/946527792275b5c4c7dde4cfa3e81241bf6900e9fee74fb1ba43e0c0f1ab/pydantic_core-2.41.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12d4257fc9187a0ccd41b8b327d6a4e57281ab75e11dda66a9148ef2e1fb712f", size = 2063230, upload-time = "2025-10-06T21:10:57.179Z" }, - { url = "https://files.pythonhosted.org/packages/31/5d/a35c5d7b414e5c0749f1d9f0d159ee2ef4bab313f499692896b918014ee3/pydantic_core-2.41.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a75a33b4db105dd1c8d57839e17ee12db8d5ad18209e792fa325dbb4baeb00f4", size = 2229469, upload-time = "2025-10-06T21:10:59.409Z" }, - { url = "https://files.pythonhosted.org/packages/21/4d/8713737c689afa57ecfefe38db78259d4484c97aa494979e6a9d19662584/pydantic_core-2.41.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08a589f850803a74e0fcb16a72081cafb0d72a3cdda500106942b07e76b7bf62", size = 2347986, upload-time = "2025-10-06T21:11:00.847Z" }, - { url = "https://files.pythonhosted.org/packages/f6/ec/929f9a3a5ed5cda767081494bacd32f783e707a690ce6eeb5e0730ec4986/pydantic_core-2.41.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a97939d6ea44763c456bd8a617ceada2c9b96bb5b8ab3dfa0d0827df7619014", size = 2072216, upload-time = "2025-10-06T21:11:02.43Z" }, - { url = "https://files.pythonhosted.org/packages/26/55/a33f459d4f9cc8786d9db42795dbecc84fa724b290d7d71ddc3d7155d46a/pydantic_core-2.41.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d2ae423c65c556f09569524b80ffd11babff61f33055ef9773d7c9fabc11ed8d", size = 2193047, upload-time = "2025-10-06T21:11:03.787Z" }, - { url = "https://files.pythonhosted.org/packages/77/af/d5c6959f8b089f2185760a2779079e3c2c411bfc70ea6111f58367851629/pydantic_core-2.41.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:4dc703015fbf8764d6a8001c327a87f1823b7328d40b47ce6000c65918ad2b4f", size = 2140613, upload-time = "2025-10-06T21:11:05.607Z" }, - { url = "https://files.pythonhosted.org/packages/58/e5/2c19bd2a14bffe7fabcf00efbfbd3ac430aaec5271b504a938ff019ac7be/pydantic_core-2.41.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:968e4ffdfd35698a5fe659e5e44c508b53664870a8e61c8f9d24d3d145d30257", size = 2327641, upload-time = "2025-10-06T21:11:07.143Z" }, - { url = "https://files.pythonhosted.org/packages/93/ef/e0870ccda798c54e6b100aff3c4d49df5458fd64217e860cb9c3b0a403f4/pydantic_core-2.41.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:fff2b76c8e172d34771cd4d4f0ade08072385310f214f823b5a6ad4006890d32", size = 2318229, upload-time = "2025-10-06T21:11:08.73Z" }, - { url = "https://files.pythonhosted.org/packages/b1/4b/c3b991d95f5deb24d0bd52e47bcf716098fa1afe0ce2d4bd3125b38566ba/pydantic_core-2.41.1-cp313-cp313-win32.whl", hash = "sha256:a38a5263185407ceb599f2f035faf4589d57e73c7146d64f10577f6449e8171d", size = 1997911, upload-time = "2025-10-06T21:11:10.329Z" }, - { url = "https://files.pythonhosted.org/packages/a7/ce/5c316fd62e01f8d6be1b7ee6b54273214e871772997dc2c95e204997a055/pydantic_core-2.41.1-cp313-cp313-win_amd64.whl", hash = "sha256:b42ae7fd6760782c975897e1fdc810f483b021b32245b0105d40f6e7a3803e4b", size = 2034301, upload-time = "2025-10-06T21:11:12.113Z" }, - { url = "https://files.pythonhosted.org/packages/29/41/902640cfd6a6523194123e2c3373c60f19006447f2fb06f76de4e8466c5b/pydantic_core-2.41.1-cp313-cp313-win_arm64.whl", hash = "sha256:ad4111acc63b7384e205c27a2f15e23ac0ee21a9d77ad6f2e9cb516ec90965fb", size = 1977238, upload-time = "2025-10-06T21:11:14.1Z" }, - { url = "https://files.pythonhosted.org/packages/04/04/28b040e88c1b89d851278478842f0bdf39c7a05da9e850333c6c8cbe7dfa/pydantic_core-2.41.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:440d0df7415b50084a4ba9d870480c16c5f67c0d1d4d5119e3f70925533a0edc", size = 1875626, upload-time = "2025-10-06T21:11:15.69Z" }, - { url = "https://files.pythonhosted.org/packages/d6/58/b41dd3087505220bb58bc81be8c3e8cbc037f5710cd3c838f44f90bdd704/pydantic_core-2.41.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:71eaa38d342099405dae6484216dcf1e8e4b0bebd9b44a4e08c9b43db6a2ab67", size = 2045708, upload-time = "2025-10-06T21:11:17.258Z" }, - { url = "https://files.pythonhosted.org/packages/d7/b8/760f23754e40bf6c65b94a69b22c394c24058a0ef7e2aa471d2e39219c1a/pydantic_core-2.41.1-cp313-cp313t-win_amd64.whl", hash = "sha256:555ecf7e50f1161d3f693bc49f23c82cf6cdeafc71fa37a06120772a09a38795", size = 1997171, upload-time = "2025-10-06T21:11:18.822Z" }, - { url = "https://files.pythonhosted.org/packages/41/12/cec246429ddfa2778d2d6301eca5362194dc8749ecb19e621f2f65b5090f/pydantic_core-2.41.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:05226894a26f6f27e1deb735d7308f74ef5fa3a6de3e0135bb66cdcaee88f64b", size = 2107836, upload-time = "2025-10-06T21:11:20.432Z" }, - { url = "https://files.pythonhosted.org/packages/20/39/baba47f8d8b87081302498e610aefc37142ce6a1cc98b2ab6b931a162562/pydantic_core-2.41.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:85ff7911c6c3e2fd8d3779c50925f6406d770ea58ea6dde9c230d35b52b16b4a", size = 1904449, upload-time = "2025-10-06T21:11:22.185Z" }, - { url = "https://files.pythonhosted.org/packages/50/32/9a3d87cae2c75a5178334b10358d631bd094b916a00a5993382222dbfd92/pydantic_core-2.41.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:47f1f642a205687d59b52dc1a9a607f45e588f5a2e9eeae05edd80c7a8c47674", size = 1961750, upload-time = "2025-10-06T21:11:24.348Z" }, - { url = "https://files.pythonhosted.org/packages/27/42/a96c9d793a04cf2a9773bff98003bb154087b94f5530a2ce6063ecfec583/pydantic_core-2.41.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:df11c24e138876ace5ec6043e5cae925e34cf38af1a1b3d63589e8f7b5f5cdc4", size = 2063305, upload-time = "2025-10-06T21:11:26.556Z" }, - { url = "https://files.pythonhosted.org/packages/3e/8d/028c4b7d157a005b1f52c086e2d4b0067886b213c86220c1153398dbdf8f/pydantic_core-2.41.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7f0bf7f5c8f7bf345c527e8a0d72d6b26eda99c1227b0c34e7e59e181260de31", size = 2228959, upload-time = "2025-10-06T21:11:28.426Z" }, - { url = "https://files.pythonhosted.org/packages/08/f7/ee64cda8fcc9ca3f4716e6357144f9ee71166775df582a1b6b738bf6da57/pydantic_core-2.41.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82b887a711d341c2c47352375d73b029418f55b20bd7815446d175a70effa706", size = 2345421, upload-time = "2025-10-06T21:11:30.226Z" }, - { url = "https://files.pythonhosted.org/packages/13/c0/e8ec05f0f5ee7a3656973ad9cd3bc73204af99f6512c1a4562f6fb4b3f7d/pydantic_core-2.41.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5f1d5d6bbba484bdf220c72d8ecd0be460f4bd4c5e534a541bb2cd57589fb8b", size = 2065288, upload-time = "2025-10-06T21:11:32.019Z" }, - { url = "https://files.pythonhosted.org/packages/0a/25/d77a73ff24e2e4fcea64472f5e39b0402d836da9b08b5361a734d0153023/pydantic_core-2.41.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2bf1917385ebe0f968dc5c6ab1375886d56992b93ddfe6bf52bff575d03662be", size = 2189759, upload-time = "2025-10-06T21:11:33.753Z" }, - { url = "https://files.pythonhosted.org/packages/66/45/4a4ebaaae12a740552278d06fe71418c0f2869537a369a89c0e6723b341d/pydantic_core-2.41.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:4f94f3ab188f44b9a73f7295663f3ecb8f2e2dd03a69c8f2ead50d37785ecb04", size = 2140747, upload-time = "2025-10-06T21:11:35.781Z" }, - { url = "https://files.pythonhosted.org/packages/da/6d/b727ce1022f143194a36593243ff244ed5a1eb3c9122296bf7e716aa37ba/pydantic_core-2.41.1-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:3925446673641d37c30bd84a9d597e49f72eacee8b43322c8999fa17d5ae5bc4", size = 2327416, upload-time = "2025-10-06T21:11:37.75Z" }, - { url = "https://files.pythonhosted.org/packages/6f/8c/02df9d8506c427787059f87c6c7253435c6895e12472a652d9616ee0fc95/pydantic_core-2.41.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:49bd51cc27adb980c7b97357ae036ce9b3c4d0bb406e84fbe16fb2d368b602a8", size = 2318138, upload-time = "2025-10-06T21:11:39.463Z" }, - { url = "https://files.pythonhosted.org/packages/98/67/0cf429a7d6802536941f430e6e3243f6d4b68f41eeea4b242372f1901794/pydantic_core-2.41.1-cp314-cp314-win32.whl", hash = "sha256:a31ca0cd0e4d12ea0df0077df2d487fc3eb9d7f96bbb13c3c5b88dcc21d05159", size = 1998429, upload-time = "2025-10-06T21:11:41.989Z" }, - { url = "https://files.pythonhosted.org/packages/38/60/742fef93de5d085022d2302a6317a2b34dbfe15258e9396a535c8a100ae7/pydantic_core-2.41.1-cp314-cp314-win_amd64.whl", hash = "sha256:1b5c4374a152e10a22175d7790e644fbd8ff58418890e07e2073ff9d4414efae", size = 2028870, upload-time = "2025-10-06T21:11:43.66Z" }, - { url = "https://files.pythonhosted.org/packages/31/38/cdd8ccb8555ef7720bd7715899bd6cfbe3c29198332710e1b61b8f5dd8b8/pydantic_core-2.41.1-cp314-cp314-win_arm64.whl", hash = "sha256:4fee76d757639b493eb600fba668f1e17475af34c17dd61db7a47e824d464ca9", size = 1974275, upload-time = "2025-10-06T21:11:45.476Z" }, - { url = "https://files.pythonhosted.org/packages/e7/7e/8ac10ccb047dc0221aa2530ec3c7c05ab4656d4d4bd984ee85da7f3d5525/pydantic_core-2.41.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f9b9c968cfe5cd576fdd7361f47f27adeb120517e637d1b189eea1c3ece573f4", size = 1875124, upload-time = "2025-10-06T21:11:47.591Z" }, - { url = "https://files.pythonhosted.org/packages/c3/e4/7d9791efeb9c7d97e7268f8d20e0da24d03438a7fa7163ab58f1073ba968/pydantic_core-2.41.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f1ebc7ab67b856384aba09ed74e3e977dded40e693de18a4f197c67d0d4e6d8e", size = 2043075, upload-time = "2025-10-06T21:11:49.542Z" }, - { url = "https://files.pythonhosted.org/packages/2d/c3/3f6e6b2342ac11ac8cd5cb56e24c7b14afa27c010e82a765ffa5f771884a/pydantic_core-2.41.1-cp314-cp314t-win_amd64.whl", hash = "sha256:8ae0dc57b62a762985bc7fbf636be3412394acc0ddb4ade07fe104230f1b9762", size = 1995341, upload-time = "2025-10-06T21:11:51.497Z" }, - { url = "https://files.pythonhosted.org/packages/16/89/d0afad37ba25f5801735af1472e650b86baad9fe807a42076508e4824a2a/pydantic_core-2.41.1-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:68f2251559b8efa99041bb63571ec7cdd2d715ba74cc82b3bc9eff824ebc8bf0", size = 2124001, upload-time = "2025-10-07T10:49:54.369Z" }, - { url = "https://files.pythonhosted.org/packages/8e/c4/08609134b34520568ddebb084d9ed0a2a3f5f52b45739e6e22cb3a7112eb/pydantic_core-2.41.1-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:c7bc140c596097cb53b30546ca257dbe3f19282283190b1b5142928e5d5d3a20", size = 1941841, upload-time = "2025-10-07T10:49:56.248Z" }, - { url = "https://files.pythonhosted.org/packages/2a/43/94a4877094e5fe19a3f37e7e817772263e2c573c94f1e3fa2b1eee56ef3b/pydantic_core-2.41.1-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2896510fce8f4725ec518f8b9d7f015a00db249d2fd40788f442af303480063d", size = 1961129, upload-time = "2025-10-07T10:49:58.298Z" }, - { url = "https://files.pythonhosted.org/packages/a2/30/23a224d7e25260eb5f69783a63667453037e07eb91ff0e62dabaadd47128/pydantic_core-2.41.1-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ced20e62cfa0f496ba68fa5d6c7ee71114ea67e2a5da3114d6450d7f4683572a", size = 2148770, upload-time = "2025-10-07T10:49:59.959Z" }, - { url = "https://files.pythonhosted.org/packages/2b/3e/a51c5f5d37b9288ba30683d6e96f10fa8f1defad1623ff09f1020973b577/pydantic_core-2.41.1-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:b04fa9ed049461a7398138c604b00550bc89e3e1151d84b81ad6dc93e39c4c06", size = 2115344, upload-time = "2025-10-07T10:50:02.466Z" }, - { url = "https://files.pythonhosted.org/packages/5a/bd/389504c9e0600ef4502cd5238396b527afe6ef8981a6a15cd1814fc7b434/pydantic_core-2.41.1-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:b3b7d9cfbfdc43c80a16638c6dc2768e3956e73031fca64e8e1a3ae744d1faeb", size = 1927994, upload-time = "2025-10-07T10:50:04.379Z" }, - { url = "https://files.pythonhosted.org/packages/ff/9c/5111c6b128861cb792a4c082677e90dac4f2e090bb2e2fe06aa5b2d39027/pydantic_core-2.41.1-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eec83fc6abef04c7f9bec616e2d76ee9a6a4ae2a359b10c21d0f680e24a247ca", size = 1959394, upload-time = "2025-10-07T10:50:06.335Z" }, - { url = "https://files.pythonhosted.org/packages/14/3f/cfec8b9a0c48ce5d64409ec5e1903cb0b7363da38f14b41de2fcb3712700/pydantic_core-2.41.1-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6771a2d9f83c4038dfad5970a3eef215940682b2175e32bcc817bdc639019b28", size = 2147365, upload-time = "2025-10-07T10:50:07.978Z" }, - { url = "https://files.pythonhosted.org/packages/d4/31/f403d7ca8352e3e4df352ccacd200f5f7f7fe81cef8e458515f015091625/pydantic_core-2.41.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:fabcbdb12de6eada8d6e9a759097adb3c15440fafc675b3e94ae5c9cb8d678a0", size = 2114268, upload-time = "2025-10-07T10:50:10.257Z" }, - { url = "https://files.pythonhosted.org/packages/6e/b5/334473b6d2810df84db67f03d4f666acacfc538512c2d2a254074fee0889/pydantic_core-2.41.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:80e97ccfaf0aaf67d55de5085b0ed0d994f57747d9d03f2de5cc9847ca737b08", size = 1935786, upload-time = "2025-10-07T10:50:12.333Z" }, - { url = "https://files.pythonhosted.org/packages/ea/5e/45513e4dc621f47397cfa5fef12ba8fa5e8b1c4c07f2ff2a5fef8ff81b25/pydantic_core-2.41.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34df1fe8fea5d332484a763702e8b6a54048a9d4fe6ccf41e34a128238e01f52", size = 1971995, upload-time = "2025-10-07T10:50:14.071Z" }, - { url = "https://files.pythonhosted.org/packages/22/e3/f1797c168e5f52b973bed1c585e99827a22d5e579d1ed57d51bc15b14633/pydantic_core-2.41.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:421b5595f845842fc093f7250e24ee395f54ca62d494fdde96f43ecf9228ae01", size = 2191264, upload-time = "2025-10-07T10:50:15.788Z" }, - { url = "https://files.pythonhosted.org/packages/bb/e1/24ef4c3b4ab91c21c3a09a966c7d2cffe101058a7bfe5cc8b2c7c7d574e2/pydantic_core-2.41.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:dce8b22663c134583aaad24827863306a933f576c79da450be3984924e2031d1", size = 2152430, upload-time = "2025-10-07T10:50:18.018Z" }, - { url = "https://files.pythonhosted.org/packages/35/74/70c1e225d67f7ef3fdba02c506d9011efaf734020914920b2aa3d1a45e61/pydantic_core-2.41.1-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:300a9c162fea9906cc5c103893ca2602afd84f0ec90d3be36f4cc360125d22e1", size = 2324691, upload-time = "2025-10-07T10:50:19.801Z" }, - { url = "https://files.pythonhosted.org/packages/c8/bf/dd4d21037c8bef0d8cce90a86a3f2dcb011c30086db2a10113c3eea23eba/pydantic_core-2.41.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e019167628f6e6161ae7ab9fb70f6d076a0bf0d55aa9b20833f86a320c70dd65", size = 2324493, upload-time = "2025-10-07T10:50:21.568Z" }, - { url = "https://files.pythonhosted.org/packages/7e/78/3093b334e9c9796c8236a4701cd2ddef1c56fb0928fe282a10c797644380/pydantic_core-2.41.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:13ab9cc2de6f9d4ab645a050ae5aee61a2424ac4d3a16ba23d4c2027705e0301", size = 2146156, upload-time = "2025-10-07T10:50:23.475Z" }, - { url = "https://files.pythonhosted.org/packages/e6/6c/fa3e45c2b054a1e627a89a364917f12cbe3abc3e91b9004edaae16e7b3c5/pydantic_core-2.41.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:af2385d3f98243fb733862f806c5bb9122e5fba05b373e3af40e3c82d711cef1", size = 2112094, upload-time = "2025-10-07T10:50:25.513Z" }, - { url = "https://files.pythonhosted.org/packages/e5/17/7eebc38b4658cc8e6902d0befc26388e4c2a5f2e179c561eeb43e1922c7b/pydantic_core-2.41.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:6550617a0c2115be56f90c31a5370261d8ce9dbf051c3ed53b51172dd34da696", size = 1935300, upload-time = "2025-10-07T10:50:27.715Z" }, - { url = "https://files.pythonhosted.org/packages/2b/00/9fe640194a1717a464ab861d43595c268830f98cb1e2705aa134b3544b70/pydantic_core-2.41.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc17b6ecf4983d298686014c92ebc955a9f9baf9f57dad4065e7906e7bee6222", size = 1970417, upload-time = "2025-10-07T10:50:29.573Z" }, - { url = "https://files.pythonhosted.org/packages/b2/ad/f4cdfaf483b78ee65362363e73b6b40c48e067078d7b146e8816d5945ad6/pydantic_core-2.41.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:42ae9352cf211f08b04ea110563d6b1e415878eea5b4c70f6bdb17dca3b932d2", size = 2190745, upload-time = "2025-10-07T10:50:31.48Z" }, - { url = "https://files.pythonhosted.org/packages/cb/c1/18f416d40a10f44e9387497ba449f40fdb1478c61ba05c4b6bdb82300362/pydantic_core-2.41.1-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:e82947de92068b0a21681a13dd2102387197092fbe7defcfb8453e0913866506", size = 2150888, upload-time = "2025-10-07T10:50:33.477Z" }, - { url = "https://files.pythonhosted.org/packages/42/30/134c8a921630d8a88d6f905a562495a6421e959a23c19b0f49b660801d67/pydantic_core-2.41.1-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:e244c37d5471c9acdcd282890c6c4c83747b77238bfa19429b8473586c907656", size = 2324489, upload-time = "2025-10-07T10:50:36.48Z" }, - { url = "https://files.pythonhosted.org/packages/9c/48/a9263aeaebdec81e941198525b43edb3b44f27cfa4cb8005b8d3eb8dec72/pydantic_core-2.41.1-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:1e798b4b304a995110d41ec93653e57975620ccb2842ba9420037985e7d7284e", size = 2322763, upload-time = "2025-10-07T10:50:38.751Z" }, - { url = "https://files.pythonhosted.org/packages/1d/62/755d2bd2593f701c5839fc084e9c2c5e2418f460383ad04e3b5d0befc3ca/pydantic_core-2.41.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f1fc716c0eb1663c59699b024428ad5ec2bcc6b928527b8fe28de6cb89f47efb", size = 2144046, upload-time = "2025-10-07T10:50:40.686Z" }, + { url = "https://files.pythonhosted.org/packages/a7/3d/9b8ca77b0f76fcdbf8bc6b72474e264283f461284ca84ac3fde570c6c49a/pydantic_core-2.41.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2442d9a4d38f3411f22eb9dd0912b7cbf4b7d5b6c92c4173b75d3e1ccd84e36e", size = 2111197, upload-time = "2025-10-14T10:19:43.303Z" }, + { url = "https://files.pythonhosted.org/packages/59/92/b7b0fe6ed4781642232755cb7e56a86e2041e1292f16d9ae410a0ccee5ac/pydantic_core-2.41.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:30a9876226dda131a741afeab2702e2d127209bde3c65a2b8133f428bc5d006b", size = 1917909, upload-time = "2025-10-14T10:19:45.194Z" }, + { url = "https://files.pythonhosted.org/packages/52/8c/3eb872009274ffa4fb6a9585114e161aa1a0915af2896e2d441642929fe4/pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d55bbac04711e2980645af68b97d445cdbcce70e5216de444a6c4b6943ebcccd", size = 1969905, upload-time = "2025-10-14T10:19:46.567Z" }, + { url = "https://files.pythonhosted.org/packages/f4/21/35adf4a753bcfaea22d925214a0c5b880792e3244731b3f3e6fec0d124f7/pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e1d778fb7849a42d0ee5927ab0f7453bf9f85eef8887a546ec87db5ddb178945", size = 2051938, upload-time = "2025-10-14T10:19:48.237Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d0/cdf7d126825e36d6e3f1eccf257da8954452934ede275a8f390eac775e89/pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1b65077a4693a98b90ec5ad8f203ad65802a1b9b6d4a7e48066925a7e1606706", size = 2250710, upload-time = "2025-10-14T10:19:49.619Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1c/af1e6fd5ea596327308f9c8d1654e1285cc3d8de0d584a3c9d7705bf8a7c/pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62637c769dee16eddb7686bf421be48dfc2fae93832c25e25bc7242e698361ba", size = 2367445, upload-time = "2025-10-14T10:19:51.269Z" }, + { url = "https://files.pythonhosted.org/packages/d3/81/8cece29a6ef1b3a92f956ea6da6250d5b2d2e7e4d513dd3b4f0c7a83dfea/pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2dfe3aa529c8f501babf6e502936b9e8d4698502b2cfab41e17a028d91b1ac7b", size = 2072875, upload-time = "2025-10-14T10:19:52.671Z" }, + { url = "https://files.pythonhosted.org/packages/e3/37/a6a579f5fc2cd4d5521284a0ab6a426cc6463a7b3897aeb95b12f1ba607b/pydantic_core-2.41.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ca2322da745bf2eeb581fc9ea3bbb31147702163ccbcbf12a3bb630e4bf05e1d", size = 2191329, upload-time = "2025-10-14T10:19:54.214Z" }, + { url = "https://files.pythonhosted.org/packages/ae/03/505020dc5c54ec75ecba9f41119fd1e48f9e41e4629942494c4a8734ded1/pydantic_core-2.41.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e8cd3577c796be7231dcf80badcf2e0835a46665eaafd8ace124d886bab4d700", size = 2151658, upload-time = "2025-10-14T10:19:55.843Z" }, + { url = "https://files.pythonhosted.org/packages/cb/5d/2c0d09fb53aa03bbd2a214d89ebfa6304be7df9ed86ee3dc7770257f41ee/pydantic_core-2.41.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:1cae8851e174c83633f0833e90636832857297900133705ee158cf79d40f03e6", size = 2316777, upload-time = "2025-10-14T10:19:57.607Z" }, + { url = "https://files.pythonhosted.org/packages/ea/4b/c2c9c8f5e1f9c864b57d08539d9d3db160e00491c9f5ee90e1bfd905e644/pydantic_core-2.41.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a26d950449aae348afe1ac8be5525a00ae4235309b729ad4d3399623125b43c9", size = 2320705, upload-time = "2025-10-14T10:19:59.016Z" }, + { url = "https://files.pythonhosted.org/packages/28/c3/a74c1c37f49c0a02c89c7340fafc0ba816b29bd495d1a31ce1bdeacc6085/pydantic_core-2.41.4-cp310-cp310-win32.whl", hash = "sha256:0cf2a1f599efe57fa0051312774280ee0f650e11152325e41dfd3018ef2c1b57", size = 1975464, upload-time = "2025-10-14T10:20:00.581Z" }, + { url = "https://files.pythonhosted.org/packages/d6/23/5dd5c1324ba80303368f7569e2e2e1a721c7d9eb16acb7eb7b7f85cb1be2/pydantic_core-2.41.4-cp310-cp310-win_amd64.whl", hash = "sha256:a8c2e340d7e454dc3340d3d2e8f23558ebe78c98aa8f68851b04dcb7bc37abdc", size = 2024497, upload-time = "2025-10-14T10:20:03.018Z" }, + { url = "https://files.pythonhosted.org/packages/62/4c/f6cbfa1e8efacd00b846764e8484fe173d25b8dab881e277a619177f3384/pydantic_core-2.41.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:28ff11666443a1a8cf2a044d6a545ebffa8382b5f7973f22c36109205e65dc80", size = 2109062, upload-time = "2025-10-14T10:20:04.486Z" }, + { url = "https://files.pythonhosted.org/packages/21/f8/40b72d3868896bfcd410e1bd7e516e762d326201c48e5b4a06446f6cf9e8/pydantic_core-2.41.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:61760c3925d4633290292bad462e0f737b840508b4f722247d8729684f6539ae", size = 1916301, upload-time = "2025-10-14T10:20:06.857Z" }, + { url = "https://files.pythonhosted.org/packages/94/4d/d203dce8bee7faeca791671c88519969d98d3b4e8f225da5b96dad226fc8/pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eae547b7315d055b0de2ec3965643b0ab82ad0106a7ffd29615ee9f266a02827", size = 1968728, upload-time = "2025-10-14T10:20:08.353Z" }, + { url = "https://files.pythonhosted.org/packages/65/f5/6a66187775df87c24d526985b3a5d78d861580ca466fbd9d4d0e792fcf6c/pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ef9ee5471edd58d1fcce1c80ffc8783a650e3e3a193fe90d52e43bb4d87bff1f", size = 2050238, upload-time = "2025-10-14T10:20:09.766Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b9/78336345de97298cf53236b2f271912ce11f32c1e59de25a374ce12f9cce/pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:15dd504af121caaf2c95cb90c0ebf71603c53de98305621b94da0f967e572def", size = 2249424, upload-time = "2025-10-14T10:20:11.732Z" }, + { url = "https://files.pythonhosted.org/packages/99/bb/a4584888b70ee594c3d374a71af5075a68654d6c780369df269118af7402/pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a926768ea49a8af4d36abd6a8968b8790f7f76dd7cbd5a4c180db2b4ac9a3a2", size = 2366047, upload-time = "2025-10-14T10:20:13.647Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8d/17fc5de9d6418e4d2ae8c675f905cdafdc59d3bf3bf9c946b7ab796a992a/pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6916b9b7d134bff5440098a4deb80e4cb623e68974a87883299de9124126c2a8", size = 2071163, upload-time = "2025-10-14T10:20:15.307Z" }, + { url = "https://files.pythonhosted.org/packages/54/e7/03d2c5c0b8ed37a4617430db68ec5e7dbba66358b629cd69e11b4d564367/pydantic_core-2.41.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5cf90535979089df02e6f17ffd076f07237efa55b7343d98760bde8743c4b265", size = 2190585, upload-time = "2025-10-14T10:20:17.3Z" }, + { url = "https://files.pythonhosted.org/packages/be/fc/15d1c9fe5ad9266a5897d9b932b7f53d7e5cfc800573917a2c5d6eea56ec/pydantic_core-2.41.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:7533c76fa647fade2d7ec75ac5cc079ab3f34879626dae5689b27790a6cf5a5c", size = 2150109, upload-time = "2025-10-14T10:20:19.143Z" }, + { url = "https://files.pythonhosted.org/packages/26/ef/e735dd008808226c83ba56972566138665b71477ad580fa5a21f0851df48/pydantic_core-2.41.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:37e516bca9264cbf29612539801ca3cd5d1be465f940417b002905e6ed79d38a", size = 2315078, upload-time = "2025-10-14T10:20:20.742Z" }, + { url = "https://files.pythonhosted.org/packages/90/00/806efdcf35ff2ac0f938362350cd9827b8afb116cc814b6b75cf23738c7c/pydantic_core-2.41.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0c19cb355224037c83642429b8ce261ae108e1c5fbf5c028bac63c77b0f8646e", size = 2318737, upload-time = "2025-10-14T10:20:22.306Z" }, + { url = "https://files.pythonhosted.org/packages/41/7e/6ac90673fe6cb36621a2283552897838c020db343fa86e513d3f563b196f/pydantic_core-2.41.4-cp311-cp311-win32.whl", hash = "sha256:09c2a60e55b357284b5f31f5ab275ba9f7f70b7525e18a132ec1f9160b4f1f03", size = 1974160, upload-time = "2025-10-14T10:20:23.817Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9d/7c5e24ee585c1f8b6356e1d11d40ab807ffde44d2db3b7dfd6d20b09720e/pydantic_core-2.41.4-cp311-cp311-win_amd64.whl", hash = "sha256:711156b6afb5cb1cb7c14a2cc2c4a8b4c717b69046f13c6b332d8a0a8f41ca3e", size = 2021883, upload-time = "2025-10-14T10:20:25.48Z" }, + { url = "https://files.pythonhosted.org/packages/33/90/5c172357460fc28b2871eb4a0fb3843b136b429c6fa827e4b588877bf115/pydantic_core-2.41.4-cp311-cp311-win_arm64.whl", hash = "sha256:6cb9cf7e761f4f8a8589a45e49ed3c0d92d1d696a45a6feaee8c904b26efc2db", size = 1968026, upload-time = "2025-10-14T10:20:27.039Z" }, + { url = "https://files.pythonhosted.org/packages/e9/81/d3b3e95929c4369d30b2a66a91db63c8ed0a98381ae55a45da2cd1cc1288/pydantic_core-2.41.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ab06d77e053d660a6faaf04894446df7b0a7e7aba70c2797465a0a1af00fc887", size = 2099043, upload-time = "2025-10-14T10:20:28.561Z" }, + { url = "https://files.pythonhosted.org/packages/58/da/46fdac49e6717e3a94fc9201403e08d9d61aa7a770fab6190b8740749047/pydantic_core-2.41.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c53ff33e603a9c1179a9364b0a24694f183717b2e0da2b5ad43c316c956901b2", size = 1910699, upload-time = "2025-10-14T10:20:30.217Z" }, + { url = "https://files.pythonhosted.org/packages/1e/63/4d948f1b9dd8e991a5a98b77dd66c74641f5f2e5225fee37994b2e07d391/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:304c54176af2c143bd181d82e77c15c41cbacea8872a2225dd37e6544dce9999", size = 1952121, upload-time = "2025-10-14T10:20:32.246Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a7/e5fc60a6f781fc634ecaa9ecc3c20171d238794cef69ae0af79ac11b89d7/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:025ba34a4cf4fb32f917d5d188ab5e702223d3ba603be4d8aca2f82bede432a4", size = 2041590, upload-time = "2025-10-14T10:20:34.332Z" }, + { url = "https://files.pythonhosted.org/packages/70/69/dce747b1d21d59e85af433428978a1893c6f8a7068fa2bb4a927fba7a5ff/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9f5f30c402ed58f90c70e12eff65547d3ab74685ffe8283c719e6bead8ef53f", size = 2219869, upload-time = "2025-10-14T10:20:35.965Z" }, + { url = "https://files.pythonhosted.org/packages/83/6a/c070e30e295403bf29c4df1cb781317b6a9bac7cd07b8d3acc94d501a63c/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd96e5d15385d301733113bcaa324c8bcf111275b7675a9c6e88bfb19fc05e3b", size = 2345169, upload-time = "2025-10-14T10:20:37.627Z" }, + { url = "https://files.pythonhosted.org/packages/f0/83/06d001f8043c336baea7fd202a9ac7ad71f87e1c55d8112c50b745c40324/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98f348cbb44fae6e9653c1055db7e29de67ea6a9ca03a5fa2c2e11a47cff0e47", size = 2070165, upload-time = "2025-10-14T10:20:39.246Z" }, + { url = "https://files.pythonhosted.org/packages/14/0a/e567c2883588dd12bcbc110232d892cf385356f7c8a9910311ac997ab715/pydantic_core-2.41.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ec22626a2d14620a83ca583c6f5a4080fa3155282718b6055c2ea48d3ef35970", size = 2189067, upload-time = "2025-10-14T10:20:41.015Z" }, + { url = "https://files.pythonhosted.org/packages/f4/1d/3d9fca34273ba03c9b1c5289f7618bc4bd09c3ad2289b5420481aa051a99/pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3a95d4590b1f1a43bf33ca6d647b990a88f4a3824a8c4572c708f0b45a5290ed", size = 2132997, upload-time = "2025-10-14T10:20:43.106Z" }, + { url = "https://files.pythonhosted.org/packages/52/70/d702ef7a6cd41a8afc61f3554922b3ed8d19dd54c3bd4bdbfe332e610827/pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:f9672ab4d398e1b602feadcffcdd3af44d5f5e6ddc15bc7d15d376d47e8e19f8", size = 2307187, upload-time = "2025-10-14T10:20:44.849Z" }, + { url = "https://files.pythonhosted.org/packages/68/4c/c06be6e27545d08b802127914156f38d10ca287a9e8489342793de8aae3c/pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:84d8854db5f55fead3b579f04bda9a36461dab0730c5d570e1526483e7bb8431", size = 2305204, upload-time = "2025-10-14T10:20:46.781Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e5/35ae4919bcd9f18603419e23c5eaf32750224a89d41a8df1a3704b69f77e/pydantic_core-2.41.4-cp312-cp312-win32.whl", hash = "sha256:9be1c01adb2ecc4e464392c36d17f97e9110fbbc906bcbe1c943b5b87a74aabd", size = 1972536, upload-time = "2025-10-14T10:20:48.39Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c2/49c5bb6d2a49eb2ee3647a93e3dae7080c6409a8a7558b075027644e879c/pydantic_core-2.41.4-cp312-cp312-win_amd64.whl", hash = "sha256:d682cf1d22bab22a5be08539dca3d1593488a99998f9f412137bc323179067ff", size = 2031132, upload-time = "2025-10-14T10:20:50.421Z" }, + { url = "https://files.pythonhosted.org/packages/06/23/936343dbcba6eec93f73e95eb346810fc732f71ba27967b287b66f7b7097/pydantic_core-2.41.4-cp312-cp312-win_arm64.whl", hash = "sha256:833eebfd75a26d17470b58768c1834dfc90141b7afc6eb0429c21fc5a21dcfb8", size = 1969483, upload-time = "2025-10-14T10:20:52.35Z" }, + { url = "https://files.pythonhosted.org/packages/13/d0/c20adabd181a029a970738dfe23710b52a31f1258f591874fcdec7359845/pydantic_core-2.41.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:85e050ad9e5f6fe1004eec65c914332e52f429bc0ae12d6fa2092407a462c746", size = 2105688, upload-time = "2025-10-14T10:20:54.448Z" }, + { url = "https://files.pythonhosted.org/packages/00/b6/0ce5c03cec5ae94cca220dfecddc453c077d71363b98a4bbdb3c0b22c783/pydantic_core-2.41.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7393f1d64792763a48924ba31d1e44c2cfbc05e3b1c2c9abb4ceeadd912cced", size = 1910807, upload-time = "2025-10-14T10:20:56.115Z" }, + { url = "https://files.pythonhosted.org/packages/68/3e/800d3d02c8beb0b5c069c870cbb83799d085debf43499c897bb4b4aaff0d/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94dab0940b0d1fb28bcab847adf887c66a27a40291eedf0b473be58761c9799a", size = 1956669, upload-time = "2025-10-14T10:20:57.874Z" }, + { url = "https://files.pythonhosted.org/packages/60/a4/24271cc71a17f64589be49ab8bd0751f6a0a03046c690df60989f2f95c2c/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:de7c42f897e689ee6f9e93c4bec72b99ae3b32a2ade1c7e4798e690ff5246e02", size = 2051629, upload-time = "2025-10-14T10:21:00.006Z" }, + { url = "https://files.pythonhosted.org/packages/68/de/45af3ca2f175d91b96bfb62e1f2d2f1f9f3b14a734afe0bfeff079f78181/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:664b3199193262277b8b3cd1e754fb07f2c6023289c815a1e1e8fb415cb247b1", size = 2224049, upload-time = "2025-10-14T10:21:01.801Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/ae4e1ff84672bf869d0a77af24fd78387850e9497753c432875066b5d622/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d95b253b88f7d308b1c0b417c4624f44553ba4762816f94e6986819b9c273fb2", size = 2342409, upload-time = "2025-10-14T10:21:03.556Z" }, + { url = "https://files.pythonhosted.org/packages/18/62/273dd70b0026a085c7b74b000394e1ef95719ea579c76ea2f0cc8893736d/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1351f5bbdbbabc689727cb91649a00cb9ee7203e0a6e54e9f5ba9e22e384b84", size = 2069635, upload-time = "2025-10-14T10:21:05.385Z" }, + { url = "https://files.pythonhosted.org/packages/30/03/cf485fff699b4cdaea469bc481719d3e49f023241b4abb656f8d422189fc/pydantic_core-2.41.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1affa4798520b148d7182da0615d648e752de4ab1a9566b7471bc803d88a062d", size = 2194284, upload-time = "2025-10-14T10:21:07.122Z" }, + { url = "https://files.pythonhosted.org/packages/f9/7e/c8e713db32405dfd97211f2fc0a15d6bf8adb7640f3d18544c1f39526619/pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7b74e18052fea4aa8dea2fb7dbc23d15439695da6cbe6cfc1b694af1115df09d", size = 2137566, upload-time = "2025-10-14T10:21:08.981Z" }, + { url = "https://files.pythonhosted.org/packages/04/f7/db71fd4cdccc8b75990f79ccafbbd66757e19f6d5ee724a6252414483fb4/pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:285b643d75c0e30abda9dc1077395624f314a37e3c09ca402d4015ef5979f1a2", size = 2316809, upload-time = "2025-10-14T10:21:10.805Z" }, + { url = "https://files.pythonhosted.org/packages/76/63/a54973ddb945f1bca56742b48b144d85c9fc22f819ddeb9f861c249d5464/pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:f52679ff4218d713b3b33f88c89ccbf3a5c2c12ba665fb80ccc4192b4608dbab", size = 2311119, upload-time = "2025-10-14T10:21:12.583Z" }, + { url = "https://files.pythonhosted.org/packages/f8/03/5d12891e93c19218af74843a27e32b94922195ded2386f7b55382f904d2f/pydantic_core-2.41.4-cp313-cp313-win32.whl", hash = "sha256:ecde6dedd6fff127c273c76821bb754d793be1024bc33314a120f83a3c69460c", size = 1981398, upload-time = "2025-10-14T10:21:14.584Z" }, + { url = "https://files.pythonhosted.org/packages/be/d8/fd0de71f39db91135b7a26996160de71c073d8635edfce8b3c3681be0d6d/pydantic_core-2.41.4-cp313-cp313-win_amd64.whl", hash = "sha256:d081a1f3800f05409ed868ebb2d74ac39dd0c1ff6c035b5162356d76030736d4", size = 2030735, upload-time = "2025-10-14T10:21:16.432Z" }, + { url = "https://files.pythonhosted.org/packages/72/86/c99921c1cf6650023c08bfab6fe2d7057a5142628ef7ccfa9921f2dda1d5/pydantic_core-2.41.4-cp313-cp313-win_arm64.whl", hash = "sha256:f8e49c9c364a7edcbe2a310f12733aad95b022495ef2a8d653f645e5d20c1564", size = 1973209, upload-time = "2025-10-14T10:21:18.213Z" }, + { url = "https://files.pythonhosted.org/packages/36/0d/b5706cacb70a8414396efdda3d72ae0542e050b591119e458e2490baf035/pydantic_core-2.41.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ed97fd56a561f5eb5706cebe94f1ad7c13b84d98312a05546f2ad036bafe87f4", size = 1877324, upload-time = "2025-10-14T10:21:20.363Z" }, + { url = "https://files.pythonhosted.org/packages/de/2d/cba1fa02cfdea72dfb3a9babb067c83b9dff0bbcb198368e000a6b756ea7/pydantic_core-2.41.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a870c307bf1ee91fc58a9a61338ff780d01bfae45922624816878dce784095d2", size = 1884515, upload-time = "2025-10-14T10:21:22.339Z" }, + { url = "https://files.pythonhosted.org/packages/07/ea/3df927c4384ed9b503c9cc2d076cf983b4f2adb0c754578dfb1245c51e46/pydantic_core-2.41.4-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d25e97bc1f5f8f7985bdc2335ef9e73843bb561eb1fa6831fdfc295c1c2061cf", size = 2042819, upload-time = "2025-10-14T10:21:26.683Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ee/df8e871f07074250270a3b1b82aad4cd0026b588acd5d7d3eb2fcb1471a3/pydantic_core-2.41.4-cp313-cp313t-win_amd64.whl", hash = "sha256:d405d14bea042f166512add3091c1af40437c2e7f86988f3915fabd27b1e9cd2", size = 1995866, upload-time = "2025-10-14T10:21:28.951Z" }, + { url = "https://files.pythonhosted.org/packages/fc/de/b20f4ab954d6d399499c33ec4fafc46d9551e11dc1858fb7f5dca0748ceb/pydantic_core-2.41.4-cp313-cp313t-win_arm64.whl", hash = "sha256:19f3684868309db5263a11bace3c45d93f6f24afa2ffe75a647583df22a2ff89", size = 1970034, upload-time = "2025-10-14T10:21:30.869Z" }, + { url = "https://files.pythonhosted.org/packages/54/28/d3325da57d413b9819365546eb9a6e8b7cbd9373d9380efd5f74326143e6/pydantic_core-2.41.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:e9205d97ed08a82ebb9a307e92914bb30e18cdf6f6b12ca4bedadb1588a0bfe1", size = 2102022, upload-time = "2025-10-14T10:21:32.809Z" }, + { url = "https://files.pythonhosted.org/packages/9e/24/b58a1bc0d834bf1acc4361e61233ee217169a42efbdc15a60296e13ce438/pydantic_core-2.41.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:82df1f432b37d832709fbcc0e24394bba04a01b6ecf1ee87578145c19cde12ac", size = 1905495, upload-time = "2025-10-14T10:21:34.812Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a4/71f759cc41b7043e8ecdaab81b985a9b6cad7cec077e0b92cff8b71ecf6b/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc3b4cc4539e055cfa39a3763c939f9d409eb40e85813257dcd761985a108554", size = 1956131, upload-time = "2025-10-14T10:21:36.924Z" }, + { url = "https://files.pythonhosted.org/packages/b0/64/1e79ac7aa51f1eec7c4cda8cbe456d5d09f05fdd68b32776d72168d54275/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b1eb1754fce47c63d2ff57fdb88c351a6c0150995890088b33767a10218eaa4e", size = 2052236, upload-time = "2025-10-14T10:21:38.927Z" }, + { url = "https://files.pythonhosted.org/packages/e9/e3/a3ffc363bd4287b80f1d43dc1c28ba64831f8dfc237d6fec8f2661138d48/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6ab5ab30ef325b443f379ddb575a34969c333004fca5a1daa0133a6ffaad616", size = 2223573, upload-time = "2025-10-14T10:21:41.574Z" }, + { url = "https://files.pythonhosted.org/packages/28/27/78814089b4d2e684a9088ede3790763c64693c3d1408ddc0a248bc789126/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:31a41030b1d9ca497634092b46481b937ff9397a86f9f51bd41c4767b6fc04af", size = 2342467, upload-time = "2025-10-14T10:21:44.018Z" }, + { url = "https://files.pythonhosted.org/packages/92/97/4de0e2a1159cb85ad737e03306717637842c88c7fd6d97973172fb183149/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a44ac1738591472c3d020f61c6df1e4015180d6262ebd39bf2aeb52571b60f12", size = 2063754, upload-time = "2025-10-14T10:21:46.466Z" }, + { url = "https://files.pythonhosted.org/packages/0f/50/8cb90ce4b9efcf7ae78130afeb99fd1c86125ccdf9906ef64b9d42f37c25/pydantic_core-2.41.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d72f2b5e6e82ab8f94ea7d0d42f83c487dc159c5240d8f83beae684472864e2d", size = 2196754, upload-time = "2025-10-14T10:21:48.486Z" }, + { url = "https://files.pythonhosted.org/packages/34/3b/ccdc77af9cd5082723574a1cc1bcae7a6acacc829d7c0a06201f7886a109/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c4d1e854aaf044487d31143f541f7aafe7b482ae72a022c664b2de2e466ed0ad", size = 2137115, upload-time = "2025-10-14T10:21:50.63Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ba/e7c7a02651a8f7c52dc2cff2b64a30c313e3b57c7d93703cecea76c09b71/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b568af94267729d76e6ee5ececda4e283d07bbb28e8148bb17adad93d025d25a", size = 2317400, upload-time = "2025-10-14T10:21:52.959Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ba/6c533a4ee8aec6b812c643c49bb3bd88d3f01e3cebe451bb85512d37f00f/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6d55fb8b1e8929b341cc313a81a26e0d48aa3b519c1dbaadec3a6a2b4fcad025", size = 2312070, upload-time = "2025-10-14T10:21:55.419Z" }, + { url = "https://files.pythonhosted.org/packages/22/ae/f10524fcc0ab8d7f96cf9a74c880243576fd3e72bd8ce4f81e43d22bcab7/pydantic_core-2.41.4-cp314-cp314-win32.whl", hash = "sha256:5b66584e549e2e32a1398df11da2e0a7eff45d5c2d9db9d5667c5e6ac764d77e", size = 1982277, upload-time = "2025-10-14T10:21:57.474Z" }, + { url = "https://files.pythonhosted.org/packages/b4/dc/e5aa27aea1ad4638f0c3fb41132f7eb583bd7420ee63204e2d4333a3bbf9/pydantic_core-2.41.4-cp314-cp314-win_amd64.whl", hash = "sha256:557a0aab88664cc552285316809cab897716a372afaf8efdbef756f8b890e894", size = 2024608, upload-time = "2025-10-14T10:21:59.557Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/51d89cc2612bd147198e120a13f150afbf0bcb4615cddb049ab10b81b79e/pydantic_core-2.41.4-cp314-cp314-win_arm64.whl", hash = "sha256:3f1ea6f48a045745d0d9f325989d8abd3f1eaf47dd00485912d1a3a63c623a8d", size = 1967614, upload-time = "2025-10-14T10:22:01.847Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c2/472f2e31b95eff099961fa050c376ab7156a81da194f9edb9f710f68787b/pydantic_core-2.41.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6c1fe4c5404c448b13188dd8bd2ebc2bdd7e6727fa61ff481bcc2cca894018da", size = 1876904, upload-time = "2025-10-14T10:22:04.062Z" }, + { url = "https://files.pythonhosted.org/packages/4a/07/ea8eeb91173807ecdae4f4a5f4b150a520085b35454350fc219ba79e66a3/pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:523e7da4d43b113bf8e7b49fa4ec0c35bf4fe66b2230bfc5c13cc498f12c6c3e", size = 1882538, upload-time = "2025-10-14T10:22:06.39Z" }, + { url = "https://files.pythonhosted.org/packages/1e/29/b53a9ca6cd366bfc928823679c6a76c7a4c69f8201c0ba7903ad18ebae2f/pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5729225de81fb65b70fdb1907fcf08c75d498f4a6f15af005aabb1fdadc19dfa", size = 2041183, upload-time = "2025-10-14T10:22:08.812Z" }, + { url = "https://files.pythonhosted.org/packages/c7/3d/f8c1a371ceebcaf94d6dd2d77c6cf4b1c078e13a5837aee83f760b4f7cfd/pydantic_core-2.41.4-cp314-cp314t-win_amd64.whl", hash = "sha256:de2cfbb09e88f0f795fd90cf955858fc2c691df65b1f21f0aa00b99f3fbc661d", size = 1993542, upload-time = "2025-10-14T10:22:11.332Z" }, + { url = "https://files.pythonhosted.org/packages/8a/ac/9fc61b4f9d079482a290afe8d206b8f490e9fd32d4fc03ed4fc698214e01/pydantic_core-2.41.4-cp314-cp314t-win_arm64.whl", hash = "sha256:d34f950ae05a83e0ede899c595f312ca976023ea1db100cd5aa188f7005e3ab0", size = 1973897, upload-time = "2025-10-14T10:22:13.444Z" }, + { url = "https://files.pythonhosted.org/packages/b0/12/5ba58daa7f453454464f92b3ca7b9d7c657d8641c48e370c3ebc9a82dd78/pydantic_core-2.41.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:a1b2cfec3879afb742a7b0bcfa53e4f22ba96571c9e54d6a3afe1052d17d843b", size = 2122139, upload-time = "2025-10-14T10:22:47.288Z" }, + { url = "https://files.pythonhosted.org/packages/21/fb/6860126a77725c3108baecd10fd3d75fec25191d6381b6eb2ac660228eac/pydantic_core-2.41.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:d175600d975b7c244af6eb9c9041f10059f20b8bbffec9e33fdd5ee3f67cdc42", size = 1936674, upload-time = "2025-10-14T10:22:49.555Z" }, + { url = "https://files.pythonhosted.org/packages/de/be/57dcaa3ed595d81f8757e2b44a38240ac5d37628bce25fb20d02c7018776/pydantic_core-2.41.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f184d657fa4947ae5ec9c47bd7e917730fa1cbb78195037e32dcbab50aca5ee", size = 1956398, upload-time = "2025-10-14T10:22:52.19Z" }, + { url = "https://files.pythonhosted.org/packages/2f/1d/679a344fadb9695f1a6a294d739fbd21d71fa023286daeea8c0ed49e7c2b/pydantic_core-2.41.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ed810568aeffed3edc78910af32af911c835cc39ebbfacd1f0ab5dd53028e5c", size = 2138674, upload-time = "2025-10-14T10:22:54.499Z" }, + { url = "https://files.pythonhosted.org/packages/c4/48/ae937e5a831b7c0dc646b2ef788c27cd003894882415300ed21927c21efa/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:4f5d640aeebb438517150fdeec097739614421900e4a08db4a3ef38898798537", size = 2112087, upload-time = "2025-10-14T10:22:56.818Z" }, + { url = "https://files.pythonhosted.org/packages/5e/db/6db8073e3d32dae017da7e0d16a9ecb897d0a4d92e00634916e486097961/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:4a9ab037b71927babc6d9e7fc01aea9e66dc2a4a34dff06ef0724a4049629f94", size = 1920387, upload-time = "2025-10-14T10:22:59.342Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c1/dd3542d072fcc336030d66834872f0328727e3b8de289c662faa04aa270e/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4dab9484ec605c3016df9ad4fd4f9a390bc5d816a3b10c6550f8424bb80b18c", size = 1951495, upload-time = "2025-10-14T10:23:02.089Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c6/db8d13a1f8ab3f1eb08c88bd00fd62d44311e3456d1e85c0e59e0a0376e7/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8a5028425820731d8c6c098ab642d7b8b999758e24acae03ed38a66eca8335", size = 2139008, upload-time = "2025-10-14T10:23:04.539Z" }, + { url = "https://files.pythonhosted.org/packages/5d/d4/912e976a2dd0b49f31c98a060ca90b353f3b73ee3ea2fd0030412f6ac5ec/pydantic_core-2.41.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:1e5ab4fc177dd41536b3c32b2ea11380dd3d4619a385860621478ac2d25ceb00", size = 2106739, upload-time = "2025-10-14T10:23:06.934Z" }, + { url = "https://files.pythonhosted.org/packages/71/f0/66ec5a626c81eba326072d6ee2b127f8c139543f1bf609b4842978d37833/pydantic_core-2.41.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:3d88d0054d3fa11ce936184896bed3c1c5441d6fa483b498fac6a5d0dd6f64a9", size = 1932549, upload-time = "2025-10-14T10:23:09.24Z" }, + { url = "https://files.pythonhosted.org/packages/c4/af/625626278ca801ea0a658c2dcf290dc9f21bb383098e99e7c6a029fccfc0/pydantic_core-2.41.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b2a054a8725f05b4b6503357e0ac1c4e8234ad3b0c2ac130d6ffc66f0e170e2", size = 2135093, upload-time = "2025-10-14T10:23:11.626Z" }, + { url = "https://files.pythonhosted.org/packages/20/f6/2fba049f54e0f4975fef66be654c597a1d005320fa141863699180c7697d/pydantic_core-2.41.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b0d9db5a161c99375a0c68c058e227bee1d89303300802601d76a3d01f74e258", size = 2187971, upload-time = "2025-10-14T10:23:14.437Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/65ab839a2dfcd3b949202f9d920c34f9de5a537c3646662bdf2f7d999680/pydantic_core-2.41.4-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:6273ea2c8ffdac7b7fda2653c49682db815aebf4a89243a6feccf5e36c18c347", size = 2147939, upload-time = "2025-10-14T10:23:16.831Z" }, + { url = "https://files.pythonhosted.org/packages/44/58/627565d3d182ce6dfda18b8e1c841eede3629d59c9d7cbc1e12a03aeb328/pydantic_core-2.41.4-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:4c973add636efc61de22530b2ef83a65f39b6d6f656df97f678720e20de26caa", size = 2311400, upload-time = "2025-10-14T10:23:19.234Z" }, + { url = "https://files.pythonhosted.org/packages/24/06/8a84711162ad5a5f19a88cead37cca81b4b1f294f46260ef7334ae4f24d3/pydantic_core-2.41.4-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b69d1973354758007f46cf2d44a4f3d0933f10b6dc9bf15cf1356e037f6f731a", size = 2316840, upload-time = "2025-10-14T10:23:21.738Z" }, + { url = "https://files.pythonhosted.org/packages/aa/8b/b7bb512a4682a2f7fbfae152a755d37351743900226d29bd953aaf870eaa/pydantic_core-2.41.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:3619320641fd212aaf5997b6ca505e97540b7e16418f4a241f44cdf108ffb50d", size = 2149135, upload-time = "2025-10-14T10:23:24.379Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7d/138e902ed6399b866f7cfe4435d22445e16fff888a1c00560d9dc79a780f/pydantic_core-2.41.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:491535d45cd7ad7e4a2af4a5169b0d07bebf1adfd164b0368da8aa41e19907a5", size = 2104721, upload-time = "2025-10-14T10:23:26.906Z" }, + { url = "https://files.pythonhosted.org/packages/47/13/0525623cf94627f7b53b4c2034c81edc8491cbfc7c28d5447fa318791479/pydantic_core-2.41.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:54d86c0cada6aba4ec4c047d0e348cbad7063b87ae0f005d9f8c9ad04d4a92a2", size = 1931608, upload-time = "2025-10-14T10:23:29.306Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f9/744bc98137d6ef0a233f808bfc9b18cf94624bf30836a18d3b05d08bf418/pydantic_core-2.41.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eca1124aced216b2500dc2609eade086d718e8249cb9696660ab447d50a758bd", size = 2132986, upload-time = "2025-10-14T10:23:32.057Z" }, + { url = "https://files.pythonhosted.org/packages/17/c8/629e88920171173f6049386cc71f893dff03209a9ef32b4d2f7e7c264bcf/pydantic_core-2.41.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6c9024169becccf0cb470ada03ee578d7348c119a0d42af3dcf9eda96e3a247c", size = 2187516, upload-time = "2025-10-14T10:23:34.871Z" }, + { url = "https://files.pythonhosted.org/packages/2e/0f/4f2734688d98488782218ca61bcc118329bf5de05bb7fe3adc7dd79b0b86/pydantic_core-2.41.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:26895a4268ae5a2849269f4991cdc97236e4b9c010e51137becf25182daac405", size = 2146146, upload-time = "2025-10-14T10:23:37.342Z" }, + { url = "https://files.pythonhosted.org/packages/ed/f2/ab385dbd94a052c62224b99cf99002eee99dbec40e10006c78575aead256/pydantic_core-2.41.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:ca4df25762cf71308c446e33c9b1fdca2923a3f13de616e2a949f38bf21ff5a8", size = 2311296, upload-time = "2025-10-14T10:23:40.145Z" }, + { url = "https://files.pythonhosted.org/packages/fc/8e/e4f12afe1beeb9823bba5375f8f258df0cc61b056b0195fb1cf9f62a1a58/pydantic_core-2.41.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:5a28fcedd762349519276c36634e71853b4541079cab4acaaac60c4421827308", size = 2315386, upload-time = "2025-10-14T10:23:42.624Z" }, + { url = "https://files.pythonhosted.org/packages/48/f7/925f65d930802e3ea2eb4d5afa4cb8730c8dc0d2cb89a59dc4ed2fcb2d74/pydantic_core-2.41.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c173ddcd86afd2535e2b695217e82191580663a1d1928239f877f5a1649ef39f", size = 2147775, upload-time = "2025-10-14T10:23:45.406Z" }, ] [[package]] @@ -1188,7 +1217,7 @@ version = "2.1.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.3.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/4d/bc/0989043118a27cccb4e906a46b7565ce36ca7b57f5a18b78f4f1b0f72d9d/shapely-2.1.2.tar.gz", hash = "sha256:2ed4ecb28320a433db18a5bf029986aa8afcfd740745e78847e330d5d94922a9", size = 315489, upload-time = "2025-09-24T13:51:41.432Z" } wheels = [ @@ -1323,7 +1352,7 @@ version = "2.1.0.20250917" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.3.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fa/19/7f28b10994433d43b9caa66f3b9bd6a0a9192b7ce8b5a7fc41534e54b821/types_shapely-2.1.0.20250917.tar.gz", hash = "sha256:5c56670742105aebe40c16414390d35fcaa55d6f774d328c1a18273ab0e2134a", size = 26363, upload-time = "2025-09-17T02:47:44.604Z" } wheels = [ From 51f74dd4e9ee8bc333780e6cd1d383719961b1ca Mon Sep 17 00:00:00 2001 From: schapper Date: Thu, 16 Oct 2025 12:20:50 -0700 Subject: [PATCH 15/19] wip - Minor --- .../overture/schema/system/_json_schema.py | 24 +++- .../src/overture/schema/system/ref/id.py | 4 +- .../tests/test___json_schema.py | 130 +++++++++++++++++- 3 files changed, 149 insertions(+), 9 deletions(-) diff --git a/packages/overture-schema-system/src/overture/schema/system/_json_schema.py b/packages/overture-schema-system/src/overture/schema/system/_json_schema.py index 9c4340db4..5000b2f82 100644 --- a/packages/overture-schema-system/src/overture/schema/system/_json_schema.py +++ b/packages/overture-schema-system/src/overture/schema/system/_json_schema.py @@ -75,8 +75,8 @@ def put_not(json_schema: JsonSchemaValue, operand: JsonSchemaValue) -> None: not_schema = prev["not"] if not isinstance(not_schema, get_origin(JsonSchemaValue)): - raise ValueError( - f'expected value of "not" key to be a `JsonSchemaValue`, but it is a {type(not_schema).__name__} in the JSON Schema {json_schema}' + raise TypeError( + 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}' ) not_schema = cast(JsonSchemaValue, not_schema) @@ -152,16 +152,28 @@ def put_properties( _verify_json_schema_value( ("json_schema", json_schema), ("new_properties", new_properties) ) + origin = get_origin(JsonSchemaValue) if "properties" in json_schema: properties = json_schema["properties"] + if not isinstance(properties, origin): + raise TypeError( + f'expected value of "properties" key to be a `JsonSchemaValue`, but {repr(properties)} has type `{type(properties).__name__}` in the JSON Schema {json_schema}' + ) + already_in = True else: properties = {} - json_schema["properties"] = properties + already_in = False for k, v in new_properties.items(): - if k not in properties: + if not isinstance(v, origin): + raise TypeError( + 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)}" + ) + elif k not in properties: properties[k] = v else: _merge(v, properties[k], k) + if not already_in and properties: + json_schema["properties"] = properties def try_move(key: str, src: JsonSchemaValue, dst: JsonSchemaValue) -> None: @@ -207,7 +219,7 @@ def _verify_operands_not_empty(tp: T, operands: list[T]) -> None: def _merge(src: JsonSchemaValue, dst: JsonValue, *loc: str) -> None: origin = get_origin(JsonSchemaValue) if not isinstance(dst, origin): - raise ValueError( + raise TypeError( 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__}`)" ) for k, v in src.items(): @@ -216,6 +228,6 @@ def _merge(src: JsonSchemaValue, dst: JsonValue, *loc: str) -> None: elif isinstance(v, origin): _merge(v, dst[k], *loc, k) elif dst[k] != v: - ValueError( + raise ValueError( 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)})" ) diff --git a/packages/overture-schema-system/src/overture/schema/system/ref/id.py b/packages/overture-schema-system/src/overture/schema/system/ref/id.py index 033b243c0..280dd31bc 100644 --- a/packages/overture-schema-system/src/overture/schema/system/ref/id.py +++ b/packages/overture-schema-system/src/overture/schema/system/ref/id.py @@ -14,4 +14,6 @@ ), ], ) -# todo - Vic - Pdoc string +""" +A unique identifier. +""" diff --git a/packages/overture-schema-system/tests/test___json_schema.py b/packages/overture-schema-system/tests/test___json_schema.py index d7ac810d2..8ae50158f 100644 --- a/packages/overture-schema-system/tests/test___json_schema.py +++ b/packages/overture-schema-system/tests/test___json_schema.py @@ -1,3 +1,4 @@ +import re from typing import cast import pytest @@ -11,6 +12,7 @@ put_if, put_not, put_one_of, + put_properties, put_required, try_move, ) @@ -248,7 +250,7 @@ def test_put_not_error_invalid_operand() -> None: def test_put_not_error_invalid_not_value() -> None: with pytest.raises( - ValueError, match='expected value of "not" key to be a `JsonSchemaValue`' + TypeError, match='expected value of "not" key to be a `JsonSchemaValue`' ): put_not({"not": []}, {}) @@ -410,7 +412,131 @@ def test_put_required_success( # put_properties # #################################################################################################### -# todo - vic + +def test_put_properties_error_invalid_json_schema_type() -> None: + with pytest.raises( + TypeError, + match="`json_schema` must be a `JsonSchemaValue` value, but 42 has type `int`", + ): + put_properties(42, {}) + + +def test_put_properties_error_invalid_new_properties_type() -> None: + with pytest.raises( + TypeError, + match="`new_properties` must be a `JsonSchemaValue` value, but 'foo' has type `str`", + ): + put_properties({}, "foo") + + +def test_put_properties_error_invalid_existing_properties_type() -> None: + with pytest.raises( + TypeError, + match='expected value of "properties" key to be a `JsonSchemaValue`, but 42 has type `int`', + ): + put_properties({"properties": 42}, {}) + + +def test_put_properties_error_invalid_new_property_value_type() -> None: + with pytest.raises( + TypeError, + match="expected property value for 'foo' key to be a `JsonSchemaValue`, but 'bar' has type `str`", + ): + put_properties({}, {"foo": "bar"}) + + +@pytest.mark.parametrize( + "json_schema,new_properties", + [ + ( + {"properties": {"foo": "bar"}}, + {"foo": {"type": "integer"}}, + ), + ( + { + "properties": { + "foo": { + "bar": "baz", + } + } + }, + { + "foo": { + "bar": {"type": "integer"}, + } + }, + ), + ], +) +def test_put_properties_error_merge_conflict_dst_type( + json_schema: JsonSchemaValue, new_properties: JsonSchemaValue +) -> None: + with pytest.raises( + TypeError, + match="put_properties` merge conflict: `dst` exists but `src` cannot be merged in because `dst` is not a `JsonSchemaValue`", + ): + put_properties(json_schema, new_properties) + + +def test_put_properties_error_merge_conflict_dst_value() -> None: + with pytest.raises( + ValueError, + match=re.escape( + "`put_properties` merge conflict: `dst['type']='bar'` exists and does not equal `src['type']='baz'`" + ), + ): + put_properties( + { + "properties": { + "foo": { + "type": "bar", + } + } + }, + { + "foo": { + "type": "baz", + }, + }, + ) + + +@pytest.mark.parametrize( + "json_schema,new_properties,expect", + [ + ({}, {}, {}), + ({}, {"foo": {}}, {"properties": {"foo": {}}}), + ({"properties": {"foo": {}}}, {"foo": {}}, {"properties": {"foo": {}}}), + ( + {"properties": {"foo": {}}}, + {"bar": {"baz": "qux"}}, + {"properties": {"foo": {}, "bar": {"baz": "qux"}}}, + ), + ( + {"properties": {"foo": {"bar": "baz"}}}, + {"foo": {"qux": "corge"}}, + {"properties": {"foo": {"bar": "baz", "qux": "corge"}}}, + ), + ( + {"properties": {"foo": {"bar": "baz", "qux": {"corge": "garply"}}}}, + {"foo": {"qux": {"hello": [42]}}}, + { + "properties": { + "foo": {"bar": "baz", "qux": {"corge": "garply", "hello": [42]}} + } + }, + ), + ], +) +def test_put_properties_success( + json_schema: JsonSchemaValue, + new_properties: JsonSchemaValue, + expect: JsonSchemaValue, +) -> None: + put_properties(json_schema, new_properties) + + assert expect == json_schema + #################################################################################################### # try_move # From 7ef9d3c0d9f1aaf2944303208b69e9bf26f6acaa Mon Sep 17 00:00:00 2001 From: schapper Date: Thu, 16 Oct 2025 13:18:18 -0700 Subject: [PATCH 16/19] wip - PASSING - Feature complete (pun intended) and make check is passing --- .../overture/schema/system/_json_schema.py | 15 ++--- .../src/overture/schema/system/feature.py | 9 +-- .../src/overture/schema/system/optionality.py | 2 +- .../tests/test___json_schema.py | 24 ++++---- .../tests/test_feature.py | 61 ++++++++++--------- .../tests/test_optionality.py | 16 ++--- packages/overture-schema-system/tests/util.py | 59 +++++++++++------- 7 files changed, 102 insertions(+), 84 deletions(-) diff --git a/packages/overture-schema-system/src/overture/schema/system/_json_schema.py b/packages/overture-schema-system/src/overture/schema/system/_json_schema.py index 5000b2f82..56213ee5e 100644 --- a/packages/overture-schema-system/src/overture/schema/system/_json_schema.py +++ b/packages/overture-schema-system/src/overture/schema/system/_json_schema.py @@ -74,7 +74,7 @@ def put_not(json_schema: JsonSchemaValue, operand: JsonSchemaValue) -> None: return not_schema = prev["not"] - if not isinstance(not_schema, get_origin(JsonSchemaValue)): + if not isinstance(not_schema, cast(type, get_origin(JsonSchemaValue))): raise TypeError( 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}' ) @@ -152,7 +152,7 @@ def put_properties( _verify_json_schema_value( ("json_schema", json_schema), ("new_properties", new_properties) ) - origin = get_origin(JsonSchemaValue) + origin = cast(type, get_origin(JsonSchemaValue)) if "properties" in json_schema: properties = json_schema["properties"] if not isinstance(properties, origin): @@ -171,7 +171,7 @@ def put_properties( elif k not in properties: properties[k] = v else: - _merge(v, properties[k], k) + _merge(cast(JsonSchemaValue, v), properties[k], k) if not already_in and properties: json_schema["properties"] = properties @@ -189,7 +189,7 @@ def try_move(key: str, src: JsonSchemaValue, dst: JsonSchemaValue) -> None: def _verify_json_schema_value(*candidates: tuple[str, JsonSchemaValue]) -> None: - origin = get_origin(JsonSchemaValue) + origin = cast(type, get_origin(JsonSchemaValue)) for target in candidates: if not isinstance(target[1], origin): raise TypeError( @@ -197,7 +197,7 @@ def _verify_json_schema_value(*candidates: tuple[str, JsonSchemaValue]) -> None: ) -def _verify_operands_not_empty(tp: T, operands: list[T]) -> None: +def _verify_operands_not_empty(tp: type[T], operands: list[T]) -> None: if not isinstance(operands, list): raise TypeError( f"`operands` must be a `list`, but {operands} has type `{type(operands).__name__}`" @@ -217,16 +217,17 @@ def _verify_operands_not_empty(tp: T, operands: list[T]) -> None: def _merge(src: JsonSchemaValue, dst: JsonValue, *loc: str) -> None: - origin = get_origin(JsonSchemaValue) + origin = cast(type, get_origin(JsonSchemaValue)) if not isinstance(dst, origin): raise TypeError( 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__}`)" ) + dst = cast(JsonSchemaValue, dst) for k, v in src.items(): if k not in dst: dst[k] = v elif isinstance(v, origin): - _merge(v, dst[k], *loc, k) + _merge(cast(JsonSchemaValue, v), dst[k], *loc, k) elif dst[k] != v: raise ValueError( 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)})" diff --git a/packages/overture-schema-system/src/overture/schema/system/feature.py b/packages/overture-schema-system/src/overture/schema/system/feature.py index d1bfc4666..001ec2629 100644 --- a/packages/overture-schema-system/src/overture/schema/system/feature.py +++ b/packages/overture-schema-system/src/overture/schema/system/feature.py @@ -7,6 +7,7 @@ Field, GetJsonSchemaHandler, ModelWrapValidatorHandler, + SerializationInfo, SerializerFunctionWrapHandler, ValidationError, ValidationInfo, @@ -101,8 +102,8 @@ class Feature(BaseModel): @model_serializer(mode="wrap") def serialize_model( - self, serializer: SerializerFunctionWrapHandler, info: ValidationInfo - ) -> dict[str, object]: + self, serializer: SerializerFunctionWrapHandler, info: SerializationInfo + ) -> Any: """ Serializes to GeoJSON when the mode is JSON, otherwise to Pydantic's standard Python mode. """ @@ -332,7 +333,7 @@ def __get_pydantic_json_schema__( json_schema, properties_object_schema, ) - if_then_else = {} + if_then_else: JsonSchemaValue = {} _json_schema.try_move("if", json_schema, if_then_else) _json_schema.try_move("then", json_schema, if_then_else) _json_schema.try_move("else", json_schema, if_then_else) @@ -541,7 +542,7 @@ def _refactor_required(schema: JsonSchemaValue) -> None: def _merge_schemas( target_schema: JsonSchemaValue, source_schema: JsonSchemaValue ) -> None: - if_then_else = {} + if_then_else: JsonSchemaValue = {} _json_schema.try_move("if", source_schema, if_then_else) _json_schema.try_move("then", source_schema, if_then_else) _json_schema.try_move("else", source_schema, if_then_else) diff --git a/packages/overture-schema-system/src/overture/schema/system/optionality.py b/packages/overture-schema-system/src/overture/schema/system/optionality.py index ba423e608..08059932b 100644 --- a/packages/overture-schema-system/src/overture/schema/system/optionality.py +++ b/packages/overture-schema-system/src/overture/schema/system/optionality.py @@ -59,7 +59,7 @@ class Omitable(Generic[T]): >>> assert 'integer' == bar_type """ - def __class_getitem__(cls, item) -> type[Any]: + def __class_getitem__(cls, item: Any) -> Any: if _has_none(item): raise TypeError( f"`None` not allowed in `{Omitable.__name__}` args, but found `None` in {item}" diff --git a/packages/overture-schema-system/tests/test___json_schema.py b/packages/overture-schema-system/tests/test___json_schema.py index 8ae50158f..50ada6ce7 100644 --- a/packages/overture-schema-system/tests/test___json_schema.py +++ b/packages/overture-schema-system/tests/test___json_schema.py @@ -45,7 +45,7 @@ def test_get_static_json_schema_error_invalid_type() -> None: ValueError, match='expected value of config\'s "json_schema_extra" key to be a `dict`, but it is a `function`', ): - get_static_json_schema(ConfigDict(json_schema_extra=lambda _: {})) + get_static_json_schema(ConfigDict(json_schema_extra=lambda _: None)) #################################################################################################### @@ -92,7 +92,7 @@ def test_put_all_of_error_bad_type(operands: list[JsonSchemaValue]) -> None: put_all_of({}, operands) -def test_put_any_of_error_existing_all_of_not_list(): +def test_put_any_of_error_existing_all_of_not_list() -> None: with pytest.raises( ValueError, match='expected value of "allOf" key to be a `list`' ): @@ -357,12 +357,12 @@ def test_put_if_success( #################################################################################################### -def test_put_required_error_invalid_json_schema(): +def test_put_required_error_invalid_json_schema() -> None: with pytest.raises( TypeError, match="`json_schema` must be a `JsonSchemaValue` value, but True has type `bool`", ): - put_required(True, ["foo"]) + put_required(cast(JsonSchemaValue, True), ["foo"]) @pytest.mark.parametrize( @@ -375,7 +375,7 @@ def test_put_required_error_invalid_json_schema(): ) def test_put_required_error_invalid_operands( operands: object, expect_error_type: type[Exception] -): +) -> None: with pytest.raises(expect_error_type): put_required({}, cast(list[str], operands)) @@ -402,7 +402,7 @@ def test_put_required_error_invalid_operands( ) def test_put_required_success( json_schema: JsonSchemaValue, operands: list[str], expect: JsonSchemaValue -): +) -> None: put_required(json_schema, operands) assert expect == json_schema @@ -418,7 +418,7 @@ def test_put_properties_error_invalid_json_schema_type() -> None: TypeError, match="`json_schema` must be a `JsonSchemaValue` value, but 42 has type `int`", ): - put_properties(42, {}) + put_properties(cast(JsonSchemaValue, 42), {}) def test_put_properties_error_invalid_new_properties_type() -> None: @@ -426,7 +426,7 @@ def test_put_properties_error_invalid_new_properties_type() -> None: TypeError, match="`new_properties` must be a `JsonSchemaValue` value, but 'foo' has type `str`", ): - put_properties({}, "foo") + put_properties({}, cast(JsonSchemaValue, "foo")) def test_put_properties_error_invalid_existing_properties_type() -> None: @@ -543,9 +543,9 @@ def test_put_properties_success( #################################################################################################### -def test_try_move_existing_key(): +def test_try_move_existing_key() -> None: src = {"foo": "bar"} - dst = {} + dst: JsonSchemaValue = {} try_move("foo", src, dst) @@ -553,9 +553,9 @@ def test_try_move_existing_key(): assert {"foo": "bar"} == dst -def test_try_move_missing_key(): +def test_try_move_missing_key() -> None: src = {"foo": "bar"} - dst = {} + dst: JsonSchemaValue = {} try_move("baz", src, dst) diff --git a/packages/overture-schema-system/tests/test_feature.py b/packages/overture-schema-system/tests/test_feature.py index 383814ac0..d64b61a74 100644 --- a/packages/overture-schema-system/tests/test_feature.py +++ b/packages/overture-schema-system/tests/test_feature.py @@ -3,7 +3,7 @@ import sys from copy import deepcopy from pathlib import Path -from typing import Annotated +from typing import Annotated, cast import pytest from pydantic import ConfigDict, ValidationError, create_model @@ -35,7 +35,7 @@ class TestSerializeModel: "feature,expect", [ ( - Feature(geometry=Geometry.from_wkt("POINT(1 2)")), + Feature(geometry=Geometry.from_wkt("POINT(1 2)")), # type: ignore[call-arg] { "type": "Feature", "geometry": {"type": "Point", "coordinates": [1.0, 2.0]}, @@ -43,7 +43,7 @@ class TestSerializeModel: }, ), ( - Feature(id="foo", geometry=Geometry.from_wkt("POINT(1 2)")), + Feature(id="foo", geometry=Geometry.from_wkt("POINT(1 2)")), # type: ignore[call-arg] { "type": "Feature", "id": "foo", @@ -52,7 +52,7 @@ class TestSerializeModel: }, ), ( - Feature( + Feature( # type: ignore[call-arg] bbox=BBox(0, 1, 0, 2), geometry=Geometry.from_wkt("POINT(1 2)") ), { @@ -87,13 +87,13 @@ def test_simple_json(self, feature: Feature, expect: dict[str, object]) -> None: "feature,expect", [ ( - Feature(geometry=Geometry.from_wkt("POINT(1 2)")), + Feature(geometry=Geometry.from_wkt("POINT(1 2)")), # type: ignore[call-arg] { "geometry": Geometry.from_wkt("POINT(1 2)"), }, ), ( - Feature(id="foo", geometry=Geometry.from_wkt("POINT(1 2)")), + Feature(id="foo", geometry=Geometry.from_wkt("POINT(1 2)")), # type: ignore[call-arg] { "id": "foo", "geometry": Geometry.from_wkt("POINT(1 2)"), @@ -101,7 +101,8 @@ def test_simple_json(self, feature: Feature, expect: dict[str, object]) -> None: ), ( Feature( - bbox=BBox(0, 1, 0, 2), geometry=Geometry.from_wkt("POINT(1 2)") + bbox=BBox(0, 1, 0, 2), + geometry=Geometry.from_wkt("POINT(1 2)"), # type: ignore[call-arg] ), { "bbox": BBox(0, 1, 0, 2), @@ -134,7 +135,7 @@ class SubFeature(Feature): baz: bool | None = None geometry = Geometry.from_wkt("LINESTRING(0 1, 0 2)") - sub_feature = SubFeature(id="foo", foo=42, geometry=geometry) + sub_feature = SubFeature(id="foo", foo=42, geometry=geometry) # type: ignore[call-arg] actual_json = json.loads(sub_feature.model_dump_json()) assert { @@ -166,7 +167,7 @@ class TestValidateModel: "geometry": {"type": "Point", "coordinates": [1.0, 2.0]}, "properties": {}, }, - Feature(geometry=Geometry.from_wkt("POINT(1 2)")), + Feature(geometry=Geometry.from_wkt("POINT(1 2)")), # type: ignore[call-arg] ), ( { @@ -175,7 +176,7 @@ class TestValidateModel: "geometry": {"type": "Point", "coordinates": [1.0, 2.0]}, "properties": {}, }, - Feature(id="foo", geometry=Geometry.from_wkt("POINT(1 2)")), + Feature(id="foo", geometry=Geometry.from_wkt("POINT(1 2)")), # type: ignore[call-arg] ), ( { @@ -184,7 +185,7 @@ class TestValidateModel: "geometry": {"type": "Point", "coordinates": [1.0, 2.0]}, "properties": {}, }, - Feature( + Feature( # type: ignore[call-arg] bbox=BBox(0, 1, 0, 2), geometry=Geometry.from_wkt("POINT(1 2)") ), ), @@ -216,21 +217,21 @@ def test_simple_json(self, json_dict: dict[str, object], expect: Feature) -> Non { "geometry": Geometry.from_wkt("POINT(1 2)"), }, - Feature(geometry=Geometry.from_wkt("POINT(1 2)")), + Feature(geometry=Geometry.from_wkt("POINT(1 2)")), # type: ignore[call-arg] ), ( { "id": "foo", "geometry": Geometry.from_wkt("POINT(1 2)"), }, - Feature(id="foo", geometry=Geometry.from_wkt("POINT(1 2)")), + Feature(id="foo", geometry=Geometry.from_wkt("POINT(1 2)")), # type: ignore[call-arg] ), ( { "bbox": BBox(0, 1, 0, 2), "geometry": Geometry.from_wkt("POINT(1 2)"), }, - Feature( + Feature( # type: ignore[call-arg] bbox=BBox(0, 1, 0, 2), geometry=Geometry.from_wkt("POINT(1 2)") ), ), @@ -263,7 +264,7 @@ class SubFeature(Feature): bbox = BBox(0, 1, 0, 2) geometry = Geometry.from_wkt("LINESTRING(0 1, 0 2)") - expect = SubFeature(id="Hello", foo=42, baz=None, bbox=bbox, geometry=geometry) + expect = SubFeature(id="Hello", foo=42, baz=None, bbox=bbox, geometry=geometry) # type: ignore[call-arg] actual_from_json = SubFeature.model_validate_json( json.dumps( @@ -628,7 +629,7 @@ class SubFeature(Feature): class TestJsonSchema: - def test_simple_json_schema(self): + def test_simple_json_schema(self) -> None: expect = { "title": "Feature", "type": "object", @@ -666,7 +667,7 @@ def test_simple_json_schema(self): assert_subset(expect, actual, "expect", "actual") - def test_subclass_new_fields(self): + def test_subclass_new_fields(self) -> None: class SubFeature(Feature): foo: Omitable[int] bar: str | None = None @@ -716,14 +717,14 @@ class SubFeature(Feature): assert_subset(expect, actual, "expect", "actual") - def test_subclass_make_required_fields_not_required(self): + def test_subclass_make_required_fields_not_required(self) -> None: """ A subclass can technically redefine a required field to make it not required. This test verifies that the JSON Schema generation works as expected in this scenario. """ class SubFeature(Feature): - geometry: Omitable[Geometry] + geometry: Omitable[Geometry] # type: ignore[assignment] expect = { "title": "SubFeature", @@ -761,7 +762,7 @@ class SubFeature(Feature): assert_subset(expect, actual, "expect", "actual") - def test_subclass_geometry_type_constraint(self): + def test_subclass_geometry_type_constraint(self) -> None: class PointFeature(Feature): geometry: Annotated[Geometry, GeometryTypeConstraint(GeometryType.POINT)] @@ -819,7 +820,7 @@ class PointFeature(Feature): assert_subset(expect, actual, "expect", "actual") - def test_forbid_extra_fields_without_adding_fields(self): + def test_forbid_extra_fields_without_adding_fields(self) -> None: class SubFeature(Feature): model_config = ConfigDict(extra="forbid") @@ -860,7 +861,7 @@ class SubFeature(Feature): assert_subset(expect, actual, "expect", "actual") - def test_forbid_extra_fields_with_added_optional_field(self): + def test_forbid_extra_fields_with_added_optional_field(self) -> None: class SubFeature(Feature): model_config = ConfigDict(extra="forbid") @@ -909,7 +910,7 @@ class SubFeature(Feature): assert_subset(expect, actual, "expect", "actual") - def test_forbid_extra_fields_with_added_required_field(self): + def test_forbid_extra_fields_with_added_required_field(self) -> None: class SubFeature(Feature): model_config = ConfigDict(extra="forbid") @@ -951,7 +952,7 @@ class SubFeature(Feature): assert_subset(expect, actual, "expect", "actual") - def test_unsupported_keyword_min_properties(self): + def test_unsupported_keyword_min_properties(self) -> None: """ We don't have a clean way to port the JSON Schema "minProperties" keyword to the GeoJSON Schema in a way that respects the fact that the "logical" properties of the Feature get @@ -970,7 +971,7 @@ class MinFieldsFeature(Feature): actual = MinFieldsFeature.model_json_schema() print(json.dumps(actual, indent=2)) - def test_reuse_synthetic_field_names(self): + def test_reuse_synthetic_field_names(self) -> None: """ GeoJSON introduces two artificial field names, "type" and "properties". Since these aren't really part of the "logical" structure of a GeoJSON Feature (they are just "physical" @@ -983,7 +984,7 @@ class SyntheticFieldNamesModel(Feature): type: int properties: str | None = None - expect = { + expect: dict[str, object] = { "properties": { "properties": { "type": "object", @@ -1006,9 +1007,9 @@ class SyntheticFieldNamesModel(Feature): actual = SyntheticFieldNamesModel.model_json_schema() print(json.dumps(actual, indent=2)) - assert_subset(expect, actual, "expect", "actual") + assert_subset(expect, cast(dict[str, object], actual), "expect", "actual") - def test_model_constraint_top_level_only(self): + def test_model_constraint_top_level_only(self) -> None: @forbid_if(["bbox"], FieldEqCondition("id", "hello")) @require_if(["id"], FieldEqCondition("bbox", [0, 0, 0, 0])) @require_any_of("id", "bbox") @@ -1078,7 +1079,7 @@ class TopLevelConstraintFeature(Feature): assert_subset(expect, actual, "expect", "actual") - def test_model_constraint_properties_object_only(self): + def test_model_constraint_properties_object_only(self) -> None: @forbid_if(["bar"], FieldEqCondition("baz", 42)) @require_any_of("foo", "bar") class PropertiesObjectConstraintFeature(Feature): @@ -1125,7 +1126,7 @@ class PropertiesObjectConstraintFeature(Feature): assert_subset(expect, actual, "expect", "actual") - def test_model_constraint_mixed(self): + def test_model_constraint_mixed(self) -> None: @forbid_if(["foo", "type"], FieldEqCondition("properties", "ban.foo")) @require_if(["id", "foo", "qux"], FieldEqCondition("corge", 42)) @require_any_of("bbox", "foo", "garply") diff --git a/packages/overture-schema-system/tests/test_optionality.py b/packages/overture-schema-system/tests/test_optionality.py index 9abf12534..c3c5b92e8 100644 --- a/packages/overture-schema-system/tests/test_optionality.py +++ b/packages/overture-schema-system/tests/test_optionality.py @@ -2,7 +2,7 @@ import sys from pathlib import Path from types import NoneType -from typing import Annotated, Any +from typing import Annotated, Any, cast import pytest from pydantic import BaseModel, create_model @@ -16,7 +16,7 @@ @pytest.mark.parametrize( - "model,expect_json,expect_json_schema", + "model_instance,expect_json,expect_json_schema", [ ( create_model("case1", foo=Omitable[int])(), @@ -71,20 +71,22 @@ ], ) def test_omitable_model( - model: type[BaseModel], expect_json: JsonDict, expect_json_schema: JsonDict + model_instance: BaseModel, + expect_json: JsonDict, + expect_json_schema: dict[str, object], ) -> None: - actual_json = json.loads(model.model_dump_json()) + actual_json = json.loads(model_instance.model_dump_json()) assert expect_json == actual_json - actual_json_schema = model.model_json_schema() + actual_json_schema = model_instance.model_json_schema() assert_subset( expect_json_schema, - actual_json_schema, + cast(dict[str, object], actual_json_schema), "expect_json_schema", "actual_json_schema", ) - assert not model.__class__.model_fields["foo"].is_required() + assert not model_instance.__class__.model_fields["foo"].is_required() @pytest.mark.parametrize( diff --git a/packages/overture-schema-system/tests/util.py b/packages/overture-schema-system/tests/util.py index 0ceb287dd..130e07b47 100644 --- a/packages/overture-schema-system/tests/util.py +++ b/packages/overture-schema-system/tests/util.py @@ -1,27 +1,25 @@ -from typing import get_origin +from typing import cast -from pydantic.json_schema import JsonDict - -def subset_conflicts(a: JsonDict, b: JsonDict) -> JsonDict: +def subset_conflicts(a: dict[str, object], b: dict[str, object]) -> dict[str, object]: """ Returns conflict items that prevent `a` from being a subset of `b`. Parameters ---------- - a : JsonDict + a : dict[str, object] Candidate subset of `b` - b : JsonDict + b : dict[str, object] Candidate supserset of `a` Returns ------- - JsonDict + dict[str, object] Equal to `{}` if `a` is a subset of `b`, otherwise a non-empty `dict` containing keys from `a` that are either missing from `b` or that have different values in `a` and `b`. """ - conflicts: JsonDict = {} + conflicts: dict[str, object] = {} for k, av in a.items(): try: bv = b[k] @@ -29,21 +27,25 @@ def subset_conflicts(a: JsonDict, b: JsonDict) -> JsonDict: conflicts[k] = av continue if av != bv: - origin = get_origin(JsonDict) - if isinstance(av, origin) and isinstance(bv, origin): - sub_conflicts = subset_conflicts(av, bv) - if sub_conflicts: - conflicts[k] = sub_conflicts + if isinstance(av, dict) and isinstance(bv, dict): + dict_conflicts = subset_conflicts(cast(dict, av), cast(dict, bv)) + if dict_conflicts: + conflicts[k] = dict_conflicts elif isinstance(av, list | tuple) and isinstance(bv, list | tuple): - sub_conflicts = _array_conflicts(av, bv) - if sub_conflicts: - conflicts[k] = sub_conflicts + array_conflicts = _array_conflicts( + cast(list[object] | tuple[object, ...], av), + cast(list[object] | tuple[object, ...], bv), + ) + if array_conflicts: + conflicts[k] = array_conflicts else: conflicts[k] = _type_mismatch(av, bv) or _value_mismatch(av, bv) return conflicts -def assert_subset(a: JsonDict, b: JsonDict, a_name: str = "a", b_name: str = "b"): +def assert_subset( + a: dict[str, object], b: dict[str, object], a_name: str = "a", b_name: str = "b" +) -> None: conflicts = subset_conflicts(a, b) if conflicts: raise AssertionError( @@ -67,15 +69,26 @@ def _value_mismatch(a: object, b: object) -> str: def _array_conflicts( a: list[object] | tuple[object, ...], b: list[object] | tuple[object, ...] ) -> list[object]: - conflicts = [] + conflicts: list[object] = [] for i, (av, bv) in enumerate(zip(a, b, strict=False)): - origin = get_origin(JsonDict) - if isinstance(av, origin) and isinstance(bv, origin): - sub_conflicts = subset_conflicts(av, bv) + if isinstance(av, dict) and isinstance(bv, dict): + sub_conflicts = subset_conflicts( + cast(dict[str, object], av), cast(dict[str, object], bv) + ) if sub_conflicts: - conflicts.append((i, sub_conflicts)) + conflicts.append( + { + "index": i, + "conflict": sub_conflicts, + } + ) elif av != bv: - conflicts.append((i, _type_mismatch(av, bv) or _value_mismatch(av, bv))) + conflicts.append( + { + "index": i, + "conflict": _type_mismatch(av, bv) or _value_mismatch(av, bv), + } + ) if len(a) != len(b): conflicts.append( From adb57c61031a89a5c627ee563e5832a880ccbe99 Mon Sep 17 00:00:00 2001 From: schapper Date: Thu, 16 Oct 2025 14:43:57 -0700 Subject: [PATCH 17/19] wip - [PASSING] - Migrate ref down to its new home in system --- .../schema/addresses/address/models.py | 3 +- .../src/overture/schema/annex/models.py | 2 +- .../schema/buildings/building_part/models.py | 3 +- .../src/overture/schema/core/models.py | 10 +++-- .../src/overture/schema/core/types.py | 40 ------------------ .../schema/divisions/division/models.py | 9 ++-- .../schema/divisions/division_area/models.py | 5 +-- .../divisions/division_boundary/models.py | 8 +--- .../src/overture/schema/divisions/models.py | 2 +- .../tests/division_baseline_schema.json | 2 +- .../division_boundary_baseline_schema.json | 2 +- .../src/overture/schema/system/__init__.py | 28 ++++++++++--- .../src/overture/schema/system/optionality.py | 8 +++- .../overture/schema/system/ref/__init__.py | 5 ++- .../src/overture/schema/system/ref/id.py | 41 ++++++++++++++++++- .../src/overture/schema/system/ref}/ref.py | 35 ++++++++-------- .../tests/ref/test_id.py | 22 ++++++++++ .../tests/ref}/test_ref.py | 11 ++--- .../overture/schema/transportation/models.py | 3 +- .../tests/segment_baseline_schema.json | 2 +- 20 files changed, 139 insertions(+), 102 deletions(-) rename packages/{overture-schema-core/src/overture/schema/core => overture-schema-system/src/overture/schema/system/ref}/ref.py (63%) create mode 100644 packages/overture-schema-system/tests/ref/test_id.py rename packages/{overture-schema-core/tests => overture-schema-system/tests/ref}/test_ref.py (69%) diff --git a/packages/overture-schema-addresses-theme/src/overture/schema/addresses/address/models.py b/packages/overture-schema-addresses-theme/src/overture/schema/addresses/address/models.py index 85bc05b54..cc9618c9e 100644 --- a/packages/overture-schema-addresses-theme/src/overture/schema/addresses/address/models.py +++ b/packages/overture-schema-addresses-theme/src/overture/schema/addresses/address/models.py @@ -7,14 +7,13 @@ from overture.schema.core import ( Feature, ) -from overture.schema.core.types import CountryCodeAlpha2 from overture.schema.system.model_constraint import no_extra_fields from overture.schema.system.primitive import ( Geometry, GeometryType, GeometryTypeConstraint, ) -from overture.schema.system.string import StrippedString +from overture.schema.system.string import CountryCodeAlpha2, StrippedString @no_extra_fields diff --git a/packages/overture-schema-annex/src/overture/schema/annex/models.py b/packages/overture-schema-annex/src/overture/schema/annex/models.py index 698744a96..b27542580 100644 --- a/packages/overture-schema-annex/src/overture/schema/annex/models.py +++ b/packages/overture-schema-annex/src/overture/schema/annex/models.py @@ -5,8 +5,8 @@ from pydantic import BaseModel, Field, HttpUrl -from overture.schema.core.types import CountryCodeAlpha2 from overture.schema.system.model_constraint import no_extra_fields +from overture.schema.system.string import CountryCodeAlpha2 from .enums import BuildSource, UpdateType from .types import LicenseShortname diff --git a/packages/overture-schema-buildings-theme/src/overture/schema/buildings/building_part/models.py b/packages/overture-schema-buildings-theme/src/overture/schema/buildings/building_part/models.py index 8878da463..a1109a006 100644 --- a/packages/overture-schema-buildings-theme/src/overture/schema/buildings/building_part/models.py +++ b/packages/overture-schema-buildings-theme/src/overture/schema/buildings/building_part/models.py @@ -6,13 +6,12 @@ from overture.schema.core import Feature from overture.schema.core.models import Named, Stacked -from overture.schema.core.ref import Reference, Relationship -from overture.schema.core.types import Id from overture.schema.system.primitive import ( Geometry, GeometryType, GeometryTypeConstraint, ) +from overture.schema.system.ref import Id, Reference, Relationship from ..building.models import Building from ..models import Shape diff --git a/packages/overture-schema-core/src/overture/schema/core/models.py b/packages/overture-schema-core/src/overture/schema/core/models.py index 55c599f7a..d58922ab1 100644 --- a/packages/overture-schema-core/src/overture/schema/core/models.py +++ b/packages/overture-schema-core/src/overture/schema/core/models.py @@ -22,7 +22,9 @@ BBox, Geometry, ) +from overture.schema.system.ref import Id, Identified from overture.schema.system.string import ( + CountryCodeAlpha2, JsonPointer, LanguageTag, RegionCode, @@ -33,10 +35,8 @@ from .types import ( CommonNames, ConfidenceScore, - CountryCodeAlpha2, FeatureUpdateTime, FeatureVersion, - Id, Level, LinearlyReferencedRange, MaxZoom, @@ -221,12 +221,14 @@ class SourcePropertyItem(GeometricRangeScope): TypeT = TypeVar("TypeT", bound=str) -class Feature(ExtensibleBaseModel, Generic[ThemeT, TypeT], ABC): +class Feature(ExtensibleBaseModel, Identified, Generic[ThemeT, TypeT], ABC): """Base class for all Overture features.""" # Required - id: Id + id: Id = Field( + description="A feature ID. This may be an ID associated with the Global Entity Reference System (GERS) if—and-only-if the feature represents an entity that is part of GERS." + ) theme: ThemeT # this is an enum in the JSON Schema, but that prevents Feature from being extended type: TypeT diff --git a/packages/overture-schema-core/src/overture/schema/core/types.py b/packages/overture-schema-core/src/overture/schema/core/types.py index 043870867..e66b0506e 100644 --- a/packages/overture-schema-core/src/overture/schema/core/types.py +++ b/packages/overture-schema-core/src/overture/schema/core/types.py @@ -14,30 +14,10 @@ CollectionConstraint, FieldConstraint, ) -from overture.schema.system.field_constraint.string import ( - CountryCodeAlpha2Constraint, -) from overture.schema.system.primitive import float32, int32, pct from overture.schema.system.string import ( - HexColor, - JsonPointer, LanguageTag, - NoWhitespaceString, - PhoneNumber, - RegionCode, StrippedString, - WikidataId, -) - -Id = NewType( - "Id", - Annotated[ - NoWhitespaceString, - Field( - min_length=1, - description="A feature ID. This may be an ID associated with the Global Entity Reference System (GERS) if—and-only-if the feature represents an entity that is part of GERS.", - ), - ], ) @@ -189,15 +169,6 @@ def __get_pydantic_json_schema__( ], ) -CountryCodeAlpha2 = NewType( - "CountryCodeAlpha2", - Annotated[ - str, - CountryCodeAlpha2Constraint(), - Field(description="ISO 3166-1 alpha-2 country code"), - ], -) - CommonNames = NewType( "CommonNames", Annotated[ @@ -305,28 +276,17 @@ def __get_pydantic_json_schema__( __all__ = [ "CommonNames", "ConfidenceScore", - "CountryCodeAlpha2", - "CountryCodeAlpha2Constraint", "FeatureUpdateTime", "FeatureVersion", - "HexColor", - "Id", - "JsonPointer", - "LanguageTag", "Level", "LinearlyReferencedPosition", "LinearlyReferencedRange", "LinearReferenceRangeConstraint", "MaxZoom", "MinZoom", - "NoWhitespaceString", "OpeningHours", - "PhoneNumber", "Prominence", - "RegionCode", "SortKey", "Theme", - "StrippedString", "Type", - "WikidataId", ] diff --git a/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division/models.py b/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division/models.py index d440576a2..02104becb 100644 --- a/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division/models.py +++ b/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division/models.py @@ -14,11 +14,7 @@ Names, Perspectives, ) -from overture.schema.core.types import ( - CommonNames, - CountryCodeAlpha2, - Id, -) +from overture.schema.core.types import CommonNames from overture.schema.system.field_constraint import ( UniqueItemsConstraint, ) @@ -33,7 +29,8 @@ GeometryTypeConstraint, int32, ) -from overture.schema.system.string import RegionCode, WikidataId +from overture.schema.system.ref import Id +from overture.schema.system.string import CountryCodeAlpha2, RegionCode, WikidataId from ..enums import IS_COUNTRY, DivisionClass, PlaceType from ..models import CapitalOfDivisionItem diff --git a/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division_area/models.py b/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division_area/models.py index 0f0d597bc..636918115 100644 --- a/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division_area/models.py +++ b/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division_area/models.py @@ -11,15 +11,14 @@ Named, Names, ) -from overture.schema.core.ref import Reference, Relationship -from overture.schema.core.types import CountryCodeAlpha2, Id from overture.schema.system.model_constraint import radio_group from overture.schema.system.primitive import ( Geometry, GeometryType, GeometryTypeConstraint, ) -from overture.schema.system.string import RegionCode +from overture.schema.system.ref import Id, Reference, Relationship +from overture.schema.system.string import CountryCodeAlpha2, RegionCode from ..division.models import Division from ..enums import PlaceType diff --git a/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division_boundary/models.py b/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division_boundary/models.py index 2adef45fb..380317224 100644 --- a/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division_boundary/models.py +++ b/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division_boundary/models.py @@ -8,11 +8,6 @@ Feature, ) from overture.schema.core.models import Perspectives -from overture.schema.core.ref import Reference, Relationship -from overture.schema.core.types import ( - CountryCodeAlpha2, - Id, -) from overture.schema.system.field_constraint import UniqueItemsConstraint from overture.schema.system.model_constraint import ( forbid_if, @@ -24,7 +19,8 @@ GeometryType, GeometryTypeConstraint, ) -from overture.schema.system.string import RegionCode +from overture.schema.system.ref import Id, Reference, Relationship +from overture.schema.system.string import CountryCodeAlpha2, RegionCode from ..division import Division from ..enums import IS_COUNTRY, PlaceType diff --git a/packages/overture-schema-divisions-theme/src/overture/schema/divisions/models.py b/packages/overture-schema-divisions-theme/src/overture/schema/divisions/models.py index 1bd78985b..4f43e4965 100644 --- a/packages/overture-schema-divisions-theme/src/overture/schema/divisions/models.py +++ b/packages/overture-schema-divisions-theme/src/overture/schema/divisions/models.py @@ -2,9 +2,9 @@ from pydantic import BaseModel, ConfigDict, Field -from overture.schema.core.types import Id from overture.schema.divisions.enums import PlaceType from overture.schema.system.model_constraint import no_extra_fields +from overture.schema.system.ref import Id from overture.schema.system.string import StrippedString DivisionId = NewType( diff --git a/packages/overture-schema-divisions-theme/tests/division_baseline_schema.json b/packages/overture-schema-divisions-theme/tests/division_baseline_schema.json index c60311e5e..32aa85eac 100644 --- a/packages/overture-schema-divisions-theme/tests/division_baseline_schema.json +++ b/packages/overture-schema-divisions-theme/tests/division_baseline_schema.json @@ -432,7 +432,7 @@ "capital_division_ids": { "description": "Division IDs of this division's capital divisions. If present, this property will refer to the division IDs of the capital cities, county seats, etc. of a division.", "items": { - "description": "A feature ID. This may be an ID associated with the Global Entity Reference System (GERS) if\u2014and-only-if the feature represents an entity that is part of GERS.", + "description": "A unique identifier", "minLength": 1, "pattern": "^\\S+$", "type": "string" diff --git a/packages/overture-schema-divisions-theme/tests/division_boundary_baseline_schema.json b/packages/overture-schema-divisions-theme/tests/division_boundary_baseline_schema.json index ac41e802a..0f2bcafea 100644 --- a/packages/overture-schema-divisions-theme/tests/division_boundary_baseline_schema.json +++ b/packages/overture-schema-divisions-theme/tests/division_boundary_baseline_schema.json @@ -294,7 +294,7 @@ "division_ids": { "description": "Identifies the two divisions to the left and right, respectively, of the boundary line. The left- and right-hand sides of the boundary are considered from the perspective of a person standing on the line facing in the direction in which the geometry is oriented, i.e. facing toward the end of the line.\n\nThe first array element is the Overture ID of the left division. The second element is the Overture ID of the right division.", "items": { - "description": "A feature ID. This may be an ID associated with the Global Entity Reference System (GERS) if\u2014and-only-if the feature represents an entity that is part of GERS.", + "description": "A unique identifier", "minLength": 1, "pattern": "^\\S+$", "type": "string" diff --git a/packages/overture-schema-system/src/overture/schema/system/__init__.py b/packages/overture-schema-system/src/overture/schema/system/__init__.py index f4a18ed28..f7b795f5b 100644 --- a/packages/overture-schema-system/src/overture/schema/system/__init__.py +++ b/packages/overture-schema-system/src/overture/schema/system/__init__.py @@ -1,19 +1,25 @@ r""" Foundational types at the base of the Overture schema system. -A set of primitive types, constraint rules, and Pydantic base model classes that -can be used to create strongly-typed, predictably validated, data. +A set of primitive types, constraint rules, and Pydantic model classes, and annotations that can be +used to create strongly-typed, predictably validated, data. Subpackages ----------- -- :mod:`primitive ` Primitive data types, including numeric and - geometry types. +- :mod:`feature ` The `Feature` type, a Pydantic model type for + geospatial data whose JSON Schema and serialized JSON representation are compatible with GeoJSON. - :mod:`field_constraint ` Constraints that can be annotated onto Pydantic model fields to force them to conform to well-known rules, for example "a collection that contains unique items" or "a string that is a valid country code". - :mod:`model_constraint ` Constraints that can be decorated onto Pydantic model classes to add cross-field validation rules, for example "these two fields are mutually-exclusive" or "if this field is set, then that field must also be set". +- :mod:`optionality` ` The `Omitable` type hint, syntax sugar to + help a Pydantic model's optional fields behave closer to JSON Schema semantics. +- :mod:`primitive ` Primitive data types, including numeric and + geometry types. +- :mod:`ref ` Unique IDs and annotations to descrie relationships + between models based on unique IDs. (*i.e.*, foreign key relationships). - :mod:`string ` String types with built-in validation to conform to well-known patterns, for example a country code, a hexadecimal color code, a language tag, or just a string that doesn't contain whitespace. @@ -26,10 +32,12 @@ Parquet.) - Tightly integrated with Pydantic's JSON Schema system, providing rich JSON Schemas and maximum parity between Pydantic, generated JSON Schemas, and Overture's code generation tools. -- First-class support for geospatial data using the geometry primitives. +- First-class support for geospatial data using the geometry primitives and the + `overture.schema.system.feature.Feature` class. - Conditional fields and validation on relationships between fields (*e.g.*, if the type field contains "region", then region code field must also be set). - Constraint rules produce detailed and consistent error messages with useful domain knowledge. +- Reference annotations allow foreign key relationships between models to be described. Examples -------- @@ -119,6 +127,16 @@ ... assert "at least one of these fields must be explicitly set, but none are: foo, bar" in str(e) ... print("Validation failed") Validation failed + +Describe a foreign key relationship between two models where one model has a field that contains the +unique identifier of another model. + +>>> from typing import Annotated +>>> from overture.schema.system.ref import Id, Identified, Reference, Relationship +>>> class Park(Identified): +... pass +>>> class ParkBench(Identified): +... park_id: Annotated[Id, Reference(Relationship.BELONGS_TO, Park)] """ from . import ( diff --git a/packages/overture-schema-system/src/overture/schema/system/optionality.py b/packages/overture-schema-system/src/overture/schema/system/optionality.py index 08059932b..f4d50ff3d 100644 --- a/packages/overture-schema-system/src/overture/schema/system/optionality.py +++ b/packages/overture-schema-system/src/overture/schema/system/optionality.py @@ -21,11 +21,15 @@ class Omitable(Generic[T]): the main type, for example: >>> from pydantic import BaseModel + >>> >>> class MyModel(BaseModel): ... my_optional_field: int | None = None + ... + >>> MyModel().model_dump() + {'my_optional_field': None} >>> json_schema = MyModel.model_json_schema() - >>> any_of = json_schema['properties']['my_optional_field']['anyOf'] - >>> assert [{'type': 'integer'}, {'type': 'null'}] == any_of + >>> json_schema['properties']['my_optional_field']['anyOf'] + [{'type': 'integer'}, {'type': 'null'}] Although this approach works well in many scenarios, it can't represent JSON Schemas that allow values to be omitted but do not allow them to be explicitly set to the JSON value `null`, for diff --git a/packages/overture-schema-system/src/overture/schema/system/ref/__init__.py b/packages/overture-schema-system/src/overture/schema/system/ref/__init__.py index d895e6fe3..97f4b3f71 100644 --- a/packages/overture-schema-system/src/overture/schema/system/ref/__init__.py +++ b/packages/overture-schema-system/src/overture/schema/system/ref/__init__.py @@ -1,3 +1,4 @@ -from .id import Id +from .id import Id, Identified +from .ref import Reference, Relationship -__all__ = ["Id"] +__all__ = ["Id", "Identified", "Reference", "Relationship"] diff --git a/packages/overture-schema-system/src/overture/schema/system/ref/id.py b/packages/overture-schema-system/src/overture/schema/system/ref/id.py index 280dd31bc..8e95a9d0b 100644 --- a/packages/overture-schema-system/src/overture/schema/system/ref/id.py +++ b/packages/overture-schema-system/src/overture/schema/system/ref/id.py @@ -1,6 +1,6 @@ from typing import Annotated, NewType -from pydantic import Field +from pydantic import BaseModel, Field from overture.schema.system.string import NoWhitespaceString @@ -17,3 +17,42 @@ """ A unique identifier. """ + + +class Identified(BaseModel): + """ + A Pydantic model with a mandatory unique ID field. + + Derive from this class to give your model a unique identifier and to make it compatible with + the reference annotations: + + >>> from pydantic import Field + >>> + >>> class House(Identified): + ... '''A house.''' + ... address: str = Field(description = "Address of the house") + ... + >>> from typing import Annotated + >>> from overture.schema.system.ref import Reference, Relationship + >>> + >>> class Room(Identified): + ... '''A room within a house.''' + ... name: str = Field(description = 'Name of the room') + ... house_id: Annotated[ + ... Id, + ... Reference(Relationship.BELONGS_TO, House) + ... ] = Field(description = "Unique ID of the house the room belongs to.") + + When combining `Identified` with another Pydantic model that has an `id` field, such as a + :class:`~overture.schema.system.feature.Feature`, you must derive from `Identified` first in + order to correctly the *mandatory* `id` field. + + >>> from overture.schema.system.feature import Feature + >>> class IdentifiedFeature(Identified, Feature): + ... pass + >>> IdentifiedFeature.model_fields['id'].is_required() + True + """ + + id: Id = Field(description="Unique identifier") + """Unique identifier of the model.""" diff --git a/packages/overture-schema-core/src/overture/schema/core/ref.py b/packages/overture-schema-system/src/overture/schema/system/ref/ref.py similarity index 63% rename from packages/overture-schema-core/src/overture/schema/core/ref.py rename to packages/overture-schema-system/src/overture/schema/system/ref/ref.py index 0522f4b60..8906560d4 100644 --- a/packages/overture-schema-core/src/overture/schema/core/ref.py +++ b/packages/overture-schema-system/src/overture/schema/system/ref/ref.py @@ -1,12 +1,12 @@ from dataclasses import dataclass from enum import Enum -from .models import Feature +from .id import Identified class Relationship(Enum): """ - Category of relationship between a value that refers to another value. + Category of relationship between two values, where the first value refers to the second one. If we call the first value, the one that holds the reference, the relator; and the second value, value, the one that is referred to, as the relatee; then this value represents the relationship @@ -25,48 +25,49 @@ def __init__(self, value: str, doc: str) -> None: @dataclass(frozen=True, slots=True) class Reference: """ - Annotation class describing a relationship between two feature types. + Annotation class describing a relationship between two values where the relatee is referenced + by its unique ID. Parameters ---------- relationship : Relationship The kind of relationship between the relator (the type annotated with an instance of this class that is said to "hold the reference") and the relatee. - relatee : type[Feature] - The feature type that is the object or target of the relationship ("the thing related to"). + relatee : type[Identified] + The type that is the object or target of the relationship ("the thing related to"). Attributes ---------- relationship : Relationship The kind of relationship between the relator (the type annotated with an instance of this class that is said to "hold the reference") and the relatee. - relatee : type[Feature] - The feature type that is the object or target of the relationship ("the thing related to"). + relatee : type[Identifier] + The type that is the object or target of the relationship ("the thing related to"). Examples -------- - A hypothetical ParkBench feature type that holds a foreign key relationship to the hypothetical - Park feature type that the bench belongs to. + A hypothetical ParkBench model holds a foreign key relationship to the model of the park the + bench belongs to. >>> from typing import Annotated - >>> from overture.schema.core.models import Feature - >>> from overture.schema.core.ref import Reference, Relationship - >>> from overture.schema.core.types import Id - >>> class Park(Feature): + >>> from overture.schema.system.ref import Id, Identified + >>> class Park(Identified): ... pass - >>> class ParkBench(Feature): + >>> class ParkBench(Identified): ... park_id: Annotated[Id, Reference(Relationship.BELONGS_TO, Park)] """ relationship: Relationship - relatee: type[Feature] + relatee: type[Identified] def __post_init__(self) -> None: if not isinstance(self.relationship, Relationship): raise TypeError( f"`relationship` must be a member of the `Relationship` enumeration, but {self.relationship} is a `{type(self.relationship).__name__}`" ) - if not isinstance(self.relatee, type) or not issubclass(self.relatee, Feature): + if not isinstance(self.relatee, type) or not issubclass( + self.relatee, Identified + ): raise TypeError( - f"`relatee` must be a `Feature` type, i.e. a type that subclasses `Feature`, but {self.relatee} is a `{type(self.relatee).__name__}`" + f"`relatee` must be a type derived from `Identified`, but {self.relatee} is a `{type(self.relatee).__name__}`" ) diff --git a/packages/overture-schema-system/tests/ref/test_id.py b/packages/overture-schema-system/tests/ref/test_id.py new file mode 100644 index 000000000..288aa2254 --- /dev/null +++ b/packages/overture-schema-system/tests/ref/test_id.py @@ -0,0 +1,22 @@ +from overture.schema.system.feature import Feature +from overture.schema.system.ref.id import Identified + + +class TestIdentifiedFeature: + """ + Tests to validate that the `Identified` model plays nicely with the `Feature` model. + """ + + class IdentifiedFeature(Identified, Feature): + pass + + def test_id_required_in_model_fields(self): + id_field = TestIdentifiedFeature.IdentifiedFeature.model_fields["id"] + + assert id_field.is_required() + + def test_description_same_in_model_fields(self): + base_id_field = Identified.model_fields["id"] + derived_id_field = TestIdentifiedFeature.IdentifiedFeature.model_fields["id"] + + assert base_id_field.description == derived_id_field.description diff --git a/packages/overture-schema-core/tests/test_ref.py b/packages/overture-schema-system/tests/ref/test_ref.py similarity index 69% rename from packages/overture-schema-core/tests/test_ref.py rename to packages/overture-schema-system/tests/ref/test_ref.py index a78137b83..98bc0d610 100644 --- a/packages/overture-schema-core/tests/test_ref.py +++ b/packages/overture-schema-system/tests/ref/test_ref.py @@ -1,11 +1,12 @@ import pytest -from overture.schema.core.models import Feature -from overture.schema.core.ref import Reference, Relationship + +from overture.schema.system.ref.id import Identified +from overture.schema.system.ref.ref import Reference, Relationship def test_reference_err_not_a_relationship() -> None: with pytest.raises(TypeError): - Reference("foo", Feature) # type: ignore[arg-type] + Reference("foo", Identified) # type: ignore[arg-type] def test_reference_err_referee_not_a_feature_type() -> None: @@ -20,7 +21,7 @@ def test_reference_err_referee_not_a_type() -> None: @pytest.mark.parametrize("relationship", tuple(Relationship)) def test_reference_ok(relationship: Relationship) -> None: - ref = Reference(relationship, Feature) + ref = Reference(relationship, Identified) assert ref.relationship is relationship - assert ref.relatee is Feature + assert ref.relatee is Identified diff --git a/packages/overture-schema-transportation-theme/src/overture/schema/transportation/models.py b/packages/overture-schema-transportation-theme/src/overture/schema/transportation/models.py index 5c8aaa831..3a25c331b 100644 --- a/packages/overture-schema-transportation-theme/src/overture/schema/transportation/models.py +++ b/packages/overture-schema-transportation-theme/src/overture/schema/transportation/models.py @@ -6,9 +6,7 @@ from overture.schema.core import Feature from overture.schema.core.models import GeometricRangeScope -from overture.schema.core.ref import Reference, Relationship from overture.schema.core.types import ( - Id, Level, LinearlyReferencedPosition, OpeningHours, @@ -20,6 +18,7 @@ require_any_of, ) from overture.schema.system.primitive import float64, int32 +from overture.schema.system.ref import Id, Reference, Relationship from overture.schema.system.string import StrippedString, WikidataId from .enums import ( diff --git a/packages/overture-schema-transportation-theme/tests/segment_baseline_schema.json b/packages/overture-schema-transportation-theme/tests/segment_baseline_schema.json index b14bd474b..9446d1e87 100644 --- a/packages/overture-schema-transportation-theme/tests/segment_baseline_schema.json +++ b/packages/overture-schema-transportation-theme/tests/segment_baseline_schema.json @@ -103,7 +103,7 @@ "type": "number" }, "connector_id": { - "description": "A feature ID. This may be an ID associated with the Global Entity Reference System (GERS) if\u2014and-only-if the feature represents an entity that is part of GERS.", + "description": "A unique identifier", "minLength": 1, "pattern": "^\\S+$", "title": "Connector Id", From b6dda7820714b20f3a160b9481ea54e087f8b030 Mon Sep 17 00:00:00 2001 From: schapper Date: Thu, 16 Oct 2025 16:41:18 -0700 Subject: [PATCH 18/19] wip - RC - PASSING - Port core Feature to use system feature, FIX BUG - patternProperties in root --- .../schema/addresses/address/models.py | 4 +- .../tests/address_baseline_schema.json | 21 +- .../overture/schema/base/bathymetry/models.py | 4 +- .../schema/base/infrastructure/models.py | 4 +- .../src/overture/schema/base/land/models.py | 7 +- .../overture/schema/base/land_cover/models.py | 4 +- .../overture/schema/base/land_use/models.py | 4 +- .../src/overture/schema/base/water/models.py | 7 +- .../tests/bathymetry_baseline_schema.json | 21 +- .../tests/infrastructure_baseline_schema.json | 21 +- .../tests/land_baseline_schema.json | 21 +- .../tests/land_cover_baseline_schema.json | 21 +- .../tests/land_use_baseline_schema.json | 21 +- .../tests/water_baseline_schema.json | 21 +- .../schema/buildings/building/models.py | 4 +- .../schema/buildings/building_part/models.py | 7 +- .../tests/building_baseline_schema.json | 21 +- .../tests/building_part_baseline_schema.json | 21 +- .../src/overture/schema/core/__init__.py | 4 +- .../src/overture/schema/core/ext.py | 105 -------- .../src/overture/schema/core/json_schema.py | 2 + .../src/overture/schema/core/models.py | 245 ++++-------------- .../overture-schema-core/tests/test_ext.py | 0 .../overture-schema-core/tests/test_models.py | 15 +- .../overture-schema-core/tests/test_serde.py | 4 +- .../schema/divisions/division/models.py | 6 +- .../schema/divisions/division_area/models.py | 6 +- .../divisions/division_boundary/models.py | 6 +- .../tests/division_area_baseline_schema.json | 21 +- .../tests/division_baseline_schema.json | 21 +- .../division_boundary_baseline_schema.json | 21 +- .../overture/schema/places/place/models.py | 4 +- .../tests/place_baseline_schema.json | 21 +- .../schema/transportation/connector/models.py | 4 +- .../overture/schema/transportation/models.py | 4 +- .../schema/transportation/segment/models.py | 4 +- .../tests/connector_baseline_schema.json | 21 +- .../tests/segment_baseline_schema.json | 63 ++--- 38 files changed, 294 insertions(+), 517 deletions(-) delete mode 100644 packages/overture-schema-core/src/overture/schema/core/ext.py delete mode 100644 packages/overture-schema-core/tests/test_ext.py diff --git a/packages/overture-schema-addresses-theme/src/overture/schema/addresses/address/models.py b/packages/overture-schema-addresses-theme/src/overture/schema/addresses/address/models.py index cc9618c9e..d10c1e058 100644 --- a/packages/overture-schema-addresses-theme/src/overture/schema/addresses/address/models.py +++ b/packages/overture-schema-addresses-theme/src/overture/schema/addresses/address/models.py @@ -5,7 +5,7 @@ from pydantic import BaseModel, ConfigDict, Field from overture.schema.core import ( - Feature, + OvertureFeature, ) from overture.schema.system.model_constraint import no_extra_fields from overture.schema.system.primitive import ( @@ -34,7 +34,7 @@ class AddressLevel(BaseModel): ] = None -class Address(Feature[Literal["addresses"], Literal["address"]]): +class Address(OvertureFeature[Literal["addresses"], Literal["address"]]): """Addresses are geographic points used for locating businesses and individuals. The rules, fields, and fieldnames of an address can vary extensively between locations. We use a simplified schema to capture worldwide address points. This initial schema diff --git a/packages/overture-schema-addresses-theme/tests/address_baseline_schema.json b/packages/overture-schema-addresses-theme/tests/address_baseline_schema.json index c2d5974e6..2d724b56a 100644 --- a/packages/overture-schema-addresses-theme/tests/address_baseline_schema.json +++ b/packages/overture-schema-addresses-theme/tests/address_baseline_schema.json @@ -75,11 +75,6 @@ }, "additionalProperties": false, "description": "Addresses are geographic points used for locating businesses and individuals. The\nrules, fields, and fieldnames of an address can vary extensively between locations.\nWe use a simplified schema to capture worldwide address points. This initial schema\nis largely based on the OpenAddresses (www.openaddresses.io) project.\n\nThe address schema allows up to 5 \"admin levels\". Rather than have field names that\napply across all countries, we provide an array called \"address_levels\" containing\nthe necessary administrative levels for an address.", - "patternProperties": { - "^ext_.*$": { - "description": "Additional top-level properties must be prefixed with `ext_`." - } - }, "properties": { "bbox": { "items": { @@ -129,9 +124,16 @@ }, "properties": { "additionalProperties": false, + "not": { + "required": [ + "id", + "bbox", + "geometry" + ] + }, "patternProperties": { "^ext_.*$": { - "description": "Additional top-level properties must be prefixed with `ext_`." + "description": "Additional top-level properties are allowed if prefixed by `ext_`.\n\nThis feature is a on a deprecation path and will be removed once the schema is\nfully migrated to Pydantic." } }, "properties": { @@ -221,8 +223,7 @@ "type", "version" ], - "type": "object", - "unevaluatedProperties": false + "type": "object" }, "type": { "const": "Feature", @@ -230,10 +231,10 @@ } }, "required": [ + "type", "id", "geometry", - "properties", - "type" + "properties" ], "title": "address", "type": "object" diff --git a/packages/overture-schema-base-theme/src/overture/schema/base/bathymetry/models.py b/packages/overture-schema-base-theme/src/overture/schema/base/bathymetry/models.py index 16a69e3f0..a9e6f2599 100644 --- a/packages/overture-schema-base-theme/src/overture/schema/base/bathymetry/models.py +++ b/packages/overture-schema-base-theme/src/overture/schema/base/bathymetry/models.py @@ -6,7 +6,7 @@ from overture.schema.base.types import Depth from overture.schema.core import ( - Feature, + OvertureFeature, ) from overture.schema.core.models import CartographicallyHinted from overture.schema.system.primitive import ( @@ -17,7 +17,7 @@ class Bathymetry( - Feature[Literal["base"], Literal["bathymetry"]], CartographicallyHinted + OvertureFeature[Literal["base"], Literal["bathymetry"]], CartographicallyHinted ): """Topographic representation of an underwater area, such as a part of the ocean floor.""" diff --git a/packages/overture-schema-base-theme/src/overture/schema/base/infrastructure/models.py b/packages/overture-schema-base-theme/src/overture/schema/base/infrastructure/models.py index 9642920fb..3b6409c5d 100644 --- a/packages/overture-schema-base-theme/src/overture/schema/base/infrastructure/models.py +++ b/packages/overture-schema-base-theme/src/overture/schema/base/infrastructure/models.py @@ -11,7 +11,7 @@ from overture.schema.base.models import SourcedFromOpenStreetMap from overture.schema.base.types import Height from overture.schema.core import ( - Feature, + OvertureFeature, ) from overture.schema.core.models import Named, Stacked from overture.schema.system.primitive import ( @@ -24,7 +24,7 @@ class Infrastructure( - Feature[Literal["base"], Literal["infrastructure"]], + OvertureFeature[Literal["base"], Literal["infrastructure"]], Named, Stacked, SourcedFromOpenStreetMap, diff --git a/packages/overture-schema-base-theme/src/overture/schema/base/land/models.py b/packages/overture-schema-base-theme/src/overture/schema/base/land/models.py index 619680310..2dc3c9b65 100644 --- a/packages/overture-schema-base-theme/src/overture/schema/base/land/models.py +++ b/packages/overture-schema-base-theme/src/overture/schema/base/land/models.py @@ -8,7 +8,7 @@ from overture.schema.base.models import SourcedFromOpenStreetMap from overture.schema.base.types import Elevation from overture.schema.core import ( - Feature, + OvertureFeature, ) from overture.schema.core.models import Named, Stacked from overture.schema.system.primitive import ( @@ -21,7 +21,10 @@ class Land( - Feature[Literal["base"], Literal["land"]], Named, Stacked, SourcedFromOpenStreetMap + OvertureFeature[Literal["base"], Literal["land"]], + Named, + Stacked, + SourcedFromOpenStreetMap, ): """Physical representations of land surfaces. diff --git a/packages/overture-schema-base-theme/src/overture/schema/base/land_cover/models.py b/packages/overture-schema-base-theme/src/overture/schema/base/land_cover/models.py index d5d58f5ae..2fd55d368 100644 --- a/packages/overture-schema-base-theme/src/overture/schema/base/land_cover/models.py +++ b/packages/overture-schema-base-theme/src/overture/schema/base/land_cover/models.py @@ -6,7 +6,7 @@ from overture.schema.base.land_cover.enums import LandCoverSubtype from overture.schema.core import ( - Feature, + OvertureFeature, ) from overture.schema.core.models import CartographicallyHinted from overture.schema.system.primitive import ( @@ -17,7 +17,7 @@ class LandCover( - Feature[Literal["base"], Literal["land_cover"]], CartographicallyHinted + OvertureFeature[Literal["base"], Literal["land_cover"]], CartographicallyHinted ): """Representation of the Earth's natural surfaces.""" diff --git a/packages/overture-schema-base-theme/src/overture/schema/base/land_use/models.py b/packages/overture-schema-base-theme/src/overture/schema/base/land_use/models.py index a0c44dab6..ac8838224 100644 --- a/packages/overture-schema-base-theme/src/overture/schema/base/land_use/models.py +++ b/packages/overture-schema-base-theme/src/overture/schema/base/land_use/models.py @@ -8,7 +8,7 @@ from overture.schema.base.models import SourcedFromOpenStreetMap from overture.schema.base.types import Elevation from overture.schema.core import ( - Feature, + OvertureFeature, ) from overture.schema.core.models import Named, Stacked from overture.schema.system.primitive import ( @@ -21,7 +21,7 @@ class LandUse( - Feature[Literal["base"], Literal["land_use"]], + OvertureFeature[Literal["base"], Literal["land_use"]], Named, Stacked, SourcedFromOpenStreetMap, diff --git a/packages/overture-schema-base-theme/src/overture/schema/base/water/models.py b/packages/overture-schema-base-theme/src/overture/schema/base/water/models.py index d88791676..08c16eab5 100644 --- a/packages/overture-schema-base-theme/src/overture/schema/base/water/models.py +++ b/packages/overture-schema-base-theme/src/overture/schema/base/water/models.py @@ -7,7 +7,7 @@ from overture.schema.base.models import SourcedFromOpenStreetMap from overture.schema.base.water.enums import WaterClass, WaterSubtype from overture.schema.core import ( - Feature, + OvertureFeature, ) from overture.schema.core.models import Named, Stacked from overture.schema.system.primitive import ( @@ -18,7 +18,10 @@ class Water( - Feature[Literal["base"], Literal["water"]], Stacked, Named, SourcedFromOpenStreetMap + OvertureFeature[Literal["base"], Literal["water"]], + Stacked, + Named, + SourcedFromOpenStreetMap, ): """Physical representations of inland and ocean marine surfaces. diff --git a/packages/overture-schema-base-theme/tests/bathymetry_baseline_schema.json b/packages/overture-schema-base-theme/tests/bathymetry_baseline_schema.json index a3b5eb446..f196529c1 100644 --- a/packages/overture-schema-base-theme/tests/bathymetry_baseline_schema.json +++ b/packages/overture-schema-base-theme/tests/bathymetry_baseline_schema.json @@ -94,11 +94,6 @@ }, "additionalProperties": false, "description": "Topographic representation of an underwater area, such as a part of the ocean\nfloor.", - "patternProperties": { - "^ext_.*$": { - "description": "Additional top-level properties must be prefixed with `ext_`." - } - }, "properties": { "bbox": { "items": { @@ -200,9 +195,16 @@ }, "properties": { "additionalProperties": false, + "not": { + "required": [ + "id", + "bbox", + "geometry" + ] + }, "patternProperties": { "^ext_.*$": { - "description": "Additional top-level properties must be prefixed with `ext_`." + "description": "Additional top-level properties are allowed if prefixed by `ext_`.\n\nThis feature is a on a deprecation path and will be removed once the schema is\nfully migrated to Pydantic." } }, "properties": { @@ -251,8 +253,7 @@ "version", "depth" ], - "type": "object", - "unevaluatedProperties": false + "type": "object" }, "type": { "const": "Feature", @@ -260,10 +261,10 @@ } }, "required": [ + "type", "id", "geometry", - "properties", - "type" + "properties" ], "title": "bathymetry", "type": "object" diff --git a/packages/overture-schema-base-theme/tests/infrastructure_baseline_schema.json b/packages/overture-schema-base-theme/tests/infrastructure_baseline_schema.json index e20fee72b..7e2e8f291 100644 --- a/packages/overture-schema-base-theme/tests/infrastructure_baseline_schema.json +++ b/packages/overture-schema-base-theme/tests/infrastructure_baseline_schema.json @@ -432,11 +432,6 @@ }, "additionalProperties": false, "description": "Various features from OpenStreetMap such as bridges, airport runways, aerialways,\nor communication towers and lines.", - "patternProperties": { - "^ext_.*$": { - "description": "Additional top-level properties must be prefixed with `ext_`." - } - }, "properties": { "bbox": { "items": { @@ -598,9 +593,16 @@ }, "properties": { "additionalProperties": false, + "not": { + "required": [ + "id", + "bbox", + "geometry" + ] + }, "patternProperties": { "^ext_.*$": { - "description": "Additional top-level properties must be prefixed with `ext_`." + "description": "Additional top-level properties are allowed if prefixed by `ext_`.\n\nThis feature is a on a deprecation path and will be removed once the schema is\nfully migrated to Pydantic." } }, "properties": { @@ -676,8 +678,7 @@ "class", "subtype" ], - "type": "object", - "unevaluatedProperties": false + "type": "object" }, "type": { "const": "Feature", @@ -685,10 +686,10 @@ } }, "required": [ + "type", "id", "geometry", - "properties", - "type" + "properties" ], "title": "Infrastructure Schema", "type": "object" diff --git a/packages/overture-schema-base-theme/tests/land_baseline_schema.json b/packages/overture-schema-base-theme/tests/land_baseline_schema.json index 891c1f477..6e6d8f0ad 100644 --- a/packages/overture-schema-base-theme/tests/land_baseline_schema.json +++ b/packages/overture-schema-base-theme/tests/land_baseline_schema.json @@ -306,11 +306,6 @@ }, "additionalProperties": false, "description": "Physical representations of land surfaces.\n\nGlobal land derived from the inverse of OSM Coastlines. Translates `natural` tags from OpenStreetMap.", - "patternProperties": { - "^ext_.*$": { - "description": "Additional top-level properties must be prefixed with `ext_`." - } - }, "properties": { "bbox": { "items": { @@ -472,9 +467,16 @@ }, "properties": { "additionalProperties": false, + "not": { + "required": [ + "id", + "bbox", + "geometry" + ] + }, "patternProperties": { "^ext_.*$": { - "description": "Additional top-level properties must be prefixed with `ext_`." + "description": "Additional top-level properties are allowed if prefixed by `ext_`.\n\nThis feature is a on a deprecation path and will be removed once the schema is\nfully migrated to Pydantic." } }, "properties": { @@ -551,8 +553,7 @@ "type", "version" ], - "type": "object", - "unevaluatedProperties": false + "type": "object" }, "type": { "const": "Feature", @@ -560,10 +561,10 @@ } }, "required": [ + "type", "id", "geometry", - "properties", - "type" + "properties" ], "title": "land", "type": "object" diff --git a/packages/overture-schema-base-theme/tests/land_cover_baseline_schema.json b/packages/overture-schema-base-theme/tests/land_cover_baseline_schema.json index c89349f12..a9e23e9ec 100644 --- a/packages/overture-schema-base-theme/tests/land_cover_baseline_schema.json +++ b/packages/overture-schema-base-theme/tests/land_cover_baseline_schema.json @@ -111,11 +111,6 @@ }, "additionalProperties": false, "description": "Representation of the Earth's natural surfaces.", - "patternProperties": { - "^ext_.*$": { - "description": "Additional top-level properties must be prefixed with `ext_`." - } - }, "properties": { "bbox": { "items": { @@ -217,9 +212,16 @@ }, "properties": { "additionalProperties": false, + "not": { + "required": [ + "id", + "bbox", + "geometry" + ] + }, "patternProperties": { "^ext_.*$": { - "description": "Additional top-level properties must be prefixed with `ext_`." + "description": "Additional top-level properties are allowed if prefixed by `ext_`.\n\nThis feature is a on a deprecation path and will be removed once the schema is\nfully migrated to Pydantic." } }, "properties": { @@ -264,8 +266,7 @@ "version", "subtype" ], - "type": "object", - "unevaluatedProperties": false + "type": "object" }, "type": { "const": "Feature", @@ -273,10 +274,10 @@ } }, "required": [ + "type", "id", "geometry", - "properties", - "type" + "properties" ], "title": "land_cover", "type": "object" diff --git a/packages/overture-schema-base-theme/tests/land_use_baseline_schema.json b/packages/overture-schema-base-theme/tests/land_use_baseline_schema.json index 4c1403635..b43c6e6aa 100644 --- a/packages/overture-schema-base-theme/tests/land_use_baseline_schema.json +++ b/packages/overture-schema-base-theme/tests/land_use_baseline_schema.json @@ -384,11 +384,6 @@ }, "additionalProperties": false, "description": "Land use features from OpenStreetMap.", - "patternProperties": { - "^ext_.*$": { - "description": "Additional top-level properties must be prefixed with `ext_`." - } - }, "properties": { "bbox": { "items": { @@ -550,9 +545,16 @@ }, "properties": { "additionalProperties": false, + "not": { + "required": [ + "id", + "bbox", + "geometry" + ] + }, "patternProperties": { "^ext_.*$": { - "description": "Additional top-level properties must be prefixed with `ext_`." + "description": "Additional top-level properties are allowed if prefixed by `ext_`.\n\nThis feature is a on a deprecation path and will be removed once the schema is\nfully migrated to Pydantic." } }, "properties": { @@ -629,8 +631,7 @@ "class", "subtype" ], - "type": "object", - "unevaluatedProperties": false + "type": "object" }, "type": { "const": "Feature", @@ -638,10 +639,10 @@ } }, "required": [ + "type", "id", "geometry", - "properties", - "type" + "properties" ], "title": "land_use", "type": "object" diff --git a/packages/overture-schema-base-theme/tests/water_baseline_schema.json b/packages/overture-schema-base-theme/tests/water_baseline_schema.json index 584573dd4..ef9338163 100644 --- a/packages/overture-schema-base-theme/tests/water_baseline_schema.json +++ b/packages/overture-schema-base-theme/tests/water_baseline_schema.json @@ -267,11 +267,6 @@ }, "additionalProperties": false, "description": "Physical representations of inland and ocean marine surfaces.\n\nTranslates `natural` and `waterway` tags from OpenStreetMap.", - "patternProperties": { - "^ext_.*$": { - "description": "Additional top-level properties must be prefixed with `ext_`." - } - }, "properties": { "bbox": { "items": { @@ -433,9 +428,16 @@ }, "properties": { "additionalProperties": false, + "not": { + "required": [ + "id", + "bbox", + "geometry" + ] + }, "patternProperties": { "^ext_.*$": { - "description": "Additional top-level properties must be prefixed with `ext_`." + "description": "Additional top-level properties are allowed if prefixed by `ext_`.\n\nThis feature is a on a deprecation path and will be removed once the schema is\nfully migrated to Pydantic." } }, "properties": { @@ -512,8 +514,7 @@ "type", "version" ], - "type": "object", - "unevaluatedProperties": false + "type": "object" }, "type": { "const": "Feature", @@ -521,10 +522,10 @@ } }, "required": [ + "type", "id", "geometry", - "properties", - "type" + "properties" ], "title": "water", "type": "object" diff --git a/packages/overture-schema-buildings-theme/src/overture/schema/buildings/building/models.py b/packages/overture-schema-buildings-theme/src/overture/schema/buildings/building/models.py index 92024fac3..34f2a9705 100644 --- a/packages/overture-schema-buildings-theme/src/overture/schema/buildings/building/models.py +++ b/packages/overture-schema-buildings-theme/src/overture/schema/buildings/building/models.py @@ -4,7 +4,7 @@ from pydantic import ConfigDict, Field -from overture.schema.core import Feature +from overture.schema.core import OvertureFeature from overture.schema.core.models import Named, Stacked from overture.schema.system.primitive import ( Geometry, @@ -20,7 +20,7 @@ class Building( - Feature[Literal["buildings"], Literal["building"]], Named, Stacked, Shape + OvertureFeature[Literal["buildings"], Literal["building"]], Named, Stacked, Shape ): """A building is a man-made structure with a roof that exists permanently in one place. diff --git a/packages/overture-schema-buildings-theme/src/overture/schema/buildings/building_part/models.py b/packages/overture-schema-buildings-theme/src/overture/schema/buildings/building_part/models.py index a1109a006..3ca522cf5 100644 --- a/packages/overture-schema-buildings-theme/src/overture/schema/buildings/building_part/models.py +++ b/packages/overture-schema-buildings-theme/src/overture/schema/buildings/building_part/models.py @@ -4,7 +4,7 @@ from pydantic import Field -from overture.schema.core import Feature +from overture.schema.core import OvertureFeature from overture.schema.core.models import Named, Stacked from overture.schema.system.primitive import ( Geometry, @@ -18,7 +18,10 @@ class BuildingPart( - Feature[Literal["buildings"], Literal["building_part"]], Named, Stacked, Shape + OvertureFeature[Literal["buildings"], Literal["building_part"]], + Named, + Stacked, + Shape, ): """A single building part. diff --git a/packages/overture-schema-buildings-theme/tests/building_baseline_schema.json b/packages/overture-schema-buildings-theme/tests/building_baseline_schema.json index b751ceb0b..6c138961a 100644 --- a/packages/overture-schema-buildings-theme/tests/building_baseline_schema.json +++ b/packages/overture-schema-buildings-theme/tests/building_baseline_schema.json @@ -389,11 +389,6 @@ }, "additionalProperties": false, "description": "A building is a man-made structure with a roof that exists permanently in one\nplace.\n\nBuildings are compatible with GeoJSON Polygon features.", - "patternProperties": { - "^ext_.*$": { - "description": "Additional top-level properties must be prefixed with `ext_`." - } - }, "properties": { "bbox": { "items": { @@ -495,9 +490,16 @@ }, "properties": { "additionalProperties": false, + "not": { + "required": [ + "id", + "bbox", + "geometry" + ] + }, "patternProperties": { "^ext_.*$": { - "description": "Additional top-level properties must be prefixed with `ext_`." + "description": "Additional top-level properties are allowed if prefixed by `ext_`.\n\nThis feature is a on a deprecation path and will be removed once the schema is\nfully migrated to Pydantic." } }, "properties": { @@ -633,8 +635,7 @@ "type", "version" ], - "type": "object", - "unevaluatedProperties": false + "type": "object" }, "type": { "const": "Feature", @@ -642,10 +643,10 @@ } }, "required": [ + "type", "id", "geometry", - "properties", - "type" + "properties" ], "title": "building", "type": "object" diff --git a/packages/overture-schema-buildings-theme/tests/building_part_baseline_schema.json b/packages/overture-schema-buildings-theme/tests/building_part_baseline_schema.json index 51b1a763e..fd0d18bd8 100644 --- a/packages/overture-schema-buildings-theme/tests/building_part_baseline_schema.json +++ b/packages/overture-schema-buildings-theme/tests/building_part_baseline_schema.json @@ -275,11 +275,6 @@ }, "additionalProperties": false, "description": "A single building part.\n\nParts describe their shape and color and other properties. Each building part must\nrefer to the building to which it belongs.", - "patternProperties": { - "^ext_.*$": { - "description": "Additional top-level properties must be prefixed with `ext_`." - } - }, "properties": { "bbox": { "items": { @@ -381,9 +376,16 @@ }, "properties": { "additionalProperties": false, + "not": { + "required": [ + "id", + "bbox", + "geometry" + ] + }, "patternProperties": { "^ext_.*$": { - "description": "Additional top-level properties must be prefixed with `ext_`." + "description": "Additional top-level properties are allowed if prefixed by `ext_`.\n\nThis feature is a on a deprecation path and will be removed once the schema is\nfully migrated to Pydantic." } }, "properties": { @@ -516,8 +518,7 @@ "version", "building_id" ], - "type": "object", - "unevaluatedProperties": false + "type": "object" }, "type": { "const": "Feature", @@ -525,10 +526,10 @@ } }, "required": [ + "type", "id", "geometry", - "properties", - "type" + "properties" ], "title": "BuildingPart", "type": "object" diff --git a/packages/overture-schema-core/src/overture/schema/core/__init__.py b/packages/overture-schema-core/src/overture/schema/core/__init__.py index eb9ec7357..2c42f6534 100644 --- a/packages/overture-schema-core/src/overture/schema/core/__init__.py +++ b/packages/overture-schema-core/src/overture/schema/core/__init__.py @@ -1,5 +1,5 @@ from .json_schema import json_schema -from .models import Feature +from .models import OvertureFeature from .parser import parse_feature -__all__ = ["Feature", "json_schema", "parse_feature"] +__all__ = ["OvertureFeature", "json_schema", "parse_feature"] diff --git a/packages/overture-schema-core/src/overture/schema/core/ext.py b/packages/overture-schema-core/src/overture/schema/core/ext.py deleted file mode 100644 index c8f850005..000000000 --- a/packages/overture-schema-core/src/overture/schema/core/ext.py +++ /dev/null @@ -1,105 +0,0 @@ -from abc import ABC, abstractmethod -from collections.abc import Callable -from typing import Any - -from pydantic import BaseModel - - -# Temporarily copied in from validation package. -class BaseConstraintValidator(ABC): - """Base class for constraint validators.""" - - def __init__(self, *args: object, **kwargs: object) -> None: - self.args = args - self.kwargs = kwargs - - @abstractmethod - def validate(self, model_instance: BaseModel) -> None: - """Validate the constraint against the model instance.""" - pass - - @abstractmethod - def get_metadata( - self, model_class: type[BaseModel] | None = None, by_alias: bool = True - ) -> dict[str, Any]: - """Return plain constraint metadata.""" - pass - - @abstractmethod - def apply_json_schema_metadata( - self, - target_schema: dict[str, Any], - model_class: type[BaseModel] | None = None, - by_alias: bool = True, - ) -> None: - """Apply this constraint's modifications directly to the target schema.""" - pass - - -# Temporarily copied in from validation package. -def register_constraint( - model_class: type[BaseModel], constraint: BaseConstraintValidator -) -> None: - """Register a constraint for a model class.""" - if not hasattr(model_class, "__constraints__"): - model_class.__constraints__ = [] # type: ignore[attr-defined] - else: - # Ensure we have a copy of the constraints list for this class - # to avoid sharing references between classes - constraints = getattr(model_class, "__constraints__", []) - model_class.__constraints__ = constraints.copy() # type: ignore[attr-defined] - constraints = model_class.__constraints__ # type: ignore[attr-defined] - constraints.append(constraint) - - -def allow_extension_fields() -> Callable: - """Decorator to allow only ext_* prefixed extension fields.""" - - def decorator(cls: type[BaseModel]) -> type[BaseModel]: - constraint = ExtensionPrefixValidator() - register_constraint(cls, constraint) - return cls - - return decorator - - -class ExtensionPrefixValidator(BaseConstraintValidator): - """Validates that extra fields use ext_ prefix only.""" - - def validate(self, model_instance: BaseModel) -> None: - """Validate that extra fields use allowed prefixes.""" - if ( - hasattr(model_instance, "__pydantic_extra__") - and model_instance.__pydantic_extra__ - ): - for field_name in model_instance.__pydantic_extra__.keys(): - if not field_name.startswith("ext_"): - raise ValueError( - f"Unrecognized field '{field_name}' must use ext_ prefix" - ) - - def get_metadata( - self, model_class: type[BaseModel] | None = None, by_alias: bool = True - ) -> dict[str, Any]: - """Return plain constraint metadata.""" - return { - "type": "extension_prefix", - "pattern": "^ext_.*$", - "description": "Additional top-level properties must be prefixed with `ext_`.", - "additional_properties": False, - } - - def apply_json_schema_metadata( - self, - target_schema: dict[str, Any], - model_class: type[BaseModel] | None = None, - by_alias: bool = True, - ) -> None: - """Apply extension prefix constraint to the schema.""" - metadata = self.get_metadata(model_class, by_alias) - - target_schema["patternProperties"] = { - metadata["pattern"]: {"description": metadata["description"]} - } - if metadata["additional_properties"] is False: - target_schema["additionalProperties"] = False diff --git a/packages/overture-schema-core/src/overture/schema/core/json_schema.py b/packages/overture-schema-core/src/overture/schema/core/json_schema.py index deda633b7..9ffd2b33e 100644 --- a/packages/overture-schema-core/src/overture/schema/core/json_schema.py +++ b/packages/overture-schema-core/src/overture/schema/core/json_schema.py @@ -8,6 +8,8 @@ from ._cache import get_type_adapter +# TODO: Vic - I think we can remove this once `Omitable[T]` is applied everywhere (and once the +# @model_constraints are made `Omitable`-aware). class EnhancedJsonSchemaGenerator(GenerateJsonSchema): """Enhanced JSON Schema generator with optional field support. diff --git a/packages/overture-schema-core/src/overture/schema/core/models.py b/packages/overture-schema-core/src/overture/schema/core/models.py index d58922ab1..d1d679a09 100644 --- a/packages/overture-schema-core/src/overture/schema/core/models.py +++ b/packages/overture-schema-core/src/overture/schema/core/models.py @@ -1,25 +1,21 @@ -from abc import ABC, abstractmethod -from collections.abc import Callable -from typing import Annotated, Any, Generic, NewType, TypeVar, cast +import textwrap +from typing import Annotated, Generic, NewType, TypeVar from pydantic import ( BaseModel, ConfigDict, Field, GetJsonSchemaHandler, - ValidationInfo, - model_serializer, model_validator, ) +from pydantic.json_schema import JsonSchemaValue from pydantic_core import core_schema +from typing_extensions import Self -from overture.schema.core.ext import ( - allow_extension_fields, -) +from overture.schema.system.feature import Feature from overture.schema.system.field_constraint import UniqueItemsConstraint from overture.schema.system.model_constraint import no_extra_fields from overture.schema.system.primitive import ( - BBox, Geometry, ) from overture.schema.system.ref import Id, Identified @@ -46,106 +42,6 @@ ) -# Temporarily copied in from validation package. -class BaseConstraintValidator(ABC): - """Base class for constraint validators.""" - - def __init__(self, *args: object, **kwargs: object) -> None: - self.args = args - self.kwargs = kwargs - - @abstractmethod - def validate(self, model_instance: BaseModel) -> None: - """Validate the constraint against the model instance.""" - pass - - @abstractmethod - def get_metadata( - self, model_class: type[BaseModel] | None = None, by_alias: bool = True - ) -> dict[str, Any]: - """Return plain constraint metadata.""" - pass - - @abstractmethod - def apply_json_schema_metadata( - self, - target_schema: dict[str, Any], - model_class: type[BaseModel] | None = None, - by_alias: bool = True, - ) -> None: - """Apply this constraint's modifications directly to the target schema.""" - pass - - -# Temporarily copied in from validation package. -class ConstraintValidatedModel: - """Mixin class that provides constraint validation capabilities. - - This is a true mixin - it doesn't inherit from BaseModel to avoid MRO issues. - Use it like: class MyModel(ConstraintValidatedModel, BaseModel) - """ - - @model_validator(mode="after") - def validate_constraints(self) -> "ConstraintValidatedModel": - """Run all registered constraints for this model and its parent classes.""" - all_constraints: list[BaseConstraintValidator] = [] - - # Collect constraints from this class and all parent classes - # Use a more sophisticated approach to avoid cross-contamination - for cls in self.__class__.__mro__: - # Skip if this class has no constraints of its own - if not hasattr(cls, "__constraints__"): - continue - - # Only include constraints that were explicitly added to this class - # (not inherited from shared base classes) - class_constraints = getattr(cls, "__constraints__", []) - if class_constraints: - all_constraints.extend(class_constraints) - - # Run all constraints - for constraint in all_constraints: - # Cast self to BaseModel for the constraint validator - constraint.validate(self) # type: ignore[arg-type] - return self - - @classmethod - def __get_pydantic_json_schema__( - cls, - core_schema: Any, # noqa: ANN401 - handler: Any, # noqa: ANN401 - ) -> dict[str, Any]: - """Generate JSON Schema with constraints applied.""" - # Get the base schema from Pydantic - schema: dict[str, Any] = handler(core_schema) - - # Apply constraint metadata - all_constraints: list[BaseConstraintValidator] = [] - class_constraints = getattr(cls, "__constraints__", []) - if class_constraints: - all_constraints.extend(class_constraints) - - for constraint in all_constraints: - # Apply constraint modifications directly to the schema - # OvertureFeature will handle moving them to the correct GeoJSON structure if needed - constraint.apply_json_schema_metadata( - target_schema=schema, - model_class=cast(type[BaseModel], cls), - by_alias=True, - ) - - return schema - - -@allow_extension_fields() -class ExtensibleBaseModel(ConstraintValidatedModel, BaseModel): - """Base model that allows ext_* prefixed fields only.""" - - model_config = ConfigDict( - extra="allow", - ) # Allow extra fields, which will be constrained by `@allow_extension_fields` - - @no_extra_fields class GeometricRangeScope(BaseModel): """Geometric scoping properties defining the range of positions on the segment where @@ -221,14 +117,17 @@ class SourcePropertyItem(GeometricRangeScope): TypeT = TypeVar("TypeT", bound=str) -class Feature(ExtensibleBaseModel, Identified, Generic[ThemeT, TypeT], ABC): +class OvertureFeature(Identified, Feature, Generic[ThemeT, TypeT]): """Base class for all Overture features.""" + # Only used to suport `ext_*` fields, which are on a deprecation path. + model_config = ConfigDict(extra="allow") + # Required id: Id = Field( description="A feature ID. This may be an ID associated with the Global Entity Reference System (GERS) if—and-only-if the feature represents an entity that is part of GERS." - ) + ) # type: ignore[assignment] theme: ThemeT # this is an enum in the JSON Schema, but that prevents Feature from being extended type: TypeT @@ -237,100 +136,48 @@ class Feature(ExtensibleBaseModel, Identified, Generic[ThemeT, TypeT], ABC): # Optional - bbox: BBox | None = None - sources: Sources | None = None - @model_serializer(mode="wrap") # type: ignore[type-var] - def serialize_model( - self, - serializer: Callable[[Any], dict[str, Any]], - info: ValidationInfo, - ) -> dict[str, Any]: - """Serialize to flattened structure for Python, GeoJSON for JSON.""" - # Get the default serialization - data = serializer(self) - - # Check the serialization mode/context - if info.mode == "json": - # Transform to GeoJSON when outputting JSON - - return { - "type": "Feature", - "id": data.pop("id"), - **({"bbox": data.pop("bbox")} if "bbox" in data else {}), - "geometry": data.pop("geometry"), - "properties": data, # All remaining fields go into properties - } - else: - # Return flattened structure for Python output (info.mode == "python") - return data + @model_validator(mode="after") + def validate_model(self) -> Self: + extra = self.model_extra + invalid_extra_fields = ( + [f for f in extra.keys() if not f.startswith("ext_")] if extra else () + ) + if invalid_extra_fields: + maybe_plural = "s" if len(invalid_extra_fields) > 1 else "" + raise ValueError( + f"invalid extra field name{maybe_plural}: {', '.join(invalid_extra_fields)} " + "(extra fields are temporarily allowed, but only if their names start with 'ext_', " + "but all extra field name support in {self.__class__.name} is on a deprecation path " + "and will be removed)" + ) + return self @classmethod def __get_pydantic_json_schema__( - cls, core_schema: "core_schema.CoreSchema", handler: "GetJsonSchemaHandler" - ) -> dict[str, Any]: - """Generate JSON Schema that follows GeoJSON conventions.""" - # Get the base JSON schema with extension constraints from parent class - json_schema = super().__get_pydantic_json_schema__(core_schema, handler) - - # Move all non-GeoJSON properties down into the GeoJSON `properties` object - json_schema_top_level_required = json_schema.get("required", []) - json_schema_top_level_properties = json_schema["properties"] - geo_json_properties = {} - geo_json_required = [] - - for name in list(json_schema_top_level_properties.keys()): - if name not in ["id", "bbox", "geometry"]: - value = json_schema_top_level_properties[name] - geo_json_properties[name] = value - del json_schema_top_level_properties[name] - if name in json_schema_top_level_required: - json_schema_top_level_required.remove(name) - geo_json_required.append(name) - - # Create the properties schema - geo_json_properties_schema = { - "type": "object", - "properties": geo_json_properties, - # always reject properties that aren't defined in the schema - "unevaluatedProperties": False, # FIXME: We don't want this unless extra='forbid' - } - - if geo_json_required: - geo_json_properties_schema["required"] = geo_json_required - - # Preserve extension constraints from the original schema - if "patternProperties" in json_schema: - geo_json_properties_schema["patternProperties"] = json_schema[ - "patternProperties" - ] - - if "additionalProperties" in json_schema: - geo_json_properties_schema["additionalProperties"] = json_schema[ - "additionalProperties" - ] - - # Move constraint metadata from root to GeoJSON properties - for constraint_key in ["anyOf", "allOf", "oneOf", "not"]: - if constraint_key in json_schema: - geo_json_properties_schema[constraint_key] = json_schema[constraint_key] - del json_schema[constraint_key] - - json_schema_top_level_properties["properties"] = geo_json_properties_schema - if "properties" not in json_schema_top_level_required: - json_schema_top_level_required.append("properties") - - # Add the `"type": "Feature"` GeoJSON property at the top level - json_schema_top_level_properties["type"] = { - "type": "string", - "const": "Feature", + cls, + schema: core_schema.CoreSchema, + handler: GetJsonSchemaHandler, + ) -> JsonSchemaValue: + # Get the main Feature JSON schema. + json_schema = super().__get_pydantic_json_schema__(schema, handler) + + # Explicitly allow `ext_*` properties, but no other properties, in the properties object. + # This feature only exists to get to initial parity between the hand-written JSON Schema and + # the Pydantic port. Once Pydantic is the primary, it will be deprecated. + properties_object_schema = json_schema["properties"]["properties"] + properties_object_schema["patternProperties"] = { + "^ext_.*$": { + "description": textwrap.dedent(""" + Additional top-level properties are allowed if prefixed by `ext_`. + + This feature is a on a deprecation path and will be removed once the schema is + fully migrated to Pydantic. + """).strip(), + } } - if "type" not in json_schema_top_level_required: - json_schema_top_level_required.append("type") - - # Update the required fields - json_schema["required"] = json_schema_top_level_required + properties_object_schema["additionalProperties"] = False return json_schema diff --git a/packages/overture-schema-core/tests/test_ext.py b/packages/overture-schema-core/tests/test_ext.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/packages/overture-schema-core/tests/test_models.py b/packages/overture-schema-core/tests/test_models.py index 7af4b78a9..c973afced 100644 --- a/packages/overture-schema-core/tests/test_models.py +++ b/packages/overture-schema-core/tests/test_models.py @@ -1,10 +1,11 @@ +import json from collections.abc import Mapping from typing import Any import pytest from deepdiff import DeepDiff from overture.schema.core.json_schema import EnhancedJsonSchemaGenerator -from overture.schema.core.models import Feature +from overture.schema.core.models import OvertureFeature from overture.schema.system.primitive import ( BBox, Geometry, @@ -33,8 +34,9 @@ def prune_json_schema(data: dict[str, Any]) -> dict[str, Any]: def test_feature_json_schema() -> None: actual = prune_json_schema( - Feature.model_json_schema(schema_generator=EnhancedJsonSchemaGenerator) + OvertureFeature.model_json_schema(schema_generator=EnhancedJsonSchemaGenerator) ) + print(json.dumps(actual, indent=2)) expect = { "$defs": { @@ -69,7 +71,6 @@ def test_feature_json_schema() -> None: } }, "additionalProperties": False, - "patternProperties": {"^ext_.*$": {}}, "properties": { "id": {"minLength": 1, "pattern": "^\\S+$", "type": "string"}, "geometry": { @@ -406,6 +407,7 @@ def test_feature_json_schema() -> None: }, "properties": { "type": "object", + "not": {"required": ["id", "bbox", "geometry"]}, "properties": { "theme": {"type": "string"}, "type": {"type": "string"}, @@ -417,7 +419,6 @@ def test_feature_json_schema() -> None: "uniqueItems": True, }, }, - "unevaluatedProperties": False, "required": ["theme", "type", "version"], "patternProperties": {"^ext_.*$": {}}, "additionalProperties": False, @@ -437,7 +438,7 @@ def test_feature_json_schema() -> None: "feature, expect", [ ( - Feature( + OvertureFeature( # type: ignore[call-arg] id="foo", theme="bar", type="baz", @@ -461,7 +462,7 @@ def test_feature_json_schema() -> None: }, ), ( - Feature( + OvertureFeature( id="foo", theme="bar", type="baz", @@ -490,5 +491,5 @@ def test_feature_json_schema() -> None: ), ], ) -def test_feature_json(feature: Feature, expect: dict[str, Any]) -> None: +def test_feature_json(feature: OvertureFeature, expect: dict[str, Any]) -> None: assert feature.model_dump(mode="json") == expect diff --git a/packages/overture-schema-core/tests/test_serde.py b/packages/overture-schema-core/tests/test_serde.py index b1cc2b3ba..c60a17c9b 100644 --- a/packages/overture-schema-core/tests/test_serde.py +++ b/packages/overture-schema-core/tests/test_serde.py @@ -8,12 +8,12 @@ import pytest from deepdiff import DeepDiff -from overture.schema.core import Feature, parse_feature +from overture.schema.core import OvertureFeature, parse_feature from pydantic import Field from shapely.geometry import Point -class Place(Feature): +class Place(OvertureFeature): """Simple stubbed place model for testing serde functionality.""" theme: Literal["places"] diff --git a/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division/models.py b/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division/models.py index 02104becb..c34ba82d7 100644 --- a/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division/models.py +++ b/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division/models.py @@ -5,7 +5,7 @@ from pydantic import BaseModel, ConfigDict, Field from overture.schema.core import ( - Feature, + OvertureFeature, ) from overture.schema.core.enums import Side from overture.schema.core.models import ( @@ -54,7 +54,9 @@ class Norms(BaseModel): @forbid_if(["parent_division_id"], IS_COUNTRY) @require_if(["parent_division_id"], ~IS_COUNTRY) class Division( - Feature[Literal["divisions"], Literal["division"]], Named, CartographicallyHinted + OvertureFeature[Literal["divisions"], Literal["division"]], + Named, + CartographicallyHinted, ): """Divisions are recognized official or non-official organizations of people as seen from a given political perspective. diff --git a/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division_area/models.py b/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division_area/models.py index 636918115..0705fd281 100644 --- a/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division_area/models.py +++ b/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division_area/models.py @@ -5,7 +5,7 @@ from pydantic import ConfigDict, Field from overture.schema.core import ( - Feature, + OvertureFeature, ) from overture.schema.core.models import ( Named, @@ -26,7 +26,9 @@ @radio_group("is_land", "is_territorial") -class DivisionArea(Feature[Literal["divisions"], Literal["division_area"]], Named): +class DivisionArea( + OvertureFeature[Literal["divisions"], Literal["division_area"]], Named +): """Division areas are polygons that represent the land or maritime area covered by a division. diff --git a/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division_boundary/models.py b/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division_boundary/models.py index 380317224..5cd0fcc13 100644 --- a/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division_boundary/models.py +++ b/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division_boundary/models.py @@ -5,7 +5,7 @@ from pydantic import ConfigDict, Field from overture.schema.core import ( - Feature, + OvertureFeature, ) from overture.schema.core.models import Perspectives from overture.schema.system.field_constraint import UniqueItemsConstraint @@ -30,7 +30,9 @@ @forbid_if(["country"], IS_COUNTRY) @require_if(["country"], ~IS_COUNTRY) @radio_group("is_land", "is_territorial") -class DivisionBoundary(Feature[Literal["divisions"], Literal["division_boundary"]]): +class DivisionBoundary( + OvertureFeature[Literal["divisions"], Literal["division_boundary"]] +): """Boundaries represent borders between divisions of the same subtype. Some boundaries may be disputed by the divisions on one or both sides. diff --git a/packages/overture-schema-divisions-theme/tests/division_area_baseline_schema.json b/packages/overture-schema-divisions-theme/tests/division_area_baseline_schema.json index 1fa3d6b43..cdbc63048 100644 --- a/packages/overture-schema-divisions-theme/tests/division_area_baseline_schema.json +++ b/packages/overture-schema-divisions-theme/tests/division_area_baseline_schema.json @@ -234,11 +234,6 @@ }, "additionalProperties": false, "description": "Division areas are polygons that represent the land or maritime area covered by a\ndivision.\n\nEach division area belongs to a division which it references by ID, and for which\nthe division area provides an area polygon. For ease of use, every division area\nrepeats the subtype, names, country, and region properties of the division it\nbelongs to.", - "patternProperties": { - "^ext_.*$": { - "description": "Additional top-level properties must be prefixed with `ext_`." - } - }, "properties": { "bbox": { "items": { @@ -340,6 +335,13 @@ }, "properties": { "additionalProperties": false, + "not": { + "required": [ + "id", + "bbox", + "geometry" + ] + }, "oneOf": [ { "properties": { @@ -358,7 +360,7 @@ ], "patternProperties": { "^ext_.*$": { - "description": "Additional top-level properties must be prefixed with `ext_`." + "description": "Additional top-level properties are allowed if prefixed by `ext_`.\n\nThis feature is a on a deprecation path and will be removed once the schema is\nfully migrated to Pydantic." } }, "properties": { @@ -442,8 +444,7 @@ "division_id", "country" ], - "type": "object", - "unevaluatedProperties": false + "type": "object" }, "type": { "const": "Feature", @@ -451,10 +452,10 @@ } }, "required": [ + "type", "id", "geometry", - "properties", - "type" + "properties" ], "title": "division_area", "type": "object" diff --git a/packages/overture-schema-divisions-theme/tests/division_baseline_schema.json b/packages/overture-schema-divisions-theme/tests/division_baseline_schema.json index 32aa85eac..c91535c88 100644 --- a/packages/overture-schema-divisions-theme/tests/division_baseline_schema.json +++ b/packages/overture-schema-divisions-theme/tests/division_baseline_schema.json @@ -335,11 +335,6 @@ }, "additionalProperties": false, "description": "Divisions are recognized official or non-official organizations of people as seen\nfrom a given political perspective.\n\nExamples include countries, provinces, cities, towns, neighborhoods, etc.", - "patternProperties": { - "^ext_.*$": { - "description": "Additional top-level properties must be prefixed with `ext_`." - } - }, "properties": { "bbox": { "items": { @@ -423,9 +418,16 @@ } } ], + "not": { + "required": [ + "id", + "bbox", + "geometry" + ] + }, "patternProperties": { "^ext_.*$": { - "description": "Additional top-level properties must be prefixed with `ext_`." + "description": "Additional top-level properties are allowed if prefixed by `ext_`.\n\nThis feature is a on a deprecation path and will be removed once the schema is\nfully migrated to Pydantic." } }, "properties": { @@ -578,8 +580,7 @@ "country", "hierarchies" ], - "type": "object", - "unevaluatedProperties": false + "type": "object" }, "type": { "const": "Feature", @@ -587,10 +588,10 @@ } }, "required": [ + "type", "id", "geometry", - "properties", - "type" + "properties" ], "title": "division", "type": "object" diff --git a/packages/overture-schema-divisions-theme/tests/division_boundary_baseline_schema.json b/packages/overture-schema-divisions-theme/tests/division_boundary_baseline_schema.json index 0f2bcafea..e9d09a158 100644 --- a/packages/overture-schema-divisions-theme/tests/division_boundary_baseline_schema.json +++ b/packages/overture-schema-divisions-theme/tests/division_boundary_baseline_schema.json @@ -126,11 +126,6 @@ }, "additionalProperties": false, "description": "Boundaries represent borders between divisions of the same subtype.\n\nSome boundaries may be disputed by the divisions on one or both sides.", - "patternProperties": { - "^ext_.*$": { - "description": "Additional top-level properties must be prefixed with `ext_`." - } - }, "properties": { "bbox": { "items": { @@ -258,6 +253,13 @@ } } ], + "not": { + "required": [ + "id", + "bbox", + "geometry" + ] + }, "oneOf": [ { "properties": { @@ -276,7 +278,7 @@ ], "patternProperties": { "^ext_.*$": { - "description": "Additional top-level properties must be prefixed with `ext_`." + "description": "Additional top-level properties are allowed if prefixed by `ext_`.\n\nThis feature is a on a deprecation path and will be removed once the schema is\nfully migrated to Pydantic." } }, "properties": { @@ -371,8 +373,7 @@ "class", "division_ids" ], - "type": "object", - "unevaluatedProperties": false + "type": "object" }, "type": { "const": "Feature", @@ -380,10 +381,10 @@ } }, "required": [ + "type", "id", "geometry", - "properties", - "type" + "properties" ], "title": "boundary", "type": "object" diff --git a/packages/overture-schema-places-theme/src/overture/schema/places/place/models.py b/packages/overture-schema-places-theme/src/overture/schema/places/place/models.py index ed09cd744..c4f8b617b 100644 --- a/packages/overture-schema-places-theme/src/overture/schema/places/place/models.py +++ b/packages/overture-schema-places-theme/src/overture/schema/places/place/models.py @@ -5,7 +5,7 @@ from pydantic import BaseModel, ConfigDict, EmailStr, Field, HttpUrl from overture.schema.core import ( - Feature, + OvertureFeature, ) from overture.schema.core.models import ( Address, @@ -70,7 +70,7 @@ class Brand(Named): wikidata: WikidataId | None = None -class Place(Feature[Literal["places"], Literal["place"]], Named): +class Place(OvertureFeature[Literal["places"], Literal["place"]], Named): """A Place is a point representation of a real-world facility, service, or amenity. Place features are compatible with GeoJSON Point features. diff --git a/packages/overture-schema-places-theme/tests/place_baseline_schema.json b/packages/overture-schema-places-theme/tests/place_baseline_schema.json index a32a3c137..eadfb5d6b 100644 --- a/packages/overture-schema-places-theme/tests/place_baseline_schema.json +++ b/packages/overture-schema-places-theme/tests/place_baseline_schema.json @@ -298,11 +298,6 @@ }, "additionalProperties": false, "description": "A Place is a point representation of a real-world facility, service, or amenity.\n\nPlace features are compatible with GeoJSON Point features.", - "patternProperties": { - "^ext_.*$": { - "description": "Additional top-level properties must be prefixed with `ext_`." - } - }, "properties": { "bbox": { "items": { @@ -352,9 +347,16 @@ }, "properties": { "additionalProperties": false, + "not": { + "required": [ + "id", + "bbox", + "geometry" + ] + }, "patternProperties": { "^ext_.*$": { - "description": "Additional top-level properties must be prefixed with `ext_`." + "description": "Additional top-level properties are allowed if prefixed by `ext_`.\n\nThis feature is a on a deprecation path and will be removed once the schema is\nfully migrated to Pydantic." } }, "properties": { @@ -475,8 +477,7 @@ "version", "operating_status" ], - "type": "object", - "unevaluatedProperties": false + "type": "object" }, "type": { "const": "Feature", @@ -484,10 +485,10 @@ } }, "required": [ + "type", "id", "geometry", - "properties", - "type" + "properties" ], "title": "place", "type": "object" diff --git a/packages/overture-schema-transportation-theme/src/overture/schema/transportation/connector/models.py b/packages/overture-schema-transportation-theme/src/overture/schema/transportation/connector/models.py index c5c1d8882..9ec0e7d01 100644 --- a/packages/overture-schema-transportation-theme/src/overture/schema/transportation/connector/models.py +++ b/packages/overture-schema-transportation-theme/src/overture/schema/transportation/connector/models.py @@ -5,7 +5,7 @@ from pydantic import ConfigDict, Field from overture.schema.core import ( - Feature, + OvertureFeature, ) from overture.schema.system.primitive import ( Geometry, @@ -14,7 +14,7 @@ ) -class Connector(Feature[Literal["transportation"], Literal["connector"]]): +class Connector(OvertureFeature[Literal["transportation"], Literal["connector"]]): """Connectors create physical connections between segments. Connectors are compatible with GeoJSON Point features. diff --git a/packages/overture-schema-transportation-theme/src/overture/schema/transportation/models.py b/packages/overture-schema-transportation-theme/src/overture/schema/transportation/models.py index 3a25c331b..1357e507f 100644 --- a/packages/overture-schema-transportation-theme/src/overture/schema/transportation/models.py +++ b/packages/overture-schema-transportation-theme/src/overture/schema/transportation/models.py @@ -4,7 +4,7 @@ from pydantic import BaseModel, ConfigDict, Field -from overture.schema.core import Feature +from overture.schema.core import OvertureFeature from overture.schema.core.models import GeometricRangeScope from overture.schema.core.types import ( Level, @@ -48,7 +48,7 @@ Width = NewType("Width", Annotated[float64, Field(gt=0)]) -def _connector_type() -> type[Feature]: +def _connector_type() -> type[OvertureFeature]: from .connector import Connector return Connector diff --git a/packages/overture-schema-transportation-theme/src/overture/schema/transportation/segment/models.py b/packages/overture-schema-transportation-theme/src/overture/schema/transportation/segment/models.py index 6d7873736..1c11fb216 100644 --- a/packages/overture-schema-transportation-theme/src/overture/schema/transportation/segment/models.py +++ b/packages/overture-schema-transportation-theme/src/overture/schema/transportation/segment/models.py @@ -5,7 +5,7 @@ from pydantic import ConfigDict, Field from overture.schema.core import ( - Feature, + OvertureFeature, ) from overture.schema.core.models import ( Named, @@ -35,7 +35,7 @@ class TransportationSegment( - Feature[Literal["transportation"], Literal["segment"]], Named + OvertureFeature[Literal["transportation"], Literal["segment"]], Named ): """Common Segment Properties.""" diff --git a/packages/overture-schema-transportation-theme/tests/connector_baseline_schema.json b/packages/overture-schema-transportation-theme/tests/connector_baseline_schema.json index 535d25665..b4ddb77f8 100644 --- a/packages/overture-schema-transportation-theme/tests/connector_baseline_schema.json +++ b/packages/overture-schema-transportation-theme/tests/connector_baseline_schema.json @@ -60,11 +60,6 @@ }, "additionalProperties": false, "description": "Connectors create physical connections between segments.\n\nConnectors are compatible with GeoJSON Point features.", - "patternProperties": { - "^ext_.*$": { - "description": "Additional top-level properties must be prefixed with `ext_`." - } - }, "properties": { "bbox": { "items": { @@ -114,9 +109,16 @@ }, "properties": { "additionalProperties": false, + "not": { + "required": [ + "id", + "bbox", + "geometry" + ] + }, "patternProperties": { "^ext_.*$": { - "description": "Additional top-level properties must be prefixed with `ext_`." + "description": "Additional top-level properties are allowed if prefixed by `ext_`.\n\nThis feature is a on a deprecation path and will be removed once the schema is\nfully migrated to Pydantic." } }, "properties": { @@ -153,8 +155,7 @@ "type", "version" ], - "type": "object", - "unevaluatedProperties": false + "type": "object" }, "type": { "const": "Feature", @@ -162,10 +163,10 @@ } }, "required": [ + "type", "id", "geometry", - "properties", - "type" + "properties" ], "title": "connector", "type": "object" diff --git a/packages/overture-schema-transportation-theme/tests/segment_baseline_schema.json b/packages/overture-schema-transportation-theme/tests/segment_baseline_schema.json index 9446d1e87..f016872da 100644 --- a/packages/overture-schema-transportation-theme/tests/segment_baseline_schema.json +++ b/packages/overture-schema-transportation-theme/tests/segment_baseline_schema.json @@ -620,11 +620,6 @@ "RailSegment": { "additionalProperties": false, "description": "Rail Segment Properties.", - "patternProperties": { - "^ext_.*$": { - "description": "Additional top-level properties must be prefixed with `ext_`." - } - }, "properties": { "bbox": { "items": { @@ -678,9 +673,16 @@ }, "properties": { "additionalProperties": false, + "not": { + "required": [ + "id", + "bbox", + "geometry" + ] + }, "patternProperties": { "^ext_.*$": { - "description": "Additional top-level properties must be prefixed with `ext_`." + "description": "Additional top-level properties are allowed if prefixed by `ext_`.\n\nThis feature is a on a deprecation path and will be removed once the schema is\nfully migrated to Pydantic." } }, "properties": { @@ -785,8 +787,7 @@ "subtype", "class" ], - "type": "object", - "unevaluatedProperties": false + "type": "object" }, "type": { "const": "Feature", @@ -794,10 +795,10 @@ } }, "required": [ + "type", "id", "geometry", - "properties", - "type" + "properties" ], "title": "Rail-Specific Properties", "type": "object" @@ -887,11 +888,6 @@ "RoadSegment": { "additionalProperties": false, "description": "Road Segment Properties.", - "patternProperties": { - "^ext_.*$": { - "description": "Additional top-level properties must be prefixed with `ext_`." - } - }, "properties": { "bbox": { "items": { @@ -945,9 +941,16 @@ }, "properties": { "additionalProperties": false, + "not": { + "required": [ + "id", + "bbox", + "geometry" + ] + }, "patternProperties": { "^ext_.*$": { - "description": "Additional top-level properties must be prefixed with `ext_`." + "description": "Additional top-level properties are allowed if prefixed by `ext_`.\n\nThis feature is a on a deprecation path and will be removed once the schema is\nfully migrated to Pydantic." } }, "properties": { @@ -1101,8 +1104,7 @@ "subtype", "class" ], - "type": "object", - "unevaluatedProperties": false + "type": "object" }, "type": { "const": "Feature", @@ -1110,10 +1112,10 @@ } }, "required": [ + "type", "id", "geometry", - "properties", - "type" + "properties" ], "title": "Road-Specific Properties", "type": "object" @@ -1555,11 +1557,6 @@ "WaterSegment": { "additionalProperties": false, "description": "Water Segment Properties.", - "patternProperties": { - "^ext_.*$": { - "description": "Additional top-level properties must be prefixed with `ext_`." - } - }, "properties": { "bbox": { "items": { @@ -1613,9 +1610,16 @@ }, "properties": { "additionalProperties": false, + "not": { + "required": [ + "id", + "bbox", + "geometry" + ] + }, "patternProperties": { "^ext_.*$": { - "description": "Additional top-level properties must be prefixed with `ext_`." + "description": "Additional top-level properties are allowed if prefixed by `ext_`.\n\nThis feature is a on a deprecation path and will be removed once the schema is\nfully migrated to Pydantic." } }, "properties": { @@ -1706,8 +1710,7 @@ "version", "subtype" ], - "type": "object", - "unevaluatedProperties": false + "type": "object" }, "type": { "const": "Feature", @@ -1715,10 +1718,10 @@ } }, "required": [ + "type", "id", "geometry", - "properties", - "type" + "properties" ], "title": "Water-Specific Properties", "type": "object" From 498d3f7d36c599393ce740e8234c70d29c94e69a Mon Sep 17 00:00:00 2001 From: schapper Date: Thu, 16 Oct 2025 17:04:18 -0700 Subject: [PATCH 19/19] wip - re-baseline due to bbox fix --- .../tests/address_baseline_schema.json | 1 + .../tests/bathymetry_baseline_schema.json | 1 + .../tests/infrastructure_baseline_schema.json | 1 + .../overture-schema-base-theme/tests/land_baseline_schema.json | 1 + .../tests/land_cover_baseline_schema.json | 1 + .../tests/land_use_baseline_schema.json | 1 + .../tests/water_baseline_schema.json | 1 + .../tests/building_baseline_schema.json | 1 + .../tests/building_part_baseline_schema.json | 1 + packages/overture-schema-core/tests/test_models.py | 1 - .../tests/division_area_baseline_schema.json | 1 + .../tests/division_baseline_schema.json | 1 + .../tests/division_boundary_baseline_schema.json | 1 + .../tests/place_baseline_schema.json | 1 + .../tests/connector_baseline_schema.json | 1 + .../tests/segment_baseline_schema.json | 3 +++ 16 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/overture-schema-addresses-theme/tests/address_baseline_schema.json b/packages/overture-schema-addresses-theme/tests/address_baseline_schema.json index 2d724b56a..880825c0a 100644 --- a/packages/overture-schema-addresses-theme/tests/address_baseline_schema.json +++ b/packages/overture-schema-addresses-theme/tests/address_baseline_schema.json @@ -77,6 +77,7 @@ "description": "Addresses are geographic points used for locating businesses and individuals. The\nrules, fields, and fieldnames of an address can vary extensively between locations.\nWe use a simplified schema to capture worldwide address points. This initial schema\nis largely based on the OpenAddresses (www.openaddresses.io) project.\n\nThe address schema allows up to 5 \"admin levels\". Rather than have field names that\napply across all countries, we provide an array called \"address_levels\" containing\nthe necessary administrative levels for an address.", "properties": { "bbox": { + "description": "An optional bounding box for the feature", "items": { "type": "number" }, diff --git a/packages/overture-schema-base-theme/tests/bathymetry_baseline_schema.json b/packages/overture-schema-base-theme/tests/bathymetry_baseline_schema.json index f196529c1..9ac75f590 100644 --- a/packages/overture-schema-base-theme/tests/bathymetry_baseline_schema.json +++ b/packages/overture-schema-base-theme/tests/bathymetry_baseline_schema.json @@ -96,6 +96,7 @@ "description": "Topographic representation of an underwater area, such as a part of the ocean\nfloor.", "properties": { "bbox": { + "description": "An optional bounding box for the feature", "items": { "type": "number" }, diff --git a/packages/overture-schema-base-theme/tests/infrastructure_baseline_schema.json b/packages/overture-schema-base-theme/tests/infrastructure_baseline_schema.json index 7e2e8f291..719fbfc62 100644 --- a/packages/overture-schema-base-theme/tests/infrastructure_baseline_schema.json +++ b/packages/overture-schema-base-theme/tests/infrastructure_baseline_schema.json @@ -434,6 +434,7 @@ "description": "Various features from OpenStreetMap such as bridges, airport runways, aerialways,\nor communication towers and lines.", "properties": { "bbox": { + "description": "An optional bounding box for the feature", "items": { "type": "number" }, diff --git a/packages/overture-schema-base-theme/tests/land_baseline_schema.json b/packages/overture-schema-base-theme/tests/land_baseline_schema.json index 6e6d8f0ad..78d54bbe9 100644 --- a/packages/overture-schema-base-theme/tests/land_baseline_schema.json +++ b/packages/overture-schema-base-theme/tests/land_baseline_schema.json @@ -308,6 +308,7 @@ "description": "Physical representations of land surfaces.\n\nGlobal land derived from the inverse of OSM Coastlines. Translates `natural` tags from OpenStreetMap.", "properties": { "bbox": { + "description": "An optional bounding box for the feature", "items": { "type": "number" }, diff --git a/packages/overture-schema-base-theme/tests/land_cover_baseline_schema.json b/packages/overture-schema-base-theme/tests/land_cover_baseline_schema.json index a9e23e9ec..783d48d5b 100644 --- a/packages/overture-schema-base-theme/tests/land_cover_baseline_schema.json +++ b/packages/overture-schema-base-theme/tests/land_cover_baseline_schema.json @@ -113,6 +113,7 @@ "description": "Representation of the Earth's natural surfaces.", "properties": { "bbox": { + "description": "An optional bounding box for the feature", "items": { "type": "number" }, diff --git a/packages/overture-schema-base-theme/tests/land_use_baseline_schema.json b/packages/overture-schema-base-theme/tests/land_use_baseline_schema.json index b43c6e6aa..0a8ab930a 100644 --- a/packages/overture-schema-base-theme/tests/land_use_baseline_schema.json +++ b/packages/overture-schema-base-theme/tests/land_use_baseline_schema.json @@ -386,6 +386,7 @@ "description": "Land use features from OpenStreetMap.", "properties": { "bbox": { + "description": "An optional bounding box for the feature", "items": { "type": "number" }, diff --git a/packages/overture-schema-base-theme/tests/water_baseline_schema.json b/packages/overture-schema-base-theme/tests/water_baseline_schema.json index ef9338163..c8b04cb46 100644 --- a/packages/overture-schema-base-theme/tests/water_baseline_schema.json +++ b/packages/overture-schema-base-theme/tests/water_baseline_schema.json @@ -269,6 +269,7 @@ "description": "Physical representations of inland and ocean marine surfaces.\n\nTranslates `natural` and `waterway` tags from OpenStreetMap.", "properties": { "bbox": { + "description": "An optional bounding box for the feature", "items": { "type": "number" }, diff --git a/packages/overture-schema-buildings-theme/tests/building_baseline_schema.json b/packages/overture-schema-buildings-theme/tests/building_baseline_schema.json index 6c138961a..61315161a 100644 --- a/packages/overture-schema-buildings-theme/tests/building_baseline_schema.json +++ b/packages/overture-schema-buildings-theme/tests/building_baseline_schema.json @@ -391,6 +391,7 @@ "description": "A building is a man-made structure with a roof that exists permanently in one\nplace.\n\nBuildings are compatible with GeoJSON Polygon features.", "properties": { "bbox": { + "description": "An optional bounding box for the feature", "items": { "type": "number" }, diff --git a/packages/overture-schema-buildings-theme/tests/building_part_baseline_schema.json b/packages/overture-schema-buildings-theme/tests/building_part_baseline_schema.json index fd0d18bd8..6bc26de5c 100644 --- a/packages/overture-schema-buildings-theme/tests/building_part_baseline_schema.json +++ b/packages/overture-schema-buildings-theme/tests/building_part_baseline_schema.json @@ -277,6 +277,7 @@ "description": "A single building part.\n\nParts describe their shape and color and other properties. Each building part must\nrefer to the building to which it belongs.", "properties": { "bbox": { + "description": "An optional bounding box for the feature", "items": { "type": "number" }, diff --git a/packages/overture-schema-core/tests/test_models.py b/packages/overture-schema-core/tests/test_models.py index c973afced..f4cf64f46 100644 --- a/packages/overture-schema-core/tests/test_models.py +++ b/packages/overture-schema-core/tests/test_models.py @@ -448,7 +448,6 @@ def test_feature_json_schema() -> None: { "type": "Feature", "id": "foo", - "bbox": None, "geometry": { "type": "Point", "coordinates": [-1, 1], diff --git a/packages/overture-schema-divisions-theme/tests/division_area_baseline_schema.json b/packages/overture-schema-divisions-theme/tests/division_area_baseline_schema.json index cdbc63048..e099f81f3 100644 --- a/packages/overture-schema-divisions-theme/tests/division_area_baseline_schema.json +++ b/packages/overture-schema-divisions-theme/tests/division_area_baseline_schema.json @@ -236,6 +236,7 @@ "description": "Division areas are polygons that represent the land or maritime area covered by a\ndivision.\n\nEach division area belongs to a division which it references by ID, and for which\nthe division area provides an area polygon. For ease of use, every division area\nrepeats the subtype, names, country, and region properties of the division it\nbelongs to.", "properties": { "bbox": { + "description": "An optional bounding box for the feature", "items": { "type": "number" }, diff --git a/packages/overture-schema-divisions-theme/tests/division_baseline_schema.json b/packages/overture-schema-divisions-theme/tests/division_baseline_schema.json index c91535c88..69422773b 100644 --- a/packages/overture-schema-divisions-theme/tests/division_baseline_schema.json +++ b/packages/overture-schema-divisions-theme/tests/division_baseline_schema.json @@ -337,6 +337,7 @@ "description": "Divisions are recognized official or non-official organizations of people as seen\nfrom a given political perspective.\n\nExamples include countries, provinces, cities, towns, neighborhoods, etc.", "properties": { "bbox": { + "description": "An optional bounding box for the feature", "items": { "type": "number" }, diff --git a/packages/overture-schema-divisions-theme/tests/division_boundary_baseline_schema.json b/packages/overture-schema-divisions-theme/tests/division_boundary_baseline_schema.json index e9d09a158..1e19452e9 100644 --- a/packages/overture-schema-divisions-theme/tests/division_boundary_baseline_schema.json +++ b/packages/overture-schema-divisions-theme/tests/division_boundary_baseline_schema.json @@ -128,6 +128,7 @@ "description": "Boundaries represent borders between divisions of the same subtype.\n\nSome boundaries may be disputed by the divisions on one or both sides.", "properties": { "bbox": { + "description": "An optional bounding box for the feature", "items": { "type": "number" }, diff --git a/packages/overture-schema-places-theme/tests/place_baseline_schema.json b/packages/overture-schema-places-theme/tests/place_baseline_schema.json index eadfb5d6b..be50ee6ad 100644 --- a/packages/overture-schema-places-theme/tests/place_baseline_schema.json +++ b/packages/overture-schema-places-theme/tests/place_baseline_schema.json @@ -300,6 +300,7 @@ "description": "A Place is a point representation of a real-world facility, service, or amenity.\n\nPlace features are compatible with GeoJSON Point features.", "properties": { "bbox": { + "description": "An optional bounding box for the feature", "items": { "type": "number" }, diff --git a/packages/overture-schema-transportation-theme/tests/connector_baseline_schema.json b/packages/overture-schema-transportation-theme/tests/connector_baseline_schema.json index b4ddb77f8..7a90fb0a8 100644 --- a/packages/overture-schema-transportation-theme/tests/connector_baseline_schema.json +++ b/packages/overture-schema-transportation-theme/tests/connector_baseline_schema.json @@ -62,6 +62,7 @@ "description": "Connectors create physical connections between segments.\n\nConnectors are compatible with GeoJSON Point features.", "properties": { "bbox": { + "description": "An optional bounding box for the feature", "items": { "type": "number" }, diff --git a/packages/overture-schema-transportation-theme/tests/segment_baseline_schema.json b/packages/overture-schema-transportation-theme/tests/segment_baseline_schema.json index f016872da..6ef8f5ca6 100644 --- a/packages/overture-schema-transportation-theme/tests/segment_baseline_schema.json +++ b/packages/overture-schema-transportation-theme/tests/segment_baseline_schema.json @@ -622,6 +622,7 @@ "description": "Rail Segment Properties.", "properties": { "bbox": { + "description": "An optional bounding box for the feature", "items": { "type": "number" }, @@ -890,6 +891,7 @@ "description": "Road Segment Properties.", "properties": { "bbox": { + "description": "An optional bounding box for the feature", "items": { "type": "number" }, @@ -1559,6 +1561,7 @@ "description": "Water Segment Properties.", "properties": { "bbox": { + "description": "An optional bounding box for the feature", "items": { "type": "number" },