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/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-addresses-theme/src/overture/schema/addresses/address/models.py b/packages/overture-schema-addresses-theme/src/overture/schema/addresses/address/models.py index 85bc05b54..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,16 +5,15 @@ from pydantic import BaseModel, ConfigDict, Field from overture.schema.core import ( - Feature, + OvertureFeature, ) -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 @@ -35,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..880825c0a 100644 --- a/packages/overture-schema-addresses-theme/tests/address_baseline_schema.json +++ b/packages/overture-schema-addresses-theme/tests/address_baseline_schema.json @@ -75,13 +75,9 @@ }, "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": { + "description": "An optional bounding box for the feature", "items": { "type": "number" }, @@ -129,9 +125,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 +224,7 @@ "type", "version" ], - "type": "object", - "unevaluatedProperties": false + "type": "object" }, "type": { "const": "Feature", @@ -230,10 +232,10 @@ } }, "required": [ + "type", "id", "geometry", - "properties", - "type" + "properties" ], "title": "address", "type": "object" 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-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..9ac75f590 100644 --- a/packages/overture-schema-base-theme/tests/bathymetry_baseline_schema.json +++ b/packages/overture-schema-base-theme/tests/bathymetry_baseline_schema.json @@ -94,13 +94,9 @@ }, "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": { + "description": "An optional bounding box for the feature", "items": { "type": "number" }, @@ -200,9 +196,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 +254,7 @@ "version", "depth" ], - "type": "object", - "unevaluatedProperties": false + "type": "object" }, "type": { "const": "Feature", @@ -260,10 +262,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..719fbfc62 100644 --- a/packages/overture-schema-base-theme/tests/infrastructure_baseline_schema.json +++ b/packages/overture-schema-base-theme/tests/infrastructure_baseline_schema.json @@ -432,13 +432,9 @@ }, "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": { + "description": "An optional bounding box for the feature", "items": { "type": "number" }, @@ -598,9 +594,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 +679,7 @@ "class", "subtype" ], - "type": "object", - "unevaluatedProperties": false + "type": "object" }, "type": { "const": "Feature", @@ -685,10 +687,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..78d54bbe9 100644 --- a/packages/overture-schema-base-theme/tests/land_baseline_schema.json +++ b/packages/overture-schema-base-theme/tests/land_baseline_schema.json @@ -306,13 +306,9 @@ }, "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": { + "description": "An optional bounding box for the feature", "items": { "type": "number" }, @@ -472,9 +468,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 +554,7 @@ "type", "version" ], - "type": "object", - "unevaluatedProperties": false + "type": "object" }, "type": { "const": "Feature", @@ -560,10 +562,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..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 @@ -111,13 +111,9 @@ }, "additionalProperties": false, "description": "Representation of the Earth's natural surfaces.", - "patternProperties": { - "^ext_.*$": { - "description": "Additional top-level properties must be prefixed with `ext_`." - } - }, "properties": { "bbox": { + "description": "An optional bounding box for the feature", "items": { "type": "number" }, @@ -217,9 +213,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 +267,7 @@ "version", "subtype" ], - "type": "object", - "unevaluatedProperties": false + "type": "object" }, "type": { "const": "Feature", @@ -273,10 +275,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..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 @@ -384,13 +384,9 @@ }, "additionalProperties": false, "description": "Land use features from OpenStreetMap.", - "patternProperties": { - "^ext_.*$": { - "description": "Additional top-level properties must be prefixed with `ext_`." - } - }, "properties": { "bbox": { + "description": "An optional bounding box for the feature", "items": { "type": "number" }, @@ -550,9 +546,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 +632,7 @@ "class", "subtype" ], - "type": "object", - "unevaluatedProperties": false + "type": "object" }, "type": { "const": "Feature", @@ -638,10 +640,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..c8b04cb46 100644 --- a/packages/overture-schema-base-theme/tests/water_baseline_schema.json +++ b/packages/overture-schema-base-theme/tests/water_baseline_schema.json @@ -267,13 +267,9 @@ }, "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": { + "description": "An optional bounding box for the feature", "items": { "type": "number" }, @@ -433,9 +429,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 +515,7 @@ "type", "version" ], - "type": "object", - "unevaluatedProperties": false + "type": "object" }, "type": { "const": "Feature", @@ -521,10 +523,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 8878da463..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,22 +4,24 @@ 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.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 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..61315161a 100644 --- a/packages/overture-schema-buildings-theme/tests/building_baseline_schema.json +++ b/packages/overture-schema-buildings-theme/tests/building_baseline_schema.json @@ -389,13 +389,9 @@ }, "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": { + "description": "An optional bounding box for the feature", "items": { "type": "number" }, @@ -495,9 +491,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 +636,7 @@ "type", "version" ], - "type": "object", - "unevaluatedProperties": false + "type": "object" }, "type": { "const": "Feature", @@ -642,10 +644,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..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 @@ -275,13 +275,9 @@ }, "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": { + "description": "An optional bounding box for the feature", "items": { "type": "number" }, @@ -381,9 +377,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 +519,7 @@ "version", "building_id" ], - "type": "object", - "unevaluatedProperties": false + "type": "object" }, "type": { "const": "Feature", @@ -525,10 +527,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 42e1d7790..000000000 --- a/packages/overture-schema-core/src/overture/schema/core/ext.py +++ /dev/null @@ -1,62 +0,0 @@ -from collections.abc import Callable -from typing import Any - -from pydantic import BaseModel - -from overture.schema.validation.mixin import ( - BaseConstraintValidator, - register_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 6d5107893..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,27 +1,26 @@ -from abc import ABC -from collections.abc import Callable -from typing import Annotated, Any, Generic, NewType, TypeVar +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 from overture.schema.system.string import ( + CountryCodeAlpha2, JsonPointer, LanguageTag, RegionCode, @@ -32,10 +31,8 @@ from .types import ( CommonNames, ConfidenceScore, - CountryCodeAlpha2, FeatureUpdateTime, FeatureVersion, - Id, Level, LinearlyReferencedRange, MaxZoom, @@ -43,16 +40,6 @@ Prominence, SortKey, ) -from .validation import ConstraintValidatedModel - - -@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 @@ -130,12 +117,17 @@ class SourcePropertyItem(GeometricRangeScope): TypeT = TypeVar("TypeT", bound=str) -class Feature(ExtensibleBaseModel, 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 + 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 @@ -144,100 +136,48 @@ class Feature(ExtensibleBaseModel, 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, - } - - 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/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 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..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[ @@ -281,6 +252,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[ @@ -303,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-core/src/overture/schema/core/validation.py b/packages/overture-schema-core/src/overture/schema/core/validation.py deleted file mode 100644 index 038536c05..000000000 --- a/packages/overture-schema-core/src/overture/schema/core/validation.py +++ /dev/null @@ -1,15 +0,0 @@ -from overture.schema.validation import ( - 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-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..f4cf64f46 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", @@ -447,7 +448,6 @@ def test_feature_json_schema() -> None: { "type": "Feature", "id": "foo", - "bbox": None, "geometry": { "type": "Point", "coordinates": [-1, 1], @@ -461,7 +461,7 @@ def test_feature_json_schema() -> None: }, ), ( - Feature( + OvertureFeature( id="foo", theme="bar", type="baz", @@ -490,5 +490,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/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..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 ( @@ -14,27 +14,27 @@ 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, ) -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, 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 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,9 +51,12 @@ 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 + 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 8d07638f3..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,31 +5,30 @@ from pydantic import ConfigDict, Field from overture.schema.core import ( - Feature, + OvertureFeature, ) from overture.schema.core.models import ( Named, Names, ) -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, 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 from .enums import AreaClass -@exactly_one_of("is_land", "is_territorial") -class DivisionArea(Feature[Literal["divisions"], Literal["division_area"]], Named): +@radio_group("is_land", "is_territorial") +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 06b975db5..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,34 +5,34 @@ from pydantic import ConfigDict, Field from overture.schema.core import ( - Feature, + OvertureFeature, ) 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.core.validation import ( - exactly_one_of, - not_required_if, -) from overture.schema.system.field_constraint import UniqueItemsConstraint +from overture.schema.system.model_constraint import ( + forbid_if, + radio_group, + require_if, +) 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 import Division -from ..enums import PlaceType +from ..enums import IS_COUNTRY, PlaceType from .enums import BoundaryClass -@exactly_one_of("is_land", "is_territorial") -@not_required_if("subtype", PlaceType.COUNTRY, ["country"]) -class DivisionBoundary(Feature[Literal["divisions"], Literal["division_boundary"]]): +@forbid_if(["country"], IS_COUNTRY) +@require_if(["country"], ~IS_COUNTRY) +@radio_group("is_land", "is_territorial") +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/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/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/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_area_baseline_schema.json b/packages/overture-schema-divisions-theme/tests/division_area_baseline_schema.json index 1fa3d6b43..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 @@ -234,13 +234,9 @@ }, "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": { + "description": "An optional bounding box for the feature", "items": { "type": "number" }, @@ -340,6 +336,13 @@ }, "properties": { "additionalProperties": false, + "not": { + "required": [ + "id", + "bbox", + "geometry" + ] + }, "oneOf": [ { "properties": { @@ -358,7 +361,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 +445,7 @@ "division_id", "country" ], - "type": "object", - "unevaluatedProperties": false + "type": "object" }, "type": { "const": "Feature", @@ -451,10 +453,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 793198ebd..69422773b 100644 --- a/packages/overture-schema-divisions-theme/tests/division_baseline_schema.json +++ b/packages/overture-schema-divisions-theme/tests/division_baseline_schema.json @@ -335,13 +335,9 @@ }, "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": { + "description": "An optional bounding box for the feature", "items": { "type": "number" }, @@ -391,11 +387,22 @@ "additionalProperties": false, "allOf": [ { - "else": { + "if": { + "not": { + "properties": { + "subtype": { + "const": "country" + } + } + } + }, + "then": { "required": [ "parent_division_id" ] - }, + } + }, + { "if": { "properties": { "subtype": { @@ -412,16 +419,23 @@ } } ], + "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": { "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" @@ -567,8 +581,7 @@ "country", "hierarchies" ], - "type": "object", - "unevaluatedProperties": false + "type": "object" }, "type": { "const": "Feature", @@ -576,10 +589,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 abe41f08e..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 @@ -126,13 +126,9 @@ }, "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": { + "description": "An optional bounding box for the feature", "items": { "type": "number" }, @@ -227,9 +223,9 @@ "allOf": [ { "if": { - "properties": { - "subtype": { - "not": { + "not": { + "properties": { + "subtype": { "const": "country" } } @@ -240,8 +236,31 @@ "country" ] } + }, + { + "if": { + "properties": { + "subtype": { + "const": "country" + } + } + }, + "then": { + "not": { + "required": [ + "country" + ] + } + } } ], + "not": { + "required": [ + "id", + "bbox", + "geometry" + ] + }, "oneOf": [ { "properties": { @@ -260,7 +279,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": { @@ -278,7 +297,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" @@ -355,8 +374,7 @@ "class", "division_ids" ], - "type": "object", - "unevaluatedProperties": false + "type": "object" }, "type": { "const": "Feature", @@ -364,10 +382,10 @@ } }, "required": [ + "type", "id", "geometry", - "properties", - "type" + "properties" ], "title": "boundary", "type": "object" 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-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..be50ee6ad 100644 --- a/packages/overture-schema-places-theme/tests/place_baseline_schema.json +++ b/packages/overture-schema-places-theme/tests/place_baseline_schema.json @@ -298,13 +298,9 @@ }, "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": { + "description": "An optional bounding box for the feature", "items": { "type": "number" }, @@ -352,9 +348,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 +478,7 @@ "version", "operating_status" ], - "type": "object", - "unevaluatedProperties": false + "type": "object" }, "type": { "const": "Feature", @@ -484,10 +486,10 @@ } }, "required": [ + "type", "id", "geometry", - "properties", - "type" + "properties" ], "title": "place", "type": "object" 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..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 -------- @@ -93,27 +101,64 @@ 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 >>> >>> @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') +>>> 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: bar, foo" 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 + +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 ( + 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/_json_schema.py b/packages/overture-schema-system/src/overture/schema/system/_json_schema.py new file mode 100644 index 000000000..56213ee5e --- /dev/null +++ b/packages/overture-schema-system/src/overture/schema/system/_json_schema.py @@ -0,0 +1,234 @@ +from collections.abc import Callable +from typing import Any, TypeVar, cast, get_origin + +from pydantic import ConfigDict +from pydantic.json_schema import JsonSchemaValue, JsonValue + + +def get_static_json_schema(config: ConfigDict) -> JsonSchemaValue: + json_schema: ( + 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(JsonSchemaValue)) + if isinstance(json_schema, origin): + 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__}`' + ) + return json_schema + + +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: + maybe_list = json_schema["allOf"] + if isinstance(maybe_list, list): + maybe_list += operands + else: + raise ValueError( + f'expected value of "allOf" key to be a `list`, but it is a `{type(maybe_list).__name__}` in the JSON Schema {json_schema}' + ) + + +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: 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: 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: + json_schema["not"] = operand + return + + not_schema = prev["not"] + 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}' + ) + 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: + 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: JsonSchemaValue, + condition: JsonSchemaValue | None, + when_true: JsonSchemaValue | None, + when_false: JsonSchemaValue | None = None, +) -> None: + _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 + + if not prev: + _put(json_schema) + else: + 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)) + _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 put_properties( + json_schema: JsonSchemaValue, + new_properties: JsonSchemaValue, +) -> None: + _verify_json_schema_value( + ("json_schema", json_schema), ("new_properties", new_properties) + ) + origin = cast(type, 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 = {} + already_in = False + for k, v in new_properties.items(): + 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(cast(JsonSchemaValue, v), properties[k], k) + if not already_in and properties: + json_schema["properties"] = properties + + +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 = cast(type, 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: 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__}`" + ) + 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 = 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(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/create_model.py b/packages/overture-schema-system/src/overture/schema/system/create_model.py new file mode 100644 index 000000000..c7022ea54 --- /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( # type: ignore[misc] + model_name, + __config__=__config__, + __doc__=__doc__, + __base__=__base__, # type: ignore[arg-type] + __module__=__module__, # type: ignore[arg-type] + __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 # type: ignore[return-value] 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..001ec2629 --- /dev/null +++ b/packages/overture-schema-system/src/overture/schema/system/feature.py @@ -0,0 +1,581 @@ +from enum import Enum +from functools import reduce +from typing import Any + +from pydantic import ( + BaseModel, + Field, + GetJsonSchemaHandler, + ModelWrapValidatorHandler, + SerializationInfo, + SerializerFunctionWrapHandler, + ValidationError, + ValidationInfo, + model_serializer, + model_validator, +) +from pydantic.json_schema import JsonSchemaValue, JsonValue +from pydantic_core import InitErrorDetails, core_schema +from typing_extensions import Self + +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 + + +class Feature(BaseModel): + """ + 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. + + To add new fields with facts about your own feature type, derive a subclass of `Feature`: + + >>> 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 + } + } + + 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. + + >>> 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( + self, serializer: SerializerFunctionWrapHandler, info: SerializationInfo + ) -> Any: + """ + 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( + 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. 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( + "missing", None, "'type' property is missing from" + ) from None + if t != "Feature": + 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: + raise properties_property_type_error( + "missing", None, "'properties' property is missing from", "" + ) from None + if not isinstance(properties, dict | None): + 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. + conflicts = [ + f + for f in data.keys() + if f not in {"id", "bbox", "geometry", "properties"} + ] + if 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: + # 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 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. + data |= properties + + return handler(data) + + @classmethod + def __get_pydantic_json_schema__( + cls: type["Feature"], + schema: core_schema.CoreSchema, + handler: GetJsonSchemaHandler, + ) -> JsonSchemaValue: + """ + Generates a JSON Schema that validates the feature as GeoJSON. + """ + json_schema = handler(schema) + + 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 = ( + 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" + # sub-object. + 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", + **( + {"required": properties_object_required} + if properties_object_required + else {} + ), + "not": {"required": ["id", "bbox", "geometry"]}, + **( + {"properties": properties_object_properties} + if properties_object_properties + else {} + ), + } + + # 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 [ + "allOf", + "anyOf", + "oneOf", + "not", + "minProperties", + "maxProperties", + ]: + if key in json_schema: + _maybe_refactor_schema( + cls, + {key: json_schema.pop(key)}, + json_schema, + properties_object_schema, + ) + 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) + if if_then_else: + _maybe_refactor_schema( + cls, if_then_else, json_schema, properties_object_schema + ) + + # 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"}, + ] + } + + # Insert the Feature's properties sub-object schema into the top-level object properties. + 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. + json_schema["additionalProperties"] = False + + # 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", "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" + ) + 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) + + 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) + _merge_schemas(top_level_schema, sub_schema) + + +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] + + 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 + + +def _merge_schemas( + target_schema: JsonSchemaValue, source_schema: JsonSchemaValue +) -> None: + 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) + 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 + f(target_schema, v) 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..6c2abd75d --- /dev/null +++ b/packages/overture-schema-system/src/overture/schema/system/metadata.py @@ -0,0 +1,319 @@ +from collections.abc import ( + Hashable, + ItemsView, + Iterable, + Iterator, + KeysView, + Mapping, + MutableMapping, + ValuesView, +) +from typing import ( + Union, + cast, + overload, +) + +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('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) + + 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({repr(self.name)}, ...)" + + def __setattr__(self, name: str, value: object) -> None: + raise AttributeError("cannot modify a `Key`") + + def __delattr__(self, name: str) -> None: + 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]] | 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]] | None = None, + ): + if not data: + 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) -> Iterator[Key]: + return iter(self.__wrapped) + + def __contains__(self, key: object) -> 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: object = 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()) + + @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]] | 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]] | None = None + New data to insert (ignored if the value is `None`) + """ + if data: + 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]] | 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]] | 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]], + ) -> Mapping[Key, object] | Iterable[tuple[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__}`" + ) + 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__}`" + ) + return data 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..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,12 +1,37 @@ -from .model_constraint import ModelConstraint, apply_alias +from .forbid_if import ForbidIfConstraint, forbid_if +from .min_fields_set import MinFieldsSetConstraint, min_fields_set +from .model_constraint import ( + Condition, + FieldEqCondition, + FieldGroupConstraint, + ModelConstraint, + Not, + OptionalFieldGroupConstraint, + 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 __all__ = [ "apply_alias", + "Condition", + "FieldEqCondition", + "FieldGroupConstraint", + "forbid_if", + "ForbidIfConstraint", + "min_fields_set", + "MinFieldsSetConstraint", "ModelConstraint", "no_extra_fields", "NoExtraFieldsConstraint", + "Not", + "OptionalFieldGroupConstraint", + "radio_group", + "RadioGroupConstraint", "require_any_of", + "require_if", "RequireAnyOfConstraint", + "RequireIfConstraint", ] 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..0dc04af1f --- /dev/null +++ b/packages/overture-schema-system/src/overture/schema/system/model_constraint/forbid_if.py @@ -0,0 +1,146 @@ +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 + 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 + ---------- + 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 an explicit 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)} has type `{type(condition).__name__}` (`{self.name}`)" + ) + self.__condition = condition + + @property + 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) + + if not self.__condition.eval(model_instance): + return + + present_fields = [ + 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 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}`)" + ) + + @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/json_schema.py b/packages/overture-schema-system/src/overture/schema/system/model_constraint/json_schema.py deleted file mode 100644 index dc46ba866..000000000 --- a/packages/overture-schema-system/src/overture/schema/system/model_constraint/json_schema.py +++ /dev/null @@ -1,110 +0,0 @@ -from collections.abc import Callable -from typing import Any, cast, get_origin - -from pydantic import ConfigDict -from pydantic.json_schema import JsonDict, JsonValue - - -def get_static_json_schema(config: ConfigDict) -> JsonDict: - json_schema: ( - JsonDict - | Callable[[JsonDict], None] - | Callable[[JsonDict, 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)) - if isinstance(json_schema, origin): - return cast(JsonDict, 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__}`' - ) - return json_schema - - -def put_all_of(json_schema: JsonDict, operands: list[JsonDict]) -> None: - _verify_operands_len_2(operands) - if "allOf" not in json_schema: - json_schema["allOf"] = cast(JsonValue, operands) - else: - maybe_list = json_schema["allOf"] - if isinstance(maybe_list, list): - maybe_list += operands - else: - raise ValueError( - f'expected value of "allOf" key to be a `list`, but it is a `{type(maybe_list).__name__}` in the JSON Schema {json_schema}' - ) - - -def put_any_of(json_schema: JsonDict, operands: list[JsonDict]) -> None: - _verify_operands_len_2(operands) - prev: JsonDict = {} - _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) - if not prev: - json_schema["oneOf"] = cast(JsonValue, operands) - else: - put_all_of(json_schema, [prev, {"oneOf": cast(JsonValue, operands)}]) - - -def put_if( - json_schema: JsonDict, - condition: JsonDict, - when_true: JsonDict, - when_false: JsonDict | 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 - if when_false: - dst["else"] = when_false - return dst - - if not prev: - _put(json_schema) - else: - 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 _try_move(key: str, src: JsonDict, dst: JsonDict) -> None: - try: - value = src[key] - dst[key] = value - del src[key] - except KeyError: - pass 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..915623040 --- /dev/null +++ b/packages/overture-schema-system/src/overture/schema/system/model_constraint/min_fields_set.py @@ -0,0 +1,130 @@ +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__}`" + ) + elif count < 1: + raise ValueError( + f"`count` must be a positive number, but {count} is less than 1" + ) + 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/model_constraint.py b/packages/overture-schema-system/src/overture/schema/system/model_constraint/model_constraint.py index db9951af4..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 @@ -1,13 +1,20 @@ +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 ( 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: @@ -20,6 +27,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): @@ -27,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 @@ -99,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() # type: ignore[union-attr] + 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, @@ -111,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: @@ -195,29 +211,309 @@ 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'] """ + return cast( + tuple[ModelConstraint, ...], + Metadata.retrieve_from(model_class, Metadata()).get( # type: ignore[union-attr] + _MODEL_CONSTRAINT_KEY, () + ), + ) + + +# Private: Used to construct the opaque metadata key. +class _ModelKeyClass: + pass + + +# Private: Opaque metadata key. +_MODEL_CONSTRAINT_KEY = Key( + f"{ModelConstraint.__module__}.{ModelConstraint.__qualname__}", _ModelKeyClass +) + + +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} 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-`str` value" + ) + 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 one or more 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)}" + ) + + +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() - maybe_tuple = getattr(model_class, _MODEL_CONSTRAINT_PRIVATE_LIST_NAME, None) - if not maybe_tuple: - return () - elif not isinstance(maybe_tuple, tuple): + +@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"attribute {_MODEL_CONSTRAINT_PRIVATE_LIST_NAME} must be a tuple, but {maybe_tuple} is a `{type(maybe_tuple).__name__}`" + f"`field_name` must be a `str`, but {repr(self.field_name)} is a {type(self.field_name).__name__}" ) - elif not all(isinstance(x, ModelConstraint) for x in maybe_tuple): + + @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"attribute {_MODEL_CONSTRAINT_PRIVATE_LIST_NAME} may only contain `{str.__name__}` values" + f"model class `{model_class.__name__}` must contain the condition field {repr(self.field_name)}, but it does not" ) - else: - return maybe_tuple -_MODEL_CONSTRAINT_PRIVATE_LIST_NAME = "_ModelConstraint__private_list" +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: 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-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..c9e1bcaa1 --- /dev/null +++ b/packages/overture-schema-system/src/overture/schema/system/model_constraint/radio_group.py @@ -0,0 +1,155 @@ +from collections.abc import Callable +from types import NoneType, UnionType +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 FieldGroupConstraint, 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 none is True" + ... ) 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(FieldGroupConstraint): + """ + 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 + 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 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 + + 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 `{model_class.__name__}`: {', '.join(non_bool_fields)} " + ) + + @override + def validate_instance(self, model_instance: BaseModel) -> None: + super().validate_instance(model_instance) + + true_fields = [ + f for f in self.field_names if getattr(model_instance, f) is True + ] + + if len(true_fields) == 1: + return + elif len(true_fields) == 0: + msg = "none is True" + 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(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_any_of.py b/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_any_of.py index f054b8a6e..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 @@ -1,21 +1,25 @@ -from collections import Counter from collections.abc import Callable 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_any_of -from .model_constraint import ModelConstraint, apply_alias +from .._json_schema import get_static_json_schema, put_any_of +from .model_constraint import OptionalFieldGroupConstraint, apply_alias 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 @@ -32,20 +36,23 @@ 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') + >>> 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: bar, foo" 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 """ @@ -56,67 +63,47 @@ 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. - 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: + @staticmethod + def __validate_field_names(field_names: tuple[str, ...]) -> tuple[str, ...]: + if len(field_names) < 2: 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: - if not (any(getattr(model_instance, f) is not None for f in self.field_names)): + super().validate_instance(model_instance) + + 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 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/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..72f6d2520 --- /dev/null +++ b/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_if.py @@ -0,0 +1,143 @@ +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 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 + ---------- + 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 an explicit 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)} has type `{type(condition).__name__}` (`{self.name}`)" + ) + self.__condition = condition + + @property + 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) + + if not self.__condition.eval(model_instance): + return + + missing_fields = [ + 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 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}`)" + ) + + @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-system/src/overture/schema/system/optionality.py b/packages/overture-schema-system/src/overture/schema/system/optionality.py new file mode 100644 index 000000000..f4d50ff3d --- /dev/null +++ b/packages/overture-schema-system/src/overture/schema/system/optionality.py @@ -0,0 +1,96 @@ +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 + ... + >>> MyModel().model_dump() + {'my_optional_field': None} + >>> json_schema = MyModel.model_json_schema() + >>> 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 + 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] + >>> json_schema = MyModel.model_json_schema() + >>> bar_type = json_schema['properties']['bar']['type'] + >>> assert 'integer' == bar_type + """ + + 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}" + ) + 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..97f4b3f71 --- /dev/null +++ b/packages/overture-schema-system/src/overture/schema/system/ref/__init__.py @@ -0,0 +1,4 @@ +from .id import Id, Identified +from .ref import Reference, Relationship + +__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 new file mode 100644 index 000000000..8e95a9d0b --- /dev/null +++ b/packages/overture-schema-system/src/overture/schema/system/ref/id.py @@ -0,0 +1,58 @@ +from typing import Annotated, NewType + +from pydantic import BaseModel, Field + +from overture.schema.system.string import NoWhitespaceString + +Id = NewType( + "Id", + Annotated[ + NoWhitespaceString, + Field( + min_length=1, + description="A unique identifier", + ), + ], +) +""" +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/model_constraint/test_forbid_if.py b/packages/overture-schema-system/tests/model_constraint/test_forbid_if.py new file mode 100644 index 000000000..e048f54b9 --- /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.parent)) # Needed to import `util` 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..798792108 --- /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.parent)) # Needed to import `util` 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_model_constraint_json_schema.py b/packages/overture-schema-system/tests/model_constraint/test_model_constraint_json_schema.py deleted file mode 100644 index ce9386a4f..000000000 --- a/packages/overture-schema-system/tests/model_constraint/test_model_constraint_json_schema.py +++ /dev/null @@ -1,283 +0,0 @@ -from typing import cast - -import pytest -from pydantic import ConfigDict -from pydantic.json_schema import JsonDict - -from overture.schema.system.model_constraint.json_schema import ( - get_static_json_schema, - put_all_of, - put_any_of, - put_if, - put_one_of, -) - -#################################################################################################### -# get_static_json_schema # -#################################################################################################### - - -@pytest.mark.parametrize( - "config,expect", - [ - (ConfigDict(), {}), - (ConfigDict(json_schema_extra=None), {}), - (ConfigDict(json_schema_extra={}), {}), - (ConfigDict(json_schema_extra={"foo": "bar"}), {"foo": "bar"}), - ], -) -def test_get_static_json_schema_success(config: ConfigDict, expect: JsonDict) -> None: - actual = get_static_json_schema(config) - - assert expect == actual - assert actual is config["json_schema_extra"] - - -def test_get_static_json_schema_error_invalid_type() -> None: - with pytest.raises( - 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 _: {})) - - -#################################################################################################### -# test_put_all_of # -#################################################################################################### - - -@pytest.mark.parametrize( - "json_schema,operands,expect", - [ - ({}, [{}, {}], {"allOf": [{}, {}]}), - ( - {"foo": "bar"}, - [{"baz": "qux"}, {"corge": True, "garply": False}], - {"foo": "bar", "allOf": [{"baz": "qux"}, {"corge": True, "garply": False}]}, - ), - ( - {"allOf": ["hello"]}, - [{"foo": "bar"}, {"baz": "qux"}], - {"allOf": ["hello", {"foo": "bar"}, {"baz": "qux"}]}, - ), - ], -) -def test_put_all_of_success( - json_schema: JsonDict, operands: list[JsonDict], expect: JsonDict -) -> 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) - - -@pytest.mark.parametrize( - "operands", [cast(list[JsonDict], False), [{}, cast(JsonDict, False)]] -) -def test_put_all_of_error_bad_type(operands: list[JsonDict]) -> None: - with pytest.raises(TypeError): - put_all_of({}, operands) - - -def test_put_any_of_error_existing_all_of_not_list(): - with pytest.raises( - ValueError, match='expected value of "allOf" key to be a `list`' - ): - put_all_of({"allOf": False}, [{}, {}]) - - -#################################################################################################### -# test_put_any_of # -#################################################################################################### - - -@pytest.mark.parametrize( - "json_schema,operands,expect", - [ - ({}, [{}, {}], {"anyOf": [{}, {}]}), - ( - {"foo": 1}, - [{"bar": 2}, {"baz": 3}], - {"foo": 1, "anyOf": [{"bar": 2}, {"baz": 3}]}, - ), - ( - {"anyOf": "foo"}, - [{}, {}], - {"allOf": [{"anyOf": "foo"}, {"anyOf": [{}, {}]}]}, - ), - ( - {"anyOf": "foo", "bar": [1, 2]}, - [{"baz": 3}, {"qux": 4}], - { - "allOf": [{"anyOf": "foo"}, {"anyOf": [{"baz": 3}, {"qux": 4}]}], - "bar": [1, 2], - }, - ), - ], -) -def test_put_any_of_success( - json_schema: JsonDict, operands: list[JsonDict], expect: JsonDict -) -> 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) - - -@pytest.mark.parametrize( - "operands", [cast(list[JsonDict], False), [{}, cast(JsonDict, False)]] -) -def test_put_any_of_error_bad_type(operands: list[JsonDict]) -> None: - with pytest.raises(TypeError): - put_any_of({}, operands) - - -#################################################################################################### -# test_put_one_of # -#################################################################################################### - - -@pytest.mark.parametrize( - "json_schema,operands,expect", - [ - ({}, [{}, {}], {"oneOf": [{}, {}]}), - ( - {"foo": 1}, - [{"bar": 2}, {"baz": 3}], - {"foo": 1, "oneOf": [{"bar": 2}, {"baz": 3}]}, - ), - ( - {"oneOf": "foo"}, - [{}, {}], - {"allOf": [{"oneOf": "foo"}, {"oneOf": [{}, {}]}]}, - ), - ( - {"oneOf": "foo", "bar": [1, 2]}, - [{"baz": 3}, {"qux": 4}], - { - "allOf": [{"oneOf": "foo"}, {"oneOf": [{"baz": 3}, {"qux": 4}]}], - "bar": [1, 2], - }, - ), - ], -) -def test_put_one_of_success( - json_schema: JsonDict, operands: list[JsonDict], expect: JsonDict -) -> 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) - - -@pytest.mark.parametrize( - "operands", [cast(list[JsonDict], False), [{}, cast(JsonDict, False)]] -) -def test_put_one_of_error_bad_type(operands: list[JsonDict]) -> None: - with pytest.raises(TypeError): - put_one_of({}, operands) - - -#################################################################################################### -# test_put_if # -#################################################################################################### - - -@pytest.mark.parametrize( - "json_schema,condition,when_true,when_false,expect", - [ - ({}, {"foo": 1}, {"bar": 2}, None, {"if": {"foo": 1}, "then": {"bar": 2}}), - ( - {}, - {"foo": 1}, - {"bar": 2}, - {"baz": 3}, - {"if": {"foo": 1}, "then": {"bar": 2}, "else": {"baz": 3}}, - ), - ( - {"if": 0}, - {"foo": 1}, - {"bar": 2}, - {"baz": 3}, - { - "allOf": [ - {"if": 0}, - {"if": {"foo": 1}, "then": {"bar": 2}, "else": {"baz": 3}}, - ] - }, - ), - ( - {"then": 0}, - {"foo": 1}, - {"bar": 2}, - {"baz": 3}, - { - "allOf": [ - {"then": 0}, - {"if": {"foo": 1}, "then": {"bar": 2}, "else": {"baz": 3}}, - ] - }, - ), - ( - {"else": 0}, - {"foo": 1}, - {"bar": 2}, - {"baz": 3}, - { - "allOf": [ - {"else": 0}, - {"if": {"foo": 1}, "then": {"bar": 2}, "else": {"baz": 3}}, - ] - }, - ), - ( - {"if": 0, "then": -1}, - {"foo": 1}, - {"bar": 2}, - {"baz": 3}, - { - "allOf": [ - {"if": 0, "then": -1}, - {"if": {"foo": 1}, "then": {"bar": 2}, "else": {"baz": 3}}, - ] - }, - ), - ( - {"if": 0, "then": -1, "else": -2}, - {"foo": 1}, - {"bar": 2}, - {"baz": 3}, - { - "allOf": [ - {"if": 0, "then": -1, "else": -2}, - {"if": {"foo": 1}, "then": {"bar": 2}, "else": {"baz": 3}}, - ] - }, - ), - ], -) -def test_put_if_success( - json_schema: JsonDict, - condition: JsonDict, - when_true: JsonDict, - when_false: JsonDict, - expect: JsonDict, -) -> None: - put_if(json_schema, condition, when_true, when_false) - - assert expect == json_schema 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..c58dda9ed --- /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.parent)) # Needed to import `util` 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_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 new file mode 100644 index 000000000..67f5ef029 --- /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.parent)) # Needed to import `util` 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 c478bfa95..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,27 +11,27 @@ 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 @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`: bar, foo" +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,37 +47,37 @@ 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 - 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 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")]) -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 - bar: str | None + foo: int | None = None + bar: str | None = None 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 - 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"]}]}, ] }, ), @@ -99,24 +99,24 @@ 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) - 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") -def test_model_constraints(): +def test_model_constraints() -> None: 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-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..7694889bb --- /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.parent)) # Needed to import `util` 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) diff --git a/packages/overture-schema-system/tests/model_constraint/util.py b/packages/overture-schema-system/tests/model_constraint/util.py deleted file mode 100644 index 8b0455013..000000000 --- a/packages/overture-schema-system/tests/model_constraint/util.py +++ /dev/null @@ -1,49 +0,0 @@ -from typing import get_origin - -from pydantic.json_schema import JsonDict - - -def subset_conflicts(a: JsonDict, b: JsonDict) -> JsonDict: - """ - Returns conflict items that prevent `a` from being a subset of `b`. - - Parameters - ---------- - a : JsonDict - Candidate subset of `b` - b : JsonDict - Candidate supserset of `a` - - Returns - ------- - JsonDict - 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 = {} - for k, av in a.items(): - try: - bv = b[k] - except KeyError: - 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 - else: - conflicts[k] = av - return conflicts - - -def assert_subset(a: JsonDict, b: JsonDict, a_name: str = "a", b_name: str = "b"): - conflicts = subset_conflicts(a, b) - if conflicts: - raise AssertionError( - f"expected `{a_name}` to be a subset of `{b_name}`, " - 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})" - ) 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-system/tests/test___json_schema.py b/packages/overture-schema-system/tests/test___json_schema.py new file mode 100644 index 000000000..50ada6ce7 --- /dev/null +++ b/packages/overture-schema-system/tests/test___json_schema.py @@ -0,0 +1,563 @@ +import re +from typing import cast + +import pytest +from pydantic import ConfigDict +from pydantic.json_schema import JsonSchemaValue + +from overture.schema.system._json_schema import ( + get_static_json_schema, + put_all_of, + put_any_of, + put_if, + put_not, + put_one_of, + put_properties, + put_required, + try_move, +) + +#################################################################################################### +# get_static_json_schema # +#################################################################################################### + + +@pytest.mark.parametrize( + "config,expect", + [ + (ConfigDict(), {}), + (ConfigDict(json_schema_extra=None), {}), + (ConfigDict(json_schema_extra={}), {}), + (ConfigDict(json_schema_extra={"foo": "bar"}), {"foo": "bar"}), + ], +) +def test_get_static_json_schema_success( + config: ConfigDict, expect: JsonSchemaValue +) -> None: + actual = get_static_json_schema(config) + + assert expect == actual + assert actual is config["json_schema_extra"] + + +def test_get_static_json_schema_error_invalid_type() -> None: + with pytest.raises( + 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 _: None)) + + +#################################################################################################### +# put_all_of # +#################################################################################################### + + +@pytest.mark.parametrize( + "json_schema,operands,expect", + [ + ({}, [{}, {}], {"allOf": [{}, {}]}), + ( + {"foo": "bar"}, + [{"baz": "qux"}, {"corge": True, "garply": False}], + {"foo": "bar", "allOf": [{"baz": "qux"}, {"corge": True, "garply": False}]}, + ), + ( + {"allOf": ["hello"]}, + [{"foo": "bar"}, {"baz": "qux"}], + {"allOf": ["hello", {"foo": "bar"}, {"baz": "qux"}]}, + ), + ], +) +def test_put_all_of_success( + json_schema: JsonSchemaValue, + operands: list[JsonSchemaValue], + expect: JsonSchemaValue, +) -> None: + put_all_of(json_schema, operands) + + assert expect == json_schema + + +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[JsonSchemaValue], False), [{}, cast(JsonSchemaValue, False)]] +) +def test_put_all_of_error_bad_type(operands: list[JsonSchemaValue]) -> None: + with pytest.raises(TypeError): + put_all_of({}, operands) + + +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`' + ): + put_all_of({"allOf": False}, [{}, {}]) + + +#################################################################################################### +# put_any_of # +#################################################################################################### + + +@pytest.mark.parametrize( + "json_schema,operands,expect", + [ + ({}, [{}, {}], {"anyOf": [{}, {}]}), + ( + {"foo": 1}, + [{"bar": 2}, {"baz": 3}], + {"foo": 1, "anyOf": [{"bar": 2}, {"baz": 3}]}, + ), + ( + {"anyOf": "foo"}, + [{}, {}], + {"allOf": [{"anyOf": "foo"}, {"anyOf": [{}, {}]}]}, + ), + ( + {"anyOf": "foo", "bar": [1, 2]}, + [{"baz": 3}, {"qux": 4}], + { + "allOf": [{"anyOf": "foo"}, {"anyOf": [{"baz": 3}, {"qux": 4}]}], + "bar": [1, 2], + }, + ), + ], +) +def test_put_any_of_success( + json_schema: JsonSchemaValue, + operands: list[JsonSchemaValue], + expect: JsonSchemaValue, +) -> None: + put_any_of(json_schema, operands) + + assert expect == json_schema + + +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[JsonSchemaValue], False), [{}, cast(JsonSchemaValue, False)]] +) +def test_put_any_of_error_bad_type(operands: list[JsonSchemaValue]) -> None: + with pytest.raises(TypeError): + put_any_of({}, operands) + + +#################################################################################################### +# put_one_of # +#################################################################################################### + + +@pytest.mark.parametrize( + "json_schema,operands,expect", + [ + ({}, [{}, {}], {"oneOf": [{}, {}]}), + ( + {"foo": 1}, + [{"bar": 2}, {"baz": 3}], + {"foo": 1, "oneOf": [{"bar": 2}, {"baz": 3}]}, + ), + ( + {"oneOf": "foo"}, + [{}, {}], + {"allOf": [{"oneOf": "foo"}, {"oneOf": [{}, {}]}]}, + ), + ( + {"oneOf": "foo", "bar": [1, 2]}, + [{"baz": 3}, {"qux": 4}], + { + "allOf": [{"oneOf": "foo"}, {"oneOf": [{"baz": 3}, {"qux": 4}]}], + "bar": [1, 2], + }, + ), + ], +) +def test_put_one_of_success( + json_schema: JsonSchemaValue, + operands: list[JsonSchemaValue], + expect: JsonSchemaValue, +) -> None: + put_one_of(json_schema, operands) + + assert expect == json_schema + + +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[JsonSchemaValue], False), [{}, cast(JsonSchemaValue, False)]] +) +def test_put_one_of_error_bad_type(operands: list[JsonSchemaValue]) -> None: + with pytest.raises(TypeError): + put_one_of({}, operands) + + +#################################################################################################### +# 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: JsonSchemaValue, operand: JsonSchemaValue, expect: JsonSchemaValue +) -> 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 `JsonSchemaValue` value, but 123 has type `int`", + ): + put_not({}, cast(JsonSchemaValue, 123)) + + +def test_put_not_error_invalid_not_value() -> None: + with pytest.raises( + TypeError, match='expected value of "not" key to be a `JsonSchemaValue`' + ): + 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": {}}}, {}) + + +#################################################################################################### +# put_if # +#################################################################################################### + + +@pytest.mark.parametrize( + "json_schema,condition,when_true,when_false,expect", + [ + ({}, {"foo": 1}, {"bar": 2}, None, {"if": {"foo": 1}, "then": {"bar": 2}}), + ( + {}, + {"foo": 1}, + {"bar": 2}, + {"baz": 3}, + {"if": {"foo": 1}, "then": {"bar": 2}, "else": {"baz": 3}}, + ), + ( + {"if": 0}, + {"foo": 1}, + {"bar": 2}, + {"baz": 3}, + { + "allOf": [ + {"if": 0}, + {"if": {"foo": 1}, "then": {"bar": 2}, "else": {"baz": 3}}, + ] + }, + ), + ( + {"then": 0}, + {"foo": 1}, + {"bar": 2}, + {"baz": 3}, + { + "allOf": [ + {"then": 0}, + {"if": {"foo": 1}, "then": {"bar": 2}, "else": {"baz": 3}}, + ] + }, + ), + ( + {"else": 0}, + {"foo": 1}, + {"bar": 2}, + {"baz": 3}, + { + "allOf": [ + {"else": 0}, + {"if": {"foo": 1}, "then": {"bar": 2}, "else": {"baz": 3}}, + ] + }, + ), + ( + {"if": 0, "then": -1}, + {"foo": 1}, + {"bar": 2}, + {"baz": 3}, + { + "allOf": [ + {"if": 0, "then": -1}, + {"if": {"foo": 1}, "then": {"bar": 2}, "else": {"baz": 3}}, + ] + }, + ), + ( + {"if": 0, "then": -1, "else": -2}, + {"foo": 1}, + {"bar": 2}, + {"baz": 3}, + { + "allOf": [ + {"if": 0, "then": -1, "else": -2}, + {"if": {"foo": 1}, "then": {"bar": 2}, "else": {"baz": 3}}, + ] + }, + ), + ], +) +def test_put_if_success( + 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() -> None: + with pytest.raises( + TypeError, + match="`json_schema` must be a `JsonSchemaValue` value, but True has type `bool`", + ): + put_required(cast(JsonSchemaValue, 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] +) -> None: + 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 +) -> None: + put_required(json_schema, operands) + + assert expect == json_schema + + +#################################################################################################### +# put_properties # +#################################################################################################### + + +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(cast(JsonSchemaValue, 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({}, cast(JsonSchemaValue, "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 # +#################################################################################################### + + +def test_try_move_existing_key() -> None: + src = {"foo": "bar"} + dst: JsonSchemaValue = {} + + try_move("foo", src, dst) + + assert {} == src + assert {"foo": "bar"} == dst + + +def test_try_move_missing_key() -> None: + src = {"foo": "bar"} + dst: JsonSchemaValue = {} + + 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 new file mode 100644 index 000000000..d64b61a74 --- /dev/null +++ b/packages/overture-schema-system/tests/test_feature.py @@ -0,0 +1,1677 @@ +import json +import re +import sys +from copy import deepcopy +from pathlib import Path +from typing import Annotated, cast + +import pytest +from pydantic import ConfigDict, ValidationError, create_model +from pydantic.json_schema import JsonSchemaValue, JsonValue + +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, + GeometryType, + GeometryTypeConstraint, +) + +sys.path.insert(0, str(Path(__file__).parent.parent)) # Needed to import `util` module. + +from util import assert_subset + + +class TestSerializeModel: + @pytest.mark.parametrize( + "feature,expect", + [ + ( + Feature(geometry=Geometry.from_wkt("POINT(1 2)")), # type: ignore[call-arg] + { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [1.0, 2.0]}, + "properties": {}, + }, + ), + ( + Feature(id="foo", geometry=Geometry.from_wkt("POINT(1 2)")), # type: ignore[call-arg] + { + "type": "Feature", + "id": "foo", + "geometry": {"type": "Point", "coordinates": [1.0, 2.0]}, + "properties": {}, + }, + ), + ( + Feature( # type: ignore[call-arg] + 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)")), # type: ignore[call-arg] + { + "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)"), + }, + ), + ( + Feature( + bbox=BBox(0, 1, 0, 2), + 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( + 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) # type: ignore[call-arg] + + 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: ignore[call-arg] + ), + ( + { + "type": "Feature", + "id": "foo", + "geometry": {"type": "Point", "coordinates": [1.0, 2.0]}, + "properties": {}, + }, + Feature(id="foo", geometry=Geometry.from_wkt("POINT(1 2)")), # type: ignore[call-arg] + ), + ( + { + "type": "Feature", + "bbox": [0, 1, 0, 2], + "geometry": {"type": "Point", "coordinates": [1.0, 2.0]}, + "properties": {}, + }, + Feature( # type: ignore[call-arg] + 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)")), # type: ignore[call-arg] + ), + ( + { + "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( # type: ignore[call-arg] + 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) # type: ignore[call-arg] + + 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_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`" + ): + 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"') + + 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", "properties": None}, + {}, + ), + ( + Feature, + "geometry", + { + "type": "Feature", + "id": "foo", + "bbox": [1, 2, 3, 4], + "properties": {}, + }, + {"id": "foo", "bbox": BBox(1, 2, 3, 4)}, + ), + ( + create_model("SubFeature", __base__=Feature, foo=int), + "foo", + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [0, 0], + }, + "properties": None, + }, + { + "geometry": Geometry.from_wkt("POINT(0 0)"), + }, + ), + ], + ) + def test_error_required_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", "properties": {}}, + {"geometry": "foo"}, + ), + ( + Feature, + "id", + { + "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", + "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"}, + }, + {"geometry": Geometry.from_wkt("POINT(0 0)"), "foo": "bar"}, + ), + ], + ) + 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: + 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_extra_property_not_allowed(self) -> None: + class SubFeature(Feature): + model_config = ConfigDict(extra="forbid") + + extra = { + "foo": "bar", + } + + input_json = { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [0, 0]}, + "properties": extra, + } + + with pytest.raises(ValidationError) as error_info: + SubFeature.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 "extra_forbidden" == first_error["type"] + assert "foo" == first_error["loc"][0] + + +class TestJsonSchema: + def test_simple_json_schema(self) -> None: + expect = { + "title": "Feature", + "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", + "not": {"required": ["id", "bbox", "geometry"]}, + }, + { + "type": "null", + }, + ], + }, + }, + } + + actual = Feature.model_json_schema() + print(json.dumps(actual, indent=2)) + + assert_subset(expect, actual, "expect", "actual") + + def test_subclass_new_fields(self) -> None: + class SubFeature(Feature): + foo: Omitable[int] + bar: str | None = None + baz: 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": ["baz"], + "not": {"required": ["id", "bbox", "geometry"]}, + "properties": { + "foo": { + "type": "integer", + }, + "bar": { + "anyOf": [ + {"type": "string"}, + {"type": "null"}, + ], + "default": None, + }, + "baz": {"type": "number"}, + }, + }, + }, + } + + 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) -> 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] # type: ignore[assignment] + + 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) -> None: + class PointFeature(Feature): + geometry: Annotated[Geometry, GeometryTypeConstraint(GeometryType.POINT)] + + expect = { + "title": "PointFeature", + "type": "object", + "additionalProperties": False, + "required": [ + "type", + "geometry", + "properties", + ], + "properties": { + "type": {"const": "Feature", "type": "string"}, + "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"]}, + }, + { + "type": "null", + }, + ], + }, + }, + } + + actual = PointFeature.model_json_schema() + print(json.dumps(actual, indent=2)) + + assert_subset(expect, actual, "expect", "actual") + + def test_forbid_extra_fields_without_adding_fields(self) -> None: + 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) -> None: + 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", + }, + "bbox": { + "type": "array", + }, + "geometry": {}, + "properties": { + "anyOf": [ + { + "type": "object", + "additionalProperties": False, + "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) -> None: + 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", + }, + }, + }, + }, + } + + actual = SubFeature.model_json_schema() + print(json.dumps(actual, indent=2)) + + assert_subset(expect, actual, "expect", "actual") + + 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 + 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) -> 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" + 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: dict[str, object] = { + "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, cast(dict[str, object], actual), "expect", "actual") + + 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") + 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) -> None: + @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) -> None: + @forbid_if(["foo", "type"], FieldEqCondition("properties", "ban.foo")) + @require_if(["id", "foo", "qux"], FieldEqCondition("corge", 42)) + @require_any_of("bbox", "foo", "garply") + 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", + ], + "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": { + "properties": { + "const": "ban.foo", + }, + }, + } + }, + }, + "then": { + "not": { + "properties": { + "properties": { + "type": "object", + "required": ["foo", "type"], + } + } + }, + }, + }, + ], + "properties": { + "type": {"const": "Feature", "type": "string"}, + "id": { + "type": "string", + }, + "bbox": { + "type": "array", + }, + "geometry": {}, + "properties": { + "type": "object", + "required": ["bar", "baz", "corge", "properties"], + "properties": { + "foo": { + "type": "boolean", + }, + "bar": { + "type": "boolean", + }, + "baz": { + "type": "boolean", + }, + "qux": { + "type": "string", + }, + "corge": { + "type": "integer", + }, + "garply": { + "type": "boolean", + }, + "type": { + "type": "string", + }, + "properties": { + "type": "string", + }, + }, + }, + }, + } + + actual = MixedConstraintFeature.model_json_schema() + print(json.dumps(actual, indent=2)) + + assert_subset(expect, actual, "expect", "actual") + + +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, + ), + ( + { + "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, + ), + ], + ) + 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 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..c3c5b92e8 --- /dev/null +++ b/packages/overture-schema-system/tests/test_optionality.py @@ -0,0 +1,109 @@ +import json +import sys +from pathlib import Path +from types import NoneType +from typing import Annotated, Any, cast + +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_instance,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_instance: BaseModel, + expect_json: JsonDict, + expect_json_schema: dict[str, object], +) -> None: + actual_json = json.loads(model_instance.model_dump_json()) + assert expect_json == actual_json + + actual_json_schema = model_instance.model_json_schema() + assert_subset( + expect_json_schema, + cast(dict[str, object], actual_json_schema), + "expect_json_schema", + "actual_json_schema", + ) + + assert not model_instance.__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/util.py b/packages/overture-schema-system/tests/util.py new file mode 100644 index 000000000..130e07b47 --- /dev/null +++ b/packages/overture-schema-system/tests/util.py @@ -0,0 +1,101 @@ +from typing import cast + + +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 : dict[str, object] + Candidate subset of `b` + b : dict[str, object] + Candidate supserset of `a` + + Returns + ------- + 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: dict[str, object] = {} + for k, av in a.items(): + try: + bv = b[k] + except KeyError: + conflicts[k] = av + continue + if av != bv: + 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): + 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: 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( + f"expected `{a_name}` to be a subset of `{b_name}`, " + 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: list[object] = [] + for i, (av, bv) in enumerate(zip(a, b, strict=False)): + 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( + { + "index": i, + "conflict": sub_conflicts, + } + ) + elif av != bv: + conflicts.append( + { + "index": i, + "conflict": _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 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 20babeb39..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,22 +4,21 @@ 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.ref import Reference, Relationship from overture.schema.core.types import ( - Id, Level, 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.ref import Id, Reference, Relationship from overture.schema.system.string import StrippedString, WikidataId from .enums import ( @@ -49,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 @@ -79,8 +78,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 +444,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 +477,8 @@ class SpeedLimitRule(GeometricRangeScope): when: SpeedLimitWhenClause | None = None -@min_properties(1) +@min_fields_set(1) class AccessRestrictionWhenClause( - ConstraintValidatedModel, TemporalScope, HeadingScope, PurposeOfUseScope, @@ -521,9 +518,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/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..7a90fb0a8 100644 --- a/packages/overture-schema-transportation-theme/tests/connector_baseline_schema.json +++ b/packages/overture-schema-transportation-theme/tests/connector_baseline_schema.json @@ -60,13 +60,9 @@ }, "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": { + "description": "An optional bounding box for the feature", "items": { "type": "number" }, @@ -114,9 +110,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 +156,7 @@ "type", "version" ], - "type": "object", - "unevaluatedProperties": false + "type": "object" }, "type": { "const": "Feature", @@ -162,10 +164,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 b14bd474b..6ef8f5ca6 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", @@ -620,13 +620,9 @@ "RailSegment": { "additionalProperties": false, "description": "Rail Segment Properties.", - "patternProperties": { - "^ext_.*$": { - "description": "Additional top-level properties must be prefixed with `ext_`." - } - }, "properties": { "bbox": { + "description": "An optional bounding box for the feature", "items": { "type": "number" }, @@ -678,9 +674,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 +788,7 @@ "subtype", "class" ], - "type": "object", - "unevaluatedProperties": false + "type": "object" }, "type": { "const": "Feature", @@ -794,10 +796,10 @@ } }, "required": [ + "type", "id", "geometry", - "properties", - "type" + "properties" ], "title": "Rail-Specific Properties", "type": "object" @@ -887,13 +889,9 @@ "RoadSegment": { "additionalProperties": false, "description": "Road Segment Properties.", - "patternProperties": { - "^ext_.*$": { - "description": "Additional top-level properties must be prefixed with `ext_`." - } - }, "properties": { "bbox": { + "description": "An optional bounding box for the feature", "items": { "type": "number" }, @@ -945,9 +943,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 +1106,7 @@ "subtype", "class" ], - "type": "object", - "unevaluatedProperties": false + "type": "object" }, "type": { "const": "Feature", @@ -1110,10 +1114,10 @@ } }, "required": [ + "type", "id", "geometry", - "properties", - "type" + "properties" ], "title": "Road-Specific Properties", "type": "object" @@ -1555,13 +1559,9 @@ "WaterSegment": { "additionalProperties": false, "description": "Water Segment Properties.", - "patternProperties": { - "^ext_.*$": { - "description": "Additional top-level properties must be prefixed with `ext_`." - } - }, "properties": { "bbox": { + "description": "An optional bounding box for the feature", "items": { "type": "number" }, @@ -1613,9 +1613,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 +1713,7 @@ "version", "subtype" ], - "type": "object", - "unevaluatedProperties": false + "type": "object" }, "type": { "const": "Feature", @@ -1715,10 +1721,10 @@ } }, "required": [ + "type", "id", "geometry", - "properties", - "type" + "properties" ], "title": "Water-Specific Properties", "type": "object" 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 842d44161..000000000 --- a/packages/overture-schema-validation/README.md +++ /dev/null @@ -1,167 +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}) - ] - -# 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/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 62bf3cd1f..000000000 --- a/packages/overture-schema-validation/src/overture/schema/validation/__init__.py +++ /dev/null @@ -1,44 +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, - 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 deleted file mode 100644 index 2e18c592c..000000000 --- a/packages/overture-schema-validation/src/overture/schema/validation/mixin.py +++ /dev/null @@ -1,461 +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 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: - 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.""" - - 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.""" - - 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 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.""" - - def decorator(cls: type[BaseModel]) -> type[BaseModel]: - constraint = ExactlyOneOfValidator(*field_names) - register_constraint(cls, constraint) - return cls - - 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.""" - - 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 8408846c9..000000000 --- a/packages/overture-schema-validation/tests/test_json_schema_generation.py +++ /dev/null @@ -1,371 +0,0 @@ -"""Test JSON Schema generation for mixin-based constraint validation.""" - -from enum import Enum -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, - required_if, -) - - -class SubtypeEnum(str, Enum): - """Test enum for subtypes.""" - - COUNTRY = "country" - REGION = "region" - 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, - 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 - - -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_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.""" - - 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 deleted file mode 100644 index 2640e470b..000000000 --- a/packages/overture-schema-validation/tests/test_mixin_constraints.py +++ /dev/null @@ -1,718 +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, - exactly_one_of, - min_properties, - not_required_if, - required_if, -) -from overture.schema.validation.mixin import ( - BaseConstraintValidator, - ExactlyOneOfValidator, - MinPropertiesValidator, - NotRequiredIfValidator, - RequiredIfValidator, -) - - -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 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 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.""" - - 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 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.""" - - 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_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.""" - - 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 - - -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__]) 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 9e42b8d6d..eb396415a 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,75 +32,100 @@ 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]] 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]] @@ -115,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] @@ -269,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 = [ @@ -278,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]] @@ -524,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]] @@ -748,14 +772,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 +788,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 +842,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" @@ -857,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" }, ] @@ -913,7 +923,7 @@ wheels = [ [[package]] name = "pydantic" -version = "2.11.9" +version = "2.12.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -921,9 +931,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/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/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/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] @@ -933,89 +943,116 @@ email = [ [[package]] name = "pydantic-core" -version = "2.33.2" +version = "2.41.4" 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/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/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/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]] @@ -1150,28 +1187,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]] @@ -1180,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 = [ @@ -1253,41 +1290,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]] @@ -1305,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 = [