From 32d4010213974447283be24898a5321d7d9d0942 Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 6 Aug 2026 17:20:26 -0700 Subject: [PATCH 01/48] feat!: bump STAPI_VERSION to 0.2.0 and export it The library targets STAPI v0.2.0. `STAPI_VERSION` is now importable from the package root rather than only from `stapi_pydantic.constants`, since callers building conformance URIs need it. This is breaking beyond the constant itself: stapi-fastapi spells every conformance URI as an f-string over `STAPI_VERSION`, so all six API and product conformance classes it advertises change version segment with it. The distribution version moves to 0.2.0 in step. --- stapi-pydantic/CHANGELOG.md | 10 ++++++++++ stapi-pydantic/pyproject.toml | 3 ++- stapi-pydantic/src/stapi_pydantic/__init__.py | 2 ++ stapi-pydantic/src/stapi_pydantic/constants.py | 2 +- uv.lock | 2 +- 5 files changed, 16 insertions(+), 3 deletions(-) diff --git a/stapi-pydantic/CHANGELOG.md b/stapi-pydantic/CHANGELOG.md index ec9a7c2..1098e09 100644 --- a/stapi-pydantic/CHANGELOG.md +++ b/stapi-pydantic/CHANGELOG.md @@ -6,6 +6,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- `STAPI_VERSION` is now exported from the package root. + +### Changed + +- `STAPI_VERSION` is `0.2.0`. + ## [0.1.0] - 2025-12-18 ### Changed diff --git a/stapi-pydantic/pyproject.toml b/stapi-pydantic/pyproject.toml index 8fc6e50..30dc64d 100644 --- a/stapi-pydantic/pyproject.toml +++ b/stapi-pydantic/pyproject.toml @@ -1,11 +1,12 @@ [project] name = "stapi-pydantic" -version = "0.1.0" +version = "0.2.0" description = "Pydantic models for Satellite Tasking API (STAPI) Specification" readme = "README.md" authors = [ { name = "Phil Varner", email = "philvarner@gmail.com" }, { name = "Pete Gadomski", email = "pete.gadomski@gmail.com" }, + { name = "Jarrett Keifer", email = "jkeifer0@gmail.com" }, ] requires-python = ">=3.11" dependencies = ["pydantic>=2.12", "cql2>=0.3.6", "geojson-pydantic>=1.2.0"] diff --git a/stapi-pydantic/src/stapi_pydantic/__init__.py b/stapi-pydantic/src/stapi_pydantic/__init__.py index 44ecbd0..e21a4a7 100644 --- a/stapi-pydantic/src/stapi_pydantic/__init__.py +++ b/stapi-pydantic/src/stapi_pydantic/__init__.py @@ -1,4 +1,5 @@ from .conformance import Conformance +from .constants import STAPI_VERSION from .datetime_interval import DatetimeInterval from .filter import CQL2Filter from .json_schema_model import JsonSchemaModel @@ -59,4 +60,5 @@ "ProviderRole", "Queryables", "RootResponse", + "STAPI_VERSION", ] diff --git a/stapi-pydantic/src/stapi_pydantic/constants.py b/stapi-pydantic/src/stapi_pydantic/constants.py index 80915d1..97daddd 100644 --- a/stapi-pydantic/src/stapi_pydantic/constants.py +++ b/stapi-pydantic/src/stapi_pydantic/constants.py @@ -1,2 +1,2 @@ -STAPI_VERSION = "0.1.0" +STAPI_VERSION = "0.2.0" """The default STAPI version for this library.""" diff --git a/uv.lock b/uv.lock index 56e280c..8344a38 100644 --- a/uv.lock +++ b/uv.lock @@ -2616,7 +2616,7 @@ dev = [ [[package]] name = "stapi-pydantic" -version = "0.1.0" +version = "0.2.0" source = { editable = "stapi-pydantic" } dependencies = [ { name = "cql2" }, From 6925e9a24830428de659907f424232c8f05ea5e5 Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 6 Aug 2026 17:21:47 -0700 Subject: [PATCH 02/48] refactor!: hold a JSON Schema in a model instead of annotating a class `JsonSchemaModel` annotated `type[BaseModel]` with a PlainValidator and a PlainSerializer so a model class could stand in for its own schema. Two consequences: every deployment published an orphan `BaseModel` component that nothing referenced, and the value could not be read back, since validation demanded a class where the wire carried an object. `JsonSchema` is an ordinary root model over the schema document, built with `JsonSchema.from_model`. The product queryables and order-parameters endpoints now derive the schema at the call site and return it. --- .../stapi_fastapi/routers/product_router.py | 10 +++---- stapi-pydantic/CHANGELOG.md | 5 ++++ stapi-pydantic/src/stapi_pydantic/__init__.py | 4 +-- .../src/stapi_pydantic/json_schema.py | 12 +++++++++ .../src/stapi_pydantic/json_schema_model.py | 26 ------------------- stapi-pydantic/tests/test_json_schema.py | 23 +++++++++++++++- 6 files changed, 46 insertions(+), 34 deletions(-) create mode 100644 stapi-pydantic/src/stapi_pydantic/json_schema.py delete mode 100644 stapi-pydantic/src/stapi_pydantic/json_schema_model.py diff --git a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py index 430ae00..07383d4 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py @@ -18,7 +18,7 @@ from returns.result import Failure, Success from stapi_pydantic import ( Conformance, - JsonSchemaModel, + JsonSchema, Link, OpportunityCollection, OpportunityPayload, @@ -343,17 +343,17 @@ def get_product_conformance(self) -> Conformance: """ return Conformance.model_validate({"conforms_to": self.conformances}) - def get_product_queryables(self) -> JsonSchemaModel: + def get_product_queryables(self) -> JsonSchema: """ Return supported queryables of a specific product """ - return self.product.queryables + return JsonSchema.from_model(self.product.queryables) - def get_product_order_parameters(self) -> JsonSchemaModel: + def get_product_order_parameters(self) -> JsonSchema: """ Return supported order parameters of a specific product """ - return self.product.order_parameters + return JsonSchema.from_model(self.product.order_parameters) async def create_order(self, payload: OrderPayload, request: Request, response: Response) -> Order: # type: ignore """ diff --git a/stapi-pydantic/CHANGELOG.md b/stapi-pydantic/CHANGELOG.md index 1098e09..ba4e6ba 100644 --- a/stapi-pydantic/CHANGELOG.md +++ b/stapi-pydantic/CHANGELOG.md @@ -10,12 +10,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added +- `JsonSchema`, a root model holding a JSON Schema document, with `JsonSchema.from_model` deriving one from a pydantic model class. - `STAPI_VERSION` is now exported from the package root. ### Changed - `STAPI_VERSION` is `0.2.0`. +### Removed + +- **BREAKING** `JsonSchemaModel` is gone. It annotated a `type[BaseModel]` with a `PlainValidator`/`PlainSerializer` pair so a model class could stand in for its own schema, which meant the published document carried an orphan `BaseModel` component and the value could not be read back. Build a `JsonSchema` with `JsonSchema.from_model(YourModel)` instead. + ## [0.1.0] - 2025-12-18 ### Changed diff --git a/stapi-pydantic/src/stapi_pydantic/__init__.py b/stapi-pydantic/src/stapi_pydantic/__init__.py index e21a4a7..83dd23c 100644 --- a/stapi-pydantic/src/stapi_pydantic/__init__.py +++ b/stapi-pydantic/src/stapi_pydantic/__init__.py @@ -2,7 +2,7 @@ from .constants import STAPI_VERSION from .datetime_interval import DatetimeInterval from .filter import CQL2Filter -from .json_schema_model import JsonSchemaModel +from .json_schema import JsonSchema from .opportunity import ( Opportunity, OpportunityCollection, @@ -34,7 +34,7 @@ "Conformance", "CQL2Filter", "DatetimeInterval", - "JsonSchemaModel", + "JsonSchema", "Link", "Opportunity", "OpportunityCollection", diff --git a/stapi-pydantic/src/stapi_pydantic/json_schema.py b/stapi-pydantic/src/stapi_pydantic/json_schema.py new file mode 100644 index 0000000..b1c455d --- /dev/null +++ b/stapi-pydantic/src/stapi_pydantic/json_schema.py @@ -0,0 +1,12 @@ +from typing import Any, Self + +from pydantic import BaseModel, RootModel + + +class JsonSchema(RootModel[dict[str, Any]]): + """A JSON Schema document.""" + + @classmethod + def from_model(cls, model: type[BaseModel]) -> Self: + """The JSON Schema describing `model`.""" + return cls(model.model_json_schema()) diff --git a/stapi-pydantic/src/stapi_pydantic/json_schema_model.py b/stapi-pydantic/src/stapi_pydantic/json_schema_model.py deleted file mode 100644 index 4ae1cfd..0000000 --- a/stapi-pydantic/src/stapi_pydantic/json_schema_model.py +++ /dev/null @@ -1,26 +0,0 @@ -from typing import Annotated, Any - -from pydantic import ( - BaseModel, - PlainSerializer, - PlainValidator, - WithJsonSchema, -) - - -def validate(v: Any) -> Any: - if not issubclass(v, BaseModel): - raise RuntimeError("BaseModel class required") - return v - - -def serialize(v: type[BaseModel]) -> dict[str, Any]: - return v.model_json_schema() - - -JsonSchemaModel = Annotated[ - type[BaseModel], - PlainValidator(validate), - PlainSerializer(serialize), - WithJsonSchema({"type": "object"}), -] diff --git a/stapi-pydantic/tests/test_json_schema.py b/stapi-pydantic/tests/test_json_schema.py index e9bc38e..64f23f4 100644 --- a/stapi-pydantic/tests/test_json_schema.py +++ b/stapi-pydantic/tests/test_json_schema.py @@ -1,6 +1,27 @@ -from pydantic import TypeAdapter +from pydantic import BaseModel, TypeAdapter +from stapi_pydantic import JsonSchema from stapi_pydantic.datetime_interval import DatetimeInterval def test_datetime_interval() -> None: assert TypeAdapter(DatetimeInterval).json_schema() == {"type": "string"} + + +class _Queryables(BaseModel): + off_nadir: float + + +def test_from_model_derives_the_schema() -> None: + assert JsonSchema.from_model(_Queryables).model_dump() == _Queryables.model_json_schema() + + +def test_json_schema_round_trips() -> None: + """A published document can be read back, which a model class could not.""" + schema = JsonSchema.from_model(_Queryables) + + assert JsonSchema.model_validate(schema.model_dump()) == schema + + +def test_json_schema_publishes_an_object_component() -> None: + """The endpoints returning it `$ref` this, so it has to describe an object.""" + assert JsonSchema.model_json_schema()["type"] == "object" From 0cb81d04231bae7d4187bd1524f9a2ea3be4b1b2 Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 6 Aug 2026 17:22:41 -0700 Subject: [PATCH 03/48] feat: report the queryables a filter must supply a predicate for `Queryables.required_property_names()` reads the required set straight out of the published queryables JSON Schema, so a client is held to exactly the set it can see rather than to a list maintained alongside it. Cached per subclass rather than computed once on `Queryables`: the cache keys on `cls`, so no class inherits a parent's answer the way a plain class attribute would. Nothing consults it yet; the routers start enforcing it later on this branch. --- stapi-pydantic/CHANGELOG.md | 1 + .../src/stapi_pydantic/queryables.py | 12 +++++++ stapi-pydantic/tests/test_queryables.py | 33 +++++++++++++++++++ 3 files changed, 46 insertions(+) create mode 100644 stapi-pydantic/tests/test_queryables.py diff --git a/stapi-pydantic/CHANGELOG.md b/stapi-pydantic/CHANGELOG.md index ba4e6ba..2a81a22 100644 --- a/stapi-pydantic/CHANGELOG.md +++ b/stapi-pydantic/CHANGELOG.md @@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added - `JsonSchema`, a root model holding a JSON Schema document, with `JsonSchema.from_model` deriving one from a pydantic model class. +- `Queryables.required_property_names`, the required set read from the published queryables JSON Schema and cached per subclass. - `STAPI_VERSION` is now exported from the package root. ### Changed diff --git a/stapi-pydantic/src/stapi_pydantic/queryables.py b/stapi-pydantic/src/stapi_pydantic/queryables.py index 2bb5c5c..22f5b02 100644 --- a/stapi-pydantic/src/stapi_pydantic/queryables.py +++ b/stapi-pydantic/src/stapi_pydantic/queryables.py @@ -1,5 +1,17 @@ +from functools import cache + from pydantic import BaseModel, ConfigDict class Queryables(BaseModel): model_config = ConfigDict(extra="allow") + + @classmethod + @cache + def required_property_names(cls) -> frozenset[str]: + """Names of the queryables a filter must supply a predicate for. + + Taken from the published queryables JSON Schema, so a client is held to + exactly the set it can see. + """ + return frozenset(cls.model_json_schema().get("required", [])) diff --git a/stapi-pydantic/tests/test_queryables.py b/stapi-pydantic/tests/test_queryables.py new file mode 100644 index 0000000..f4dbf19 --- /dev/null +++ b/stapi-pydantic/tests/test_queryables.py @@ -0,0 +1,33 @@ +from stapi_pydantic import Queryables + + +class ParentQueryables(Queryables): + pass + + +class ChildQueryables(ParentQueryables): + gsd: float + + +class UnrelatedQueryables(Queryables): + platform: str + + +def test_required_property_names_is_isolated_per_class() -> None: + # the cache keys on cls, so no class ever reads another's result -- + # including a parent's, which a plain class attribute would inherit + assert ChildQueryables.required_property_names() == frozenset({"gsd"}) + assert ParentQueryables.required_property_names() == frozenset() + assert UnrelatedQueryables.required_property_names() == frozenset({"platform"}) + assert Queryables.required_property_names() == frozenset() + + +def test_required_property_names_is_cached() -> None: + assert ChildQueryables.required_property_names() is ChildQueryables.required_property_names() + + +def test_required_property_names_omits_optional_queryables() -> None: + class Optional_(Queryables): + gsd: float | None = None + + assert Optional_.required_property_names() == frozenset() From 8b3576fc771e96fb20c58a53ea23ea4f385c9cf2 Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 6 Aug 2026 17:24:26 -0700 Subject: [PATCH 04/48] feat!: narrow Geometry to the six types the spec enumerates STAPI defines a geometry conformance class for each of Point, MultiPoint, LineString, MultiLineString, Polygon and MultiPolygon, and none for GeometryCollection. Accepting one meant accepting a value no implementation could declare support for, so `stapi_pydantic.Geometry` is now that six-member union, discriminated on `type`, in place of `geojson_pydantic.geometries.Geometry`. The new module also carries the bbox helpers the models need next: `compute_geometry_bbox`, `bbox_from_geometry_input` and `union_bboxes`. Nothing calls them yet; `bbox` becomes geometry-derived later on this branch, which is where their tests land. stapi-fastapi's opportunity response model follows the same union. --- .../stapi_fastapi/routers/product_router.py | 2 +- stapi-pydantic/CHANGELOG.md | 3 + stapi-pydantic/src/stapi_pydantic/__init__.py | 2 + stapi-pydantic/src/stapi_pydantic/geometry.py | 86 +++++++++++++++++++ .../src/stapi_pydantic/opportunity.py | 2 +- stapi-pydantic/src/stapi_pydantic/order.py | 2 +- 6 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 stapi-pydantic/src/stapi_pydantic/geometry.py diff --git a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py index 07383d4..c63d025 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py @@ -13,11 +13,11 @@ status, ) from fastapi.responses import JSONResponse -from geojson_pydantic.geometries import Geometry from returns.maybe import Maybe, Some from returns.result import Failure, Success from stapi_pydantic import ( Conformance, + Geometry, JsonSchema, Link, OpportunityCollection, diff --git a/stapi-pydantic/CHANGELOG.md b/stapi-pydantic/CHANGELOG.md index 2a81a22..66b55c8 100644 --- a/stapi-pydantic/CHANGELOG.md +++ b/stapi-pydantic/CHANGELOG.md @@ -12,11 +12,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - `JsonSchema`, a root model holding a JSON Schema document, with `JsonSchema.from_model` deriving one from a pydantic model class. - `Queryables.required_property_names`, the required set read from the published queryables JSON Schema and cached per subclass. +- `Geometry`, the union of the six geometry types STAPI defines, discriminated on `type`. Exported from the package root. +- A `stapi_pydantic.geometry` module providing `compute_geometry_bbox`, `bbox_from_geometry_input`, and `union_bboxes`. - `STAPI_VERSION` is now exported from the package root. ### Changed - `STAPI_VERSION` is `0.2.0`. +- **BREAKING** `Geometry` is the six-member STAPI union and no longer includes `GeometryCollection`. The spec enumerates exactly six geometry conformance classes, so a `GeometryCollection` was a value no implementation could declare support for. Import `geojson_pydantic.geometries.Geometry` directly if you need the wider union. ### Removed diff --git a/stapi-pydantic/src/stapi_pydantic/__init__.py b/stapi-pydantic/src/stapi_pydantic/__init__.py index 83dd23c..d9ccc20 100644 --- a/stapi-pydantic/src/stapi_pydantic/__init__.py +++ b/stapi-pydantic/src/stapi_pydantic/__init__.py @@ -2,6 +2,7 @@ from .constants import STAPI_VERSION from .datetime_interval import DatetimeInterval from .filter import CQL2Filter +from .geometry import Geometry from .json_schema import JsonSchema from .opportunity import ( Opportunity, @@ -31,6 +32,7 @@ from .shared import Link __all__ = [ + "Geometry", "Conformance", "CQL2Filter", "DatetimeInterval", diff --git a/stapi-pydantic/src/stapi_pydantic/geometry.py b/stapi-pydantic/src/stapi_pydantic/geometry.py new file mode 100644 index 0000000..3cb8a9f --- /dev/null +++ b/stapi-pydantic/src/stapi_pydantic/geometry.py @@ -0,0 +1,86 @@ +from collections.abc import Sequence +from typing import Annotated, Any, TypeAlias + +from geojson_pydantic.geometries import ( + LineString, + MultiLineString, + MultiPoint, + MultiPolygon, + Point, + Polygon, +) +from geojson_pydantic.types import BBox +from pydantic import Field, TypeAdapter + +#: The geometry types STAPI defines, discriminated on ``type``. +#: +#: Deliberately narrower than ``geojson_pydantic.Geometry``: there is no +#: conformance class for ``GeometryCollection``, so no implementation could +#: declare support for one. +Geometry: TypeAlias = Annotated[ + Point | MultiPoint | LineString | MultiLineString | Polygon | MultiPolygon, + Field(discriminator="type"), +] + +_GEOMETRY_ADAPTER: TypeAdapter[Geometry] = TypeAdapter(Geometry) + + +def _all_coordinates(geometry: Geometry) -> list[list[float]]: + """Flatten a GeoJSON geometry's coordinates to a list of positions.""" + + def flatten(coords: Any) -> list[list[float]]: + if coords and isinstance(coords[0], int | float): + return [list(coords)] + return [p for c in coords for p in flatten(c)] + + return flatten(geometry.coordinates) + + +def compute_geometry_bbox(geometry: Geometry) -> BBox: + """Compute an RFC 7946 bbox (2D or 3D) from a geometry's coordinates.""" + coords = _all_coordinates(geometry) + if not coords: + raise ValueError("cannot compute bbox: geometry has no coordinates") + lons = [c[0] for c in coords] + lats = [c[1] for c in coords] + # a third position element is always elevation, never a measure (RFC 7946 3.1.1) + if all(len(c) == 3 for c in coords): + elevations = [c[2] for c in coords] + return (min(lons), min(lats), min(elevations), max(lons), max(lats), max(elevations)) + return (min(lons), min(lats), max(lons), max(lats)) + + +def bbox_from_geometry_input(geometry: Any) -> BBox: + """Compute a bbox from a geometry in model, mapping, or ``__geo_interface__`` + form. + """ + if hasattr(geometry, "__geo_interface__"): + geometry = geometry.__geo_interface__ + return compute_geometry_bbox(_GEOMETRY_ADAPTER.validate_python(geometry)) + + +def union_bboxes(bboxes: Sequence[BBox]) -> BBox | None: + """Union RFC 7946 bboxes into one, or None if there are none to union. + + The result is 3D only when every input is 3D; a mix degrades to 2D since + elevation is unknown for the 2D members. + """ + values = [list(b) for b in bboxes] + if not values: + return None + if all(len(v) == 6 for v in values): + return ( + min(v[0] for v in values), + min(v[1] for v in values), + min(v[2] for v in values), + max(v[3] for v in values), + max(v[4] for v in values), + max(v[5] for v in values), + ) + horizontals = [(v[0], v[1], v[3], v[4]) if len(v) == 6 else (v[0], v[1], v[2], v[3]) for v in values] + return ( + min(h[0] for h in horizontals), + min(h[1] for h in horizontals), + max(h[2] for h in horizontals), + max(h[3] for h in horizontals), + ) diff --git a/stapi-pydantic/src/stapi_pydantic/opportunity.py b/stapi-pydantic/src/stapi_pydantic/opportunity.py index a20a9fc..6d2cccd 100644 --- a/stapi-pydantic/src/stapi_pydantic/opportunity.py +++ b/stapi-pydantic/src/stapi_pydantic/opportunity.py @@ -2,11 +2,11 @@ from typing import Any, Literal, TypeVar from geojson_pydantic import Feature, FeatureCollection -from geojson_pydantic.geometries import Geometry from pydantic import AwareDatetime, BaseModel, ConfigDict, Field from .datetime_interval import DatetimeInterval from .filter import CQL2Filter +from .geometry import Geometry from .shared import Link diff --git a/stapi-pydantic/src/stapi_pydantic/order.py b/stapi-pydantic/src/stapi_pydantic/order.py index 159b341..ccf05fd 100644 --- a/stapi-pydantic/src/stapi_pydantic/order.py +++ b/stapi-pydantic/src/stapi_pydantic/order.py @@ -6,7 +6,6 @@ from typing import Any, Generic, Literal, TypeVar from geojson_pydantic.base import _GeoJsonBase -from geojson_pydantic.geometries import Geometry from pydantic import ( AwareDatetime, BaseModel, @@ -19,6 +18,7 @@ from .constants import STAPI_VERSION from .datetime_interval import DatetimeInterval from .filter import CQL2Filter +from .geometry import Geometry from .opportunity import OpportunityProperties from .shared import Link From 0d65c752e078c051a6f95ab978fbed90e02e72a9 Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 6 Aug 2026 17:25:25 -0700 Subject: [PATCH 05/48] feat!: allow a datetime interval to be open at one end A STAPI interval expressing a query may be open on one end, written `..` or an empty string. `DatetimeInterval` rejected both, so a search for "anything after this instant" could not be spelled. `DatetimeInterval` is now that general interval and validates as `tuple[AwareDatetime | None, AwareDatetime | None]`; a doubly-open interval is still rejected. The both-ends-bounded form moves to the new `BoundedDatetimeInterval`, which is what a result rather than a query needs: `OpportunityProperties.datetime` uses it, since a provider returning an opportunity has already determined both ends. Callers relying on both tuple members being non-None must switch to `BoundedDatetimeInterval`. stapi-fastapi's interval tests do exactly that. --- stapi-fastapi/tests/test_datetime_interval.py | 4 +- stapi-pydantic/CHANGELOG.md | 2 + stapi-pydantic/src/stapi_pydantic/__init__.py | 3 +- .../src/stapi_pydantic/datetime_interval.py | 65 +++++++++++++++++-- .../src/stapi_pydantic/opportunity.py | 4 +- 5 files changed, 67 insertions(+), 11 deletions(-) diff --git a/stapi-fastapi/tests/test_datetime_interval.py b/stapi-fastapi/tests/test_datetime_interval.py index e4aa3a3..aa2d69b 100644 --- a/stapi-fastapi/tests/test_datetime_interval.py +++ b/stapi-fastapi/tests/test_datetime_interval.py @@ -4,13 +4,13 @@ from pydantic import BaseModel, ValidationError from pytest import mark, raises -from stapi_pydantic import DatetimeInterval +from stapi_pydantic import BoundedDatetimeInterval EUROPE_BERLIN = ZoneInfo("Europe/Berlin") class Model(BaseModel): - datetime: DatetimeInterval + datetime: BoundedDatetimeInterval # format_timezone was removed from pyrfc3339 (MIT) in v2.1, so included here now diff --git a/stapi-pydantic/CHANGELOG.md b/stapi-pydantic/CHANGELOG.md index 66b55c8..97c9fa7 100644 --- a/stapi-pydantic/CHANGELOG.md +++ b/stapi-pydantic/CHANGELOG.md @@ -14,11 +14,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - `Queryables.required_property_names`, the required set read from the published queryables JSON Schema and cached per subclass. - `Geometry`, the union of the six geometry types STAPI defines, discriminated on `type`. Exported from the package root. - A `stapi_pydantic.geometry` module providing `compute_geometry_bbox`, `bbox_from_geometry_input`, and `union_bboxes`. +- `BoundedDatetimeInterval`, for intervals that are bounded at both ends. - `STAPI_VERSION` is now exported from the package root. ### Changed - `STAPI_VERSION` is `0.2.0`. +- **BREAKING** `DatetimeInterval` now denotes the general interval, which may be open on one end via `..` or an empty string, and validates as a `tuple[AwareDatetime | None, AwareDatetime | None]`. The both-ends-bounded form is now `BoundedDatetimeInterval`. Code that relied on `DatetimeInterval` rejecting open ends, or on both tuple members being non-`None`, must switch to `BoundedDatetimeInterval`. - **BREAKING** `Geometry` is the six-member STAPI union and no longer includes `GeometryCollection`. The spec enumerates exactly six geometry conformance classes, so a `GeometryCollection` was a value no implementation could declare support for. Import `geojson_pydantic.geometries.Geometry` directly if you need the wider union. ### Removed diff --git a/stapi-pydantic/src/stapi_pydantic/__init__.py b/stapi-pydantic/src/stapi_pydantic/__init__.py index d9ccc20..b2317ca 100644 --- a/stapi-pydantic/src/stapi_pydantic/__init__.py +++ b/stapi-pydantic/src/stapi_pydantic/__init__.py @@ -1,6 +1,6 @@ from .conformance import Conformance from .constants import STAPI_VERSION -from .datetime_interval import DatetimeInterval +from .datetime_interval import BoundedDatetimeInterval, DatetimeInterval from .filter import CQL2Filter from .geometry import Geometry from .json_schema import JsonSchema @@ -33,6 +33,7 @@ __all__ = [ "Geometry", + "BoundedDatetimeInterval", "Conformance", "CQL2Filter", "DatetimeInterval", diff --git a/stapi-pydantic/src/stapi_pydantic/datetime_interval.py b/stapi-pydantic/src/stapi_pydantic/datetime_interval.py index ea31577..99095e5 100644 --- a/stapi-pydantic/src/stapi_pydantic/datetime_interval.py +++ b/stapi-pydantic/src/stapi_pydantic/datetime_interval.py @@ -10,8 +10,21 @@ WrapSerializer, ) +OPEN_END = ".." -def validate_before( + +def _check_order(start: datetime, end: datetime) -> None: + if end < start: + raise ValueError("end before start") + + +def _parse_end(value: str) -> datetime | None: + if value in ("", OPEN_END): + return None + return datetime.fromisoformat(value) + + +def validate_bounded_before( value: str | tuple[datetime, datetime], ) -> tuple[datetime, datetime]: if isinstance(value, str): @@ -20,13 +33,31 @@ def validate_before( return value -def validate_after(value: tuple[datetime, datetime]) -> tuple[datetime, datetime]: - if value[1] < value[0]: - raise ValueError("end before start") +def validate_bounded_after(value: tuple[datetime, datetime]) -> tuple[datetime, datetime]: + _check_order(*value) return value -def serialize( +def validate_before( + value: str | tuple[datetime | None, datetime | None], +) -> tuple[datetime | None, datetime | None]: + if isinstance(value, str): + start, end = value.split("/", 1) + return (_parse_end(start), _parse_end(end)) + return value + + +def validate_after( + value: tuple[datetime | None, datetime | None], +) -> tuple[datetime | None, datetime | None]: + if value[0] is None and value[1] is None: + raise ValueError("only singly-open intervals are allowed") + if value[0] is not None and value[1] is not None: + _check_order(value[0], value[1]) + return value + + +def serialize_bounded( value: tuple[datetime, datetime], serializer: Callable[[tuple[datetime, datetime]], tuple[str, str]], ) -> str: @@ -34,8 +65,30 @@ def serialize( return f"{value[0].isoformat()}/{value[1].isoformat()}" -DatetimeInterval = Annotated[ +def serialize( + value: tuple[datetime | None, datetime | None], + serializer: Callable[[tuple[datetime | None, datetime | None]], tuple[str, str]], +) -> str: + del serializer # unused + start = OPEN_END if value[0] is None else value[0].isoformat() + end = OPEN_END if value[1] is None else value[1].isoformat() + return f"{start}/{end}" + + +# Both ends bounded: for a window the provider has already determined, e.g. an +# Opportunity's datetime property. +BoundedDatetimeInterval = Annotated[ tuple[AwareDatetime, AwareDatetime], + BeforeValidator(validate_bounded_before), + AfterValidator(validate_bounded_after), + WrapSerializer(serialize_bounded, return_type=str), + WithJsonSchema({"type": "string"}), +] + +# The general STAPI interval: open (via ``..`` or an empty string) on at most one +# end, for intervals that express a query rather than a result. +DatetimeInterval = Annotated[ + tuple[AwareDatetime | None, AwareDatetime | None], BeforeValidator(validate_before), AfterValidator(validate_after), WrapSerializer(serialize, return_type=str), diff --git a/stapi-pydantic/src/stapi_pydantic/opportunity.py b/stapi-pydantic/src/stapi_pydantic/opportunity.py index 6d2cccd..454bf70 100644 --- a/stapi-pydantic/src/stapi_pydantic/opportunity.py +++ b/stapi-pydantic/src/stapi_pydantic/opportunity.py @@ -4,7 +4,7 @@ from geojson_pydantic import Feature, FeatureCollection from pydantic import AwareDatetime, BaseModel, ConfigDict, Field -from .datetime_interval import DatetimeInterval +from .datetime_interval import BoundedDatetimeInterval, DatetimeInterval from .filter import CQL2Filter from .geometry import Geometry from .shared import Link @@ -12,7 +12,7 @@ # Copied and modified from https://github.com/stac-utils/stac-pydantic/blob/main/stac_pydantic/item.py#L11 class OpportunityProperties(BaseModel): - datetime: DatetimeInterval + datetime: BoundedDatetimeInterval product_id: str model_config = ConfigDict(extra="allow") From 885db33c217e9743831f85b452fe476a44b33f1c Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 6 Aug 2026 17:26:13 -0700 Subject: [PATCH 06/48] fix!: reject a malformed CQL2 filter as a validation error `Expr.validate()` raises cql2's own exception types, which pydantic does not convert into a `ValidationError`. The exception escaped validation entirely, so a client sending a malformed filter got a server error rather than having its request rejected. It is now re-raised as a `ValueError`, which pydantic does convert. `CQL2Filter` is typed `dict[str, Any]` instead of a bare `dict`, which retires the `type: ignore[type-arg]` its users carried. Also adds `cql2_property_names`, which walks a CQL2 JSON expression and collects the property names it references. The routers use it later on this branch to check a filter against a product's required queryables. --- stapi-pydantic/CHANGELOG.md | 7 ++++ stapi-pydantic/src/stapi_pydantic/__init__.py | 3 +- stapi-pydantic/src/stapi_pydantic/filter.py | 29 +++++++++++++-- stapi-pydantic/tests/test_filter.py | 35 +++++++++++++++++++ 4 files changed, 70 insertions(+), 4 deletions(-) create mode 100644 stapi-pydantic/tests/test_filter.py diff --git a/stapi-pydantic/CHANGELOG.md b/stapi-pydantic/CHANGELOG.md index 97c9fa7..3acef9a 100644 --- a/stapi-pydantic/CHANGELOG.md +++ b/stapi-pydantic/CHANGELOG.md @@ -15,6 +15,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - `Geometry`, the union of the six geometry types STAPI defines, discriminated on `type`. Exported from the package root. - A `stapi_pydantic.geometry` module providing `compute_geometry_bbox`, `bbox_from_geometry_input`, and `union_bboxes`. - `BoundedDatetimeInterval`, for intervals that are bounded at both ends. +- `cql2_property_names`, which collects the property names referenced by a CQL2 JSON filter. - `STAPI_VERSION` is now exported from the package root. ### Changed @@ -23,6 +24,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **BREAKING** `DatetimeInterval` now denotes the general interval, which may be open on one end via `..` or an empty string, and validates as a `tuple[AwareDatetime | None, AwareDatetime | None]`. The both-ends-bounded form is now `BoundedDatetimeInterval`. Code that relied on `DatetimeInterval` rejecting open ends, or on both tuple members being non-`None`, must switch to `BoundedDatetimeInterval`. - **BREAKING** `Geometry` is the six-member STAPI union and no longer includes `GeometryCollection`. The spec enumerates exactly six geometry conformance classes, so a `GeometryCollection` was a value no implementation could declare support for. Import `geojson_pydantic.geometries.Geometry` directly if you need the wider union. +- `CQL2Filter` is typed as `dict[str, Any]` rather than a bare `dict`. + +### Fixed + +- A malformed CQL2 filter is reported as a validation error. `cql2` raises its own exception types, which pydantic does not convert, so an invalid filter escaped validation and surfaced as a server error rather than a rejected request. + ### Removed - **BREAKING** `JsonSchemaModel` is gone. It annotated a `type[BaseModel]` with a `PlainValidator`/`PlainSerializer` pair so a model class could stand in for its own schema, which meant the published document carried an orphan `BaseModel` component and the value could not be read back. Build a `JsonSchema` with `JsonSchema.from_model(YourModel)` instead. diff --git a/stapi-pydantic/src/stapi_pydantic/__init__.py b/stapi-pydantic/src/stapi_pydantic/__init__.py index b2317ca..224fe9f 100644 --- a/stapi-pydantic/src/stapi_pydantic/__init__.py +++ b/stapi-pydantic/src/stapi_pydantic/__init__.py @@ -1,7 +1,7 @@ from .conformance import Conformance from .constants import STAPI_VERSION from .datetime_interval import BoundedDatetimeInterval, DatetimeInterval -from .filter import CQL2Filter +from .filter import CQL2Filter, cql2_property_names from .geometry import Geometry from .json_schema import JsonSchema from .opportunity import ( @@ -64,4 +64,5 @@ "Queryables", "RootResponse", "STAPI_VERSION", + "cql2_property_names", ] diff --git a/stapi-pydantic/src/stapi_pydantic/filter.py b/stapi-pydantic/src/stapi_pydantic/filter.py index 2064fa9..cdca033 100644 --- a/stapi-pydantic/src/stapi_pydantic/filter.py +++ b/stapi-pydantic/src/stapi_pydantic/filter.py @@ -6,12 +6,35 @@ def validate(v: dict[str, Any]) -> dict[str, Any]: if v: - expr = Expr(v) - expr.validate() + # cql2 raises its own exception types, which pydantic does not convert. + # Re-raise as ValueError so a malformed filter is a 422 rather than a 500. + try: + Expr(v).validate() + except Exception as e: + raise ValueError(f"invalid CQL2 filter: {e}") from e return v CQL2Filter: TypeAlias = Annotated[ - dict, + dict[str, Any], BeforeValidator(validate), ] + + +def cql2_property_names(filter_: dict[str, Any] | None) -> set[str]: + """Collect all property names referenced in a CQL2 JSON expression.""" + names: set[str] = set() + + def walk(node: Any) -> None: + match node: + case {"property": str(name)}: + names.add(name) + case dict(): + for value in node.values(): + walk(value) + case list(): + for item in node: + walk(item) + + walk(filter_ or {}) + return names diff --git a/stapi-pydantic/tests/test_filter.py b/stapi-pydantic/tests/test_filter.py new file mode 100644 index 0000000..0dad3e4 --- /dev/null +++ b/stapi-pydantic/tests/test_filter.py @@ -0,0 +1,35 @@ +import pytest +from pydantic import TypeAdapter, ValidationError +from stapi_pydantic import CQL2Filter +from stapi_pydantic.filter import cql2_property_names + +FILTER = TypeAdapter(CQL2Filter) + + +def test_malformed_filter_is_a_validation_error() -> None: + # cql2 raises its own exception types, which pydantic does not convert, so a + # malformed filter has to be re-raised as a ValueError to be a 422 not a 500 + # `=` takes two arguments; cql2 parses this but rejects it on validation + with pytest.raises(ValidationError, match="invalid CQL2 filter"): + FILTER.validate_python({"op": "=", "args": [{"property": "platform"}]}) + + +def test_valid_filter_is_accepted() -> None: + filter_ = {"op": "=", "args": [{"property": "platform"}, "umbra"]} + assert FILTER.validate_python(filter_) == filter_ + + +def test_property_names_empty() -> None: + assert cql2_property_names(None) == set() + assert cql2_property_names({}) == set() + + +def test_property_names_nested() -> None: + filter_ = { + "op": "and", + "args": [ + {"op": ">=", "args": [{"property": "sar:resolution_range"}, 1.0]}, + {"op": "=", "args": [{"property": "platform"}, "umbra"]}, + ], + } + assert cql2_property_names(filter_) == {"sar:resolution_range", "platform"} From c5706467efbef55619d7aec47c0b79982cc6ee0f Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 6 Aug 2026 17:26:52 -0700 Subject: [PATCH 07/48] feat: add SearchParameters, the shared Search Parameters Object The Opportunity Request and the Order Request each declared `datetime`, `geometry` and `filter` themselves, so the two definitions of the same spec object could drift. `SearchParameters` is that object, declared once. It permits extra fields: a provider extension parameter sent by a client has to survive being stored on an order and read back, which a strict model would silently drop. Nothing composes it yet -- the requests are reshaped onto it next. Its tests are also where the open-ended interval behaviour from the previous commit is exercised, since `SearchParameters.datetime` is the first field to use the general `DatetimeInterval`. --- stapi-pydantic/CHANGELOG.md | 1 + stapi-pydantic/src/stapi_pydantic/__init__.py | 2 + .../src/stapi_pydantic/search_parameters.py | 19 +++++++ .../tests/test_search_parameters.py | 57 +++++++++++++++++++ 4 files changed, 79 insertions(+) create mode 100644 stapi-pydantic/src/stapi_pydantic/search_parameters.py create mode 100644 stapi-pydantic/tests/test_search_parameters.py diff --git a/stapi-pydantic/CHANGELOG.md b/stapi-pydantic/CHANGELOG.md index 3acef9a..45dabb4 100644 --- a/stapi-pydantic/CHANGELOG.md +++ b/stapi-pydantic/CHANGELOG.md @@ -16,6 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - A `stapi_pydantic.geometry` module providing `compute_geometry_bbox`, `bbox_from_geometry_input`, and `union_bboxes`. - `BoundedDatetimeInterval`, for intervals that are bounded at both ends. - `cql2_property_names`, which collects the property names referenced by a CQL2 JSON filter. +- `SearchParameters`, the Search Parameters Object (`datetime`, `geometry`, `filter`) shared by the Opportunity Request and the Order Request. It permits extra fields, so provider extension parameters round-trip instead of being dropped. - `STAPI_VERSION` is now exported from the package root. ### Changed diff --git a/stapi-pydantic/src/stapi_pydantic/__init__.py b/stapi-pydantic/src/stapi_pydantic/__init__.py index 224fe9f..0e01000 100644 --- a/stapi-pydantic/src/stapi_pydantic/__init__.py +++ b/stapi-pydantic/src/stapi_pydantic/__init__.py @@ -29,6 +29,7 @@ from .product import Product, ProductsCollection, Provider, ProviderRole from .queryables import Queryables from .root import RootResponse +from .search_parameters import SearchParameters from .shared import Link __all__ = [ @@ -63,6 +64,7 @@ "ProviderRole", "Queryables", "RootResponse", + "SearchParameters", "STAPI_VERSION", "cql2_property_names", ] diff --git a/stapi-pydantic/src/stapi_pydantic/search_parameters.py b/stapi-pydantic/src/stapi_pydantic/search_parameters.py new file mode 100644 index 0000000..bafb67d --- /dev/null +++ b/stapi-pydantic/src/stapi_pydantic/search_parameters.py @@ -0,0 +1,19 @@ +from pydantic import BaseModel, ConfigDict + +from .datetime_interval import DatetimeInterval +from .filter import CQL2Filter +from .geometry import Geometry + + +class SearchParameters(BaseModel): + """STAPI Search Parameters Object. + + Shared by the Opportunity Request and the Order Request. + """ + + datetime: DatetimeInterval + geometry: Geometry + filter: CQL2Filter | None = None + + # vendor extension fields must round-trip through stored orders + model_config = ConfigDict(extra="allow") diff --git a/stapi-pydantic/tests/test_search_parameters.py b/stapi-pydantic/tests/test_search_parameters.py new file mode 100644 index 0000000..414068c --- /dev/null +++ b/stapi-pydantic/tests/test_search_parameters.py @@ -0,0 +1,57 @@ +import pytest +from pydantic import ValidationError +from stapi_pydantic import STAPI_VERSION, SearchParameters + +GEOMETRY = {"type": "Point", "coordinates": [13.4, 52.5]} + + +def test_stapi_version_is_0_2_0() -> None: + assert STAPI_VERSION == "0.2.0" + + +def test_search_parameters_minimal() -> None: + sp = SearchParameters.model_validate( + { + "datetime": "2024-04-18T10:56:00Z/2024-04-25T10:56:00Z", + "geometry": {"type": "Point", "coordinates": [13.4, 52.5]}, + } + ) + assert sp.filter is None + + +@pytest.mark.parametrize("interval", ["2024-04-18T10:56:00Z/..", "2024-04-18T10:56:00Z/"]) +def test_search_parameters_open_end(interval: str) -> None: + sp = SearchParameters.model_validate({"datetime": interval, "geometry": GEOMETRY}) + assert sp.datetime[0] is not None + assert sp.datetime[1] is None + assert sp.model_dump(mode="json")["datetime"] == "2024-04-18T10:56:00+00:00/.." + + +@pytest.mark.parametrize("interval", ["../2024-04-25T10:56:00+01:00", "/2024-04-25T10:56:00+01:00"]) +def test_search_parameters_open_start(interval: str) -> None: + sp = SearchParameters.model_validate({"datetime": interval, "geometry": GEOMETRY}) + assert sp.datetime[0] is None + assert sp.datetime[1] is not None + assert sp.model_dump(mode="json")["datetime"] == "../2024-04-25T10:56:00+01:00" + + +@pytest.mark.parametrize("interval", ["../..", "/", "../", "/.."]) +def test_search_parameters_doubly_open_interval_rejected(interval: str) -> None: + with pytest.raises(ValidationError): + SearchParameters.model_validate({"datetime": interval, "geometry": GEOMETRY}) + + +def test_search_parameters_end_before_start_rejected() -> None: + with pytest.raises(ValidationError, match="end before start"): + SearchParameters.model_validate({"datetime": "2024-04-25T10:56:00Z/2024-04-18T10:56:00Z", "geometry": GEOMETRY}) + + +def test_search_parameters_with_filter() -> None: + sp = SearchParameters.model_validate( + { + "datetime": "2024-04-18T10:56:00Z/2024-04-25T10:56:00Z", + "geometry": {"type": "Point", "coordinates": [13.4, 52.5]}, + "filter": {"op": ">=", "args": [{"property": "gsd"}, 1.0]}, + } + ) + assert sp.filter is not None From 6d8588bd81f8fe23eb82039f7d7ec6f50ba99b8e Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 6 Aug 2026 17:28:17 -0700 Subject: [PATCH 08/48] fix!: omit unset Link fields from every dump, not only JSON `Link` carried a wrap serializer registered `when_used="json"`, so `model_dump()` still emitted `"title": null` and friends while `model_dump(mode="json")` did not. The two disagreed about the same model. Each optional field now declares `exclude_if`, so both dump modes omit it. That also keeps the field out of the serialization schema's required set, which matters once the response models start marking their defaulted fields required. `href` gains a default on the redefined `__init__`. pydantic routes validation through it, so a payload missing `href` died in argument binding with a TypeError instead of yielding a ValidationError. --- stapi-pydantic/CHANGELOG.md | 1 + stapi-pydantic/src/stapi_pydantic/shared.py | 34 ++++++++++++--------- stapi-pydantic/tests/test_shared.py | 20 ++++++++++++ 3 files changed, 41 insertions(+), 14 deletions(-) create mode 100644 stapi-pydantic/tests/test_shared.py diff --git a/stapi-pydantic/CHANGELOG.md b/stapi-pydantic/CHANGELOG.md index 45dabb4..fab13f3 100644 --- a/stapi-pydantic/CHANGELOG.md +++ b/stapi-pydantic/CHANGELOG.md @@ -26,6 +26,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **BREAKING** `Geometry` is the six-member STAPI union and no longer includes `GeometryCollection`. The spec enumerates exactly six geometry conformance classes, so a `GeometryCollection` was a value no implementation could declare support for. Import `geojson_pydantic.geometries.Geometry` directly if you need the wider union. - `CQL2Filter` is typed as `dict[str, Any]` rather than a bare `dict`. +- **BREAKING** `Link` omits its unset fields from `model_dump()` as well as from JSON output. The `None`-filtering serializer it previously carried applied only to JSON dumps. ### Fixed diff --git a/stapi-pydantic/src/stapi_pydantic/shared.py b/stapi-pydantic/src/stapi_pydantic/shared.py index 51558a8..989afa7 100644 --- a/stapi-pydantic/src/stapi_pydantic/shared.py +++ b/stapi-pydantic/src/stapi_pydantic/shared.py @@ -4,29 +4,35 @@ AnyUrl, BaseModel, ConfigDict, - SerializerFunctionWrapHandler, - model_serializer, + Field, ) +def omitted_when_none(**kwargs: Any) -> Any: + """A spec-OPTIONAL field that is omitted rather than serialized as null. + + `exclude_if` also keeps the field out of the serialization-required set + that `json_schema_serialization_defaults_required` would otherwise put it + in. + """ + return Field(default=None, exclude_if=lambda v: v is None, **kwargs) + + class Link(BaseModel): href: AnyUrl rel: str - type: str | None = None - title: str | None = None - method: str | None = None - headers: dict[str, str | list[str]] | None = None - body: Any = None + type: str | None = omitted_when_none() + title: str | None = omitted_when_none() + method: str | None = omitted_when_none() + headers: dict[str, str | list[str]] | None = omitted_when_none() + body: Any = omitted_when_none() model_config = ConfigDict(extra="allow") # redefining init is a hack to get str type to validate for `href`, # as str is ultimately coerced into an AnyUrl automatically anyway - def __init__(self, href: Any, **kwargs: Any) -> None: + # `href` must carry a default: without one, pydantic routes validation + # through this __init__ and a payload missing `href` raises TypeError from + # argument binding rather than yielding a ValidationError. + def __init__(self, href: Any = None, **kwargs: Any) -> None: super().__init__(href=href if isinstance(href, AnyUrl) else str(href), **kwargs) - - # overriding the default serialization to filter None field values from - # dumped json - @model_serializer(mode="wrap", when_used="json") - def serialize(self, handler: SerializerFunctionWrapHandler) -> dict[str, Any]: - return {k: v for k, v in handler(self).items() if v is not None} diff --git a/stapi-pydantic/tests/test_shared.py b/stapi-pydantic/tests/test_shared.py new file mode 100644 index 0000000..93ec7a6 --- /dev/null +++ b/stapi-pydantic/tests/test_shared.py @@ -0,0 +1,20 @@ +from stapi_pydantic import Link + + +def test_link_serialization_schema_is_structured() -> None: + schema = Link.model_json_schema(mode="serialization") + assert {"href", "rel"} <= set(schema["required"]) + assert "href" in schema["properties"] + + +def test_link_json_dump_omits_none_fields() -> None: + link = Link(href="https://example.com/orders/1", rel="self") + dumped = link.model_dump(mode="json") + assert dumped["rel"] == "self" + assert "title" not in dumped + assert "body" not in dumped + + +def test_link_preserves_extra_fields() -> None: + link = Link.model_validate({"href": "https://example.com", "rel": "self", "vendor:hint": "x"}) + assert link.model_dump(mode="json")["vendor:hint"] == "x" From 18da4a30de993a7ab169b669b02e21382d781288 Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 6 Aug 2026 17:30:59 -0700 Subject: [PATCH 09/48] feat!: serialize response models by alias and declare one conformsTo `Conformance` declared a serialization alias but no validation alias, so it could not parse its own output: a `conformsTo` on the way in bound nothing and the value was silently dropped. `RootResponse` had the mirror of the same bug by spelling the field `conformsTo` in Python and never aliasing it. `ConformsTo` is now one annotated definition -- validating from either spelling, serializing as `conformsTo` -- shared by `Conformance`, `RootResponse` and `Product`, so the wire name cannot drift between them. The Python attribute is `conforms_to` on all three. Response models opt into `STAPI_RESPONSE_CONFIG`, which turns on `serialize_by_alias` so a bare `model_dump()` emits wire names, and `json_schema_serialization_defaults_required` so a defaulted-but-always-present field is marked required in the serialization schema. It has to be set per model, since pydantic does not inherit config into nested models. `Provider.roles` and `Provider.url` become optional and omitted when unset; both were required, which left a provider publishing neither unrepresentable. Also deletes stapi-fastapi's `models/root.py`: a duplicate `RootResponse` carrying the same conformsTo bug, already unreferenced -- the router has been importing the stapi-pydantic one. --- .../src/stapi_fastapi/models/root.py | 10 ---- .../stapi_fastapi/routers/product_router.py | 6 +- .../src/stapi_fastapi/routers/root_router.py | 2 +- stapi-fastapi/tests/conftest.py | 4 +- stapi-fastapi/tests/shared.py | 12 ++-- stapi-pydantic/CHANGELOG.md | 4 ++ .../src/stapi_pydantic/conformance.py | 30 +++++++++- stapi-pydantic/src/stapi_pydantic/product.py | 21 ++++--- stapi-pydantic/src/stapi_pydantic/root.py | 9 ++- stapi-pydantic/src/stapi_pydantic/shared.py | 22 +++++++ stapi-pydantic/tests/test_product.py | 15 +++++ stapi-pydantic/tests/test_shared.py | 57 ++++++++++++++++++- 12 files changed, 155 insertions(+), 37 deletions(-) delete mode 100644 stapi-fastapi/src/stapi_fastapi/models/root.py create mode 100644 stapi-pydantic/tests/test_product.py diff --git a/stapi-fastapi/src/stapi_fastapi/models/root.py b/stapi-fastapi/src/stapi_fastapi/models/root.py deleted file mode 100644 index 6c8a680..0000000 --- a/stapi-fastapi/src/stapi_fastapi/models/root.py +++ /dev/null @@ -1,10 +0,0 @@ -from pydantic import BaseModel, Field -from stapi_pydantic import Link - - -class RootResponse(BaseModel): - id: str - conformsTo: list[str] = Field(default_factory=list) - title: str = "" - description: str = "" - links: list[Link] = Field(default_factory=list) diff --git a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py index c63d025..c37a820 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py @@ -70,10 +70,10 @@ def get_prefer(prefer: str | None = Header(None)) -> str | None: def build_conformances(product: Product, root_router: RootRouter) -> list[str]: # FIXME we can make this check more robust - if not any(conformance.startswith("https://geojson.org/schema/") for conformance in product.conformsTo): + if not any(conformance.startswith("https://geojson.org/schema/") for conformance in product.conforms_to): raise ValueError("product conformance does not contain at least one geojson conformance") - conformances = set(product.conformsTo) + conformances = set(product.conforms_to) if product.supports_opportunity_search: conformances.add(PRODUCT_CONFORMACES.opportunities) @@ -341,7 +341,7 @@ def get_product_conformance(self) -> Conformance: """ Return conformance urls of a specific product """ - return Conformance.model_validate({"conforms_to": self.conformances}) + return Conformance(conforms_to=self.conformances) def get_product_queryables(self) -> JsonSchema: """ diff --git a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py index c33abc1..7b66c23 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py @@ -207,7 +207,7 @@ def get_root(self, request: Request) -> RootResponse: return RootResponse( id="STAPI API", - conformsTo=self.conformances, + conforms_to=self.conformances, links=links, ) diff --git a/stapi-fastapi/tests/conftest.py b/stapi-fastapi/tests/conftest.py index b0be83c..a041f8c 100644 --- a/stapi-fastapi/tests/conftest.py +++ b/stapi-fastapi/tests/conftest.py @@ -79,7 +79,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[dict[str, Any]]: ) for mock_product in mock_products: - mock_product.conformsTo = [PRODUCT.opportunities, PRODUCT.opportunities_async, PRODUCT.geojson_point] + mock_product.conforms_to = [PRODUCT.opportunities, PRODUCT.opportunities_async, PRODUCT.geojson_point] root_router.add_product(mock_product) app = FastAPI(lifespan=lifespan) @@ -121,7 +121,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[dict[str, Any]]: ) for mock_product in mock_products: - mock_product.conformsTo = [PRODUCT.opportunities, PRODUCT.opportunities_async, PRODUCT.geojson_point] + mock_product.conforms_to = [PRODUCT.opportunities, PRODUCT.opportunities_async, PRODUCT.geojson_point] root_router.add_product(mock_product) app = FastAPI(lifespan=lifespan) diff --git a/stapi-fastapi/tests/shared.py b/stapi-fastapi/tests/shared.py index 7be0c1d..6595f67 100644 --- a/stapi-fastapi/tests/shared.py +++ b/stapi-fastapi/tests/shared.py @@ -121,7 +121,7 @@ class MyOrderParameters(OrderParameters): description="A provider for Test data", roles=[ProviderRole.producer], # Example role url="https://test-provider.example.com", # Must be a valid URL - conformsTo=[PRODUCT.geojson_point], + conforms_to=[PRODUCT.geojson_point], ) product_test_spotlight = Product( @@ -139,7 +139,7 @@ class MyOrderParameters(OrderParameters): queryables=MyProductQueryables, opportunity_properties=MyOpportunityProperties, order_parameters=MyOrderParameters, - conformsTo=[PRODUCT.geojson_point], + conforms_to=[PRODUCT.geojson_point], ) product_test_spotlight_sync_opportunity = Product( @@ -157,7 +157,7 @@ class MyOrderParameters(OrderParameters): queryables=MyProductQueryables, opportunity_properties=MyOpportunityProperties, order_parameters=MyOrderParameters, - conformsTo=[PRODUCT.geojson_point, PRODUCT.opportunities], + conforms_to=[PRODUCT.geojson_point, PRODUCT.opportunities], ) @@ -176,7 +176,7 @@ class MyOrderParameters(OrderParameters): queryables=MyProductQueryables, opportunity_properties=MyOpportunityProperties, order_parameters=MyOrderParameters, - conformsTo=[PRODUCT.geojson_point, PRODUCT.opportunities_async], + conforms_to=[PRODUCT.geojson_point, PRODUCT.opportunities_async], ) product_test_spotlight_sync_async_opportunity = Product( @@ -194,7 +194,7 @@ class MyOrderParameters(OrderParameters): queryables=MyProductQueryables, opportunity_properties=MyOpportunityProperties, order_parameters=MyOrderParameters, - conformsTo=[PRODUCT.geojson_point, PRODUCT.opportunities, PRODUCT.opportunities_async], + conforms_to=[PRODUCT.geojson_point, PRODUCT.opportunities, PRODUCT.opportunities_async], ) product_test_satellite_provider_sync_opportunity = Product( @@ -212,7 +212,7 @@ class MyOrderParameters(OrderParameters): queryables=MyProductQueryables, opportunity_properties=MyOpportunityProperties, order_parameters=MyOrderParameters, - conformsTo=[PRODUCT.geojson_point, PRODUCT.opportunities], + conforms_to=[PRODUCT.geojson_point, PRODUCT.opportunities], ) diff --git a/stapi-pydantic/CHANGELOG.md b/stapi-pydantic/CHANGELOG.md index fab13f3..8877b73 100644 --- a/stapi-pydantic/CHANGELOG.md +++ b/stapi-pydantic/CHANGELOG.md @@ -27,6 +27,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - `CQL2Filter` is typed as `dict[str, Any]` rather than a bare `dict`. - **BREAKING** `Link` omits its unset fields from `model_dump()` as well as from JSON output. The `None`-filtering serializer it previously carried applied only to JSON dumps. +- **BREAKING** Models that declare aliases now serialize by alias. `model_dump()` emits `conformsTo` on `Conformance` and `type` on `Product`, where it previously emitted the Python field names `conforms_to` and `type_`. Callers already passing `by_alias=True` are unaffected; callers reading the Python names out of a dump must switch to the wire names. +- **BREAKING** `Product.conformsTo` and `RootResponse.conformsTo` are spelled `conforms_to` in Python, matching `Conformance`. The wire name is unchanged: all three validate from either `conformsTo` or `conforms_to` and serialize as `conformsTo`. Keyword construction and attribute access must use the new name. +- **BREAKING** `Provider.roles` and `Provider.url` are optional. Both were required, which made a provider that publishes neither unrepresentable; they are now omitted from output rather than published empty or null. +- Spec-REQUIRED fields that carry defaults (`type`, `stapi_type`, `stapi_version`, `links`, `conformsTo`, and so on) are now marked required in the serialization JSON Schema, since they are always present in a response. ### Fixed diff --git a/stapi-pydantic/src/stapi_pydantic/conformance.py b/stapi-pydantic/src/stapi_pydantic/conformance.py index 2011b4f..691fd20 100644 --- a/stapi-pydantic/src/stapi_pydantic/conformance.py +++ b/stapi-pydantic/src/stapi_pydantic/conformance.py @@ -1,5 +1,31 @@ -from pydantic import BaseModel, Field +from typing import Annotated + +from pydantic import AliasChoices, BaseModel, Field + +from .constants import STAPI_VERSION +from .shared import STAPI_RESPONSE_CONFIG + +#: The URI of the STAPI core conformance class. Defined here rather than only in +#: ``stapi_fastapi.conformance`` so the models can use it as their ``conformsTo`` +#: example without that example drifting from what a server advertises. +CORE_CONFORMANCE = f"https://stapi.example.com/v{STAPI_VERSION}/core" + +#: An illustrative ``conformsTo`` value. +CONFORMS_TO_EXAMPLE = [CORE_CONFORMANCE] + +#: The ``conformsTo`` field, declared once so its wire name cannot drift between +#: the models that publish it. +ConformsTo = Annotated[ + list[str], + Field( + validation_alias=AliasChoices("conformsTo", "conforms_to"), + serialization_alias="conformsTo", + examples=[CONFORMS_TO_EXAMPLE], + ), +] class Conformance(BaseModel): - conforms_to: list[str] = Field(default_factory=list, serialization_alias="conformsTo") + model_config = STAPI_RESPONSE_CONFIG + + conforms_to: ConformsTo = [] diff --git a/stapi-pydantic/src/stapi_pydantic/product.py b/stapi-pydantic/src/stapi_pydantic/product.py index 54b946f..f43407a 100644 --- a/stapi-pydantic/src/stapi_pydantic/product.py +++ b/stapi-pydantic/src/stapi_pydantic/product.py @@ -3,8 +3,9 @@ from pydantic import AnyHttpUrl, BaseModel, Field +from .conformance import ConformsTo from .constants import STAPI_VERSION -from .shared import Link +from .shared import STAPI_RESPONSE_CONFIG, Link, omitted_when_empty, omitted_when_none class ProviderRole(StrEnum): @@ -16,27 +17,29 @@ class ProviderRole(StrEnum): class Provider(BaseModel): name: str - description: str | None = None - roles: list[ProviderRole] - url: AnyHttpUrl + description: str | None = omitted_when_none() + roles: list[ProviderRole] = omitted_when_empty(default_factory=list) + url: AnyHttpUrl | None = omitted_when_none() # redefining init is a hack to get str type to validate for `url`, # as str is ultimately coerced into an AnyHttpUrl automatically anyway - def __init__(self, url: AnyHttpUrl | str, **kwargs: Any) -> None: + def __init__(self, url: AnyHttpUrl | str | None = None, **kwargs: Any) -> None: super().__init__(url=url, **kwargs) class Product(BaseModel): + model_config = STAPI_RESPONSE_CONFIG + type_: Literal["Collection"] = Field(default="Collection", alias="type") stapi_type: Literal["Product"] = "Product" stapi_version: str = STAPI_VERSION - conformsTo: list[str] = Field(default_factory=list) + conforms_to: ConformsTo = [] id: str - title: str = "" + title: str = omitted_when_empty(default="") description: str = "" - keywords: list[str] = Field(default_factory=list) + keywords: list[str] = omitted_when_empty(default_factory=list) license: str - providers: list[Provider] = Field(default_factory=list) + providers: list[Provider] = omitted_when_empty(default_factory=list) links: list[Link] = Field(default_factory=list) def with_links(self, links: list[Link] | None = None) -> Self: diff --git a/stapi-pydantic/src/stapi_pydantic/root.py b/stapi-pydantic/src/stapi_pydantic/root.py index e42efae..4ca1528 100644 --- a/stapi-pydantic/src/stapi_pydantic/root.py +++ b/stapi-pydantic/src/stapi_pydantic/root.py @@ -1,11 +1,14 @@ from pydantic import BaseModel, Field -from .shared import Link +from .conformance import ConformsTo +from .shared import STAPI_RESPONSE_CONFIG, Link, omitted_when_empty class RootResponse(BaseModel): + model_config = STAPI_RESPONSE_CONFIG + id: str - conformsTo: list[str] = Field(default_factory=list) - title: str = "" + conforms_to: ConformsTo = [] + title: str = omitted_when_empty(default="") description: str = "" links: list[Link] = Field(default_factory=list) diff --git a/stapi-pydantic/src/stapi_pydantic/shared.py b/stapi-pydantic/src/stapi_pydantic/shared.py index 989afa7..d7db0ab 100644 --- a/stapi-pydantic/src/stapi_pydantic/shared.py +++ b/stapi-pydantic/src/stapi_pydantic/shared.py @@ -7,6 +7,20 @@ Field, ) +# Shared config for the models that make up STAPI responses. Must be set on +# every model that declares an alias, not just the outermost one: model config +# is not inherited by nested models. +STAPI_RESPONSE_CONFIG = ConfigDict( + serialize_by_alias=True, + json_schema_serialization_defaults_required=True, +) + +# As above, for response models that must also round-trip unknown fields. A dict +# literal rather than ``ConfigDict(**STAPI_RESPONSE_CONFIG, extra="allow")``, +# which type checkers reject: they cannot prove the unpacked TypedDict does not +# already carry ``extra``. +STAPI_RESPONSE_CONFIG_ALLOW_EXTRA: ConfigDict = {**STAPI_RESPONSE_CONFIG, "extra": "allow"} + def omitted_when_none(**kwargs: Any) -> Any: """A spec-OPTIONAL field that is omitted rather than serialized as null. @@ -18,6 +32,14 @@ def omitted_when_none(**kwargs: Any) -> Any: return Field(default=None, exclude_if=lambda v: v is None, **kwargs) +def omitted_when_empty(**kwargs: Any) -> Any: + """A spec-OPTIONAL field that is omitted rather than serialized empty. + + For fields whose "unset" is an empty list or string rather than None. + """ + return Field(exclude_if=lambda v: not v, **kwargs) + + class Link(BaseModel): href: AnyUrl rel: str diff --git a/stapi-pydantic/tests/test_product.py b/stapi-pydantic/tests/test_product.py new file mode 100644 index 0000000..ef2640d --- /dev/null +++ b/stapi-pydantic/tests/test_product.py @@ -0,0 +1,15 @@ +from stapi_pydantic import Product + + +def test_product_serialization_schema_marks_spec_required_fields() -> None: + schema = Product.model_json_schema(mode="serialization") + assert {"type", "stapi_type", "stapi_version", "id", "description", "license", "links"} <= set(schema["required"]) + + +def test_product_dumps_type_by_alias() -> None: + # `type` is required by Product's own serialization schema, so a bare dump + # (not just FastAPI's by-alias response rendering) has to emit it. + product = Product(id="p1", license="proprietary", description="d") + assert product.model_dump(mode="json")["type"] == "Collection" + assert product.model_dump()["type"] == "Collection" + assert "type_" not in product.model_dump() diff --git a/stapi-pydantic/tests/test_shared.py b/stapi-pydantic/tests/test_shared.py index 93ec7a6..767148c 100644 --- a/stapi-pydantic/tests/test_shared.py +++ b/stapi-pydantic/tests/test_shared.py @@ -1,4 +1,14 @@ -from stapi_pydantic import Link +from typing import Any + +import pytest +from pydantic import BaseModel +from stapi_pydantic import ( + Conformance, + Link, + Product, + Provider, + RootResponse, +) def test_link_serialization_schema_is_structured() -> None: @@ -18,3 +28,48 @@ def test_link_json_dump_omits_none_fields() -> None: def test_link_preserves_extra_fields() -> None: link = Link.model_validate({"href": "https://example.com", "rel": "self", "vendor:hint": "x"}) assert link.model_dump(mode="json")["vendor:hint"] == "x" + + +def test_root_response_serialization_schema_marks_spec_required_fields() -> None: + schema = RootResponse.model_json_schema(mode="serialization") + assert {"id", "conformsTo", "description", "links"} <= set(schema["required"]) + + +def test_conformance_serialization_schema_requires_conforms_to() -> None: + schema = Conformance.model_json_schema(mode="serialization", by_alias=True) + assert "conformsTo" in schema.get("required", []) + + +def test_conformance_dumps_by_alias() -> None: + # Conformance is nested in responses, and model config is not inherited by + # nested models, so it has to serialize by alias itself. + assert Conformance(conforms_to=["a"]).model_dump(mode="json") == {"conformsTo": ["a"]} + assert Conformance(conforms_to=["a"]).model_dump() == {"conformsTo": ["a"]} + + +#: Models whose spec-OPTIONAL fields must be omitted rather than published as +#: null, and so must not appear in the serialization-required set. +OMIT_WHEN_UNSET = [ + (Link, {"href": "https://example.test", "rel": "self"}, {"type", "title", "method", "headers", "body"}), + (Provider, {"name": "n"}, {"description", "roles", "url"}), + # conformsTo is spec-REQUIRED on a Product, so it is always published and + # is deliberately not in this set. + ( + Product, + {"id": "p", "description": "d", "license": "proprietary"}, + {"title", "keywords", "providers"}, + ), + (RootResponse, {"id": "x", "description": "d"}, {"title"}), +] + + +@pytest.mark.parametrize(("model", "minimal", "optional"), OMIT_WHEN_UNSET, ids=lambda v: getattr(v, "__name__", "")) +def test_unset_optional_fields_are_omitted_not_published_as_null( + model: type[BaseModel], minimal: dict[str, Any], optional: set[str] +) -> None: + """A spec-OPTIONAL field is absent when unset, and so is not required.""" + dumped = model(**minimal).model_dump(mode="json") + assert optional.isdisjoint(dumped), f"unset optional fields present: {sorted(optional & set(dumped))}" + + schema = model.model_json_schema(mode="serialization") + assert optional.isdisjoint(schema.get("required", [])) From f285d7ee36d3bf190bebefde15c859bb92e8b339 Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 6 Aug 2026 17:32:16 -0700 Subject: [PATCH 10/48] feat!: rename ProductsCollection to ProductCollection The class disagreed with its own payload: it was named `ProductsCollection` while its `type` field said `ProductCollection`, which is what the spec calls it. The name is corrected and the old one dropped rather than aliased. The aliased `type` field is replaced by `stapi_type`, so a response carries `"stapi_type": "ProductCollection"`, and `stapi_version` joins it. This is the first of the collections to gain the pair; the rest follow. `Product.description` is required, per the spec. It defaulted to the empty string, so a product could be published with no description at all and still validate. stapi-fastapi and pystapi-client follow the rename. --- pystapi-client/src/pystapi_client/client.py | 4 +-- .../src/stapi_fastapi/routers/root_router.py | 6 ++-- stapi-fastapi/tests/test_product.py | 2 +- stapi-pydantic/CHANGELOG.md | 4 +++ stapi-pydantic/src/stapi_pydantic/__init__.py | 4 +-- stapi-pydantic/src/stapi_pydantic/product.py | 9 ++++-- stapi-pydantic/tests/test_product.py | 29 ++++++++++++++++++- 7 files changed, 46 insertions(+), 12 deletions(-) diff --git a/pystapi-client/src/pystapi_client/client.py b/pystapi-client/src/pystapi_client/client.py index d38c452..b77b961 100644 --- a/pystapi-client/src/pystapi_client/client.py +++ b/pystapi-client/src/pystapi_client/client.py @@ -19,7 +19,7 @@ OrderCollection, OrderPayload, Product, - ProductsCollection, + ProductCollection, ) from pystapi_client.conformance import ConformanceClasses @@ -282,7 +282,7 @@ def get_products(self, limit: int | None = None) -> Iterator[Product]: products_collection_iterator = self.stapi_io.get_pages(link=products_link, lookup_key="products") for products_collection in products_collection_iterator: - yield from ProductsCollection.model_validate(products_collection).products + yield from ProductCollection.model_validate(products_collection).products def get_product(self, product_id: str) -> Product: """Get a single product from this STAPI API diff --git a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py index 7b66c23..5cfc256 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py @@ -16,7 +16,7 @@ OrderCollection, OrderStatus, OrderStatuses, - ProductsCollection, + ProductCollection, RootResponse, ) @@ -214,7 +214,7 @@ def get_root(self, request: Request) -> RootResponse: def get_conformance(self) -> Conformance: return Conformance(conforms_to=self.conformances) - def get_products(self, request: Request, next: str | None = None, limit: int = 10) -> ProductsCollection: + def get_products(self, request: Request, next: str | None = None, limit: int = 10) -> ProductCollection: start = 0 limit = min(limit, 100) try: @@ -233,7 +233,7 @@ def get_products(self, request: Request, next: str | None = None, limit: int = 1 ] if end > 0 and end < len(self.product_ids): links.append(self.pagination_link(request, f"{self.name}:{LIST_PRODUCTS}", self.product_ids[end], limit)) - return ProductsCollection( + return ProductCollection( products=[self.product_routers[product_id].get_product(request) for product_id in ids], links=links, ) diff --git a/stapi-fastapi/tests/test_product.py b/stapi-fastapi/tests/test_product.py index 1fee345..325797b 100644 --- a/stapi-fastapi/tests/test_product.py +++ b/stapi-fastapi/tests/test_product.py @@ -15,7 +15,7 @@ def test_products_response(stapi_client: TestClient): data = res.json() - assert data["type"] == "ProductCollection" + assert data["stapi_type"] == "ProductCollection" assert isinstance(data["products"], list) diff --git a/stapi-pydantic/CHANGELOG.md b/stapi-pydantic/CHANGELOG.md index 8877b73..51a5ad0 100644 --- a/stapi-pydantic/CHANGELOG.md +++ b/stapi-pydantic/CHANGELOG.md @@ -17,6 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - `BoundedDatetimeInterval`, for intervals that are bounded at both ends. - `cql2_property_names`, which collects the property names referenced by a CQL2 JSON filter. - `SearchParameters`, the Search Parameters Object (`datetime`, `geometry`, `filter`) shared by the Opportunity Request and the Order Request. It permits extra fields, so provider extension parameters round-trip instead of being dropped. +- `ProductCollection`, the new name for `ProductsCollection` (see Changed). - `STAPI_VERSION` is now exported from the package root. ### Changed @@ -31,6 +32,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **BREAKING** `Product.conformsTo` and `RootResponse.conformsTo` are spelled `conforms_to` in Python, matching `Conformance`. The wire name is unchanged: all three validate from either `conformsTo` or `conforms_to` and serialize as `conformsTo`. Keyword construction and attribute access must use the new name. - **BREAKING** `Provider.roles` and `Provider.url` are optional. Both were required, which made a provider that publishes neither unrepresentable; they are now omitted from output rather than published empty or null. - Spec-REQUIRED fields that carry defaults (`type`, `stapi_type`, `stapi_version`, `links`, `conformsTo`, and so on) are now marked required in the serialization JSON Schema, since they are always present in a response. +- **BREAKING** `ProductsCollection` is renamed to `ProductCollection`, matching its own `stapi_type` and the spec. The old name is gone rather than aliased; update imports. The model also replaces its aliased `type` field with `stapi_type`, so responses carry `"stapi_type": "ProductCollection"` rather than `"type": "ProductCollection"`, and gains `stapi_version`. +- **BREAKING** `Product.description` is required, per the spec. It previously defaulted to the empty string. ### Fixed @@ -38,6 +41,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Removed +- **BREAKING** The pre-0.2.0 compatibility alias `ProductsCollection` is gone. Use `ProductCollection`. - **BREAKING** `JsonSchemaModel` is gone. It annotated a `type[BaseModel]` with a `PlainValidator`/`PlainSerializer` pair so a model class could stand in for its own schema, which meant the published document carried an orphan `BaseModel` component and the value could not be read back. Build a `JsonSchema` with `JsonSchema.from_model(YourModel)` instead. ## [0.1.0] - 2025-12-18 diff --git a/stapi-pydantic/src/stapi_pydantic/__init__.py b/stapi-pydantic/src/stapi_pydantic/__init__.py index 0e01000..b06d7da 100644 --- a/stapi-pydantic/src/stapi_pydantic/__init__.py +++ b/stapi-pydantic/src/stapi_pydantic/__init__.py @@ -26,7 +26,7 @@ OrderStatusCode, OrderStatuses, ) -from .product import Product, ProductsCollection, Provider, ProviderRole +from .product import Product, ProductCollection, Provider, ProviderRole from .queryables import Queryables from .root import RootResponse from .search_parameters import SearchParameters @@ -59,7 +59,7 @@ "OrderStatuses", "Prefer", "Product", - "ProductsCollection", + "ProductCollection", "Provider", "ProviderRole", "Queryables", diff --git a/stapi-pydantic/src/stapi_pydantic/product.py b/stapi-pydantic/src/stapi_pydantic/product.py index f43407a..12918ed 100644 --- a/stapi-pydantic/src/stapi_pydantic/product.py +++ b/stapi-pydantic/src/stapi_pydantic/product.py @@ -36,7 +36,7 @@ class Product(BaseModel): conforms_to: ConformsTo = [] id: str title: str = omitted_when_empty(default="") - description: str = "" + description: str keywords: list[str] = omitted_when_empty(default_factory=list) license: str providers: list[Provider] = omitted_when_empty(default_factory=list) @@ -51,7 +51,10 @@ def with_links(self, links: list[Link] | None = None) -> Self: return new -class ProductsCollection(BaseModel): - type_: Literal["ProductCollection"] = Field(default="ProductCollection", alias="type") +class ProductCollection(BaseModel): + model_config = STAPI_RESPONSE_CONFIG + + stapi_type: Literal["ProductCollection"] = "ProductCollection" + stapi_version: str = STAPI_VERSION links: list[Link] = Field(default_factory=list) products: list[Product] diff --git a/stapi-pydantic/tests/test_product.py b/stapi-pydantic/tests/test_product.py index ef2640d..5d7ef1b 100644 --- a/stapi-pydantic/tests/test_product.py +++ b/stapi-pydantic/tests/test_product.py @@ -1,4 +1,19 @@ -from stapi_pydantic import Product +import pydantic +import pytest +from stapi_pydantic import Product, ProductCollection + + +def test_products_collection_stapi_fields() -> None: + collection = ProductCollection(products=[Product(id="p1", license="proprietary", description="d")]) + dumped = collection.model_dump(mode="json") + assert dumped["stapi_type"] == "ProductCollection" + assert dumped["stapi_version"] == "0.2.0" + assert "type" not in dumped + + +def test_product_description_is_required() -> None: + with pytest.raises(pydantic.ValidationError, match="description"): + Product.model_validate({"id": "p1", "license": "proprietary"}) def test_product_serialization_schema_marks_spec_required_fields() -> None: @@ -13,3 +28,15 @@ def test_product_dumps_type_by_alias() -> None: assert product.model_dump(mode="json")["type"] == "Collection" assert product.model_dump()["type"] == "Collection" assert "type_" not in product.model_dump() + + +def test_products_collection_dumps_nested_product_by_alias() -> None: + # model config is not inherited by nested models: the collection setting + # serialize_by_alias does nothing for the products it contains. + collection = ProductCollection(products=[Product(id="p1", license="proprietary", description="d")]) + assert collection.model_dump(mode="json")["products"][0]["type"] == "Collection" + assert collection.model_dump()["products"][0]["type"] == "Collection" + + +def test_product_collection_is_named_for_its_stapi_type() -> None: + assert ProductCollection.model_fields["stapi_type"].default == "ProductCollection" From 065d8aa1a3e5fc8462b1939a545e3dd99a2a0631 Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 6 Aug 2026 17:33:49 -0700 Subject: [PATCH 11/48] feat: split order parameters into a permissive base and a strict subclass Order parameters play two roles. At the boundary a product's own model should reject anything it does not declare, so a typo in a request is an error rather than a silently ignored field. At rest, inside a stored order, parameters from any product have to round-trip whatever they carry. One strict model cannot do both. `BaseOrderParameters` is the permissive form for stored parameters; `OrderParameters` is now a strict subclass of it and stays the base products derive from. Nothing stores the base form yet; `StoredOrderRequest` uses it shortly. Also drops `Props`, `Geom` and `OPP`, three type variables that were declared and never used, along with the `OpportunityProperties` import only `OPP` needed. --- stapi-pydantic/CHANGELOG.md | 2 ++ stapi-pydantic/src/stapi_pydantic/__init__.py | 2 ++ stapi-pydantic/src/stapi_pydantic/order.py | 16 ++++++++++----- stapi-pydantic/tests/test_order.py | 20 ++++++++++++++++++- 4 files changed, 34 insertions(+), 6 deletions(-) diff --git a/stapi-pydantic/CHANGELOG.md b/stapi-pydantic/CHANGELOG.md index 51a5ad0..f21d4d3 100644 --- a/stapi-pydantic/CHANGELOG.md +++ b/stapi-pydantic/CHANGELOG.md @@ -18,6 +18,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - `cql2_property_names`, which collects the property names referenced by a CQL2 JSON filter. - `SearchParameters`, the Search Parameters Object (`datetime`, `geometry`, `filter`) shared by the Opportunity Request and the Order Request. It permits extra fields, so provider extension parameters round-trip instead of being dropped. - `ProductCollection`, the new name for `ProductsCollection` (see Changed). +- `BaseOrderParameters`, a permissive base for order parameters at rest. `OrderParameters` is now a strict (`extra="forbid"`) subclass of it. - `STAPI_VERSION` is now exported from the package root. ### Changed @@ -42,6 +43,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Removed - **BREAKING** The pre-0.2.0 compatibility alias `ProductsCollection` is gone. Use `ProductCollection`. +- The unused `Props`, `Geom`, and `OPP` type variables in `stapi_pydantic.order`. - **BREAKING** `JsonSchemaModel` is gone. It annotated a `type[BaseModel]` with a `PlainValidator`/`PlainSerializer` pair so a model class could stand in for its own schema, which meant the published document carried an orphan `BaseModel` component and the value could not be read back. Build a `JsonSchema` with `JsonSchema.from_model(YourModel)` instead. ## [0.1.0] - 2025-12-18 diff --git a/stapi-pydantic/src/stapi_pydantic/__init__.py b/stapi-pydantic/src/stapi_pydantic/__init__.py index b06d7da..aa75f2c 100644 --- a/stapi-pydantic/src/stapi_pydantic/__init__.py +++ b/stapi-pydantic/src/stapi_pydantic/__init__.py @@ -16,6 +16,7 @@ Prefer, ) from .order import ( + BaseOrderParameters, Order, OrderCollection, OrderParameters, @@ -34,6 +35,7 @@ __all__ = [ "Geometry", + "BaseOrderParameters", "BoundedDatetimeInterval", "Conformance", "CQL2Filter", diff --git a/stapi-pydantic/src/stapi_pydantic/order.py b/stapi-pydantic/src/stapi_pydantic/order.py index ccf05fd..a08845f 100644 --- a/stapi-pydantic/src/stapi_pydantic/order.py +++ b/stapi-pydantic/src/stapi_pydantic/order.py @@ -19,18 +19,24 @@ from .datetime_interval import DatetimeInterval from .filter import CQL2Filter from .geometry import Geometry -from .opportunity import OpportunityProperties from .shared import Link -Props = TypeVar("Props", bound=dict[str, Any] | BaseModel) -Geom = TypeVar("Geom", bound=Geometry) +class BaseOrderParameters(BaseModel): + """Minimum-expectations type for order parameters at rest. + + Permissive so stored parameters from any product round-trip. + """ + + model_config = ConfigDict(extra="allow") + + +class OrderParameters(BaseOrderParameters): + """Boundary base for product-specific order parameters (strict).""" -class OrderParameters(BaseModel): model_config = ConfigDict(extra="forbid") -OPP = TypeVar("OPP", bound=OpportunityProperties) ORP = TypeVar("ORP", bound=OrderParameters) diff --git a/stapi-pydantic/tests/test_order.py b/stapi-pydantic/tests/test_order.py index 83c4778..3383237 100644 --- a/stapi-pydantic/tests/test_order.py +++ b/stapi-pydantic/tests/test_order.py @@ -1,6 +1,8 @@ import datetime -from stapi_pydantic import OrderStatus, OrderStatusCode +import pydantic +import pytest +from stapi_pydantic import BaseOrderParameters, OrderParameters, OrderStatus, OrderStatusCode def test_order_status_new() -> None: @@ -10,3 +12,19 @@ def test_order_status_new() -> None: assert status.reason_code is None assert status.reason_text is None assert status.links == [] + + +class RequiredParams(OrderParameters): + delivery_format: str + + +def test_concrete_order_parameters_are_base_order_parameters() -> None: + assert isinstance(RequiredParams(delivery_format="GEOTIFF"), BaseOrderParameters) + + # RequiredParams (via OrderParameters) forbids extra fields... + with pytest.raises(pydantic.ValidationError): + RequiredParams.model_validate({"delivery_format": "GEOTIFF", "unexpected_field": "value"}) + + # ...while BaseOrderParameters allows and preserves them. + base = BaseOrderParameters.model_validate({"unexpected_field": "value"}) + assert base.model_dump()["unexpected_field"] == "value" From ff463f5b274333969db5b1e0c5a197c745c347b1 Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 6 Aug 2026 17:38:56 -0700 Subject: [PATCH 12/48] feat!: compose SearchParameters into the opportunity and order requests `OpportunityPayload` and `OrderPayload` each declared `datetime`, `geometry` and `filter` at the top level, so the same spec object was defined twice and an opportunity search body was not a valid order request body. Both now carry a single `search_parameters`, and both are renamed to match the spec's names for them: `OpportunityRequest` and `OrderRequest`. `OrderRequest.order_parameters` becomes optional, defaulting to an empty object. A product whose `OrderParameters` declares required fields still rejects an omitted value, so it stays effectively required where it matters. `OpportunityRequest.limit` is `int | None` with the spec's lower bound of 1 and no default. It defaulted to 10, which asserted a page size the client never named. Callers passing `limit=0` now get a validation error, so the pagination tests drop that case. stapi-fastapi's backends, routers and fixtures follow, as does the search body pystapi-client builds. --- pystapi-client/src/pystapi_client/client.py | 22 +++++----- .../stapi_fastapi/backends/product_backend.py | 16 +++---- .../stapi_fastapi/routers/product_router.py | 20 ++++----- stapi-fastapi/tests/backends.py | 24 ++++++----- stapi-fastapi/tests/conftest.py | 24 ++++++----- stapi-fastapi/tests/test_opportunity.py | 2 +- stapi-fastapi/tests/test_opportunity_async.py | 31 ++----------- stapi-fastapi/tests/test_order.py | 29 +++++++------ stapi-pydantic/CHANGELOG.md | 6 ++- stapi-pydantic/src/stapi_pydantic/__init__.py | 8 ++-- .../src/stapi_pydantic/opportunity.py | 27 ++++++++---- stapi-pydantic/src/stapi_pydantic/order.py | 15 ++++--- stapi-pydantic/tests/test_opportunity.py | 43 ++++++++++++++++--- stapi-pydantic/tests/test_order.py | 23 +++++++++- 14 files changed, 174 insertions(+), 116 deletions(-) diff --git a/pystapi-client/src/pystapi_client/client.py b/pystapi-client/src/pystapi_client/client.py index b77b961..68cc909 100644 --- a/pystapi-client/src/pystapi_client/client.py +++ b/pystapi-client/src/pystapi_client/client.py @@ -14,10 +14,10 @@ Link, Opportunity, OpportunityCollection, - OpportunityPayload, + OpportunityRequest, Order, OrderCollection, - OrderPayload, + OrderRequest, Product, ProductCollection, ) @@ -316,14 +316,16 @@ def get_product_opportunities( """ product_opportunities_endpoint = self._get_products_href(product_id, subpath="opportunities") - opportunity_parameters = OpportunityPayload.model_validate( + opportunity_parameters = OpportunityRequest.model_validate( { - "datetime": ( - datetime.fromisoformat(date_range[0]), - datetime.fromisoformat(date_range[1]), - ), - "geometry": geometry, - "filter": cql2_filter, + "search_parameters": { + "datetime": ( + datetime.fromisoformat(date_range[0]), + datetime.fromisoformat(date_range[1]), + ), + "geometry": geometry, + "filter": cql2_filter, + }, "limit": limit, } ) @@ -348,7 +350,7 @@ def get_product_opportunities( for opportunity_collection in product_opportunities_json: yield from OpportunityCollection.model_validate(opportunity_collection).features - def create_product_order(self, product_id: str, order_parameters: OrderPayload) -> Order: # type: ignore[type-arg] + def create_product_order(self, product_id: str, order_parameters: OrderRequest) -> Order: # type: ignore[type-arg] # TODO Update return type after the pydantic model generic type is fixed """Create an order for a product diff --git a/stapi-fastapi/src/stapi_fastapi/backends/product_backend.py b/stapi-fastapi/src/stapi_fastapi/backends/product_backend.py index aa74510..8698d64 100644 --- a/stapi-fastapi/src/stapi_fastapi/backends/product_backend.py +++ b/stapi-fastapi/src/stapi_fastapi/backends/product_backend.py @@ -9,16 +9,16 @@ from stapi_pydantic import ( Opportunity, OpportunityCollection, - OpportunityPayload, + OpportunityRequest, OpportunitySearchRecord, Order, - OrderPayload, + OrderRequest, ) from stapi_fastapi.routers.product_router import ProductRouter SearchOpportunities = Callable[ - [ProductRouter, OpportunityPayload, str | None, int, Request], + [ProductRouter, OpportunityRequest, str | None, int, Request], Coroutine[Any, Any, ResultE[tuple[list[Opportunity], Maybe[str]]]], # type: ignore ] """ @@ -27,7 +27,7 @@ Args: product_router (ProductRouter): The product router. - search (OpportunityPayload): The search parameters. + search (OpportunityRequest): The search parameters. next (str | None): A pagination token. limit (int): The maximum number of opportunities to return in a page. request (Request): FastAPI's Request object. @@ -47,7 +47,7 @@ """ SearchOpportunitiesAsync = Callable[ - [ProductRouter, OpportunityPayload, Request], + [ProductRouter, OpportunityRequest, Request], Coroutine[Any, Any, ResultE[OpportunitySearchRecord]], ] """ @@ -56,7 +56,7 @@ Args: product_router (ProductRouter): The product router. - search (OpportunityPayload): The search parameters. + search (OpportunityRequest): The search parameters. request (Request): FastAPI's Request object. Returns: @@ -90,13 +90,13 @@ - Returning returns.result.Failure[Exception] will result in a 500. """ -CreateOrder = Callable[[ProductRouter, OrderPayload, Request], Coroutine[Any, Any, ResultE[Order]]] # type: ignore +CreateOrder = Callable[[ProductRouter, OrderRequest, Request], Coroutine[Any, Any, ResultE[Order]]] # type: ignore """ Type alias for an async function that creates a new order. Args: product_router (ProductRouter): The product router. - payload (OrderPayload): The order payload. + payload (OrderRequest): The order payload. request (Request): FastAPI's Request object. Returns: diff --git a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py index c37a820..6cd73e4 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py @@ -21,10 +21,10 @@ JsonSchema, Link, OpportunityCollection, - OpportunityPayload, + OpportunityRequest, OpportunitySearchRecord, Order, - OrderPayload, + OrderRequest, OrderStatus, Prefer, ) @@ -144,13 +144,13 @@ def __init__( # noqa # the annotation on every `ProductRouter` instance's `create_order`, not just # this one's. async def _create_order( - payload: OrderPayload, # type: ignore + payload: OrderRequest, # type: ignore request: Request, response: Response, ) -> Order[OrderStatus]: return await self.create_order(payload, request, response) - _create_order.__annotations__["payload"] = OrderPayload[ + _create_order.__annotations__["payload"] = OrderRequest[ self.product.order_parameters # type: ignore ] @@ -233,7 +233,7 @@ def get_product(self, request: Request) -> ProductPydantic: async def search_opportunities( self, - search: OpportunityPayload, + search: OpportunityRequest, request: Request, response: Response, prefer: Prefer | None = Depends(get_prefer), @@ -264,7 +264,7 @@ async def search_opportunities( async def search_opportunities_sync( self, - search: OpportunityPayload, + search: OpportunityRequest, request: Request, response: Response, prefer: Prefer | None, @@ -305,7 +305,7 @@ async def search_opportunities_sync( async def search_opportunities_async( self, - search: OpportunityPayload, + search: OpportunityRequest, request: Request, prefer: Prefer | None, ) -> JSONResponse: @@ -355,7 +355,7 @@ def get_product_order_parameters(self) -> JsonSchema: """ return JsonSchema.from_model(self.product.order_parameters) - async def create_order(self, payload: OrderPayload, request: Request, response: Response) -> Order: # type: ignore + async def create_order(self, payload: OrderRequest, request: Request, response: Response) -> Order: # type: ignore """ Create a new order. """ @@ -383,7 +383,7 @@ async def create_order(self, payload: OrderPayload, request: Request, response: case x: raise AssertionError(f"Expected code to be unreachable {x}") - def order_link(self, request: Request, opp_req: OpportunityPayload) -> Link: + def order_link(self, request: Request, opp_req: OpportunityRequest) -> Link: return Link( href=self.url_for(request, f"{self.root_router.name}:{self.product.id}:{CREATE_ORDER}"), rel="create-order", @@ -392,7 +392,7 @@ def order_link(self, request: Request, opp_req: OpportunityPayload) -> Link: body=opp_req.search_body(), ) - def pagination_link(self, request: Request, opp_req: OpportunityPayload, pagination_token: str) -> Link: + def pagination_link(self, request: Request, opp_req: OpportunityRequest, pagination_token: str) -> Link: body = opp_req.body() body["next"] = pagination_token return Link( diff --git a/stapi-fastapi/tests/backends.py b/stapi-fastapi/tests/backends.py index f902fb1..fbf2140 100644 --- a/stapi-fastapi/tests/backends.py +++ b/stapi-fastapi/tests/backends.py @@ -8,13 +8,13 @@ from stapi_pydantic import ( Opportunity, OpportunityCollection, - OpportunityPayload, + OpportunityRequest, OpportunitySearchRecord, OpportunitySearchStatus, OpportunitySearchStatusCode, Order, - OrderPayload, OrderProperties, + OrderRequest, OrderSearchParameters, OrderStatus, OrderStatusCode, @@ -80,7 +80,7 @@ async def mock_get_order_statuses( return Failure(e) -async def mock_create_order(product_router: ProductRouter, payload: OrderPayload, request: Request) -> ResultE[Order]: +async def mock_create_order(product_router: ProductRouter, payload: OrderRequest, request: Request) -> ResultE[Order]: """ Create a new order. """ @@ -91,15 +91,15 @@ async def mock_create_order(product_router: ProductRouter, payload: OrderPayload ) order = Order( id=str(uuid4()), - geometry=payload.geometry, + geometry=payload.search_parameters.geometry, properties=OrderProperties( product_id=product_router.product.id, created=datetime.now(UTC), status=status, search_parameters=OrderSearchParameters( - geometry=payload.geometry, - datetime=payload.datetime, - filter=payload.filter, + geometry=payload.search_parameters.geometry, + datetime=payload.search_parameters.datetime, + filter=payload.search_parameters.filter, ), order_parameters=payload.order_parameters.model_dump(), opportunity_properties={ @@ -119,7 +119,7 @@ async def mock_create_order(product_router: ProductRouter, payload: OrderPayload async def mock_search_opportunities( product_router: ProductRouter, - search: OpportunityPayload, + search: OpportunityRequest, next: str | None, limit: int, request: Request, @@ -130,7 +130,11 @@ async def mock_search_opportunities( if next: start = int(next) end = start + limit - opportunities = [o.model_copy(update=search.model_dump()) for o in request.state._opportunities[start:end]] + # Reflect the searched geometry into the returned opportunities. + opportunities = [ + o.model_copy(update={"geometry": search.search_parameters.geometry}) + for o in request.state._opportunities[start:end] + ] if end > 0 and end < len(request.state._opportunities): return Success((opportunities, Some(str(end)))) return Success((opportunities, Nothing)) @@ -140,7 +144,7 @@ async def mock_search_opportunities( async def mock_search_opportunities_async( product_router: ProductRouter, - search: OpportunityPayload, + search: OpportunityRequest, request: Request, ) -> ResultE[OpportunitySearchRecord]: try: diff --git a/stapi-fastapi/tests/conftest.py b/stapi-fastapi/tests/conftest.py index a041f8c..0363c08 100644 --- a/stapi-fastapi/tests/conftest.py +++ b/stapi-fastapi/tests/conftest.py @@ -176,17 +176,19 @@ def opportunity_search(limit) -> dict[str, Any]: end_string = rfc3339_strftime(end, format) return { - "geometry": { - "type": "Point", - "coordinates": [0, 0], - }, - "datetime": f"{start_string}/{end_string}", - "filter": { - "op": "and", - "args": [ - {"op": ">", "args": [{"property": "off_nadir"}, 0]}, - {"op": "<", "args": [{"property": "off_nadir"}, 45]}, - ], + "search_parameters": { + "geometry": { + "type": "Point", + "coordinates": [0, 0], + }, + "datetime": f"{start_string}/{end_string}", + "filter": { + "op": "and", + "args": [ + {"op": ">", "args": [{"property": "off_nadir"}, 0]}, + {"op": "<", "args": [{"property": "off_nadir"}, 45]}, + ], + }, }, "limit": limit, } diff --git a/stapi-fastapi/tests/test_opportunity.py b/stapi-fastapi/tests/test_opportunity.py index cffe525..c9b4bfb 100644 --- a/stapi-fastapi/tests/test_opportunity.py +++ b/stapi-fastapi/tests/test_opportunity.py @@ -33,7 +33,7 @@ def test_search_opportunities_response(stapi_client: TestClient, assert_link, op ) -@pytest.mark.parametrize("limit", [0, 1, 2, 4]) +@pytest.mark.parametrize("limit", [1, 2, 4]) def test_search_opportunities_pagination( limit: int, stapi_client: TestClient, diff --git a/stapi-fastapi/tests/test_opportunity_async.py b/stapi-fastapi/tests/test_opportunity_async.py index ea34eb1..0432fb5 100644 --- a/stapi-fastapi/tests/test_opportunity_async.py +++ b/stapi-fastapi/tests/test_opportunity_async.py @@ -1,5 +1,5 @@ from collections.abc import Callable -from datetime import UTC, datetime, timedelta +from datetime import UTC, datetime from typing import Any from uuid import uuid4 @@ -23,7 +23,6 @@ product_test_spotlight_sync_async_opportunity, product_test_spotlight_sync_opportunity, ) -from .test_datetime_interval import rfc3339_strftime @pytest.mark.mock_products([product_test_spotlight]) @@ -282,34 +281,12 @@ def test_bad_ids(stapi_client_async_opportunity: TestClient) -> None: @pytest.fixture def setup_search_record_pagination( stapi_client_async_opportunity: TestClient, + opportunity_search: dict[str, Any], ) -> list[dict[str, Any]]: product_id = "test-spotlight" search_records = [] for _ in range(3): - now = datetime.now(UTC) - end = now + timedelta(days=5) - format = "%Y-%m-%dT%H:%M:%S.%f%z" - start_string = rfc3339_strftime(now, format) - end_string = rfc3339_strftime(end, format) - - opportunity_request = { - "geometry": { - "type": "Point", - "coordinates": [0, 0], - }, - "datetime": f"{start_string}/{end_string}", - "filter": { - "op": "and", - "args": [ - {"op": ">", "args": [{"property": "off_nadir"}, 0]}, - {"op": "<", "args": [{"property": "off_nadir"}, 45]}, - ], - }, - } - - response = stapi_client_async_opportunity.post( - f"/products/{product_id}/opportunities", json=opportunity_request - ) + response = stapi_client_async_opportunity.post(f"/products/{product_id}/opportunities", json=opportunity_search) assert response.status_code == 201 body = response.json() @@ -318,7 +295,7 @@ def setup_search_record_pagination( return search_records -@pytest.mark.parametrize("limit", [0, 1, 2, 4]) +@pytest.mark.parametrize("limit", [1, 2, 4]) @pytest.mark.mock_products([product_test_spotlight_async_opportunity]) def test_get_search_records_pagination( stapi_client_async_opportunity: TestClient, diff --git a/stapi-fastapi/tests/test_order.py b/stapi-fastapi/tests/test_order.py index d33291c..c0c1395 100644 --- a/stapi-fastapi/tests/test_order.py +++ b/stapi-fastapi/tests/test_order.py @@ -6,7 +6,7 @@ from geojson_pydantic import Point from geojson_pydantic.types import Position2D from httpx import Response -from stapi_pydantic import Order, OrderPayload, OrderStatus, OrderStatusCode +from stapi_pydantic import Order, OrderRequest, OrderStatus, OrderStatusCode, SearchParameters from .shared import MyOrderParameters, find_link, pagination_tester @@ -23,7 +23,7 @@ def test_empty_order(stapi_client: TestClient): @pytest.fixture -def create_order_payloads() -> list[OrderPayload]: +def create_order_payloads() -> list[OrderRequest]: datetimes = [ ("2024-10-09T18:55:33Z", "2024-10-12T18:55:33Z"), ("2024-10-15T18:55:33Z", "2024-10-18T18:55:33Z"), @@ -31,13 +31,15 @@ def create_order_payloads() -> list[OrderPayload]: ] payloads = [] for start, end in datetimes: - payload = OrderPayload( - geometry=Point(type="Point", coordinates=Position2D(longitude=14.4, latitude=56.5)), - datetime=( - datetime.fromisoformat(start), - datetime.fromisoformat(end), + payload = OrderRequest( + search_parameters=SearchParameters( + geometry=Point(type="Point", coordinates=Position2D(longitude=14.4, latitude=56.5)), + datetime=( + datetime.fromisoformat(start), + datetime.fromisoformat(end), + ), + filter=None, ), - filter=None, order_parameters=MyOrderParameters(s3_path="s3://my-bucket"), ) payloads.append(payload) @@ -48,7 +50,7 @@ def create_order_payloads() -> list[OrderPayload]: def new_order_response( product_id: str, stapi_client: TestClient, - create_order_payloads: list[OrderPayload], + create_order_payloads: list[OrderRequest], ) -> Response: res = stapi_client.post( f"products/{product_id}/orders", @@ -105,15 +107,18 @@ def test_get_order_properties(get_order_response: Response, create_order_payload assert order["geometry"] == { "type": "Point", - "coordinates": list(create_order_payloads[0].geometry.coordinates), + "coordinates": list(create_order_payloads[0].search_parameters.geometry.coordinates), } assert order["properties"]["search_parameters"]["geometry"] == { "type": "Point", - "coordinates": list(create_order_payloads[0].geometry.coordinates), + "coordinates": list(create_order_payloads[0].search_parameters.geometry.coordinates), } - assert order["properties"]["search_parameters"]["datetime"] == create_order_payloads[0].model_dump()["datetime"] + assert ( + order["properties"]["search_parameters"]["datetime"] + == create_order_payloads[0].search_parameters.model_dump(mode="json")["datetime"] + ) @pytest.mark.parametrize("product_id", ["test-spotlight"]) diff --git a/stapi-pydantic/CHANGELOG.md b/stapi-pydantic/CHANGELOG.md index f21d4d3..e7455ba 100644 --- a/stapi-pydantic/CHANGELOG.md +++ b/stapi-pydantic/CHANGELOG.md @@ -26,7 +26,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - `STAPI_VERSION` is `0.2.0`. - **BREAKING** `DatetimeInterval` now denotes the general interval, which may be open on one end via `..` or an empty string, and validates as a `tuple[AwareDatetime | None, AwareDatetime | None]`. The both-ends-bounded form is now `BoundedDatetimeInterval`. Code that relied on `DatetimeInterval` rejecting open ends, or on both tuple members being non-`None`, must switch to `BoundedDatetimeInterval`. - **BREAKING** `Geometry` is the six-member STAPI union and no longer includes `GeometryCollection`. The spec enumerates exactly six geometry conformance classes, so a `GeometryCollection` was a value no implementation could declare support for. Import `geojson_pydantic.geometries.Geometry` directly if you need the wider union. - - `CQL2Filter` is typed as `dict[str, Any]` rather than a bare `dict`. - **BREAKING** `Link` omits its unset fields from `model_dump()` as well as from JSON output. The `None`-filtering serializer it previously carried applied only to JSON dumps. - **BREAKING** Models that declare aliases now serialize by alias. `model_dump()` emits `conformsTo` on `Conformance` and `type` on `Product`, where it previously emitted the Python field names `conforms_to` and `type_`. Callers already passing `by_alias=True` are unaffected; callers reading the Python names out of a dump must switch to the wire names. @@ -35,6 +34,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - Spec-REQUIRED fields that carry defaults (`type`, `stapi_type`, `stapi_version`, `links`, `conformsTo`, and so on) are now marked required in the serialization JSON Schema, since they are always present in a response. - **BREAKING** `ProductsCollection` is renamed to `ProductCollection`, matching its own `stapi_type` and the spec. The old name is gone rather than aliased; update imports. The model also replaces its aliased `type` field with `stapi_type`, so responses carry `"stapi_type": "ProductCollection"` rather than `"type": "ProductCollection"`, and gains `stapi_version`. - **BREAKING** `Product.description` is required, per the spec. It previously defaulted to the empty string. +- **BREAKING** `OpportunityRequest` (was `OpportunityPayload`) and `OrderRequest` (was `OrderPayload`) now compose `SearchParameters` instead of declaring `datetime`, `geometry`, and `filter` themselves. A request body that was `{"datetime": ..., "geometry": ..., "filter": ...}` becomes `{"search_parameters": {"datetime": ..., "geometry": ..., "filter": ...}}`. +- **BREAKING** `OrderRequest.order_parameters` is optional and defaults to an empty object. It was previously required. Products whose `OrderParameters` model has required fields still make it effectively required, via validation. +- **BREAKING** `OpportunityRequest.limit` is `int | None` with a lower bound of 1, and defaults to `None`. It defaulted to 10, which asserted a page size the client never asked for; the default is the server's to choose. ### Fixed @@ -42,7 +44,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Removed -- **BREAKING** The pre-0.2.0 compatibility alias `ProductsCollection` is gone. Use `ProductCollection`. +- **BREAKING** The pre-0.2.0 compatibility aliases `ProductsCollection`, `OrderPayload` and `OpportunityPayload` are gone. Use `ProductCollection`, `OrderRequest` and `OpportunityRequest`. - The unused `Props`, `Geom`, and `OPP` type variables in `stapi_pydantic.order`. - **BREAKING** `JsonSchemaModel` is gone. It annotated a `type[BaseModel]` with a `PlainValidator`/`PlainSerializer` pair so a model class could stand in for its own schema, which meant the published document carried an orphan `BaseModel` component and the value could not be read back. Build a `JsonSchema` with `JsonSchema.from_model(YourModel)` instead. diff --git a/stapi-pydantic/src/stapi_pydantic/__init__.py b/stapi-pydantic/src/stapi_pydantic/__init__.py index aa75f2c..e466c95 100644 --- a/stapi-pydantic/src/stapi_pydantic/__init__.py +++ b/stapi-pydantic/src/stapi_pydantic/__init__.py @@ -7,8 +7,8 @@ from .opportunity import ( Opportunity, OpportunityCollection, - OpportunityPayload, OpportunityProperties, + OpportunityRequest, OpportunitySearchRecord, OpportunitySearchRecords, OpportunitySearchStatus, @@ -20,8 +20,8 @@ Order, OrderCollection, OrderParameters, - OrderPayload, OrderProperties, + OrderRequest, OrderSearchParameters, OrderStatus, OrderStatusCode, @@ -44,7 +44,7 @@ "Link", "Opportunity", "OpportunityCollection", - "OpportunityPayload", + "OpportunityRequest", "OpportunityProperties", "OpportunitySearchRecord", "OpportunitySearchRecords", @@ -53,7 +53,7 @@ "Order", "OrderCollection", "OrderParameters", - "OrderPayload", + "OrderRequest", "OrderProperties", "OrderSearchParameters", "OrderStatus", diff --git a/stapi-pydantic/src/stapi_pydantic/opportunity.py b/stapi-pydantic/src/stapi_pydantic/opportunity.py index 454bf70..d0f5dcd 100644 --- a/stapi-pydantic/src/stapi_pydantic/opportunity.py +++ b/stapi-pydantic/src/stapi_pydantic/opportunity.py @@ -4,9 +4,9 @@ from geojson_pydantic import Feature, FeatureCollection from pydantic import AwareDatetime, BaseModel, ConfigDict, Field -from .datetime_interval import BoundedDatetimeInterval, DatetimeInterval -from .filter import CQL2Filter +from .datetime_interval import BoundedDatetimeInterval from .geometry import Geometry +from .search_parameters import SearchParameters from .shared import Link @@ -17,18 +17,27 @@ class OpportunityProperties(BaseModel): model_config = ConfigDict(extra="allow") -class OpportunityPayload(BaseModel): - datetime: DatetimeInterval - geometry: Geometry - filter: CQL2Filter | None = None # type: ignore [type-arg] +class OpportunityRequest(BaseModel): + """STAPI Opportunity Request Object. + + Carries the same ``search_parameters`` as an Order Request, which + additionally supplies ``order_parameters``. ``next`` and ``limit`` page a + search and have no Order Request equivalent. + """ + + search_parameters: SearchParameters next: str | None = None - limit: int = 10 + # No default page size: `None` means the request named none, leaving the + # server to supply its own, rather than a number this library picked. The + # lower bound is the spec's; it publishes no upper one, so a server clamps + # rather than rejects. + limit: int | None = Field(default=None, ge=1) model_config = ConfigDict(strict=True) def search_body(self) -> dict[str, Any]: - return self.model_dump(mode="json", include={"datetime", "geometry", "filter"}) + return self.model_dump(mode="json", include={"search_parameters"}) def body(self) -> dict[str, Any]: return self.model_dump(mode="json") @@ -68,7 +77,7 @@ class OpportunitySearchStatus(BaseModel): class OpportunitySearchRecord(BaseModel): id: str product_id: str - opportunity_request: OpportunityPayload + opportunity_request: OpportunityRequest status: OpportunitySearchStatus links: list[Link] = Field(default_factory=list) diff --git a/stapi-pydantic/src/stapi_pydantic/order.py b/stapi-pydantic/src/stapi_pydantic/order.py index a08845f..1c9a430 100644 --- a/stapi-pydantic/src/stapi_pydantic/order.py +++ b/stapi-pydantic/src/stapi_pydantic/order.py @@ -19,6 +19,7 @@ from .datetime_interval import DatetimeInterval from .filter import CQL2Filter from .geometry import Geometry +from .search_parameters import SearchParameters from .shared import Link @@ -152,12 +153,14 @@ def __getitem__(self, index: int) -> Order[T]: return self.features[index] -class OrderPayload(BaseModel, Generic[ORP]): - datetime: DatetimeInterval = Field(examples=["2018-02-12T00:00:00Z/2018-03-18T12:31:12Z"]) - geometry: Geometry - # TODO: validate the CQL2 filter? - filter: CQL2Filter | None = None # type: ignore [type-arg] +class OrderRequest(BaseModel, Generic[ORP]): + """STAPI Order Request Object. + + An omitted order_parameters is equivalent to an empty object, so products + with required order parameters make the field effectively required. + """ - order_parameters: ORP + search_parameters: SearchParameters + order_parameters: ORP = Field(default_factory=dict, validate_default=True) model_config = ConfigDict(strict=True) diff --git a/stapi-pydantic/tests/test_opportunity.py b/stapi-pydantic/tests/test_opportunity.py index 922e9dd..af35915 100644 --- a/stapi-pydantic/tests/test_opportunity.py +++ b/stapi-pydantic/tests/test_opportunity.py @@ -1,7 +1,40 @@ -from stapi_pydantic import OpportunityProperties +from stapi_pydantic import ( + OpportunityRequest, + OrderParameters, + OrderRequest, +) +SEARCH_PARAMS = { + "datetime": "2024-04-18T10:56:00Z/2024-04-25T10:56:00Z", + "geometry": {"type": "Point", "coordinates": [13.4, 52.5]}, +} -def test_create_properties() -> None: - _ = OpportunityProperties.model_validate( - {"datetime": "2025-04-01T00:00:00Z/2025-04-01T23:59:59Z", "product_id": "foo"} - ) + +def test_opportunity_request_shape() -> None: + """An unspecified page size stays unspecified: the default is the server's to + choose, not the model's. + """ + req = OpportunityRequest.model_validate({"search_parameters": SEARCH_PARAMS}) + assert req.limit is None + assert req.next is None + + +def test_opportunity_request_search_body_is_order_request_shaped() -> None: + req = OpportunityRequest.model_validate({"search_parameters": SEARCH_PARAMS}) + body = req.search_body() + assert set(body) == {"search_parameters"} + assert body["search_parameters"]["geometry"]["type"] == "Point" + + +def test_search_body_is_valid_order_request() -> None: + req = OpportunityRequest.model_validate({"search_parameters": SEARCH_PARAMS}) + order_request = OrderRequest[OrderParameters].model_validate(req.search_body()) + assert order_request.search_parameters == req.search_parameters + + +def test_opportunity_request_body_includes_pagination() -> None: + req = OpportunityRequest.model_validate({"search_parameters": SEARCH_PARAMS, "next": "abc", "limit": 5}) + body = req.body() + assert body["next"] == "abc" + assert body["limit"] == 5 + assert "search_parameters" in body diff --git a/stapi-pydantic/tests/test_order.py b/stapi-pydantic/tests/test_order.py index 3383237..6b90a17 100644 --- a/stapi-pydantic/tests/test_order.py +++ b/stapi-pydantic/tests/test_order.py @@ -2,7 +2,7 @@ import pydantic import pytest -from stapi_pydantic import BaseOrderParameters, OrderParameters, OrderStatus, OrderStatusCode +from stapi_pydantic import BaseOrderParameters, OrderParameters, OrderRequest, OrderStatus, OrderStatusCode def test_order_status_new() -> None: @@ -14,6 +14,12 @@ def test_order_status_new() -> None: assert status.links == [] +SEARCH_PARAMS = { + "datetime": "2024-04-18T10:56:00Z/2024-04-25T10:56:00Z", + "geometry": {"type": "Point", "coordinates": [13.4, 52.5]}, +} + + class RequiredParams(OrderParameters): delivery_format: str @@ -28,3 +34,18 @@ def test_concrete_order_parameters_are_base_order_parameters() -> None: # ...while BaseOrderParameters allows and preserves them. base = BaseOrderParameters.model_validate({"unexpected_field": "value"}) assert base.model_dump()["unexpected_field"] == "value" + + +def test_order_request_shape() -> None: + req = OrderRequest[OrderParameters].model_validate({"search_parameters": SEARCH_PARAMS, "order_parameters": {}}) + assert req.search_parameters.filter is None + + +def test_order_request_omitted_order_parameters_is_empty_object() -> None: + req = OrderRequest[OrderParameters].model_validate({"search_parameters": SEARCH_PARAMS}) + assert req.order_parameters == OrderParameters() + + +def test_order_request_omitted_order_parameters_fails_when_required() -> None: + with pytest.raises(pydantic.ValidationError): + OrderRequest[RequiredParams].model_validate({"search_parameters": SEARCH_PARAMS}) From 3c6c9446207046d4d5bbdcfccaf11f78b436d48f Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 6 Aug 2026 17:40:58 -0700 Subject: [PATCH 13/48] feat!: record an order's request as a single StoredOrderRequest `OrderProperties` carried the request in three separate fields: `search_parameters` (its own near-copy of the search object), `opportunity_properties`, and an untyped `order_parameters` dict. Nothing tied them together, and `opportunity_properties` had no counterpart in a request at all. They collapse into one `order_request`, a `StoredOrderRequest` holding the `search_parameters` the client sent alongside its `order_parameters`. The stored parameters are typed `BaseOrderParameters`, not `OrderParameters`: a persisted order can no longer be validated against the product's strict model, and permissiveness is what lets provider extension fields survive the round trip. Both the stored request and its search parameters preserve unknown fields, which the old untyped dict did only by accident. `OrderSearchParameters` is gone; `SearchParameters` is the one definition. --- stapi-fastapi/tests/backends.py | 16 ++-- stapi-fastapi/tests/test_order.py | 4 +- stapi-pydantic/CHANGELOG.md | 6 +- stapi-pydantic/src/stapi_pydantic/__init__.py | 4 +- stapi-pydantic/src/stapi_pydantic/order.py | 29 +++---- stapi-pydantic/tests/test_order.py | 76 ++++++++++++++++++- 6 files changed, 104 insertions(+), 31 deletions(-) diff --git a/stapi-fastapi/tests/backends.py b/stapi-fastapi/tests/backends.py index fbf2140..bf59e35 100644 --- a/stapi-fastapi/tests/backends.py +++ b/stapi-fastapi/tests/backends.py @@ -15,9 +15,9 @@ Order, OrderProperties, OrderRequest, - OrderSearchParameters, OrderStatus, OrderStatusCode, + StoredOrderRequest, ) @@ -96,16 +96,12 @@ async def mock_create_order(product_router: ProductRouter, payload: OrderRequest product_id=product_router.product.id, created=datetime.now(UTC), status=status, - search_parameters=OrderSearchParameters( - geometry=payload.search_parameters.geometry, - datetime=payload.search_parameters.datetime, - filter=payload.search_parameters.filter, + order_request=StoredOrderRequest( + search_parameters=payload.search_parameters, + # declared as BaseOrderParameters; pydantic validates the + # dumped dict into one at runtime + order_parameters=payload.order_parameters.model_dump(), # type: ignore[arg-type] ), - order_parameters=payload.order_parameters.model_dump(), - opportunity_properties={ - "datetime": "2024-01-29T12:00:00Z/2024-01-30T12:00:00Z", - "off_nadir": 10, - }, ), links=[], ) diff --git a/stapi-fastapi/tests/test_order.py b/stapi-fastapi/tests/test_order.py index c0c1395..815da88 100644 --- a/stapi-fastapi/tests/test_order.py +++ b/stapi-fastapi/tests/test_order.py @@ -110,13 +110,13 @@ def test_get_order_properties(get_order_response: Response, create_order_payload "coordinates": list(create_order_payloads[0].search_parameters.geometry.coordinates), } - assert order["properties"]["search_parameters"]["geometry"] == { + assert order["properties"]["order_request"]["search_parameters"]["geometry"] == { "type": "Point", "coordinates": list(create_order_payloads[0].search_parameters.geometry.coordinates), } assert ( - order["properties"]["search_parameters"]["datetime"] + order["properties"]["order_request"]["search_parameters"]["datetime"] == create_order_payloads[0].search_parameters.model_dump(mode="json")["datetime"] ) diff --git a/stapi-pydantic/CHANGELOG.md b/stapi-pydantic/CHANGELOG.md index e7455ba..910a544 100644 --- a/stapi-pydantic/CHANGELOG.md +++ b/stapi-pydantic/CHANGELOG.md @@ -18,7 +18,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - `cql2_property_names`, which collects the property names referenced by a CQL2 JSON filter. - `SearchParameters`, the Search Parameters Object (`datetime`, `geometry`, `filter`) shared by the Opportunity Request and the Order Request. It permits extra fields, so provider extension parameters round-trip instead of being dropped. - `ProductCollection`, the new name for `ProductsCollection` (see Changed). -- `BaseOrderParameters`, a permissive base for order parameters at rest. `OrderParameters` is now a strict (`extra="forbid"`) subclass of it. +- `BaseOrderParameters`, a permissive base for order parameters at rest, and `StoredOrderRequest`, the form an Order Request takes once it is persisted inside `OrderProperties`. `OrderParameters` is now a strict (`extra="forbid"`) subclass of `BaseOrderParameters`. - `STAPI_VERSION` is now exported from the package root. ### Changed @@ -36,15 +36,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **BREAKING** `Product.description` is required, per the spec. It previously defaulted to the empty string. - **BREAKING** `OpportunityRequest` (was `OpportunityPayload`) and `OrderRequest` (was `OrderPayload`) now compose `SearchParameters` instead of declaring `datetime`, `geometry`, and `filter` themselves. A request body that was `{"datetime": ..., "geometry": ..., "filter": ...}` becomes `{"search_parameters": {"datetime": ..., "geometry": ..., "filter": ...}}`. - **BREAKING** `OrderRequest.order_parameters` is optional and defaults to an empty object. It was previously required. Products whose `OrderParameters` model has required fields still make it effectively required, via validation. +- **BREAKING** `OrderProperties` carries a single `order_request` (a `StoredOrderRequest`) in place of the former `search_parameters`, `opportunity_properties`, and `order_parameters` fields. - **BREAKING** `OpportunityRequest.limit` is `int | None` with a lower bound of 1, and defaults to `None`. It defaulted to 10, which asserted a page size the client never asked for; the default is the server's to choose. ### Fixed +- Stored order requests and search parameters round-trip unknown fields rather than dropping them. - A malformed CQL2 filter is reported as a validation error. `cql2` raises its own exception types, which pydantic does not convert, so an invalid filter escaped validation and surfaced as a server error rather than a rejected request. ### Removed -- **BREAKING** The pre-0.2.0 compatibility aliases `ProductsCollection`, `OrderPayload` and `OpportunityPayload` are gone. Use `ProductCollection`, `OrderRequest` and `OpportunityRequest`. +- **BREAKING** The pre-0.2.0 compatibility aliases `ProductsCollection`, `OrderPayload`, `OpportunityPayload` and `OrderSearchParameters` are gone. Use `ProductCollection`, `OrderRequest`, `OpportunityRequest` and `SearchParameters`. - The unused `Props`, `Geom`, and `OPP` type variables in `stapi_pydantic.order`. - **BREAKING** `JsonSchemaModel` is gone. It annotated a `type[BaseModel]` with a `PlainValidator`/`PlainSerializer` pair so a model class could stand in for its own schema, which meant the published document carried an orphan `BaseModel` component and the value could not be read back. Build a `JsonSchema` with `JsonSchema.from_model(YourModel)` instead. diff --git a/stapi-pydantic/src/stapi_pydantic/__init__.py b/stapi-pydantic/src/stapi_pydantic/__init__.py index e466c95..26fc402 100644 --- a/stapi-pydantic/src/stapi_pydantic/__init__.py +++ b/stapi-pydantic/src/stapi_pydantic/__init__.py @@ -22,10 +22,10 @@ OrderParameters, OrderProperties, OrderRequest, - OrderSearchParameters, OrderStatus, OrderStatusCode, OrderStatuses, + StoredOrderRequest, ) from .product import Product, ProductCollection, Provider, ProviderRole from .queryables import Queryables @@ -55,7 +55,6 @@ "OrderParameters", "OrderRequest", "OrderProperties", - "OrderSearchParameters", "OrderStatus", "OrderStatusCode", "OrderStatuses", @@ -67,6 +66,7 @@ "Queryables", "RootResponse", "SearchParameters", + "StoredOrderRequest", "STAPI_VERSION", "cql2_property_names", ] diff --git a/stapi-pydantic/src/stapi_pydantic/order.py b/stapi-pydantic/src/stapi_pydantic/order.py index 1c9a430..f1e43ff 100644 --- a/stapi-pydantic/src/stapi_pydantic/order.py +++ b/stapi-pydantic/src/stapi_pydantic/order.py @@ -16,11 +16,9 @@ ) from .constants import STAPI_VERSION -from .datetime_interval import DatetimeInterval -from .filter import CQL2Filter from .geometry import Geometry from .search_parameters import SearchParameters -from .shared import Link +from .shared import STAPI_RESPONSE_CONFIG_ALLOW_EXTRA, Link class BaseOrderParameters(BaseModel): @@ -87,23 +85,26 @@ class OrderStatuses(BaseModel, Generic[T]): links: list[Link] = Field(default_factory=list) -class OrderSearchParameters(BaseModel): - datetime: DatetimeInterval - geometry: Geometry - # TODO: validate the CQL2 filter? - filter: CQL2Filter | None = None # type: ignore [type-arg] +class StoredOrderRequest(BaseModel): + """Stored form of an Order Request within Order properties. + + order_parameters is typed as BaseOrderParameters because a persisted order + can no longer be validated against a product's strict OrderParameters model. + """ + + model_config = STAPI_RESPONSE_CONFIG_ALLOW_EXTRA + + search_parameters: SearchParameters + order_parameters: BaseOrderParameters = Field(default_factory=BaseOrderParameters) class OrderProperties(BaseModel, Generic[T]): + model_config = STAPI_RESPONSE_CONFIG_ALLOW_EXTRA + product_id: str created: AwareDatetime status: T - - search_parameters: OrderSearchParameters - opportunity_properties: dict[str, Any] - order_parameters: dict[str, Any] - - model_config = ConfigDict(extra="allow") + order_request: StoredOrderRequest # derived from geojson_pydantic.Feature diff --git a/stapi-pydantic/tests/test_order.py b/stapi-pydantic/tests/test_order.py index 6b90a17..ec03506 100644 --- a/stapi-pydantic/tests/test_order.py +++ b/stapi-pydantic/tests/test_order.py @@ -1,8 +1,17 @@ import datetime +from typing import Any import pydantic import pytest -from stapi_pydantic import BaseOrderParameters, OrderParameters, OrderRequest, OrderStatus, OrderStatusCode +from stapi_pydantic import ( + BaseOrderParameters, + Order, + OrderParameters, + OrderRequest, + OrderStatus, + OrderStatusCode, + StoredOrderRequest, +) def test_order_status_new() -> None: @@ -49,3 +58,68 @@ def test_order_request_omitted_order_parameters_is_empty_object() -> None: def test_order_request_omitted_order_parameters_fails_when_required() -> None: with pytest.raises(pydantic.ValidationError): OrderRequest[RequiredParams].model_validate({"search_parameters": SEARCH_PARAMS}) + + +ORDER_DICT: dict[str, Any] = { + "id": "order-1", + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [13.4, 52.5]}, + "properties": { + "product_id": "umbra_spotlight", + "created": "2024-04-10T09:15:00Z", + "status": { + "timestamp": "2024-04-10T09:15:00Z", + "status_code": "received", + "links": [], + }, + "order_request": {"search_parameters": SEARCH_PARAMS}, + "owner": {"organization": "ACME"}, + }, +} + + +def test_order_properties_order_request() -> None: + order = Order[OrderStatus].model_validate(ORDER_DICT) + assert order.properties.order_request.order_parameters == BaseOrderParameters() + assert order.properties.status.status_code == OrderStatusCode.received + + +def test_stored_order_parameters_preserve_provider_fields() -> None: + order_dict = { + **ORDER_DICT, + "properties": { + **ORDER_DICT["properties"], + "order_request": { + "search_parameters": SEARCH_PARAMS, + "order_parameters": {"deliveryFormat": "GEOTIFF"}, + }, + }, + } + order = Order[OrderStatus].model_validate(order_dict) + params = order.properties.order_request.order_parameters + assert isinstance(params, BaseOrderParameters) + assert params.model_dump()["deliveryFormat"] == "GEOTIFF" + + +def test_order_extra_properties_allowed() -> None: + order = Order[OrderStatus].model_validate(ORDER_DICT) + assert order.properties.model_dump()["owner"] == {"organization": "ACME"} + + +def test_stored_order_request_preserves_unknown_fields() -> None: + stored = StoredOrderRequest.model_validate({"search_parameters": SEARCH_PARAMS, "provider_extra": 1}) + assert stored.model_dump()["provider_extra"] == 1 + + +def test_search_parameters_preserve_unknown_fields() -> None: + order = Order[OrderStatus].model_validate( + { + **ORDER_DICT, + "properties": { + **ORDER_DICT["properties"], + "order_request": {"search_parameters": {**SEARCH_PARAMS, "vendor:priority": "high"}}, + }, + } + ) + dumped = order.model_dump(mode="json") + assert dumped["properties"]["order_request"]["search_parameters"]["vendor:priority"] == "high" From cd448b40f20805cb4e650c70e4599bc199f76f10 Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 6 Aug 2026 17:42:22 -0700 Subject: [PATCH 14/48] feat!: record what an opportunity search was for, not the request that carried it `OpportunitySearchRecord.opportunity_request` held the whole request object, so every record echoed back whatever `next` and `limit` the client happened to page with. Two records describing an identical search compared unequal if the clients paged differently, and the pagination state was meaningless once the search was done. It becomes `search_parameters`, the `SearchParameters` that were actually searched for. `OpportunitySearchRecords` is renamed `OpportunitySearchRecordCollection` to match its members, and holds them in `records` rather than `search_records`. Both models gain `stapi_type` and `stapi_version`. --- .../src/stapi_fastapi/routers/root_router.py | 6 ++-- stapi-fastapi/tests/backends.py | 2 +- stapi-fastapi/tests/test_opportunity_async.py | 6 ++-- stapi-pydantic/CHANGELOG.md | 4 ++- stapi-pydantic/src/stapi_pydantic/__init__.py | 4 +-- .../src/stapi_pydantic/opportunity.py | 17 ++++++++--- stapi-pydantic/tests/test_opportunity.py | 28 +++++++++++++++++++ 7 files changed, 53 insertions(+), 14 deletions(-) diff --git a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py index 5cfc256..255410b 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py @@ -10,7 +10,7 @@ Conformance, Link, OpportunitySearchRecord, - OpportunitySearchRecords, + OpportunitySearchRecordCollection, OpportunitySearchStatus, Order, OrderCollection, @@ -374,7 +374,7 @@ def pagination_link(self, request: Request, name: str, pagination_token: str, li async def get_opportunity_search_records( self, request: Request, next: str | None = None, limit: int = 10 - ) -> OpportunitySearchRecords: + ) -> OpportunitySearchRecordCollection: links: list[Link] = [] match await self._get_opportunity_search_records(next, limit, request): case Success((records, maybe_pagination_token)): @@ -402,7 +402,7 @@ async def get_opportunity_search_records( ) case _: raise AssertionError("Expected code to be unreachable") - return OpportunitySearchRecords(search_records=records, links=links) + return OpportunitySearchRecordCollection(records=records, links=links) async def get_opportunity_search_record(self, search_record_id: str, request: Request) -> OpportunitySearchRecord: """ diff --git a/stapi-fastapi/tests/backends.py b/stapi-fastapi/tests/backends.py index bf59e35..eb92ba1 100644 --- a/stapi-fastapi/tests/backends.py +++ b/stapi-fastapi/tests/backends.py @@ -151,7 +151,7 @@ async def mock_search_opportunities_async( search_record = OpportunitySearchRecord( id=str(uuid4()), product_id=product_router.product.id, - opportunity_request=search, + search_parameters=search.search_parameters, status=received_status, links=[], ) diff --git a/stapi-fastapi/tests/test_opportunity_async.py b/stapi-fastapi/tests/test_opportunity_async.py index 0432fb5..5082551 100644 --- a/stapi-fastapi/tests/test_opportunity_async.py +++ b/stapi-fastapi/tests/test_opportunity_async.py @@ -169,7 +169,7 @@ def test_async_search_record_retrieval( records_response = stapi_client_async_opportunity.get("/searches/opportunities") assert records_response.status_code == 200 records_response_body = records_response.json() - assert search_record_id in [x["id"] for x in records_response_body["search_records"]] + assert search_record_id in [x["id"] for x in records_response_body["records"]] @pytest.mark.mock_products([product_test_spotlight_async_opportunity]) @@ -195,7 +195,7 @@ def test_async_opportunity_search_to_completion( Link( rel="create-order", href=url_for(f"/products/{product_id}/orders"), - body=search_record.opportunity_request.model_dump(), + body=search_record.search_parameters.model_dump(), method="POST", ) ) @@ -311,6 +311,6 @@ def test_get_search_records_pagination( url="/searches/opportunities", method="GET", limit=limit, - target="search_records", + target="records", expected_returns=expected_returns, ) diff --git a/stapi-pydantic/CHANGELOG.md b/stapi-pydantic/CHANGELOG.md index 910a544..c482198 100644 --- a/stapi-pydantic/CHANGELOG.md +++ b/stapi-pydantic/CHANGELOG.md @@ -18,6 +18,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - `cql2_property_names`, which collects the property names referenced by a CQL2 JSON filter. - `SearchParameters`, the Search Parameters Object (`datetime`, `geometry`, `filter`) shared by the Opportunity Request and the Order Request. It permits extra fields, so provider extension parameters round-trip instead of being dropped. - `ProductCollection`, the new name for `ProductsCollection` (see Changed). +- `stapi_type` and `stapi_version` on `OpportunitySearchRecord` and `OpportunitySearchRecordCollection`. - `BaseOrderParameters`, a permissive base for order parameters at rest, and `StoredOrderRequest`, the form an Order Request takes once it is persisted inside `OrderProperties`. `OrderParameters` is now a strict (`extra="forbid"`) subclass of `BaseOrderParameters`. - `STAPI_VERSION` is now exported from the package root. @@ -37,6 +38,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **BREAKING** `OpportunityRequest` (was `OpportunityPayload`) and `OrderRequest` (was `OrderPayload`) now compose `SearchParameters` instead of declaring `datetime`, `geometry`, and `filter` themselves. A request body that was `{"datetime": ..., "geometry": ..., "filter": ...}` becomes `{"search_parameters": {"datetime": ..., "geometry": ..., "filter": ...}}`. - **BREAKING** `OrderRequest.order_parameters` is optional and defaults to an empty object. It was previously required. Products whose `OrderParameters` model has required fields still make it effectively required, via validation. - **BREAKING** `OrderProperties` carries a single `order_request` (a `StoredOrderRequest`) in place of the former `search_parameters`, `opportunity_properties`, and `order_parameters` fields. +- **BREAKING** `OpportunitySearchRecord.opportunity_request` is replaced by `search_parameters`, a `SearchParameters` rather than a whole request object. A record describes what was searched for, not the request body that carried it; holding the request meant every record echoed back whatever `limit`/`next` the client happened to page with, so two records describing an identical search differed if the clients paged differently. `OpportunitySearchRecordCollection` (was `OpportunitySearchRecords`) holds its items in `records` rather than `search_records`. - **BREAKING** `OpportunityRequest.limit` is `int | None` with a lower bound of 1, and defaults to `None`. It defaulted to 10, which asserted a page size the client never asked for; the default is the server's to choose. ### Fixed @@ -46,7 +48,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Removed -- **BREAKING** The pre-0.2.0 compatibility aliases `ProductsCollection`, `OrderPayload`, `OpportunityPayload` and `OrderSearchParameters` are gone. Use `ProductCollection`, `OrderRequest`, `OpportunityRequest` and `SearchParameters`. +- **BREAKING** The pre-0.2.0 compatibility aliases `ProductsCollection`, `OrderPayload`, `OpportunityPayload`, `OrderSearchParameters` and `OpportunitySearchRecords` are gone. Use `ProductCollection`, `OrderRequest`, `OpportunityRequest`, `SearchParameters` and `OpportunitySearchRecordCollection`. - The unused `Props`, `Geom`, and `OPP` type variables in `stapi_pydantic.order`. - **BREAKING** `JsonSchemaModel` is gone. It annotated a `type[BaseModel]` with a `PlainValidator`/`PlainSerializer` pair so a model class could stand in for its own schema, which meant the published document carried an orphan `BaseModel` component and the value could not be read back. Build a `JsonSchema` with `JsonSchema.from_model(YourModel)` instead. diff --git a/stapi-pydantic/src/stapi_pydantic/__init__.py b/stapi-pydantic/src/stapi_pydantic/__init__.py index 26fc402..5a99f5b 100644 --- a/stapi-pydantic/src/stapi_pydantic/__init__.py +++ b/stapi-pydantic/src/stapi_pydantic/__init__.py @@ -10,7 +10,7 @@ OpportunityProperties, OpportunityRequest, OpportunitySearchRecord, - OpportunitySearchRecords, + OpportunitySearchRecordCollection, OpportunitySearchStatus, OpportunitySearchStatusCode, Prefer, @@ -47,7 +47,7 @@ "OpportunityRequest", "OpportunityProperties", "OpportunitySearchRecord", - "OpportunitySearchRecords", + "OpportunitySearchRecordCollection", "OpportunitySearchStatus", "OpportunitySearchStatusCode", "Order", diff --git a/stapi-pydantic/src/stapi_pydantic/opportunity.py b/stapi-pydantic/src/stapi_pydantic/opportunity.py index d0f5dcd..8573db0 100644 --- a/stapi-pydantic/src/stapi_pydantic/opportunity.py +++ b/stapi-pydantic/src/stapi_pydantic/opportunity.py @@ -4,10 +4,11 @@ from geojson_pydantic import Feature, FeatureCollection from pydantic import AwareDatetime, BaseModel, ConfigDict, Field +from .constants import STAPI_VERSION from .datetime_interval import BoundedDatetimeInterval from .geometry import Geometry from .search_parameters import SearchParameters -from .shared import Link +from .shared import STAPI_RESPONSE_CONFIG, Link # Copied and modified from https://github.com/stac-utils/stac-pydantic/blob/main/stac_pydantic/item.py#L11 @@ -75,15 +76,23 @@ class OpportunitySearchStatus(BaseModel): class OpportunitySearchRecord(BaseModel): + model_config = STAPI_RESPONSE_CONFIG + id: str product_id: str - opportunity_request: OpportunityRequest + search_parameters: SearchParameters status: OpportunitySearchStatus + stapi_type: Literal["OpportunitySearchRecord"] = "OpportunitySearchRecord" + stapi_version: str = STAPI_VERSION links: list[Link] = Field(default_factory=list) -class OpportunitySearchRecords(BaseModel): - search_records: list[OpportunitySearchRecord] +class OpportunitySearchRecordCollection(BaseModel): + model_config = STAPI_RESPONSE_CONFIG + + stapi_type: Literal["OpportunitySearchRecordCollection"] = "OpportunitySearchRecordCollection" + stapi_version: str = STAPI_VERSION + records: list[OpportunitySearchRecord] links: list[Link] = Field(default_factory=list) diff --git a/stapi-pydantic/tests/test_opportunity.py b/stapi-pydantic/tests/test_opportunity.py index af35915..1899d4b 100644 --- a/stapi-pydantic/tests/test_opportunity.py +++ b/stapi-pydantic/tests/test_opportunity.py @@ -1,5 +1,7 @@ from stapi_pydantic import ( OpportunityRequest, + OpportunitySearchRecord, + OpportunitySearchRecordCollection, OrderParameters, OrderRequest, ) @@ -38,3 +40,29 @@ def test_opportunity_request_body_includes_pagination() -> None: assert body["next"] == "abc" assert body["limit"] == 5 assert "search_parameters" in body + + +SEARCH_RECORD_DICT = { + "id": "search-1", + "product_id": "umbra_spotlight", + "search_parameters": SEARCH_PARAMS, + "status": {"timestamp": "2024-04-10T09:15:00Z", "status_code": "received"}, +} + + +def test_opportunity_search_record_request_field() -> None: + """A record says what was searched for, not which request body carried it.""" + record = OpportunitySearchRecord.model_validate(SEARCH_RECORD_DICT) + assert record.search_parameters.geometry.type == "Point" + dumped = record.model_dump(mode="json") + assert dumped["stapi_type"] == "OpportunitySearchRecord" + assert "opportunity_request" not in dumped + + +def test_opportunity_search_record_collection() -> None: + collection = OpportunitySearchRecordCollection.model_validate( + {"records": [SEARCH_RECORD_DICT]}, + ) + dumped = collection.model_dump(mode="json") + assert dumped["stapi_type"] == "OpportunitySearchRecordCollection" + assert len(dumped["records"]) == 1 From 2ead0c8675a707f61af514786da01d1d334bb0cb Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 6 Aug 2026 18:31:10 -0700 Subject: [PATCH 15/48] feat!: let an implementation constrain its own status code set The spec lets providers add statuses through extensions, so a closed enum rejected values a conforming server may legitimately send. `status_code` now accepts any string by default, while still validating a known code to the enum, and both status models are generic over their code set so an implementation can narrow them: `OrderStatus[MyCodes]`. `OrderStatus.new` constructed a bare `OrderStatus` no matter what class it was called on, so `OrderStatus[MyCodes].new(...)` returned the wrong type and accepted codes outside the enum it was parameterized with. It builds `cls` now. `T` gains a default of `OrderStatus` rather than resolving to the bound `OrderStatus[Any]`, which is what made `OrderStatusCollection` publish a second, unconstrained `OrderStatus-2` component whose `status_code` carried no schema at all. `reason_code` and `reason_text` are omitted when unset rather than published as null. Requires typing-extensions >= 4.12, for TypeVar defaults. --- stapi-pydantic/CHANGELOG.md | 6 +++ stapi-pydantic/pyproject.toml | 2 +- .../src/stapi_pydantic/opportunity.py | 26 ++++++++--- stapi-pydantic/src/stapi_pydantic/order.py | 38 +++++++++++----- stapi-pydantic/tests/test_opportunity.py | 21 +++++++++ stapi-pydantic/tests/test_order.py | 45 +++++++++++++++++++ uv.lock | 2 + 7 files changed, 121 insertions(+), 19 deletions(-) diff --git a/stapi-pydantic/CHANGELOG.md b/stapi-pydantic/CHANGELOG.md index c482198..a16028f 100644 --- a/stapi-pydantic/CHANGELOG.md +++ b/stapi-pydantic/CHANGELOG.md @@ -20,6 +20,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - `ProductCollection`, the new name for `ProductsCollection` (see Changed). - `stapi_type` and `stapi_version` on `OpportunitySearchRecord` and `OpportunitySearchRecordCollection`. - `BaseOrderParameters`, a permissive base for order parameters at rest, and `StoredOrderRequest`, the form an Order Request takes once it is persisted inside `OrderProperties`. `OrderParameters` is now a strict (`extra="forbid"`) subclass of `BaseOrderParameters`. +- `OrderStatus` and `OpportunitySearchStatus` are generic over their status code set, so an implementation can constrain it with its own `StrEnum`, e.g. `OrderStatus[MyCodes]`. - `STAPI_VERSION` is now exported from the package root. ### Changed @@ -32,6 +33,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **BREAKING** Models that declare aliases now serialize by alias. `model_dump()` emits `conformsTo` on `Conformance` and `type` on `Product`, where it previously emitted the Python field names `conforms_to` and `type_`. Callers already passing `by_alias=True` are unaffected; callers reading the Python names out of a dump must switch to the wire names. - **BREAKING** `Product.conformsTo` and `RootResponse.conformsTo` are spelled `conforms_to` in Python, matching `Conformance`. The wire name is unchanged: all three validate from either `conformsTo` or `conforms_to` and serialize as `conformsTo`. Keyword construction and attribute access must use the new name. - **BREAKING** `Provider.roles` and `Provider.url` are optional. Both were required, which made a provider that publishes neither unrepresentable; they are now omitted from output rather than published empty or null. +- **BREAKING** `status_code` on `OrderStatus` and `OpportunitySearchStatus` accepts any string by default, since the spec lets providers add statuses through extensions. Known codes still validate to the enum. Code that assumed an `OrderStatusCode` instance must handle a plain `str`, or parameterize the model with its own code set. +- Optional status fields (`reason_code`, `reason_text`) are omitted rather than serialized as null, and so are correspondingly not marked required. +- `typing-extensions >= 4.12` is now required, for `TypeVar` defaults. - Spec-REQUIRED fields that carry defaults (`type`, `stapi_type`, `stapi_version`, `links`, `conformsTo`, and so on) are now marked required in the serialization JSON Schema, since they are always present in a response. - **BREAKING** `ProductsCollection` is renamed to `ProductCollection`, matching its own `stapi_type` and the spec. The old name is gone rather than aliased; update imports. The model also replaces its aliased `type` field with `stapi_type`, so responses carry `"stapi_type": "ProductCollection"` rather than `"type": "ProductCollection"`, and gains `stapi_version`. - **BREAKING** `Product.description` is required, per the spec. It previously defaulted to the empty string. @@ -43,6 +47,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Fixed +- `OrderStatus.new` respects the class it is called on. It constructed a bare `OrderStatus` regardless, so a parameterized `OrderStatus[MyCodes]` returned the wrong type and accepted codes outside its enum. +- `OrderStatusCollection` no longer emits a second, unconstrained `OrderStatus-2` schema whose `status_code` had no schema at all. - Stored order requests and search parameters round-trip unknown fields rather than dropping them. - A malformed CQL2 filter is reported as a validation error. `cql2` raises its own exception types, which pydantic does not convert, so an invalid filter escaped validation and surfaced as a server error rather than a rejected request. diff --git a/stapi-pydantic/pyproject.toml b/stapi-pydantic/pyproject.toml index 30dc64d..2a16c73 100644 --- a/stapi-pydantic/pyproject.toml +++ b/stapi-pydantic/pyproject.toml @@ -9,7 +9,7 @@ authors = [ { name = "Jarrett Keifer", email = "jkeifer0@gmail.com" }, ] requires-python = ">=3.11" -dependencies = ["pydantic>=2.12", "cql2>=0.3.6", "geojson-pydantic>=1.2.0"] +dependencies = ["pydantic>=2.12", "cql2>=0.3.6", "geojson-pydantic>=1.2.0", "typing-extensions>=4.12"] [dependency-groups] dev = [ diff --git a/stapi-pydantic/src/stapi_pydantic/opportunity.py b/stapi-pydantic/src/stapi_pydantic/opportunity.py index 8573db0..d1a9153 100644 --- a/stapi-pydantic/src/stapi_pydantic/opportunity.py +++ b/stapi-pydantic/src/stapi_pydantic/opportunity.py @@ -1,14 +1,17 @@ +from __future__ import annotations + from enum import StrEnum -from typing import Any, Literal, TypeVar +from typing import Annotated, Any, Generic, Literal, TypeVar from geojson_pydantic import Feature, FeatureCollection from pydantic import AwareDatetime, BaseModel, ConfigDict, Field +from typing_extensions import TypeVar as DefaultTypeVar from .constants import STAPI_VERSION from .datetime_interval import BoundedDatetimeInterval from .geometry import Geometry from .search_parameters import SearchParameters -from .shared import STAPI_RESPONSE_CONFIG, Link +from .shared import STAPI_RESPONSE_CONFIG, Link, omitted_when_none # Copied and modified from https://github.com/stac-utils/stac-pydantic/blob/main/stac_pydantic/item.py#L11 @@ -67,11 +70,22 @@ class OpportunitySearchStatusCode(StrEnum): completed = "completed" -class OpportunitySearchStatus(BaseModel): +AnySearchStatusCode = Annotated[OpportunitySearchStatusCode | str, Field(union_mode="left_to_right")] + +SearchStatusCode = DefaultTypeVar("SearchStatusCode", bound=str, default=AnySearchStatusCode) + + +class OpportunitySearchStatus(BaseModel, Generic[SearchStatusCode]): + """A search record status; parameterize with a StrEnum + (``OpportunitySearchStatus[MyCodes]``) to constrain status_code to an + implementation-defined set.""" + + model_config = STAPI_RESPONSE_CONFIG + timestamp: AwareDatetime - status_code: OpportunitySearchStatusCode - reason_code: str | None = None - reason_text: str | None = None + status_code: SearchStatusCode + reason_code: str | None = omitted_when_none() + reason_text: str | None = omitted_when_none() links: list[Link] = Field(default_factory=list) diff --git a/stapi-pydantic/src/stapi_pydantic/order.py b/stapi-pydantic/src/stapi_pydantic/order.py index f1e43ff..3ece0ed 100644 --- a/stapi-pydantic/src/stapi_pydantic/order.py +++ b/stapi-pydantic/src/stapi_pydantic/order.py @@ -3,7 +3,7 @@ import datetime from collections.abc import Iterator from enum import StrEnum -from typing import Any, Generic, Literal, TypeVar +from typing import Annotated, Any, Generic, Literal, Self, TypeVar, cast from geojson_pydantic.base import _GeoJsonBase from pydantic import ( @@ -14,11 +14,12 @@ StrictStr, field_validator, ) +from typing_extensions import TypeVar as DefaultTypeVar from .constants import STAPI_VERSION from .geometry import Geometry from .search_parameters import SearchParameters -from .shared import STAPI_RESPONSE_CONFIG_ALLOW_EXTRA, Link +from .shared import STAPI_RESPONSE_CONFIG_ALLOW_EXTRA, Link, omitted_when_none class BaseOrderParameters(BaseModel): @@ -55,29 +56,42 @@ class OrderStatusCode(StrEnum): failed = "failed" -class OrderStatus(BaseModel): +AnyOrderStatusCode = Annotated[OrderStatusCode | str, Field(union_mode="left_to_right")] + +StatusCode = DefaultTypeVar("StatusCode", bound=str, default=AnyOrderStatusCode) + + +class OrderStatus(BaseModel, Generic[StatusCode]): + """An order status; parameterize with a StrEnum (``OrderStatus[MyCodes]``) + to constrain status_code to an implementation-defined set.""" + timestamp: AwareDatetime - status_code: OrderStatusCode - reason_code: str | None = None - reason_text: str | None = None + status_code: StatusCode + reason_code: str | None = omitted_when_none() + reason_text: str | None = omitted_when_none() links: list[Link] = Field(default_factory=list) - model_config = ConfigDict(extra="allow") + model_config = STAPI_RESPONSE_CONFIG_ALLOW_EXTRA @classmethod def new( - cls, status_code: OrderStatusCode, reason_code: str | None = None, reason_text: str | None = None - ) -> OrderStatus: + cls, status_code: OrderStatusCode | str, reason_code: str | None = None, reason_text: str | None = None + ) -> Self: """Creates a new order status with timestamp set to now in UTC.""" - return OrderStatus( + return cls( timestamp=datetime.datetime.now(tz=datetime.UTC), - status_code=status_code, + # the accepted codes are whatever cls was parameterized with, which + # the signature can't name; validation enforces it. + status_code=cast(StatusCode, status_code), reason_code=reason_code, reason_text=reason_text, ) -T = TypeVar("T", bound=OrderStatus) +# Defaulted so an unparameterized Order resolves to OrderStatus itself rather +# than the bound OrderStatus[Any], which would emit a second, unconstrained +# OrderStatus schema. +T = DefaultTypeVar("T", bound=OrderStatus[Any], default=OrderStatus) class OrderStatuses(BaseModel, Generic[T]): diff --git a/stapi-pydantic/tests/test_opportunity.py b/stapi-pydantic/tests/test_opportunity.py index 1899d4b..f1762e2 100644 --- a/stapi-pydantic/tests/test_opportunity.py +++ b/stapi-pydantic/tests/test_opportunity.py @@ -1,7 +1,10 @@ +import pydantic +import pytest from stapi_pydantic import ( OpportunityRequest, OpportunitySearchRecord, OpportunitySearchRecordCollection, + OpportunitySearchStatus, OrderParameters, OrderRequest, ) @@ -66,3 +69,21 @@ def test_opportunity_search_record_collection() -> None: dumped = collection.model_dump(mode="json") assert dumped["stapi_type"] == "OpportunitySearchRecordCollection" assert len(dumped["records"]) == 1 + + +def test_opportunity_search_status_accepts_extension_status_code() -> None: + status = OpportunitySearchStatus.model_validate({"timestamp": "2024-04-10T09:15:00Z", "status_code": "queued"}) + assert status.status_code == "queued" + assert status.model_dump(mode="json")["status_code"] == "queued" + + +def test_opportunity_search_status_code_constrainable_with_custom_enum() -> None: + from enum import StrEnum + + class NarrowCodes(StrEnum): + special = "special" + + with pytest.raises(pydantic.ValidationError): + OpportunitySearchStatus[NarrowCodes].model_validate( + {"timestamp": "2024-04-10T09:15:00Z", "status_code": "received"} + ) diff --git a/stapi-pydantic/tests/test_order.py b/stapi-pydantic/tests/test_order.py index ec03506..9b0243e 100644 --- a/stapi-pydantic/tests/test_order.py +++ b/stapi-pydantic/tests/test_order.py @@ -1,4 +1,5 @@ import datetime +from enum import StrEnum from typing import Any import pydantic @@ -14,6 +15,50 @@ ) +def test_order_status_new_uses_cls() -> None: + class NarrowCodes(StrEnum): + special = "special" + + narrowed = OrderStatus[NarrowCodes] + status = narrowed.new("special") + assert type(status) is narrowed + assert status.status_code is NarrowCodes.special + + # the parameterization is enforced rather than silently falling back to the + # unparameterized OrderStatus + with pytest.raises(pydantic.ValidationError): + narrowed.new("received") + + +def test_order_status_accepts_extension_status_code() -> None: + status = OrderStatus.model_validate({"timestamp": "2024-04-10T09:15:00Z", "status_code": "tasking_window_open"}) + assert status.status_code == "tasking_window_open" + assert status.model_dump(mode="json")["status_code"] == "tasking_window_open" + + +def test_order_status_known_code_validates_to_enum() -> None: + status = OrderStatus.model_validate({"timestamp": "2024-04-10T09:15:00Z", "status_code": "received"}) + assert status.status_code is OrderStatusCode.received + + +def test_order_status_code_constrainable_with_custom_enum() -> None: + class NarrowCodes(StrEnum): + special = "special" + + narrowed = OrderStatus[NarrowCodes] + assert narrowed.model_validate({"timestamp": "2024-04-10T09:15:00Z", "status_code": "special"}).status_code is ( + NarrowCodes.special + ) + with pytest.raises(pydantic.ValidationError): + narrowed.model_validate({"timestamp": "2024-04-10T09:15:00Z", "status_code": "received"}) + + +def test_order_status_code_schema_allows_extension_strings() -> None: + status_code_schema = OrderStatus.model_json_schema()["properties"]["status_code"] + assert {"type": "string"} in status_code_schema["anyOf"] + assert any("$ref" in member for member in status_code_schema["anyOf"]) + + def test_order_status_new() -> None: status = OrderStatus.new(OrderStatusCode.accepted) assert status.timestamp.tzinfo == datetime.UTC diff --git a/uv.lock b/uv.lock index 8344a38..87edbf5 100644 --- a/uv.lock +++ b/uv.lock @@ -2622,6 +2622,7 @@ dependencies = [ { name = "cql2" }, { name = "geojson-pydantic" }, { name = "pydantic" }, + { name = "typing-extensions" }, ] [package.dev-dependencies] @@ -2634,6 +2635,7 @@ requires-dist = [ { name = "cql2", specifier = ">=0.3.6" }, { name = "geojson-pydantic", specifier = ">=1.2.0" }, { name = "pydantic", specifier = ">=2.12" }, + { name = "typing-extensions", specifier = ">=4.12" }, ] [package.metadata.requires-dev] From 0d3a4746bc1fb88499e0de873c868ebabe9c237a Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 6 Aug 2026 18:32:03 -0700 Subject: [PATCH 16/48] feat!: give order and search statuses matching collection wrappers `OrderStatuses` was named for its contents rather than for what it is, and had no counterpart on the opportunity side: the search-record statuses endpoint returned a bare JSON list, which cannot carry links or a count and does not match any other collection response in the API. It becomes `OrderStatusCollection`, and `OpportunitySearchStatusCollection` is added alongside it. Both carry `stapi_type`, `stapi_version`, `statuses` and `links`. The search-record statuses endpoint keeps returning a list for now; it moves onto the new collection when that endpoint is paginated. --- stapi-fastapi/src/stapi_fastapi/routers/root_router.py | 6 +++--- stapi-pydantic/CHANGELOG.md | 6 ++++-- stapi-pydantic/src/stapi_pydantic/__init__.py | 6 ++++-- stapi-pydantic/src/stapi_pydantic/opportunity.py | 9 +++++++++ stapi-pydantic/src/stapi_pydantic/order.py | 8 ++++++-- stapi-pydantic/tests/test_opportunity.py | 10 ++++++++++ 6 files changed, 36 insertions(+), 9 deletions(-) diff --git a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py index 255410b..6aaecc3 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py @@ -15,7 +15,7 @@ Order, OrderCollection, OrderStatus, - OrderStatuses, + OrderStatusCollection, ProductCollection, RootResponse, ) @@ -306,7 +306,7 @@ async def get_order_statuses( request: Request, next: str | None = None, limit: int = 10, - ) -> OrderStatuses: # type: ignore + ) -> OrderStatusCollection: # type: ignore links: list[Link] = [] match await self._get_order_statuses(order_id, next, limit, request): case Success(Some((statuses, maybe_pagination_token))): @@ -335,7 +335,7 @@ async def get_order_statuses( ) case _: raise AssertionError("Expected code to be unreachable") - return OrderStatuses(statuses=statuses, links=links) + return OrderStatusCollection(statuses=statuses, links=links) def add_product(self, product: Product, *args: Any, **kwargs: Any) -> None: # Give the include a prefix from the product router diff --git a/stapi-pydantic/CHANGELOG.md b/stapi-pydantic/CHANGELOG.md index a16028f..ed70c37 100644 --- a/stapi-pydantic/CHANGELOG.md +++ b/stapi-pydantic/CHANGELOG.md @@ -18,7 +18,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - `cql2_property_names`, which collects the property names referenced by a CQL2 JSON filter. - `SearchParameters`, the Search Parameters Object (`datetime`, `geometry`, `filter`) shared by the Opportunity Request and the Order Request. It permits extra fields, so provider extension parameters round-trip instead of being dropped. - `ProductCollection`, the new name for `ProductsCollection` (see Changed). -- `stapi_type` and `stapi_version` on `OpportunitySearchRecord` and `OpportunitySearchRecordCollection`. +- `OpportunitySearchStatusCollection`, the collection wrapper for the statuses of an Opportunity Search Record. +- `stapi_type` and `stapi_version` on `OpportunitySearchRecord`, `OpportunitySearchRecordCollection` and `OrderStatusCollection`. - `BaseOrderParameters`, a permissive base for order parameters at rest, and `StoredOrderRequest`, the form an Order Request takes once it is persisted inside `OrderProperties`. `OrderParameters` is now a strict (`extra="forbid"`) subclass of `BaseOrderParameters`. - `OrderStatus` and `OpportunitySearchStatus` are generic over their status code set, so an implementation can constrain it with its own `StrEnum`, e.g. `OrderStatus[MyCodes]`. - `STAPI_VERSION` is now exported from the package root. @@ -33,6 +34,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **BREAKING** Models that declare aliases now serialize by alias. `model_dump()` emits `conformsTo` on `Conformance` and `type` on `Product`, where it previously emitted the Python field names `conforms_to` and `type_`. Callers already passing `by_alias=True` are unaffected; callers reading the Python names out of a dump must switch to the wire names. - **BREAKING** `Product.conformsTo` and `RootResponse.conformsTo` are spelled `conforms_to` in Python, matching `Conformance`. The wire name is unchanged: all three validate from either `conformsTo` or `conforms_to` and serialize as `conformsTo`. Keyword construction and attribute access must use the new name. - **BREAKING** `Provider.roles` and `Provider.url` are optional. Both were required, which made a provider that publishes neither unrepresentable; they are now omitted from output rather than published empty or null. +- **BREAKING** `OrderStatuses` is renamed `OrderStatusCollection`, matching its sibling collections, and gains `stapi_type` and `stapi_version`. - **BREAKING** `status_code` on `OrderStatus` and `OpportunitySearchStatus` accepts any string by default, since the spec lets providers add statuses through extensions. Known codes still validate to the enum. Code that assumed an `OrderStatusCode` instance must handle a plain `str`, or parameterize the model with its own code set. - Optional status fields (`reason_code`, `reason_text`) are omitted rather than serialized as null, and so are correspondingly not marked required. - `typing-extensions >= 4.12` is now required, for `TypeVar` defaults. @@ -54,7 +56,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Removed -- **BREAKING** The pre-0.2.0 compatibility aliases `ProductsCollection`, `OrderPayload`, `OpportunityPayload`, `OrderSearchParameters` and `OpportunitySearchRecords` are gone. Use `ProductCollection`, `OrderRequest`, `OpportunityRequest`, `SearchParameters` and `OpportunitySearchRecordCollection`. +- **BREAKING** The pre-0.2.0 compatibility aliases `ProductsCollection`, `OrderPayload`, `OpportunityPayload`, `OrderSearchParameters`, `OpportunitySearchRecords` and `OrderStatuses` are gone. Use `ProductCollection`, `OrderRequest`, `OpportunityRequest`, `SearchParameters`, `OpportunitySearchRecordCollection` and `OrderStatusCollection`. - The unused `Props`, `Geom`, and `OPP` type variables in `stapi_pydantic.order`. - **BREAKING** `JsonSchemaModel` is gone. It annotated a `type[BaseModel]` with a `PlainValidator`/`PlainSerializer` pair so a model class could stand in for its own schema, which meant the published document carried an orphan `BaseModel` component and the value could not be read back. Build a `JsonSchema` with `JsonSchema.from_model(YourModel)` instead. diff --git a/stapi-pydantic/src/stapi_pydantic/__init__.py b/stapi-pydantic/src/stapi_pydantic/__init__.py index 5a99f5b..b8f9e25 100644 --- a/stapi-pydantic/src/stapi_pydantic/__init__.py +++ b/stapi-pydantic/src/stapi_pydantic/__init__.py @@ -13,6 +13,7 @@ OpportunitySearchRecordCollection, OpportunitySearchStatus, OpportunitySearchStatusCode, + OpportunitySearchStatusCollection, Prefer, ) from .order import ( @@ -24,7 +25,7 @@ OrderRequest, OrderStatus, OrderStatusCode, - OrderStatuses, + OrderStatusCollection, StoredOrderRequest, ) from .product import Product, ProductCollection, Provider, ProviderRole @@ -50,6 +51,7 @@ "OpportunitySearchRecordCollection", "OpportunitySearchStatus", "OpportunitySearchStatusCode", + "OpportunitySearchStatusCollection", "Order", "OrderCollection", "OrderParameters", @@ -57,7 +59,7 @@ "OrderProperties", "OrderStatus", "OrderStatusCode", - "OrderStatuses", + "OrderStatusCollection", "Prefer", "Product", "ProductCollection", diff --git a/stapi-pydantic/src/stapi_pydantic/opportunity.py b/stapi-pydantic/src/stapi_pydantic/opportunity.py index d1a9153..e1a4001 100644 --- a/stapi-pydantic/src/stapi_pydantic/opportunity.py +++ b/stapi-pydantic/src/stapi_pydantic/opportunity.py @@ -110,6 +110,15 @@ class OpportunitySearchRecordCollection(BaseModel): links: list[Link] = Field(default_factory=list) +class OpportunitySearchStatusCollection(BaseModel): + model_config = STAPI_RESPONSE_CONFIG + + stapi_type: Literal["OpportunitySearchStatusCollection"] = "OpportunitySearchStatusCollection" + stapi_version: str = STAPI_VERSION + statuses: list[OpportunitySearchStatus] + links: list[Link] = Field(default_factory=list) + + class Prefer(StrEnum): respond_async = "respond-async" wait = "wait" diff --git a/stapi-pydantic/src/stapi_pydantic/order.py b/stapi-pydantic/src/stapi_pydantic/order.py index 3ece0ed..135312c 100644 --- a/stapi-pydantic/src/stapi_pydantic/order.py +++ b/stapi-pydantic/src/stapi_pydantic/order.py @@ -19,7 +19,7 @@ from .constants import STAPI_VERSION from .geometry import Geometry from .search_parameters import SearchParameters -from .shared import STAPI_RESPONSE_CONFIG_ALLOW_EXTRA, Link, omitted_when_none +from .shared import STAPI_RESPONSE_CONFIG, STAPI_RESPONSE_CONFIG_ALLOW_EXTRA, Link, omitted_when_none class BaseOrderParameters(BaseModel): @@ -94,7 +94,11 @@ def new( T = DefaultTypeVar("T", bound=OrderStatus[Any], default=OrderStatus) -class OrderStatuses(BaseModel, Generic[T]): +class OrderStatusCollection(BaseModel, Generic[T]): + model_config = STAPI_RESPONSE_CONFIG + + stapi_type: Literal["OrderStatusCollection"] = "OrderStatusCollection" + stapi_version: str = STAPI_VERSION statuses: list[T] links: list[Link] = Field(default_factory=list) diff --git a/stapi-pydantic/tests/test_opportunity.py b/stapi-pydantic/tests/test_opportunity.py index f1762e2..92b0749 100644 --- a/stapi-pydantic/tests/test_opportunity.py +++ b/stapi-pydantic/tests/test_opportunity.py @@ -5,6 +5,7 @@ OpportunitySearchRecord, OpportunitySearchRecordCollection, OpportunitySearchStatus, + OpportunitySearchStatusCollection, OrderParameters, OrderRequest, ) @@ -87,3 +88,12 @@ class NarrowCodes(StrEnum): OpportunitySearchStatus[NarrowCodes].model_validate( {"timestamp": "2024-04-10T09:15:00Z", "status_code": "received"} ) + + +def test_opportunity_search_status_collection() -> None: + collection = OpportunitySearchStatusCollection.model_validate( + {"statuses": [{"timestamp": "2024-04-10T09:15:00Z", "status_code": "received"}]} + ) + dumped = collection.model_dump(mode="json") + assert dumped["stapi_type"] == "OpportunitySearchStatusCollection" + assert len(dumped["statuses"]) == 1 From 26fe7a711ba1aa49b7a9c469a2894b30ade6b860 Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 6 Aug 2026 18:33:35 -0700 Subject: [PATCH 17/48] refactor!: derive Order from Feature again, and harden Opportunity `Order` and `OrderCollection` re-implemented `geojson_pydantic`'s `Feature` and `FeatureCollection` on top of `_GeoJsonBase`, duplicating the geometry coercion and the exclude-if-none handling. They derive from the real base classes again. Field order in a dump follows those bases, so `id` now trails `geometry` and `properties`. `OrderCollection`'s `__iter__`, `__len__` and `__getitem__` go with them. `__iter__` in particular shadowed the one pydantic reserves, which broke `dict(collection)`. Use `collection.iter()` and `collection.length`, as `OpportunityCollection` already offered. On the opportunity side, `Feature` types `geometry` and `properties` as nullable, so `model_validate({"geometry": None, ...})` was accepted and produced a dump that violates the spec. Both are now required and non-nullable, and `id` is string-only rather than accepting any scalar. --- stapi-pydantic/CHANGELOG.md | 3 ++ .../src/stapi_pydantic/opportunity.py | 9 +++- stapi-pydantic/src/stapi_pydantic/order.py | 35 +++------------- stapi-pydantic/tests/test_opportunity.py | 41 +++++++++++++++++++ 4 files changed, 58 insertions(+), 30 deletions(-) diff --git a/stapi-pydantic/CHANGELOG.md b/stapi-pydantic/CHANGELOG.md index ed70c37..4abfb3b 100644 --- a/stapi-pydantic/CHANGELOG.md +++ b/stapi-pydantic/CHANGELOG.md @@ -35,6 +35,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **BREAKING** `Product.conformsTo` and `RootResponse.conformsTo` are spelled `conforms_to` in Python, matching `Conformance`. The wire name is unchanged: all three validate from either `conformsTo` or `conforms_to` and serialize as `conformsTo`. Keyword construction and attribute access must use the new name. - **BREAKING** `Provider.roles` and `Provider.url` are optional. Both were required, which made a provider that publishes neither unrepresentable; they are now omitted from output rather than published empty or null. - **BREAKING** `OrderStatuses` is renamed `OrderStatusCollection`, matching its sibling collections, and gains `stapi_type` and `stapi_version`. +- `Order` and `OrderCollection` derive from `geojson_pydantic`'s `Feature` and `FeatureCollection` again, rather than re-implementing them on top of `_GeoJsonBase`. Field order in a dump follows the base classes, so `id` now trails `geometry` and `properties`. +- **BREAKING** `OrderCollection` offers the same iteration surface as `OpportunityCollection`: `collection.iter()` and `collection.length`, in place of `iter(collection)`, `len(collection)`, and `collection[i]`. Its `__iter__` override shadowed the one pydantic reserves, which broke `dict(collection)`. +- **BREAKING** `Opportunity.geometry` and `Opportunity.properties` are required and non-nullable, and `Opportunity.id` is string-only. `Feature` typed geometry and properties as nullable, so `model_validate({"geometry": None, ...})` was accepted and produced a dump that violated the spec. - **BREAKING** `status_code` on `OrderStatus` and `OpportunitySearchStatus` accepts any string by default, since the spec lets providers add statuses through extensions. Known codes still validate to the enum. Code that assumed an `OrderStatusCode` instance must handle a plain `str`, or parameterize the model with its own code set. - Optional status fields (`reason_code`, `reason_text`) are omitted rather than serialized as null, and so are correspondingly not marked required. - `typing-extensions >= 4.12` is now required, for `TypeVar` defaults. diff --git a/stapi-pydantic/src/stapi_pydantic/opportunity.py b/stapi-pydantic/src/stapi_pydantic/opportunity.py index e1a4001..ce1967a 100644 --- a/stapi-pydantic/src/stapi_pydantic/opportunity.py +++ b/stapi-pydantic/src/stapi_pydantic/opportunity.py @@ -52,14 +52,21 @@ def body(self) -> dict[str, Any]: class Opportunity(Feature[G, P]): + model_config = STAPI_RESPONSE_CONFIG + + id: str | None = omitted_when_none() type: Literal["Feature"] = "Feature" + geometry: G = Field(...) + properties: P = Field(...) links: list[Link] = Field(default_factory=list) class OpportunityCollection(FeatureCollection[Opportunity[G, P]]): + model_config = STAPI_RESPONSE_CONFIG + type: Literal["FeatureCollection"] = "FeatureCollection" links: list[Link] = Field(default_factory=list) - id: str | None = None + id: str | None = omitted_when_none() class OpportunitySearchStatusCode(StrEnum): diff --git a/stapi-pydantic/src/stapi_pydantic/order.py b/stapi-pydantic/src/stapi_pydantic/order.py index 135312c..e295495 100644 --- a/stapi-pydantic/src/stapi_pydantic/order.py +++ b/stapi-pydantic/src/stapi_pydantic/order.py @@ -1,18 +1,16 @@ from __future__ import annotations import datetime -from collections.abc import Iterator from enum import StrEnum from typing import Annotated, Any, Generic, Literal, Self, TypeVar, cast -from geojson_pydantic.base import _GeoJsonBase +from geojson_pydantic import Feature, FeatureCollection from pydantic import ( AwareDatetime, BaseModel, ConfigDict, Field, StrictStr, - field_validator, ) from typing_extensions import TypeVar as DefaultTypeVar @@ -125,8 +123,9 @@ class OrderProperties(BaseModel, Generic[T]): order_request: StoredOrderRequest -# derived from geojson_pydantic.Feature -class Order(_GeoJsonBase, Generic[T]): +class Order(Feature[Geometry, OrderProperties[T]], Generic[T]): + model_config = STAPI_RESPONSE_CONFIG + # We need to enforce that orders have an id defined, as that is required to # retrieve them via the API id: StrictStr @@ -139,38 +138,16 @@ class Order(_GeoJsonBase, Generic[T]): links: list[Link] = Field(default_factory=list) - __geojson_exclude_if_none__ = {"bbox", "id"} - - @field_validator("geometry", mode="before") - def set_geometry(cls, geometry: Any) -> Any: - """set geometry from geo interface or input""" - if hasattr(geometry, "__geo_interface__"): - return geometry.__geo_interface__ - - return geometry +class OrderCollection(FeatureCollection[Order[T]], Generic[T]): + model_config = STAPI_RESPONSE_CONFIG -# derived from geojson_pydantic.FeatureCollection -class OrderCollection(_GeoJsonBase, Generic[T]): type: Literal["FeatureCollection"] = "FeatureCollection" - features: list[Order[T]] links: list[Link] = Field(default_factory=list) number_matched: int | None = Field( serialization_alias="numberMatched", default=None, exclude_if=lambda x: x is None ) - def __iter__(self) -> Iterator[Order[T]]: # type: ignore [override] - """iterate over features""" - return iter(self.features) - - def __len__(self) -> int: - """return features length""" - return len(self.features) - - def __getitem__(self, index: int) -> Order[T]: - """get feature at a given index""" - return self.features[index] - class OrderRequest(BaseModel, Generic[ORP]): """STAPI Order Request Object. diff --git a/stapi-pydantic/tests/test_opportunity.py b/stapi-pydantic/tests/test_opportunity.py index 92b0749..c5c1e7c 100644 --- a/stapi-pydantic/tests/test_opportunity.py +++ b/stapi-pydantic/tests/test_opportunity.py @@ -1,6 +1,10 @@ import pydantic import pytest +from geojson_pydantic.geometries import Point from stapi_pydantic import ( + Opportunity, + OpportunityCollection, + OpportunityProperties, OpportunityRequest, OpportunitySearchRecord, OpportunitySearchRecordCollection, @@ -97,3 +101,40 @@ def test_opportunity_search_status_collection() -> None: dumped = collection.model_dump(mode="json") assert dumped["stapi_type"] == "OpportunitySearchStatusCollection" assert len(dumped["statuses"]) == 1 + + +OPPORTUNITY_DICT = { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [13.4, 52.5]}, + "properties": { + "datetime": "2024-04-18T10:56:00Z/2024-04-25T10:56:00Z", + "product_id": "umbra_spotlight", + }, +} + + +def test_opportunity_id_is_string_only() -> None: + opportunity = Opportunity[Point, OpportunityProperties].model_validate({**OPPORTUNITY_DICT, "id": "opp-1"}) + assert opportunity.id == "opp-1" + with pytest.raises(pydantic.ValidationError): + Opportunity[Point, OpportunityProperties].model_validate({**OPPORTUNITY_DICT, "id": 1}) + + +def test_opportunity_collection_omits_null_id() -> None: + collection = OpportunityCollection[Point, OpportunityProperties].model_validate( + {"type": "FeatureCollection", "features": []} + ) + assert "id" not in collection.model_dump(mode="json") + + +def test_opportunity_geometry_required_non_null() -> None: + """Feature types geometry as nullable, which would let a spec-violating + response validate and dump. + """ + with pytest.raises(pydantic.ValidationError): + Opportunity[Point, OpportunityProperties].model_validate({**OPPORTUNITY_DICT, "geometry": None}) + + +def test_opportunity_properties_required() -> None: + with pytest.raises(pydantic.ValidationError): + Opportunity[Point, OpportunityProperties].model_validate({**OPPORTUNITY_DICT, "properties": None}) From 799f49633566829cf105050493e1761690549ce9 Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 6 Aug 2026 18:34:49 -0700 Subject: [PATCH 18/48] feat: publish stapi_type and stapi_version on every entity `Order` and `Product` carried the pair; the collections and `Opportunity` did not, so a consumer could not tell an `OrderCollection` from an `OpportunityCollection` by its payload -- both said only `"type": "FeatureCollection"`. `Opportunity`, `OpportunityCollection` and `OrderCollection` gain them, which completes the set alongside the ones added with the earlier renames. --- stapi-fastapi/tests/test_order.py | 11 +++++++++-- stapi-pydantic/CHANGELOG.md | 2 +- stapi-pydantic/src/stapi_pydantic/opportunity.py | 4 ++++ stapi-pydantic/src/stapi_pydantic/order.py | 2 ++ stapi-pydantic/tests/test_opportunity.py | 9 +++++++++ stapi-pydantic/tests/test_order.py | 8 ++++++++ 6 files changed, 33 insertions(+), 3 deletions(-) diff --git a/stapi-fastapi/tests/test_order.py b/stapi-fastapi/tests/test_order.py index 815da88..1b5a898 100644 --- a/stapi-fastapi/tests/test_order.py +++ b/stapi-fastapi/tests/test_order.py @@ -6,7 +6,7 @@ from geojson_pydantic import Point from geojson_pydantic.types import Position2D from httpx import Response -from stapi_pydantic import Order, OrderRequest, OrderStatus, OrderStatusCode, SearchParameters +from stapi_pydantic import STAPI_VERSION, Order, OrderRequest, OrderStatus, OrderStatusCode, SearchParameters from .shared import MyOrderParameters, find_link, pagination_tester @@ -19,7 +19,14 @@ def test_empty_order(stapi_client: TestClient): res = stapi_client.get("/orders") assert res.status_code == status.HTTP_200_OK assert res.headers["Content-Type"] == "application/geo+json" - assert res.json() == {"type": "FeatureCollection", "features": [], "links": [], "numberMatched": 314} + assert res.json() == { + "type": "FeatureCollection", + "stapi_type": "OrderCollection", + "stapi_version": STAPI_VERSION, + "features": [], + "links": [], + "numberMatched": 314, + } @pytest.fixture diff --git a/stapi-pydantic/CHANGELOG.md b/stapi-pydantic/CHANGELOG.md index 4abfb3b..bb13853 100644 --- a/stapi-pydantic/CHANGELOG.md +++ b/stapi-pydantic/CHANGELOG.md @@ -19,7 +19,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - `SearchParameters`, the Search Parameters Object (`datetime`, `geometry`, `filter`) shared by the Opportunity Request and the Order Request. It permits extra fields, so provider extension parameters round-trip instead of being dropped. - `ProductCollection`, the new name for `ProductsCollection` (see Changed). - `OpportunitySearchStatusCollection`, the collection wrapper for the statuses of an Opportunity Search Record. -- `stapi_type` and `stapi_version` on `OpportunitySearchRecord`, `OpportunitySearchRecordCollection` and `OrderStatusCollection`. +- `stapi_type` and `stapi_version` on `Opportunity`, `OpportunityCollection`, `OpportunitySearchRecord`, `OpportunitySearchRecordCollection`, `OrderCollection`, and `OrderStatusCollection`. - `BaseOrderParameters`, a permissive base for order parameters at rest, and `StoredOrderRequest`, the form an Order Request takes once it is persisted inside `OrderProperties`. `OrderParameters` is now a strict (`extra="forbid"`) subclass of `BaseOrderParameters`. - `OrderStatus` and `OpportunitySearchStatus` are generic over their status code set, so an implementation can constrain it with its own `StrEnum`, e.g. `OrderStatus[MyCodes]`. - `STAPI_VERSION` is now exported from the package root. diff --git a/stapi-pydantic/src/stapi_pydantic/opportunity.py b/stapi-pydantic/src/stapi_pydantic/opportunity.py index ce1967a..09c39f9 100644 --- a/stapi-pydantic/src/stapi_pydantic/opportunity.py +++ b/stapi-pydantic/src/stapi_pydantic/opportunity.py @@ -56,6 +56,8 @@ class Opportunity(Feature[G, P]): id: str | None = omitted_when_none() type: Literal["Feature"] = "Feature" + stapi_type: Literal["Opportunity"] = "Opportunity" + stapi_version: str = STAPI_VERSION geometry: G = Field(...) properties: P = Field(...) links: list[Link] = Field(default_factory=list) @@ -65,6 +67,8 @@ class OpportunityCollection(FeatureCollection[Opportunity[G, P]]): model_config = STAPI_RESPONSE_CONFIG type: Literal["FeatureCollection"] = "FeatureCollection" + stapi_type: Literal["OpportunityCollection"] = "OpportunityCollection" + stapi_version: str = STAPI_VERSION links: list[Link] = Field(default_factory=list) id: str | None = omitted_when_none() diff --git a/stapi-pydantic/src/stapi_pydantic/order.py b/stapi-pydantic/src/stapi_pydantic/order.py index e295495..9566664 100644 --- a/stapi-pydantic/src/stapi_pydantic/order.py +++ b/stapi-pydantic/src/stapi_pydantic/order.py @@ -143,6 +143,8 @@ class OrderCollection(FeatureCollection[Order[T]], Generic[T]): model_config = STAPI_RESPONSE_CONFIG type: Literal["FeatureCollection"] = "FeatureCollection" + stapi_type: Literal["OrderCollection"] = "OrderCollection" + stapi_version: str = STAPI_VERSION links: list[Link] = Field(default_factory=list) number_matched: int | None = Field( serialization_alias="numberMatched", default=None, exclude_if=lambda x: x is None diff --git a/stapi-pydantic/tests/test_opportunity.py b/stapi-pydantic/tests/test_opportunity.py index c5c1e7c..9bc836a 100644 --- a/stapi-pydantic/tests/test_opportunity.py +++ b/stapi-pydantic/tests/test_opportunity.py @@ -1,3 +1,5 @@ +from typing import Any + import pydantic import pytest from geojson_pydantic.geometries import Point @@ -138,3 +140,10 @@ def test_opportunity_geometry_required_non_null() -> None: def test_opportunity_properties_required() -> None: with pytest.raises(pydantic.ValidationError): Opportunity[Point, OpportunityProperties].model_validate({**OPPORTUNITY_DICT, "properties": None}) + + +def test_opportunity_collection_stapi_fields() -> None: + collection: OpportunityCollection[Any, Any] = OpportunityCollection(features=[]) + dumped = collection.model_dump(mode="json") + assert dumped["stapi_type"] == "OpportunityCollection" + assert dumped["stapi_version"] == "0.2.0" diff --git a/stapi-pydantic/tests/test_order.py b/stapi-pydantic/tests/test_order.py index 9b0243e..7fbb8b6 100644 --- a/stapi-pydantic/tests/test_order.py +++ b/stapi-pydantic/tests/test_order.py @@ -7,6 +7,7 @@ from stapi_pydantic import ( BaseOrderParameters, Order, + OrderCollection, OrderParameters, OrderRequest, OrderStatus, @@ -168,3 +169,10 @@ def test_search_parameters_preserve_unknown_fields() -> None: ) dumped = order.model_dump(mode="json") assert dumped["properties"]["order_request"]["search_parameters"]["vendor:priority"] == "high" + + +def test_order_collection_stapi_fields() -> None: + collection = OrderCollection[OrderStatus](features=[Order[OrderStatus].model_validate(ORDER_DICT)]) + dumped = collection.model_dump(mode="json") + assert dumped["stapi_type"] == "OrderCollection" + assert dumped["stapi_version"] == "0.2.0" From a9ab3735e2d31d9e5363e25bac2b664729450200 Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 6 Aug 2026 18:37:28 -0700 Subject: [PATCH 19/48] feat!: derive a required bbox from geometry, and union it across collections An item's bbox is spec-REQUIRED but was optional and excluded from output when unset, so a conforming server had to compute it itself or publish a response that violated the spec. `Order.bbox` and `Opportunity.bbox` are now required and non-nullable, and a before-validator derives them from the geometry when the caller omits one -- so declaring them required costs callers nothing. The validation and serialization schemas differ deliberately: a request may omit bbox, a response always carries it. A collection's bbox is spec-OPTIONAL and is unioned from its members. An empty collection has no extent, so it is omitted rather than published as null. The union is 3D only when every member is; a mix degrades to 2D, since elevation is unknown for the 2D members. Two bugs fall out of doing this properly: `union_bboxes` returns None for an empty sequence, and assigning that back re-triggered the validator without bound under `validate_assignment`; and a geometry with no coordinates now raises a clear error rather than an IndexError from `min()`. --- stapi-pydantic/CHANGELOG.md | 3 + .../src/stapi_pydantic/opportunity.py | 17 ++- stapi-pydantic/src/stapi_pydantic/order.py | 18 +++- stapi-pydantic/src/stapi_pydantic/shared.py | 61 ++++++++++- stapi-pydantic/tests/test_opportunity.py | 59 ++++++++++ stapi-pydantic/tests/test_order.py | 101 ++++++++++++++++++ stapi-pydantic/tests/test_shared.py | 16 +++ 7 files changed, 268 insertions(+), 7 deletions(-) diff --git a/stapi-pydantic/CHANGELOG.md b/stapi-pydantic/CHANGELOG.md index bb13853..ee29af8 100644 --- a/stapi-pydantic/CHANGELOG.md +++ b/stapi-pydantic/CHANGELOG.md @@ -35,6 +35,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **BREAKING** `Product.conformsTo` and `RootResponse.conformsTo` are spelled `conforms_to` in Python, matching `Conformance`. The wire name is unchanged: all three validate from either `conformsTo` or `conforms_to` and serialize as `conformsTo`. Keyword construction and attribute access must use the new name. - **BREAKING** `Provider.roles` and `Provider.url` are optional. Both were required, which made a provider that publishes neither unrepresentable; they are now omitted from output rather than published empty or null. - **BREAKING** `OrderStatuses` is renamed `OrderStatusCollection`, matching its sibling collections, and gains `stapi_type` and `stapi_version`. +- **BREAKING** `bbox` is required and non-nullable on `Order` and `Opportunity`, and is derived from the geometry when the caller omits it. `Order` previously excluded `bbox` from its output when unset. Collection `bbox` stays optional and is unioned from the members, and is omitted rather than emitted as null when there are none. - `Order` and `OrderCollection` derive from `geojson_pydantic`'s `Feature` and `FeatureCollection` again, rather than re-implementing them on top of `_GeoJsonBase`. Field order in a dump follows the base classes, so `id` now trails `geometry` and `properties`. - **BREAKING** `OrderCollection` offers the same iteration surface as `OpportunityCollection`: `collection.iter()` and `collection.length`, in place of `iter(collection)`, `len(collection)`, and `collection[i]`. Its `__iter__` override shadowed the one pydantic reserves, which broke `dict(collection)`. - **BREAKING** `Opportunity.geometry` and `Opportunity.properties` are required and non-nullable, and `Opportunity.id` is string-only. `Feature` typed geometry and properties as nullable, so `model_validate({"geometry": None, ...})` was accepted and produced a dump that violated the spec. @@ -52,6 +53,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Fixed +- Collection `bbox` computation no longer recurses without bound on a collection with no features. +- Computing a bbox for a geometry with no coordinates raises a clear error. - `OrderStatus.new` respects the class it is called on. It constructed a bare `OrderStatus` regardless, so a parameterized `OrderStatus[MyCodes]` returned the wrong type and accepted codes outside its enum. - `OrderStatusCollection` no longer emits a second, unconstrained `OrderStatus-2` schema whose `status_code` had no schema at all. - Stored order requests and search parameters round-trip unknown fields rather than dropping them. diff --git a/stapi-pydantic/src/stapi_pydantic/opportunity.py b/stapi-pydantic/src/stapi_pydantic/opportunity.py index 09c39f9..2792d96 100644 --- a/stapi-pydantic/src/stapi_pydantic/opportunity.py +++ b/stapi-pydantic/src/stapi_pydantic/opportunity.py @@ -11,7 +11,16 @@ from .datetime_interval import BoundedDatetimeInterval from .geometry import Geometry from .search_parameters import SearchParameters -from .shared import STAPI_RESPONSE_CONFIG, Link, omitted_when_none +from .shared import ( + STAPI_RESPONSE_CONFIG, + UNSET_BBOX, + ComputedBBox, + DerivedCollectionBBox, + DerivedItemBBox, + Link, + OptionalBBox, + omitted_when_none, +) # Copied and modified from https://github.com/stac-utils/stac-pydantic/blob/main/stac_pydantic/item.py#L11 @@ -51,7 +60,7 @@ def body(self) -> dict[str, Any]: P = TypeVar("P", bound=OpportunityProperties) -class Opportunity(Feature[G, P]): +class Opportunity(Feature[G, P], DerivedItemBBox): model_config = STAPI_RESPONSE_CONFIG id: str | None = omitted_when_none() @@ -59,16 +68,18 @@ class Opportunity(Feature[G, P]): stapi_type: Literal["Opportunity"] = "Opportunity" stapi_version: str = STAPI_VERSION geometry: G = Field(...) + bbox: ComputedBBox = UNSET_BBOX properties: P = Field(...) links: list[Link] = Field(default_factory=list) -class OpportunityCollection(FeatureCollection[Opportunity[G, P]]): +class OpportunityCollection(FeatureCollection[Opportunity[G, P]], DerivedCollectionBBox): model_config = STAPI_RESPONSE_CONFIG type: Literal["FeatureCollection"] = "FeatureCollection" stapi_type: Literal["OpportunityCollection"] = "OpportunityCollection" stapi_version: str = STAPI_VERSION + bbox: OptionalBBox = None links: list[Link] = Field(default_factory=list) id: str | None = omitted_when_none() diff --git a/stapi-pydantic/src/stapi_pydantic/order.py b/stapi-pydantic/src/stapi_pydantic/order.py index 9566664..b11a3b5 100644 --- a/stapi-pydantic/src/stapi_pydantic/order.py +++ b/stapi-pydantic/src/stapi_pydantic/order.py @@ -17,7 +17,17 @@ from .constants import STAPI_VERSION from .geometry import Geometry from .search_parameters import SearchParameters -from .shared import STAPI_RESPONSE_CONFIG, STAPI_RESPONSE_CONFIG_ALLOW_EXTRA, Link, omitted_when_none +from .shared import ( + STAPI_RESPONSE_CONFIG, + STAPI_RESPONSE_CONFIG_ALLOW_EXTRA, + UNSET_BBOX, + ComputedBBox, + DerivedCollectionBBox, + DerivedItemBBox, + Link, + OptionalBBox, + omitted_when_none, +) class BaseOrderParameters(BaseModel): @@ -123,7 +133,7 @@ class OrderProperties(BaseModel, Generic[T]): order_request: StoredOrderRequest -class Order(Feature[Geometry, OrderProperties[T]], Generic[T]): +class Order(Feature[Geometry, OrderProperties[T]], DerivedItemBBox, Generic[T]): model_config = STAPI_RESPONSE_CONFIG # We need to enforce that orders have an id defined, as that is required to @@ -134,17 +144,19 @@ class Order(Feature[Geometry, OrderProperties[T]], Generic[T]): stapi_version: str = STAPI_VERSION geometry: Geometry = Field(...) + bbox: ComputedBBox = UNSET_BBOX properties: OrderProperties[T] = Field(...) links: list[Link] = Field(default_factory=list) -class OrderCollection(FeatureCollection[Order[T]], Generic[T]): +class OrderCollection(FeatureCollection[Order[T]], DerivedCollectionBBox, Generic[T]): model_config = STAPI_RESPONSE_CONFIG type: Literal["FeatureCollection"] = "FeatureCollection" stapi_type: Literal["OrderCollection"] = "OrderCollection" stapi_version: str = STAPI_VERSION + bbox: OptionalBBox = None links: list[Link] = Field(default_factory=list) number_matched: int | None = Field( serialization_alias="numberMatched", default=None, exclude_if=lambda x: x is None diff --git a/stapi-pydantic/src/stapi_pydantic/shared.py b/stapi-pydantic/src/stapi_pydantic/shared.py index d7db0ab..ba35b72 100644 --- a/stapi-pydantic/src/stapi_pydantic/shared.py +++ b/stapi-pydantic/src/stapi_pydantic/shared.py @@ -1,12 +1,16 @@ -from typing import Any +from typing import TYPE_CHECKING, Annotated, Any, Self, TypeAlias, cast +from geojson_pydantic.types import BBox from pydantic import ( AnyUrl, BaseModel, ConfigDict, Field, + model_validator, ) +from .geometry import bbox_from_geometry_input, union_bboxes + # Shared config for the models that make up STAPI responses. Must be set on # every model that declares an alias, not just the outermost one: model config # is not inherited by nested models. @@ -40,6 +44,61 @@ def omitted_when_empty(**kwargs: Any) -> Any: return Field(exclude_if=lambda v: not v, **kwargs) +# A bbox the model derives from its geometry when the caller omits it. Declare +# it as ``bbox: ComputedBBox = UNSET_BBOX``. +# +# The two schema modes differ here on purpose: a caller may omit bbox, but a +# response always carries it. The ``UNSET_BBOX`` default gives the former, and +# ``json_schema_serialization_defaults_required`` the latter. +ComputedBBox: TypeAlias = BBox + +# A collection bbox, derived from the members when there are any. Declare it as +# ``bbox: OptionalBBox = None``. Unlike the item bbox it is spec-OPTIONAL, and +# an empty collection has no extent, so it is absent rather than null. +OptionalBBox = Annotated[ + BBox | None, + Field(exclude_if=lambda v: v is None), +] + +#: Placeholder standing in for "derive this from the geometry". Assigned in the +#: class body rather than via ``Field(default=...)`` so the type checker also +#: treats the field as omittable: pydantic's synthesized ``__init__`` reads +#: defaults from the assignment, not from ``Annotated``. +UNSET_BBOX: BBox = cast(BBox, None) + + +class DerivedItemBBox(BaseModel): + """Mixin deriving a ``ComputedBBox`` from ``geometry`` when it is omitted.""" + + # Before field validation, where the geometry is still input, so ``bbox`` can + # be declared required and non-nullable. + @model_validator(mode="before") + @classmethod + def set_bbox(cls, data: Any) -> Any: + if isinstance(data, dict) and data.get("bbox") is None and data.get("geometry") is not None: + return {**data, "bbox": bbox_from_geometry_input(data["geometry"])} + return data + + +class DerivedCollectionBBox(BaseModel): + """Mixin deriving an ``OptionalBBox`` from the members' bboxes.""" + + # declared for the type checker only; the models mixing this in own the + # real fields + if TYPE_CHECKING: + bbox: BBox | None + features: list[Any] + + @model_validator(mode="after") + def set_bbox(self) -> Self: + # `self.features` is checked because union_bboxes returns None for an + # empty sequence: assigning that back would re-trigger this validator + # under validate_assignment, unbounded. + if self.bbox is None and self.features: + self.bbox = union_bboxes([feature.bbox for feature in self.features]) + return self + + class Link(BaseModel): href: AnyUrl rel: str diff --git a/stapi-pydantic/tests/test_opportunity.py b/stapi-pydantic/tests/test_opportunity.py index 9bc836a..574fb33 100644 --- a/stapi-pydantic/tests/test_opportunity.py +++ b/stapi-pydantic/tests/test_opportunity.py @@ -147,3 +147,62 @@ def test_opportunity_collection_stapi_fields() -> None: dumped = collection.model_dump(mode="json") assert dumped["stapi_type"] == "OpportunityCollection" assert dumped["stapi_version"] == "0.2.0" + + +def test_opportunity_bbox_3d_geometry() -> None: + opportunity_dict: dict[str, Any] = { + **OPPORTUNITY_DICT, + "geometry": { + "type": "LineString", + "coordinates": [[13.0, 52.0, 10.0], [14.0, 53.0, 200.0]], + }, + } + opportunity: Opportunity[Any, Any] = Opportunity.model_validate(opportunity_dict) + assert opportunity.model_dump(mode="json")["bbox"] == [13.0, 52.0, 10.0, 14.0, 53.0, 200.0] + + +def test_opportunity_serialization_schema_marks_spec_required_fields() -> None: + schema = Opportunity[Point, OpportunityProperties].model_json_schema(mode="serialization") + assert {"type", "stapi_type", "stapi_version", "links", "bbox"} <= set(schema["required"]) + assert {"type": "null"} not in schema["properties"]["bbox"].get("anyOf", []) + + +def test_opportunity_bbox_is_optional_to_supply_but_always_emitted() -> None: + """Validation and serialization differ on bbox: a caller may omit it (the + before-validator derives it), but a response always carries it, and neither + mode may permit null. + """ + validation = Opportunity.model_json_schema(mode="validation") + serialization = Opportunity.model_json_schema(mode="serialization") + + assert "bbox" not in validation["required"] + assert "bbox" in serialization["required"] + for schema in (validation, serialization): + assert "null" not in str(schema["properties"]["bbox"]).lower() + + +def test_opportunity_bbox_may_still_be_omitted_by_callers() -> None: + # the before-validator supplies bbox, so declaring it required costs + # callers nothing + model = Opportunity[Point, OpportunityProperties] + assert model.model_validate(OPPORTUNITY_DICT).bbox == (13.4, 52.5, 13.4, 52.5) + assert model(**OPPORTUNITY_DICT).bbox == (13.4, 52.5, 13.4, 52.5) + + +def test_opportunity_collection_bbox_is_none_for_empty_features() -> None: + # union_bboxes returns None for an empty sequence; assigning that back into + # bbox re-triggers the validator under validate_assignment, unbounded + class ValidatedOnAssignment(OpportunityCollection[Point, OpportunityProperties]): + model_config = pydantic.ConfigDict(validate_assignment=True) + + assert OpportunityCollection(features=[]).bbox is None + assert ValidatedOnAssignment(features=[]).bbox is None + + +def test_opportunity_collection_bbox_unions_features() -> None: + model = Opportunity[Point, OpportunityProperties] + other = {**OPPORTUNITY_DICT, "geometry": {"type": "Point", "coordinates": [14.4, 53.5]}} + collection = OpportunityCollection[Point, OpportunityProperties]( + features=[model.model_validate(OPPORTUNITY_DICT), model.model_validate(other)] + ) + assert collection.bbox == (13.4, 52.5, 14.4, 53.5) diff --git a/stapi-pydantic/tests/test_order.py b/stapi-pydantic/tests/test_order.py index 7fbb8b6..b7951d4 100644 --- a/stapi-pydantic/tests/test_order.py +++ b/stapi-pydantic/tests/test_order.py @@ -176,3 +176,104 @@ def test_order_collection_stapi_fields() -> None: dumped = collection.model_dump(mode="json") assert dumped["stapi_type"] == "OrderCollection" assert dumped["stapi_version"] == "0.2.0" + + +def test_order_bbox_computed_and_serialized() -> None: + order = Order[OrderStatus].model_validate(ORDER_DICT) + dumped = order.model_dump(mode="json") + assert dumped["bbox"] == [13.4, 52.5, 13.4, 52.5] + + +def test_order_bbox_3d_geometry() -> None: + order_dict: dict[str, Any] = { + **ORDER_DICT, + "geometry": { + "type": "LineString", + "coordinates": [[13.0, 52.0, 10.0], [14.0, 53.0, 200.0]], + }, + } + order = Order[OrderStatus].model_validate(order_dict) + assert order.model_dump(mode="json")["bbox"] == [13.0, 52.0, 10.0, 14.0, 53.0, 200.0] + + +def test_order_serialization_schema_marks_spec_required_fields() -> None: + schema = Order[OrderStatus].model_json_schema(mode="serialization") + assert {"type", "stapi_type", "stapi_version", "links", "bbox"} <= set(schema["required"]) + + +def test_order_bbox_serialization_schema_is_not_nullable() -> None: + schema = Order[OrderStatus].model_json_schema(mode="serialization") + bbox = schema["properties"]["bbox"] + assert {"type": "null"} not in bbox.get("anyOf", []) + + +def test_order_bbox_is_optional_to_supply_but_always_emitted() -> None: + """Validation and serialization differ on bbox: a caller may omit it (the + before-validator derives it), but a response always carries it, and neither + mode may permit null. + """ + validation = Order[OrderStatus].model_json_schema(mode="validation") + serialization = Order[OrderStatus].model_json_schema(mode="serialization") + + assert "bbox" not in validation["required"] + assert "bbox" in serialization["required"] + for schema in (validation, serialization): + assert "null" not in str(schema["properties"]["bbox"]).lower() + + +def test_order_bbox_may_still_be_omitted_by_callers() -> None: + # the before-validator supplies bbox, so declaring it required costs + # callers nothing + assert Order[OrderStatus].model_validate(ORDER_DICT).bbox == (13.4, 52.5, 13.4, 52.5) + assert Order[OrderStatus](**ORDER_DICT).bbox == (13.4, 52.5, 13.4, 52.5) + + +def test_order_collection_bbox_is_none_for_empty_features() -> None: + # union_bboxes returns None for an empty sequence; assigning that back into + # bbox re-triggers the validator under validate_assignment, unbounded + class ValidatedOnAssignment(OrderCollection[OrderStatus]): + model_config = pydantic.ConfigDict(validate_assignment=True) + + assert OrderCollection[OrderStatus](features=[]).bbox is None + assert ValidatedOnAssignment(features=[]).bbox is None + + +def test_order_collection_bbox_unions_features() -> None: + other = {**ORDER_DICT, "id": "order-2", "geometry": {"type": "Point", "coordinates": [14.4, 53.5]}} + collection = OrderCollection[OrderStatus]( + features=[Order[OrderStatus].model_validate(ORDER_DICT), Order[OrderStatus].model_validate(other)] + ) + assert collection.bbox == (13.4, 52.5, 14.4, 53.5) + + +def _order_with(geometry: dict[str, Any], id_: str) -> Order[OrderStatus]: + return Order[OrderStatus].model_validate({**ORDER_DICT, "id": id_, "geometry": geometry}) + + +LINE_3D = {"type": "LineString", "coordinates": [[13.0, 52.0, 10.0], [16.0, 55.0, 200.0]]} + + +LINE_3D_LOWER = {"type": "LineString", "coordinates": [[12.0, 51.0, 5.0], [12.5, 51.5, 100.0]]} + + +def test_order_collection_bbox_unions_3d_features() -> None: + collection = OrderCollection[OrderStatus]( + features=[_order_with(LINE_3D, "order-1"), _order_with(LINE_3D_LOWER, "order-2")] + ) + assert collection.bbox == (12.0, 51.0, 5.0, 16.0, 55.0, 200.0) + + +def test_order_collection_bbox_degrades_to_2d_when_members_are_mixed() -> None: + # elevation is unknown for the 2D member, so the union cannot claim one + collection = OrderCollection[OrderStatus]( + features=[ + _order_with(LINE_3D, "order-1"), + _order_with({"type": "Point", "coordinates": [12.0, 51.0]}, "order-2"), + ] + ) + assert collection.bbox == (12.0, 51.0, 16.0, 55.0) + + +def test_order_empty_geometry_bbox_error_is_clear() -> None: + with pytest.raises(pydantic.ValidationError, match="bbox"): + Order[OrderStatus].model_validate({**ORDER_DICT, "geometry": {"type": "MultiPoint", "coordinates": []}}) diff --git a/stapi-pydantic/tests/test_shared.py b/stapi-pydantic/tests/test_shared.py index 767148c..118a363 100644 --- a/stapi-pydantic/tests/test_shared.py +++ b/stapi-pydantic/tests/test_shared.py @@ -5,6 +5,8 @@ from stapi_pydantic import ( Conformance, Link, + OpportunityCollection, + OrderCollection, Product, Provider, RootResponse, @@ -73,3 +75,17 @@ def test_unset_optional_fields_are_omitted_not_published_as_null( schema = model.model_json_schema(mode="serialization") assert optional.isdisjoint(schema.get("required", [])) + + +@pytest.mark.parametrize("model", [OrderCollection, OpportunityCollection], ids=lambda m: m.__name__) +def test_collection_bbox_is_omitted_when_there_is_no_extent(model: type[BaseModel]) -> None: + """A collection bbox is absent when unknown, not null, which is also what + keeps it out of the serialization-required set. + """ + assert "bbox" not in model(features=[]).model_dump(mode="json") + for mode in ("validation", "serialization"): + assert "bbox" not in model.model_json_schema(mode=mode).get("required", []) + + +#: Models whose spec-OPTIONAL fields must be omitted rather than published as +#: null, and so must not appear in the serialization-required set. From c1291f48a27f21c224801100f7679df710a8c5bc Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 6 Aug 2026 18:39:03 -0700 Subject: [PATCH 20/48] feat: publish numberMatched on every collection Only `OrderCollection` carried `number_matched`, so no other paginated response could tell a client how many items existed in total. It is hoisted into one shared annotated field and applied to all six collections. That fixes a bug the single definition carried: it declared a serialization alias but no validation alias, so a `numberMatched` in incoming JSON bound nothing, parsed to None, and was dropped on the way back out. Both spellings are accepted on input now, and the field is omitted rather than published as null when unset. --- stapi-pydantic/CHANGELOG.md | 2 + .../src/stapi_pydantic/opportunity.py | 4 + stapi-pydantic/src/stapi_pydantic/order.py | 6 +- stapi-pydantic/src/stapi_pydantic/product.py | 3 +- stapi-pydantic/src/stapi_pydantic/shared.py | 14 +++ stapi-pydantic/tests/test_opportunity.py | 11 +++ stapi-pydantic/tests/test_order.py | 6 ++ stapi-pydantic/tests/test_product.py | 6 ++ stapi-pydantic/tests/test_shared.py | 90 +++++++++++++++++++ 9 files changed, 138 insertions(+), 4 deletions(-) diff --git a/stapi-pydantic/CHANGELOG.md b/stapi-pydantic/CHANGELOG.md index ee29af8..bbbda48 100644 --- a/stapi-pydantic/CHANGELOG.md +++ b/stapi-pydantic/CHANGELOG.md @@ -19,6 +19,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - `SearchParameters`, the Search Parameters Object (`datetime`, `geometry`, `filter`) shared by the Opportunity Request and the Order Request. It permits extra fields, so provider extension parameters round-trip instead of being dropped. - `ProductCollection`, the new name for `ProductsCollection` (see Changed). - `OpportunitySearchStatusCollection`, the collection wrapper for the statuses of an Opportunity Search Record. +- `number_matched` (serialized as `numberMatched`) on every collection, rather than only on `OrderCollection`. - `stapi_type` and `stapi_version` on `Opportunity`, `OpportunityCollection`, `OpportunitySearchRecord`, `OpportunitySearchRecordCollection`, `OrderCollection`, and `OrderStatusCollection`. - `BaseOrderParameters`, a permissive base for order parameters at rest, and `StoredOrderRequest`, the form an Order Request takes once it is persisted inside `OrderProperties`. `OrderParameters` is now a strict (`extra="forbid"`) subclass of `BaseOrderParameters`. - `OrderStatus` and `OpportunitySearchStatus` are generic over their status code set, so an implementation can constrain it with its own `StrEnum`, e.g. `OrderStatus[MyCodes]`. @@ -53,6 +54,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Fixed +- `number_matched` round-trips under its wire name. It declared a serialization alias but no validation alias, so a `numberMatched` in incoming JSON parsed to `None` and was then dropped on the way back out. Both `numberMatched` and `number_matched` are now accepted on input. - Collection `bbox` computation no longer recurses without bound on a collection with no features. - Computing a bbox for a geometry with no coordinates raises a clear error. - `OrderStatus.new` respects the class it is called on. It constructed a bare `OrderStatus` regardless, so a parameterized `OrderStatus[MyCodes]` returned the wrong type and accepted codes outside its enum. diff --git a/stapi-pydantic/src/stapi_pydantic/opportunity.py b/stapi-pydantic/src/stapi_pydantic/opportunity.py index 2792d96..63fea9f 100644 --- a/stapi-pydantic/src/stapi_pydantic/opportunity.py +++ b/stapi-pydantic/src/stapi_pydantic/opportunity.py @@ -18,6 +18,7 @@ DerivedCollectionBBox, DerivedItemBBox, Link, + NumberMatched, OptionalBBox, omitted_when_none, ) @@ -82,6 +83,7 @@ class OpportunityCollection(FeatureCollection[Opportunity[G, P]], DerivedCollect bbox: OptionalBBox = None links: list[Link] = Field(default_factory=list) id: str | None = omitted_when_none() + number_matched: NumberMatched = None class OpportunitySearchStatusCode(StrEnum): @@ -130,6 +132,7 @@ class OpportunitySearchRecordCollection(BaseModel): stapi_version: str = STAPI_VERSION records: list[OpportunitySearchRecord] links: list[Link] = Field(default_factory=list) + number_matched: NumberMatched = None class OpportunitySearchStatusCollection(BaseModel): @@ -139,6 +142,7 @@ class OpportunitySearchStatusCollection(BaseModel): stapi_version: str = STAPI_VERSION statuses: list[OpportunitySearchStatus] links: list[Link] = Field(default_factory=list) + number_matched: NumberMatched = None class Prefer(StrEnum): diff --git a/stapi-pydantic/src/stapi_pydantic/order.py b/stapi-pydantic/src/stapi_pydantic/order.py index b11a3b5..6c8d5bf 100644 --- a/stapi-pydantic/src/stapi_pydantic/order.py +++ b/stapi-pydantic/src/stapi_pydantic/order.py @@ -25,6 +25,7 @@ DerivedCollectionBBox, DerivedItemBBox, Link, + NumberMatched, OptionalBBox, omitted_when_none, ) @@ -109,6 +110,7 @@ class OrderStatusCollection(BaseModel, Generic[T]): stapi_version: str = STAPI_VERSION statuses: list[T] links: list[Link] = Field(default_factory=list) + number_matched: NumberMatched = None class StoredOrderRequest(BaseModel): @@ -158,9 +160,7 @@ class OrderCollection(FeatureCollection[Order[T]], DerivedCollectionBBox, Generi stapi_version: str = STAPI_VERSION bbox: OptionalBBox = None links: list[Link] = Field(default_factory=list) - number_matched: int | None = Field( - serialization_alias="numberMatched", default=None, exclude_if=lambda x: x is None - ) + number_matched: NumberMatched = None class OrderRequest(BaseModel, Generic[ORP]): diff --git a/stapi-pydantic/src/stapi_pydantic/product.py b/stapi-pydantic/src/stapi_pydantic/product.py index 12918ed..dc41507 100644 --- a/stapi-pydantic/src/stapi_pydantic/product.py +++ b/stapi-pydantic/src/stapi_pydantic/product.py @@ -5,7 +5,7 @@ from .conformance import ConformsTo from .constants import STAPI_VERSION -from .shared import STAPI_RESPONSE_CONFIG, Link, omitted_when_empty, omitted_when_none +from .shared import STAPI_RESPONSE_CONFIG, Link, NumberMatched, omitted_when_empty, omitted_when_none class ProviderRole(StrEnum): @@ -58,3 +58,4 @@ class ProductCollection(BaseModel): stapi_version: str = STAPI_VERSION links: list[Link] = Field(default_factory=list) products: list[Product] + number_matched: NumberMatched = None diff --git a/stapi-pydantic/src/stapi_pydantic/shared.py b/stapi-pydantic/src/stapi_pydantic/shared.py index ba35b72..c306b68 100644 --- a/stapi-pydantic/src/stapi_pydantic/shared.py +++ b/stapi-pydantic/src/stapi_pydantic/shared.py @@ -2,6 +2,7 @@ from geojson_pydantic.types import BBox from pydantic import ( + AliasChoices, AnyUrl, BaseModel, ConfigDict, @@ -99,6 +100,19 @@ def set_bbox(self) -> Self: return self +# The numberMatched collection field, under its spec alias (the field name is +# also accepted, so keyword construction still works) and omitted when unset. +NumberMatched = Annotated[ + int | None, + Field( + default=None, + validation_alias=AliasChoices("numberMatched", "number_matched"), + serialization_alias="numberMatched", + exclude_if=lambda v: v is None, + ), +] + + class Link(BaseModel): href: AnyUrl rel: str diff --git a/stapi-pydantic/tests/test_opportunity.py b/stapi-pydantic/tests/test_opportunity.py index 574fb33..9b0b495 100644 --- a/stapi-pydantic/tests/test_opportunity.py +++ b/stapi-pydantic/tests/test_opportunity.py @@ -206,3 +206,14 @@ def test_opportunity_collection_bbox_unions_features() -> None: features=[model.model_validate(OPPORTUNITY_DICT), model.model_validate(other)] ) assert collection.bbox == (13.4, 52.5, 14.4, 53.5) + + +def test_opportunity_collection_number_matched() -> None: + collection: OpportunityCollection[Any, Any] = OpportunityCollection(features=[], number_matched=3) + assert collection.model_dump(mode="json")["numberMatched"] == 3 + assert "numberMatched" not in OpportunityCollection(features=[]).model_dump(mode="json") + + +def test_search_record_collection_number_matched() -> None: + collection = OpportunitySearchRecordCollection(records=[], number_matched=0) + assert collection.model_dump(mode="json")["numberMatched"] == 0 diff --git a/stapi-pydantic/tests/test_order.py b/stapi-pydantic/tests/test_order.py index b7951d4..21ddf05 100644 --- a/stapi-pydantic/tests/test_order.py +++ b/stapi-pydantic/tests/test_order.py @@ -277,3 +277,9 @@ def test_order_collection_bbox_degrades_to_2d_when_members_are_mixed() -> None: def test_order_empty_geometry_bbox_error_is_clear() -> None: with pytest.raises(pydantic.ValidationError, match="bbox"): Order[OrderStatus].model_validate({**ORDER_DICT, "geometry": {"type": "MultiPoint", "coordinates": []}}) + + +def test_order_collection_number_matched_not_serialization_required() -> None: + schema = OrderCollection[OrderStatus].model_json_schema(mode="serialization") + assert {"type", "stapi_type", "stapi_version", "links", "features"} <= set(schema["required"]) + assert "numberMatched" not in schema["required"] diff --git a/stapi-pydantic/tests/test_product.py b/stapi-pydantic/tests/test_product.py index 5d7ef1b..733f270 100644 --- a/stapi-pydantic/tests/test_product.py +++ b/stapi-pydantic/tests/test_product.py @@ -40,3 +40,9 @@ def test_products_collection_dumps_nested_product_by_alias() -> None: def test_product_collection_is_named_for_its_stapi_type() -> None: assert ProductCollection.model_fields["stapi_type"].default == "ProductCollection" + + +def test_products_collection_number_matched() -> None: + collection = ProductCollection(products=[], number_matched=12) + assert collection.model_dump(mode="json")["numberMatched"] == 12 + assert "numberMatched" not in ProductCollection(products=[]).model_dump(mode="json") diff --git a/stapi-pydantic/tests/test_shared.py b/stapi-pydantic/tests/test_shared.py index 118a363..a47e2c5 100644 --- a/stapi-pydantic/tests/test_shared.py +++ b/stapi-pydantic/tests/test_shared.py @@ -1,6 +1,7 @@ from typing import Any import pytest +import stapi_pydantic from pydantic import BaseModel from stapi_pydantic import ( Conformance, @@ -89,3 +90,92 @@ def test_collection_bbox_is_omitted_when_there_is_no_extent(model: type[BaseMode #: Models whose spec-OPTIONAL fields must be omitted rather than published as #: null, and so must not appear in the serialization-required set. + + +def _number_matched_collections() -> list[tuple[type[BaseModel], dict[str, Any]]]: + """Every exported model carrying the shared NumberMatched field. + + Discovered rather than listed, so a collection added later cannot quietly + escape the checks below. + """ + discovered: list[tuple[type[BaseModel], dict[str, Any]]] = [] + seen: set[int] = set() + for name in stapi_pydantic.__all__: + model = getattr(stapi_pydantic, name) + if not (isinstance(model, type) and issubclass(model, BaseModel)): + continue + if "number_matched" not in model.model_fields: + continue + # Deduplicate by identity, not by name: a deprecated alias exports the + # same class twice and would otherwise be parametrized twice. + if id(model) in seen: + continue + seen.add(id(model)) + required = [field for field, info in model.model_fields.items() if info.is_required()] + discovered.append((model, dict.fromkeys(required, []))) + return discovered + + +NUMBER_MATCHED_COLLECTIONS = _number_matched_collections() + + +def test_number_matched_collections_were_discovered() -> None: + """Guard the discovery above: a bug there would silently parametrize nothing.""" + assert len(NUMBER_MATCHED_COLLECTIONS) >= 6 + assert all(payload for _, payload in NUMBER_MATCHED_COLLECTIONS) + + +def test_every_aliased_model_dumps_by_alias() -> None: + # a model that declares an alias but does not dump by it emits field names + # that contradict its own published schema whenever it is dumped outside a + # by-alias context (e.g. nested in another model) + offenders: list[str] = [] + checked: list[str] = [] + for name in stapi_pydantic.__all__: + model = getattr(stapi_pydantic, name) + if not (isinstance(model, type) and issubclass(model, BaseModel)): + continue + for field_name, field in model.model_fields.items(): + alias = field.serialization_alias or field.alias + if alias is None or alias == field_name: + continue + # model_construct builds the model without knowing its required + # fields; the placeholder value keeps `exclude_if` from omitting the + # aliased field before it can be checked. + placeholder: dict[str, Any] = {field_name: 1} + dumped = model.model_construct(**placeholder).model_dump(warnings=False) + checked.append(f"{model.__name__}.{field_name}") + if alias not in dumped or field_name in dumped: + offenders.append(f"{model.__name__}.{field_name}") + assert offenders == [] + # guard the discovery: a bug there would check nothing and still pass + assert {"Conformance.conforms_to", "Product.type_", "ProductCollection.number_matched"} <= set(checked) + + +@pytest.mark.parametrize(("model", "payload"), NUMBER_MATCHED_COLLECTIONS, ids=lambda v: getattr(v, "__name__", "")) +def test_number_matched_round_trips_under_wire_name(model: type[BaseModel], payload: dict[str, Any]) -> None: + collection = model.model_validate({**payload, "links": [], "numberMatched": 7}) + assert collection.number_matched == 7 # type: ignore[attr-defined] + assert collection.model_dump(mode="json")["numberMatched"] == 7 + assert collection.model_dump()["numberMatched"] == 7 + + +@pytest.mark.parametrize(("model", "payload"), NUMBER_MATCHED_COLLECTIONS, ids=lambda v: getattr(v, "__name__", "")) +def test_number_matched_accepts_field_name_and_is_omitted_when_unset( + model: type[BaseModel], payload: dict[str, Any] +) -> None: + assert model.model_validate({**payload, "number_matched": 7}).number_matched == 7 # type: ignore[attr-defined] + assert "numberMatched" not in model.model_validate(payload).model_dump(mode="json") + + +@pytest.mark.parametrize(("model", "payload"), NUMBER_MATCHED_COLLECTIONS, ids=lambda v: getattr(v, "__name__", "")) +def test_number_matched_stays_optional_and_aliased_in_json_schema( + model: type[BaseModel], payload: dict[str, Any] +) -> None: + validation = model.model_json_schema(mode="validation") + assert "numberMatched" in validation["properties"] + assert "numberMatched" not in validation.get("required", []) + + serialization = model.model_json_schema(mode="serialization") + assert "numberMatched" in serialization["properties"] + assert "numberMatched" not in serialization.get("required", []) From 0fdbb8d14e58bc12d146aed24e7d2302c6988c76 Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 6 Aug 2026 18:40:15 -0700 Subject: [PATCH 21/48] refactor: name parameterized generics readably in published schemas pydantic builds a parameterized model's schema name from each parameter's repr. For the `Geometry` union that repr is 150 characters of `Annotated_Union_Point__MultiPoint__LineString__...`, so `OpportunityCollection[Geometry, MyProperties]` was published as a 200-character component name and repeated in full at every `$ref` to it. `StapiGenericModel` overrides `model_parametrized_name` to spell each parameter with a short name -- a class supplies its own, and the one alias that does not is named explicitly. Anything unnameable falls back to pydantic's own behaviour rather than guessing. Only the parameter spelling changes, so the result is `OpportunityCollection[Geometry, MyProperties]`. This does change component names in the generated OpenAPI document, which the changelog now records: a client generated against an earlier build needs regenerating. --- stapi-pydantic/CHANGELOG.md | 1 + .../src/stapi_pydantic/opportunity.py | 7 ++-- stapi-pydantic/src/stapi_pydantic/order.py | 13 +++--- stapi-pydantic/src/stapi_pydantic/shared.py | 38 +++++++++++++++++- stapi-pydantic/tests/test_shared.py | 40 ++++++++++++++++++- 5 files changed, 87 insertions(+), 12 deletions(-) diff --git a/stapi-pydantic/CHANGELOG.md b/stapi-pydantic/CHANGELOG.md index bbbda48..9b8584b 100644 --- a/stapi-pydantic/CHANGELOG.md +++ b/stapi-pydantic/CHANGELOG.md @@ -31,6 +31,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **BREAKING** `DatetimeInterval` now denotes the general interval, which may be open on one end via `..` or an empty string, and validates as a `tuple[AwareDatetime | None, AwareDatetime | None]`. The both-ends-bounded form is now `BoundedDatetimeInterval`. Code that relied on `DatetimeInterval` rejecting open ends, or on both tuple members being non-`None`, must switch to `BoundedDatetimeInterval`. - **BREAKING** `Geometry` is the six-member STAPI union and no longer includes `GeometryCollection`. The spec enumerates exactly six geometry conformance classes, so a `GeometryCollection` was a value no implementation could declare support for. Import `geojson_pydantic.geometries.Geometry` directly if you need the wider union. - `CQL2Filter` is typed as `dict[str, Any]` rather than a bare `dict`. +- Parameterized STAPI generics publish readable schema component names. pydantic names a parameterized schema from each parameter's repr, so `OpportunityCollection[Geometry, MyProperties]` was published as a 200-character `OpportunityCollection_Annotated_Union_Point__MultiPoint__...` component, repeated in every `$ref` to it. This changes component names in the generated OpenAPI document, so a client generated against an earlier build needs regenerating. - **BREAKING** `Link` omits its unset fields from `model_dump()` as well as from JSON output. The `None`-filtering serializer it previously carried applied only to JSON dumps. - **BREAKING** Models that declare aliases now serialize by alias. `model_dump()` emits `conformsTo` on `Conformance` and `type` on `Product`, where it previously emitted the Python field names `conforms_to` and `type_`. Callers already passing `by_alias=True` are unaffected; callers reading the Python names out of a dump must switch to the wire names. - **BREAKING** `Product.conformsTo` and `RootResponse.conformsTo` are spelled `conforms_to` in Python, matching `Conformance`. The wire name is unchanged: all three validate from either `conformsTo` or `conforms_to` and serialize as `conformsTo`. Keyword construction and attribute access must use the new name. diff --git a/stapi-pydantic/src/stapi_pydantic/opportunity.py b/stapi-pydantic/src/stapi_pydantic/opportunity.py index 63fea9f..2717dc6 100644 --- a/stapi-pydantic/src/stapi_pydantic/opportunity.py +++ b/stapi-pydantic/src/stapi_pydantic/opportunity.py @@ -20,6 +20,7 @@ Link, NumberMatched, OptionalBBox, + StapiGenericModel, omitted_when_none, ) @@ -61,7 +62,7 @@ def body(self) -> dict[str, Any]: P = TypeVar("P", bound=OpportunityProperties) -class Opportunity(Feature[G, P], DerivedItemBBox): +class Opportunity(Feature[G, P], StapiGenericModel, DerivedItemBBox): model_config = STAPI_RESPONSE_CONFIG id: str | None = omitted_when_none() @@ -74,7 +75,7 @@ class Opportunity(Feature[G, P], DerivedItemBBox): links: list[Link] = Field(default_factory=list) -class OpportunityCollection(FeatureCollection[Opportunity[G, P]], DerivedCollectionBBox): +class OpportunityCollection(FeatureCollection[Opportunity[G, P]], StapiGenericModel, DerivedCollectionBBox): model_config = STAPI_RESPONSE_CONFIG type: Literal["FeatureCollection"] = "FeatureCollection" @@ -99,7 +100,7 @@ class OpportunitySearchStatusCode(StrEnum): SearchStatusCode = DefaultTypeVar("SearchStatusCode", bound=str, default=AnySearchStatusCode) -class OpportunitySearchStatus(BaseModel, Generic[SearchStatusCode]): +class OpportunitySearchStatus(StapiGenericModel, Generic[SearchStatusCode]): """A search record status; parameterize with a StrEnum (``OpportunitySearchStatus[MyCodes]``) to constrain status_code to an implementation-defined set.""" diff --git a/stapi-pydantic/src/stapi_pydantic/order.py b/stapi-pydantic/src/stapi_pydantic/order.py index 6c8d5bf..d54af51 100644 --- a/stapi-pydantic/src/stapi_pydantic/order.py +++ b/stapi-pydantic/src/stapi_pydantic/order.py @@ -27,6 +27,7 @@ Link, NumberMatched, OptionalBBox, + StapiGenericModel, omitted_when_none, ) @@ -70,7 +71,7 @@ class OrderStatusCode(StrEnum): StatusCode = DefaultTypeVar("StatusCode", bound=str, default=AnyOrderStatusCode) -class OrderStatus(BaseModel, Generic[StatusCode]): +class OrderStatus(StapiGenericModel, Generic[StatusCode]): """An order status; parameterize with a StrEnum (``OrderStatus[MyCodes]``) to constrain status_code to an implementation-defined set.""" @@ -103,7 +104,7 @@ def new( T = DefaultTypeVar("T", bound=OrderStatus[Any], default=OrderStatus) -class OrderStatusCollection(BaseModel, Generic[T]): +class OrderStatusCollection(StapiGenericModel, Generic[T]): model_config = STAPI_RESPONSE_CONFIG stapi_type: Literal["OrderStatusCollection"] = "OrderStatusCollection" @@ -126,7 +127,7 @@ class StoredOrderRequest(BaseModel): order_parameters: BaseOrderParameters = Field(default_factory=BaseOrderParameters) -class OrderProperties(BaseModel, Generic[T]): +class OrderProperties(StapiGenericModel, Generic[T]): model_config = STAPI_RESPONSE_CONFIG_ALLOW_EXTRA product_id: str @@ -135,7 +136,7 @@ class OrderProperties(BaseModel, Generic[T]): order_request: StoredOrderRequest -class Order(Feature[Geometry, OrderProperties[T]], DerivedItemBBox, Generic[T]): +class Order(Feature[Geometry, OrderProperties[T]], StapiGenericModel, DerivedItemBBox, Generic[T]): model_config = STAPI_RESPONSE_CONFIG # We need to enforce that orders have an id defined, as that is required to @@ -152,7 +153,7 @@ class Order(Feature[Geometry, OrderProperties[T]], DerivedItemBBox, Generic[T]): links: list[Link] = Field(default_factory=list) -class OrderCollection(FeatureCollection[Order[T]], DerivedCollectionBBox, Generic[T]): +class OrderCollection(FeatureCollection[Order[T]], StapiGenericModel, DerivedCollectionBBox, Generic[T]): model_config = STAPI_RESPONSE_CONFIG type: Literal["FeatureCollection"] = "FeatureCollection" @@ -163,7 +164,7 @@ class OrderCollection(FeatureCollection[Order[T]], DerivedCollectionBBox, Generi number_matched: NumberMatched = None -class OrderRequest(BaseModel, Generic[ORP]): +class OrderRequest(StapiGenericModel, Generic[ORP]): """STAPI Order Request Object. An omitted order_parameters is equivalent to an empty object, so products diff --git a/stapi-pydantic/src/stapi_pydantic/shared.py b/stapi-pydantic/src/stapi_pydantic/shared.py index c306b68..a53c727 100644 --- a/stapi-pydantic/src/stapi_pydantic/shared.py +++ b/stapi-pydantic/src/stapi_pydantic/shared.py @@ -10,7 +10,7 @@ model_validator, ) -from .geometry import bbox_from_geometry_input, union_bboxes +from .geometry import Geometry, bbox_from_geometry_input, union_bboxes # Shared config for the models that make up STAPI responses. Must be set on # every model that declares an alias, not just the outermost one: model config @@ -27,6 +27,42 @@ STAPI_RESPONSE_CONFIG_ALLOW_EXTRA: ConfigDict = {**STAPI_RESPONSE_CONFIG, "extra": "allow"} +#: Readable names for the type aliases the STAPI generics are parameterized +#: with. A list of pairs rather than a dict because an ``Annotated`` alias is +#: not hashable in every Python version. +_ALIAS_NAMES: list[tuple[Any, str]] = [(Geometry, "Geometry")] + + +def _parameter_name(parameter: Any) -> str | None: + """A short, readable name for one generic parameter, or None if it has none. + + A class supplies its own; a type alias does not -- ``Geometry.__name__`` is + the useless ``"Annotated"``. + """ + for alias, name in _ALIAS_NAMES: + if parameter is alias: + return name + return parameter.__name__ if isinstance(parameter, type) else None + + +class StapiGenericModel(BaseModel): + """Base giving a generic STAPI model a readable parameterized name. + + Pydantic names a parameterized schema using each parameter's *repr*, which + for the ``Geometry`` union is 150 characters of + ``Annotated_Union_Point__MultiPoint__...``, published as a component name + and in every ``$ref`` to it. Only the parameter spelling changes here, so + the result is e.g. ``OpportunityCollection[Geometry, MyProperties]``. + """ + + @classmethod + def model_parametrized_name(cls, params: tuple[type[Any], ...]) -> str: + names = [_parameter_name(param) for param in params] + if any(name is None for name in names): + return super().model_parametrized_name(params) + return f"{cls.__name__}[{', '.join(name for name in names if name is not None)}]" + + def omitted_when_none(**kwargs: Any) -> Any: """A spec-OPTIONAL field that is omitted rather than serialized as null. diff --git a/stapi-pydantic/tests/test_shared.py b/stapi-pydantic/tests/test_shared.py index a47e2c5..3974c9b 100644 --- a/stapi-pydantic/tests/test_shared.py +++ b/stapi-pydantic/tests/test_shared.py @@ -1,13 +1,17 @@ -from typing import Any +from typing import Annotated, Any import pytest import stapi_pydantic -from pydantic import BaseModel +from geojson_pydantic.geometries import Point +from pydantic import BaseModel, Field from stapi_pydantic import ( Conformance, + Geometry, Link, OpportunityCollection, + OpportunityProperties, OrderCollection, + OrderStatus, Product, Provider, RootResponse, @@ -179,3 +183,35 @@ def test_number_matched_stays_optional_and_aliased_in_json_schema( serialization = model.model_json_schema(mode="serialization") assert "numberMatched" in serialization["properties"] assert "numberMatched" not in serialization.get("required", []) + + +def test_parameterized_generics_are_named_readably() -> None: + """Pydantic names a parameterization after the *repr* of its parameters, which + for the `Geometry` union is 150 characters. Only the parameter spelling + changes here; the surrounding `Model[param, ...]` form is still pydantic's. + """ + assert OpportunityCollection[Geometry, OpportunityProperties].__name__ == ( + "OpportunityCollection[Geometry, OpportunityProperties]" + ) + assert OrderCollection[OrderStatus].__name__ == "OrderCollection[OrderStatus]" + + +def test_parameterizations_are_named_apart() -> None: + """A server mounting several products has several parameterizations of one + generic, so the name has to keep them distinct rather than collapse them.""" + + class OtherProperties(OpportunityProperties): + pass + + assert ( + OpportunityCollection[Geometry, OpportunityProperties].__name__ + != OpportunityCollection[Geometry, OtherProperties].__name__ + ) + + +def test_unnameable_parameter_falls_back_to_pydantic() -> None: + """A parameter with no short name must not be given one that could collide.""" + name = OpportunityCollection[Annotated[Point, Field(title="anything")], OpportunityProperties].__name__ + + assert name.startswith("OpportunityCollection[") + assert "OpportunityProperties" in name From 78a16cece24f2b427722fa797d594eec61410aa0 Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 6 Aug 2026 18:41:05 -0700 Subject: [PATCH 22/48] build!: drop the runtime dependencies stapi-fastapi never imported `httpx`, `pygeofilter`, `nox`, `pydantic-settings` and `uvicorn` were declared as runtime dependencies of the library, and none of them is imported by anything under `src/`. Every application installing stapi-fastapi was pulling in a test client, a filter parser, a task runner, a settings library and a server it may not use. `httpx` moves to the dev group, where the test client actually needs it. The floor on stapi-pydantic rises to 0.2.0, which is where the models this release targets live, and the package version moves to 0.9.0. --- stapi-fastapi/CHANGELOG.md | 6 + stapi-fastapi/pyproject.toml | 11 +- uv.lock | 251 +---------------------------------- 3 files changed, 13 insertions(+), 255 deletions(-) diff --git a/stapi-fastapi/CHANGELOG.md b/stapi-fastapi/CHANGELOG.md index 23e23da..6056821 100644 --- a/stapi-fastapi/CHANGELOG.md +++ b/stapi-fastapi/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Removed + +- **BREAKING** The runtime dependencies the library never imported: `httpx`, `pygeofilter`, `nox`, `pydantic-settings`, and `uvicorn`. If your application imports any of these, depend on it directly. `httpx` remains a development dependency, for the test client. + ## [0.8.0] - 2025-12-18 ### Added diff --git a/stapi-fastapi/pyproject.toml b/stapi-fastapi/pyproject.toml index d97a9e8..e72b3e0 100644 --- a/stapi-fastapi/pyproject.toml +++ b/stapi-fastapi/pyproject.toml @@ -1,10 +1,11 @@ [project] name = "stapi-fastapi" -version = "0.8.0" +version = "0.9.0" description = "Sensor Tasking API (STAPI) with FastAPI" authors = [ { name = "Christian Wygoda", email = "christian.wygoda@wygoda.net" }, { name = "Phil Varner", email = "phil@philvarner.com" }, + { name = "Jarrett Keifer", email = "jkeifer0@gmail.com" }, ] readme = "README.md" license = "MIT" @@ -12,21 +13,17 @@ license = "MIT" requires-python = ">=3.11" dependencies = [ - "httpx>=0.27.0", "fastapi>=0.115.0", "pydantic>=2.10", "geojson-pydantic>=1.1", - "pygeofilter>=0.2", "returns>=0.23", - "nox>=2024.4.15", - "pydantic-settings>=2.2.1", - "uvicorn>=0.29.0", - "stapi-pydantic>=0.1.0", + "stapi-pydantic>=0.2.0", ] [dependency-groups] dev = [ "fastapi[standard]>=0.115.0", + "httpx>=0.27.0", # used by the test client, not by the library "pytest>=8.3.5", ] diff --git a/uv.lock b/uv.lock index 87edbf5..f8c808e 100644 --- a/uv.lock +++ b/uv.lock @@ -57,15 +57,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/69/4c/18c89dabeaa60ebabffe53375aa3b9853ef10c47fdb3dfa979b5dbbfe4f7/application_properties-0.9.0-py3-none-any.whl", hash = "sha256:2f3d4cba46c4807c0dad5df632c379f1676d2c3b1a45a962f4f4527ce2713c97", size = 22433, upload-time = "2025-07-02T02:06:43.781Z" }, ] -[[package]] -name = "argcomplete" -version = "3.6.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/38/61/0b9ae6399dd4a58d8c1b1dc5a27d6f2808023d0b5dd3104bb99f45a33ff6/argcomplete-3.6.3.tar.gz", hash = "sha256:62e8ed4fd6a45864acc8235409461b72c9a28ee785a2011cc5eb78318786c89c", size = 73754, upload-time = "2025-10-20T03:33:34.741Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/74/f5/9373290775639cb67a2fce7f629a1c240dce9f12fe927bc32b2736e16dfc/argcomplete-3.6.3-py3-none-any.whl", hash = "sha256:f5007b3a600ccac5d25bbce33089211dfd49eab4a7718da3f10e3082525a92ce", size = 43846, upload-time = "2025-10-20T03:33:33.021Z" }, -] - [[package]] name = "arrow" version = "1.4.0" @@ -293,18 +284,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] -[[package]] -name = "colorlog" -version = "6.10.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a2/61/f083b5ac52e505dfc1c624eafbf8c7589a0d7f32daa398d2e7590efa5fda/colorlog-6.10.1.tar.gz", hash = "sha256:eb4ae5cb65fe7fec7773c2306061a8e63e02efc2c72eba9d27b0fa23c94f1321", size = 17162, upload-time = "2025-10-16T16:14:11.978Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/c1/e419ef3723a074172b68aaa89c9f3de486ed4c2399e2dbd8113a4fdcaf9e/colorlog-6.10.1-py3-none-any.whl", hash = "sha256:2d7e8348291948af66122cff006c9f8da6255d224e7cf8e37d8de2df3bad8c9c", size = 11743, upload-time = "2025-10-16T16:14:10.512Z" }, -] - [[package]] name = "columnar" version = "1.4.1" @@ -402,33 +381,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0d/c3/e90f4a4feae6410f914f8ebac129b9ae7a8c92eb60a638012dde42030a9d/cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c", size = 3438528, upload-time = "2025-10-15T23:18:26.227Z" }, ] -[[package]] -name = "dateparser" -version = "1.2.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "python-dateutil" }, - { name = "pytz" }, - { name = "regex" }, - { name = "tzlocal" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a9/30/064144f0df1749e7bb5faaa7f52b007d7c2d08ec08fed8411aba87207f68/dateparser-1.2.2.tar.gz", hash = "sha256:986316f17cb8cdc23ea8ce563027c5ef12fc725b6fb1d137c14ca08777c5ecf7", size = 329840, upload-time = "2025-06-26T09:29:23.211Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/87/22/f020c047ae1346613db9322638186468238bcfa8849b4668a22b97faad65/dateparser-1.2.2-py3-none-any.whl", hash = "sha256:5a5d7211a09013499867547023a2a0c91d5a27d15dd4dbcea676ea9fe66f2482", size = 315453, upload-time = "2025-06-26T09:29:21.412Z" }, -] - -[[package]] -name = "dependency-groups" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "packaging" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/62/55/f054de99871e7beb81935dea8a10b90cd5ce42122b1c3081d5282fdb3621/dependency_groups-1.3.1.tar.gz", hash = "sha256:78078301090517fd938c19f64a53ce98c32834dfe0dee6b88004a569a6adfefd", size = 10093, upload-time = "2025-05-02T00:34:29.452Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/99/c7/d1ec24fb280caa5a79b6b950db565dab30210a66259d17d5bb2b3a9f878d/dependency_groups-1.3.1-py3-none-any.whl", hash = "sha256:51aeaa0dfad72430fcfb7bcdbefbd75f3792e5919563077f30bc0d73f4493030", size = 8664, upload-time = "2025-05-02T00:34:27.085Z" }, -] - [[package]] name = "distlib" version = "0.4.0" @@ -764,15 +716,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] -[[package]] -name = "humanize" -version = "4.14.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b6/43/50033d25ad96a7f3845f40999b4778f753c3901a11808a584fed7c00d9f5/humanize-4.14.0.tar.gz", hash = "sha256:2fa092705ea640d605c435b1ca82b2866a1b601cdf96f076d70b79a855eba90d", size = 82939, upload-time = "2025-10-15T13:04:51.214Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/5b/9512c5fb6c8218332b530f13500c6ff5f3ce3342f35e0dd7be9ac3856fd3/humanize-4.14.0-py3-none-any.whl", hash = "sha256:d57701248d040ad456092820e6fde56c930f17749956ac47f4f655c0c547bfff", size = 132092, upload-time = "2025-10-15T13:04:49.404Z" }, -] - [[package]] name = "hypothesis" version = "6.148.5" @@ -922,15 +865,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/93/2d896b5fd3d79b4cadd8882c06650e66d003f465c9d12c488d92853dff78/junit_xml-1.9-py2.py3-none-any.whl", hash = "sha256:ec5ca1a55aefdd76d28fcc0b135251d156c7106fa979686a4b48d62b761b4732", size = 7130, upload-time = "2020-02-22T20:41:37.661Z" }, ] -[[package]] -name = "lark" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/da/34/28fff3ab31ccff1fd4f6c7c7b0ceb2b6968d8ea4950663eadcb5720591a0/lark-1.3.1.tar.gz", hash = "sha256:b426a7a6d6d53189d318f2b6236ab5d6429eaf09259f1ca33eb716eed10d2905", size = 382732, upload-time = "2025-10-27T18:25:56.653Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl", hash = "sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12", size = 113151, upload-time = "2025-10-27T18:25:54.882Z" }, -] - [[package]] name = "librt" version = "0.6.3" @@ -1278,24 +1212,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, ] -[[package]] -name = "nox" -version = "2025.11.12" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "argcomplete" }, - { name = "attrs" }, - { name = "colorlog" }, - { name = "dependency-groups" }, - { name = "humanize" }, - { name = "packaging" }, - { name = "virtualenv" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b4/a8/e169497599266d176832e2232c08557ffba97eef87bf8a18f9f918e0c6aa/nox-2025.11.12.tar.gz", hash = "sha256:3d317f9e61f49d6bde39cf2f59695bb4e1722960457eee3ae19dacfe03c07259", size = 4030561, upload-time = "2025-11-12T18:39:03.319Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/34/434c594e0125a16b05a7bedaea33e63c90abbfbe47e5729a735a8a8a90ea/nox-2025.11.12-py3-none-any.whl", hash = "sha256:707171f9f63bc685da9d00edd8c2ceec8405b8e38b5fb4e46114a860070ef0ff", size = 74447, upload-time = "2025-11-12T18:39:01.575Z" }, -] - [[package]] name = "packaging" version = "25.0" @@ -1495,46 +1411,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, ] -[[package]] -name = "pydantic-settings" -version = "2.12.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "python-dotenv" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, -] - -[[package]] -name = "pygeofilter" -version = "0.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "dateparser" }, - { name = "lark" }, - { name = "pygeoif" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/61/60/0aa6583cb96123317bfe08d75aac4aa9ef60be0611a530e78c594c1ae168/pygeofilter-0.3.1.tar.gz", hash = "sha256:f92bc0622099f87fe8b36de7abdd86059d615a89ed60822737082223a578e150", size = 58151, upload-time = "2024-12-31T11:17:10.072Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/5a/ff8edd2ea65c13b9dfaefe7953509def5c37545a6b5dc5bb17bee1e901bb/pygeofilter-0.3.1-py2.py3-none-any.whl", hash = "sha256:f13dcdd685bdca32cf9e6665bf2938522276bee140abff321ab04abb12f5761d", size = 87339, upload-time = "2024-12-31T11:17:06.985Z" }, -] - -[[package]] -name = "pygeoif" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/02/2e/c6660ceea2fc28feefdfb0389bf53b5d0e0ba92aaba72e813901cb0552ed/pygeoif-1.6.0.tar.gz", hash = "sha256:eb0efa59c6573ea2cadce69a7ea9d2d10394b895ed47831c00d44752219c01be", size = 40915, upload-time = "2025-10-01T10:02:13.429Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/7f/c803c39fa76fe055bc4154fb6e897185ad21946820a2227283e0a20eeb35/pygeoif-1.6.0-py3-none-any.whl", hash = "sha256:02f84807dadbaf1941c4bb2a9ef1ebac99b1b0404597d2602efdbb58910c69c9", size = 27976, upload-time = "2025-10-01T10:02:12.19Z" }, -] - [[package]] name = "pygithub" version = "2.8.1" @@ -1943,15 +1819,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, ] -[[package]] -name = "pytz" -version = "2025.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, -] - [[package]] name = "pyyaml" version = "6.0.3" @@ -2033,98 +1900,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, ] -[[package]] -name = "regex" -version = "2025.11.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cc/a9/546676f25e573a4cf00fe8e119b78a37b6a8fe2dc95cda877b30889c9c45/regex-2025.11.3.tar.gz", hash = "sha256:1fedc720f9bb2494ce31a58a1631f9c82df6a09b49c19517ea5cc280b4541e01", size = 414669, upload-time = "2025-11-03T21:34:22.089Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/90/4fb5056e5f03a7048abd2b11f598d464f0c167de4f2a51aa868c376b8c70/regex-2025.11.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eadade04221641516fa25139273505a1c19f9bf97589a05bc4cfcd8b4a618031", size = 488081, upload-time = "2025-11-03T21:31:11.946Z" }, - { url = "https://files.pythonhosted.org/packages/85/23/63e481293fac8b069d84fba0299b6666df720d875110efd0338406b5d360/regex-2025.11.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:feff9e54ec0dd3833d659257f5c3f5322a12eee58ffa360984b716f8b92983f4", size = 290554, upload-time = "2025-11-03T21:31:13.387Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9d/b101d0262ea293a0066b4522dfb722eb6a8785a8c3e084396a5f2c431a46/regex-2025.11.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3b30bc921d50365775c09a7ed446359e5c0179e9e2512beec4a60cbcef6ddd50", size = 288407, upload-time = "2025-11-03T21:31:14.809Z" }, - { url = "https://files.pythonhosted.org/packages/0c/64/79241c8209d5b7e00577ec9dca35cd493cc6be35b7d147eda367d6179f6d/regex-2025.11.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f99be08cfead2020c7ca6e396c13543baea32343b7a9a5780c462e323bd8872f", size = 793418, upload-time = "2025-11-03T21:31:16.556Z" }, - { url = "https://files.pythonhosted.org/packages/3d/e2/23cd5d3573901ce8f9757c92ca4db4d09600b865919b6d3e7f69f03b1afd/regex-2025.11.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6dd329a1b61c0ee95ba95385fb0c07ea0d3fe1a21e1349fa2bec272636217118", size = 860448, upload-time = "2025-11-03T21:31:18.12Z" }, - { url = "https://files.pythonhosted.org/packages/2a/4c/aecf31beeaa416d0ae4ecb852148d38db35391aac19c687b5d56aedf3a8b/regex-2025.11.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4c5238d32f3c5269d9e87be0cf096437b7622b6920f5eac4fd202468aaeb34d2", size = 907139, upload-time = "2025-11-03T21:31:20.753Z" }, - { url = "https://files.pythonhosted.org/packages/61/22/b8cb00df7d2b5e0875f60628594d44dba283e951b1ae17c12f99e332cc0a/regex-2025.11.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10483eefbfb0adb18ee9474498c9a32fcf4e594fbca0543bb94c48bac6183e2e", size = 800439, upload-time = "2025-11-03T21:31:22.069Z" }, - { url = "https://files.pythonhosted.org/packages/02/a8/c4b20330a5cdc7a8eb265f9ce593f389a6a88a0c5f280cf4d978f33966bc/regex-2025.11.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:78c2d02bb6e1da0720eedc0bad578049cad3f71050ef8cd065ecc87691bed2b0", size = 782965, upload-time = "2025-11-03T21:31:23.598Z" }, - { url = "https://files.pythonhosted.org/packages/b4/4c/ae3e52988ae74af4b04d2af32fee4e8077f26e51b62ec2d12d246876bea2/regex-2025.11.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e6b49cd2aad93a1790ce9cffb18964f6d3a4b0b3dbdbd5de094b65296fce6e58", size = 854398, upload-time = "2025-11-03T21:31:25.008Z" }, - { url = "https://files.pythonhosted.org/packages/06/d1/a8b9cf45874eda14b2e275157ce3b304c87e10fb38d9fc26a6e14eb18227/regex-2025.11.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:885b26aa3ee56433b630502dc3d36ba78d186a00cc535d3806e6bfd9ed3c70ab", size = 845897, upload-time = "2025-11-03T21:31:26.427Z" }, - { url = "https://files.pythonhosted.org/packages/ea/fe/1830eb0236be93d9b145e0bd8ab499f31602fe0999b1f19e99955aa8fe20/regex-2025.11.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ddd76a9f58e6a00f8772e72cff8ebcff78e022be95edf018766707c730593e1e", size = 788906, upload-time = "2025-11-03T21:31:28.078Z" }, - { url = "https://files.pythonhosted.org/packages/66/47/dc2577c1f95f188c1e13e2e69d8825a5ac582ac709942f8a03af42ed6e93/regex-2025.11.3-cp311-cp311-win32.whl", hash = "sha256:3e816cc9aac1cd3cc9a4ec4d860f06d40f994b5c7b4d03b93345f44e08cc68bf", size = 265812, upload-time = "2025-11-03T21:31:29.72Z" }, - { url = "https://files.pythonhosted.org/packages/50/1e/15f08b2f82a9bbb510621ec9042547b54d11e83cb620643ebb54e4eb7d71/regex-2025.11.3-cp311-cp311-win_amd64.whl", hash = "sha256:087511f5c8b7dfbe3a03f5d5ad0c2a33861b1fc387f21f6f60825a44865a385a", size = 277737, upload-time = "2025-11-03T21:31:31.422Z" }, - { url = "https://files.pythonhosted.org/packages/f4/fc/6500eb39f5f76c5e47a398df82e6b535a5e345f839581012a418b16f9cc3/regex-2025.11.3-cp311-cp311-win_arm64.whl", hash = "sha256:1ff0d190c7f68ae7769cd0313fe45820ba07ffebfddfaa89cc1eb70827ba0ddc", size = 270290, upload-time = "2025-11-03T21:31:33.041Z" }, - { url = "https://files.pythonhosted.org/packages/e8/74/18f04cb53e58e3fb107439699bd8375cf5a835eec81084e0bddbd122e4c2/regex-2025.11.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bc8ab71e2e31b16e40868a40a69007bc305e1109bd4658eb6cad007e0bf67c41", size = 489312, upload-time = "2025-11-03T21:31:34.343Z" }, - { url = "https://files.pythonhosted.org/packages/78/3f/37fcdd0d2b1e78909108a876580485ea37c91e1acf66d3bb8e736348f441/regex-2025.11.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:22b29dda7e1f7062a52359fca6e58e548e28c6686f205e780b02ad8ef710de36", size = 291256, upload-time = "2025-11-03T21:31:35.675Z" }, - { url = "https://files.pythonhosted.org/packages/bf/26/0a575f58eb23b7ebd67a45fccbc02ac030b737b896b7e7a909ffe43ffd6a/regex-2025.11.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3a91e4a29938bc1a082cc28fdea44be420bf2bebe2665343029723892eb073e1", size = 288921, upload-time = "2025-11-03T21:31:37.07Z" }, - { url = "https://files.pythonhosted.org/packages/ea/98/6a8dff667d1af907150432cf5abc05a17ccd32c72a3615410d5365ac167a/regex-2025.11.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08b884f4226602ad40c5d55f52bf91a9df30f513864e0054bad40c0e9cf1afb7", size = 798568, upload-time = "2025-11-03T21:31:38.784Z" }, - { url = "https://files.pythonhosted.org/packages/64/15/92c1db4fa4e12733dd5a526c2dd2b6edcbfe13257e135fc0f6c57f34c173/regex-2025.11.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3e0b11b2b2433d1c39c7c7a30e3f3d0aeeea44c2a8d0bae28f6b95f639927a69", size = 864165, upload-time = "2025-11-03T21:31:40.559Z" }, - { url = "https://files.pythonhosted.org/packages/f9/e7/3ad7da8cdee1ce66c7cd37ab5ab05c463a86ffeb52b1a25fe7bd9293b36c/regex-2025.11.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87eb52a81ef58c7ba4d45c3ca74e12aa4b4e77816f72ca25258a85b3ea96cb48", size = 912182, upload-time = "2025-11-03T21:31:42.002Z" }, - { url = "https://files.pythonhosted.org/packages/84/bd/9ce9f629fcb714ffc2c3faf62b6766ecb7a585e1e885eb699bcf130a5209/regex-2025.11.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a12ab1f5c29b4e93db518f5e3872116b7e9b1646c9f9f426f777b50d44a09e8c", size = 803501, upload-time = "2025-11-03T21:31:43.815Z" }, - { url = "https://files.pythonhosted.org/packages/7c/0f/8dc2e4349d8e877283e6edd6c12bdcebc20f03744e86f197ab6e4492bf08/regex-2025.11.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7521684c8c7c4f6e88e35ec89680ee1aa8358d3f09d27dfbdf62c446f5d4c695", size = 787842, upload-time = "2025-11-03T21:31:45.353Z" }, - { url = "https://files.pythonhosted.org/packages/f9/73/cff02702960bc185164d5619c0c62a2f598a6abff6695d391b096237d4ab/regex-2025.11.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7fe6e5440584e94cc4b3f5f4d98a25e29ca12dccf8873679a635638349831b98", size = 858519, upload-time = "2025-11-03T21:31:46.814Z" }, - { url = "https://files.pythonhosted.org/packages/61/83/0e8d1ae71e15bc1dc36231c90b46ee35f9d52fab2e226b0e039e7ea9c10a/regex-2025.11.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:8e026094aa12b43f4fd74576714e987803a315c76edb6b098b9809db5de58f74", size = 850611, upload-time = "2025-11-03T21:31:48.289Z" }, - { url = "https://files.pythonhosted.org/packages/c8/f5/70a5cdd781dcfaa12556f2955bf170cd603cb1c96a1827479f8faea2df97/regex-2025.11.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:435bbad13e57eb5606a68443af62bed3556de2f46deb9f7d4237bc2f1c9fb3a0", size = 789759, upload-time = "2025-11-03T21:31:49.759Z" }, - { url = "https://files.pythonhosted.org/packages/59/9b/7c29be7903c318488983e7d97abcf8ebd3830e4c956c4c540005fcfb0462/regex-2025.11.3-cp312-cp312-win32.whl", hash = "sha256:3839967cf4dc4b985e1570fd8d91078f0c519f30491c60f9ac42a8db039be204", size = 266194, upload-time = "2025-11-03T21:31:51.53Z" }, - { url = "https://files.pythonhosted.org/packages/1a/67/3b92df89f179d7c367be654ab5626ae311cb28f7d5c237b6bb976cd5fbbb/regex-2025.11.3-cp312-cp312-win_amd64.whl", hash = "sha256:e721d1b46e25c481dc5ded6f4b3f66c897c58d2e8cfdf77bbced84339108b0b9", size = 277069, upload-time = "2025-11-03T21:31:53.151Z" }, - { url = "https://files.pythonhosted.org/packages/d7/55/85ba4c066fe5094d35b249c3ce8df0ba623cfd35afb22d6764f23a52a1c5/regex-2025.11.3-cp312-cp312-win_arm64.whl", hash = "sha256:64350685ff08b1d3a6fff33f45a9ca183dc1d58bbfe4981604e70ec9801bbc26", size = 270330, upload-time = "2025-11-03T21:31:54.514Z" }, - { url = "https://files.pythonhosted.org/packages/e1/a7/dda24ebd49da46a197436ad96378f17df30ceb40e52e859fc42cac45b850/regex-2025.11.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c1e448051717a334891f2b9a620fe36776ebf3dd8ec46a0b877c8ae69575feb4", size = 489081, upload-time = "2025-11-03T21:31:55.9Z" }, - { url = "https://files.pythonhosted.org/packages/19/22/af2dc751aacf88089836aa088a1a11c4f21a04707eb1b0478e8e8fb32847/regex-2025.11.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9b5aca4d5dfd7fbfbfbdaf44850fcc7709a01146a797536a8f84952e940cca76", size = 291123, upload-time = "2025-11-03T21:31:57.758Z" }, - { url = "https://files.pythonhosted.org/packages/a3/88/1a3ea5672f4b0a84802ee9891b86743438e7c04eb0b8f8c4e16a42375327/regex-2025.11.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:04d2765516395cf7dda331a244a3282c0f5ae96075f728629287dfa6f76ba70a", size = 288814, upload-time = "2025-11-03T21:32:01.12Z" }, - { url = "https://files.pythonhosted.org/packages/fb/8c/f5987895bf42b8ddeea1b315c9fedcfe07cadee28b9c98cf50d00adcb14d/regex-2025.11.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d9903ca42bfeec4cebedba8022a7c97ad2aab22e09573ce9976ba01b65e4361", size = 798592, upload-time = "2025-11-03T21:32:03.006Z" }, - { url = "https://files.pythonhosted.org/packages/99/2a/6591ebeede78203fa77ee46a1c36649e02df9eaa77a033d1ccdf2fcd5d4e/regex-2025.11.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:639431bdc89d6429f6721625e8129413980ccd62e9d3f496be618a41d205f160", size = 864122, upload-time = "2025-11-03T21:32:04.553Z" }, - { url = "https://files.pythonhosted.org/packages/94/d6/be32a87cf28cf8ed064ff281cfbd49aefd90242a83e4b08b5a86b38e8eb4/regex-2025.11.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f117efad42068f9715677c8523ed2be1518116d1c49b1dd17987716695181efe", size = 912272, upload-time = "2025-11-03T21:32:06.148Z" }, - { url = "https://files.pythonhosted.org/packages/62/11/9bcef2d1445665b180ac7f230406ad80671f0fc2a6ffb93493b5dd8cd64c/regex-2025.11.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4aecb6f461316adf9f1f0f6a4a1a3d79e045f9b71ec76055a791affa3b285850", size = 803497, upload-time = "2025-11-03T21:32:08.162Z" }, - { url = "https://files.pythonhosted.org/packages/e5/a7/da0dc273d57f560399aa16d8a68ae7f9b57679476fc7ace46501d455fe84/regex-2025.11.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3b3a5f320136873cc5561098dfab677eea139521cb9a9e8db98b7e64aef44cbc", size = 787892, upload-time = "2025-11-03T21:32:09.769Z" }, - { url = "https://files.pythonhosted.org/packages/da/4b/732a0c5a9736a0b8d6d720d4945a2f1e6f38f87f48f3173559f53e8d5d82/regex-2025.11.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:75fa6f0056e7efb1f42a1c34e58be24072cb9e61a601340cc1196ae92326a4f9", size = 858462, upload-time = "2025-11-03T21:32:11.769Z" }, - { url = "https://files.pythonhosted.org/packages/0c/f5/a2a03df27dc4c2d0c769220f5110ba8c4084b0bfa9ab0f9b4fcfa3d2b0fc/regex-2025.11.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:dbe6095001465294f13f1adcd3311e50dd84e5a71525f20a10bd16689c61ce0b", size = 850528, upload-time = "2025-11-03T21:32:13.906Z" }, - { url = "https://files.pythonhosted.org/packages/d6/09/e1cd5bee3841c7f6eb37d95ca91cdee7100b8f88b81e41c2ef426910891a/regex-2025.11.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:454d9b4ae7881afbc25015b8627c16d88a597479b9dea82b8c6e7e2e07240dc7", size = 789866, upload-time = "2025-11-03T21:32:15.748Z" }, - { url = "https://files.pythonhosted.org/packages/eb/51/702f5ea74e2a9c13d855a6a85b7f80c30f9e72a95493260193c07f3f8d74/regex-2025.11.3-cp313-cp313-win32.whl", hash = "sha256:28ba4d69171fc6e9896337d4fc63a43660002b7da53fc15ac992abcf3410917c", size = 266189, upload-time = "2025-11-03T21:32:17.493Z" }, - { url = "https://files.pythonhosted.org/packages/8b/00/6e29bb314e271a743170e53649db0fdb8e8ff0b64b4f425f5602f4eb9014/regex-2025.11.3-cp313-cp313-win_amd64.whl", hash = "sha256:bac4200befe50c670c405dc33af26dad5a3b6b255dd6c000d92fe4629f9ed6a5", size = 277054, upload-time = "2025-11-03T21:32:19.042Z" }, - { url = "https://files.pythonhosted.org/packages/25/f1/b156ff9f2ec9ac441710764dda95e4edaf5f36aca48246d1eea3f1fd96ec/regex-2025.11.3-cp313-cp313-win_arm64.whl", hash = "sha256:2292cd5a90dab247f9abe892ac584cb24f0f54680c73fcb4a7493c66c2bf2467", size = 270325, upload-time = "2025-11-03T21:32:21.338Z" }, - { url = "https://files.pythonhosted.org/packages/20/28/fd0c63357caefe5680b8ea052131acbd7f456893b69cc2a90cc3e0dc90d4/regex-2025.11.3-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:1eb1ebf6822b756c723e09f5186473d93236c06c579d2cc0671a722d2ab14281", size = 491984, upload-time = "2025-11-03T21:32:23.466Z" }, - { url = "https://files.pythonhosted.org/packages/df/ec/7014c15626ab46b902b3bcc4b28a7bae46d8f281fc7ea9c95e22fcaaa917/regex-2025.11.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:1e00ec2970aab10dc5db34af535f21fcf32b4a31d99e34963419636e2f85ae39", size = 292673, upload-time = "2025-11-03T21:32:25.034Z" }, - { url = "https://files.pythonhosted.org/packages/23/ab/3b952ff7239f20d05f1f99e9e20188513905f218c81d52fb5e78d2bf7634/regex-2025.11.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a4cb042b615245d5ff9b3794f56be4138b5adc35a4166014d31d1814744148c7", size = 291029, upload-time = "2025-11-03T21:32:26.528Z" }, - { url = "https://files.pythonhosted.org/packages/21/7e/3dc2749fc684f455f162dcafb8a187b559e2614f3826877d3844a131f37b/regex-2025.11.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:44f264d4bf02f3176467d90b294d59bf1db9fe53c141ff772f27a8b456b2a9ed", size = 807437, upload-time = "2025-11-03T21:32:28.363Z" }, - { url = "https://files.pythonhosted.org/packages/1b/0b/d529a85ab349c6a25d1ca783235b6e3eedf187247eab536797021f7126c6/regex-2025.11.3-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7be0277469bf3bd7a34a9c57c1b6a724532a0d235cd0dc4e7f4316f982c28b19", size = 873368, upload-time = "2025-11-03T21:32:30.4Z" }, - { url = "https://files.pythonhosted.org/packages/7d/18/2d868155f8c9e3e9d8f9e10c64e9a9f496bb8f7e037a88a8bed26b435af6/regex-2025.11.3-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0d31e08426ff4b5b650f68839f5af51a92a5b51abd8554a60c2fbc7c71f25d0b", size = 914921, upload-time = "2025-11-03T21:32:32.123Z" }, - { url = "https://files.pythonhosted.org/packages/2d/71/9d72ff0f354fa783fe2ba913c8734c3b433b86406117a8db4ea2bf1c7a2f/regex-2025.11.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e43586ce5bd28f9f285a6e729466841368c4a0353f6fd08d4ce4630843d3648a", size = 812708, upload-time = "2025-11-03T21:32:34.305Z" }, - { url = "https://files.pythonhosted.org/packages/e7/19/ce4bf7f5575c97f82b6e804ffb5c4e940c62609ab2a0d9538d47a7fdf7d4/regex-2025.11.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:0f9397d561a4c16829d4e6ff75202c1c08b68a3bdbfe29dbfcdb31c9830907c6", size = 795472, upload-time = "2025-11-03T21:32:36.364Z" }, - { url = "https://files.pythonhosted.org/packages/03/86/fd1063a176ffb7b2315f9a1b08d17b18118b28d9df163132615b835a26ee/regex-2025.11.3-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:dd16e78eb18ffdb25ee33a0682d17912e8cc8a770e885aeee95020046128f1ce", size = 868341, upload-time = "2025-11-03T21:32:38.042Z" }, - { url = "https://files.pythonhosted.org/packages/12/43/103fb2e9811205e7386366501bc866a164a0430c79dd59eac886a2822950/regex-2025.11.3-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:ffcca5b9efe948ba0661e9df0fa50d2bc4b097c70b9810212d6b62f05d83b2dd", size = 854666, upload-time = "2025-11-03T21:32:40.079Z" }, - { url = "https://files.pythonhosted.org/packages/7d/22/e392e53f3869b75804762c7c848bd2dd2abf2b70fb0e526f58724638bd35/regex-2025.11.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c56b4d162ca2b43318ac671c65bd4d563e841a694ac70e1a976ac38fcf4ca1d2", size = 799473, upload-time = "2025-11-03T21:32:42.148Z" }, - { url = "https://files.pythonhosted.org/packages/4f/f9/8bd6b656592f925b6845fcbb4d57603a3ac2fb2373344ffa1ed70aa6820a/regex-2025.11.3-cp313-cp313t-win32.whl", hash = "sha256:9ddc42e68114e161e51e272f667d640f97e84a2b9ef14b7477c53aac20c2d59a", size = 268792, upload-time = "2025-11-03T21:32:44.13Z" }, - { url = "https://files.pythonhosted.org/packages/e5/87/0e7d603467775ff65cd2aeabf1b5b50cc1c3708556a8b849a2fa4dd1542b/regex-2025.11.3-cp313-cp313t-win_amd64.whl", hash = "sha256:7a7c7fdf755032ffdd72c77e3d8096bdcb0eb92e89e17571a196f03d88b11b3c", size = 280214, upload-time = "2025-11-03T21:32:45.853Z" }, - { url = "https://files.pythonhosted.org/packages/8d/d0/2afc6f8e94e2b64bfb738a7c2b6387ac1699f09f032d363ed9447fd2bb57/regex-2025.11.3-cp313-cp313t-win_arm64.whl", hash = "sha256:df9eb838c44f570283712e7cff14c16329a9f0fb19ca492d21d4b7528ee6821e", size = 271469, upload-time = "2025-11-03T21:32:48.026Z" }, - { url = "https://files.pythonhosted.org/packages/31/e9/f6e13de7e0983837f7b6d238ad9458800a874bf37c264f7923e63409944c/regex-2025.11.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:9697a52e57576c83139d7c6f213d64485d3df5bf84807c35fa409e6c970801c6", size = 489089, upload-time = "2025-11-03T21:32:50.027Z" }, - { url = "https://files.pythonhosted.org/packages/a3/5c/261f4a262f1fa65141c1b74b255988bd2fa020cc599e53b080667d591cfc/regex-2025.11.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e18bc3f73bd41243c9b38a6d9f2366cd0e0137a9aebe2d8ff76c5b67d4c0a3f4", size = 291059, upload-time = "2025-11-03T21:32:51.682Z" }, - { url = "https://files.pythonhosted.org/packages/8e/57/f14eeb7f072b0e9a5a090d1712741fd8f214ec193dba773cf5410108bb7d/regex-2025.11.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:61a08bcb0ec14ff4e0ed2044aad948d0659604f824cbd50b55e30b0ec6f09c73", size = 288900, upload-time = "2025-11-03T21:32:53.569Z" }, - { url = "https://files.pythonhosted.org/packages/3c/6b/1d650c45e99a9b327586739d926a1cd4e94666b1bd4af90428b36af66dc7/regex-2025.11.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9c30003b9347c24bcc210958c5d167b9e4f9be786cb380a7d32f14f9b84674f", size = 799010, upload-time = "2025-11-03T21:32:55.222Z" }, - { url = "https://files.pythonhosted.org/packages/99/ee/d66dcbc6b628ce4e3f7f0cbbb84603aa2fc0ffc878babc857726b8aab2e9/regex-2025.11.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4e1e592789704459900728d88d41a46fe3969b82ab62945560a31732ffc19a6d", size = 864893, upload-time = "2025-11-03T21:32:57.239Z" }, - { url = "https://files.pythonhosted.org/packages/bf/2d/f238229f1caba7ac87a6c4153d79947fb0261415827ae0f77c304260c7d3/regex-2025.11.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6538241f45eb5a25aa575dbba1069ad786f68a4f2773a29a2bd3dd1f9de787be", size = 911522, upload-time = "2025-11-03T21:32:59.274Z" }, - { url = "https://files.pythonhosted.org/packages/bd/3d/22a4eaba214a917c80e04f6025d26143690f0419511e0116508e24b11c9b/regex-2025.11.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce22519c989bb72a7e6b36a199384c53db7722fe669ba891da75907fe3587db", size = 803272, upload-time = "2025-11-03T21:33:01.393Z" }, - { url = "https://files.pythonhosted.org/packages/84/b1/03188f634a409353a84b5ef49754b97dbcc0c0f6fd6c8ede505a8960a0a4/regex-2025.11.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:66d559b21d3640203ab9075797a55165d79017520685fb407b9234d72ab63c62", size = 787958, upload-time = "2025-11-03T21:33:03.379Z" }, - { url = "https://files.pythonhosted.org/packages/99/6a/27d072f7fbf6fadd59c64d210305e1ff865cc3b78b526fd147db768c553b/regex-2025.11.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:669dcfb2e38f9e8c69507bace46f4889e3abbfd9b0c29719202883c0a603598f", size = 859289, upload-time = "2025-11-03T21:33:05.374Z" }, - { url = "https://files.pythonhosted.org/packages/9a/70/1b3878f648e0b6abe023172dacb02157e685564853cc363d9961bcccde4e/regex-2025.11.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:32f74f35ff0f25a5021373ac61442edcb150731fbaa28286bbc8bb1582c89d02", size = 850026, upload-time = "2025-11-03T21:33:07.131Z" }, - { url = "https://files.pythonhosted.org/packages/dd/d5/68e25559b526b8baab8e66839304ede68ff6727237a47727d240006bd0ff/regex-2025.11.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e6c7a21dffba883234baefe91bc3388e629779582038f75d2a5be918e250f0ed", size = 789499, upload-time = "2025-11-03T21:33:09.141Z" }, - { url = "https://files.pythonhosted.org/packages/fc/df/43971264857140a350910d4e33df725e8c94dd9dee8d2e4729fa0d63d49e/regex-2025.11.3-cp314-cp314-win32.whl", hash = "sha256:795ea137b1d809eb6836b43748b12634291c0ed55ad50a7d72d21edf1cd565c4", size = 271604, upload-time = "2025-11-03T21:33:10.9Z" }, - { url = "https://files.pythonhosted.org/packages/01/6f/9711b57dc6894a55faf80a4c1b5aa4f8649805cb9c7aef46f7d27e2b9206/regex-2025.11.3-cp314-cp314-win_amd64.whl", hash = "sha256:9f95fbaa0ee1610ec0fc6b26668e9917a582ba80c52cc6d9ada15e30aa9ab9ad", size = 280320, upload-time = "2025-11-03T21:33:12.572Z" }, - { url = "https://files.pythonhosted.org/packages/f1/7e/f6eaa207d4377481f5e1775cdeb5a443b5a59b392d0065f3417d31d80f87/regex-2025.11.3-cp314-cp314-win_arm64.whl", hash = "sha256:dfec44d532be4c07088c3de2876130ff0fbeeacaa89a137decbbb5f665855a0f", size = 273372, upload-time = "2025-11-03T21:33:14.219Z" }, - { url = "https://files.pythonhosted.org/packages/c3/06/49b198550ee0f5e4184271cee87ba4dfd9692c91ec55289e6282f0f86ccf/regex-2025.11.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ba0d8a5d7f04f73ee7d01d974d47c5834f8a1b0224390e4fe7c12a3a92a78ecc", size = 491985, upload-time = "2025-11-03T21:33:16.555Z" }, - { url = "https://files.pythonhosted.org/packages/ce/bf/abdafade008f0b1c9da10d934034cb670432d6cf6cbe38bbb53a1cfd6cf8/regex-2025.11.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:442d86cf1cfe4faabf97db7d901ef58347efd004934da045c745e7b5bd57ac49", size = 292669, upload-time = "2025-11-03T21:33:18.32Z" }, - { url = "https://files.pythonhosted.org/packages/f9/ef/0c357bb8edbd2ad8e273fcb9e1761bc37b8acbc6e1be050bebd6475f19c1/regex-2025.11.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fd0a5e563c756de210bb964789b5abe4f114dacae9104a47e1a649b910361536", size = 291030, upload-time = "2025-11-03T21:33:20.048Z" }, - { url = "https://files.pythonhosted.org/packages/79/06/edbb67257596649b8fb088d6aeacbcb248ac195714b18a65e018bf4c0b50/regex-2025.11.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf3490bcbb985a1ae97b2ce9ad1c0f06a852d5b19dde9b07bdf25bf224248c95", size = 807674, upload-time = "2025-11-03T21:33:21.797Z" }, - { url = "https://files.pythonhosted.org/packages/f4/d9/ad4deccfce0ea336296bd087f1a191543bb99ee1c53093dcd4c64d951d00/regex-2025.11.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3809988f0a8b8c9dcc0f92478d6501fac7200b9ec56aecf0ec21f4a2ec4b6009", size = 873451, upload-time = "2025-11-03T21:33:23.741Z" }, - { url = "https://files.pythonhosted.org/packages/13/75/a55a4724c56ef13e3e04acaab29df26582f6978c000ac9cd6810ad1f341f/regex-2025.11.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f4ff94e58e84aedb9c9fce66d4ef9f27a190285b451420f297c9a09f2b9abee9", size = 914980, upload-time = "2025-11-03T21:33:25.999Z" }, - { url = "https://files.pythonhosted.org/packages/67/1e/a1657ee15bd9116f70d4a530c736983eed997b361e20ecd8f5ca3759d5c5/regex-2025.11.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eb542fd347ce61e1321b0a6b945d5701528dca0cd9759c2e3bb8bd57e47964d", size = 812852, upload-time = "2025-11-03T21:33:27.852Z" }, - { url = "https://files.pythonhosted.org/packages/b8/6f/f7516dde5506a588a561d296b2d0044839de06035bb486b326065b4c101e/regex-2025.11.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d6c2d5919075a1f2e413c00b056ea0c2f065b3f5fe83c3d07d325ab92dce51d6", size = 795566, upload-time = "2025-11-03T21:33:32.364Z" }, - { url = "https://files.pythonhosted.org/packages/d9/dd/3d10b9e170cc16fb34cb2cef91513cf3df65f440b3366030631b2984a264/regex-2025.11.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3f8bf11a4827cc7ce5a53d4ef6cddd5ad25595d3c1435ef08f76825851343154", size = 868463, upload-time = "2025-11-03T21:33:34.459Z" }, - { url = "https://files.pythonhosted.org/packages/f5/8e/935e6beff1695aa9085ff83195daccd72acc82c81793df480f34569330de/regex-2025.11.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:22c12d837298651e5550ac1d964e4ff57c3f56965fc1812c90c9fb2028eaf267", size = 854694, upload-time = "2025-11-03T21:33:36.793Z" }, - { url = "https://files.pythonhosted.org/packages/92/12/10650181a040978b2f5720a6a74d44f841371a3d984c2083fc1752e4acf6/regex-2025.11.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62ba394a3dda9ad41c7c780f60f6e4a70988741415ae96f6d1bf6c239cf01379", size = 799691, upload-time = "2025-11-03T21:33:39.079Z" }, - { url = "https://files.pythonhosted.org/packages/67/90/8f37138181c9a7690e7e4cb388debbd389342db3c7381d636d2875940752/regex-2025.11.3-cp314-cp314t-win32.whl", hash = "sha256:4bf146dca15cdd53224a1bf46d628bd7590e4a07fbb69e720d561aea43a32b38", size = 274583, upload-time = "2025-11-03T21:33:41.302Z" }, - { url = "https://files.pythonhosted.org/packages/8f/cd/867f5ec442d56beb56f5f854f40abcfc75e11d10b11fdb1869dd39c63aaf/regex-2025.11.3-cp314-cp314t-win_amd64.whl", hash = "sha256:adad1a1bcf1c9e76346e091d22d23ac54ef28e1365117d99521631078dfec9de", size = 284286, upload-time = "2025-11-03T21:33:43.324Z" }, - { url = "https://files.pythonhosted.org/packages/20/31/32c0c4610cbc070362bf1d2e4ea86d1ea29014d400a6d6c2486fcfd57766/regex-2025.11.3-cp314-cp314t-win_arm64.whl", hash = "sha256:c54f768482cef41e219720013cd05933b6f971d9562544d691c68699bf2b6801", size = 274741, upload-time = "2025-11-03T21:33:45.557Z" }, -] - [[package]] name = "requests" version = "2.32.5" @@ -2573,24 +2348,20 @@ wheels = [ [[package]] name = "stapi-fastapi" -version = "0.8.0" +version = "0.9.0" source = { editable = "stapi-fastapi" } dependencies = [ { name = "fastapi" }, { name = "geojson-pydantic" }, - { name = "httpx" }, - { name = "nox" }, { name = "pydantic" }, - { name = "pydantic-settings" }, - { name = "pygeofilter" }, { name = "returns" }, { name = "stapi-pydantic" }, - { name = "uvicorn" }, ] [package.dev-dependencies] dev = [ { name = "fastapi", extra = ["standard"] }, + { name = "httpx" }, { name = "pytest" }, ] @@ -2598,19 +2369,15 @@ dev = [ requires-dist = [ { name = "fastapi", specifier = ">=0.115.0" }, { name = "geojson-pydantic", specifier = ">=1.1" }, - { name = "httpx", specifier = ">=0.27.0" }, - { name = "nox", specifier = ">=2024.4.15" }, { name = "pydantic", specifier = ">=2.10" }, - { name = "pydantic-settings", specifier = ">=2.2.1" }, - { name = "pygeofilter", specifier = ">=0.2" }, { name = "returns", specifier = ">=0.23" }, { name = "stapi-pydantic", editable = "stapi-pydantic" }, - { name = "uvicorn", specifier = ">=0.29.0" }, ] [package.metadata.requires-dev] dev = [ { name = "fastapi", extras = ["standard"], specifier = ">=0.115.0" }, + { name = "httpx", specifier = ">=0.27.0" }, { name = "pytest", specifier = ">=8.3.5" }, ] @@ -2788,18 +2555,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, ] -[[package]] -name = "tzlocal" -version = "5.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "tzdata", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, -] - [[package]] name = "uri-template" version = "1.3.0" From 3e2aedadd56ad5cb452da078393de78efdea2a12 Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 6 Aug 2026 18:41:34 -0700 Subject: [PATCH 23/48] fix!: dedupe and rename the route name constants `LIST_PRODUCTS` was defined twice, identically, on consecutive lines. `GET_OPPORTUNITY_SEARCH_RECORD_STATUSES` is renamed `LIST_OPPORTUNITY_SEARCH_RECORD_STATUSES`: it names a collection endpoint, and every sibling list route is spelled `LIST_`. The registered route name changes with the constant, so anything calling `url_for` with the old name must be updated. --- stapi-fastapi/CHANGELOG.md | 5 +++++ stapi-fastapi/src/stapi_fastapi/routers/root_router.py | 4 ++-- stapi-fastapi/src/stapi_fastapi/routers/route_names.py | 3 +-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/stapi-fastapi/CHANGELOG.md b/stapi-fastapi/CHANGELOG.md index 6056821..c36a362 100644 --- a/stapi-fastapi/CHANGELOG.md +++ b/stapi-fastapi/CHANGELOG.md @@ -6,8 +6,13 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a ## [Unreleased] +### Changed + +- **BREAKING** The `GET_OPPORTUNITY_SEARCH_RECORD_STATUSES` route name constant is renamed `LIST_OPPORTUNITY_SEARCH_RECORD_STATUSES`, matching its sibling list routes, and the registered route name changes with it. + ### Removed +- A duplicate definition of the `LIST_PRODUCTS` route name constant. - **BREAKING** The runtime dependencies the library never imported: `httpx`, `pygeofilter`, `nox`, `pydantic-settings`, and `uvicorn`. If your application imports any of these, depend on it directly. `httpx` remains a development dependency, for the test client. ## [0.8.0] - 2025-12-18 diff --git a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py index 6aaecc3..2b1319f 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py @@ -38,8 +38,8 @@ from stapi_fastapi.routers.route_names import ( CONFORMANCE, GET_OPPORTUNITY_SEARCH_RECORD, - GET_OPPORTUNITY_SEARCH_RECORD_STATUSES, GET_ORDER, + LIST_OPPORTUNITY_SEARCH_RECORD_STATUSES, LIST_OPPORTUNITY_SEARCH_RECORDS, LIST_ORDER_STATUSES, LIST_ORDERS, @@ -166,7 +166,7 @@ def __init__( "/searches/opportunities/{search_record_id}/statuses", self.get_opportunity_search_record_statuses, methods=["GET"], - name=f"{self.name}:{GET_OPPORTUNITY_SEARCH_RECORD_STATUSES}", + name=f"{self.name}:{LIST_OPPORTUNITY_SEARCH_RECORD_STATUSES}", summary="Get an Opportunity Search Record statuses by ID", tags=["Opportunities"], ) diff --git a/stapi-fastapi/src/stapi_fastapi/routers/route_names.py b/stapi-fastapi/src/stapi_fastapi/routers/route_names.py index 3283c57..a6906ce 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/route_names.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/route_names.py @@ -4,7 +4,6 @@ # Product LIST_PRODUCTS = "list-products" -LIST_PRODUCTS = "list-products" GET_PRODUCT = "get-product" GET_QUERYABLES = "get-queryables" GET_ORDER_PARAMETERS = "get-order-parameters" @@ -12,7 +11,7 @@ # Opportunity LIST_OPPORTUNITY_SEARCH_RECORDS = "list-opportunity-search-records" GET_OPPORTUNITY_SEARCH_RECORD = "get-opportunity-search-record" -GET_OPPORTUNITY_SEARCH_RECORD_STATUSES = "get-opportunity-search-record-statuses" +LIST_OPPORTUNITY_SEARCH_RECORD_STATUSES = "list-opportunity-search-record-statuses" SEARCH_OPPORTUNITIES = "search-opportunities" GET_OPPORTUNITY_COLLECTION = "get-opportunity-collection" From debcb7b1ee345c04ac7c05f1ddc452d6034315e2 Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 6 Aug 2026 18:42:35 -0700 Subject: [PATCH 24/48] refactor: spell a route's registered name in one place Every registration and every `url_for` built the route's name as an f-string, `f"{self.name}:{ROUTE}"` on the root router and `f"{self.root_router.name}:{self.product.id}:{ROUTE}"` on a product router -- 38 sites in all, each of which had to agree on the prefixing convention. The prefix moves onto the router as `route_name_prefix`, and `route_name()` joins it. Nothing about the resulting names changes; there is now one place that decides them, which is what lets `register_route` take a plain route name in the next commit. --- .../src/stapi_fastapi/routers/base.py | 8 ++++ .../stapi_fastapi/routers/product_router.py | 33 +++++++------ .../src/stapi_fastapi/routers/root_router.py | 47 ++++++++++--------- 3 files changed, 48 insertions(+), 40 deletions(-) diff --git a/stapi-fastapi/src/stapi_fastapi/routers/base.py b/stapi-fastapi/src/stapi_fastapi/routers/base.py index 717bb1a..dc3e7a3 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/base.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/base.py @@ -8,6 +8,14 @@ class StapiFastapiBaseRouter(APIRouter): + #: Segments prefixed to the name of every route registered on this router, + #: so route names stay unique across products. + route_name_prefix: tuple[str, ...] = () + @staticmethod def url_for(request: Request, name: str, /, **path_params: Any) -> URL: return request.url_for(name, **path_params) + + def route_name(self, name: str) -> str: + """The registered name of the route this router serves under `name`.""" + return ":".join((*self.route_name_prefix, name)) diff --git a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py index 6cd73e4..32e2f4f 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py @@ -98,12 +98,13 @@ def __init__( # noqa self.product = product self.root_router = root_router + self.route_name_prefix = (root_router.name, product.id) self.conformances = build_conformances(product, root_router) self.add_api_route( path="", endpoint=self.get_product, - name=f"{self.root_router.name}:{self.product.id}:{GET_PRODUCT}", + name=self.route_name(GET_PRODUCT), methods=["GET"], summary="Retrieve this product", tags=["Products"], @@ -112,7 +113,7 @@ def __init__( # noqa self.add_api_route( path="/conformance", endpoint=self.get_product_conformance, - name=f"{self.root_router.name}:{self.product.id}:{CONFORMANCE}", + name=self.route_name(CONFORMANCE), methods=["GET"], summary="Get conformance urls for the product", tags=["Products"], @@ -121,7 +122,7 @@ def __init__( # noqa self.add_api_route( path="/queryables", endpoint=self.get_product_queryables, - name=f"{self.root_router.name}:{self.product.id}:{GET_QUERYABLES}", + name=self.route_name(GET_QUERYABLES), methods=["GET"], summary="Get queryables for the product", tags=["Products"], @@ -130,7 +131,7 @@ def __init__( # noqa self.add_api_route( path="/order-parameters", endpoint=self.get_product_order_parameters, - name=f"{self.root_router.name}:{self.product.id}:{GET_ORDER_PARAMETERS}", + name=self.route_name(GET_ORDER_PARAMETERS), methods=["GET"], summary="Get order parameters for the product", tags=["Products"], @@ -157,7 +158,7 @@ async def _create_order( self.add_api_route( path="/orders", endpoint=_create_order, - name=f"{self.root_router.name}:{self.product.id}:{CREATE_ORDER}", + name=self.route_name(CREATE_ORDER), methods=["POST"], response_class=GeoJSONResponse, status_code=status.HTTP_201_CREATED, @@ -171,7 +172,7 @@ async def _create_order( self.add_api_route( path="/opportunities", endpoint=self.search_opportunities, - name=f"{self.root_router.name}:{self.product.id}:{SEARCH_OPPORTUNITIES}", + name=self.route_name(SEARCH_OPPORTUNITIES), methods=["POST"], response_class=GeoJSONResponse, # unknown why mypy can't see the queryables property on Product, ignoring @@ -193,7 +194,7 @@ async def _create_order( self.add_api_route( path="/opportunities/{opportunity_collection_id}", endpoint=self.get_opportunity_collection, - name=f"{self.root_router.name}:{self.product.id}:{GET_OPPORTUNITY_COLLECTION}", + name=self.route_name(GET_OPPORTUNITY_COLLECTION), methods=["GET"], response_class=GeoJSONResponse, summary="Get an Opportunity Collection by ID", @@ -202,17 +203,15 @@ async def _create_order( def get_product(self, request: Request) -> ProductPydantic: links = [ - json_link("self", self.url_for(request, f"{self.root_router.name}:{self.product.id}:{GET_PRODUCT}")), - json_link("conformance", self.url_for(request, f"{self.root_router.name}:{self.product.id}:{CONFORMANCE}")), - json_link( - "queryables", self.url_for(request, f"{self.root_router.name}:{self.product.id}:{GET_QUERYABLES}") - ), + json_link("self", self.url_for(request, self.route_name(GET_PRODUCT))), + json_link("conformance", self.url_for(request, self.route_name(CONFORMANCE))), + json_link("queryables", self.url_for(request, self.route_name(GET_QUERYABLES))), json_link( "order-parameters", - self.url_for(request, f"{self.root_router.name}:{self.product.id}:{GET_ORDER_PARAMETERS}"), + self.url_for(request, self.route_name(GET_ORDER_PARAMETERS)), ), Link( - href=self.url_for(request, f"{self.root_router.name}:{self.product.id}:{CREATE_ORDER}"), + href=self.url_for(request, self.route_name(CREATE_ORDER)), rel="create-order", type=TYPE_JSON, method="POST", @@ -225,7 +224,7 @@ def get_product(self, request: Request) -> ProductPydantic: links.append( json_link( "opportunities", - self.url_for(request, f"{self.root_router.name}:{self.product.id}:{SEARCH_OPPORTUNITIES}"), + self.url_for(request, self.route_name(SEARCH_OPPORTUNITIES)), ), ) @@ -385,7 +384,7 @@ async def create_order(self, payload: OrderRequest, request: Request, response: def order_link(self, request: Request, opp_req: OpportunityRequest) -> Link: return Link( - href=self.url_for(request, f"{self.root_router.name}:{self.product.id}:{CREATE_ORDER}"), + href=self.url_for(request, self.route_name(CREATE_ORDER)), rel="create-order", type=TYPE_JSON, method="POST", @@ -420,7 +419,7 @@ async def get_opportunity_collection( "self", self.url_for( request, - f"{self.root_router.name}:{self.product.id}:{GET_OPPORTUNITY_COLLECTION}", + self.route_name(GET_OPPORTUNITY_COLLECTION), opportunity_collection_id=opportunity_collection_id, ), ), diff --git a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py index 2b1319f..beef23f 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py @@ -78,6 +78,7 @@ def __init__( self.__get_opportunity_search_record = get_opportunity_search_record self.__get_opportunity_search_record_statuses = get_opportunity_search_record_statuses self.name = name + self.route_name_prefix = (name,) self.openapi_endpoint_name = openapi_endpoint_name self.docs_endpoint_name = docs_endpoint_name self.product_ids: list[str] = [] @@ -92,7 +93,7 @@ def __init__( "/", self.get_root, methods=["GET"], - name=f"{self.name}:{ROOT}", + name=self.route_name(ROOT), tags=["Root"], ) @@ -100,7 +101,7 @@ def __init__( "/conformance", self.get_conformance, methods=["GET"], - name=f"{self.name}:{CONFORMANCE}", + name=self.route_name(CONFORMANCE), tags=["Conformance"], ) @@ -108,7 +109,7 @@ def __init__( "/products", self.get_products, methods=["GET"], - name=f"{self.name}:{LIST_PRODUCTS}", + name=self.route_name(LIST_PRODUCTS), tags=["Products"], ) @@ -116,7 +117,7 @@ def __init__( "/orders", self.get_orders, methods=["GET"], - name=f"{self.name}:{LIST_ORDERS}", + name=self.route_name(LIST_ORDERS), response_class=GeoJSONResponse, tags=["Orders"], ) @@ -125,7 +126,7 @@ def __init__( "/orders/{order_id}", self.get_order, methods=["GET"], - name=f"{self.name}:{GET_ORDER}", + name=self.route_name(GET_ORDER), response_class=GeoJSONResponse, tags=["Orders"], ) @@ -136,7 +137,7 @@ def __init__( "/orders/{order_id}/statuses", self.get_order_statuses, methods=["GET"], - name=f"{self.name}:{LIST_ORDER_STATUSES}", + name=self.route_name(LIST_ORDER_STATUSES), tags=["Orders"], ) @@ -146,7 +147,7 @@ def __init__( "/searches/opportunities", self.get_opportunity_search_records, methods=["GET"], - name=f"{self.name}:{LIST_OPPORTUNITY_SEARCH_RECORDS}", + name=self.route_name(LIST_OPPORTUNITY_SEARCH_RECORDS), summary="List all Opportunity Search Records", tags=["Opportunities"], ) @@ -155,7 +156,7 @@ def __init__( "/searches/opportunities/{search_record_id}", self.get_opportunity_search_record, methods=["GET"], - name=f"{self.name}:{GET_OPPORTUNITY_SEARCH_RECORD}", + name=self.route_name(GET_OPPORTUNITY_SEARCH_RECORD), summary="Get an Opportunity Search Record by ID", tags=["Opportunities"], ) @@ -166,7 +167,7 @@ def __init__( "/searches/opportunities/{search_record_id}/statuses", self.get_opportunity_search_record_statuses, methods=["GET"], - name=f"{self.name}:{LIST_OPPORTUNITY_SEARCH_RECORD_STATUSES}", + name=self.route_name(LIST_OPPORTUNITY_SEARCH_RECORD_STATUSES), summary="Get an Opportunity Search Record statuses by ID", tags=["Opportunities"], ) @@ -177,7 +178,7 @@ def get_root(self, request: Request) -> RootResponse: links = [ json_link( "self", - self.url_for(request, f"{self.name}:{ROOT}"), + self.url_for(request, self.route_name(ROOT)), ), json_link( "service-description", @@ -188,11 +189,11 @@ def get_root(self, request: Request) -> RootResponse: href=self.url_for(request, self.docs_endpoint_name), type="text/html", ), - json_link("conformance", href=self.url_for(request, f"{self.name}:{CONFORMANCE}")), - json_link("products", self.url_for(request, f"{self.name}:{LIST_PRODUCTS}")), + json_link("conformance", href=self.url_for(request, self.route_name(CONFORMANCE))), + json_link("products", self.url_for(request, self.route_name(LIST_PRODUCTS))), Link( rel="orders", - href=self.url_for(request, f"{self.name}:{LIST_ORDERS}"), + href=self.url_for(request, self.route_name(LIST_ORDERS)), type=TYPE_GEOJSON, ), ] @@ -201,7 +202,7 @@ def get_root(self, request: Request) -> RootResponse: links.append( json_link( "opportunity-search-records", - self.url_for(request, f"{self.name}:{LIST_OPPORTUNITY_SEARCH_RECORDS}"), + self.url_for(request, self.route_name(LIST_OPPORTUNITY_SEARCH_RECORDS)), ), ) @@ -228,11 +229,11 @@ def get_products(self, request: Request, next: str | None = None, limit: int = 1 links = [ json_link( "self", - self.url_for(request, f"{self.name}:{LIST_PRODUCTS}"), + self.url_for(request, self.route_name(LIST_PRODUCTS)), ), ] if end > 0 and end < len(self.product_ids): - links.append(self.pagination_link(request, f"{self.name}:{LIST_PRODUCTS}", self.product_ids[end], limit)) + links.append(self.pagination_link(request, self.route_name(LIST_PRODUCTS), self.product_ids[end], limit)) return ProductCollection( products=[self.product_routers[product_id].get_product(request) for product_id in ids], links=links, @@ -249,7 +250,7 @@ async def get_orders( # noqa: C901 order.links.extend(self.order_links(order, request)) match maybe_pagination_token: case Some(next_): - links.append(self.pagination_link(request, f"{self.name}:{LIST_ORDERS}", next_, limit)) + links.append(self.pagination_link(request, self.route_name(LIST_ORDERS), next_, limit)) case Maybe.empty: pass match maybe_orders_count: @@ -315,7 +316,7 @@ async def get_order_statuses( case Some(next_): links.append( self.pagination_link( - request, f"{self.name}:{LIST_ORDER_STATUSES}", next_, limit, order_id=order_id + request, self.route_name(LIST_ORDER_STATUSES), next_, limit, order_id=order_id ) ) case Maybe.empty: @@ -345,10 +346,10 @@ def add_product(self, product: Product, *args: Any, **kwargs: Any) -> None: self.product_ids = [*self.product_routers.keys()] def generate_order_href(self, request: Request, order_id: str) -> URL: - return self.url_for(request, f"{self.name}:{GET_ORDER}", order_id=order_id) + return self.url_for(request, self.route_name(GET_ORDER), order_id=order_id) def generate_order_statuses_href(self, request: Request, order_id: str) -> URL: - return self.url_for(request, f"{self.name}:{LIST_ORDER_STATUSES}", order_id=order_id) + return self.url_for(request, self.route_name(LIST_ORDER_STATUSES), order_id=order_id) def order_links(self, order: Order[OrderStatus], request: Request) -> list[Link]: return [ @@ -364,7 +365,7 @@ def order_links(self, order: Order[OrderStatus], request: Request) -> list[Link] ] def order_statuses_link(self, request: Request, order_id: str) -> Link: - return json_link("self", self.url_for(request, f"{self.name}:{LIST_ORDER_STATUSES}", order_id=order_id)) + return json_link("self", self.url_for(request, self.route_name(LIST_ORDER_STATUSES), order_id=order_id)) def pagination_link(self, request: Request, name: str, pagination_token: str, limit: int, **kwargs: Any) -> Link: return json_link( @@ -384,7 +385,7 @@ async def get_opportunity_search_records( case Some(next_): links.append( self.pagination_link( - request, f"{self.name}:{LIST_OPPORTUNITY_SEARCH_RECORDS}", next_, limit + request, self.route_name(LIST_OPPORTUNITY_SEARCH_RECORDS), next_, limit ) ) case Maybe.empty: @@ -454,7 +455,7 @@ async def get_opportunity_search_record_statuses( def generate_opportunity_search_record_href(self, request: Request, search_record_id: str) -> URL: return self.url_for( request, - f"{self.name}:{GET_OPPORTUNITY_SEARCH_RECORD}", + self.route_name(GET_OPPORTUNITY_SEARCH_RECORD), search_record_id=search_record_id, ) From ff356354dffb7b0ad8fd6fed25452ab1111e2113 Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 6 Aug 2026 18:46:19 -0700 Subject: [PATCH 25/48] refactor!: let a Route describe itself, including how it can fail Fifteen hand-assembled `add_api_route` calls each repeated the same bookkeeping, and each could omit any part of it without complaint. Six routes had no `summary`, so FastAPI derived titles like `Root:List-Orders` from the route name; tags were assigned per owning router, so creating an order for a product was filed under Products rather than Orders. A route is now a frozen `Route` declaring its name, path, endpoint, summary, tag and errors, handed to `register_route`. `summary` and `tag` are required, so an operation cannot be published without a title or a heading. `errors` is required and deliberately not defaulted. A shared set merged into every route cannot be narrowed, which is why `GET /` and `GET /conformance` advertised a 404 despite taking no input and calling no backend. Each route composes only what it can produce, from `BAD_REQUEST`, `NOT_FOUND` and `SERVER_ERROR`. That also declares 500, which was returned deliberately whenever a backend reported failure and documented nowhere. Every operation gets a stable `operationId`, derived from the prefixed route name so it stays unique across a deployment mounting several products. --- stapi-fastapi/CHANGELOG.md | 15 ++ .../src/stapi_fastapi/routers/base.py | 116 ++++++++++++- .../stapi_fastapi/routers/product_router.py | 153 ++++++++++-------- .../src/stapi_fastapi/routers/root_router.py | 150 +++++++++-------- .../src/stapi_fastapi/routers/route_names.py | 10 ++ stapi-fastapi/tests/test_openapi.py | 71 ++++++++ 6 files changed, 378 insertions(+), 137 deletions(-) create mode 100644 stapi-fastapi/tests/test_openapi.py diff --git a/stapi-fastapi/CHANGELOG.md b/stapi-fastapi/CHANGELOG.md index c36a362..1141bfd 100644 --- a/stapi-fastapi/CHANGELOG.md +++ b/stapi-fastapi/CHANGELOG.md @@ -6,10 +6,25 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a ## [Unreleased] +### Added + +- `Route`, a declarative route descriptor, with `Route.to_api_route()` returning the keyword arguments for FastAPI's own `add_api_route`, and `StapiFastapiBaseRouter.register_route()` handing them over. `Route` requires a `summary`, a `tag` (the new `Tag` enum) and an `errors` set, so an operation cannot be published without a title, a heading, or an accurate statement of how it can fail. +- `Responses` type alias for the response-declaration mapping, and `BAD_REQUEST` / `NOT_FOUND` / `SERVER_ERROR` to compose an `errors` set from, e.g. `errors=NOT_FOUND | SERVER_ERROR`. +- Every route declares its 400 and 404 responses. 404 was raised from nine places and declared nowhere; 400 likewise. +- Operation summaries on the six routes that had none, where FastAPI was deriving titles like `Root:List-Orders` from route names. + ### Changed +- **BREAKING** A route is declared as a `Route` and registered with `StapiFastapiBaseRouter.register_route`, which hands it to FastAPI's own `add_api_route`. `summary`, `tag` and `errors` are required, so a route cannot be registered without saying what it is called, where it is filed, or which errors it can produce. `errors` is deliberately not defaulted: a shared set merged into every route cannot be narrowed, and so published a 404 for the landing page, an endpoint that takes no input and calls no backend. +- OpenAPI tags come from the route family rather than the owning router: creating an order for a product is filed under Orders, and the opportunity routes under Opportunities, rather than all of them under Products. - **BREAKING** The `GET_OPPORTUNITY_SEARCH_RECORD_STATUSES` route name constant is renamed `LIST_OPPORTUNITY_SEARCH_RECORD_STATUSES`, matching its sibling list routes, and the registered route name changes with it. +### Fixed + +- Operations declare only the error responses they can actually produce. A shared set was previously merged into every route and could not be narrowed, so `GET /` and `GET /conformance` published a 404 despite taking no input and calling no backend. +- `500` is declared. It is returned deliberately when a backend reports failure, so a client has to be prepared for it. +- Every operation publishes a stable `operationId`, derived from the route's prefixed name so it stays unique across a deployment mounting several products. + ### Removed - A duplicate definition of the `LIST_PRODUCTS` route name constant. diff --git a/stapi-fastapi/src/stapi_fastapi/routers/base.py b/stapi-fastapi/src/stapi_fastapi/routers/base.py index dc3e7a3..d1878cc 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/base.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/base.py @@ -1,10 +1,118 @@ -from typing import Any +import re +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Any, TypeAlias from fastapi import ( APIRouter, Request, + Response, + status, ) -from fastapi.datastructures import URL +from fastapi.datastructures import URL, Default, DefaultPlaceholder +from fastapi.responses import JSONResponse + +from stapi_fastapi.routers.route_names import Tag + +#: OpenAPI response declarations, keyed by status code. +Responses: TypeAlias = dict[int | str, dict[str, Any]] + +# The error responses a route can declare, one apiece so a route composes only +# the ones it actually produces: `errors=NOT_FOUND | SERVER_ERROR`. + +BAD_REQUEST: Responses = { + status.HTTP_400_BAD_REQUEST: { + "description": ( + "The request was rejected: e.g. it omits a predicate for a queryable the " + "Product requires, or its `Prefer` header carries an unsupported value." + ), + }, +} + +NOT_FOUND: Responses = { + status.HTTP_404_NOT_FOUND: { + "description": ( + "The requested resource does not exist or is not accessible, or the " + "supplied pagination token does not identify a page." + ), + }, +} + +SERVER_ERROR: Responses = { + status.HTTP_500_INTERNAL_SERVER_ERROR: { + "description": ("The request could not be served because a backend reported failure."), + }, +} + + +_NON_IDENTIFIER = re.compile(r"\W") + + +def operation_id(route_name: str) -> str: + """The published operationId for the route registered under `route_name`. + + Derived from the *prefixed* name, not from `Route.name`: a server mounting + several products registers `get-product` once per product, and an + operationId has to be unique across the whole document. + """ + return _NON_IDENTIFIER.sub("_", route_name) + + +@dataclass(frozen=True, kw_only=True) +class Route: + """One route of the STAPI contract, declared rather than hand-assembled.""" + + name: str + """The route family, one of the `stapi_fastapi.routers.route_names` constants. + + Prefixed with the owning router's segments to form the registered route name. + """ + + path: str + endpoint: Callable[..., Any] + + summary: str + """Required: it is the operation's title wherever the API is published.""" + + tag: Tag + """The OpenAPI tag this operation is published under.""" + + methods: tuple[str, ...] = ("GET",) + status_code: int | None = None + # FastAPI's own defaults, as factories because a DefaultPlaceholder is + # unhashable and so cannot be a dataclass default. They must stay the + # placeholders: an explicit `None` response_model would suppress the + # inference from the endpoint's return annotation. + response_class: type[Response] | DefaultPlaceholder = field(default_factory=lambda: Default(JSONResponse)) + response_model: Any = field(default_factory=lambda: Default(None)) + responses: Responses = field(default_factory=dict) + """Responses particular to this route, merged over `errors`.""" + + errors: Responses + """The error responses this route can actually produce, e.g. + `NOT_FOUND | SERVER_ERROR`, or `{}` for a route that produces none. + """ + + def to_api_route(self, name: str) -> dict[str, Any]: + """This route as keyword arguments for `APIRouter.add_api_route`. + + `name` is the registered name, passed in rather than derived, so + `StapiFastapiBaseRouter.route_name` stays the only place that knows how a + prefixed route name is spelled. + """ + return { + "path": self.path, + "endpoint": self.endpoint, + "name": name, + "operation_id": operation_id(name), + "tags": [self.tag], + "summary": self.summary, + "methods": list(self.methods), + "status_code": self.status_code, + "response_class": self.response_class, + "response_model": self.response_model, + "responses": {**self.errors, **self.responses}, + } class StapiFastapiBaseRouter(APIRouter): @@ -19,3 +127,7 @@ def url_for(request: Request, name: str, /, **path_params: Any) -> URL: def route_name(self, name: str) -> str: """The registered name of the route this router serves under `name`.""" return ":".join((*self.route_name_prefix, name)) + + def register_route(self, route: Route) -> None: + """Register `route` on this router.""" + self.add_api_route(**route.to_api_route(self.route_name(route.name))) diff --git a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py index 32e2f4f..ea26549 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py @@ -37,7 +37,7 @@ from stapi_fastapi.errors import NotFoundError, QueryablesError from stapi_fastapi.models.product import Product from stapi_fastapi.responses import GeoJSONResponse -from stapi_fastapi.routers.base import StapiFastapiBaseRouter +from stapi_fastapi.routers.base import BAD_REQUEST, NOT_FOUND, SERVER_ERROR, Route, StapiFastapiBaseRouter from stapi_fastapi.routers.route_names import ( CONFORMANCE, CREATE_ORDER, @@ -46,6 +46,7 @@ GET_PRODUCT, GET_QUERYABLES, SEARCH_OPPORTUNITIES, + Tag, ) from stapi_fastapi.routers.utils import json_link @@ -86,8 +87,7 @@ def build_conformances(product: Product, root_router: RootRouter) -> list[str]: class ProductRouter(StapiFastapiBaseRouter): - # FIXME ruff is complaining that the init is too complex - def __init__( # noqa + def __init__( self, product: Product, root_router: RootRouter, @@ -101,40 +101,45 @@ def __init__( # noqa self.route_name_prefix = (root_router.name, product.id) self.conformances = build_conformances(product, root_router) - self.add_api_route( - path="", - endpoint=self.get_product, - name=self.route_name(GET_PRODUCT), - methods=["GET"], - summary="Retrieve this product", - tags=["Products"], + self.register_route( + Route( + name=GET_PRODUCT, + tag=Tag.PRODUCTS, + path="", + endpoint=self.get_product, + errors={}, + summary="Retrieve this product", + ) ) - - self.add_api_route( - path="/conformance", - endpoint=self.get_product_conformance, - name=self.route_name(CONFORMANCE), - methods=["GET"], - summary="Get conformance urls for the product", - tags=["Products"], + self.register_route( + Route( + name=CONFORMANCE, + tag=Tag.CONFORMANCE, + path="/conformance", + endpoint=self.get_product_conformance, + errors={}, + summary="Get conformance urls for the product", + ) ) - - self.add_api_route( - path="/queryables", - endpoint=self.get_product_queryables, - name=self.route_name(GET_QUERYABLES), - methods=["GET"], - summary="Get queryables for the product", - tags=["Products"], + self.register_route( + Route( + name=GET_QUERYABLES, + tag=Tag.PRODUCTS, + path="/queryables", + endpoint=self.get_product_queryables, + errors={}, + summary="Get queryables for the product", + ) ) - - self.add_api_route( - path="/order-parameters", - endpoint=self.get_product_order_parameters, - name=self.route_name(GET_ORDER_PARAMETERS), - methods=["GET"], - summary="Get order parameters for the product", - tags=["Products"], + self.register_route( + Route( + name=GET_ORDER_PARAMETERS, + tag=Tag.PRODUCTS, + path="/order-parameters", + endpoint=self.get_product_order_parameters, + errors={}, + summary="Get order parameters for the product", + ) ) # This wraps `self.create_order` to explicitly parameterize `OrderRequest` @@ -155,50 +160,58 @@ async def _create_order( self.product.order_parameters # type: ignore ] - self.add_api_route( - path="/orders", - endpoint=_create_order, - name=self.route_name(CREATE_ORDER), - methods=["POST"], - response_class=GeoJSONResponse, - status_code=status.HTTP_201_CREATED, - summary="Create an order for the product", - tags=["Products"], + self.register_route( + Route( + name=CREATE_ORDER, + tag=Tag.ORDERS, + path="/orders", + endpoint=_create_order, + methods=("POST",), + errors=BAD_REQUEST | SERVER_ERROR, + summary="Create an order for the product", + response_class=GeoJSONResponse, + status_code=status.HTTP_201_CREATED, + ) ) if product.supports_opportunity_search or ( self.product.supports_async_opportunity_search and self.root_router.supports_async_opportunity_search ): - self.add_api_route( - path="/opportunities", - endpoint=self.search_opportunities, - name=self.route_name(SEARCH_OPPORTUNITIES), - methods=["POST"], - response_class=GeoJSONResponse, - # unknown why mypy can't see the queryables property on Product, ignoring - response_model=OpportunityCollection[ - Geometry, - self.product.opportunity_properties, # type: ignore - ], - responses={ - 201: { - "model": OpportunitySearchRecord, - "content": {TYPE_JSON: {}}, - } - }, - summary="Search Opportunities for the product", - tags=["Products"], + self.register_route( + Route( + name=SEARCH_OPPORTUNITIES, + tag=Tag.OPPORTUNITIES, + path="/opportunities", + endpoint=self.search_opportunities, + methods=("POST",), + errors=BAD_REQUEST | NOT_FOUND | SERVER_ERROR, + summary="Search Opportunities for the product", + response_class=GeoJSONResponse, + # unknown why mypy can't see the queryables property on Product, ignoring + response_model=OpportunityCollection[ + Geometry, + self.product.opportunity_properties, # type: ignore + ], + responses={ + 201: { + "model": OpportunitySearchRecord, + "content": {TYPE_JSON: {}}, + } + }, + ) ) if product.supports_async_opportunity_search and root_router.supports_async_opportunity_search: - self.add_api_route( - path="/opportunities/{opportunity_collection_id}", - endpoint=self.get_opportunity_collection, - name=self.route_name(GET_OPPORTUNITY_COLLECTION), - methods=["GET"], - response_class=GeoJSONResponse, - summary="Get an Opportunity Collection by ID", - tags=["Products"], + self.register_route( + Route( + name=GET_OPPORTUNITY_COLLECTION, + tag=Tag.OPPORTUNITIES, + path="/opportunities/{opportunity_collection_id}", + endpoint=self.get_opportunity_collection, + errors=NOT_FOUND | SERVER_ERROR, + summary="Get an Opportunity Collection by ID", + response_class=GeoJSONResponse, + ) ) def get_product(self, request: Request) -> ProductPydantic: diff --git a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py index beef23f..b6fc02a 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py @@ -33,7 +33,7 @@ from stapi_fastapi.errors import NotFoundError from stapi_fastapi.models.product import Product from stapi_fastapi.responses import GeoJSONResponse -from stapi_fastapi.routers.base import StapiFastapiBaseRouter +from stapi_fastapi.routers.base import NOT_FOUND, SERVER_ERROR, Route, StapiFastapiBaseRouter from stapi_fastapi.routers.product_router import ProductRouter from stapi_fastapi.routers.route_names import ( CONFORMANCE, @@ -45,6 +45,7 @@ LIST_ORDERS, LIST_PRODUCTS, ROOT, + Tag, ) from stapi_fastapi.routers.utils import json_link @@ -89,87 +90,106 @@ def __init__( # added. self.product_routers: dict[str, ProductRouter] = {} - self.add_api_route( - "/", - self.get_root, - methods=["GET"], - name=self.route_name(ROOT), - tags=["Root"], + self.register_route( + Route( + name=ROOT, + tag=Tag.ROOT, + path="/", + endpoint=self.get_root, + errors={}, + summary="Get the API landing page", + ) ) - - self.add_api_route( - "/conformance", - self.get_conformance, - methods=["GET"], - name=self.route_name(CONFORMANCE), - tags=["Conformance"], + self.register_route( + Route( + name=CONFORMANCE, + tag=Tag.CONFORMANCE, + path="/conformance", + endpoint=self.get_conformance, + errors={}, + summary="Get conformance urls for the API", + ) ) - - self.add_api_route( - "/products", - self.get_products, - methods=["GET"], - name=self.route_name(LIST_PRODUCTS), - tags=["Products"], + self.register_route( + Route( + name=LIST_PRODUCTS, + tag=Tag.PRODUCTS, + path="/products", + endpoint=self.get_products, + errors=NOT_FOUND, + summary="List all Products", + ) ) - - self.add_api_route( - "/orders", - self.get_orders, - methods=["GET"], - name=self.route_name(LIST_ORDERS), - response_class=GeoJSONResponse, - tags=["Orders"], + self.register_route( + Route( + name=LIST_ORDERS, + tag=Tag.ORDERS, + path="/orders", + endpoint=self.get_orders, + errors=NOT_FOUND | SERVER_ERROR, + summary="List all Orders", + response_class=GeoJSONResponse, + ) ) - - self.add_api_route( - "/orders/{order_id}", - self.get_order, - methods=["GET"], - name=self.route_name(GET_ORDER), - response_class=GeoJSONResponse, - tags=["Orders"], + self.register_route( + Route( + name=GET_ORDER, + tag=Tag.ORDERS, + path="/orders/{order_id}", + endpoint=self.get_order, + errors=NOT_FOUND | SERVER_ERROR, + summary="Get an Order by ID", + response_class=GeoJSONResponse, + ) ) if self.get_order_statuses is not None: _conformances.add(API_CONFORMANCE.order_statuses) - self.add_api_route( - "/orders/{order_id}/statuses", - self.get_order_statuses, - methods=["GET"], - name=self.route_name(LIST_ORDER_STATUSES), - tags=["Orders"], + self.register_route( + Route( + name=LIST_ORDER_STATUSES, + tag=Tag.ORDERS, + path="/orders/{order_id}/statuses", + endpoint=self.get_order_statuses, + errors=NOT_FOUND | SERVER_ERROR, + summary="List statuses for an Order", + ) ) if self.supports_async_opportunity_search: _conformances.add(API_CONFORMANCE.searches_opportunity) - self.add_api_route( - "/searches/opportunities", - self.get_opportunity_search_records, - methods=["GET"], - name=self.route_name(LIST_OPPORTUNITY_SEARCH_RECORDS), - summary="List all Opportunity Search Records", - tags=["Opportunities"], + self.register_route( + Route( + name=LIST_OPPORTUNITY_SEARCH_RECORDS, + tag=Tag.OPPORTUNITIES, + path="/searches/opportunities", + endpoint=self.get_opportunity_search_records, + errors=NOT_FOUND | SERVER_ERROR, + summary="List all Opportunity Search Records", + ) ) - - self.add_api_route( - "/searches/opportunities/{search_record_id}", - self.get_opportunity_search_record, - methods=["GET"], - name=self.route_name(GET_OPPORTUNITY_SEARCH_RECORD), - summary="Get an Opportunity Search Record by ID", - tags=["Opportunities"], + self.register_route( + Route( + name=GET_OPPORTUNITY_SEARCH_RECORD, + tag=Tag.OPPORTUNITIES, + path="/searches/opportunities/{search_record_id}", + endpoint=self.get_opportunity_search_record, + errors=NOT_FOUND | SERVER_ERROR, + summary="Get an Opportunity Search Record by ID", + ) ) if self.__get_opportunity_search_record_statuses is not None: _conformances.add(API_CONFORMANCE.searches_opportunity_statuses) - self.add_api_route( - "/searches/opportunities/{search_record_id}/statuses", - self.get_opportunity_search_record_statuses, - methods=["GET"], - name=self.route_name(LIST_OPPORTUNITY_SEARCH_RECORD_STATUSES), - summary="Get an Opportunity Search Record statuses by ID", - tags=["Opportunities"], + self.register_route( + Route( + name=LIST_OPPORTUNITY_SEARCH_RECORD_STATUSES, + tag=Tag.OPPORTUNITIES, + path="/searches/opportunities/{search_record_id}/statuses", + endpoint=self.get_opportunity_search_record_statuses, + errors=NOT_FOUND | SERVER_ERROR, + summary="List statuses for an Opportunity Search Record", + ) ) self.conformances = list(_conformances) diff --git a/stapi-fastapi/src/stapi_fastapi/routers/route_names.py b/stapi-fastapi/src/stapi_fastapi/routers/route_names.py index a6906ce..45e92d3 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/route_names.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/route_names.py @@ -1,3 +1,5 @@ +from enum import StrEnum + # Root ROOT = "root" CONFORMANCE = "conformance" @@ -20,3 +22,11 @@ GET_ORDER = "get-order" LIST_ORDER_STATUSES = "list-order-statuses" CREATE_ORDER = "create-order" + + +class Tag(StrEnum): + ROOT = "Root" + CONFORMANCE = "Conformance" + PRODUCTS = "Products" + ORDERS = "Orders" + OPPORTUNITIES = "Opportunities" diff --git a/stapi-fastapi/tests/test_openapi.py b/stapi-fastapi/tests/test_openapi.py new file mode 100644 index 0000000..a5b7288 --- /dev/null +++ b/stapi-fastapi/tests/test_openapi.py @@ -0,0 +1,71 @@ +"""Tests for the OpenAPI document a multi-product deployment publishes. + +The fixtures mount several products on one root router, so every route family is +registered once per product. Whether a name derived per route is unique -- an +operationId, a parameterized component -- is only a real question under those +conditions. +""" + +from typing import Any, cast + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +HTTP_METHODS = {"get", "put", "post", "delete", "options", "head", "patch", "trace"} + +PRODUCT_ID = "test-spotlight" + +SEARCH_OPPORTUNITIES_PATH = f"/products/{PRODUCT_ID}/opportunities" + + +def operations(spec: dict[str, Any]) -> list[tuple[str, str, dict[str, Any]]]: + return [ + (path, method, operation) + for path, path_item in spec["paths"].items() + for method, operation in path_item.items() + if method in HTTP_METHODS + ] + + +@pytest.fixture +def spec(stapi_client_async_opportunity: TestClient) -> dict[str, Any]: + # TestClient types `app` as the bare ASGI callable; the fixture always + # builds a FastAPI app, which is what exposes `openapi()`. + return cast(FastAPI, stapi_client_async_opportunity.app).openapi() + + +def test_routes_declare_only_the_errors_they_produce(spec: dict[str, Any]) -> None: + """`errors` is declared per route rather than applied blanket. + + The landing page takes no input and calls no backend, so it can fail no way + the document should promise; a paginated collection can do both. + """ + assert set(spec["paths"]["/"]["get"]["responses"]) == {"200"} + assert {"404", "500"} <= set(spec["paths"]["/orders"]["get"]["responses"]) + + +def test_no_component_schema_is_unreferenced(spec: dict[str, Any]) -> None: + """An orphan component is a schema a reader cannot reach and a client cannot use.""" + schemas = spec["components"]["schemas"] + referenced: set[str] = set() + + def collect(node: Any) -> None: + if isinstance(node, dict): + ref = node.get("$ref") + if isinstance(ref, str): + referenced.add(ref.rpartition("/")[2]) + discriminator = node.get("discriminator") + if isinstance(discriminator, dict): + # mapping values are bare ref strings, not {"$ref": ...} objects + referenced.update(value.rpartition("/")[2] for value in discriminator.get("mapping", {}).values()) + for value in node.values(): + collect(value) + elif isinstance(node, list): + for item in node: + collect(item) + + collect(spec["paths"]) + collect(schemas) + + assert set(schemas) - referenced == set() From d05cee95e4253754c800fc78b1f0dfd114e933ec Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 6 Aug 2026 18:47:13 -0700 Subject: [PATCH 26/48] feat!: publish camelCase path parameters The spec documents the path parameters as `{orderId}`, `{searchRecordId}` and `{opportunityCollectionId}`; the routes declared them snake_case, so the published document disagreed with the spec everywhere except the already camelCase `{productId}`. The annotations in the new `stapi_fastapi.path_params` alias each parameter, so the published name is camelCase while the Python parameter stays snake_case. Titles are set explicitly, since FastAPI would otherwise derive "Orderid" from the alias. Request URLs are unchanged -- a path parameter's name never appears in one -- but a generated client binds by parameter name and needs regenerating, and `url_for` callers must pass the camelCase keyword. --- stapi-fastapi/CHANGELOG.md | 2 ++ .../src/stapi_fastapi/path_params.py | 15 ++++++++++ .../stapi_fastapi/routers/product_router.py | 7 +++-- .../src/stapi_fastapi/routers/root_router.py | 29 ++++++++++--------- 4 files changed, 37 insertions(+), 16 deletions(-) create mode 100644 stapi-fastapi/src/stapi_fastapi/path_params.py diff --git a/stapi-fastapi/CHANGELOG.md b/stapi-fastapi/CHANGELOG.md index 1141bfd..6275508 100644 --- a/stapi-fastapi/CHANGELOG.md +++ b/stapi-fastapi/CHANGELOG.md @@ -9,12 +9,14 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a ### Added - `Route`, a declarative route descriptor, with `Route.to_api_route()` returning the keyword arguments for FastAPI's own `add_api_route`, and `StapiFastapiBaseRouter.register_route()` handing them over. `Route` requires a `summary`, a `tag` (the new `Tag` enum) and an `errors` set, so an operation cannot be published without a title, a heading, or an accurate statement of how it can fail. +- `stapi_fastapi.path_params` provides the camelCase path parameter annotations. - `Responses` type alias for the response-declaration mapping, and `BAD_REQUEST` / `NOT_FOUND` / `SERVER_ERROR` to compose an `errors` set from, e.g. `errors=NOT_FOUND | SERVER_ERROR`. - Every route declares its 400 and 404 responses. 404 was raised from nine places and declared nowhere; 400 likewise. - Operation summaries on the six routes that had none, where FastAPI was deriving titles like `Root:List-Orders` from route names. ### Changed +- **BREAKING** Path parameters are camelCase in the routes and in the exported OpenAPI document: `{orderId}`, `{searchRecordId}`, and `{opportunityCollectionId}`, joining the existing `{productId}`. Request URLs are unchanged, since path parameter names never appear in them, but generated clients that bind by parameter name need regenerating, and `url_for` calls must pass the camelCase keyword (`url_for(request, name, orderId=...)`, not `order_id=...`). - **BREAKING** A route is declared as a `Route` and registered with `StapiFastapiBaseRouter.register_route`, which hands it to FastAPI's own `add_api_route`. `summary`, `tag` and `errors` are required, so a route cannot be registered without saying what it is called, where it is filed, or which errors it can produce. `errors` is deliberately not defaulted: a shared set merged into every route cannot be narrowed, and so published a 404 for the landing page, an endpoint that takes no input and calls no backend. - OpenAPI tags come from the route family rather than the owning router: creating an order for a product is filed under Orders, and the opportunity routes under Opportunities, rather than all of them under Products. - **BREAKING** The `GET_OPPORTUNITY_SEARCH_RECORD_STATUSES` route name constant is renamed `LIST_OPPORTUNITY_SEARCH_RECORD_STATUSES`, matching its sibling list routes, and the registered route name changes with it. diff --git a/stapi-fastapi/src/stapi_fastapi/path_params.py b/stapi-fastapi/src/stapi_fastapi/path_params.py new file mode 100644 index 0000000..94b7f52 --- /dev/null +++ b/stapi-fastapi/src/stapi_fastapi/path_params.py @@ -0,0 +1,15 @@ +"""Path parameter annotations. + +Aliased so the published names stay camelCase, as the spec documents them, +while the Python parameters stay snake_case. +""" + +from typing import Annotated + +from fastapi import Path + +# Titles are explicit because FastAPI would otherwise derive them from the +# alias, yielding "Orderid" rather than "Order ID". +OrderIdPath = Annotated[str, Path(alias="orderId", title="Order ID")] +SearchRecordIdPath = Annotated[str, Path(alias="searchRecordId", title="Search Record ID")] +OpportunityCollectionIdPath = Annotated[str, Path(alias="opportunityCollectionId", title="Opportunity Collection ID")] diff --git a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py index ea26549..8a096b2 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py @@ -36,6 +36,7 @@ from stapi_fastapi.constants import TYPE_JSON from stapi_fastapi.errors import NotFoundError, QueryablesError from stapi_fastapi.models.product import Product +from stapi_fastapi.path_params import OpportunityCollectionIdPath from stapi_fastapi.responses import GeoJSONResponse from stapi_fastapi.routers.base import BAD_REQUEST, NOT_FOUND, SERVER_ERROR, Route, StapiFastapiBaseRouter from stapi_fastapi.routers.route_names import ( @@ -206,7 +207,7 @@ async def _create_order( Route( name=GET_OPPORTUNITY_COLLECTION, tag=Tag.OPPORTUNITIES, - path="/opportunities/{opportunity_collection_id}", + path="/opportunities/{opportunityCollectionId}", endpoint=self.get_opportunity_collection, errors=NOT_FOUND | SERVER_ERROR, summary="Get an Opportunity Collection by ID", @@ -416,7 +417,7 @@ def pagination_link(self, request: Request, opp_req: OpportunityRequest, paginat ) async def get_opportunity_collection( - self, opportunity_collection_id: str, request: Request + self, opportunity_collection_id: OpportunityCollectionIdPath, request: Request ) -> OpportunityCollection: # type: ignore """ Fetch an opportunity collection generated by an asynchronous opportunity search. @@ -433,7 +434,7 @@ async def get_opportunity_collection( self.url_for( request, self.route_name(GET_OPPORTUNITY_COLLECTION), - opportunity_collection_id=opportunity_collection_id, + opportunityCollectionId=opportunity_collection_id, ), ), ) diff --git a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py index b6fc02a..ee7a32a 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py @@ -32,6 +32,7 @@ from stapi_fastapi.constants import TYPE_GEOJSON from stapi_fastapi.errors import NotFoundError from stapi_fastapi.models.product import Product +from stapi_fastapi.path_params import OrderIdPath, SearchRecordIdPath from stapi_fastapi.responses import GeoJSONResponse from stapi_fastapi.routers.base import NOT_FOUND, SERVER_ERROR, Route, StapiFastapiBaseRouter from stapi_fastapi.routers.product_router import ProductRouter @@ -135,7 +136,7 @@ def __init__( Route( name=GET_ORDER, tag=Tag.ORDERS, - path="/orders/{order_id}", + path="/orders/{orderId}", endpoint=self.get_order, errors=NOT_FOUND | SERVER_ERROR, summary="Get an Order by ID", @@ -149,7 +150,7 @@ def __init__( Route( name=LIST_ORDER_STATUSES, tag=Tag.ORDERS, - path="/orders/{order_id}/statuses", + path="/orders/{orderId}/statuses", endpoint=self.get_order_statuses, errors=NOT_FOUND | SERVER_ERROR, summary="List statuses for an Order", @@ -172,7 +173,7 @@ def __init__( Route( name=GET_OPPORTUNITY_SEARCH_RECORD, tag=Tag.OPPORTUNITIES, - path="/searches/opportunities/{search_record_id}", + path="/searches/opportunities/{searchRecordId}", endpoint=self.get_opportunity_search_record, errors=NOT_FOUND | SERVER_ERROR, summary="Get an Opportunity Search Record by ID", @@ -185,7 +186,7 @@ def __init__( Route( name=LIST_OPPORTUNITY_SEARCH_RECORD_STATUSES, tag=Tag.OPPORTUNITIES, - path="/searches/opportunities/{search_record_id}/statuses", + path="/searches/opportunities/{searchRecordId}/statuses", endpoint=self.get_opportunity_search_record_statuses, errors=NOT_FOUND | SERVER_ERROR, summary="List statuses for an Opportunity Search Record", @@ -298,7 +299,7 @@ async def get_orders( # noqa: C901 number_matched=orders_count, ) - async def get_order(self, order_id: str, request: Request) -> Order[OrderStatus]: + async def get_order(self, order_id: OrderIdPath, request: Request) -> Order[OrderStatus]: """ Get details for order with `order_id`. """ @@ -323,7 +324,7 @@ async def get_order(self, order_id: str, request: Request) -> Order[OrderStatus] async def get_order_statuses( self, - order_id: str, + order_id: OrderIdPath, request: Request, next: str | None = None, limit: int = 10, @@ -336,7 +337,7 @@ async def get_order_statuses( case Some(next_): links.append( self.pagination_link( - request, self.route_name(LIST_ORDER_STATUSES), next_, limit, order_id=order_id + request, self.route_name(LIST_ORDER_STATUSES), next_, limit, orderId=order_id ) ) case Maybe.empty: @@ -366,10 +367,10 @@ def add_product(self, product: Product, *args: Any, **kwargs: Any) -> None: self.product_ids = [*self.product_routers.keys()] def generate_order_href(self, request: Request, order_id: str) -> URL: - return self.url_for(request, self.route_name(GET_ORDER), order_id=order_id) + return self.url_for(request, self.route_name(GET_ORDER), orderId=order_id) def generate_order_statuses_href(self, request: Request, order_id: str) -> URL: - return self.url_for(request, self.route_name(LIST_ORDER_STATUSES), order_id=order_id) + return self.url_for(request, self.route_name(LIST_ORDER_STATUSES), orderId=order_id) def order_links(self, order: Order[OrderStatus], request: Request) -> list[Link]: return [ @@ -385,7 +386,7 @@ def order_links(self, order: Order[OrderStatus], request: Request) -> list[Link] ] def order_statuses_link(self, request: Request, order_id: str) -> Link: - return json_link("self", self.url_for(request, self.route_name(LIST_ORDER_STATUSES), order_id=order_id)) + return json_link("self", self.url_for(request, self.route_name(LIST_ORDER_STATUSES), orderId=order_id)) def pagination_link(self, request: Request, name: str, pagination_token: str, limit: int, **kwargs: Any) -> Link: return json_link( @@ -425,7 +426,9 @@ async def get_opportunity_search_records( raise AssertionError("Expected code to be unreachable") return OpportunitySearchRecordCollection(records=records, links=links) - async def get_opportunity_search_record(self, search_record_id: str, request: Request) -> OpportunitySearchRecord: + async def get_opportunity_search_record( + self, search_record_id: SearchRecordIdPath, request: Request + ) -> OpportunitySearchRecord: """ Get the Opportunity Search Record with `search_record_id`. """ @@ -449,7 +452,7 @@ async def get_opportunity_search_record(self, search_record_id: str, request: Re raise AssertionError("Expected code to be unreachable") async def get_opportunity_search_record_statuses( - self, search_record_id: str, request: Request + self, search_record_id: SearchRecordIdPath, request: Request ) -> list[OpportunitySearchStatus]: """ Get the Opportunity Search Record statuses with `search_record_id`. @@ -476,7 +479,7 @@ def generate_opportunity_search_record_href(self, request: Request, search_recor return self.url_for( request, self.route_name(GET_OPPORTUNITY_SEARCH_RECORD), - search_record_id=search_record_id, + searchRecordId=search_record_id, ) def opportunity_search_record_self_link( From 152e693ac2129115f967d1d288baf2db9aa0387e Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 6 Aug 2026 18:49:16 -0700 Subject: [PATCH 27/48] feat!: bound the pagination limit and publish what it accepts Each paginated endpoint declared `limit: int = 10` and validated nothing. `GET /products` capped the value at 100 by hand; nowhere else did. A `limit=0` dead-ended paging, since the next token pointed back at the page just served, and a negative limit silently truncated the result set with no `next` link at all. `Limit` and `NextToken` are declared once and used by every paginated endpoint, so they validate identically and publish the same bounds. Below the spec's minimum of 1 is a 422. Above the maximum is not. The spec makes `limit` what the client asks for rather than what the server owes, and publishes no ceiling, so an over-large ask is clamped and answered with a smaller page -- what the client wanted, and one round trip cheaper than making it ask again. The document advertises no maximum for the same reason: doing so would invite a 422 that never comes. The opportunity search's POST body carries its own `limit` and is held to the same bound. --- stapi-fastapi/CHANGELOG.md | 2 + .../src/stapi_fastapi/query_params.py | 46 ++++++++++ .../stapi_fastapi/routers/product_router.py | 8 +- .../src/stapi_fastapi/routers/root_router.py | 12 +-- stapi-fastapi/tests/test_opportunity.py | 31 +++++++ stapi-fastapi/tests/test_order.py | 4 +- stapi-fastapi/tests/test_pagination.py | 83 +++++++++++++++++++ stapi-fastapi/tests/test_product.py | 2 +- 8 files changed, 178 insertions(+), 10 deletions(-) create mode 100644 stapi-fastapi/src/stapi_fastapi/query_params.py create mode 100644 stapi-fastapi/tests/test_pagination.py diff --git a/stapi-fastapi/CHANGELOG.md b/stapi-fastapi/CHANGELOG.md index 6275508..47171bd 100644 --- a/stapi-fastapi/CHANGELOG.md +++ b/stapi-fastapi/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a ### Added - `Route`, a declarative route descriptor, with `Route.to_api_route()` returning the keyword arguments for FastAPI's own `add_api_route`, and `StapiFastapiBaseRouter.register_route()` handing them over. `Route` requires a `summary`, a `tag` (the new `Tag` enum) and an `errors` set, so an operation cannot be published without a title, a heading, or an accurate statement of how it can fail. +- `stapi_fastapi.query_params` provides the shared `Limit` and `NextToken` annotations and `DEFAULT_LIMIT`, so every paginated endpoint validates identically and publishes its bounds. - `stapi_fastapi.path_params` provides the camelCase path parameter annotations. - `Responses` type alias for the response-declaration mapping, and `BAD_REQUEST` / `NOT_FOUND` / `SERVER_ERROR` to compose an `errors` set from, e.g. `errors=NOT_FOUND | SERVER_ERROR`. - Every route declares its 400 and 404 responses. 404 was raised from nine places and declared nowhere; 400 likewise. @@ -16,6 +17,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a ### Changed +- **BREAKING** A `limit` below 1 is rejected with a 422 instead of being silently accepted, on every paginated endpoint and in the opportunity search body. Previously the 100-item cap was applied only to `GET /products`, `limit=0` dead-ended paging, and a negative limit silently truncated the result set with no `next` link. An over-large `limit` is clamped rather than rejected: the spec makes it what the client asks for, not what the server owes, and publishes no maximum -- so neither does the document. - **BREAKING** Path parameters are camelCase in the routes and in the exported OpenAPI document: `{orderId}`, `{searchRecordId}`, and `{opportunityCollectionId}`, joining the existing `{productId}`. Request URLs are unchanged, since path parameter names never appear in them, but generated clients that bind by parameter name need regenerating, and `url_for` calls must pass the camelCase keyword (`url_for(request, name, orderId=...)`, not `order_id=...`). - **BREAKING** A route is declared as a `Route` and registered with `StapiFastapiBaseRouter.register_route`, which hands it to FastAPI's own `add_api_route`. `summary`, `tag` and `errors` are required, so a route cannot be registered without saying what it is called, where it is filed, or which errors it can produce. `errors` is deliberately not defaulted: a shared set merged into every route cannot be narrowed, and so published a 404 for the landing page, an endpoint that takes no input and calls no backend. - OpenAPI tags come from the route family rather than the owning router: creating an order for a product is filed under Orders, and the opportunity routes under Opportunities, rather than all of them under Products. diff --git a/stapi-fastapi/src/stapi_fastapi/query_params.py b/stapi-fastapi/src/stapi_fastapi/query_params.py new file mode 100644 index 0000000..c0d67f7 --- /dev/null +++ b/stapi-fastapi/src/stapi_fastapi/query_params.py @@ -0,0 +1,46 @@ +"""Query parameter annotations shared by the paginated collection endpoints.""" + +from typing import Annotated + +from fastapi import Query +from pydantic import AfterValidator + +MIN_LIMIT = 1 +"""Smallest page size a client may request.""" + +MAX_LIMIT = 100 +"""Largest page size the server will serve, however many a client asks for.""" + +DEFAULT_LIMIT = 10 +"""Page size used when a client requests none.""" + + +def clamp_limit(limit: int) -> int: + """Hold a requested page size down to what the server will serve. + + The spec makes `limit` an upper bound the client *requests*, not one the + server must honour, and publishes no maximum. An over-large ask is therefore + answered with a smaller page rather than rejected, which is both what the + client wanted and one round trip cheaper than making them ask again. + """ + return min(limit, MAX_LIMIT) + + +#: Maximum number of items to return in a single page. Only the lower bound is +#: published, as in the spec; above `MAX_LIMIT` the page is quietly clamped. +Limit = Annotated[ + int, + Query( + ge=MIN_LIMIT, + description="The maximum number of items to return in a single page.", + ), + AfterValidator(clamp_limit), +] + +#: Opaque pagination token, as returned in the `next` link of a prior page. +NextToken = Annotated[ + str | None, + Query( + description="Pagination token, as provided by the `next` link of a previous response.", + ), +] diff --git a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py index 8a096b2..1c29b39 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py @@ -37,6 +37,7 @@ from stapi_fastapi.errors import NotFoundError, QueryablesError from stapi_fastapi.models.product import Product from stapi_fastapi.path_params import OpportunityCollectionIdPath +from stapi_fastapi.query_params import DEFAULT_LIMIT, clamp_limit from stapi_fastapi.responses import GeoJSONResponse from stapi_fastapi.routers.base import BAD_REQUEST, NOT_FOUND, SERVER_ERROR, Route, StapiFastapiBaseRouter from stapi_fastapi.routers.route_names import ( @@ -282,12 +283,17 @@ async def search_opportunities_sync( response: Response, prefer: Prefer | None, ) -> OpportunityCollection: # type: ignore + # The POST body carries its own `limit`, so it is held to the same bound + # as the GET collections' query parameter. Its lower bound is the + # model's, so only the clamp is applied here. + limit = DEFAULT_LIMIT if search.limit is None else clamp_limit(search.limit) + links: list[Link] = [] match await self.product.search_opportunities( self, search, search.next, - search.limit, + limit, request, ): case Success((features, maybe_pagination_token)): diff --git a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py index ee7a32a..54ac60d 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py @@ -33,6 +33,7 @@ from stapi_fastapi.errors import NotFoundError from stapi_fastapi.models.product import Product from stapi_fastapi.path_params import OrderIdPath, SearchRecordIdPath +from stapi_fastapi.query_params import DEFAULT_LIMIT, Limit, NextToken from stapi_fastapi.responses import GeoJSONResponse from stapi_fastapi.routers.base import NOT_FOUND, SERVER_ERROR, Route, StapiFastapiBaseRouter from stapi_fastapi.routers.product_router import ProductRouter @@ -236,9 +237,8 @@ def get_root(self, request: Request) -> RootResponse: def get_conformance(self) -> Conformance: return Conformance(conforms_to=self.conformances) - def get_products(self, request: Request, next: str | None = None, limit: int = 10) -> ProductCollection: + def get_products(self, request: Request, next: NextToken = None, limit: Limit = DEFAULT_LIMIT) -> ProductCollection: start = 0 - limit = min(limit, 100) try: if next: start = self.product_ids.index(next) @@ -261,7 +261,7 @@ def get_products(self, request: Request, next: str | None = None, limit: int = 1 ) async def get_orders( # noqa: C901 - self, request: Request, next: str | None = None, limit: int = 10 + self, request: Request, next: NextToken = None, limit: Limit = DEFAULT_LIMIT ) -> OrderCollection[OrderStatus]: links: list[Link] = [] orders_count: int | None = None @@ -326,8 +326,8 @@ async def get_order_statuses( self, order_id: OrderIdPath, request: Request, - next: str | None = None, - limit: int = 10, + next: NextToken = None, + limit: Limit = DEFAULT_LIMIT, ) -> OrderStatusCollection: # type: ignore links: list[Link] = [] match await self._get_order_statuses(order_id, next, limit, request): @@ -395,7 +395,7 @@ def pagination_link(self, request: Request, name: str, pagination_token: str, li ) async def get_opportunity_search_records( - self, request: Request, next: str | None = None, limit: int = 10 + self, request: Request, next: NextToken = None, limit: Limit = DEFAULT_LIMIT ) -> OpportunitySearchRecordCollection: links: list[Link] = [] match await self._get_opportunity_search_records(next, limit, request): diff --git a/stapi-fastapi/tests/test_opportunity.py b/stapi-fastapi/tests/test_opportunity.py index c9b4bfb..ac7d643 100644 --- a/stapi-fastapi/tests/test_opportunity.py +++ b/stapi-fastapi/tests/test_opportunity.py @@ -1,5 +1,8 @@ +from typing import Any + import pytest from fastapi.testclient import TestClient +from stapi_fastapi.query_params import MAX_LIMIT from stapi_pydantic import ( OpportunityCollection, ) @@ -55,3 +58,31 @@ def test_search_opportunities_pagination( expected_returns=expected_returns, body=opportunity_search, ) + + +@pytest.mark.parametrize("limit", [0, -1]) +def test_search_opportunities_rejects_limit_below_the_minimum( + limit: int, + stapi_client: TestClient, + opportunity_search: dict[str, Any], +) -> None: + """The POST body's `limit` is bounded exactly as the GET query param is.""" + response = stapi_client.post( + "/products/test-spotlight/opportunities", + json={**opportunity_search, "limit": limit}, + ) + # 422 is spelled out: starlette renamed its constant for this status code, + # and the old name now raises a DeprecationWarning. + assert response.status_code == 422 + + +def test_search_opportunities_clamps_an_over_large_limit( + stapi_client: TestClient, + opportunity_search: dict[str, Any], +) -> None: + """As on the GET collections: the spec's `limit` is a request, not a demand.""" + response = stapi_client.post( + "/products/test-spotlight/opportunities", + json={**opportunity_search, "limit": MAX_LIMIT + 1}, + ) + assert response.status_code == 200, response.text diff --git a/stapi-fastapi/tests/test_order.py b/stapi-fastapi/tests/test_order.py index 1b5a898..8270a24 100644 --- a/stapi-fastapi/tests/test_order.py +++ b/stapi-fastapi/tests/test_order.py @@ -159,7 +159,7 @@ def setup_orders_pagination(stapi_client: TestClient, create_order_payloads) -> return orders -@pytest.mark.parametrize("limit", [0, 1, 2, 4]) +@pytest.mark.parametrize("limit", [1, 2, 4]) def test_get_orders_pagination(limit, setup_orders_pagination, create_order_payloads, stapi_client: TestClient) -> None: expected_returns = [] if limit > 0: @@ -204,7 +204,7 @@ def order_statuses() -> dict[str, list[OrderStatus]]: return statuses -@pytest.mark.parametrize("limit", [0, 1, 2, 4]) +@pytest.mark.parametrize("limit", [1, 2, 4]) def test_get_order_status_pagination( limit: int, stapi_client: TestClient, diff --git a/stapi-fastapi/tests/test_pagination.py b/stapi-fastapi/tests/test_pagination.py new file mode 100644 index 0000000..6f076f1 --- /dev/null +++ b/stapi-fastapi/tests/test_pagination.py @@ -0,0 +1,83 @@ +"""Tests for the shared pagination query parameters, `self` links and totals.""" + +from typing import Any, cast + +import pytest +from fastapi import FastAPI, status +from fastapi.testclient import TestClient +from stapi_fastapi.query_params import MAX_LIMIT, clamp_limit + +from .shared import ( + product_test_spotlight_async_opportunity, +) + +PAGINATED_PATHS = [ + "/products", + "/orders", + "/orders/an-order-id/statuses", +] + +INVALID_LIMITS = [0, -2] + +LIMITS = [1, 2, 4] + +PRODUCT_ID = "test-spotlight" + +ORDER_PAYLOAD: dict[str, Any] = { + "search_parameters": { + "datetime": "2024-10-09T18:55:33Z/2024-10-12T18:55:33Z", + "geometry": {"type": "Point", "coordinates": [0, 0]}, + "filter": { + "op": "and", + "args": [ + {"op": ">", "args": [{"property": "off_nadir"}, 0]}, + {"op": "<", "args": [{"property": "off_nadir"}, 45]}, + ], + }, + }, + "order_parameters": {"s3_path": "s3://my-bucket"}, +} + + +@pytest.mark.parametrize("path", PAGINATED_PATHS) +@pytest.mark.parametrize("limit", INVALID_LIMITS) +def test_out_of_range_limit_is_rejected(path: str, limit: int, stapi_client: TestClient) -> None: + res = stapi_client.get(path, params={"limit": limit}) + # 422 is spelled out: starlette renamed its constant for this status code, + # so referencing either name warns on one version or breaks on the other. + assert res.status_code == 422 + + +@pytest.mark.parametrize("limit", INVALID_LIMITS) +@pytest.mark.mock_products([product_test_spotlight_async_opportunity]) +def test_out_of_range_limit_is_rejected_on_search_records( + limit: int, stapi_client_async_opportunity: TestClient +) -> None: + res = stapi_client_async_opportunity.get("/searches/opportunities", params={"limit": limit}) + assert res.status_code == 422 + + +@pytest.mark.parametrize("path", PAGINATED_PATHS) +def test_limit_bounds_are_documented(path: str, stapi_client: TestClient) -> None: + spec = cast(FastAPI, stapi_client.app).openapi() + parameters = {p["name"]: p for p in spec["paths"][path.replace("an-order-id", "{orderId}")]["get"]["parameters"]} + + limit_schema = parameters["limit"]["schema"] + assert limit_schema["minimum"] == 1 + assert limit_schema["default"] == 10 + # The spec publishes no maximum, so neither does this: an over-large ask is + # clamped, and advertising a ceiling would invite a 422 that never comes. + assert "maximum" not in limit_schema + + +@pytest.mark.parametrize("path", ["/products", "/orders"]) +def test_over_large_limit_is_clamped_not_rejected(path: str, stapi_client: TestClient) -> None: + """The spec's `limit` is what the client asks for, not what it is owed.""" + res = stapi_client.get(path, params={"limit": MAX_LIMIT + 1}) + assert res.status_code == status.HTTP_200_OK, res.text + + +def test_clamp_limit_caps_at_the_maximum() -> None: + assert clamp_limit(MAX_LIMIT + 1) == MAX_LIMIT + assert clamp_limit(MAX_LIMIT) == MAX_LIMIT + assert clamp_limit(1) == 1 diff --git a/stapi-fastapi/tests/test_product.py b/stapi-fastapi/tests/test_product.py index 325797b..127c787 100644 --- a/stapi-fastapi/tests/test_product.py +++ b/stapi-fastapi/tests/test_product.py @@ -83,7 +83,7 @@ def test_product_order_parameters_response( assert "s3_path" in json_schema["properties"] -@pytest.mark.parametrize("limit", [0, 1, 2, 4]) +@pytest.mark.parametrize("limit", [1, 2, 4]) def test_get_products_pagination( limit: int, stapi_client: TestClient, From ec2ef0e494acbcbc3f81ad5529122b3996738ce0 Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 6 Aug 2026 18:50:10 -0700 Subject: [PATCH 28/48] refactor!: move the shared pagination link onto the base router Two different things were called `pagination_link`. The root router's built a `next` URL by appending query parameters; the product router's built a `next` that re-POSTs the search body, because an opportunity search is parameterized by a body rather than a URL. A reader of one had no way to know the other existed and behaved differently. The query-parameter one moves to `StapiFastapiBaseRouter`, where every router that paginates can reach it. The POST-bodied one is renamed `search_pagination_link` and says in its docstring what makes it different. --- stapi-fastapi/CHANGELOG.md | 1 + stapi-fastapi/src/stapi_fastapi/routers/base.py | 7 +++++++ .../src/stapi_fastapi/routers/product_router.py | 9 +++++++-- stapi-fastapi/src/stapi_fastapi/routers/root_router.py | 6 ------ 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/stapi-fastapi/CHANGELOG.md b/stapi-fastapi/CHANGELOG.md index 47171bd..a479f26 100644 --- a/stapi-fastapi/CHANGELOG.md +++ b/stapi-fastapi/CHANGELOG.md @@ -21,6 +21,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a - **BREAKING** Path parameters are camelCase in the routes and in the exported OpenAPI document: `{orderId}`, `{searchRecordId}`, and `{opportunityCollectionId}`, joining the existing `{productId}`. Request URLs are unchanged, since path parameter names never appear in them, but generated clients that bind by parameter name need regenerating, and `url_for` calls must pass the camelCase keyword (`url_for(request, name, orderId=...)`, not `order_id=...`). - **BREAKING** A route is declared as a `Route` and registered with `StapiFastapiBaseRouter.register_route`, which hands it to FastAPI's own `add_api_route`. `summary`, `tag` and `errors` are required, so a route cannot be registered without saying what it is called, where it is filed, or which errors it can produce. `errors` is deliberately not defaulted: a shared set merged into every route cannot be narrowed, and so published a 404 for the landing page, an endpoint that takes no input and calls no backend. - OpenAPI tags come from the route family rather than the owning router: creating an order for a product is filed under Orders, and the opportunity routes under Opportunities, rather than all of them under Products. +- **BREAKING** `ProductRouter.pagination_link` is renamed `search_pagination_link`, distinguishing the POST-bodied opportunity search `next` link from the shared query-parameter one, which now lives on the base router. - **BREAKING** The `GET_OPPORTUNITY_SEARCH_RECORD_STATUSES` route name constant is renamed `LIST_OPPORTUNITY_SEARCH_RECORD_STATUSES`, matching its sibling list routes, and the registered route name changes with it. ### Fixed diff --git a/stapi-fastapi/src/stapi_fastapi/routers/base.py b/stapi-fastapi/src/stapi_fastapi/routers/base.py index d1878cc..03acb13 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/base.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/base.py @@ -11,7 +11,9 @@ ) from fastapi.datastructures import URL, Default, DefaultPlaceholder from fastapi.responses import JSONResponse +from stapi_pydantic import Link +from stapi_fastapi.constants import TYPE_JSON from stapi_fastapi.routers.route_names import Tag #: OpenAPI response declarations, keyed by status code. @@ -128,6 +130,11 @@ def route_name(self, name: str) -> str: """The registered name of the route this router serves under `name`.""" return ":".join((*self.route_name_prefix, name)) + def pagination_link(self, request: Request, name: str, pagination_token: str, limit: int, **kwargs: Any) -> Link: + """A `next` link for the page after the one being returned.""" + url = self.url_for(request, name, **kwargs).include_query_params(next=pagination_token, limit=limit) + return Link(href=url, rel="next", type=TYPE_JSON) + def register_route(self, route: Route) -> None: """Register `route` on this router.""" self.add_api_route(**route.to_api_route(self.route_name(route.name))) diff --git a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py index 1c29b39..98e8e05 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py @@ -300,7 +300,7 @@ async def search_opportunities_sync( links.append(self.order_link(request, search)) match maybe_pagination_token: case Some(x): - links.append(self.pagination_link(request, search, x)) + links.append(self.search_pagination_link(request, search, x)) case Maybe.empty: pass case Failure(e) if isinstance(e, QueryablesError): @@ -411,7 +411,12 @@ def order_link(self, request: Request, opp_req: OpportunityRequest) -> Link: body=opp_req.search_body(), ) - def pagination_link(self, request: Request, opp_req: OpportunityRequest, pagination_token: str) -> Link: + def search_pagination_link(self, request: Request, opp_req: OpportunityRequest, pagination_token: str) -> Link: + """A `next` link for an opportunity search, whose parameters are a POST body. + + Distinct from the base router's query-parameter one: paging a search + means re-POSTing the search body with a new token, not following a URL. + """ body = opp_req.body() body["next"] = pagination_token return Link( diff --git a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py index 54ac60d..ab80d47 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py @@ -388,12 +388,6 @@ def order_links(self, order: Order[OrderStatus], request: Request) -> list[Link] def order_statuses_link(self, request: Request, order_id: str) -> Link: return json_link("self", self.url_for(request, self.route_name(LIST_ORDER_STATUSES), orderId=order_id)) - def pagination_link(self, request: Request, name: str, pagination_token: str, limit: int, **kwargs: Any) -> Link: - return json_link( - "next", - self.url_for(request, name, **kwargs).include_query_params(next=pagination_token, limit=limit), - ) - async def get_opportunity_search_records( self, request: Request, next: NextToken = None, limit: Limit = DEFAULT_LIMIT ) -> OpportunitySearchRecordCollection: From ba7b479ace222ecf64e7a6c65fc1f0c3ba13c638 Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 6 Aug 2026 18:54:43 -0700 Subject: [PATCH 29/48] feat!: return a Page from every list backend Four list backends returned four differently-shaped tuples: orders a three-tuple, statuses a `Maybe` of a two-tuple, search records and opportunities a plain two-tuple. Only the orders one could carry a total, so only `GET /orders` could publish `numberMatched`, and each handler unpacked its own shape with its own nested `match`. `Page` is that shape, once: `items`, `next_token`, `number_matched`, and any collection-level `links` only the backend can know. Every list backend returns one, and every collection response is assembled the same way through `self_link` and `page_links`. Two things follow. `numberMatched` is now populated on every collection the backend can count, including `GET /products`. And every paginated response carries a `self` link, which several did not, so `RootRouter.order_statuses_link` -- a hand-built `self` link for one endpoint -- is gone. --- stapi-fastapi/CHANGELOG.md | 4 + stapi-fastapi/src/stapi_fastapi/__init__.py | 2 + .../stapi_fastapi/backends/product_backend.py | 3 +- .../stapi_fastapi/backends/root_backend.py | 40 +++---- stapi-fastapi/src/stapi_fastapi/pagination.py | 31 ++++++ .../src/stapi_fastapi/routers/base.py | 24 ++++ .../stapi_fastapi/routers/product_router.py | 17 +-- .../src/stapi_fastapi/routers/root_router.py | 105 ++++++------------ stapi-fastapi/tests/backends.py | 53 ++++++--- stapi-fastapi/tests/test_order.py | 8 +- 10 files changed, 169 insertions(+), 118 deletions(-) create mode 100644 stapi-fastapi/src/stapi_fastapi/pagination.py diff --git a/stapi-fastapi/CHANGELOG.md b/stapi-fastapi/CHANGELOG.md index a479f26..e5abbe9 100644 --- a/stapi-fastapi/CHANGELOG.md +++ b/stapi-fastapi/CHANGELOG.md @@ -9,6 +9,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a ### Added - `Route`, a declarative route descriptor, with `Route.to_api_route()` returning the keyword arguments for FastAPI's own `add_api_route`, and `StapiFastapiBaseRouter.register_route()` handing them over. `Route` requires a `summary`, a `tag` (the new `Tag` enum) and an `errors` set, so an operation cannot be published without a title, a heading, or an accurate statement of how it can fail. +- `Page`, exported from `stapi_fastapi`, is the one shape every list backend returns. It carries `items`, a `next_token`, an optional `number_matched`, and any collection-level `links` only the backend can know (e.g. `create-order` on a stored Opportunity Collection). +- `numberMatched` is populated on every collection response the backend can count, including `GET /products`. - `stapi_fastapi.query_params` provides the shared `Limit` and `NextToken` annotations and `DEFAULT_LIMIT`, so every paginated endpoint validates identically and publishes its bounds. - `stapi_fastapi.path_params` provides the camelCase path parameter annotations. - `Responses` type alias for the response-declaration mapping, and `BAD_REQUEST` / `NOT_FOUND` / `SERVER_ERROR` to compose an `errors` set from, e.g. `errors=NOT_FOUND | SERVER_ERROR`. @@ -17,6 +19,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a ### Changed +- **BREAKING** Every list backend must now return a `Page`. `GetOrders` returned `tuple[list[Order], Maybe[str], Maybe[int]]`, `SearchOpportunities` and `GetOpportunitySearchRecords` returned `tuple[list[...], Maybe[str]]`, and `GetOrderStatuses` returned `Maybe[tuple[list[...], Maybe[str]]]`. All now return `Page` (wrapped in `Maybe` where they were before): put the items in `Page.items`, the pagination token in `Page.next_token`, and the total in `Page.number_matched`. - **BREAKING** A `limit` below 1 is rejected with a 422 instead of being silently accepted, on every paginated endpoint and in the opportunity search body. Previously the 100-item cap was applied only to `GET /products`, `limit=0` dead-ended paging, and a negative limit silently truncated the result set with no `next` link. An over-large `limit` is clamped rather than rejected: the spec makes it what the client asks for, not what the server owes, and publishes no maximum -- so neither does the document. - **BREAKING** Path parameters are camelCase in the routes and in the exported OpenAPI document: `{orderId}`, `{searchRecordId}`, and `{opportunityCollectionId}`, joining the existing `{productId}`. Request URLs are unchanged, since path parameter names never appear in them, but generated clients that bind by parameter name need regenerating, and `url_for` calls must pass the camelCase keyword (`url_for(request, name, orderId=...)`, not `order_id=...`). - **BREAKING** A route is declared as a `Route` and registered with `StapiFastapiBaseRouter.register_route`, which hands it to FastAPI's own `add_api_route`. `summary`, `tag` and `errors` are required, so a route cannot be registered without saying what it is called, where it is filed, or which errors it can produce. `errors` is deliberately not defaulted: a shared set merged into every route cannot be narrowed, and so published a 404 for the landing page, an endpoint that takes no input and calls no backend. @@ -32,6 +35,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a ### Removed +- `RootRouter.order_statuses_link`. The order statuses response builds its `self` link through the shared `page_links` helper. - A duplicate definition of the `LIST_PRODUCTS` route name constant. - **BREAKING** The runtime dependencies the library never imported: `httpx`, `pygeofilter`, `nox`, `pydantic-settings`, and `uvicorn`. If your application imports any of these, depend on it directly. `httpx` remains a development dependency, for the test client. diff --git a/stapi-fastapi/src/stapi_fastapi/__init__.py b/stapi-fastapi/src/stapi_fastapi/__init__.py index 7a8957d..851fd1f 100644 --- a/stapi-fastapi/src/stapi_fastapi/__init__.py +++ b/stapi-fastapi/src/stapi_fastapi/__init__.py @@ -1,6 +1,8 @@ +from .pagination import Page from .routers import ProductRouter, RootRouter __all__ = [ + "Page", "ProductRouter", "RootRouter", ] diff --git a/stapi-fastapi/src/stapi_fastapi/backends/product_backend.py b/stapi-fastapi/src/stapi_fastapi/backends/product_backend.py index 8698d64..e120de2 100644 --- a/stapi-fastapi/src/stapi_fastapi/backends/product_backend.py +++ b/stapi-fastapi/src/stapi_fastapi/backends/product_backend.py @@ -15,11 +15,12 @@ OrderRequest, ) +from stapi_fastapi.pagination import Page from stapi_fastapi.routers.product_router import ProductRouter SearchOpportunities = Callable[ [ProductRouter, OpportunityRequest, str | None, int, Request], - Coroutine[Any, Any, ResultE[tuple[list[Opportunity], Maybe[str]]]], # type: ignore + Coroutine[Any, Any, ResultE[Page[Opportunity]]], # type: ignore ] """ Type alias for an async function that searches for ordering opportunities for the given diff --git a/stapi-fastapi/src/stapi_fastapi/backends/root_backend.py b/stapi-fastapi/src/stapi_fastapi/backends/root_backend.py index f13e5cc..9f65fa2 100644 --- a/stapi-fastapi/src/stapi_fastapi/backends/root_backend.py +++ b/stapi-fastapi/src/stapi_fastapi/backends/root_backend.py @@ -11,12 +11,14 @@ OrderStatus, ) +from stapi_fastapi.pagination import Page + GetOrders = Callable[ [str | None, int, Request], - Coroutine[Any, Any, ResultE[tuple[list[Order[OrderStatus]], Maybe[str], Maybe[int]]]], + Coroutine[Any, Any, ResultE[Page[Order[OrderStatus]]]], ] """ -Type alias for an async function that returns a list of existing Orders. +Type alias for an async function that returns a page of existing Orders. Args: next (str | None): A pagination token. @@ -24,12 +26,9 @@ request (Request): FastAPI's Request object. Returns: - A tuple containing a list of orders and a pagination token. - - - Should return returns.result.Success[tuple[list[Order], returns.maybe.Some[str]]] - if including a pagination token - - Should return returns.result.Success[tuple[list[Order], returns.maybe.Nothing]] - if not including a pagination token + - Should return returns.result.Success[stapi_fastapi.pagination.Page[Order]]. + The page's `next_token` becomes the collection's `next` link and its + `number_matched` becomes the collection's `numberMatched`. - Returning returns.result.Failure[Exception] will result in a 500. """ @@ -53,10 +52,11 @@ GetOrderStatuses = Callable[ [str, str | None, int, Request], - Coroutine[Any, Any, ResultE[Maybe[tuple[list[T], Maybe[str]]]]], + Coroutine[Any, Any, ResultE[Maybe[Page[T]]]], ] """ -Type alias for an async function that gets statuses for the order with `order_id`. +Type alias for an async function that gets a page of statuses for the order with +`order_id`. Args: order_id (str): The order ID. @@ -65,33 +65,27 @@ request (Request): FastAPI's Request object. Returns: - A tuple containing a list of order statuses and a pagination token. - - - Should return returns.result.Success[returns.maybe.Some[tuple[list[OrderStatus], returns.maybe.Some[str]]] - if order is found and including a pagination token. - - Should return returns.result.Success[returns.maybe.Some[tuple[list[OrderStatus], returns.maybe.Nothing]]] - if order is found and not including a pagination token. + - Should return returns.result.Success[returns.maybe.Some[stapi_fastapi.pagination.Page[OrderStatus]]] + if the order is found. - Should return returns.result.Success[returns.maybe.Nothing] if the order is not found or if access is denied. - Returning returns.result.Failure[Exception] will result in a 500. """ GetOpportunitySearchRecords = Callable[ [str | None, int, Request], - Coroutine[Any, Any, ResultE[tuple[list[OpportunitySearchRecord], Maybe[str]]]], + Coroutine[Any, Any, ResultE[Page[OpportunitySearchRecord]]], ] """ -Type alias for an async function that gets OpportunitySearchRecords for all products. +Type alias for an async function that gets a page of OpportunitySearchRecords for +all products. Args: - request (Request): FastAPI's Request object. next (str | None): A pagination token. limit (int): The maximum number of search records to return in a page. + request (Request): FastAPI's Request object. Returns: - - Should return returns.result.Success[tuple[list[OpportunitySearchRecord], returns.maybe.Some[str]]] - if including a pagination token - - Should return returns.result.Success[tuple[list[OpportunitySearchRecord], returns.maybe.Nothing]] - if not including a pagination token + - Should return returns.result.Success[stapi_fastapi.pagination.Page[OpportunitySearchRecord]]. - Returning returns.result.Failure[Exception] will result in a 500. """ diff --git a/stapi-fastapi/src/stapi_fastapi/pagination.py b/stapi-fastapi/src/stapi_fastapi/pagination.py new file mode 100644 index 0000000..d2f09eb --- /dev/null +++ b/stapi-fastapi/src/stapi_fastapi/pagination.py @@ -0,0 +1,31 @@ +"""The page contract shared by every list backend.""" + +from dataclasses import dataclass, field +from typing import Generic, TypeVar + +from returns.maybe import Maybe, Nothing +from stapi_pydantic import Link + +T = TypeVar("T") + + +@dataclass(frozen=True) +class Page(Generic[T]): + """One page of a collection, as returned by a list backend.""" + + items: list[T] + """The items on this page, no more than the requested `limit` of them.""" + + next_token: Maybe[str] = Nothing + """Token identifying the page after this one, `Nothing` if this is the last.""" + + number_matched: Maybe[int] = Nothing + """Total items matching the request across all pages. + + `Nothing` means unknown, and `numberMatched` is omitted from the response. + """ + + links: list[Link] = field(default_factory=list) + """Collection-level links only the backend can know, e.g. `create-order` on + an Opportunity Collection. The handler adds `self` and `next` itself. + """ diff --git a/stapi-fastapi/src/stapi_fastapi/routers/base.py b/stapi-fastapi/src/stapi_fastapi/routers/base.py index 03acb13..173ba14 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/base.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/base.py @@ -14,6 +14,7 @@ from stapi_pydantic import Link from stapi_fastapi.constants import TYPE_JSON +from stapi_fastapi.pagination import Page from stapi_fastapi.routers.route_names import Tag #: OpenAPI response declarations, keyed by status code. @@ -130,11 +131,34 @@ def route_name(self, name: str) -> str: """The registered name of the route this router serves under `name`.""" return ":".join((*self.route_name_prefix, name)) + def self_link(self, request: Request, name: str, **path_params: Any) -> Link: + """A `self` link for the current request.""" + return Link(href=self.url_for(request, name, **path_params), rel="self", type=TYPE_JSON) + def pagination_link(self, request: Request, name: str, pagination_token: str, limit: int, **kwargs: Any) -> Link: """A `next` link for the page after the one being returned.""" url = self.url_for(request, name, **kwargs).include_query_params(next=pagination_token, limit=limit) return Link(href=url, rel="next", type=TYPE_JSON) + def page_links( + self, + request: Request, + page: Page[Any], + name: str, + limit: int, + **path_params: Any, + ) -> list[Link]: + """The links published on a collection response for `page`. + + Backend-supplied links come first: they describe the collection rather + than this page of it. + """ + links = [*page.links, self.self_link(request, name, **path_params)] + next_token = page.next_token.value_or(None) + if next_token is not None: + links.append(self.pagination_link(request, name, next_token, limit, **path_params)) + return links + def register_route(self, route: Route) -> None: """Register `route` on this router.""" self.add_api_route(**route.to_api_route(self.route_name(route.name))) diff --git a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py index 98e8e05..7b9eeae 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py @@ -296,13 +296,12 @@ async def search_opportunities_sync( limit, request, ): - case Success((features, maybe_pagination_token)): + case Success(page): + links.extend(page.links) links.append(self.order_link(request, search)) - match maybe_pagination_token: - case Some(x): - links.append(self.search_pagination_link(request, search, x)) - case Maybe.empty: - pass + next_token = page.next_token.value_or(None) + if next_token is not None: + links.append(self.search_pagination_link(request, search, next_token)) case Failure(e) if isinstance(e, QueryablesError): raise e case Failure(e): @@ -320,7 +319,11 @@ async def search_opportunities_sync( if prefer is Prefer.wait and self.root_router.supports_async_opportunity_search: response.headers["Preference-Applied"] = "wait" - return OpportunityCollection(features=features, links=links) + return OpportunityCollection( + features=page.items, + links=links, + number_matched=page.number_matched.value_or(None), + ) async def search_opportunities_async( self, diff --git a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py index ab80d47..07935d1 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py @@ -4,7 +4,7 @@ from fastapi import HTTPException, Request, status from fastapi.datastructures import URL -from returns.maybe import Maybe, Some +from returns.maybe import Maybe, Nothing, Some from returns.result import Failure, Success from stapi_pydantic import ( Conformance, @@ -32,6 +32,7 @@ from stapi_fastapi.constants import TYPE_GEOJSON from stapi_fastapi.errors import NotFoundError from stapi_fastapi.models.product import Product +from stapi_fastapi.pagination import Page from stapi_fastapi.path_params import OrderIdPath, SearchRecordIdPath from stapi_fastapi.query_params import DEFAULT_LIMIT, Limit, NextToken from stapi_fastapi.responses import GeoJSONResponse @@ -239,46 +240,36 @@ def get_conformance(self) -> Conformance: def get_products(self, request: Request, next: NextToken = None, limit: Limit = DEFAULT_LIMIT) -> ProductCollection: start = 0 - try: - if next: + if next: + try: start = self.product_ids.index(next) - except ValueError: - logger.exception("An error occurred while retrieving products") - raise NotFoundError(detail="Error finding pagination token for products") from None + except ValueError: + raise NotFoundError(detail="Error finding pagination token for products") from None + end = start + limit - ids = self.product_ids[start:end] - links = [ - json_link( - "self", - self.url_for(request, self.route_name(LIST_PRODUCTS)), - ), - ] - if end > 0 and end < len(self.product_ids): - links.append(self.pagination_link(request, self.route_name(LIST_PRODUCTS), self.product_ids[end], limit)) + page = Page( + items=[self.product_routers[product_id].get_product(request) for product_id in self.product_ids[start:end]], + next_token=Some(self.product_ids[end]) if end < len(self.product_ids) else Nothing, + number_matched=Some(len(self.product_ids)), + ) return ProductCollection( - products=[self.product_routers[product_id].get_product(request) for product_id in ids], - links=links, + products=page.items, + links=self.page_links(request, page, self.route_name(LIST_PRODUCTS), limit), + number_matched=page.number_matched.value_or(None), ) - async def get_orders( # noqa: C901 + async def get_orders( self, request: Request, next: NextToken = None, limit: Limit = DEFAULT_LIMIT ) -> OrderCollection[OrderStatus]: - links: list[Link] = [] - orders_count: int | None = None match await self._get_orders(next, limit, request): - case Success((orders, maybe_pagination_token, maybe_orders_count)): - for order in orders: + case Success(page): + for order in page.items: order.links.extend(self.order_links(order, request)) - match maybe_pagination_token: - case Some(next_): - links.append(self.pagination_link(request, self.route_name(LIST_ORDERS), next_, limit)) - case Maybe.empty: - pass - match maybe_orders_count: - case Some(x): - orders_count = x - case Maybe.empty: - pass + return OrderCollection( + features=page.items, + links=self.page_links(request, page, self.route_name(LIST_ORDERS), limit), + number_matched=page.number_matched.value_or(None), + ) case Failure(ValueError()): raise NotFoundError(detail="Error finding pagination token") case Failure(e): @@ -293,12 +284,6 @@ async def get_orders( # noqa: C901 case _: raise AssertionError("Expected code to be unreachable") - return OrderCollection( - features=orders, - links=links, - number_matched=orders_count, - ) - async def get_order(self, order_id: OrderIdPath, request: Request) -> Order[OrderStatus]: """ Get details for order with `order_id`. @@ -328,20 +313,14 @@ async def get_order_statuses( request: Request, next: NextToken = None, limit: Limit = DEFAULT_LIMIT, - ) -> OrderStatusCollection: # type: ignore - links: list[Link] = [] + ) -> OrderStatusCollection: match await self._get_order_statuses(order_id, next, limit, request): - case Success(Some((statuses, maybe_pagination_token))): - links.append(self.order_statuses_link(request, order_id)) - match maybe_pagination_token: - case Some(next_): - links.append( - self.pagination_link( - request, self.route_name(LIST_ORDER_STATUSES), next_, limit, orderId=order_id - ) - ) - case Maybe.empty: - pass + case Success(Some(page)): + return OrderStatusCollection( + statuses=page.items, + links=self.page_links(request, page, self.route_name(LIST_ORDER_STATUSES), limit, orderId=order_id), + number_matched=page.number_matched.value_or(None), + ) case Success(Maybe.empty): raise NotFoundError("Order not found") case Failure(ValueError()): @@ -357,7 +336,6 @@ async def get_order_statuses( ) case _: raise AssertionError("Expected code to be unreachable") - return OrderStatusCollection(statuses=statuses, links=links) def add_product(self, product: Product, *args: Any, **kwargs: Any) -> None: # Give the include a prefix from the product router @@ -385,26 +363,18 @@ def order_links(self, order: Order[OrderStatus], request: Request) -> list[Link] ), ] - def order_statuses_link(self, request: Request, order_id: str) -> Link: - return json_link("self", self.url_for(request, self.route_name(LIST_ORDER_STATUSES), orderId=order_id)) - async def get_opportunity_search_records( self, request: Request, next: NextToken = None, limit: Limit = DEFAULT_LIMIT ) -> OpportunitySearchRecordCollection: - links: list[Link] = [] match await self._get_opportunity_search_records(next, limit, request): - case Success((records, maybe_pagination_token)): - for record in records: + case Success(page): + for record in page.items: record.links.append(self.opportunity_search_record_self_link(record, request)) - match maybe_pagination_token: - case Some(next_): - links.append( - self.pagination_link( - request, self.route_name(LIST_OPPORTUNITY_SEARCH_RECORDS), next_, limit - ) - ) - case Maybe.empty: - pass + return OpportunitySearchRecordCollection( + records=page.items, + links=self.page_links(request, page, self.route_name(LIST_OPPORTUNITY_SEARCH_RECORDS), limit), + number_matched=page.number_matched.value_or(None), + ) case Failure(ValueError()): raise NotFoundError(detail="Error finding pagination token") case Failure(e): @@ -418,7 +388,6 @@ async def get_opportunity_search_records( ) case _: raise AssertionError("Expected code to be unreachable") - return OpportunitySearchRecordCollection(records=records, links=links) async def get_opportunity_search_record( self, search_record_id: SearchRecordIdPath, request: Request diff --git a/stapi-fastapi/tests/backends.py b/stapi-fastapi/tests/backends.py index eb92ba1..75bdb4d 100644 --- a/stapi-fastapi/tests/backends.py +++ b/stapi-fastapi/tests/backends.py @@ -4,6 +4,7 @@ from fastapi import Request from returns.maybe import Maybe, Nothing, Some from returns.result import Failure, ResultE, Success +from stapi_fastapi import Page from stapi_fastapi.routers.product_router import ProductRouter from stapi_pydantic import ( Opportunity, @@ -25,10 +26,12 @@ async def mock_get_orders( next: str | None, limit: int, request: Request, -) -> ResultE[tuple[list[Order], Maybe[str], Maybe[int]]]: +) -> ResultE[Page[Order]]: """ Return orders from backend. Handle pagination/limit if applicable """ + # Deliberately not len(order_ids): the tests assert that whatever the + # backend reports as the total is what reaches `numberMatched`. count = 314 try: start = 0 @@ -41,9 +44,8 @@ async def mock_get_orders( ids = order_ids[start:end] orders = [request.state._orders_db.get_order(order_id) for order_id in ids] - if end > 0 and end < len(order_ids): - return Success((orders, Some(request.state._orders_db._orders[order_ids[end]].id), Some(count))) - return Success((orders, Nothing, Some(count))) + next_token = Some(request.state._orders_db._orders[order_ids[end]].id) if end < len(order_ids) else Nothing + return Success(Page(items=orders, next_token=next_token, number_matched=Some(count))) except Exception as e: return Failure(e) @@ -60,7 +62,7 @@ async def mock_get_order(order_id: str, request: Request) -> ResultE[Maybe[Order async def mock_get_order_statuses( order_id: str, next: str | None, limit: int, request: Request -) -> ResultE[Maybe[tuple[list[OrderStatus], Maybe[str]]]]: +) -> ResultE[Maybe[Page[OrderStatus]]]: try: start = 0 limit = min(limit, 100) @@ -71,11 +73,16 @@ async def mock_get_order_statuses( if next: start = int(next) end = start + limit - stati = statuses[start:end] - if end > 0 and end < len(statuses): - return Success(Some((stati, Some(str(end))))) - return Success(Some((stati, Nothing))) + return Success( + Some( + Page( + items=statuses[start:end], + next_token=Some(str(end)) if end < len(statuses) else Nothing, + number_matched=Some(len(statuses)), + ) + ) + ) except Exception as e: return Failure(e) @@ -119,7 +126,7 @@ async def mock_search_opportunities( next: str | None, limit: int, request: Request, -) -> ResultE[tuple[list[Opportunity], Maybe[str]]]: +) -> ResultE[Page[Opportunity]]: try: start = 0 limit = min(limit, 100) @@ -131,9 +138,16 @@ async def mock_search_opportunities( o.model_copy(update={"geometry": search.search_parameters.geometry}) for o in request.state._opportunities[start:end] ] - if end > 0 and end < len(request.state._opportunities): - return Success((opportunities, Some(str(end)))) - return Success((opportunities, Nothing)) + total = len(request.state._opportunities) + # `end > 0` because the search body may ask for a limit of 0, and a + # token pointing back at offset 0 would page forever. + return Success( + Page( + items=opportunities, + next_token=Some(str(end)) if 0 < end < total else Nothing, + number_matched=Some(total), + ) + ) except Exception as e: return Failure(e) @@ -176,7 +190,7 @@ async def mock_get_opportunity_search_records( next: str | None, limit: int, request: Request, -) -> ResultE[tuple[list[OpportunitySearchRecord], Maybe[str]]]: +) -> ResultE[Page[OpportunitySearchRecord]]: try: start = 0 limit = min(limit, 100) @@ -185,11 +199,14 @@ async def mock_get_opportunity_search_records( if next: start = int(next) end = start + limit - page = search_records[start:end] - if end > 0 and end < len(search_records): - return Success((page, Some(str(end)))) - return Success((page, Nothing)) + return Success( + Page( + items=search_records[start:end], + next_token=Some(str(end)) if end < len(search_records) else Nothing, + number_matched=Some(len(search_records)), + ) + ) except Exception as e: return Failure(e) diff --git a/stapi-fastapi/tests/test_order.py b/stapi-fastapi/tests/test_order.py index 8270a24..a15b353 100644 --- a/stapi-fastapi/tests/test_order.py +++ b/stapi-fastapi/tests/test_order.py @@ -24,7 +24,13 @@ def test_empty_order(stapi_client: TestClient): "stapi_type": "OrderCollection", "stapi_version": STAPI_VERSION, "features": [], - "links": [], + "links": [ + { + "href": "http://stapiserver/orders", + "rel": "self", + "type": "application/json", + }, + ], "numberMatched": 314, } From 544829a6591db89faad546d91b64d43dd7029374 Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 6 Aug 2026 18:56:28 -0700 Subject: [PATCH 30/48] fix: carry query params and the served media type on collection links Two bugs in one place, both about a link describing the thing it points at. A paginated `self` link was built from the route alone, so every page of a collection published the same `self` -- pointing at the first page rather than the one just returned. It now carries the request's query string. That query string is copied wholesale rather than splatted into `include_query_params` as Python keywords. Those are user-controlled *names*: a request for `?self=x` collided with the method's own `self` parameter and answered 500, and repeated parameters collapsed to their last value. `next` was hard-coded to `application/json`, so every geo+json collection published a next link that contradicted its own response. Both `self` and `next` now take the media type their target actually serves. --- stapi-fastapi/CHANGELOG.md | 3 + .../src/stapi_fastapi/routers/base.py | 34 +++++++--- .../stapi_fastapi/routers/product_router.py | 4 +- .../src/stapi_fastapi/routers/root_router.py | 2 +- stapi-fastapi/tests/test_order.py | 2 +- stapi-fastapi/tests/test_pagination.py | 62 +++++++++++++++++++ 6 files changed, 95 insertions(+), 12 deletions(-) diff --git a/stapi-fastapi/CHANGELOG.md b/stapi-fastapi/CHANGELOG.md index e5abbe9..e8f20b2 100644 --- a/stapi-fastapi/CHANGELOG.md +++ b/stapi-fastapi/CHANGELOG.md @@ -24,11 +24,14 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a - **BREAKING** Path parameters are camelCase in the routes and in the exported OpenAPI document: `{orderId}`, `{searchRecordId}`, and `{opportunityCollectionId}`, joining the existing `{productId}`. Request URLs are unchanged, since path parameter names never appear in them, but generated clients that bind by parameter name need regenerating, and `url_for` calls must pass the camelCase keyword (`url_for(request, name, orderId=...)`, not `order_id=...`). - **BREAKING** A route is declared as a `Route` and registered with `StapiFastapiBaseRouter.register_route`, which hands it to FastAPI's own `add_api_route`. `summary`, `tag` and `errors` are required, so a route cannot be registered without saying what it is called, where it is filed, or which errors it can produce. `errors` is deliberately not defaulted: a shared set merged into every route cannot be narrowed, and so published a 404 for the landing page, an endpoint that takes no input and calls no backend. - OpenAPI tags come from the route family rather than the owning router: creating an order for a product is filed under Orders, and the opportunity routes under Opportunities, rather than all of them under Products. +- The `self` link of a paginated response carries the request's query parameters, so it points at the page that was returned rather than at the first page. - **BREAKING** `ProductRouter.pagination_link` is renamed `search_pagination_link`, distinguishing the POST-bodied opportunity search `next` link from the shared query-parameter one, which now lives on the base router. - **BREAKING** The `GET_OPPORTUNITY_SEARCH_RECORD_STATUSES` route name constant is renamed `LIST_OPPORTUNITY_SEARCH_RECORD_STATUSES`, matching its sibling list routes, and the registered route name changes with it. ### Fixed +- A collection's `self` and `next` links carry the media type their target serves. `next` was hard-coded to `application/json`, so every geo+json collection published a next link contradicting its own response. +- A query parameter named `self` no longer fails the request. The raw query params were splatted into `URL.include_query_params` as Python keywords, colliding with that method's own `self`; repeated parameters were also collapsed to the last value. - Operations declare only the error responses they can actually produce. A shared set was previously merged into every route and could not be narrowed, so `GET /` and `GET /conformance` published a 404 despite taking no input and calling no backend. - `500` is declared. It is returned deliberately when a backend reports failure, so a client has to be prepared for it. - Every operation publishes a stable `operationId`, derived from the route's prefixed name so it stays unique across a deployment mounting several products. diff --git a/stapi-fastapi/src/stapi_fastapi/routers/base.py b/stapi-fastapi/src/stapi_fastapi/routers/base.py index 173ba14..29e44e4 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/base.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/base.py @@ -131,14 +131,31 @@ def route_name(self, name: str) -> str: """The registered name of the route this router serves under `name`.""" return ":".join((*self.route_name_prefix, name)) - def self_link(self, request: Request, name: str, **path_params: Any) -> Link: - """A `self` link for the current request.""" - return Link(href=self.url_for(request, name, **path_params), rel="self", type=TYPE_JSON) - - def pagination_link(self, request: Request, name: str, pagination_token: str, limit: int, **kwargs: Any) -> Link: + def self_link(self, request: Request, name: str, media_type: str = TYPE_JSON, **path_params: Any) -> Link: + """A `self` link for the current request, query params and all, so a + paged response points at the page actually returned. + """ + url = self.url_for(request, name, **path_params) + # The raw query string is copied rather than splatted into + # `include_query_params`: those are user-controlled *names*, and passing + # them as Python keywords both collides with that method's own `self` + # (a 500 on `?self=...`) and collapses repeated params. + if request.url.query: + url = url.replace(query=request.url.query) + return Link(href=url, rel="self", type=media_type) + + def pagination_link( + self, + request: Request, + name: str, + pagination_token: str, + limit: int, + media_type: str = TYPE_JSON, + **kwargs: Any, + ) -> Link: """A `next` link for the page after the one being returned.""" url = self.url_for(request, name, **kwargs).include_query_params(next=pagination_token, limit=limit) - return Link(href=url, rel="next", type=TYPE_JSON) + return Link(href=url, rel="next", type=media_type) def page_links( self, @@ -146,6 +163,7 @@ def page_links( page: Page[Any], name: str, limit: int, + media_type: str = TYPE_JSON, **path_params: Any, ) -> list[Link]: """The links published on a collection response for `page`. @@ -153,10 +171,10 @@ def page_links( Backend-supplied links come first: they describe the collection rather than this page of it. """ - links = [*page.links, self.self_link(request, name, **path_params)] + links = [*page.links, self.self_link(request, name, media_type=media_type, **path_params)] next_token = page.next_token.value_or(None) if next_token is not None: - links.append(self.pagination_link(request, name, next_token, limit, **path_params)) + links.append(self.pagination_link(request, name, next_token, limit, media_type=media_type, **path_params)) return links def register_route(self, route: Route) -> None: diff --git a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py index 7b9eeae..c770a63 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py @@ -33,7 +33,7 @@ ) from stapi_fastapi.conformance import PRODUCT as PRODUCT_CONFORMACES -from stapi_fastapi.constants import TYPE_JSON +from stapi_fastapi.constants import TYPE_GEOJSON, TYPE_JSON from stapi_fastapi.errors import NotFoundError, QueryablesError from stapi_fastapi.models.product import Product from stapi_fastapi.path_params import OpportunityCollectionIdPath @@ -425,7 +425,7 @@ def search_pagination_link(self, request: Request, opp_req: OpportunityRequest, return Link( href=request.url, rel="next", - type=TYPE_JSON, + type=TYPE_GEOJSON, method="POST", body=body, ) diff --git a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py index 07935d1..f374a64 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py @@ -267,7 +267,7 @@ async def get_orders( order.links.extend(self.order_links(order, request)) return OrderCollection( features=page.items, - links=self.page_links(request, page, self.route_name(LIST_ORDERS), limit), + links=self.page_links(request, page, self.route_name(LIST_ORDERS), limit, media_type=TYPE_GEOJSON), number_matched=page.number_matched.value_or(None), ) case Failure(ValueError()): diff --git a/stapi-fastapi/tests/test_order.py b/stapi-fastapi/tests/test_order.py index a15b353..772b9d3 100644 --- a/stapi-fastapi/tests/test_order.py +++ b/stapi-fastapi/tests/test_order.py @@ -28,7 +28,7 @@ def test_empty_order(stapi_client: TestClient): { "href": "http://stapiserver/orders", "rel": "self", - "type": "application/json", + "type": "application/geo+json", }, ], "numberMatched": 314, diff --git a/stapi-fastapi/tests/test_pagination.py b/stapi-fastapi/tests/test_pagination.py index 6f076f1..3894f3e 100644 --- a/stapi-fastapi/tests/test_pagination.py +++ b/stapi-fastapi/tests/test_pagination.py @@ -1,6 +1,7 @@ """Tests for the shared pagination query parameters, `self` links and totals.""" from typing import Any, cast +from urllib.parse import parse_qs, urlsplit import pytest from fastapi import FastAPI, status @@ -8,6 +9,7 @@ from stapi_fastapi.query_params import MAX_LIMIT, clamp_limit from .shared import ( + find_link, product_test_spotlight_async_opportunity, ) @@ -81,3 +83,63 @@ def test_clamp_limit_caps_at_the_maximum() -> None: assert clamp_limit(MAX_LIMIT + 1) == MAX_LIMIT assert clamp_limit(MAX_LIMIT) == MAX_LIMIT assert clamp_limit(1) == 1 + + +def query_of(href: str) -> dict[str, list[str]]: + return parse_qs(urlsplit(href).query) + + +def test_self_link_carries_query_params(stapi_client: TestClient) -> None: + res = stapi_client.get("/products", params={"limit": 1}) + assert res.status_code == status.HTTP_200_OK + + self_link = find_link(res.json()["links"], "self") + assert self_link is not None + assert query_of(self_link["href"]) == {"limit": ["1"]} + + +def test_self_link_of_second_page_points_at_second_page(stapi_client: TestClient) -> None: + first = stapi_client.get("/products", params={"limit": 1}).json() + next_link = find_link(first["links"], "next") + assert next_link is not None + + second = stapi_client.get(next_link["href"]).json() + self_link = find_link(second["links"], "self") + assert self_link is not None + assert query_of(self_link["href"]) == query_of(next_link["href"]) + + +def test_self_link_survives_a_query_param_named_self(stapi_client: TestClient) -> None: + """Query param names are data, not Python keywords: `?self=` used to 500.""" + res = stapi_client.get("/products", params={"self": "x"}) + assert res.status_code == status.HTTP_200_OK, res.text + + self_link = find_link(res.json()["links"], "self") + assert self_link is not None + assert query_of(self_link["href"]) == {"self": ["x"]} + + +def test_self_link_preserves_repeated_query_params(stapi_client: TestClient) -> None: + """A repeated query param keeps every value; it used to collapse to the last.""" + res = stapi_client.get("/products", params=[("limit", "2"), ("a", "1"), ("a", "2")]) + assert res.status_code == status.HTTP_200_OK, res.text + + self_link = find_link(res.json()["links"], "self") + assert self_link is not None + assert query_of(self_link["href"]) == {"limit": ["2"], "a": ["1", "2"]} + + +def test_orders_collection_has_self_link(stapi_client: TestClient) -> None: + body = stapi_client.get("/orders").json() + self_link = find_link(body["links"], "self") + assert self_link is not None + assert self_link["href"] == "http://stapiserver/orders" + assert self_link["type"] == "application/geo+json" + + +@pytest.mark.mock_products([product_test_spotlight_async_opportunity]) +def test_search_records_collection_has_self_link(stapi_client_async_opportunity: TestClient) -> None: + body = stapi_client_async_opportunity.get("/searches/opportunities").json() + self_link = find_link(body["links"], "self") + assert self_link is not None + assert self_link["href"] == "http://stapiserver/searches/opportunities" From e8a788c1c38e68932bf74bd0362039381429798d Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 6 Aug 2026 18:57:57 -0700 Subject: [PATCH 31/48] fix!: report an unusable pagination token with its own error Every paginated handler matched `Failure(ValueError())` and answered 404. That is right for a token identifying no page, and wrong for everything else: a backend raises `ValueError` incidentally all the time -- an `int()` on unparseable input, a `list.index` miss on something other than the token -- and each one was reported to the client as a page that does not exist rather than as the server error it actually was. `PaginationTokenError` says which is which. It subclasses `ValueError` so existing backends keep working, but it is not an `HTTPException`: a backend reports it inside a `Failure` rather than raising it. Handlers match that type and answer 404; anything else falls through to a 500, as it should. --- stapi-fastapi/CHANGELOG.md | 2 ++ stapi-fastapi/src/stapi_fastapi/errors.py | 12 +++++++ .../stapi_fastapi/routers/product_router.py | 4 ++- .../src/stapi_fastapi/routers/root_router.py | 8 ++--- stapi-fastapi/tests/backends.py | 24 ++++++++++--- stapi-fastapi/tests/test_pagination.py | 35 ++++++++++++++++++- 6 files changed, 75 insertions(+), 10 deletions(-) diff --git a/stapi-fastapi/CHANGELOG.md b/stapi-fastapi/CHANGELOG.md index e8f20b2..7d6f6ab 100644 --- a/stapi-fastapi/CHANGELOG.md +++ b/stapi-fastapi/CHANGELOG.md @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a - `Route`, a declarative route descriptor, with `Route.to_api_route()` returning the keyword arguments for FastAPI's own `add_api_route`, and `StapiFastapiBaseRouter.register_route()` handing them over. `Route` requires a `summary`, a `tag` (the new `Tag` enum) and an `errors` set, so an operation cannot be published without a title, a heading, or an accurate statement of how it can fail. - `Page`, exported from `stapi_fastapi`, is the one shape every list backend returns. It carries `items`, a `next_token`, an optional `number_matched`, and any collection-level `links` only the backend can know (e.g. `create-order` on a stored Opportunity Collection). +- `PaginationTokenError`, which a backend returns inside a `Failure` when a pagination token identifies no page. The handler answers it with a 404. - `numberMatched` is populated on every collection response the backend can count, including `GET /products`. - `stapi_fastapi.query_params` provides the shared `Limit` and `NextToken` annotations and `DEFAULT_LIMIT`, so every paginated endpoint validates identically and publishes its bounds. - `stapi_fastapi.path_params` provides the camelCase path parameter annotations. @@ -30,6 +31,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a ### Fixed +- An unusable pagination token is distinguished from an incidental failure. The handlers matched a bare `Failure(ValueError())` and answered 404, so any `ValueError` a backend raised in passing -- an `int()` on unparseable input, an unrelated `list.index` miss -- was reported to the client as a page that does not exist rather than as the server error it was. Backends now return `PaginationTokenError` for a bad token; everything else stays a 500. - A collection's `self` and `next` links carry the media type their target serves. `next` was hard-coded to `application/json`, so every geo+json collection published a next link contradicting its own response. - A query parameter named `self` no longer fails the request. The raw query params were splatted into `URL.include_query_params` as Python keywords, colliding with that method's own `self`; repeated parameters were also collapsed to the last value. - Operations declare only the error responses they can actually produce. A shared set was previously merged into every route and could not be narrowed, so `GET /` and `GET /conformance` published a 404 despite taking no input and calling no backend. diff --git a/stapi-fastapi/src/stapi_fastapi/errors.py b/stapi-fastapi/src/stapi_fastapi/errors.py index 1ae870c..df9fb1b 100644 --- a/stapi-fastapi/src/stapi_fastapi/errors.py +++ b/stapi-fastapi/src/stapi_fastapi/errors.py @@ -15,3 +15,15 @@ def __init__(self, detail: Any) -> None: class NotFoundError(StapiError): def __init__(self, detail: Any | None = None) -> None: super().__init__(status.HTTP_404_NOT_FOUND, detail) + + +class PaginationTokenError(ValueError): + """Returned by a backend, inside a `Failure`, when a pagination token + identifies no page. The handler answers it with a 404. + + Not an `HTTPException`: a backend reports it rather than raises it. Its own + type, rather than a bare `ValueError`, because a backend raises those + incidentally -- an `int()` on unparseable input, a `list.index` miss on + something other than the token -- and every one of them would otherwise be + reported to the client as a page that does not exist. + """ diff --git a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py index c770a63..f27f7a9 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py @@ -34,7 +34,7 @@ from stapi_fastapi.conformance import PRODUCT as PRODUCT_CONFORMACES from stapi_fastapi.constants import TYPE_GEOJSON, TYPE_JSON -from stapi_fastapi.errors import NotFoundError, QueryablesError +from stapi_fastapi.errors import NotFoundError, PaginationTokenError, QueryablesError from stapi_fastapi.models.product import Product from stapi_fastapi.path_params import OpportunityCollectionIdPath from stapi_fastapi.query_params import DEFAULT_LIMIT, clamp_limit @@ -304,6 +304,8 @@ async def search_opportunities_sync( links.append(self.search_pagination_link(request, search, next_token)) case Failure(e) if isinstance(e, QueryablesError): raise e + case Failure(PaginationTokenError()): + raise NotFoundError(detail="Error finding pagination token") case Failure(e): logger.error( "An error occurred while searching opportunities: %s", diff --git a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py index f374a64..7d9a7da 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py @@ -30,7 +30,7 @@ ) from stapi_fastapi.conformance import API as API_CONFORMANCE from stapi_fastapi.constants import TYPE_GEOJSON -from stapi_fastapi.errors import NotFoundError +from stapi_fastapi.errors import NotFoundError, PaginationTokenError from stapi_fastapi.models.product import Product from stapi_fastapi.pagination import Page from stapi_fastapi.path_params import OrderIdPath, SearchRecordIdPath @@ -270,7 +270,7 @@ async def get_orders( links=self.page_links(request, page, self.route_name(LIST_ORDERS), limit, media_type=TYPE_GEOJSON), number_matched=page.number_matched.value_or(None), ) - case Failure(ValueError()): + case Failure(PaginationTokenError()): raise NotFoundError(detail="Error finding pagination token") case Failure(e): logger.error( @@ -323,7 +323,7 @@ async def get_order_statuses( ) case Success(Maybe.empty): raise NotFoundError("Order not found") - case Failure(ValueError()): + case Failure(PaginationTokenError()): raise NotFoundError("Error finding pagination token") case Failure(e): logger.error( @@ -375,7 +375,7 @@ async def get_opportunity_search_records( links=self.page_links(request, page, self.route_name(LIST_OPPORTUNITY_SEARCH_RECORDS), limit), number_matched=page.number_matched.value_or(None), ) - case Failure(ValueError()): + case Failure(PaginationTokenError()): raise NotFoundError(detail="Error finding pagination token") case Failure(e): logger.error( diff --git a/stapi-fastapi/tests/backends.py b/stapi-fastapi/tests/backends.py index 75bdb4d..b3329b0 100644 --- a/stapi-fastapi/tests/backends.py +++ b/stapi-fastapi/tests/backends.py @@ -5,6 +5,7 @@ from returns.maybe import Maybe, Nothing, Some from returns.result import Failure, ResultE, Success from stapi_fastapi import Page +from stapi_fastapi.errors import PaginationTokenError from stapi_fastapi.routers.product_router import ProductRouter from stapi_pydantic import ( Opportunity, @@ -22,6 +23,18 @@ ) +def _offset(token: str) -> int: + """Read a page offset out of a pagination token. + + The mocks encode the offset in the token itself, so anything unparseable is + a token that identifies no page rather than an incidental error. + """ + try: + return int(token) + except ValueError: + raise PaginationTokenError(f"unusable pagination token: {token!r}") from None + + async def mock_get_orders( next: str | None, limit: int, @@ -39,7 +52,10 @@ async def mock_get_orders( order_ids = [*request.state._orders_db._orders.keys()] if next: - start = order_ids.index(next) + try: + start = order_ids.index(next) + except ValueError: + raise PaginationTokenError(f"unknown pagination token: {next!r}") from None end = start + limit ids = order_ids[start:end] orders = [request.state._orders_db.get_order(order_id) for order_id in ids] @@ -71,7 +87,7 @@ async def mock_get_order_statuses( return Success(Nothing) if next: - start = int(next) + start = _offset(next) end = start + limit return Success( @@ -131,7 +147,7 @@ async def mock_search_opportunities( start = 0 limit = min(limit, 100) if next: - start = int(next) + start = _offset(next) end = start + limit # Reflect the searched geometry into the returned opportunities. opportunities = [ @@ -197,7 +213,7 @@ async def mock_get_opportunity_search_records( search_records = request.state._opportunities_db.get_search_records() if next: - start = int(next) + start = _offset(next) end = start + limit return Success( diff --git a/stapi-fastapi/tests/test_pagination.py b/stapi-fastapi/tests/test_pagination.py index 3894f3e..fba4cfe 100644 --- a/stapi-fastapi/tests/test_pagination.py +++ b/stapi-fastapi/tests/test_pagination.py @@ -4,10 +4,15 @@ from urllib.parse import parse_qs, urlsplit import pytest -from fastapi import FastAPI, status +from fastapi import FastAPI, Request, status from fastapi.testclient import TestClient +from returns.result import Failure, ResultE +from stapi_fastapi.pagination import Page from stapi_fastapi.query_params import MAX_LIMIT, clamp_limit +from stapi_fastapi.routers.root_router import RootRouter +from stapi_pydantic import Order, OrderStatus +from .backends import mock_get_order from .shared import ( find_link, product_test_spotlight_async_opportunity, @@ -143,3 +148,31 @@ def test_search_records_collection_has_self_link(stapi_client_async_opportunity: self_link = find_link(body["links"], "self") assert self_link is not None assert self_link["href"] == "http://stapiserver/searches/opportunities" + + +@pytest.mark.parametrize("path", PAGINATED_PATHS) +def test_unusable_pagination_token_is_a_404(path: str, stapi_client: TestClient) -> None: + """A token that identifies no page is a missing resource, not a bad request.""" + res = stapi_client.get(path, params={"next": "not-a-token"}) + assert res.status_code == status.HTTP_404_NOT_FOUND + + +async def _orders_raising(next: str | None, limit: int, request: Request) -> ResultE[Page[Order[OrderStatus]]]: + return Failure(ValueError("a backend failed for some other reason")) + + +def test_an_incidental_value_error_is_a_500_not_a_404() -> None: + """Only `PaginationTokenError` means "no such page". + + A backend raising a plain `ValueError` -- an `int()` on bad input, a + `list.index` miss on anything but the token -- has failed, and saying "not + found" would hide that from the operator and lie to the client. + """ + root_router = RootRouter(get_orders=_orders_raising, get_order=mock_get_order) + app = FastAPI() + app.include_router(root_router) + + with TestClient(app, raise_server_exceptions=False) as client: + res = client.get("/orders") + + assert res.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR, res.text From 579999b82c03d0258bdedae3316abc4fd89b6460 Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 6 Aug 2026 19:00:01 -0700 Subject: [PATCH 32/48] feat!: paginate the search record statuses endpoint `GET /searches/opportunities/{searchRecordId}/statuses` returned a bare JSON array. It published neither `next` nor `limit`, because its backend returned a plain list with nothing to page, and a bare array cannot carry links or a total the way every other collection response does. It also meant the operation had no named response model, so FastAPI inlined the schema and auto-titled it after the operation. The backend returns `Maybe[Page[OpportunitySearchStatus]]` and takes `next` and `limit`, and the endpoint answers with an `OpportunitySearchStatusCollection`. The test harness now keeps each search record's status history rather than reporting only its current status, since a single status cannot exercise paging. --- stapi-fastapi/CHANGELOG.md | 2 + .../stapi_fastapi/backends/root_backend.py | 15 ++-- .../src/stapi_fastapi/routers/root_router.py | 30 +++++-- stapi-fastapi/tests/backends.py | 21 ++++- stapi-fastapi/tests/shared.py | 9 ++- stapi-fastapi/tests/test_openapi.py | 16 ++++ stapi-fastapi/tests/test_opportunity_async.py | 4 +- stapi-fastapi/tests/test_pagination.py | 78 ++++++++++++++++++- 8 files changed, 155 insertions(+), 20 deletions(-) diff --git a/stapi-fastapi/CHANGELOG.md b/stapi-fastapi/CHANGELOG.md index 7d6f6ab..b35193e 100644 --- a/stapi-fastapi/CHANGELOG.md +++ b/stapi-fastapi/CHANGELOG.md @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a - `Route`, a declarative route descriptor, with `Route.to_api_route()` returning the keyword arguments for FastAPI's own `add_api_route`, and `StapiFastapiBaseRouter.register_route()` handing them over. `Route` requires a `summary`, a `tag` (the new `Tag` enum) and an `errors` set, so an operation cannot be published without a title, a heading, or an accurate statement of how it can fail. - `Page`, exported from `stapi_fastapi`, is the one shape every list backend returns. It carries `items`, a `next_token`, an optional `number_matched`, and any collection-level `links` only the backend can know (e.g. `create-order` on a stored Opportunity Collection). +- Pagination on `GET /searches/opportunities/{searchRecordId}/statuses`, which previously published neither `next` nor `limit` because its backend had nothing to paginate. - `PaginationTokenError`, which a backend returns inside a `Failure` when a pagination token identifies no page. The handler answers it with a 404. - `numberMatched` is populated on every collection response the backend can count, including `GET /products`. - `stapi_fastapi.query_params` provides the shared `Limit` and `NextToken` annotations and `DEFAULT_LIMIT`, so every paginated endpoint validates identically and publishes its bounds. @@ -20,6 +21,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a ### Changed +- **BREAKING** `GetOpportunitySearchRecordStatuses` gained `next` and `limit` parameters and returns `Maybe[Page[OpportunitySearchStatus]]` in place of `Maybe[list[OpportunitySearchStatus]]`. The endpoint answers with an `OpportunitySearchStatusCollection` rather than a bare JSON array, so it can carry links and a total like every other collection. - **BREAKING** Every list backend must now return a `Page`. `GetOrders` returned `tuple[list[Order], Maybe[str], Maybe[int]]`, `SearchOpportunities` and `GetOpportunitySearchRecords` returned `tuple[list[...], Maybe[str]]`, and `GetOrderStatuses` returned `Maybe[tuple[list[...], Maybe[str]]]`. All now return `Page` (wrapped in `Maybe` where they were before): put the items in `Page.items`, the pagination token in `Page.next_token`, and the total in `Page.number_matched`. - **BREAKING** A `limit` below 1 is rejected with a 422 instead of being silently accepted, on every paginated endpoint and in the opportunity search body. Previously the 100-item cap was applied only to `GET /products`, `limit=0` dead-ended paging, and a negative limit silently truncated the result set with no `next` link. An over-large `limit` is clamped rather than rejected: the spec makes it what the client asks for, not what the server owes, and publishes no maximum -- so neither does the document. - **BREAKING** Path parameters are camelCase in the routes and in the exported OpenAPI document: `{orderId}`, `{searchRecordId}`, and `{opportunityCollectionId}`, joining the existing `{productId}`. Request URLs are unchanged, since path parameter names never appear in them, but generated clients that bind by parameter name need regenerating, and `url_for` calls must pass the camelCase keyword (`url_for(request, name, orderId=...)`, not `order_id=...`). diff --git a/stapi-fastapi/src/stapi_fastapi/backends/root_backend.py b/stapi-fastapi/src/stapi_fastapi/backends/root_backend.py index 9f65fa2..7a618f1 100644 --- a/stapi-fastapi/src/stapi_fastapi/backends/root_backend.py +++ b/stapi-fastapi/src/stapi_fastapi/backends/root_backend.py @@ -106,21 +106,26 @@ """ GetOpportunitySearchRecordStatuses = Callable[ - [str, Request], Coroutine[Any, Any, ResultE[Maybe[list[OpportunitySearchStatus]]]] + [str, str | None, int, Request], + Coroutine[Any, Any, ResultE[Maybe[Page[OpportunitySearchStatus]]]], ] """ -Type alias for an async function that gets the statuses of a OpportunitySearchRecord with -`search_record_id`. +Type alias for an async function that gets a page of statuses of the +OpportunitySearchRecord with `search_record_id`. Args: search_record_id (str): The ID of the OpportunitySearchRecord. + next (str | None): A pagination token. + limit (int): The maximum number of statuses to return in a page. request (Request): FastAPI's Request object. Returns: - Should return - returns.result.Success[returns.maybe.Some[list[OpportunitySearchStatus]]] if - the search record is found. + returns.result.Success[returns.maybe.Some[stapi_fastapi.pagination.Page[OpportunitySearchStatus]]] + if the search record is found. - Should return returns.result.Success[returns.maybe.Nothing] if the search record is not found or if access is denied. + - Returning returns.result.Failure[stapi_fastapi.errors.PaginationTokenError] + will result in a 404, which is how an unusable pagination token is reported. - Returning returns.result.Failure[Exception] will result in a 500. """ diff --git a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py index 7d9a7da..62d31e4 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py @@ -11,7 +11,7 @@ Link, OpportunitySearchRecord, OpportunitySearchRecordCollection, - OpportunitySearchStatus, + OpportunitySearchStatusCollection, Order, OrderCollection, OrderStatus, @@ -415,16 +415,32 @@ async def get_opportunity_search_record( raise AssertionError("Expected code to be unreachable") async def get_opportunity_search_record_statuses( - self, search_record_id: SearchRecordIdPath, request: Request - ) -> list[OpportunitySearchStatus]: + self, + search_record_id: SearchRecordIdPath, + request: Request, + next: NextToken = None, + limit: Limit = DEFAULT_LIMIT, + ) -> OpportunitySearchStatusCollection: """ - Get the Opportunity Search Record statuses with `search_record_id`. + Get the Opportunity Search Record statuses with `searchRecordId`. """ - match await self._get_opportunity_search_record_statuses(search_record_id, request): - case Success(Some(search_record_statuses)): - return search_record_statuses # type: ignore + match await self._get_opportunity_search_record_statuses(search_record_id, next, limit, request): + case Success(Some(page)): + return OpportunitySearchStatusCollection( + statuses=page.items, + links=self.page_links( + request, + page, + self.route_name(LIST_OPPORTUNITY_SEARCH_RECORD_STATUSES), + limit, + searchRecordId=search_record_id, + ), + number_matched=page.number_matched.value_or(None), + ) case Success(Maybe.empty): raise NotFoundError("Opportunity Search Record not found") + case Failure(PaginationTokenError()): + raise NotFoundError("Error finding pagination token") case Failure(e): logger.error( "An error occurred while retrieving opportunity search record statuses '%s': %s", diff --git a/stapi-fastapi/tests/backends.py b/stapi-fastapi/tests/backends.py index b3329b0..f38a671 100644 --- a/stapi-fastapi/tests/backends.py +++ b/stapi-fastapi/tests/backends.py @@ -237,11 +237,26 @@ async def mock_get_opportunity_search_record( async def mock_get_opportunity_search_record_statuses( - search_record_id: str, request: Request -) -> ResultE[Maybe[list[OpportunitySearchStatus]]]: + search_record_id: str, + next: str | None, + limit: int, + request: Request, +) -> ResultE[Maybe[Page[OpportunitySearchStatus]]]: try: + statuses = request.state._opportunities_db.get_search_record_statuses(search_record_id) + if statuses is None: + return Success(Nothing) + + start = _offset(next) if next else 0 + end = start + limit return Success( - Maybe.from_optional(request.state._opportunities_db.get_search_record_statuses(search_record_id)) + Some( + Page( + items=statuses[start:end], + next_token=Some(str(end)) if end < len(statuses) else Nothing, + number_matched=Some(len(statuses)), + ) + ) ) except Exception as e: return Failure(e) diff --git a/stapi-fastapi/tests/shared.py b/stapi-fastapi/tests/shared.py index 6595f67..aab77b7 100644 --- a/stapi-fastapi/tests/shared.py +++ b/stapi-fastapi/tests/shared.py @@ -65,22 +65,25 @@ def put_order_status(self, order_id: str, status: OrderStatus) -> None: class InMemoryOpportunityDB: def __init__(self) -> None: self._search_records: dict[str, OpportunitySearchRecord] = {} + self._statuses: defaultdict[str, list[OpportunitySearchStatus]] = defaultdict(list) self._collections: dict[str, OpportunityCollection] = {} def get_search_record(self, search_id: str) -> OpportunitySearchRecord | None: return deepcopy(self._search_records.get(search_id)) def get_search_record_statuses(self, search_id: str) -> list[OpportunitySearchStatus] | None: - if search_record := self.get_search_record(search_id): - return [deepcopy(search_record.status)] - else: + # None only when the record is unknown: a known record always has at + # least the status it was created with. + if search_id not in self._search_records: return None + return deepcopy(self._statuses[search_id]) def get_search_records(self) -> list[OpportunitySearchRecord]: return deepcopy(list(self._search_records.values())) def put_search_record(self, search_record: OpportunitySearchRecord) -> None: self._search_records[search_record.id] = deepcopy(search_record) + self._statuses[search_record.id].append(deepcopy(search_record.status)) def get_opportunity_collection(self, collection_id) -> OpportunityCollection | None: return deepcopy(self._collections.get(collection_id)) diff --git a/stapi-fastapi/tests/test_openapi.py b/stapi-fastapi/tests/test_openapi.py index a5b7288..3f80acd 100644 --- a/stapi-fastapi/tests/test_openapi.py +++ b/stapi-fastapi/tests/test_openapi.py @@ -45,6 +45,22 @@ def test_routes_declare_only_the_errors_they_produce(spec: dict[str, Any]) -> No assert {"404", "500"} <= set(spec["paths"]["/orders"]["get"]["responses"]) +def test_no_response_schema_carries_a_mangled_auto_title(spec: dict[str, Any]) -> None: + """FastAPI auto-titles an *inline* response schema after the operation that + returns it, yielding names like `Response Root Test Spotlight Get + Queryables ...`. Every response therefore has to `$ref` a named model. + """ + titles = [ + media["schema"]["title"] + for _, _, operation in operations(spec) + for response in operation.get("responses", {}).values() + for media in response.get("content", {}).values() + if isinstance(media.get("schema"), dict) and "title" in media["schema"] + ] + + assert titles == [] + + def test_no_component_schema_is_unreferenced(spec: dict[str, Any]) -> None: """An orphan component is a schema a reader cannot reach and a client cannot use.""" schemas = spec["components"]["schemas"] diff --git a/stapi-fastapi/tests/test_opportunity_async.py b/stapi-fastapi/tests/test_opportunity_async.py index 5082551..af7e479 100644 --- a/stapi-fastapi/tests/test_opportunity_async.py +++ b/stapi-fastapi/tests/test_opportunity_async.py @@ -233,7 +233,9 @@ def test_async_opportunity_search_to_completion( url = f"/searches/opportunities/{search_record.id}/statuses" retrieved_statuses_response = stapi_client_async_opportunity.get(url) assert retrieved_statuses_response.status_code == 200 - retrieved_statuses = [OpportunitySearchStatus(**d) for d in retrieved_statuses_response.json()] + retrieved_statuses_body = retrieved_statuses_response.json() + assert retrieved_statuses_body["stapi_type"] == "OpportunitySearchStatusCollection" + retrieved_statuses = [OpportunitySearchStatus(**d) for d in retrieved_statuses_body["statuses"]] assert len(retrieved_statuses) >= 1 assert retrieved_statuses[-1].status_code == OpportunitySearchStatusCode.completed diff --git a/stapi-fastapi/tests/test_pagination.py b/stapi-fastapi/tests/test_pagination.py index fba4cfe..2b861fa 100644 --- a/stapi-fastapi/tests/test_pagination.py +++ b/stapi-fastapi/tests/test_pagination.py @@ -1,5 +1,7 @@ """Tests for the shared pagination query parameters, `self` links and totals.""" +from collections.abc import Callable +from datetime import UTC, datetime from typing import Any, cast from urllib.parse import parse_qs, urlsplit @@ -10,7 +12,13 @@ from stapi_fastapi.pagination import Page from stapi_fastapi.query_params import MAX_LIMIT, clamp_limit from stapi_fastapi.routers.root_router import RootRouter -from stapi_pydantic import Order, OrderStatus +from stapi_pydantic import ( + OpportunitySearchStatus, + OpportunitySearchStatusCode, + Order, + OrderStatus, + OrderStatusCode, +) from .backends import mock_get_order from .shared import ( @@ -176,3 +184,71 @@ def test_an_incidental_value_error_is_a_500_not_a_404() -> None: res = client.get("/orders") assert res.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR, res.text + + +def follow_pages( + stapi_client: TestClient, + url: str, + target: str, + limit: int, + key: Callable[[dict[str, Any]], Any] = lambda item: item["id"], +) -> list[Any]: + """Walk a collection's `next` links, returning a key of every item seen.""" + seen: list[Any] = [] + res = stapi_client.get(url, params={"limit": limit}) + while True: + assert res.status_code == status.HTTP_200_OK, res.text + body = res.json() + assert len(body[target]) <= limit + seen.extend(key(item) for item in body[target]) + next_link = find_link(body["links"], "next") + if next_link is None: + return seen + res = stapi_client.get(next_link["href"]) + + +def add_order_statuses(stapi_client: TestClient, order_id: str, *codes: OrderStatusCode) -> None: + """Append status revisions to a stored order.""" + db = stapi_client.app_state["_orders_db"] + for code in codes: + db.put_order_status(order_id, OrderStatus(timestamp=datetime.now(UTC), status_code=code)) + + +def add_search_record_statuses( + stapi_client: TestClient, search_record_id: str, *codes: OpportunitySearchStatusCode +) -> None: + """Append status revisions to a stored search record.""" + db = stapi_client.app_state["_opportunities_db"] + for code in codes: + record = db.get_search_record(search_record_id) + record.status = OpportunitySearchStatus(timestamp=datetime.now(UTC), status_code=code) + db.put_search_record(record) + + +@pytest.mark.parametrize("limit", LIMITS) +@pytest.mark.mock_products([product_test_spotlight_async_opportunity]) +def test_search_record_statuses_are_paginated( + limit: int, + stapi_client_async_opportunity: TestClient, + opportunity_search: dict[str, Any], +) -> None: + """The statuses endpoint pages like every other collection endpoint.""" + client = stapi_client_async_opportunity + created = client.post(f"/products/{PRODUCT_ID}/opportunities", json=opportunity_search) + assert created.status_code == status.HTTP_201_CREATED + record_id = created.json()["id"] + add_search_record_statuses( + client, + record_id, + OpportunitySearchStatusCode.in_progress, + OpportunitySearchStatusCode.completed, + ) + + codes = follow_pages( + client, + f"/searches/opportunities/{record_id}/statuses", + "statuses", + limit, + key=lambda status_: status_["status_code"], + ) + assert codes == ["received", "in_progress", "completed"] From 1a4c3bd0af979655f4dd9a18f4378cbdce9491bf Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 6 Aug 2026 19:02:20 -0700 Subject: [PATCH 33/48] feat!: paginate the stored opportunity collection `GET /products/{productId}/opportunities/{opportunityCollectionId}` returned whatever collection the backend had stored, whole. It published no `next` and no `limit`, so a search that produced thousands of opportunities had no way to serve them a page at a time. The backend returns `Maybe[Page[Opportunity]]` and takes `next` and `limit`. Assembling the collection moves out of the backend and into the handler, which knows the id from the path and can build the `self` and `next` links itself; a backend now returns only the opportunities plus any collection-level links it alone knows, such as `create-order`. --- stapi-fastapi/CHANGELOG.md | 4 +- .../stapi_fastapi/backends/product_backend.py | 18 ++- .../stapi_fastapi/routers/product_router.py | 34 ++++-- stapi-fastapi/tests/backends.py | 31 ++++- stapi-fastapi/tests/test_pagination.py | 114 +++++++++++++++++- 5 files changed, 176 insertions(+), 25 deletions(-) diff --git a/stapi-fastapi/CHANGELOG.md b/stapi-fastapi/CHANGELOG.md index b35193e..ed04f4a 100644 --- a/stapi-fastapi/CHANGELOG.md +++ b/stapi-fastapi/CHANGELOG.md @@ -10,7 +10,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a - `Route`, a declarative route descriptor, with `Route.to_api_route()` returning the keyword arguments for FastAPI's own `add_api_route`, and `StapiFastapiBaseRouter.register_route()` handing them over. `Route` requires a `summary`, a `tag` (the new `Tag` enum) and an `errors` set, so an operation cannot be published without a title, a heading, or an accurate statement of how it can fail. - `Page`, exported from `stapi_fastapi`, is the one shape every list backend returns. It carries `items`, a `next_token`, an optional `number_matched`, and any collection-level `links` only the backend can know (e.g. `create-order` on a stored Opportunity Collection). -- Pagination on `GET /searches/opportunities/{searchRecordId}/statuses`, which previously published neither `next` nor `limit` because its backend had nothing to paginate. +- Pagination on `GET /searches/opportunities/{searchRecordId}/statuses` and `GET /products/{productId}/opportunities/{opportunityCollectionId}`, which previously published neither `next` nor `limit` because their backends had nothing to paginate. - `PaginationTokenError`, which a backend returns inside a `Failure` when a pagination token identifies no page. The handler answers it with a 404. - `numberMatched` is populated on every collection response the backend can count, including `GET /products`. - `stapi_fastapi.query_params` provides the shared `Limit` and `NextToken` annotations and `DEFAULT_LIMIT`, so every paginated endpoint validates identically and publishes its bounds. @@ -21,7 +21,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a ### Changed -- **BREAKING** `GetOpportunitySearchRecordStatuses` gained `next` and `limit` parameters and returns `Maybe[Page[OpportunitySearchStatus]]` in place of `Maybe[list[OpportunitySearchStatus]]`. The endpoint answers with an `OpportunitySearchStatusCollection` rather than a bare JSON array, so it can carry links and a total like every other collection. +- **BREAKING** `GetOpportunitySearchRecordStatuses` and `GetOpportunityCollection` gained `next` and `limit` parameters. `GetOpportunitySearchRecordStatuses` returned `Maybe[list[OpportunitySearchStatus]]` and now returns `Maybe[Page[OpportunitySearchStatus]]`; the endpoint answers with an `OpportunitySearchStatusCollection` rather than a bare JSON array, so it can carry links and a total like every other collection. `GetOpportunityCollection` returned `Maybe[OpportunityCollection]` and now returns `Maybe[Page[Opportunity]]`; the handler assembles the collection, setting its `id` from the path and its `self`/`next` links, so a backend returns only the opportunities plus any collection-level links. - **BREAKING** Every list backend must now return a `Page`. `GetOrders` returned `tuple[list[Order], Maybe[str], Maybe[int]]`, `SearchOpportunities` and `GetOpportunitySearchRecords` returned `tuple[list[...], Maybe[str]]`, and `GetOrderStatuses` returned `Maybe[tuple[list[...], Maybe[str]]]`. All now return `Page` (wrapped in `Maybe` where they were before): put the items in `Page.items`, the pagination token in `Page.next_token`, and the total in `Page.number_matched`. - **BREAKING** A `limit` below 1 is rejected with a 422 instead of being silently accepted, on every paginated endpoint and in the opportunity search body. Previously the 100-item cap was applied only to `GET /products`, `limit=0` dead-ended paging, and a negative limit silently truncated the result set with no `next` link. An over-large `limit` is clamped rather than rejected: the spec makes it what the client asks for, not what the server owes, and publishes no maximum -- so neither does the document. - **BREAKING** Path parameters are camelCase in the routes and in the exported OpenAPI document: `{orderId}`, `{searchRecordId}`, and `{opportunityCollectionId}`, joining the existing `{productId}`. Request URLs are unchanged, since path parameter names never appear in them, but generated clients that bind by parameter name need regenerating, and `url_for` calls must pass the camelCase keyword (`url_for(request, name, orderId=...)`, not `order_id=...`). diff --git a/stapi-fastapi/src/stapi_fastapi/backends/product_backend.py b/stapi-fastapi/src/stapi_fastapi/backends/product_backend.py index e120de2..2d0cd65 100644 --- a/stapi-fastapi/src/stapi_fastapi/backends/product_backend.py +++ b/stapi-fastapi/src/stapi_fastapi/backends/product_backend.py @@ -8,7 +8,6 @@ from returns.result import ResultE from stapi_pydantic import ( Opportunity, - OpportunityCollection, OpportunityRequest, OpportunitySearchRecord, Order, @@ -69,25 +68,32 @@ """ GetOpportunityCollection = Callable[ - [ProductRouter, str, Request], - Coroutine[Any, Any, ResultE[Maybe[OpportunityCollection]]], # type: ignore + [ProductRouter, str, str | None, int, Request], + Coroutine[Any, Any, ResultE[Maybe[Page[Opportunity]]]], # type: ignore ] """ -Type alias for an async function that retrieves the opportunity collection with -`opportunity_collection_id`. +Type alias for an async function that retrieves a page of the opportunity +collection with `opportunity_collection_id`. The opportunity collection is generated by an asynchronous opportunity search. +The handler assembles the OpportunityCollection around the page, so the page +carries only the opportunities themselves plus any collection-level links the +backend wants to publish (e.g. `create-order`, `search-record`). Args: product_router (ProductRouter): The product router. opportunity_collection_id (str): The ID of the opportunity collection. + next (str | None): A pagination token. + limit (int): The maximum number of opportunities to return in a page. request (Request): FastAPI's Request object. Returns: - - Should return returns.result.Success[returns.maybe.Some[OpportunityCollection]] + - Should return returns.result.Success[returns.maybe.Some[stapi_fastapi.pagination.Page[Opportunity]]] if the opportunity collection is found. - Should return returns.result.Success[returns.maybe.Nothing] if the opportunity collection is not found or if access is denied. + - Returning returns.result.Failure[stapi_fastapi.errors.PaginationTokenError] + will result in a 404, which is how an unusable pagination token is reported. - Returning returns.result.Failure[Exception] will result in a 500. """ diff --git a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py index f27f7a9..ad4909a 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py @@ -37,7 +37,7 @@ from stapi_fastapi.errors import NotFoundError, PaginationTokenError, QueryablesError from stapi_fastapi.models.product import Product from stapi_fastapi.path_params import OpportunityCollectionIdPath -from stapi_fastapi.query_params import DEFAULT_LIMIT, clamp_limit +from stapi_fastapi.query_params import DEFAULT_LIMIT, Limit, NextToken, clamp_limit from stapi_fastapi.responses import GeoJSONResponse from stapi_fastapi.routers.base import BAD_REQUEST, NOT_FOUND, SERVER_ERROR, Route, StapiFastapiBaseRouter from stapi_fastapi.routers.route_names import ( @@ -433,7 +433,11 @@ def search_pagination_link(self, request: Request, opp_req: OpportunityRequest, ) async def get_opportunity_collection( - self, opportunity_collection_id: OpportunityCollectionIdPath, request: Request + self, + opportunity_collection_id: OpportunityCollectionIdPath, + request: Request, + next: NextToken = None, + limit: Limit = DEFAULT_LIMIT, ) -> OpportunityCollection: # type: ignore """ Fetch an opportunity collection generated by an asynchronous opportunity search. @@ -441,22 +445,28 @@ async def get_opportunity_collection( match await self.product.get_opportunity_collection( self, opportunity_collection_id, + next, + limit, request, ): - case Success(Some(opportunity_collection)): - opportunity_collection.links.append( - json_link( - "self", - self.url_for( - request, - self.route_name(GET_OPPORTUNITY_COLLECTION), - opportunityCollectionId=opportunity_collection_id, - ), + case Success(Some(page)): + return OpportunityCollection( + id=opportunity_collection_id, + features=page.items, + links=self.page_links( + request, + page, + self.route_name(GET_OPPORTUNITY_COLLECTION), + limit, + media_type=TYPE_GEOJSON, + opportunityCollectionId=opportunity_collection_id, ), + number_matched=page.number_matched.value_or(None), ) - return opportunity_collection # type: ignore case Success(Maybe.empty): raise NotFoundError("Opportunity Collection not found") + case Failure(PaginationTokenError()): + raise NotFoundError("Error finding pagination token") case Failure(e): logger.error( "An error occurred while fetching opportunity collection: '%s': %s", diff --git a/stapi-fastapi/tests/backends.py b/stapi-fastapi/tests/backends.py index f38a671..ec0f6fd 100644 --- a/stapi-fastapi/tests/backends.py +++ b/stapi-fastapi/tests/backends.py @@ -9,7 +9,6 @@ from stapi_fastapi.routers.product_router import ProductRouter from stapi_pydantic import ( Opportunity, - OpportunityCollection, OpportunityRequest, OpportunitySearchRecord, OpportunitySearchStatus, @@ -192,11 +191,35 @@ async def mock_search_opportunities_async( async def mock_get_opportunity_collection( - product_router: ProductRouter, opportunity_collection_id: str, request: Request -) -> ResultE[Maybe[OpportunityCollection]]: + product_router: ProductRouter, + opportunity_collection_id: str, + next: str | None, + limit: int, + request: Request, +) -> ResultE[Maybe[Page[Opportunity]]]: try: + collection = request.state._opportunities_db.get_opportunity_collection(opportunity_collection_id) + if collection is None: + return Success(Nothing) + + start = 0 + limit = min(limit, 100) + if next: + start = _offset(next) + end = start + limit + total = len(collection.features) + return Success( - Maybe.from_optional(request.state._opportunities_db.get_opportunity_collection(opportunity_collection_id)) + Some( + Page( + items=collection.features[start:end], + next_token=Some(str(end)) if end < total else Nothing, + number_matched=Some(total), + # The stored collection's own links (e.g. `create-order`) + # describe the collection, not this page of it. + links=collection.links, + ) + ) ) except Exception as e: return Failure(e) diff --git a/stapi-fastapi/tests/test_pagination.py b/stapi-fastapi/tests/test_pagination.py index 2b861fa..ba5e811 100644 --- a/stapi-fastapi/tests/test_pagination.py +++ b/stapi-fastapi/tests/test_pagination.py @@ -13,6 +13,7 @@ from stapi_fastapi.query_params import MAX_LIMIT, clamp_limit from stapi_fastapi.routers.root_router import RootRouter from stapi_pydantic import ( + OpportunityCollection, OpportunitySearchStatus, OpportunitySearchStatusCode, Order, @@ -20,10 +21,14 @@ OrderStatusCode, ) -from .backends import mock_get_order +from .backends import ( + mock_get_order, +) from .shared import ( + create_mock_opportunity, find_link, product_test_spotlight_async_opportunity, + product_test_spotlight_sync_opportunity, ) PAGINATED_PATHS = [ @@ -252,3 +257,110 @@ def test_search_record_statuses_are_paginated( key=lambda status_: status_["status_code"], ) assert codes == ["received", "in_progress", "completed"] + + +@pytest.mark.parametrize("limit", LIMITS) +@pytest.mark.mock_products([product_test_spotlight_async_opportunity]) +def test_opportunity_collection_is_paginated( + limit: int, + stapi_client_async_opportunity: TestClient, +) -> None: + """The async search's Opportunity Collection pages too.""" + client = stapi_client_async_opportunity + collection = OpportunityCollection( + id="an-opportunity-collection", + features=[create_mock_opportunity() for _ in range(3)], + ) + client.app_state["_opportunities_db"].put_opportunity_collection(collection) + + ids = follow_pages( + client, + f"/products/{PRODUCT_ID}/opportunities/{collection.id}", + "features", + limit, + ) + assert ids == [opportunity.id for opportunity in collection.features] + + +def test_products_publishes_number_matched(stapi_client: TestClient) -> None: + # The default fixture registers two products, and the router knows it. + body = stapi_client.get("/products", params={"limit": 1}).json() + assert len(body["products"]) == 1 + assert body["numberMatched"] == 2 + + +def test_orders_publishes_number_matched(stapi_client: TestClient) -> None: + # Whatever the backend reports as the total is what is published. + assert stapi_client.get("/orders").json()["numberMatched"] == 314 + + +def test_order_statuses_publishes_number_matched(stapi_client: TestClient) -> None: + """`numberMatched` is the total across pages, not the length of this page.""" + created = stapi_client.post(f"/products/{PRODUCT_ID}/orders", json=ORDER_PAYLOAD) + assert created.status_code == status.HTTP_201_CREATED + order_id = created.json()["id"] + add_order_statuses(stapi_client, order_id, OrderStatusCode.accepted, OrderStatusCode.completed) + + body = stapi_client.get(f"/orders/{order_id}/statuses", params={"limit": 1}).json() + assert len(body["statuses"]) == 1 + assert body["numberMatched"] == 3 + + +@pytest.mark.mock_products([product_test_spotlight_sync_opportunity]) +def test_opportunity_search_publishes_number_matched( + stapi_client: TestClient, + opportunity_search: dict[str, Any], +) -> None: + stapi_client.app_state["_opportunities"] = [create_mock_opportunity() for _ in range(3)] + opportunity_search["limit"] = 1 + + body = stapi_client.post(f"/products/{PRODUCT_ID}/opportunities", json=opportunity_search).json() + assert len(body["features"]) == 1 + assert body["numberMatched"] == 3 + + +@pytest.mark.mock_products([product_test_spotlight_async_opportunity]) +def test_search_records_publish_number_matched( + stapi_client_async_opportunity: TestClient, + opportunity_search: dict[str, Any], +) -> None: + client = stapi_client_async_opportunity + for _ in range(3): + assert ( + client.post(f"/products/{PRODUCT_ID}/opportunities", json=opportunity_search).status_code + == status.HTTP_201_CREATED + ) + + body = client.get("/searches/opportunities", params={"limit": 1}).json() + assert len(body["records"]) == 1 + assert body["numberMatched"] == 3 + + +@pytest.mark.mock_products([product_test_spotlight_async_opportunity]) +def test_search_record_statuses_publish_number_matched( + stapi_client_async_opportunity: TestClient, + opportunity_search: dict[str, Any], +) -> None: + client = stapi_client_async_opportunity + record_id = client.post(f"/products/{PRODUCT_ID}/opportunities", json=opportunity_search).json()["id"] + add_search_record_statuses(client, record_id, OpportunitySearchStatusCode.completed) + + body = client.get(f"/searches/opportunities/{record_id}/statuses", params={"limit": 1}).json() + assert len(body["statuses"]) == 1 + assert body["numberMatched"] == 2 + + +@pytest.mark.mock_products([product_test_spotlight_async_opportunity]) +def test_opportunity_collection_publishes_number_matched( + stapi_client_async_opportunity: TestClient, +) -> None: + client = stapi_client_async_opportunity + collection = OpportunityCollection( + id="an-opportunity-collection", + features=[create_mock_opportunity() for _ in range(3)], + ) + client.app_state["_opportunities_db"].put_opportunity_collection(collection) + + body = client.get(f"/products/{PRODUCT_ID}/opportunities/{collection.id}", params={"limit": 1}).json() + assert len(body["features"]) == 1 + assert body["numberMatched"] == 3 From c3a0e5eccb7b54dc0c8e23719d2a3336f81b9215 Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 6 Aug 2026 19:04:05 -0700 Subject: [PATCH 34/48] feat!: reject a search whose filter omits a required queryable A product publishes a queryables schema, and that schema can mark properties required. Nothing enforced it: a search or order whose filter named none of them was accepted, and the product's own contract went unchecked. `Product.validate_required_queryables` reads the required set from the published schema -- so a client is held to exactly what it can see -- and compares it against the property names the CQL2 filter actually references. The opportunity search, the async search and order creation all call it. `QueryablesError` becomes a 400 rather than a 422. A filter missing a predicate the product requires is a malformed request, not a well-formed one the server cannot process. --- stapi-fastapi/CHANGELOG.md | 2 ++ stapi-fastapi/src/stapi_fastapi/errors.py | 2 +- .../src/stapi_fastapi/models/product.py | 19 ++++++++++++- .../stapi_fastapi/routers/product_router.py | 3 ++ stapi-fastapi/tests/shared.py | 3 +- stapi-fastapi/tests/test_opportunity.py | 18 ++++++++++++ stapi-fastapi/tests/test_opportunity_async.py | 18 ++++++++++++ stapi-fastapi/tests/test_order.py | 28 ++++++++++++++++++- 8 files changed, 89 insertions(+), 4 deletions(-) diff --git a/stapi-fastapi/CHANGELOG.md b/stapi-fastapi/CHANGELOG.md index ed04f4a..f4a84d0 100644 --- a/stapi-fastapi/CHANGELOG.md +++ b/stapi-fastapi/CHANGELOG.md @@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a - `Route`, a declarative route descriptor, with `Route.to_api_route()` returning the keyword arguments for FastAPI's own `add_api_route`, and `StapiFastapiBaseRouter.register_route()` handing them over. `Route` requires a `summary`, a `tag` (the new `Tag` enum) and an `errors` set, so an operation cannot be published without a title, a heading, or an accurate statement of how it can fail. - `Page`, exported from `stapi_fastapi`, is the one shape every list backend returns. It carries `items`, a `next_token`, an optional `number_matched`, and any collection-level `links` only the backend can know (e.g. `create-order` on a stored Opportunity Collection). - Pagination on `GET /searches/opportunities/{searchRecordId}/statuses` and `GET /products/{productId}/opportunities/{opportunityCollectionId}`, which previously published neither `next` nor `limit` because their backends had nothing to paginate. +- `Product.validate_required_queryables()`, which rejects a search or order whose filter omits a predicate for a queryable the product requires. - `PaginationTokenError`, which a backend returns inside a `Failure` when a pagination token identifies no page. The handler answers it with a 404. - `numberMatched` is populated on every collection response the backend can count, including `GET /products`. - `stapi_fastapi.query_params` provides the shared `Limit` and `NextToken` annotations and `DEFAULT_LIMIT`, so every paginated endpoint validates identically and publishes its bounds. @@ -21,6 +22,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a ### Changed +- **BREAKING** A request that omits a predicate for a required queryable is answered with 400 rather than 422, since it is a malformed request rather than an unprocessable one. `QueryablesError` carries the new status. - **BREAKING** `GetOpportunitySearchRecordStatuses` and `GetOpportunityCollection` gained `next` and `limit` parameters. `GetOpportunitySearchRecordStatuses` returned `Maybe[list[OpportunitySearchStatus]]` and now returns `Maybe[Page[OpportunitySearchStatus]]`; the endpoint answers with an `OpportunitySearchStatusCollection` rather than a bare JSON array, so it can carry links and a total like every other collection. `GetOpportunityCollection` returned `Maybe[OpportunityCollection]` and now returns `Maybe[Page[Opportunity]]`; the handler assembles the collection, setting its `id` from the path and its `self`/`next` links, so a backend returns only the opportunities plus any collection-level links. - **BREAKING** Every list backend must now return a `Page`. `GetOrders` returned `tuple[list[Order], Maybe[str], Maybe[int]]`, `SearchOpportunities` and `GetOpportunitySearchRecords` returned `tuple[list[...], Maybe[str]]`, and `GetOrderStatuses` returned `Maybe[tuple[list[...], Maybe[str]]]`. All now return `Page` (wrapped in `Maybe` where they were before): put the items in `Page.items`, the pagination token in `Page.next_token`, and the total in `Page.number_matched`. - **BREAKING** A `limit` below 1 is rejected with a 422 instead of being silently accepted, on every paginated endpoint and in the opportunity search body. Previously the 100-item cap was applied only to `GET /products`, `limit=0` dead-ended paging, and a negative limit silently truncated the result set with no `next` link. An over-large `limit` is clamped rather than rejected: the spec makes it what the client asks for, not what the server owes, and publishes no maximum -- so neither does the document. diff --git a/stapi-fastapi/src/stapi_fastapi/errors.py b/stapi-fastapi/src/stapi_fastapi/errors.py index df9fb1b..e7d6656 100644 --- a/stapi-fastapi/src/stapi_fastapi/errors.py +++ b/stapi-fastapi/src/stapi_fastapi/errors.py @@ -9,7 +9,7 @@ class StapiError(HTTPException): class QueryablesError(StapiError): def __init__(self, detail: Any) -> None: - super().__init__(status.HTTP_422_UNPROCESSABLE_ENTITY, detail) + super().__init__(status.HTTP_400_BAD_REQUEST, detail) class NotFoundError(StapiError): diff --git a/stapi-fastapi/src/stapi_fastapi/models/product.py b/stapi-fastapi/src/stapi_fastapi/models/product.py index fd17bcd..c725bbc 100644 --- a/stapi-fastapi/src/stapi_fastapi/models/product.py +++ b/stapi-fastapi/src/stapi_fastapi/models/product.py @@ -2,9 +2,17 @@ from typing import TYPE_CHECKING, Any -from stapi_pydantic import OpportunityProperties, OrderParameters, Queryables +from stapi_pydantic import ( + OpportunityProperties, + OrderParameters, + Queryables, + SearchParameters, + cql2_property_names, +) from stapi_pydantic import Product as BaseProduct +from stapi_fastapi.errors import QueryablesError + if TYPE_CHECKING: from stapi_fastapi.backends.product_backend import ( CreateOrder, @@ -94,3 +102,12 @@ def opportunity_properties(self) -> type[OpportunityProperties]: @property def order_parameters(self) -> type[OrderParameters]: return self._order_parameters + + def validate_required_queryables(self, search_parameters: SearchParameters) -> None: + """Raise if the filter omits a predicate for a required queryable.""" + required = self._queryables.required_property_names() + if not required: + return + missing = required - cql2_property_names(search_parameters.filter) + if missing: + raise QueryablesError(f"filter must include predicates for required queryables: {sorted(missing)}") diff --git a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py index ad4909a..76fe8c8 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py @@ -288,6 +288,7 @@ async def search_opportunities_sync( # model's, so only the clamp is applied here. limit = DEFAULT_LIMIT if search.limit is None else clamp_limit(search.limit) + self.product.validate_required_queryables(search.search_parameters) links: list[Link] = [] match await self.product.search_opportunities( self, @@ -333,6 +334,7 @@ async def search_opportunities_async( request: Request, prefer: Prefer | None, ) -> JSONResponse: + self.product.validate_required_queryables(search.search_parameters) match await self.product.search_opportunities_async(self, search, request): case Success(search_record): search_record.links.append(self.root_router.opportunity_search_record_self_link(search_record, request)) @@ -383,6 +385,7 @@ async def create_order(self, payload: OrderRequest, request: Request, response: """ Create a new order. """ + self.product.validate_required_queryables(payload.search_parameters) match await self.product.create_order( self, payload, diff --git a/stapi-fastapi/tests/shared.py b/stapi-fastapi/tests/shared.py index aab77b7..711c2d6 100644 --- a/stapi-fastapi/tests/shared.py +++ b/stapi-fastapi/tests/shared.py @@ -25,6 +25,7 @@ OrderStatus, Provider, ProviderRole, + Queryables, ) from .backends import ( @@ -94,7 +95,7 @@ def put_opportunity_collection(self, collection: OpportunityCollection) -> None: self._collections[collection.id] = deepcopy(collection) -class MyProductQueryables(BaseModel): +class MyProductQueryables(Queryables): off_nadir: int diff --git a/stapi-fastapi/tests/test_opportunity.py b/stapi-fastapi/tests/test_opportunity.py index ac7d643..a1dd037 100644 --- a/stapi-fastapi/tests/test_opportunity.py +++ b/stapi-fastapi/tests/test_opportunity.py @@ -86,3 +86,21 @@ def test_search_opportunities_clamps_an_over_large_limit( json={**opportunity_search, "limit": MAX_LIMIT + 1}, ) assert response.status_code == 200, response.text + + +def test_search_opportunities_rejects_missing_required_queryable_predicate( + stapi_client: TestClient, +) -> None: + # test-spotlight's queryables model (MyProductQueryables) requires `off_nadir`; + # omitting a filter predicate for it should be rejected before hitting the backend. + product_id = "test-spotlight" + response = stapi_client.post( + f"/products/{product_id}/opportunities", + json={ + "search_parameters": { + "datetime": "2024-04-18T10:56:00Z/2024-04-25T10:56:00Z", + "geometry": {"type": "Point", "coordinates": [13.4, 52.5]}, + }, + }, + ) + assert response.status_code == 400 diff --git a/stapi-fastapi/tests/test_opportunity_async.py b/stapi-fastapi/tests/test_opportunity_async.py index af7e479..bf8007d 100644 --- a/stapi-fastapi/tests/test_opportunity_async.py +++ b/stapi-fastapi/tests/test_opportunity_async.py @@ -316,3 +316,21 @@ def test_get_search_records_pagination( target="records", expected_returns=expected_returns, ) + + +def test_async_search_rejects_missing_required_queryable_predicate( + stapi_client_async_opportunity: TestClient, +) -> None: + # test-spotlight's queryables model (MyProductQueryables) requires `off_nadir`; + # omitting a filter predicate for it should be rejected before hitting the backend. + product_id = "test-spotlight" + response = stapi_client_async_opportunity.post( + f"/products/{product_id}/opportunities", + json={ + "search_parameters": { + "datetime": "2024-04-18T10:56:00Z/2024-04-25T10:56:00Z", + "geometry": {"type": "Point", "coordinates": [13.4, 52.5]}, + }, + }, + ) + assert response.status_code == 400 diff --git a/stapi-fastapi/tests/test_order.py b/stapi-fastapi/tests/test_order.py index 772b9d3..c1705d7 100644 --- a/stapi-fastapi/tests/test_order.py +++ b/stapi-fastapi/tests/test_order.py @@ -35,6 +35,15 @@ def test_empty_order(stapi_client: TestClient): } +REQUIRED_QUERYABLE_FILTER = { + "op": "and", + "args": [ + {"op": ">", "args": [{"property": "off_nadir"}, 0]}, + {"op": "<", "args": [{"property": "off_nadir"}, 45]}, + ], +} + + @pytest.fixture def create_order_payloads() -> list[OrderRequest]: datetimes = [ @@ -51,7 +60,7 @@ def create_order_payloads() -> list[OrderRequest]: datetime.fromisoformat(start), datetime.fromisoformat(end), ), - filter=None, + filter=REQUIRED_QUERYABLE_FILTER, ), order_parameters=MyOrderParameters(s3_path="s3://my-bucket"), ) @@ -245,3 +254,20 @@ def test_get_order_statuses_bad_token( order_id = "non_existing_order_id" res = stapi_client.get(f"/orders/{order_id}/statuses") assert res.status_code == status.HTTP_404_NOT_FOUND + + +def test_create_order_rejects_missing_required_queryable_predicate(stapi_client: TestClient) -> None: + # test-spotlight's queryables model (MyProductQueryables) requires `off_nadir`; + # omitting a filter predicate for it should be rejected before hitting the backend. + product_id = "test-spotlight" + response = stapi_client.post( + f"/products/{product_id}/orders", + json={ + "search_parameters": { + "datetime": "2024-04-18T10:56:00Z/2024-04-25T10:56:00Z", + "geometry": {"type": "Point", "coordinates": [13.4, 52.5]}, + }, + "order_parameters": {"s3_path": "s3://my-bucket"}, + }, + ) + assert response.status_code == 400 From 74ae4a4b85f3100ada4dfa487935a6aba4da8890 Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 6 Aug 2026 19:08:20 -0700 Subject: [PATCH 35/48] fix: advertise only the conformance classes actually served Conformance was a declaration rather than a fact. A product listed whatever classes it wanted and `build_conformances` added to them; the root router published whatever `conformances=` it was handed. Either could name a class whose routes were never registered, sending a client that trusted the landing page straight to a 404. Both now derive the optional classes from what is actually served. The two opportunity classes are subtracted from a product's declaration and re-added only if that product supports the search and the router it is mounted on serves it -- so an async-only product on a sync-only root router advertises neither. The root router does the same for its three optional API classes, adding each alongside the route it gates. Supporting async search also no longer implies the sync class, which it did by adding both. Lists are sorted, so a deployment's conformance output no longer varies between processes with set iteration order. The core URI comes from stapi-pydantic, so the models and the server cannot disagree about it. --- pyproject.toml | 1 + stapi-fastapi/CHANGELOG.md | 3 + .../src/stapi_fastapi/conformance.py | 3 +- .../stapi_fastapi/routers/product_router.py | 11 ++- .../src/stapi_fastapi/routers/root_router.py | 13 ++- stapi-fastapi/tests/conftest.py | 34 ++++--- stapi-fastapi/tests/shared.py | 30 +++++- stapi-fastapi/tests/test_capabilities.py | 94 +++++++++++++++++++ stapi-fastapi/tests/test_opportunity_async.py | 32 +++++++ 9 files changed, 199 insertions(+), 22 deletions(-) create mode 100644 stapi-fastapi/tests/test_capabilities.py diff --git a/pyproject.toml b/pyproject.toml index ae04ce5..e743c01 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,4 +84,5 @@ filterwarnings = [ ] markers = [ "mock_products", + "root_router_kwargs", ] diff --git a/stapi-fastapi/CHANGELOG.md b/stapi-fastapi/CHANGELOG.md index f4a84d0..eee67eb 100644 --- a/stapi-fastapi/CHANGELOG.md +++ b/stapi-fastapi/CHANGELOG.md @@ -29,6 +29,9 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a - **BREAKING** Path parameters are camelCase in the routes and in the exported OpenAPI document: `{orderId}`, `{searchRecordId}`, and `{opportunityCollectionId}`, joining the existing `{productId}`. Request URLs are unchanged, since path parameter names never appear in them, but generated clients that bind by parameter name need regenerating, and `url_for` calls must pass the camelCase keyword (`url_for(request, name, orderId=...)`, not `order_id=...`). - **BREAKING** A route is declared as a `Route` and registered with `StapiFastapiBaseRouter.register_route`, which hands it to FastAPI's own `add_api_route`. `summary`, `tag` and `errors` are required, so a route cannot be registered without saying what it is called, where it is filed, or which errors it can produce. `errors` is deliberately not defaulted: a shared set merged into every route cannot be narrowed, and so published a 404 for the landing page, an endpoint that takes no input and calls no backend. - OpenAPI tags come from the route family rather than the owning router: creating an order for a product is filed under Orders, and the opportunity routes under Opportunities, rather than all of them under Products. +- A product advertises the opportunity conformance classes it is actually served under, rather than whatever it declared. An async-only product mounted on a root router without async support previously advertised classes whose routes were never registered. +- The root router advertises only the optional conformance classes whose backends were supplied, mirroring what `build_conformances` already did per product. `RootRouter(conformances=...)` now defaults to `None` rather than a fixed list. +- Conformance lists are sorted, so they no longer vary between processes. - The `self` link of a paginated response carries the request's query parameters, so it points at the page that was returned rather than at the first page. - **BREAKING** `ProductRouter.pagination_link` is renamed `search_pagination_link`, distinguishing the POST-bodied opportunity search `next` link from the shared query-parameter one, which now lives on the base router. - **BREAKING** The `GET_OPPORTUNITY_SEARCH_RECORD_STATUSES` route name constant is renamed `LIST_OPPORTUNITY_SEARCH_RECORD_STATUSES`, matching its sibling list routes, and the registered route name changes with it. diff --git a/stapi-fastapi/src/stapi_fastapi/conformance.py b/stapi-fastapi/src/stapi_fastapi/conformance.py index 097edec..5b24c0a 100644 --- a/stapi-fastapi/src/stapi_fastapi/conformance.py +++ b/stapi-fastapi/src/stapi_fastapi/conformance.py @@ -4,6 +4,7 @@ import dataclasses from dataclasses import dataclass +from stapi_pydantic.conformance import CORE_CONFORMANCE from stapi_pydantic.constants import STAPI_VERSION @@ -15,7 +16,7 @@ def all(self) -> list[str]: @dataclass(frozen=True) class _Api(_All): - core: str = f"https://stapi.example.com/v{STAPI_VERSION}/core" + core: str = CORE_CONFORMANCE order_statuses: str = f"https://stapi.example.com/v{STAPI_VERSION}/order-statuses" searches_opportunity: str = f"https://stapi.example.com/v{STAPI_VERSION}/searches-opportunity" searches_opportunity_statuses: str = f"https://stapi.example.com/v{STAPI_VERSION}/searches-opportunity-statuses" diff --git a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py index 76fe8c8..14a3e1c 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py @@ -76,16 +76,21 @@ def build_conformances(product: Product, root_router: RootRouter) -> list[str]: if not any(conformance.startswith("https://geojson.org/schema/") for conformance in product.conforms_to): raise ValueError("product conformance does not contain at least one geojson conformance") - conformances = set(product.conforms_to) + # The opportunity conformance classes are derived from what this router + # actually serves: an async-only product mounted on a root router without + # async support gets no opportunity routes, so declaring them is not enough. + conformances = set(product.conforms_to) - { + PRODUCT_CONFORMACES.opportunities, + PRODUCT_CONFORMACES.opportunities_async, + } if product.supports_opportunity_search: conformances.add(PRODUCT_CONFORMACES.opportunities) if product.supports_async_opportunity_search and root_router.supports_async_opportunity_search: - conformances.add(PRODUCT_CONFORMACES.opportunities) conformances.add(PRODUCT_CONFORMACES.opportunities_async) - return list(conformances) + return sorted(conformances) class ProductRouter(StapiFastapiBaseRouter): diff --git a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py index 62d31e4..6218cf9 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py @@ -64,7 +64,7 @@ def __init__( get_opportunity_search_records: GetOpportunitySearchRecords | None = None, get_opportunity_search_record: GetOpportunitySearchRecord | None = None, get_opportunity_search_record_statuses: GetOpportunitySearchRecordStatuses | None = None, - conformances: list[str] = [API_CONFORMANCE.core], + conformances: list[str] | None = None, name: str = "root", openapi_endpoint_name: str = "openapi", docs_endpoint_name: str = "swagger_ui_html", @@ -73,7 +73,14 @@ def __init__( ) -> None: super().__init__(*args, **kwargs) - _conformances = set(conformances) + # The optional conformance classes are derived from the backends actually + # supplied and re-added below alongside their routes: advertising a class + # whose routes were never registered would send clients to a 404. + _conformances = set(conformances or [API_CONFORMANCE.core]) - { + API_CONFORMANCE.order_statuses, + API_CONFORMANCE.searches_opportunity, + API_CONFORMANCE.searches_opportunity_statuses, + } self._get_orders = get_orders self._get_order = get_order @@ -195,7 +202,7 @@ def __init__( ) ) - self.conformances = list(_conformances) + self.conformances = sorted(_conformances) def get_root(self, request: Request) -> RootResponse: links = [ diff --git a/stapi-fastapi/tests/conftest.py b/stapi-fastapi/tests/conftest.py index 0363c08..c6cccae 100644 --- a/stapi-fastapi/tests/conftest.py +++ b/stapi-fastapi/tests/conftest.py @@ -7,7 +7,6 @@ import pytest from fastapi import FastAPI from fastapi.testclient import TestClient -from stapi_fastapi.conformance import API, PRODUCT from stapi_fastapi.models.product import ( Product, ) @@ -55,11 +54,29 @@ def mock_opportunities() -> list[Opportunity]: return [create_mock_opportunity()] +@pytest.fixture +def root_router_kwargs(request: pytest.FixtureRequest) -> dict[str, Any]: + """Per-test overrides for the RootRouter the client fixtures build. + + Mark a test with `@pytest.mark.root_router_kwargs({...})` to add or replace + router arguments; pass None for a backend to withhold it, which is how a + capability is turned off. + """ + marker = request.node.get_closest_marker("root_router_kwargs") + return dict(marker.args[0]) if marker is not None else {} + + +def _root_router(overrides: dict[str, Any], **defaults: Any) -> RootRouter: + kwargs = {**defaults, **overrides} + return RootRouter(**{k: v for k, v in kwargs.items() if v is not None}) + + @pytest.fixture def stapi_client( mock_products: list[Product], base_url: str, mock_opportunities: list[Opportunity], + root_router_kwargs: dict[str, Any], ) -> Generator[TestClient, None, None]: @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[dict[str, Any]]: @@ -71,15 +88,14 @@ async def lifespan(app: FastAPI) -> AsyncIterator[dict[str, Any]]: finally: pass - root_router = RootRouter( + root_router = _root_router( + root_router_kwargs, get_orders=mock_get_orders, get_order=mock_get_order, get_order_statuses=mock_get_order_statuses, - conformances=[API.core], ) for mock_product in mock_products: - mock_product.conforms_to = [PRODUCT.opportunities, PRODUCT.opportunities_async, PRODUCT.geojson_point] root_router.add_product(mock_product) app = FastAPI(lifespan=lifespan) @@ -94,6 +110,7 @@ def stapi_client_async_opportunity( mock_products: list[Product], base_url: str, mock_opportunities: list[Opportunity], + root_router_kwargs: dict[str, Any], ) -> Generator[TestClient, None, None]: @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[dict[str, Any]]: @@ -106,22 +123,17 @@ async def lifespan(app: FastAPI) -> AsyncIterator[dict[str, Any]]: finally: pass - root_router = RootRouter( + root_router = _root_router( + root_router_kwargs, get_orders=mock_get_orders, get_order=mock_get_order, get_order_statuses=mock_get_order_statuses, get_opportunity_search_records=mock_get_opportunity_search_records, get_opportunity_search_record=mock_get_opportunity_search_record, get_opportunity_search_record_statuses=mock_get_opportunity_search_record_statuses, - conformances=[ - API.core, - API.searches_opportunity, - API.searches_opportunity_statuses, - ], ) for mock_product in mock_products: - mock_product.conforms_to = [PRODUCT.opportunities, PRODUCT.opportunities_async, PRODUCT.geojson_point] root_router.add_product(mock_product) app = FastAPI(lifespan=lifespan) diff --git a/stapi-fastapi/tests/shared.py b/stapi-fastapi/tests/shared.py index 711c2d6..48bfa9b 100644 --- a/stapi-fastapi/tests/shared.py +++ b/stapi-fastapi/tests/shared.py @@ -161,7 +161,7 @@ class MyOrderParameters(OrderParameters): queryables=MyProductQueryables, opportunity_properties=MyOpportunityProperties, order_parameters=MyOrderParameters, - conforms_to=[PRODUCT.geojson_point, PRODUCT.opportunities], + conforms_to=[PRODUCT.geojson_point], ) @@ -180,7 +180,29 @@ class MyOrderParameters(OrderParameters): queryables=MyProductQueryables, opportunity_properties=MyOpportunityProperties, order_parameters=MyOrderParameters, - conforms_to=[PRODUCT.geojson_point, PRODUCT.opportunities_async], + conforms_to=[PRODUCT.geojson_point], +) + + +# Declares the opportunity conformance classes itself, the way a provider +# publishing an async-search product would. What is actually advertised must +# still depend on which routes the router ends up registering. +product_test_spotlight_async_opportunity_declared_conformances = Product( + id="test-spotlight", + title="Test Spotlight Product", + description="Test product for test spotlight", + license="CC-BY-4.0", + keywords=["test", "satellite"], + providers=[provider], + links=[], + create_order=mock_create_order, + search_opportunities=None, + search_opportunities_async=mock_search_opportunities_async, + get_opportunity_collection=mock_get_opportunity_collection, + queryables=MyProductQueryables, + opportunity_properties=MyOpportunityProperties, + order_parameters=MyOrderParameters, + conforms_to=[PRODUCT.geojson_point, PRODUCT.opportunities, PRODUCT.opportunities_async], ) product_test_spotlight_sync_async_opportunity = Product( @@ -198,7 +220,7 @@ class MyOrderParameters(OrderParameters): queryables=MyProductQueryables, opportunity_properties=MyOpportunityProperties, order_parameters=MyOrderParameters, - conforms_to=[PRODUCT.geojson_point, PRODUCT.opportunities, PRODUCT.opportunities_async], + conforms_to=[PRODUCT.geojson_point], ) product_test_satellite_provider_sync_opportunity = Product( @@ -216,7 +238,7 @@ class MyOrderParameters(OrderParameters): queryables=MyProductQueryables, opportunity_properties=MyOpportunityProperties, order_parameters=MyOrderParameters, - conforms_to=[PRODUCT.geojson_point, PRODUCT.opportunities], + conforms_to=[PRODUCT.geojson_point], ) diff --git a/stapi-fastapi/tests/test_capabilities.py b/stapi-fastapi/tests/test_capabilities.py new file mode 100644 index 0000000..6652607 --- /dev/null +++ b/stapi-fastapi/tests/test_capabilities.py @@ -0,0 +1,94 @@ +"""Tests that what a router advertises matches what it actually serves. + +Every capability here is optional, so the fixtures deliberately withhold a +backend (via the `root_router_kwargs` marker) or mount a product whose +capabilities the root router cannot support. +""" + +from typing import Any, cast + +import pytest +from fastapi import FastAPI, status +from fastapi.routing import APIRoute +from fastapi.testclient import TestClient +from stapi_fastapi.conformance import API, PRODUCT + +from .shared import ( + find_link, + product_test_spotlight_async_opportunity_declared_conformances, +) + +REQUIRED_QUERYABLE_FILTER: dict[str, Any] = { + "op": "and", + "args": [ + {"op": ">", "args": [{"property": "off_nadir"}, 0]}, + {"op": "<", "args": [{"property": "off_nadir"}, 45]}, + ], +} + +CREATE_ORDER_PAYLOAD: dict[str, Any] = { + "search_parameters": { + "datetime": "2024-10-09T18:55:33Z/2024-10-12T18:55:33Z", + "geometry": {"type": "Point", "coordinates": [0, 0]}, + "filter": REQUIRED_QUERYABLE_FILTER, + }, + "order_parameters": {"s3_path": "s3://my-bucket"}, +} + + +def route_paths(client: TestClient) -> set[str]: + """The paths the client's app actually registered.""" + # TestClient types `app` as the bare ASGI callable; the fixtures always build + # a FastAPI app, which is what exposes `routes`. + return {route.path for route in cast(FastAPI, client.app).routes if isinstance(route, APIRoute)} + + +ORDER_STATUSES_PATH = "/orders/{orderId}/statuses" + + +def test_order_statuses_advertised_with_backend(stapi_client: TestClient) -> None: + """Control for the tests below: the default fixture supplies the backend.""" + assert ORDER_STATUSES_PATH in route_paths(stapi_client) + assert API.order_statuses in stapi_client.get("/conformance").json()["conformsTo"] + + res = stapi_client.post("/products/test-spotlight/orders", json=CREATE_ORDER_PAYLOAD) + assert res.status_code == status.HTTP_201_CREATED + assert find_link(res.json()["links"], "monitor") is not None + + +OPPORTUNITIES_PATH = "/products/test-spotlight/opportunities" + + +@pytest.mark.mock_products([product_test_spotlight_async_opportunity_declared_conformances]) +def test_async_product_on_sync_root_does_not_advertise_opportunities(stapi_client: TestClient) -> None: + """An async-only product mounted on a root router without async support. + + No opportunity route can be registered, so neither conformance class may be + advertised, even though the product itself declares both. + """ + paths = route_paths(stapi_client) + assert OPPORTUNITIES_PATH not in paths + assert f"{OPPORTUNITIES_PATH}/{{opportunityCollectionId}}" not in paths + + conformance = stapi_client.get("/products/test-spotlight/conformance").json()["conformsTo"] + assert PRODUCT.opportunities not in conformance + assert PRODUCT.opportunities_async not in conformance + assert PRODUCT.geojson_point in conformance + + body = stapi_client.get("/products/test-spotlight").json() + assert find_link(body["links"], "opportunities") is None + + +@pytest.mark.mock_products([product_test_spotlight_async_opportunity_declared_conformances]) +def test_async_product_on_async_root_advertises_async_opportunities( + stapi_client_async_opportunity: TestClient, +) -> None: + """Control for the test above: with async support the routes do exist.""" + client = stapi_client_async_opportunity + assert OPPORTUNITIES_PATH in route_paths(client) + + conformance = client.get("/products/test-spotlight/conformance").json()["conformsTo"] + assert PRODUCT.opportunities_async in conformance + + body = client.get("/products/test-spotlight").json() + assert find_link(body["links"], "opportunities") is not None diff --git a/stapi-fastapi/tests/test_opportunity_async.py b/stapi-fastapi/tests/test_opportunity_async.py index bf8007d..bc684dd 100644 --- a/stapi-fastapi/tests/test_opportunity_async.py +++ b/stapi-fastapi/tests/test_opportunity_async.py @@ -6,6 +6,7 @@ import pytest from fastapi import status from fastapi.testclient import TestClient +from stapi_fastapi.conformance import PRODUCT from stapi_pydantic import ( Link, OpportunityCollection, @@ -334,3 +335,34 @@ def test_async_search_rejects_missing_required_queryable_predicate( }, ) assert response.status_code == 400 + + +@pytest.mark.mock_products([product_test_spotlight_sync_opportunity]) +def test_sync_only_product_conformance(stapi_client: TestClient) -> None: + product_id = "test-spotlight" + res = stapi_client.get(f"/products/{product_id}/conformance") + assert res.status_code == status.HTTP_200_OK + conforms_to = res.json()["conformsTo"] + assert PRODUCT.opportunities in conforms_to + assert PRODUCT.opportunities_async not in conforms_to + + +@pytest.mark.mock_products([product_test_spotlight_async_opportunity]) +def test_async_only_product_conformance(stapi_client_async_opportunity: TestClient) -> None: + product_id = "test-spotlight" + res = stapi_client_async_opportunity.get(f"/products/{product_id}/conformance") + assert res.status_code == status.HTTP_200_OK + conforms_to = res.json()["conformsTo"] + # async capability does not imply sync class + assert PRODUCT.opportunities_async in conforms_to + assert PRODUCT.opportunities not in conforms_to + + +@pytest.mark.mock_products([product_test_spotlight_sync_async_opportunity]) +def test_sync_async_product_conformance(stapi_client_async_opportunity: TestClient) -> None: + product_id = "test-spotlight" + res = stapi_client_async_opportunity.get(f"/products/{product_id}/conformance") + assert res.status_code == status.HTTP_200_OK + conforms_to = res.json()["conformsTo"] + assert PRODUCT.opportunities in conforms_to + assert PRODUCT.opportunities_async in conforms_to From a57c5ab97a71aca528b1567be43902968d8378f1 Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 6 Aug 2026 19:09:26 -0700 Subject: [PATCH 36/48] fix: withhold order statuses when no backend supplies them The gate read `self.get_order_statuses`, which is the router's own handler method, not the `get_order_statuses` backend argument. A bound method is always truthy, so the gate never closed. A server that supplied no order-statuses backend therefore advertised the conformance class, published `GET /orders/{orderId}/statuses`, and emitted a `monitor` link on every order -- and returned a 500 when a client followed it, because the backend it needed was never there. `supports_order_statuses` reads the backend, and both the route registration and the `monitor` link go through it. Every sibling gate was checked against the same mistake; this was the only one making it. --- stapi-fastapi/CHANGELOG.md | 2 ++ .../src/stapi_fastapi/routers/root_router.py | 22 +++++++++++----- stapi-fastapi/tests/test_capabilities.py | 26 +++++++++++++++++++ 3 files changed, 44 insertions(+), 6 deletions(-) diff --git a/stapi-fastapi/CHANGELOG.md b/stapi-fastapi/CHANGELOG.md index eee67eb..3797a83 100644 --- a/stapi-fastapi/CHANGELOG.md +++ b/stapi-fastapi/CHANGELOG.md @@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a - `Route`, a declarative route descriptor, with `Route.to_api_route()` returning the keyword arguments for FastAPI's own `add_api_route`, and `StapiFastapiBaseRouter.register_route()` handing them over. `Route` requires a `summary`, a `tag` (the new `Tag` enum) and an `errors` set, so an operation cannot be published without a title, a heading, or an accurate statement of how it can fail. - `Page`, exported from `stapi_fastapi`, is the one shape every list backend returns. It carries `items`, a `next_token`, an optional `number_matched`, and any collection-level `links` only the backend can know (e.g. `create-order` on a stored Opportunity Collection). - Pagination on `GET /searches/opportunities/{searchRecordId}/statuses` and `GET /products/{productId}/opportunities/{opportunityCollectionId}`, which previously published neither `next` nor `limit` because their backends had nothing to paginate. +- `RootRouter.supports_order_statuses`, reporting whether the order-statuses endpoint is registered. - `Product.validate_required_queryables()`, which rejects a search or order whose filter omits a predicate for a queryable the product requires. - `PaginationTokenError`, which a backend returns inside a `Failure` when a pagination token identifies no page. The handler answers it with a 404. - `numberMatched` is populated on every collection response the backend can count, including `GET /products`. @@ -38,6 +39,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a ### Fixed +- A withheld `get_order_statuses` backend is now actually withheld. The gate tested the router's own handler method instead of the backend, so it was always truthy: a server that supplied no backend still advertised the order-statuses conformance class, published `GET /orders/{orderId}/statuses`, and emitted a `monitor` link on every order, then returned a 500 when a client followed it. Every sibling gate was audited and this was the only one wrong. - An unusable pagination token is distinguished from an incidental failure. The handlers matched a bare `Failure(ValueError())` and answered 404, so any `ValueError` a backend raised in passing -- an `int()` on unparseable input, an unrelated `list.index` miss -- was reported to the client as a page that does not exist rather than as the server error it was. Backends now return `PaginationTokenError` for a bad token; everything else stays a 500. - A collection's `self` and `next` links carry the media type their target serves. `next` was hard-coded to `application/json`, so every geo+json collection published a next link contradicting its own response. - A query parameter named `self` no longer fails the request. The raw query params were splatted into `URL.include_query_params` as Python keywords, colliding with that method's own `self`; repeated parameters were also collapsed to the last value. diff --git a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py index 6218cf9..759f900 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py @@ -153,7 +153,7 @@ def __init__( ) ) - if self.get_order_statuses is not None: + if self.supports_order_statuses: _conformances.add(API_CONFORMANCE.order_statuses) self.register_route( Route( @@ -358,17 +358,22 @@ def generate_order_statuses_href(self, request: Request, order_id: str) -> URL: return self.url_for(request, self.route_name(LIST_ORDER_STATUSES), orderId=order_id) def order_links(self, order: Order[OrderStatus], request: Request) -> list[Link]: - return [ + """Links added to every order response.""" + links = [ Link( href=self.generate_order_href(request, order.id), rel="self", type=TYPE_GEOJSON, ), - json_link( - "monitor", - self.generate_order_statuses_href(request, order.id), - ), ] + if self.supports_order_statuses: + links.append( + json_link( + "monitor", + self.generate_order_statuses_href(request, order.id), + ) + ) + return links async def get_opportunity_search_records( self, request: Request, next: NextToken = None, limit: Limit = DEFAULT_LIMIT @@ -497,6 +502,11 @@ def _get_opportunity_search_record_statuses(self) -> GetOpportunitySearchRecordS raise AttributeError("Root router does not support async opportunity search status history") return self.__get_opportunity_search_record_statuses + @property + def supports_order_statuses(self) -> bool: + """Whether the order-statuses endpoint is registered.""" + return self.__get_order_statuses is not None + @property def supports_async_opportunity_search(self) -> bool: return self.__get_opportunity_search_records is not None and self.__get_opportunity_search_record is not None diff --git a/stapi-fastapi/tests/test_capabilities.py b/stapi-fastapi/tests/test_capabilities.py index 6652607..93cce54 100644 --- a/stapi-fastapi/tests/test_capabilities.py +++ b/stapi-fastapi/tests/test_capabilities.py @@ -92,3 +92,29 @@ def test_async_product_on_async_root_advertises_async_opportunities( body = client.get("/products/test-spotlight").json() assert find_link(body["links"], "opportunities") is not None + + +@pytest.mark.root_router_kwargs({"get_order_statuses": None}) +def test_order_statuses_not_advertised_without_backend(stapi_client: TestClient) -> None: + assert ORDER_STATUSES_PATH not in route_paths(stapi_client) + + assert API.order_statuses not in stapi_client.get("/conformance").json()["conformsTo"] + assert API.order_statuses not in stapi_client.get("/").json()["conformsTo"] + + +@pytest.mark.root_router_kwargs({"get_order_statuses": None}) +def test_no_monitor_link_on_orders_without_statuses_backend(stapi_client: TestClient) -> None: + create_res = stapi_client.post("/products/test-spotlight/orders", json=CREATE_ORDER_PAYLOAD) + assert create_res.status_code == status.HTTP_201_CREATED + create_body = create_res.json() + assert find_link(create_body["links"], "self") is not None + assert find_link(create_body["links"], "monitor") is None + + get_res = stapi_client.get(f"/orders/{create_body['id']}") + assert get_res.status_code == status.HTTP_200_OK + assert find_link(get_res.json()["links"], "monitor") is None + + list_res = stapi_client.get("/orders") + assert list_res.status_code == status.HTTP_200_OK + order = next(o for o in list_res.json()["features"] if o["id"] == create_body["id"]) + assert find_link(order["links"], "monitor") is None From 06decfe5d0c7af90b22304b441d259be136947c8 Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 6 Aug 2026 19:12:18 -0700 Subject: [PATCH 37/48] feat!: publish the search-records rel and gate the statuses endpoint Three related corrections to how search records are advertised. The landing page published the search records link under `opportunity-search-records`. The spec calls the relation `search-records`. The statuses route was registered whenever its backend was supplied, even on a router with no async opportunity search. The search-record endpoints it hangs off only exist when async search is supported, so it registered a route whose parent resources were absent; it now sits inside that gate, as does its conformance class. A search record carried only a `self` link, so a client had no way to reach its statuses. `opportunity_search_record_links` adds a `monitor` link when the statuses endpoint is actually registered, and every place that returned a search record -- the async search response, the single-record fetch, the list -- now builds its links through it rather than appending a `self` link apiece. --- stapi-fastapi/CHANGELOG.md | 5 +- .../stapi_fastapi/routers/product_router.py | 2 +- .../src/stapi_fastapi/routers/root_router.py | 54 ++++++++--- stapi-fastapi/tests/test_opportunity_async.py | 95 ++++++++++++++++++- 4 files changed, 136 insertions(+), 20 deletions(-) diff --git a/stapi-fastapi/CHANGELOG.md b/stapi-fastapi/CHANGELOG.md index 3797a83..049d38a 100644 --- a/stapi-fastapi/CHANGELOG.md +++ b/stapi-fastapi/CHANGELOG.md @@ -11,7 +11,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a - `Route`, a declarative route descriptor, with `Route.to_api_route()` returning the keyword arguments for FastAPI's own `add_api_route`, and `StapiFastapiBaseRouter.register_route()` handing them over. `Route` requires a `summary`, a `tag` (the new `Tag` enum) and an `errors` set, so an operation cannot be published without a title, a heading, or an accurate statement of how it can fail. - `Page`, exported from `stapi_fastapi`, is the one shape every list backend returns. It carries `items`, a `next_token`, an optional `number_matched`, and any collection-level `links` only the backend can know (e.g. `create-order` on a stored Opportunity Collection). - Pagination on `GET /searches/opportunities/{searchRecordId}/statuses` and `GET /products/{productId}/opportunities/{opportunityCollectionId}`, which previously published neither `next` nor `limit` because their backends had nothing to paginate. -- `RootRouter.supports_order_statuses`, reporting whether the order-statuses endpoint is registered. +- `RootRouter.supports_order_statuses` and `RootRouter.supports_opportunity_search_record_statuses`, reporting whether those endpoints are registered. +- `RootRouter.opportunity_search_record_links()`, which adds a `monitor` link to a search record when the statuses endpoint is registered. - `Product.validate_required_queryables()`, which rejects a search or order whose filter omits a predicate for a queryable the product requires. - `PaginationTokenError`, which a backend returns inside a `Failure` when a pagination token identifies no page. The handler answers it with a 404. - `numberMatched` is populated on every collection response the backend can count, including `GET /products`. @@ -30,6 +31,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a - **BREAKING** Path parameters are camelCase in the routes and in the exported OpenAPI document: `{orderId}`, `{searchRecordId}`, and `{opportunityCollectionId}`, joining the existing `{productId}`. Request URLs are unchanged, since path parameter names never appear in them, but generated clients that bind by parameter name need regenerating, and `url_for` calls must pass the camelCase keyword (`url_for(request, name, orderId=...)`, not `order_id=...`). - **BREAKING** A route is declared as a `Route` and registered with `StapiFastapiBaseRouter.register_route`, which hands it to FastAPI's own `add_api_route`. `summary`, `tag` and `errors` are required, so a route cannot be registered without saying what it is called, where it is filed, or which errors it can produce. `errors` is deliberately not defaulted: a shared set merged into every route cannot be narrowed, and so published a 404 for the landing page, an endpoint that takes no input and calls no backend. - OpenAPI tags come from the route family rather than the owning router: creating an order for a product is filed under Orders, and the opportunity routes under Opportunities, rather than all of them under Products. +- **BREAKING** The landing page publishes the search records link under the spec's `search-records` rel, not `opportunity-search-records`. - A product advertises the opportunity conformance classes it is actually served under, rather than whatever it declared. An async-only product mounted on a root router without async support previously advertised classes whose routes were never registered. - The root router advertises only the optional conformance classes whose backends were supplied, mirroring what `build_conformances` already did per product. `RootRouter(conformances=...)` now defaults to `None` rather than a fixed list. - Conformance lists are sorted, so they no longer vary between processes. @@ -39,6 +41,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a ### Fixed +- The opportunity search record statuses endpoint and its conformance class are gated on async opportunity search support, since the search-record endpoints they hang off only exist when async search is supported. - A withheld `get_order_statuses` backend is now actually withheld. The gate tested the router's own handler method instead of the backend, so it was always truthy: a server that supplied no backend still advertised the order-statuses conformance class, published `GET /orders/{orderId}/statuses`, and emitted a `monitor` link on every order, then returned a 500 when a client followed it. Every sibling gate was audited and this was the only one wrong. - An unusable pagination token is distinguished from an incidental failure. The handlers matched a bare `Failure(ValueError())` and answered 404, so any `ValueError` a backend raised in passing -- an `int()` on unparseable input, an unrelated `list.index` miss -- was reported to the client as a page that does not exist rather than as the server error it was. Backends now return `PaginationTokenError` for a bad token; everything else stays a 500. - A collection's `self` and `next` links carry the media type their target serves. `next` was hard-coded to `application/json`, so every geo+json collection published a next link contradicting its own response. diff --git a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py index 14a3e1c..1d0f006 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py @@ -342,7 +342,7 @@ async def search_opportunities_async( self.product.validate_required_queryables(search.search_parameters) match await self.product.search_opportunities_async(self, search, request): case Success(search_record): - search_record.links.append(self.root_router.opportunity_search_record_self_link(search_record, request)) + search_record.links.extend(self.root_router.opportunity_search_record_links(search_record, request)) headers = {} headers["Location"] = str( self.root_router.generate_opportunity_search_record_href(request, search_record.id) diff --git a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py index 759f900..49dc41d 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py @@ -189,18 +189,18 @@ def __init__( ) ) - if self.__get_opportunity_search_record_statuses is not None: - _conformances.add(API_CONFORMANCE.searches_opportunity_statuses) - self.register_route( - Route( - name=LIST_OPPORTUNITY_SEARCH_RECORD_STATUSES, - tag=Tag.OPPORTUNITIES, - path="/searches/opportunities/{searchRecordId}/statuses", - endpoint=self.get_opportunity_search_record_statuses, - errors=NOT_FOUND | SERVER_ERROR, - summary="List statuses for an Opportunity Search Record", + if self.__get_opportunity_search_record_statuses is not None: + _conformances.add(API_CONFORMANCE.searches_opportunity_statuses) + self.register_route( + Route( + name=LIST_OPPORTUNITY_SEARCH_RECORD_STATUSES, + tag=Tag.OPPORTUNITIES, + path="/searches/opportunities/{searchRecordId}/statuses", + endpoint=self.get_opportunity_search_record_statuses, + errors=NOT_FOUND | SERVER_ERROR, + summary="List statuses for an Opportunity Search Record", + ) ) - ) self.conformances = sorted(_conformances) @@ -231,7 +231,7 @@ def get_root(self, request: Request) -> RootResponse: if self.supports_async_opportunity_search: links.append( json_link( - "opportunity-search-records", + "search-records", self.url_for(request, self.route_name(LIST_OPPORTUNITY_SEARCH_RECORDS)), ), ) @@ -381,7 +381,7 @@ async def get_opportunity_search_records( match await self._get_opportunity_search_records(next, limit, request): case Success(page): for record in page.items: - record.links.append(self.opportunity_search_record_self_link(record, request)) + record.links.extend(self.opportunity_search_record_links(record, request)) return OpportunitySearchRecordCollection( records=page.items, links=self.page_links(request, page, self.route_name(LIST_OPPORTUNITY_SEARCH_RECORDS), limit), @@ -409,7 +409,7 @@ async def get_opportunity_search_record( """ match await self._get_opportunity_search_record(search_record_id, request): case Success(Some(search_record)): - search_record.links.append(self.opportunity_search_record_self_link(search_record, request)) + search_record.links.extend(self.opportunity_search_record_links(search_record, request)) return search_record # type: ignore case Success(Maybe.empty): raise NotFoundError("Opportunity Search Record not found") @@ -473,6 +473,27 @@ def generate_opportunity_search_record_href(self, request: Request, search_recor searchRecordId=search_record_id, ) + def generate_opportunity_search_record_statuses_href(self, request: Request, search_record_id: str) -> URL: + return self.url_for( + request, + self.route_name(LIST_OPPORTUNITY_SEARCH_RECORD_STATUSES), + searchRecordId=search_record_id, + ) + + def opportunity_search_record_links( + self, opportunity_search_record: OpportunitySearchRecord, request: Request + ) -> list[Link]: + """Links added to every search record response.""" + links = [self.opportunity_search_record_self_link(opportunity_search_record, request)] + if self.supports_opportunity_search_record_statuses: + links.append( + json_link( + "monitor", + self.generate_opportunity_search_record_statuses_href(request, opportunity_search_record.id), + ) + ) + return links + def opportunity_search_record_self_link( self, opportunity_search_record: OpportunitySearchRecord, request: Request ) -> Link: @@ -502,6 +523,11 @@ def _get_opportunity_search_record_statuses(self) -> GetOpportunitySearchRecordS raise AttributeError("Root router does not support async opportunity search status history") return self.__get_opportunity_search_record_statuses + @property + def supports_opportunity_search_record_statuses(self) -> bool: + """Whether the search-record-statuses endpoint is registered.""" + return self.supports_async_opportunity_search and self.__get_opportunity_search_record_statuses is not None + @property def supports_order_statuses(self) -> bool: """Whether the order-statuses endpoint is registered.""" diff --git a/stapi-fastapi/tests/test_opportunity_async.py b/stapi-fastapi/tests/test_opportunity_async.py index bc684dd..a1e77d9 100644 --- a/stapi-fastapi/tests/test_opportunity_async.py +++ b/stapi-fastapi/tests/test_opportunity_async.py @@ -6,7 +6,7 @@ import pytest from fastapi import status from fastapi.testclient import TestClient -from stapi_fastapi.conformance import PRODUCT +from stapi_fastapi.conformance import API, PRODUCT from stapi_pydantic import ( Link, OpportunityCollection, @@ -15,6 +15,7 @@ OpportunitySearchStatusCode, ) +from .backends import mock_get_opportunity_search_record_statuses from .shared import ( create_mock_opportunity, find_link, @@ -38,7 +39,7 @@ def test_no_opportunity_search_advertised(stapi_client: TestClient) -> None: # the `searches/opportunities` link should not be advertised on the root root_response = stapi_client.get("/") root_body = root_response.json() - assert find_link(root_body["links"], "opportunity-search-records") is None + assert find_link(root_body["links"], "search-records") is None @pytest.mark.mock_products([product_test_spotlight_sync_opportunity]) @@ -53,7 +54,7 @@ def test_only_sync_search_advertised(stapi_client: TestClient) -> None: # the `searches/opportunities` link should not be advertised on the root root_response = stapi_client.get("/") root_body = root_response.json() - assert find_link(root_body["links"], "opportunity-search-records") is None + assert find_link(root_body["links"], "search-records") is None # test async search offered @@ -75,7 +76,7 @@ def test_async_search_advertised(stapi_client_async_opportunity: TestClient) -> # the `searches/opportunities` link should be advertised on the root root_response = stapi_client_async_opportunity.get("/") root_body = root_response.json() - assert find_link(root_body["links"], "opportunity-search-records") + assert find_link(root_body["links"], "search-records") @pytest.mark.mock_products([product_test_spotlight_async_opportunity]) @@ -366,3 +367,89 @@ def test_sync_async_product_conformance(stapi_client_async_opportunity: TestClie conforms_to = res.json()["conformsTo"] assert PRODUCT.opportunities in conforms_to assert PRODUCT.opportunities_async in conforms_to + + +@pytest.mark.mock_products([product_test_spotlight_async_opportunity]) +def test_monitor_link_present_on_search_records( + stapi_client_async_opportunity: TestClient, + opportunity_search: dict[str, Any], + url_for: Callable[[str], str], +) -> None: + client = stapi_client_async_opportunity + product_id = "test-spotlight" + + # 201 create + create_res = client.post(f"/products/{product_id}/opportunities", json=opportunity_search) + assert create_res.status_code == 201 + create_body = create_res.json() + record_id = create_body["id"] + statuses_href = url_for(f"/searches/opportunities/{record_id}/statuses") + + monitor = find_link(create_body["links"], "monitor") + assert monitor + assert monitor["href"] == statuses_href + + # GET single record + get_res = client.get(f"/searches/opportunities/{record_id}") + assert get_res.status_code == 200 + get_monitor = find_link(get_res.json()["links"], "monitor") + assert get_monitor + assert get_monitor["href"] == statuses_href + + # GET record list + list_res = client.get("/searches/opportunities") + assert list_res.status_code == 200 + record = next(r for r in list_res.json()["records"] if r["id"] == record_id) + list_monitor = find_link(record["links"], "monitor") + assert list_monitor + assert list_monitor["href"] == statuses_href + + +@pytest.mark.mock_products([product_test_spotlight_async_opportunity]) +@pytest.mark.root_router_kwargs({"get_opportunity_search_record_statuses": None}) +def test_monitor_link_absent_without_statuses_backend( + stapi_client_async_opportunity: TestClient, + opportunity_search: dict[str, Any], +) -> None: + client = stapi_client_async_opportunity + product_id = "test-spotlight" + + create_res = client.post(f"/products/{product_id}/opportunities", json=opportunity_search) + assert create_res.status_code == 201 + record_id = create_res.json()["id"] + assert find_link(create_res.json()["links"], "monitor") is None + + get_res = client.get(f"/searches/opportunities/{record_id}") + assert find_link(get_res.json()["links"], "monitor") is None + + list_res = client.get("/searches/opportunities") + record = next(r for r in list_res.json()["records"] if r["id"] == record_id) + assert find_link(record["links"], "monitor") is None + + +@pytest.mark.mock_products([product_test_spotlight_async_opportunity]) +def test_statuses_unknown_id_returns_404( + stapi_client_async_opportunity: TestClient, +) -> None: + res = stapi_client_async_opportunity.get("/searches/opportunities/does-not-exist/statuses") + assert res.status_code == status.HTTP_404_NOT_FOUND + + +@pytest.mark.mock_products([product_test_spotlight_async_opportunity]) +@pytest.mark.root_router_kwargs( + { + # A statuses backend is supplied but the async search record backends + # are withheld, so the statuses route must not be registered and its + # conformance must be absent. + "get_opportunity_search_records": None, + "get_opportunity_search_record": None, + "get_opportunity_search_record_statuses": mock_get_opportunity_search_record_statuses, + "conformances": [API.core], + } +) +def test_statuses_endpoint_gated_on_async_support(stapi_client_async_opportunity: TestClient) -> None: + res = stapi_client_async_opportunity.get("/searches/opportunities/anything/statuses") + assert res.status_code == status.HTTP_404_NOT_FOUND + + conformance = stapi_client_async_opportunity.get("/conformance").json()["conformsTo"] + assert API.searches_opportunity_statuses not in conformance From 4c4bcd3b2a133450d4eb2138fe9f02cff230e557 Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 6 Aug 2026 19:13:05 -0700 Subject: [PATCH 38/48] fix: reject a product registered under an id already taken `add_product` overwrote `product_routers[product.id]` but `include_router` only ever appends. Registering two products with the same id left the first router's routes mounted and matching every request, while `product_routers` -- which the handlers read -- pointed at the second. The server served one product and reported the other. Nothing can be un-included, so the second registration is rejected rather than replacing the first. --- stapi-fastapi/CHANGELOG.md | 1 + .../src/stapi_fastapi/routers/root_router.py | 6 ++++++ stapi-fastapi/tests/test_product.py | 12 +++++++++++- 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/stapi-fastapi/CHANGELOG.md b/stapi-fastapi/CHANGELOG.md index 049d38a..3e76f49 100644 --- a/stapi-fastapi/CHANGELOG.md +++ b/stapi-fastapi/CHANGELOG.md @@ -42,6 +42,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a ### Fixed - The opportunity search record statuses endpoint and its conformance class are gated on async opportunity search support, since the search-record endpoints they hang off only exist when async search is supported. +- `RootRouter.add_product` rejects a product whose id is already registered. `include_router` only appends, so a second product with the same id left the first router's routes serving every request -- they match first -- while `product_routers` pointed at the new one, making the two disagree about what was mounted. - A withheld `get_order_statuses` backend is now actually withheld. The gate tested the router's own handler method instead of the backend, so it was always truthy: a server that supplied no backend still advertised the order-statuses conformance class, published `GET /orders/{orderId}/statuses`, and emitted a `monitor` link on every order, then returned a 500 when a client followed it. Every sibling gate was audited and this was the only one wrong. - An unusable pagination token is distinguished from an incidental failure. The handlers matched a bare `Failure(ValueError())` and answered 404, so any `ValueError` a backend raised in passing -- an `int()` on unparseable input, an unrelated `list.index` miss -- was reported to the client as a page that does not exist rather than as the server error it was. Backends now return `PaginationTokenError` for a bad token; everything else stays a 500. - A collection's `self` and `next` links carry the media type their target serves. `next` was hard-coded to `application/json`, so every geo+json collection published a next link contradicting its own response. diff --git a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py index 49dc41d..1000681 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py @@ -345,6 +345,12 @@ async def get_order_statuses( raise AssertionError("Expected code to be unreachable") def add_product(self, product: Product, *args: Any, **kwargs: Any) -> None: + # Rejected rather than replaced: `include_router` only appends, so the + # first router's routes would keep serving (they match first) while + # `product_routers` pointed at the new one. + if product.id in self.product_routers: + raise ValueError(f"product {product.id!r} is already registered") + # Give the include a prefix from the product router product_router = ProductRouter(product, self, *args, **kwargs) self.include_router(product_router, prefix=f"/products/{product.id}") diff --git a/stapi-fastapi/tests/test_product.py b/stapi-fastapi/tests/test_product.py index 127c787..ef16ea1 100644 --- a/stapi-fastapi/tests/test_product.py +++ b/stapi-fastapi/tests/test_product.py @@ -2,9 +2,11 @@ from fastapi import status from fastapi.testclient import TestClient from stapi_fastapi.models.product import Product +from stapi_fastapi.routers.root_router import RootRouter from stapi_pydantic import Conformance -from .shared import pagination_tester +from .backends import mock_get_order, mock_get_orders +from .shared import pagination_tester, product_test_spotlight_sync_opportunity def test_products_response(stapi_client: TestClient): @@ -151,3 +153,11 @@ def test_no_products(stapi_client: TestClient): print("hold") assert res.status_code == status.HTTP_200_OK assert len(body["products"]) == 0 + + +def test_add_product_rejects_a_duplicate_id() -> None: + root_router = RootRouter(get_orders=mock_get_orders, get_order=mock_get_order) + root_router.add_product(product_test_spotlight_sync_opportunity) + + with pytest.raises(ValueError, match="already registered"): + root_router.add_product(product_test_spotlight_sync_opportunity) From a1290a036ca6b7dd3d048ece2d8de2365fecb173 Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 6 Aug 2026 19:13:49 -0700 Subject: [PATCH 39/48] fix: send Preference-Applied whenever a Prefer header arrived The header was set only when the preference was `wait` *and* the root router supported async search. A client that asked for `respond-async` on a sync-only product, or for `wait` on a router that could not have done otherwise, was told nothing about what the server actually did -- which is the one case where the client most needs to be told. It is now sent whenever the request carried a `Prefer` header, naming the preference the server actually applied, as the spec requires. --- stapi-fastapi/CHANGELOG.md | 1 + .../stapi_fastapi/routers/product_router.py | 2 +- stapi-fastapi/tests/test_opportunity_async.py | 78 +++++++++++++++++++ 3 files changed, 80 insertions(+), 1 deletion(-) diff --git a/stapi-fastapi/CHANGELOG.md b/stapi-fastapi/CHANGELOG.md index 3e76f49..4a535f4 100644 --- a/stapi-fastapi/CHANGELOG.md +++ b/stapi-fastapi/CHANGELOG.md @@ -31,6 +31,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a - **BREAKING** Path parameters are camelCase in the routes and in the exported OpenAPI document: `{orderId}`, `{searchRecordId}`, and `{opportunityCollectionId}`, joining the existing `{productId}`. Request URLs are unchanged, since path parameter names never appear in them, but generated clients that bind by parameter name need regenerating, and `url_for` calls must pass the camelCase keyword (`url_for(request, name, orderId=...)`, not `order_id=...`). - **BREAKING** A route is declared as a `Route` and registered with `StapiFastapiBaseRouter.register_route`, which hands it to FastAPI's own `add_api_route`. `summary`, `tag` and `errors` are required, so a route cannot be registered without saying what it is called, where it is filed, or which errors it can produce. `errors` is deliberately not defaulted: a shared set merged into every route cannot be narrowed, and so published a 404 for the landing page, an endpoint that takes no input and calls no backend. - OpenAPI tags come from the route family rather than the owning router: creating an order for a product is filed under Orders, and the opportunity routes under Opportunities, rather than all of them under Products. +- `Preference-Applied` is sent whenever the request carried a `Prefer` header, as the spec requires. It was previously sent only when the preference was `wait` and the root router supported async search, so a client that asked for a preference the server did not honour was told nothing at all. - **BREAKING** The landing page publishes the search records link under the spec's `search-records` rel, not `opportunity-search-records`. - A product advertises the opportunity conformance classes it is actually served under, rather than whatever it declared. An async-only product mounted on a root router without async support previously advertised classes whose routes were never registered. - The root router advertises only the optional conformance classes whose backends were supplied, mirroring what `build_conformances` already did per product. `RootRouter(conformances=...)` now defaults to `None` rather than a fixed list. diff --git a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py index 1d0f006..fc3b1d6 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py @@ -324,7 +324,7 @@ async def search_opportunities_sync( case x: raise AssertionError(f"Expected code to be unreachable {x}") - if prefer is Prefer.wait and self.root_router.supports_async_opportunity_search: + if prefer is not None: response.headers["Preference-Applied"] = "wait" return OpportunityCollection( diff --git a/stapi-fastapi/tests/test_opportunity_async.py b/stapi-fastapi/tests/test_opportunity_async.py index a1e77d9..39f9fc8 100644 --- a/stapi-fastapi/tests/test_opportunity_async.py +++ b/stapi-fastapi/tests/test_opportunity_async.py @@ -453,3 +453,81 @@ def test_statuses_endpoint_gated_on_async_support(stapi_client_async_opportunity conformance = stapi_client_async_opportunity.get("/conformance").json()["conformsTo"] assert API.searches_opportunity_statuses not in conformance + + +@pytest.mark.parametrize("prefer", ["respond-sync", "wait, respond-async", "WAIT", ""]) +@pytest.mark.mock_products([product_test_spotlight_sync_async_opportunity]) +def test_unsupported_prefer_header_is_rejected( + prefer: str, + stapi_client_async_opportunity: TestClient, + opportunity_search: dict[str, Any], +) -> None: + """A `Prefer` value outside the enum is a 400, as the published document promises.""" + res = stapi_client_async_opportunity.post( + "/products/test-spotlight/opportunities", + json=opportunity_search, + headers={"Prefer": prefer}, + ) + assert res.status_code == status.HTTP_400_BAD_REQUEST + + +@pytest.mark.mock_products([product_test_spotlight_sync_opportunity]) +def test_unsupported_prefer_header_is_rejected_on_sync_only_product( + stapi_client: TestClient, + opportunity_search: dict[str, Any], +) -> None: + """The check guards every opportunity search route, not just the async one.""" + res = stapi_client.post( + "/products/test-spotlight/opportunities", + json=opportunity_search, + headers={"Prefer": "respond-sync"}, + ) + assert res.status_code == status.HTTP_400_BAD_REQUEST + + +@pytest.mark.mock_products([product_test_spotlight_sync_async_opportunity]) +def test_preference_applied_match_wait( + stapi_client_async_opportunity: TestClient, + opportunity_search: dict[str, Any], +) -> None: + # prefer=wait honored by a sync+async product -> wait applied + url = "/products/test-spotlight/opportunities" + res = stapi_client_async_opportunity.post(url, json=opportunity_search, headers={"Prefer": "wait"}) + assert res.status_code == 200 + assert res.headers["Preference-Applied"] == "wait" + + +@pytest.mark.mock_products([product_test_spotlight_sync_async_opportunity]) +def test_preference_applied_match_respond_async( + stapi_client_async_opportunity: TestClient, + opportunity_search: dict[str, Any], +) -> None: + # prefer=respond-async honored by a sync+async product -> respond-async applied + url = "/products/test-spotlight/opportunities" + res = stapi_client_async_opportunity.post(url, json=opportunity_search, headers={"Prefer": "respond-async"}) + assert res.status_code == 201 + assert res.headers["Preference-Applied"] == "respond-async" + + +@pytest.mark.mock_products([product_test_spotlight_sync_opportunity]) +def test_preference_applied_mismatch_respond_async_on_sync_only( + stapi_client: TestClient, + opportunity_search: dict[str, Any], +) -> None: + # respond-async requested but product only supports sync -> wait applied + url = "/products/test-spotlight/opportunities" + res = stapi_client.post(url, json=opportunity_search, headers={"Prefer": "respond-async"}) + assert res.status_code == 200 + assert res.headers["Preference-Applied"] == "wait" + + +@pytest.mark.mock_products([product_test_spotlight_async_opportunity]) +def test_preference_applied_mismatch_wait_on_async_only( + stapi_client_async_opportunity: TestClient, + opportunity_search: dict[str, Any], +) -> None: + # wait requested but product only supports async -> respond-async applied + url = "/products/test-spotlight/opportunities" + res = stapi_client_async_opportunity.post(url, json=opportunity_search, headers={"Prefer": "wait"}) + assert res.status_code == 201 + assert res.headers["Preference-Applied"] == "respond-async" From e4d1a99f37d7a74a2c9f57e4a4590d5dae65e1ba Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 6 Aug 2026 19:15:19 -0700 Subject: [PATCH 40/48] feat: declare what the opportunity search and order creation return The search route declared one shape for every product: a GeoJSON response class and a 200 `OpportunityCollection`, plus a 201 `OpportunitySearchRecord` merged in unconditionally. An async-only product cannot return a 200 collection at all, and documenting the 201 on a sync-only product `$ref`d a component that deployment never registers. Each outcome is now declared only when the product can produce it: the response class, status code and model are chosen from what it supports, and the 200 and 201 blocks are added independently. Both also declare `Preference-Applied`, with the values it can take, on whichever responses can carry it -- it was sent but documented nowhere. The `Location` headers on order creation and async search are documented for the same reason. --- stapi-fastapi/CHANGELOG.md | 2 + .../stapi_fastapi/routers/product_router.py | 81 +++++++++++++++---- stapi-fastapi/tests/test_openapi.py | 12 +++ stapi-fastapi/tests/test_opportunity_async.py | 37 ++++++++- 4 files changed, 114 insertions(+), 18 deletions(-) diff --git a/stapi-fastapi/CHANGELOG.md b/stapi-fastapi/CHANGELOG.md index 4a535f4..01e4ba2 100644 --- a/stapi-fastapi/CHANGELOG.md +++ b/stapi-fastapi/CHANGELOG.md @@ -20,6 +20,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a - `stapi_fastapi.path_params` provides the camelCase path parameter annotations. - `Responses` type alias for the response-declaration mapping, and `BAD_REQUEST` / `NOT_FOUND` / `SERVER_ERROR` to compose an `errors` set from, e.g. `errors=NOT_FOUND | SERVER_ERROR`. - Every route declares its 400 and 404 responses. 404 was raised from nine places and declared nowhere; 400 likewise. +- The opportunity search declares the `Preference-Applied` response header on both its 200 and 201 responses, and `Location` headers are documented on order creation and on async opportunity search. - Operation summaries on the six routes that had none, where FastAPI was deriving titles like `Root:List-Orders` from route names. ### Changed @@ -33,6 +34,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a - OpenAPI tags come from the route family rather than the owning router: creating an order for a product is filed under Orders, and the opportunity routes under Opportunities, rather than all of them under Products. - `Preference-Applied` is sent whenever the request carried a `Prefer` header, as the spec requires. It was previously sent only when the preference was `wait` and the root router supported async search, so a client that asked for a preference the server did not honour was told nothing at all. - **BREAKING** The landing page publishes the search records link under the spec's `search-records` rel, not `opportunity-search-records`. +- An async-only product no longer documents a 200 `OpportunityCollection` it can never return: the search route's response class, status code and model are chosen from what the product actually supports. - A product advertises the opportunity conformance classes it is actually served under, rather than whatever it declared. An async-only product mounted on a root router without async support previously advertised classes whose routes were never registered. - The root router advertises only the optional conformance classes whose backends were supplied, mirroring what `build_conformances` already did per product. `RootRouter(conformances=...)` now defaults to `None` rather than a fixed list. - Conformance lists are sorted, so they no longer vary between processes. diff --git a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py index fc3b1d6..24d1517 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py @@ -22,7 +22,6 @@ Link, OpportunityCollection, OpportunityRequest, - OpportunitySearchRecord, Order, OrderRequest, OrderStatus, @@ -39,7 +38,14 @@ from stapi_fastapi.path_params import OpportunityCollectionIdPath from stapi_fastapi.query_params import DEFAULT_LIMIT, Limit, NextToken, clamp_limit from stapi_fastapi.responses import GeoJSONResponse -from stapi_fastapi.routers.base import BAD_REQUEST, NOT_FOUND, SERVER_ERROR, Route, StapiFastapiBaseRouter +from stapi_fastapi.routers.base import ( + BAD_REQUEST, + NOT_FOUND, + SERVER_ERROR, + Responses, + Route, + StapiFastapiBaseRouter, +) from stapi_fastapi.routers.route_names import ( CONFORMANCE, CREATE_ORDER, @@ -178,33 +184,76 @@ async def _create_order( summary="Create an order for the product", response_class=GeoJSONResponse, status_code=status.HTTP_201_CREATED, + responses={ + 201: { + "headers": { + "Location": { + "description": "URL of the created Order.", + "schema": {"type": "string", "format": "uri"}, + }, + }, + }, + }, ) ) - if product.supports_opportunity_search or ( + supports_async = ( self.product.supports_async_opportunity_search and self.root_router.supports_async_opportunity_search - ): + ) + if product.supports_opportunity_search or supports_async: + preference_applied = { + "Preference-Applied": { + "description": ( + "Which preference the server applied, sent whenever the request " + "carried a `Prefer` header. It may differ from the requested " + "preference when the Product cannot honour it." + ), + "schema": {"type": "string", "enum": [preference.value for preference in Prefer]}, + }, + } + + # Each outcome is declared only when this product can produce it: the + # async 201 also `$ref`s OpportunitySearchRecord, which is registered + # in the components schemas only when the async endpoints exist. + extra_responses: Responses = {} + if product.supports_opportunity_search: + extra_responses[200] = {"headers": {**preference_applied}} + if supports_async: + extra_responses[201] = { + "description": "Created (async opportunity search record)", + "content": {TYPE_JSON: {"schema": {"$ref": "#/components/schemas/OpportunitySearchRecord"}}}, + "headers": { + "Location": { + "description": "URL of the created Opportunity Search Record.", + "schema": {"type": "string", "format": "uri"}, + }, + **preference_applied, + }, + } + self.register_route( Route( name=SEARCH_OPPORTUNITIES, tag=Tag.OPPORTUNITIES, path="/opportunities", endpoint=self.search_opportunities, - methods=("POST",), errors=BAD_REQUEST | NOT_FOUND | SERVER_ERROR, summary="Search Opportunities for the product", - response_class=GeoJSONResponse, + methods=("POST",), + # An async-only product answers with a search record, which + # is JSON rather than GeoJSON. + response_class=GeoJSONResponse if product.supports_opportunity_search else JSONResponse, + status_code=None if product.supports_opportunity_search else status.HTTP_201_CREATED, # unknown why mypy can't see the queryables property on Product, ignoring - response_model=OpportunityCollection[ - Geometry, - self.product.opportunity_properties, # type: ignore - ], - responses={ - 201: { - "model": OpportunitySearchRecord, - "content": {TYPE_JSON: {}}, - } - }, + response_model=( + OpportunityCollection[ + Geometry, + self.product.opportunity_properties, # type: ignore + ] + if product.supports_opportunity_search + else None + ), + responses=extra_responses, ) ) diff --git a/stapi-fastapi/tests/test_openapi.py b/stapi-fastapi/tests/test_openapi.py index 3f80acd..06e1400 100644 --- a/stapi-fastapi/tests/test_openapi.py +++ b/stapi-fastapi/tests/test_openapi.py @@ -12,6 +12,8 @@ from fastapi import FastAPI from fastapi.testclient import TestClient +from .shared import product_test_spotlight_sync_async_opportunity + HTTP_METHODS = {"get", "put", "post", "delete", "options", "head", "patch", "trace"} PRODUCT_ID = "test-spotlight" @@ -85,3 +87,13 @@ def collect(node: Any) -> None: collect(schemas) assert set(schemas) - referenced == set() + + +@pytest.mark.mock_products([product_test_spotlight_sync_async_opportunity]) +@pytest.mark.parametrize("code", ["200", "201"]) +def test_preference_applied_declared_on_opportunity_search(spec: dict[str, Any], code: str) -> None: + """`Preference-Applied` is a spec MUST, and which codes carry it depends on + the capabilities of the product actually mounted. + """ + header = spec["paths"][SEARCH_OPPORTUNITIES_PATH]["post"]["responses"][code]["headers"]["Preference-Applied"] + assert set(header["schema"]["enum"]) == {"wait", "respond-async"} diff --git a/stapi-fastapi/tests/test_opportunity_async.py b/stapi-fastapi/tests/test_opportunity_async.py index 39f9fc8..8c3414c 100644 --- a/stapi-fastapi/tests/test_opportunity_async.py +++ b/stapi-fastapi/tests/test_opportunity_async.py @@ -1,10 +1,10 @@ from collections.abc import Callable from datetime import UTC, datetime -from typing import Any +from typing import Any, cast from uuid import uuid4 import pytest -from fastapi import status +from fastapi import FastAPI, status from fastapi.testclient import TestClient from stapi_fastapi.conformance import API, PRODUCT from stapi_pydantic import ( @@ -531,3 +531,36 @@ def test_preference_applied_mismatch_wait_on_async_only( res = stapi_client_async_opportunity.post(url, json=opportunity_search, headers={"Prefer": "wait"}) assert res.status_code == 201 assert res.headers["Preference-Applied"] == "respond-async" + + +@pytest.mark.mock_products([product_test_spotlight_async_opportunity]) +def test_openapi_async_search_201_metadata(stapi_client_async_opportunity: TestClient) -> None: + from stapi_fastapi.constants import TYPE_JSON + + # TestClient types `app` as the bare ASGI callable; the fixture always + # builds a FastAPI app, which is what exposes `openapi()`. + spec = cast(FastAPI, stapi_client_async_opportunity.app).openapi() + responses = spec["paths"]["/products/test-spotlight/opportunities"]["post"]["responses"] + + # 201 documents the OpportunitySearchRecord as application/json (not geo+json) + r201 = responses["201"] + assert set(r201["content"].keys()) == {TYPE_JSON} + assert r201["content"][TYPE_JSON]["schema"]["$ref"].endswith("/OpportunitySearchRecord") + # Location header documented + assert "Location" in r201["headers"] + + # This product cannot search synchronously, so it can only ever answer 201. + # Documenting a 200 OpportunityCollection would promise a response that no + # request to this deployment can elicit. + assert "200" not in responses + + +@pytest.mark.mock_products([product_test_spotlight_async_opportunity]) +def test_openapi_create_order_201_location_header(stapi_client_async_opportunity: TestClient) -> None: + from stapi_fastapi.constants import TYPE_GEOJSON + + spec = cast(FastAPI, stapi_client_async_opportunity.app).openapi() + r201 = spec["paths"]["/products/test-spotlight/orders"]["post"]["responses"]["201"] + assert "Location" in r201["headers"] + # Order is GeoJSON, content stays geo+json + assert set(r201["content"].keys()) == {TYPE_GEOJSON} From 4fb46e4817b1f3361ba7c33d0862d595ab9fab0d Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 6 Aug 2026 19:16:15 -0700 Subject: [PATCH 41/48] fix!: type the client against the v0.2.0 models The client's public signatures were suppressed rather than typed: `Iterator[Order]`, `Iterator[Opportunity]` and `OrderRequest` each carried a `type: ignore[type-arg]` and a TODO saying the annotation would be fixed once the pydantic generics were. They are, so the parameters are spelled out -- `Opportunity[Geometry, OpportunityProperties]`, `OrderRequest[OrderParameters]` -- and the suppressions and TODOs go with them. `CQL2Filter` no longer needs one either, now that it is `dict[str, Any]`. The package requires stapi-pydantic 0.2.0, since that is where these models live, and its version moves to 0.0.2. --- pystapi-client/pyproject.toml | 4 ++-- pystapi-client/src/pystapi_client/client.py | 17 ++++++++--------- uv.lock | 2 +- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/pystapi-client/pyproject.toml b/pystapi-client/pyproject.toml index 51b5a18..210583c 100644 --- a/pystapi-client/pyproject.toml +++ b/pystapi-client/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pystapi-client" -version = "0.0.1" +version = "0.0.2" description = "Python library for searching Satellite Tasking API (STAPI) APIs." readme = "README.md" authors = [ @@ -14,7 +14,7 @@ license = { text = "MIT" } requires-python = ">=3.11" dependencies = [ "httpx>=0.28.1", - "stapi-pydantic", + "stapi-pydantic>=0.2.0", "python-dateutil>=2.8.2", "click>=8.1.8", ] diff --git a/pystapi-client/src/pystapi_client/client.py b/pystapi-client/src/pystapi_client/client.py index 68cc909..3c70e78 100644 --- a/pystapi-client/src/pystapi_client/client.py +++ b/pystapi-client/src/pystapi_client/client.py @@ -11,12 +11,15 @@ from pydantic import AnyUrl from stapi_pydantic import ( CQL2Filter, + Geometry, Link, Opportunity, OpportunityCollection, + OpportunityProperties, OpportunityRequest, Order, OrderCollection, + OrderParameters, OrderRequest, Product, ProductCollection, @@ -302,10 +305,9 @@ def get_product_opportunities( product_id: str, date_range: tuple[str, str], geometry: dict[str, Any], - cql2_filter: CQL2Filter | None = None, # type: ignore[type-arg] + cql2_filter: CQL2Filter | None = None, limit: int = 10, - ) -> Iterator[Opportunity]: # type: ignore[type-arg] - # TODO Update return type after the pydantic model generic type is fixed + ) -> Iterator[Opportunity[Geometry, OpportunityProperties]]: """Get all opportunities for a product from this STAPI API Args: product_id: The Product ID to get opportunities for @@ -350,8 +352,7 @@ def get_product_opportunities( for opportunity_collection in product_opportunities_json: yield from OpportunityCollection.model_validate(opportunity_collection).features - def create_product_order(self, product_id: str, order_parameters: OrderRequest) -> Order: # type: ignore[type-arg] - # TODO Update return type after the pydantic model generic type is fixed + def create_product_order(self, product_id: str, order_parameters: OrderRequest[OrderParameters]) -> Order: """Create an order for a product Args: @@ -395,8 +396,7 @@ def _get_products_href(self, product_id: str | None = None, subpath: str | None return str(product_url) - def get_orders(self, limit: int | None = None) -> Iterator[Order]: # type: ignore[type-arg] - # TODO Update return type after the pydantic model generic type is fixed + def get_orders(self, limit: int | None = None) -> Iterator[Order]: """Get orders from this STAPI API Args: @@ -418,8 +418,7 @@ def get_orders(self, limit: int | None = None) -> Iterator[Order]: # type: igno for orders_collection in orders_collection_iterator: yield from OrderCollection.model_validate(orders_collection).features - def get_order(self, order_id: str) -> Order: # type: ignore[type-arg] - # TODO Update return type after the pydantic model generic type is fixed + def get_order(self, order_id: str) -> Order: """Get a single order from this STAPI API Args: diff --git a/uv.lock b/uv.lock index f8c808e..af9a900 100644 --- a/uv.lock +++ b/uv.lock @@ -1680,7 +1680,7 @@ docs = [ [[package]] name = "pystapi-client" -version = "0.0.1" +version = "0.0.2" source = { editable = "pystapi-client" } dependencies = [ { name = "click" }, From d2cceefb20b05bd3cc24bb2682aad421a2c30df3 Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 6 Aug 2026 19:16:58 -0700 Subject: [PATCH 42/48] fix!: correct the client's conformance URI matching and scope The conformance pattern was `(.*)` for the version segment and unanchored, so it matched far more than it should: `/opportunities` matched `/opportunities-async`, a URI with extra path segments after the class matched, and a trailing newline matched. The segment is now `[^/]+` and the pattern is anchored with `\Z`. `ASYNC_OPPORTUNITIES` also pointed at `/async-opportunities`, which no server publishes -- the class is `/opportunities-async`. The opportunity capability checks read the root landing page, but those classes are advertised per Product, not at the root. `_supports_opportunities` and `_supports_async_opportunities` are replaced by public `product_supports_opportunities` and `product_supports_async_opportunities`, which take a Product or its id and read that Product's own `conformsTo`. The fixtures move to v0.2.0 conformance URIs and carry per-product `conformsTo`, with one product deliberately declaring neither opportunity class so the negative case is exercised. A `has_next_page` off-by-one in the pagination mock -- comparing against the already-sliced page rather than the full fixture -- is fixed in passing. --- pystapi-client/src/pystapi_client/client.py | 51 +++++++++-- .../src/pystapi_client/conformance.py | 9 +- pystapi-client/tests/conftest.py | 8 +- .../tests/fixtures/landing_page.json | 7 +- pystapi-client/tests/fixtures/products.json | 16 ++++ pystapi-client/tests/test_client.py | 88 +++++++++++++++++++ 6 files changed, 166 insertions(+), 13 deletions(-) diff --git a/pystapi-client/src/pystapi_client/client.py b/pystapi-client/src/pystapi_client/client.py index 3c70e78..9a39a80 100644 --- a/pystapi-client/src/pystapi_client/client.py +++ b/pystapi-client/src/pystapi_client/client.py @@ -260,13 +260,52 @@ def has_conformance(self, conformance_class: ConformanceClasses | str) -> bool: return any(re.match(conformance_class.pattern, uri) for uri in self.get_conforms_to()) - def _supports_opportunities(self) -> bool: - """Check if the API supports opportunities""" - return self.has_conformance(ConformanceClasses.OPPORTUNITIES) + def _product_has_conformance( + self, + product: str | Product, + conformance_class: ConformanceClasses, + ) -> bool: + """Check whether a Product advertises the given conformance class. + + Opportunity capability classes are advertised per-Product, not in the + root landing page. + + Args: + product: A Product ID or an already-fetched + :class:`~stapi_pydantic.Product`. If an ID is given the Product + is fetched from the API. + conformance_class: The conformance class to check for. + + Return: + Whether the Product conforms to the given class. + """ + if isinstance(product, str): + product = self.get_product(product) + return any(re.match(conformance_class.pattern, uri) for uri in product.conforms_to) + + def product_supports_opportunities(self, product: str | Product) -> bool: + """Check if a Product supports synchronous opportunity search. + + Args: + product: A Product ID or an already-fetched + :class:`~stapi_pydantic.Product`. + + Return: + Whether the Product supports synchronous opportunity search. + """ + return self._product_has_conformance(product, ConformanceClasses.OPPORTUNITIES) + + def product_supports_async_opportunities(self, product: str | Product) -> bool: + """Check if a Product supports asynchronous opportunity search. - def _supports_async_opportunities(self) -> bool: - """Check if the API supports asynchronous opportunities""" - return self.has_conformance(ConformanceClasses.ASYNC_OPPORTUNITIES) + Args: + product: A Product ID or an already-fetched + :class:`~stapi_pydantic.Product`. + + Return: + Whether the Product supports asynchronous opportunity search. + """ + return self._product_has_conformance(product, ConformanceClasses.ASYNC_OPPORTUNITIES) def get_products(self, limit: int | None = None) -> Iterator[Product]: """Get all products from this STAPI API diff --git a/pystapi-client/src/pystapi_client/conformance.py b/pystapi-client/src/pystapi_client/conformance.py index f936173..24d3ff0 100644 --- a/pystapi-client/src/pystapi_client/conformance.py +++ b/pystapi-client/src/pystapi_client/conformance.py @@ -6,9 +6,14 @@ class ConformanceClasses(Enum): """Enumeration class for Conformance Classes""" # defined conformance classes regexes + # API-level classes (advertised in the root landing page / `/conformance`) CORE = "/core" + ORDER_STATUSES = "/order-statuses" + SEARCHES_OPPORTUNITY = "/searches-opportunity" + SEARCHES_OPPORTUNITY_STATUSES = "/searches-opportunity-statuses" + # Product-level classes (advertised in a Product's own `conformsTo`) OPPORTUNITIES = "/opportunities" - ASYNC_OPPORTUNITIES = "/async-opportunities" + ASYNC_OPPORTUNITIES = "/opportunities-async" @classmethod def get_by_name(cls, name: str) -> "ConformanceClasses": @@ -29,4 +34,4 @@ def valid_uri(self) -> str: @property def pattern(self) -> re.Pattern[str]: - return re.compile(rf"{re.escape('https://stapi.example.com/v')}(.*){re.escape(self.value)}") + return re.compile(rf"{re.escape('https://stapi.example.com/v')}[^/]+{re.escape(self.value)}\Z") diff --git a/pystapi-client/tests/conftest.py b/pystapi-client/tests/conftest.py index d3c57e1..cd3b066 100644 --- a/pystapi-client/tests/conftest.py +++ b/pystapi-client/tests/conftest.py @@ -14,7 +14,7 @@ def load_fixture(name: str) -> dict[str, Any]: with open(WORKING_DIR / "fixtures" / f"{name}.json") as f: - return cast(dict, json.load(f)) + return cast(dict[str, Any], json.load(f)) @pytest.fixture @@ -37,7 +37,8 @@ def mock_products_response(request: Request) -> Response: start_index = (page - 1) * int(limit) end_index = start_index + int(limit) products_limited["products"] = products_limited["products"][start_index:end_index] - has_next_page = end_index < len(products_limited["products"]) + 1 + # against the full fixture, not the page just sliced out of it + has_next_page = end_index < len(products["products"]) if has_next_page: products_limited["links"].append( { @@ -51,4 +52,7 @@ def mock_products_response(request: Request) -> Response: respx_mock.get("/products").mock(side_effect=mock_products_response) respx_mock.get("/products", params={"limit": 1}).mock(side_effect=mock_products_response) + for product in products["products"]: + respx_mock.get(f"/products/{product['id']}").return_value = Response(200, json=product) + yield respx_mock diff --git a/pystapi-client/tests/fixtures/landing_page.json b/pystapi-client/tests/fixtures/landing_page.json index 5c4a27e..edc1c80 100644 --- a/pystapi-client/tests/fixtures/landing_page.json +++ b/pystapi-client/tests/fixtures/landing_page.json @@ -3,9 +3,10 @@ "title": "A simple STAPI Example", "description": "This API demonstrated the landing page for a SpatioTemporal Asset Tasking API", "conformsTo": [ - "https://stapi.example.com/v0.1.0/core", - "https://geojson.org/schema/Point.json", - "https://geojson.org/schema/Polygon.json" + "https://stapi.example.com/v0.2.0/core", + "https://stapi.example.com/v0.2.0/order-statuses", + "https://stapi.example.com/v0.2.0/searches-opportunity", + "https://stapi.example.com/v0.2.0/searches-opportunity-statuses" ], "links": [ { diff --git a/pystapi-client/tests/fixtures/products.json b/pystapi-client/tests/fixtures/products.json index 5f216b0..e9fb083 100644 --- a/pystapi-client/tests/fixtures/products.json +++ b/pystapi-client/tests/fixtures/products.json @@ -1,8 +1,18 @@ { + "stapi_type": "ProductCollection", + "stapi_version": "0.2.0", "products": [ { "type": "Collection", + "stapi_type": "Product", + "stapi_version": "0.2.0", "id": "multispectral", + "conformsTo": [ + "https://stapi.example.com/v0.2.0/opportunities", + "https://stapi.example.com/v0.2.0/opportunities-async", + "https://geojson.org/schema/Point.json", + "https://geojson.org/schema/Polygon.json" + ], "title": "Multispectral", "description": "Full color EO image", "keywords": [ @@ -103,7 +113,13 @@ }, { "type": "Collection", + "stapi_type": "Product", + "stapi_version": "0.2.0", "id": "spotlight", + "conformsTo": [ + "https://geojson.org/schema/Point.json", + "https://geojson.org/schema/Polygon.json" + ], "title": "Spotlight", "description": "SAR Spotlight frame", "keywords": [ diff --git a/pystapi-client/tests/test_client.py b/pystapi-client/tests/test_client.py index ea39e5f..c0370da 100644 --- a/pystapi-client/tests/test_client.py +++ b/pystapi-client/tests/test_client.py @@ -1,4 +1,5 @@ from pystapi_client.client import Client +from pystapi_client.conformance import ConformanceClasses from respx import MockRouter from stapi_pydantic import Link @@ -23,3 +24,90 @@ def test_pagination(api: MockRouter) -> None: products_link = Link(href="http://stapi.test/products", method="GET", body={"limit": 1}, rel="") for products_collection in client.stapi_io.get_pages(products_link, "products"): assert len(products_collection["products"]) == 1 + + +def test_async_opportunities_uri_matches_reference_server() -> None: + server_advertised = "https://stapi.example.com/v0.2.0/opportunities-async" + assert ConformanceClasses.ASYNC_OPPORTUNITIES.pattern.match(server_advertised) + + +def test_sync_opportunities_uri_does_not_match_async_uri() -> None: + async_uri = "https://stapi.example.com/v0.2.0/opportunities-async" + assert not ConformanceClasses.OPPORTUNITIES.pattern.match(async_uri) + assert ConformanceClasses.OPPORTUNITIES.pattern.match("https://stapi.example.com/v0.2.0/opportunities") + + +# --- Item 1: version pattern is a single path segment, anchored with \Z --- + + +def test_version_pattern_matches_single_version_segment() -> None: + pattern = ConformanceClasses.OPPORTUNITIES.pattern + assert pattern.match("https://stapi.example.com/v0.2.0/opportunities") + + +def test_version_pattern_rejects_extra_path_segments() -> None: + pattern = ConformanceClasses.OPPORTUNITIES.pattern + assert not pattern.match("https://stapi.example.com/v0.2.0/foo/opportunities") + + +def test_version_pattern_rejects_empty_version() -> None: + pattern = ConformanceClasses.OPPORTUNITIES.pattern + assert not pattern.match("https://stapi.example.com/v/opportunities") + + +def test_version_pattern_rejects_trailing_newline() -> None: + pattern = ConformanceClasses.OPPORTUNITIES.pattern + assert not pattern.match("https://stapi.example.com/v0.2.0/opportunities\n") + + +# --- Item 2: API-level extension conformance classes exist in the enum --- + + +def test_api_level_extension_classes_exist_and_match() -> None: + order_statuses = ConformanceClasses.get_by_name("ORDER_STATUSES") + searches_opportunity = ConformanceClasses.get_by_name("SEARCHES_OPPORTUNITY") + searches_opportunity_statuses = ConformanceClasses.get_by_name("SEARCHES_OPPORTUNITY_STATUSES") + + assert order_statuses.pattern.match("https://stapi.example.com/v0.2.0/order-statuses") + assert searches_opportunity.pattern.match("https://stapi.example.com/v0.2.0/searches-opportunity") + assert searches_opportunity_statuses.pattern.match("https://stapi.example.com/v0.2.0/searches-opportunity-statuses") + + +def test_searches_opportunity_does_not_match_statuses_uri() -> None: + searches_opportunity = ConformanceClasses.get_by_name("SEARCHES_OPPORTUNITY") + assert not searches_opportunity.pattern.match("https://stapi.example.com/v0.2.0/searches-opportunity-statuses") + + +# --- Item 3 / 4: product-scoped opportunity capability checks --- + + +def test_supports_opportunities_reads_product_conformance(api: MockRouter) -> None: + client = Client.open(url="http://stapi.test") + assert client.product_supports_opportunities("multispectral") is True + + +def test_supports_async_opportunities_reads_product_conformance(api: MockRouter) -> None: + client = Client.open(url="http://stapi.test") + assert client.product_supports_async_opportunities("multispectral") is True + + +def test_product_without_opportunities_returns_false(api: MockRouter) -> None: + client = Client.open(url="http://stapi.test") + assert client.product_supports_opportunities("spotlight") is False + assert client.product_supports_async_opportunities("spotlight") is False + + +def test_opportunity_support_does_not_depend_on_root_conformance(api: MockRouter) -> None: + client = Client.open(url="http://stapi.test") + # Root conformsTo must not advertise the product-level opportunity classes. + assert not client.has_conformance(ConformanceClasses.OPPORTUNITIES) + assert not client.has_conformance(ConformanceClasses.ASYNC_OPPORTUNITIES) + # Yet the product does support opportunities per its own conformsTo. + assert client.product_supports_opportunities("multispectral") is True + + +def test_root_advertises_api_level_extension_classes(api: MockRouter) -> None: + client = Client.open(url="http://stapi.test") + assert client.has_conformance(ConformanceClasses.CORE) + assert client.has_conformance("ORDER_STATUSES") + assert client.has_conformance("SEARCHES_OPPORTUNITY") From 61d8a7386b0c2015984b4ce23df21d2fe4b2bf00 Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 6 Aug 2026 19:17:26 -0700 Subject: [PATCH 43/48] build: cap schemathesis below 4 schemathesis 4 moved the OpenAPI checks to `schemathesis.specs.openapi.checks`, dropped `experimental` and `from_uri`, and gave every check a `CheckContext` first argument. The validator suite uses all four, so it cannot run against v4 unported. The cap is explicit, with the reason in the manifest, rather than left to whatever resolves. It pulls schemathesis back to 3.39.16 and pytest to 8.4.2, which is what that line supports. --- pystapi-validator/pyproject.toml | 5 +- uv.lock | 376 +++++++++++++++++++++++++++++-- 2 files changed, 361 insertions(+), 20 deletions(-) diff --git a/pystapi-validator/pyproject.toml b/pystapi-validator/pyproject.toml index f5eb228..57937c5 100644 --- a/pystapi-validator/pyproject.toml +++ b/pystapi-validator/pyproject.toml @@ -9,7 +9,10 @@ license = "MIT" readme = "README.md" requires-python = ">=3.11" dependencies = [ - "schemathesis>=3.37.0", + # Capped: v4 moved the checks to schemathesis.specs.openapi.checks, dropped + # `experimental` and `from_uri`, and gave checks a CheckContext first + # argument. + "schemathesis>=3.37.0,<4", "pytest>=8.3.3", "requests>=2.32.3", "pyyaml>=6.0.2", diff --git a/uv.lock b/uv.lock index af9a900..3e3faed 100644 --- a/uv.lock +++ b/uv.lock @@ -88,6 +88,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" }, ] +[[package]] +name = "backoff" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, +] + [[package]] name = "backrefs" version = "6.1" @@ -1155,6 +1164,123 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/de/063481352688c3a1468c51c10b6cfb858d5e35dfef8323d9c83c4f2faa03/mkdocstrings_python-2.0.0-py3-none-any.whl", hash = "sha256:1d552dda109d47e4fddecbb1f06f9a86699c1b073e8b166fba89eeef0a0ffec6", size = 104803, upload-time = "2025-11-27T16:44:43.441Z" }, ] +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" }, + { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" }, + { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" }, + { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" }, + { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" }, + { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" }, + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + [[package]] name = "mypy" version = "1.19.0" @@ -1285,6 +1411,117 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/46/eba9be9daa403fa94854ce16a458c29df9a01c6c047931c3d8be6016cd9a/pre_commit_hooks-6.0.0-py2.py3-none-any.whl", hash = "sha256:76161b76d321d2f8ee2a8e0b84c30ee8443e01376121fd1c90851e33e3bd7ee2", size = 41338, upload-time = "2025-08-09T19:25:03.513Z" }, ] +[[package]] +name = "propcache" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/f1/8a8cc1c2c7e7934ab77e0163414f736fadbc0f5e8dd9673b952355ac175b/propcache-0.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78", size = 90744, upload-time = "2026-05-08T20:59:45.799Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f4/651b1225e976bd1a2ba5cfba0c29d096581c2636b437e3a9a7ab6276270a/propcache-0.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959", size = 52033, upload-time = "2026-05-08T20:59:47.408Z" }, + { url = "https://files.pythonhosted.org/packages/15/a8/8ede85d6aa1f79fc7dc2f8fd2c8d65920b8272c3892903c8a1affde48cfb/propcache-0.5.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7", size = 52754, upload-time = "2026-05-08T20:59:49.202Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fe/b3551b41bbc2f5b5bb088fc6920567cd43101253e68fbaa261339eb96fe1/propcache-0.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2293949b855ce597f2826452d17c2d545fb5622379c4ea6fdf525e9b8e8a2511", size = 57573, upload-time = "2026-05-08T20:59:50.778Z" }, + { url = "https://files.pythonhosted.org/packages/83/27/ab851ebd1b7172e3e161f5f8d39e315d54a91bea246f01f4d872d3376aef/propcache-0.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0fd59b5af35f74da48d905dcbad55449ba13be91823cb05a9bd590bbf5b61660", size = 60645, upload-time = "2026-05-08T20:59:52.227Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/466b3d18022e9897cbda9c735c493c5bd747d7a4c6f5ea1480b4cec434b6/propcache-0.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29f9309a2e42b0d273be006fdb4be2d6c39a47f6f57d8fb1cf9f81481df81b66", size = 61563, upload-time = "2026-05-08T20:59:53.866Z" }, + { url = "https://files.pythonhosted.org/packages/27/1b/16ab7f2cf2041da2f60d156ba64c2484eadf9168075b4ff43c3ef60045af/propcache-0.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5aaa2b923c1944ac8febd6609cb373540a5563e7cbcb0fd770f75dace2eb817b", size = 58888, upload-time = "2026-05-08T20:59:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/0a/67/bb777ffd907633563bf35fd859c4ce97b0512c32f4633cf5d1eb7c33512b/propcache-0.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66ea454f095ddf5b6b14f56c064c0941c4788be11e18d2464cf643bf7203ff67", size = 59253, upload-time = "2026-05-08T20:59:57.075Z" }, + { url = "https://files.pythonhosted.org/packages/b9/42/64f8d90b73fd9cdc1499b48057ff6d9cd2a98a25734c9bb62ecf07e87061/propcache-0.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:95f1e3f4760d404b13c9976c0229b2b49a3c8e2c62a9ce92efdd2b11ada75e3f", size = 57558, upload-time = "2026-05-08T20:59:58.602Z" }, + { url = "https://files.pythonhosted.org/packages/eb/02/dba5bc03c9041f2092ea55a449caf5dfe68352c6654511b29ba0654ddb69/propcache-0.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:85341b12b9d55bad0bded24cac341bb34289469e03a11f3f583ea1cc1db0326c", size = 55007, upload-time = "2026-05-08T20:59:59.837Z" }, + { url = "https://files.pythonhosted.org/packages/14/c0/43f649c7aa2a77a3b100d84e9dea3a483120ecb608bfe36ce49eaff517fe/propcache-0.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:26a4dca084132874e639895c3135dfad5eb20bae209f62d1aeb31b03e601c3c0", size = 60355, upload-time = "2026-05-08T21:00:01.144Z" }, + { url = "https://files.pythonhosted.org/packages/83/c0/435dafd27f1cb4a495381dae60e25883ccfe4020bb72818e8184c1678092/propcache-0.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3b199b9b2b3d6a7edf3183ba8a9a137a22b97f7df525feb5ae1eccf026d2a9c6", size = 59057, upload-time = "2026-05-08T21:00:02.401Z" }, + { url = "https://files.pythonhosted.org/packages/53/ae/6e292df9135d659944e96cb3389258e4a663e5b2b5f6c217ef0ddc8d2f73/propcache-0.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e59bc9e66329185b93dab73f210f1a37f81cb40f321501db8017c9aea15dba27", size = 61938, upload-time = "2026-05-08T21:00:03.638Z" }, + { url = "https://files.pythonhosted.org/packages/0b/42/314ebc50d8159055411fd6b0bda322ff510e4b1f7d2e4927940ad0f6af20/propcache-0.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:552ffadf6ad409844bc5919c42a0a83d88314cedddaea0e41e80a8b8fffe881f", size = 59731, upload-time = "2026-05-08T21:00:04.881Z" }, + { url = "https://files.pythonhosted.org/packages/b8/9b/2da6dee38871c3c8772fabc2758325a5c9077d6d18c597737dc04dd884cd/propcache-0.5.2-cp311-cp311-win32.whl", hash = "sha256:cd416c1de191973c52ff1a12a57446bfc7642797b282d7caf2162d7d1b8aa9a0", size = 38966, upload-time = "2026-05-08T21:00:06.511Z" }, + { url = "https://files.pythonhosted.org/packages/42/4e/f17363fb58c0afe05b067361cb6d86ed2d29de6506779a27547c4d183075/propcache-0.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:44e488ef40dbb452700b2b1f8188934121f6648f52c295055662d2191959ff82", size = 42135, upload-time = "2026-05-08T21:00:08.088Z" }, + { url = "https://files.pythonhosted.org/packages/c6/eb/6af6685077d22e8b33358d3c548e3282706a0b3cd85044ffba4e5dd08e3b/propcache-0.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:54adaa85a22078d1e306304a40984dc5be99d599bf3dc0a24dc98f7daeab89ab", size = 38381, upload-time = "2026-05-08T21:00:09.692Z" }, + { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" }, + { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" }, + { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" }, + { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" }, + { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" }, + { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" }, + { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" }, + { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" }, + { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" }, + { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" }, + { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" }, + { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" }, + { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" }, + { url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" }, + { url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" }, + { url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" }, + { url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" }, + { url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" }, + { url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" }, + { url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" }, + { url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" }, + { url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" }, + { url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" }, + { url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" }, + { url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" }, + { url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" }, + { url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" }, + { url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" }, + { url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" }, + { url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" }, + { url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" }, + { url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" }, + { url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" }, + { url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" }, + { url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" }, + { url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" }, + { url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" }, + { url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" }, + { url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" }, + { url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" }, + { url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" }, + { url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" }, + { url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" }, + { url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" }, + { url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" }, + { url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" }, + { url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, +] + [[package]] name = "pycparser" version = "2.23" @@ -1731,12 +1968,12 @@ requires-dist = [ { name = "pytest-metadata", specifier = ">=3.1.1" }, { name = "pyyaml", specifier = ">=6.0.2" }, { name = "requests", specifier = ">=2.32.3" }, - { name = "schemathesis", specifier = ">=3.37.0" }, + { name = "schemathesis", specifier = ">=3.37.0,<4" }, ] [[package]] name = "pytest" -version = "9.0.1" +version = "8.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -1745,9 +1982,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/07/56/f013048ac4bc4c1d9be45afd4ab209ea62822fb1598f40687e6bf45dcea4/pytest-9.0.1.tar.gz", hash = "sha256:3e9c069ea73583e255c3b21cf46b8d3c56f6e3a1a8f6da94ccb0fcf57b9d73c8", size = 1564125, upload-time = "2025-11-12T13:05:09.333Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/8b/6300fb80f858cda1c51ffa17075df5d846757081d11ab4aa35cef9e6258b/pytest-9.0.1-py3-none-any.whl", hash = "sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad", size = 373668, upload-time = "2025-11-12T13:05:07.379Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, ] [[package]] @@ -2278,9 +2515,10 @@ wheels = [ [[package]] name = "schemathesis" -version = "4.6.4" +version = "3.39.16" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "backoff" }, { name = "click" }, { name = "colorama" }, { name = "harfile" }, @@ -2295,15 +2533,16 @@ dependencies = [ { name = "pytest-subtests" }, { name = "pyyaml" }, { name = "requests" }, - { name = "rich" }, + { name = "starlette" }, { name = "starlette-testclient" }, - { name = "tenacity" }, - { name = "typing-extensions" }, + { name = "tomli" }, + { name = "tomli-w" }, { name = "werkzeug" }, + { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/56/10/0204321f99292baa0a7a9f5a5ef911e271cc6c4662e48ed638fe1abfcd7a/schemathesis-4.6.4.tar.gz", hash = "sha256:2702d3f65ec84fd624fddeea458581f0e9981deec29d908ea858f662a355bd20", size = 57953865, upload-time = "2025-11-28T16:14:00.835Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/4a/b241f9a76c66d4a1c70ef0feb51cab957792087631bfb40589c2b362c848/schemathesis-3.39.16.tar.gz", hash = "sha256:d903368786e745ad151924d4e2e883a8b158332e60af56cd623d88d08ad55076", size = 57912241, upload-time = "2025-04-20T20:46:14.985Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/59/1a/dfbfd909ec2ead2a92a28eaa2f0aa8b87f99f0e0bcd06784d7ae480c3a0e/schemathesis-4.6.4-py3-none-any.whl", hash = "sha256:2b5c1ddc01bc746cc2d6bf985a18de41327d10eca676d21f0966924718a835da", size = 413909, upload-time = "2025-11-28T16:13:57.963Z" }, + { url = "https://files.pythonhosted.org/packages/c7/c2/ae49f7c54a06fcd6aa1133225d18ef25cfa2e63a29bcf6174a42063873c7/schemathesis-3.39.16-py3-none-any.whl", hash = "sha256:4db548ce016a13f18fe70fbc7a296058aef799e6b8e14220865157c0ca88850c", size = 332565, upload-time = "2025-04-20T20:46:11.985Z" }, ] [[package]] @@ -2434,15 +2673,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/25/44/f5209b889a344b1331a103aec4e9f906c7f67f9295fd287fdaa818179d95/starlette_testclient-0.4.1-py3-none-any.whl", hash = "sha256:dcf0eb237dc47f062ef5925f98330af46f67e547cb587119c9ae78c17ae6c1d1", size = 8143, upload-time = "2024-04-29T10:54:25.728Z" }, ] -[[package]] -name = "tenacity" -version = "9.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0a/d4/2b0cd0fe285e14b36db076e78c93766ff1d529d70408bd1d2a5a84f1d929/tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb", size = 48036, upload-time = "2025-04-02T08:25:09.966Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248, upload-time = "2025-04-02T08:25:07.678Z" }, -] - [[package]] name = "tomli" version = "2.3.0" @@ -2492,6 +2722,15 @@ wheels = [ { 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]] +name = "tomli-w" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, +] + [[package]] name = "toolz" version = "1.1.0" @@ -2834,3 +3073,102 @@ sdist = { url = "https://files.pythonhosted.org/packages/45/ea/b0f8eeb287f8df906 wheels = [ { url = "https://files.pythonhosted.org/packages/2f/f9/9e082990c2585c744734f85bec79b5dae5df9c974ffee58fe421652c8e91/werkzeug-3.1.4-py3-none-any.whl", hash = "sha256:2ad50fb9ed09cc3af22c54698351027ace879a0b60a3b5edf5730b2f7d876905", size = 224960, upload-time = "2025-11-29T02:15:21.13Z" }, ] + +[[package]] +name = "yarl" +version = "1.24.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/33/ebe9e3d1f86c7a0b51094c0a146392045ca1631d2664889539dec8088a33/yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f", size = 228679, upload-time = "2026-07-20T02:07:45.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/db/3cb5df059756a45761cc3dee8fd25ec82b83a6585ea3542b969fda850f99/yarl-1.24.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2c1fe720934a16ea8e7146175cba2126f87f54912c8c5435e7f7c7a51ef808d3", size = 135043, upload-time = "2026-07-20T02:04:52.39Z" }, + { url = "https://files.pythonhosted.org/packages/44/f8/767d6bd5a03db63bc467df2fb56d6fafeae9667d74aea92cd6af399f828b/yarl-1.24.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c687ed078e145f5fd53a14854beff320e1d2ab76df03e2009c98f39a0f68f39a", size = 96942, upload-time = "2026-07-20T02:04:54.26Z" }, + { url = "https://files.pythonhosted.org/packages/ce/97/10b939c44d7b28d1dbc389cfc7012306d1ea8dba01eaef44b39fffaee52a/yarl-1.24.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:709f1efed56c4a145793c046cd4939f9959bcd818979a787b77d8e09c57a0840", size = 97046, upload-time = "2026-07-20T02:04:56.638Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7a/b410dbe39b6255c55fb2a2bcee96eb844d0789235ddc381a889a90dc72d6/yarl-1.24.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:874019bd513008b009f58657134e5d0c5e030b3559bd0553976837adf52fe966", size = 110512, upload-time = "2026-07-20T02:04:58.955Z" }, + { url = "https://files.pythonhosted.org/packages/83/c7/da591971f78a5617e1f21f5699858ebccd836fe181a6493788ffc91ba69b/yarl-1.24.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a4582acf7ef76482f6f511ebaf1946dae7f2e85ec4728b81a678c01df63bd723", size = 102454, upload-time = "2026-07-20T02:05:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8e/73b0ed4de47289a78a96045d76d1cfe5e41848bf0da59ce25b2ec87ee05d/yarl-1.24.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2cabe6546e41dabe439999a23fcb5246e0c3b595b4315b96ef755252be90caeb", size = 117617, upload-time = "2026-07-20T02:05:02.325Z" }, + { url = "https://files.pythonhosted.org/packages/cf/14/b744747bc4f57a8d55bd744df463457524583e1e9f7538b5ace0346ab92e/yarl-1.24.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:17f57620f5475b3c69109376cc87e42a7af5db13c9398e4292772a706ff10780", size = 116135, upload-time = "2026-07-20T02:05:04.05Z" }, + { url = "https://files.pythonhosted.org/packages/66/ca/95aa4d0e5b7ea4f20e4d577c42d001ed9df207569fdb063cc5ed4ebb496b/yarl-1.24.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:570fec8fbd22b032733625f03f10b7ff023bc399213db15e72a7acaef28c2f4e", size = 111935, upload-time = "2026-07-20T02:05:05.738Z" }, + { url = "https://files.pythonhosted.org/packages/72/0d/d2ad8d6b147832d177a4e720ba1962fe686eb0913b74503b3eca094b8bba/yarl-1.24.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5fede79c6f73ff2c3ef822864cb1ada23196e62756df53bc6231d351a49516a2", size = 110010, upload-time = "2026-07-20T02:05:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/50/18/eb335e4120903903f4865041355ae46256a2406eb2865bc24827f4f27b61/yarl-1.24.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8ccf9aca873b767977c73df497a85dbedee4ee086ae9ae49dc461333b9b79f58", size = 110058, upload-time = "2026-07-20T02:05:09.246Z" }, + { url = "https://files.pythonhosted.org/packages/44/70/97353add32c62ad6f206d948ac5a5ee84398225e534dc6ed6433d1b335b6/yarl-1.24.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ad5d8201d310b031e6cd839d9bac2d4e5a01533ce5d3d5b50b7de1ef3af1de61", size = 103308, upload-time = "2026-07-20T02:05:11.31Z" }, + { url = "https://files.pythonhosted.org/packages/68/39/5e7398d4b6f6b3c9062823ebc60802df5b272e3fe9e788f9734c6ee46c85/yarl-1.24.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:841f0852f48fefea3b12c9dfec00704dfa3aef5215d0e3ce564bb3d7cd8d57c6", size = 116898, upload-time = "2026-07-20T02:05:13.099Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c9/09e52f2239e8b96357eccca05915382e4ba5405ebfb623b6036040d99654/yarl-1.24.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:9baafc71b04f8f4bb0703b21d6fc9f0c30b346c636a532ff16ec8491a5ea4b1f", size = 109400, upload-time = "2026-07-20T02:05:14.821Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6a/e94133d4c2d1a14d2384310bf3e79d9cf32c9d1eae1c6f034fb80d098fa1/yarl-1.24.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d897129df1a22b12aeed2c2c98df0785a2e8e6e0bde87b389491d0025c187077", size = 115934, upload-time = "2026-07-20T02:05:17.78Z" }, + { url = "https://files.pythonhosted.org/packages/4e/3c/34955ed967b976fc38edcbb6d538dee79dbda4cb7fc7f72a0907a7c78e0f/yarl-1.24.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dd625535328fd9882374356269227670189adfcc6a2d90284f323c05862eecbd", size = 112178, upload-time = "2026-07-20T02:05:19.675Z" }, + { url = "https://files.pythonhosted.org/packages/f5/46/d7bd3a8859d47dcfaffd7127af7076032a7da278a9a02e17b5f37bfb6712/yarl-1.24.5-cp311-cp311-win_amd64.whl", hash = "sha256:f4239bbec5a3577ddb49e4b50aeb32d8e5792098262ae2f63723f916a29b1a25", size = 97544, upload-time = "2026-07-20T02:05:21.523Z" }, + { url = "https://files.pythonhosted.org/packages/01/69/c1bfd21e32c638974ea2c542a0b8c53ef1fa9eff336020f5d014f9503ff2/yarl-1.24.5-cp311-cp311-win_arm64.whl", hash = "sha256:3ac6aff147deb9c09461b2d4bbdf6256831198f5d8a23f5d37138213090b6d8a", size = 93359, upload-time = "2026-07-20T02:05:23.493Z" }, + { url = "https://files.pythonhosted.org/packages/1b/84/71d051c850b5af41d168c679d9eb67eb7c55283ac4ee131673edf134bc4e/yarl-1.24.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d693396e5aea78db03decd60aec9ece16c9b40ba00a587f089615ff4e718a81d", size = 136035, upload-time = "2026-07-20T02:05:25.489Z" }, + { url = "https://files.pythonhosted.org/packages/03/4d/8ad27f9a1b7e69313cca5d695b925b48efe51208d3490e0844bae97cabc0/yarl-1.24.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3363fcc96e665878946ad7a106b9a13eac0541766a690ef287c0232ac768b6ec", size = 97642, upload-time = "2026-07-20T02:05:27.429Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b4/05b4131c407006cd1e410e9c6539f16a0945724677e5364447313c15ea3e/yarl-1.24.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9d399bdcfb4a0f659b9b3788bbc89babe63d9a6a65aacdf4d4e7065ff2e6316c", size = 97323, upload-time = "2026-07-20T02:05:29.441Z" }, + { url = "https://files.pythonhosted.org/packages/20/16/e618c875c73e0e39611f20a581b3d5e8d59b8857bf001bee3263044c6deb/yarl-1.24.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90333fd89b43c0d08ac85f3f1447593fc2c66de18c3d6378d7125ea118dc7a54", size = 107741, upload-time = "2026-07-20T02:05:31.367Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9a/c4defeaf3ed33fcb346aacf9c6e971a8d4e2bde04a0310e79abb208e7965/yarl-1.24.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:665b0a2c463cc9423dd647e0bfd9f4ccc9b50f768c55304d5e9f80b177c1de12", size = 103570, upload-time = "2026-07-20T02:05:33.303Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e7/0e0e0de5865ebd5914537ef486f36c727a59865c3ac0cf5ff1b32aececbf/yarl-1.24.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e006d3a974c4ee19512e5f058abedb6eef36a5e553c14812bdeba1758d812e6d", size = 115815, upload-time = "2026-07-20T02:05:35.292Z" }, + { url = "https://files.pythonhosted.org/packages/2b/27/ca56b700cb170aba25a3893b75355b213935657dc5714d2383354a270e62/yarl-1.24.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7d42c531243450ef0d4d9c172e7ed6ef052640f195629065041b5add4e058d1", size = 116025, upload-time = "2026-07-20T02:05:37.503Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d0/d56c859b8222116f5d68459199f48359e0bf121b6f65a69bf329b3602ba0/yarl-1.24.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f08c7513ecef5aad65687bfdf6bc601ae9fccd04a42904501f8f7141abad9eb9", size = 109835, upload-time = "2026-07-20T02:05:39.506Z" }, + { url = "https://files.pythonhosted.org/packages/70/a2/3a35557e4d1a79425040eba202ccaf08bdc8717680fc77e2498a1ad2e0a5/yarl-1.24.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c95b17fe34ed802f17e205112e6e10db92275c34fee290aa9bdc55a9c724027", size = 108884, upload-time = "2026-07-20T02:05:41.584Z" }, + { url = "https://files.pythonhosted.org/packages/e4/35/ef4c26356b7913c68983bac2d72a4212b3347af551cb8d250b99b5ed7b7f/yarl-1.24.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:56b149b22de33b23b0c6077ab9518c6dcb538ad462e1830e68d06591ccf6e38b", size = 107308, upload-time = "2026-07-20T02:05:43.697Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/ff0dc66c2ccf3e0153ab97ff61eabab4400e6a5264af427ab30cd69f1857/yarl-1.24.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a8fe66b8f300da93798025a785a5b90b42f3810dc2b72283ff84a41aaaebc293", size = 103646, upload-time = "2026-07-20T02:05:45.895Z" }, + { url = "https://files.pythonhosted.org/packages/74/f0/33b9271c7f881766359d58266fa0811d2e5210ed860e28da7dc6d7786344/yarl-1.24.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:377fe3732edbaf78ee74efdf2c9f49f6e99f20e7f9d2649fda3eb4badd77d76e", size = 115305, upload-time = "2026-07-20T02:05:47.832Z" }, + { url = "https://files.pythonhosted.org/packages/ef/65/fd79fb1868c4a80db8661091de525bf430f63c3bea1b20e8b6a84fc7d359/yarl-1.24.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e8ffa78582120024f476a611d7befc123cee59e47e8309d470cf667d806e613b", size = 108404, upload-time = "2026-07-20T02:05:49.604Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ba/dbabe6b262f17a816c70cfc09558dbf03ece3ec76684d02f911a3d3a189c/yarl-1.24.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:daba5e594f06114e37db186efd2dd916609071e59daca901a0a2e71f02b142ce", size = 115940, upload-time = "2026-07-20T02:05:51.741Z" }, + { url = "https://files.pythonhosted.org/packages/a5/43/fab2d1dad9d340a268cdde63756a123d069723efff6a372d123fa74a9517/yarl-1.24.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:65be18ec59496c13908f02a2472751d9ef840b4f3fb5726f129306bf6a2a7bba", size = 110006, upload-time = "2026-07-20T02:05:53.554Z" }, + { url = "https://files.pythonhosted.org/packages/c4/27/41eb51bbd1b8d89546b83897cfb0164f1e109304fd408dbb151b639eec0f/yarl-1.24.5-cp312-cp312-win_amd64.whl", hash = "sha256:a929d878fec099030c292803b31e5d5540a7b6a31e6a3cc76cb4685fc2a2f51b", size = 97618, upload-time = "2026-07-20T02:05:55.57Z" }, + { url = "https://files.pythonhosted.org/packages/3c/25/b2553764b3d65db711d8f45416351ec4f420847558eb669edcbcaadf5780/yarl-1.24.5-cp312-cp312-win_arm64.whl", hash = "sha256:7ce27823052e2013b597e0c738b13e7e36b8ccb9400df8959417b052ab0fd92c", size = 93018, upload-time = "2026-07-20T02:05:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/e1/63/64ef361967cc983573149dc1515d531db5da8a4c92d22bb833d59e01b313/yarl-1.24.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:79af890482fc94648e8cde4c68620378f7fef60932710fa17a66abc039244da2", size = 135075, upload-time = "2026-07-20T02:05:59.671Z" }, + { url = "https://files.pythonhosted.org/packages/bb/89/55920fd853ce43e608adbc3962456f0d649d6bb15250dc2988321da0fe1c/yarl-1.24.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:46c2f213e23a04b93a392942d782eb9e413e6ef6bf7c8c53884e599a5c174dcb", size = 97225, upload-time = "2026-07-20T02:06:01.769Z" }, + { url = "https://files.pythonhosted.org/packages/15/f0/7688d3f2cfff7590df2af38ec46d969f4281a4dddb08a9ad2eafbcdddf98/yarl-1.24.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92ab3e11448f2ff7bf53c5a26eff0edc086898ec8b21fb154b85839ce1d88075", size = 96751, upload-time = "2026-07-20T02:06:03.676Z" }, + { url = "https://files.pythonhosted.org/packages/05/1a/a851a0f94aaaf379dd4f901bfc80f634280bec51eb260b47363e2a4cd62e/yarl-1.24.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ebb0ec7f17803063d5aeb982f3b1bd2b2f4e4fae6751226cbd6ba1fcfe9e63ff", size = 107960, upload-time = "2026-07-20T02:06:05.699Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a8/faea066c12f9c77ca0de90641f1655f9dd7b412477bf28c76d692f3aecff/yarl-1.24.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:82632daed195dcc8ea664e8556dc9bdbd671960fb3776bd92806ce05792c2448", size = 103500, upload-time = "2026-07-20T02:06:07.556Z" }, + { url = "https://files.pythonhosted.org/packages/fb/9c/1e67084c2a6e2f2db0e3be798328cb3be42c0119b621d25461479a224d21/yarl-1.24.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:53e549287ef628fecba270045c9701b0c564563a9b0577d24a4ec75b8ab8040f", size = 115780, upload-time = "2026-07-20T02:06:09.599Z" }, + { url = "https://files.pythonhosted.org/packages/58/86/1f94664e147474337e3359f52012cf3d02f825f694317b178bfba1078c62/yarl-1.24.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fcd3b77e2f17bbe4ca56ec7bcb07992647d19d0b9c05d84886dcd6f9eb810afd", size = 115308, upload-time = "2026-07-20T02:06:11.352Z" }, + { url = "https://files.pythonhosted.org/packages/0a/43/8e55ae7538ba5f28ccb3c845c6dd4549cf7016d5992e5326512519107cdd/yarl-1.24.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d46b86567dd4e248c6c159fcbcdcce01e0a5c8a7cd2334a0fff759d0fa075b16", size = 110574, upload-time = "2026-07-20T02:06:13.129Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ba/a889ec8765cedcf2ac44dcb02d6a21e4861399b243b263c5f2dde27ee740/yarl-1.24.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f72c74aa99359e27a2ee8d6613fefa28b5f76a983c083074dfc2aaa4ab46213", size = 109914, upload-time = "2026-07-20T02:06:15.243Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c3/e45f821af67b791c2dbbe4a9f4137a1d33f8d386654a05a0c3f47bdfa25d/yarl-1.24.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f45789ce415a7ec0820dc4f82925f9b5f7732070be1dec1f5f23ec381435a24", size = 107712, upload-time = "2026-07-20T02:06:17.443Z" }, + { url = "https://files.pythonhosted.org/packages/02/00/2ab0f42c9857fcb490bfaa6647b14540b53d241ab209f23220b958cc5832/yarl-1.24.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6e73e7fe93f17a7b191f52ec9da9dd8c06a8fe735a1ecbd13b97d1c723bff385", size = 104251, upload-time = "2026-07-20T02:06:19.259Z" }, + { url = "https://files.pythonhosted.org/packages/7a/70/709d9a286e98af2c7fd8e4e6cada658b5c0e30d87dd7e2a63c2fb5767217/yarl-1.24.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4a36f9becdd4c5c52a20c3e9484128b070b1dcfc8944c006f3a528295a359a9c", size = 115319, upload-time = "2026-07-20T02:06:21.207Z" }, + { url = "https://files.pythonhosted.org/packages/5c/6c/3eaa515142991fe84cfc483ff986492211f1978f90161ccefdbec919d09b/yarl-1.24.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:7bcbe0fcf850eae67b6b01749815a4f7161c560a844c769ad7b48fcd99f791c4", size = 109163, upload-time = "2026-07-20T02:06:23.006Z" }, + { url = "https://files.pythonhosted.org/packages/bb/64/711dafce66c323a3144d470547a71c5384c57623308ac8bb5e4b903ac148/yarl-1.24.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:24e861e9630e0daddcb9191fb187f60f034e17a4426f8101279f0c475cd74144", size = 115435, upload-time = "2026-07-20T02:06:24.923Z" }, + { url = "https://files.pythonhosted.org/packages/cf/f3/9b9d0e6d84bea851eb1ba99e4bdc755b86fd813e49ec86dfe42f26befdef/yarl-1.24.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9335a099ad87287c37fe5d1a982ff392fa5efe5d14b40a730b1ec1d6a41382b4", size = 110691, upload-time = "2026-07-20T02:06:26.973Z" }, + { url = "https://files.pythonhosted.org/packages/86/e4/62a06b7e87c4246ac76b7c2da136f972eb4a3a1fc94abb07e7022d6fdb0a/yarl-1.24.5-cp313-cp313-win_amd64.whl", hash = "sha256:2dbe06fc16bc91502bca713704022182e5729861ae00277c3a23354b40929740", size = 97454, upload-time = "2026-07-20T02:06:29.163Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c9/5fc8025b318ab10db413b61056bd0d95c557a70e8df4210c7511f866329c/yarl-1.24.5-cp313-cp313-win_arm64.whl", hash = "sha256:6b8536851f9f65e7f00c7a1d49ba7f2be0ffe2c11555367fc9f50d9f842410a1", size = 92813, upload-time = "2026-07-20T02:06:31.113Z" }, + { url = "https://files.pythonhosted.org/packages/a9/08/5f3085fef9564217074db9dd8573de1795bc82cde61a7ad10b6a7234a569/yarl-1.24.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2729fcfc4f6a596fb0c50f32090400aa9367774ac296a00387e65098c0befa76", size = 135680, upload-time = "2026-07-20T02:06:33.273Z" }, + { url = "https://files.pythonhosted.org/packages/98/35/ba9436e579bd48a8801f2021d842d9ab4994c26e4c7dd3a4c1f1bcb57a9e/yarl-1.24.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ff330d3c30db4eb6b01d79e29d2d0b407a7ecad39cfd9ec993ece57396a2ec0d", size = 97395, upload-time = "2026-07-20T02:06:35.259Z" }, + { url = "https://files.pythonhosted.org/packages/18/a9/a07f76f3c44e02b25cc743af5ef93eef27f7013eadca770451b6a6ccb5db/yarl-1.24.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e42d75862735da90e7fc5a7b23db0c976f737113a54b3c9777a9b665e9cbff75", size = 97223, upload-time = "2026-07-20T02:06:37.216Z" }, + { url = "https://files.pythonhosted.org/packages/77/f7/a9a1d6fa7dd9e388f95b30f6ad3ec4e285f6c8f61f44ce16070c3fcfe414/yarl-1.24.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3732e66413163e72508da9eff9ce9d2846fde51fae45d3605393d3e6cd303e9", size = 108777, upload-time = "2026-07-20T02:06:39.292Z" }, + { url = "https://files.pythonhosted.org/packages/2f/44/e0b86c302471fabd6f02808ecf2ac52b8412b624787849d4bf2cdb466f6f/yarl-1.24.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5b8ee53be440a0cffc991a27be3057e0530122548dbe7c0892df08822fce5ede", size = 103119, upload-time = "2026-07-20T02:06:41.456Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/9c16d180bf8faaf223225eb50e1245870ff1ae0e302a27153988e65c51fd/yarl-1.24.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:af3aefa655adb5869491fa907e652290386800ae99cc50095cba71e2c6aefdca", size = 116471, upload-time = "2026-07-20T02:06:43.696Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8d/b219b9df28a02ce95cfbdd41d2f7caa5669d0ff979c1c9975697145e33c5/yarl-1.24.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2120b96872df4a117cde97d270bac96aea7cc52205d305cf4611df694a487027", size = 115974, upload-time = "2026-07-20T02:06:45.874Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e8/f20557aca240d88e69850ad1ee91756821d094bb1310565c04d25c6682a2/yarl-1.24.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:66410eb6345d467151934b49bfa70fb32f5b35a6140baa40ad97d6436abea2e9", size = 110830, upload-time = "2026-07-20T02:06:47.852Z" }, + { url = "https://files.pythonhosted.org/packages/db/18/199b85109a53eeca64ee19c9cca228287e8e4ab0cc1a09b28f530e65cce0/yarl-1.24.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4af7b7e1be0a69bee8210735fe6dcfc38879adfac6d62e789d53ba432d1ffa41", size = 110054, upload-time = "2026-07-20T02:06:49.84Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/ed28147f8cd7f48c49367c90713b30a555284b6105a6a56f3a05568da795/yarl-1.24.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa139875ff98ab97da323cfadfaff08900d1ad42f1b5087b0b812a55c5a06373", size = 108312, upload-time = "2026-07-20T02:06:51.835Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c5/55e16ae0a5c227cea8df1c6871ba57d614a34243146c05729caf2a1bd9c5/yarl-1.24.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:0055afc45e864b92729ac7600e2d102c17bef060647e74bca75fa84d66b9ff36", size = 103662, upload-time = "2026-07-20T02:06:54.061Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ea/dbd7c2caec459c9a426f18b02688ecbfb58620d0f6a3422d24769fbaf8ab/yarl-1.24.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f0e466ed7511fe9d459a819edbc6c2585c0b6eabde9fa8a8947552468a7a6ef0", size = 116090, upload-time = "2026-07-20T02:06:56.015Z" }, + { url = "https://files.pythonhosted.org/packages/06/84/39ce4ce3059e07fece5fbdbee8c4053406af9aca911ce9fa5f8548aab6af/yarl-1.24.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f141474e85b7e54998ec5180530a7cda99ab29e282fa50e0756d89981a9b43c5", size = 109523, upload-time = "2026-07-20T02:06:57.926Z" }, + { url = "https://files.pythonhosted.org/packages/a9/8b/71ff44137b405c64a7788075669c24010019f57a7464b78c3a6cbee539d9/yarl-1.24.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:e2935f8c39e3b03e83519292d78f075189978f3f4adc15a78144c7c8e2a1cba5", size = 116084, upload-time = "2026-07-20T02:06:59.868Z" }, + { url = "https://files.pythonhosted.org/packages/62/c0/423078fdd4042e1862c11f0ffd977a0ffa393783c12bee94685923bc189e/yarl-1.24.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9d1216a7f6f77836617dba35687c5b78a4170afc3c3f18fc788f785ba26565c4", size = 111006, upload-time = "2026-07-20T02:07:01.907Z" }, + { url = "https://files.pythonhosted.org/packages/cf/52/6daa2ee9d95e5c98b8128f8df91eb692eb423ab274b8cf08db52152fad26/yarl-1.24.5-cp314-cp314-win_amd64.whl", hash = "sha256:5ba4f78df2bcc19f764a4b26a8a4f5049c110090ad5825993aacb052bf8003ad", size = 99215, upload-time = "2026-07-20T02:07:03.852Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0e/464a847d7359e0da75dd9fc5c1d1aa35d0159ea31e5f8e66a3c1c29ff3d0/yarl-1.24.5-cp314-cp314-win_arm64.whl", hash = "sha256:9e4e16c73d717c5cf27626c524d0a2e261ad20e46932b2670f64ad5dde23e26f", size = 94566, upload-time = "2026-07-20T02:07:06.074Z" }, + { url = "https://files.pythonhosted.org/packages/e2/55/e03acc4446772660bc335e86e41ef31e4d0d838fd641531a11a5ee33b493/yarl-1.24.5-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e1ae548a9d901adca07899a4147a7c826bbcc06239d3ce9a59f57886a28a4c88", size = 142533, upload-time = "2026-07-20T02:07:08.284Z" }, + { url = "https://files.pythonhosted.org/packages/ae/71/4acd3a1fc7cf14345cdb302665ecd2097f62c365b4f14ca17d4f37775cf9/yarl-1.24.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ff405d91509d88e8d44129cd87b18d70acd1f0c1aeabd7bc3c46792b1fe2acba", size = 100776, upload-time = "2026-07-20T02:07:10.197Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/cfb76b7fe99686db264bff829779a539d923e7564ffd7ef18da6c54c3774/yarl-1.24.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:47e98aab9d8d82ff682e7b0b5dded33bf138a32b817fcf7fa3b27b2d7c412928", size = 100913, upload-time = "2026-07-20T02:07:12.357Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3f/7116e782992abbd4fb6948488aec72078895e929a23078290739e8396fce/yarl-1.24.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0a658a6d3fafee5c6f63c58f3e785c8c43c93fbc02bf9f2b6663f8185e0971f", size = 106507, upload-time = "2026-07-20T02:07:14.173Z" }, + { url = "https://files.pythonhosted.org/packages/33/90/d4d2d73ee78229cc889872eb8e085d8f5c6f51abdb178409fd9b23cf74fd/yarl-1.24.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4377407001ca3c057773f44d8ddd6358fa5f691407c1ba92210bd3cf8d9e4c95", size = 99219, upload-time = "2026-07-20T02:07:16.019Z" }, + { url = "https://files.pythonhosted.org/packages/3e/fa/a6df1a9bccd644eec00abee0dff4277416222cec435330fd1f2858523ec1/yarl-1.24.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7c0494a31a1ac5461a226e7947a9c9b78c44e1dc7185164fa7e9651557a5d9bc", size = 111804, upload-time = "2026-07-20T02:07:18.141Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9e/7b2a1f4bcc20e9447156dd2b1c4d01f70d9df0759025ee7d09a84ffae134/yarl-1.24.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a7cff474ab7cd149765bb784cf6d78b32e18e20473fb7bda860bce98ab58e9da", size = 110943, upload-time = "2026-07-20T02:07:20.06Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/22c92affb0f9b623ca753d27d968b5625b868f12c6378d049d55ae247643/yarl-1.24.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbb833ccacdb5519eff9b8b71ee618cc2801c878e77e288775d77c3a2ced858a", size = 108251, upload-time = "2026-07-20T02:07:22.217Z" }, + { url = "https://files.pythonhosted.org/packages/45/44/5769b96298c1e195fb412997b6090af2a84105cf59c17613558a2d011d1f/yarl-1.24.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:82f75e05912e84b7a0fe57075d9c59de3cb352b928330f2eb69b2e1f54c3e1f0", size = 106025, upload-time = "2026-07-20T02:07:24.083Z" }, + { url = "https://files.pythonhosted.org/packages/4c/40/009e8e791fd9762c0e1567e69248acb4f49064597e1680874c16dd8bb798/yarl-1.24.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:16a2f5010280020e90f5330257e6944bc33e73593b136cc5a241e6c1dc292498", size = 106573, upload-time = "2026-07-20T02:07:26.248Z" }, + { url = "https://files.pythonhosted.org/packages/20/c6/b7480578f8a0a80946f36ad6df547ecec704f9ba69d2de60f8aa6f1c1cbf/yarl-1.24.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ffcd54362564dc1a30fb74d8b8a6e5a6b11ebd5e27266adc3b7427a21a6c9104", size = 100751, upload-time = "2026-07-20T02:07:28.098Z" }, + { url = "https://files.pythonhosted.org/packages/d4/27/4476f3360b91a48c5cf125e91f59a3bd35299d84a431a258d57f5977bb11/yarl-1.24.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0465ec8cedc2349b97a6b595ace64084a50c6e839eca40aa0626f38b8350e331", size = 111643, upload-time = "2026-07-20T02:07:30.88Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4b/5cdd3e5ee944e8af31e52f6cd3d3af5fd7b937e036ccbbba2c9ffebede95/yarl-1.24.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4db9aecb141cb7a5447171b57aa1ed3a8fee06af40b992ffc31206c0b0121550", size = 106312, upload-time = "2026-07-20T02:07:33.06Z" }, + { url = "https://files.pythonhosted.org/packages/18/86/f406b0c2a6f99575de2da671ef47aa06f89a5be83a27a46971c3b86cecdb/yarl-1.24.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f540c013589084679a6c7fac07096b10159737918174f5dfc5e11bf5bca4dfe6", size = 110379, upload-time = "2026-07-20T02:07:35.155Z" }, + { url = "https://files.pythonhosted.org/packages/f0/6c/9f3adfbd3b30b4fa0f7ccb3a83eba2c1152d3fff554d535e640ba0f7ba2b/yarl-1.24.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a61834fb15d81322d872eaafd333838ae7c9cea84067f232656f75965933d047", size = 108497, upload-time = "2026-07-20T02:07:37.35Z" }, + { url = "https://files.pythonhosted.org/packages/dd/37/91eb2e5ca883a529c1b390348a74cd9fc0512171727f547ce70bfe02be5c/yarl-1.24.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5c88e5815a49d289e599f3513aa7fde0bc2092ff188f99c940f007f90f53d104", size = 102450, upload-time = "2026-07-20T02:07:39.578Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f4/ed5c402ac8fde4403ed3366c2716bfddc8a6677ebd59f3d62772cc7fe468/yarl-1.24.5-cp314-cp314t-win_arm64.whl", hash = "sha256:cf139c02f5f23ef6532040a30ff662c00a318c952334f211046b8e60b7f17688", size = 97222, upload-time = "2026-07-20T02:07:41.55Z" }, + { url = "https://files.pythonhosted.org/packages/61/02/962c1cbfc401a30c1d034dc67ff395f64b52302c6d62de556c1fca99acc0/yarl-1.24.5-py3-none-any.whl", hash = "sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7", size = 58612, upload-time = "2026-07-20T02:07:43.461Z" }, +] From 5b2665de51dd807ee41eedee48d00dc944c4a300 Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 6 Aug 2026 19:18:11 -0700 Subject: [PATCH 44/48] fix!: make the conformance gate demand an explicit document The gate could not fail. `scripts/validate-stapi-fastapi` ran `test 0`, which always succeeds, and then checked `if [ $result ]` -- a one-argument test that is true for any non-empty string, so an exit code of 1 still printed "Validated OK!". Underneath it, `tests/validate_api.py` was never collected by pytest at all, because the name does not match `test_*.py`: the validation suite silently ran zero tests. The file is renamed `test_validate_api.py`, which is the substantive fix. With it running, three things in it turn out to be broken: `assert schema.validate()` asserts on a method that returns `None`, the checks were invoked directly with the wrong signature instead of being passed to `call_and_validate`, and hypothesis filters out every generated POST body -- now suppressed explicitly, with a note that it is a generator limitation and must not mask the contract checks. The document to validate against is now supplied by the caller rather than fetched from a hard-coded GitHub URL, so this checks conformance to the spec rather than that an export matches the app it came from. The script takes it as a mandatory argument and exits 2 without one; the console script grows a real CLI and resolves the suite from `__file__` rather than the cwd. Server cleanup moves to a trap, since a bare `kill` with no PIDs fails under `set -e` and would fail a run that passed. CI drops the step: it has no document to hand the script now that one is required. --- .github/workflows/ci.yaml | 2 - pyproject.toml | 3 + .../src/pystapi_validator/__init__.py | 30 +++++- pystapi-validator/tests/test_validate_api.py | 98 +++++++++++++++++++ pystapi-validator/tests/validate_api.py | 61 ------------ scripts/validate-stapi-fastapi | 33 +++++-- 6 files changed, 156 insertions(+), 71 deletions(-) create mode 100644 pystapi-validator/tests/test_validate_api.py delete mode 100644 pystapi-validator/tests/validate_api.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index fbf9d5f..bcc3af3 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -31,8 +31,6 @@ jobs: run: uv run pre-commit run --all-files - name: Test run: ./scripts/run-tests.sh - - name: Validate test server - run: uv run scripts/validate-stapi-fastapi - name: Docs run: uv run mkdocs build --strict - uses: actions/upload-pages-artifact@v3 diff --git a/pyproject.toml b/pyproject.toml index e743c01..62800e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,6 +81,9 @@ filterwarnings = [ "ignore:The 'app' shortcut is now deprecated.:DeprecationWarning", "ignore:Pydantic serializer warnings:UserWarning", "ignore:jsonschema.exceptions.RefResolutionError is deprecated:DeprecationWarning", + # Both raised by schemathesis<4, which still uses the pre-4.18 jsonschema + # referencing API. Removable once the validator moves to schemathesis 4. + "ignore:jsonschema.RefResolver is deprecated:DeprecationWarning", ] markers = [ "mock_products", diff --git a/pystapi-validator/src/pystapi_validator/__init__.py b/pystapi-validator/src/pystapi_validator/__init__.py index 54fe86a..ac6ddd9 100644 --- a/pystapi-validator/src/pystapi_validator/__init__.py +++ b/pystapi-validator/src/pystapi_validator/__init__.py @@ -1,10 +1,38 @@ +import argparse +import os import sys +from pathlib import Path import pytest +#: The schemathesis suite this CLI drives, resolved from this file so the CLI +#: does not depend on the working directory. +TESTS = Path(__file__).resolve().parents[2] / "tests" + def main() -> None: - sys.exit(pytest.main(["pystapi-validator/tests/validate_api.py"])) + parser = argparse.ArgumentParser( + description="Validate a running STAPI server against an OpenAPI document.", + ) + parser.add_argument("schema", help="path to the OpenAPI document to validate against") + parser.add_argument( + "--base-url", + default="http://localhost:8000", + help="base URL of the running server (default: %(default)s)", + ) + args = parser.parse_args() + + if not Path(args.schema).is_file(): + parser.error(f"no such document: {args.schema}") + if not TESTS.is_dir(): + parser.error(f"test suite not found at {TESTS}; run from a source checkout") + + # read by the suite at import time; unset, it would skip every check and + # still exit 0 + os.environ["STAPI_OPENAPI_SCHEMA"] = args.schema + os.environ["STAPI_BASE_URL"] = args.base_url + + sys.exit(pytest.main([str(TESTS), "-q"])) if __name__ == "__main__": diff --git a/pystapi-validator/tests/test_validate_api.py b/pystapi-validator/tests/test_validate_api.py new file mode 100644 index 0000000..e1c6628 --- /dev/null +++ b/pystapi-validator/tests/test_validate_api.py @@ -0,0 +1,98 @@ +import json +import os +from collections.abc import Generator +from typing import Protocol, cast + +import pluggy +import pytest +import schemathesis +from hypothesis import HealthCheck, settings +from schemathesis.checks import not_a_server_error + +# The OpenAPI-specific checks are only re-exported by schemathesis.checks; +# import them from the module that defines them. +from schemathesis.specs.openapi.checks import ( + content_type_conformance, + negative_data_rejection, + response_headers_conformance, + response_schema_conformance, + status_code_conformance, +) + +schemathesis.experimental.OPEN_API_3_1.enable() + +# The document to validate against, supplied by the caller rather than exported +# from the application under test. +# +# These tests drive a live server, so they are opt-in; skipping at module level +# keeps them out of the ordinary unit-test run without pretending they passed. +SCHEMA_PATH = os.environ.get("STAPI_OPENAPI_SCHEMA") +if SCHEMA_PATH is None: + pytest.skip( + "set STAPI_OPENAPI_SCHEMA and run a server, or use the pystapi-validator CLI", + allow_module_level=True, + ) + +schema = schemathesis.from_path(SCHEMA_PATH) + +BASE_URL = os.environ.get("STAPI_BASE_URL", "http://localhost:8000") + + +# Hypothesis filters out every candidate it generates for the POST bodies. That +# is a limitation of the generator, not a server/spec disagreement, so it must +# not mask the contract checks below. +@settings(suppress_health_check=[HealthCheck.filter_too_much]) +@schema.parametrize() +def test_api(case: schemathesis.Case) -> None: + # Checks take a CheckContext that only schemathesis can build, so hand them + # to call_and_validate rather than invoking them directly. + case.call_and_validate( + base_url=BASE_URL, + checks=( + not_a_server_error, + status_code_conformance, + content_type_conformance, + response_schema_conformance, + response_headers_conformance, + negative_data_rejection, + ), + ) + + +def test_openapi_specification() -> None: + # Raises on an invalid schema; there is no return value to assert on. + schema.validate() + + +class _ResultsSession(Protocol): + """Structural view of the session with the ``results`` attribute these hooks attach to it.""" + + results: dict[str, pytest.TestReport] + + +@pytest.hookimpl(tryfirst=True, hookwrapper=True) +def pytest_runtest_makereport( + item: pytest.Item, call: pytest.CallInfo[None] +) -> Generator[None, pluggy.Result[pytest.TestReport], None]: + outcome = yield + rep = outcome.get_result() + if rep.when == "call": + session = cast(_ResultsSession, item.session) + session.results = getattr(item.session, "results", {}) + session.results[item.nodeid] = rep + + +def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None: + if hasattr(session, "results"): + with open("test_results.json", "w") as f: + json.dump( + { + nodeid: { + "outcome": rep.outcome, + "longrepr": str(rep.longrepr) if rep.longrepr else None, + } + for nodeid, rep in session.results.items() + }, + f, + indent=2, + ) diff --git a/pystapi-validator/tests/validate_api.py b/pystapi-validator/tests/validate_api.py deleted file mode 100644 index 6a31d20..0000000 --- a/pystapi-validator/tests/validate_api.py +++ /dev/null @@ -1,61 +0,0 @@ -import json - -import pytest -import schemathesis -from schemathesis.checks import ( - content_type_conformance, - negative_data_rejection, - not_a_server_error, - response_headers_conformance, - response_schema_conformance, - status_code_conformance, -) - -schemathesis.experimental.OPEN_API_3_1.enable() - -SCHEMA_URL = "https://raw.githubusercontent.com/stapi-spec/stapi-spec/refs/heads/main/openapi.yaml" -schema = schemathesis.from_uri(SCHEMA_URL) - -BASE_URL = "http://localhost:8000" - - -@schema.parametrize() -def test_api(case): - response = case.call_and_validate(base_url=BASE_URL) - case.validate_response(response) - - not_a_server_error(response, case) - status_code_conformance(response, case) - content_type_conformance(response, case) - response_schema_conformance(response, case) - response_headers_conformance(response, case) - negative_data_rejection(response, case) - - -def test_openapi_specification(): - assert schema.validate() - - -@pytest.hookimpl(tryfirst=True, hookwrapper=True) -def pytest_runtest_makereport(item, call): - outcome = yield - rep = outcome.get_result() - if rep.when == "call": - item.session.results = getattr(item.session, "results", {}) - item.session.results[item.nodeid] = rep - - -def pytest_sessionfinish(session, exitstatus): - if hasattr(session, "results"): - with open("test_results.json", "w") as f: - json.dump( - { - nodeid: { - "outcome": rep.outcome, - "longrepr": str(rep.longrepr) if rep.longrepr else None, - } - for nodeid, rep in session.results.items() - }, - f, - indent=2, - ) diff --git a/scripts/validate-stapi-fastapi b/scripts/validate-stapi-fastapi index 24507af..77058ac 100755 --- a/scripts/validate-stapi-fastapi +++ b/scripts/validate-stapi-fastapi @@ -2,20 +2,39 @@ set -e +# Validate the stapi-fastapi test server against an OpenAPI document. +# +# The document must be supplied, so that this checks conformance to the spec +# rather than that an export matches the app it came from. +# +# Usage: +# scripts/validate-stapi-fastapi + +if [ $# -ne 1 ]; then + echo "usage: $0 " >&2 + exit 2 +fi + +schema=$1 +if [ ! -f "$schema" ]; then + echo "$0: no such document: $schema" >&2 + exit 2 +fi + scripts="$(cd -- "$(dirname "$0")" >/dev/null 2>&1 ; pwd -P )" root=$(dirname "$scripts") uv run --package stapi-fastapi fastapi dev "$root/stapi-fastapi/tests/application.py" >/dev/null 2>&1 & server_pid=$! +# tolerate the server having already exited: a bare `kill` with no arguments +# fails, and under `set -e` that would fail a run that passed +trap 'kill $(pgrep -P "$server_pid") "$server_pid" 2>/dev/null || true' EXIT set +e -"$scripts"/wait-for-it.sh localhost:8000 -- test 0 # TODO update to validate +"$scripts"/wait-for-it.sh localhost:8000 -- \ + env STAPI_OPENAPI_SCHEMA="$schema" STAPI_BASE_URL="http://localhost:8000" \ + uv run --package pystapi-validator --directory "$root/pystapi-validator" pytest -q result=$? set -e -kill $(pgrep -P $server_pid) -if [ $result ]; then - echo "Validated OK!" -else - exit $result -fi +exit "$result" From 7f17cd8ba1c96cc6b594704a43e6d38246ca49b6 Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 6 Aug 2026 19:19:37 -0700 Subject: [PATCH 45/48] docs: state the pagination-token contract on the backend protocols `PaginationTokenError` is the one failure a backend returns that the handlers translate into something other than a 500, and none of the six protocol docstrings said so. Each paginated backend now documents it alongside the `Success` and generic-`Failure` cases, so an implementer can see the whole contract without reading the router. Also settles helper placement -- `register_route` after the link builders, `opportunity_search_record_self_link` beside the links helper that calls it, the `supports_*` properties together -- and drops two stale docstrings naming the Python parameter where the published camelCase name belongs. No behaviour change. --- .../stapi_fastapi/backends/product_backend.py | 11 ++++---- .../stapi_fastapi/backends/root_backend.py | 6 +++++ .../src/stapi_fastapi/routers/base.py | 8 +++--- .../stapi_fastapi/routers/product_router.py | 10 ++++--- .../src/stapi_fastapi/routers/root_router.py | 26 +++++++++---------- stapi-pydantic/src/stapi_pydantic/__init__.py | 4 +-- 6 files changed, 36 insertions(+), 29 deletions(-) diff --git a/stapi-fastapi/src/stapi_fastapi/backends/product_backend.py b/stapi-fastapi/src/stapi_fastapi/backends/product_backend.py index 2d0cd65..8ed544b 100644 --- a/stapi-fastapi/src/stapi_fastapi/backends/product_backend.py +++ b/stapi-fastapi/src/stapi_fastapi/backends/product_backend.py @@ -33,12 +33,11 @@ request (Request): FastAPI's Request object. Returns: - A tuple containing a list of opportunities and a pagination token. - - - Should return returns.result.Success[tuple[list[Opportunity], returns.maybe.Some[str]]] - if including a pagination token - - Should return returns.result.Success[tuple[list[Opportunity], returns.maybe.Nothing]] - if not including a pagination token + - Should return returns.result.Success[stapi_fastapi.pagination.Page[Opportunity]]. + The page's `next_token` becomes the collection's `next` link and its + `number_matched` becomes the collection's `numberMatched`. + - Returning returns.result.Failure[stapi_fastapi.errors.PaginationTokenError] + will result in a 404, which is how an unusable pagination token is reported. - Returning returns.result.Failure[Exception] will result in a 500. Note: diff --git a/stapi-fastapi/src/stapi_fastapi/backends/root_backend.py b/stapi-fastapi/src/stapi_fastapi/backends/root_backend.py index 7a618f1..0f61c60 100644 --- a/stapi-fastapi/src/stapi_fastapi/backends/root_backend.py +++ b/stapi-fastapi/src/stapi_fastapi/backends/root_backend.py @@ -29,6 +29,8 @@ - Should return returns.result.Success[stapi_fastapi.pagination.Page[Order]]. The page's `next_token` becomes the collection's `next` link and its `number_matched` becomes the collection's `numberMatched`. + - Returning returns.result.Failure[stapi_fastapi.errors.PaginationTokenError] + will result in a 404, which is how an unusable pagination token is reported. - Returning returns.result.Failure[Exception] will result in a 500. """ @@ -68,6 +70,8 @@ - Should return returns.result.Success[returns.maybe.Some[stapi_fastapi.pagination.Page[OrderStatus]]] if the order is found. - Should return returns.result.Success[returns.maybe.Nothing] if the order is not found or if access is denied. + - Returning returns.result.Failure[stapi_fastapi.errors.PaginationTokenError] + will result in a 404, which is how an unusable pagination token is reported. - Returning returns.result.Failure[Exception] will result in a 500. """ @@ -86,6 +90,8 @@ Returns: - Should return returns.result.Success[stapi_fastapi.pagination.Page[OpportunitySearchRecord]]. + - Returning returns.result.Failure[stapi_fastapi.errors.PaginationTokenError] + will result in a 404, which is how an unusable pagination token is reported. - Returning returns.result.Failure[Exception] will result in a 500. """ diff --git a/stapi-fastapi/src/stapi_fastapi/routers/base.py b/stapi-fastapi/src/stapi_fastapi/routers/base.py index 29e44e4..c21d626 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/base.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/base.py @@ -131,6 +131,10 @@ def route_name(self, name: str) -> str: """The registered name of the route this router serves under `name`.""" return ":".join((*self.route_name_prefix, name)) + def register_route(self, route: Route) -> None: + """Register `route` on this router.""" + self.add_api_route(**route.to_api_route(self.route_name(route.name))) + def self_link(self, request: Request, name: str, media_type: str = TYPE_JSON, **path_params: Any) -> Link: """A `self` link for the current request, query params and all, so a paged response points at the page actually returned. @@ -176,7 +180,3 @@ def page_links( if next_token is not None: links.append(self.pagination_link(request, name, next_token, limit, media_type=media_type, **path_params)) return links - - def register_route(self, route: Route) -> None: - """Register `route` on this router.""" - self.add_api_route(**route.to_api_route(self.route_name(route.name))) diff --git a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py index 24d1517..2256f4c 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py @@ -179,9 +179,9 @@ async def _create_order( tag=Tag.ORDERS, path="/orders", endpoint=_create_order, - methods=("POST",), errors=BAD_REQUEST | SERVER_ERROR, summary="Create an order for the product", + methods=("POST",), response_class=GeoJSONResponse, status_code=status.HTTP_201_CREATED, responses={ @@ -474,16 +474,18 @@ def order_link(self, request: Request, opp_req: OpportunityRequest) -> Link: ) def search_pagination_link(self, request: Request, opp_req: OpportunityRequest, pagination_token: str) -> Link: - """A `next` link for an opportunity search, whose parameters are a POST body. + """The `next` link of a paged synchronous opportunity search. - Distinct from the base router's query-parameter one: paging a search - means re-POSTing the search body with a new token, not following a URL. + Spelled differently from the shared `pagination_link` because search is a + POST: the next page is identified by a body, not by query params. """ body = opp_req.body() body["next"] = pagination_token return Link( href=request.url, rel="next", + # a next link only appears on the synchronous result, which is an + # Opportunity Collection type=TYPE_GEOJSON, method="POST", body=body, diff --git a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py index 1000681..64bf637 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py @@ -153,7 +153,7 @@ def __init__( ) ) - if self.supports_order_statuses: + if self.__get_order_statuses is not None: _conformances.add(API_CONFORMANCE.order_statuses) self.register_route( Route( @@ -293,7 +293,7 @@ async def get_orders( async def get_order(self, order_id: OrderIdPath, request: Request) -> Order[OrderStatus]: """ - Get details for order with `order_id`. + Get details for order with `orderId`. """ match await self._get_order(order_id, request): case Success(Some(order)): @@ -411,7 +411,7 @@ async def get_opportunity_search_record( self, search_record_id: SearchRecordIdPath, request: Request ) -> OpportunitySearchRecord: """ - Get the Opportunity Search Record with `search_record_id`. + Get the Opportunity Search Record with `searchRecordId`. """ match await self._get_opportunity_search_record(search_record_id, request): case Success(Some(search_record)): @@ -479,6 +479,11 @@ def generate_opportunity_search_record_href(self, request: Request, search_recor searchRecordId=search_record_id, ) + def opportunity_search_record_self_link( + self, opportunity_search_record: OpportunitySearchRecord, request: Request + ) -> Link: + return json_link("self", self.generate_opportunity_search_record_href(request, opportunity_search_record.id)) + def generate_opportunity_search_record_statuses_href(self, request: Request, search_record_id: str) -> URL: return self.url_for( request, @@ -500,11 +505,6 @@ def opportunity_search_record_links( ) return links - def opportunity_search_record_self_link( - self, opportunity_search_record: OpportunitySearchRecord, request: Request - ) -> Link: - return json_link("self", self.generate_opportunity_search_record_href(request, opportunity_search_record.id)) - @property def _get_order_statuses(self) -> GetOrderStatuses: # type: ignore if not self.__get_order_statuses: @@ -529,11 +529,6 @@ def _get_opportunity_search_record_statuses(self) -> GetOpportunitySearchRecordS raise AttributeError("Root router does not support async opportunity search status history") return self.__get_opportunity_search_record_statuses - @property - def supports_opportunity_search_record_statuses(self) -> bool: - """Whether the search-record-statuses endpoint is registered.""" - return self.supports_async_opportunity_search and self.__get_opportunity_search_record_statuses is not None - @property def supports_order_statuses(self) -> bool: """Whether the order-statuses endpoint is registered.""" @@ -542,3 +537,8 @@ def supports_order_statuses(self) -> bool: @property def supports_async_opportunity_search(self) -> bool: return self.__get_opportunity_search_records is not None and self.__get_opportunity_search_record is not None + + @property + def supports_opportunity_search_record_statuses(self) -> bool: + """Whether the search-record-statuses endpoint is registered.""" + return self.supports_async_opportunity_search and self.__get_opportunity_search_record_statuses is not None diff --git a/stapi-pydantic/src/stapi_pydantic/__init__.py b/stapi-pydantic/src/stapi_pydantic/__init__.py index b8f9e25..043ca5b 100644 --- a/stapi-pydantic/src/stapi_pydantic/__init__.py +++ b/stapi-pydantic/src/stapi_pydantic/__init__.py @@ -45,8 +45,8 @@ "Link", "Opportunity", "OpportunityCollection", - "OpportunityRequest", "OpportunityProperties", + "OpportunityRequest", "OpportunitySearchRecord", "OpportunitySearchRecordCollection", "OpportunitySearchStatus", @@ -55,8 +55,8 @@ "Order", "OrderCollection", "OrderParameters", - "OrderRequest", "OrderProperties", + "OrderRequest", "OrderStatus", "OrderStatusCode", "OrderStatusCollection", From ef25672e8b14df231ce6f9ed3ec4529e83b0841f Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 6 Aug 2026 19:20:53 -0700 Subject: [PATCH 46/48] build: type-check the test suites mypy ran over `src/` only, so the test suites -- which are where the models and routers are actually exercised -- were unchecked. Pointing it at whole packages does not work in one invocation: each package carries its own top-level `tests` package, and mypy collides on the module name. `scripts/run-mypy.sh` runs one package at a time, mirroring `run-tests.sh`, and collects failures so it reports every package rather than bailing on the first. pre-commit calls it in place of the bare `mypy`. The fallout is annotations across the suites: return types, an `AssertLink` protocol for the `assert_link` fixture's call signature, `cast` where a fixture types something more loosely than its use needs. The mypy override for `pygeofilter` is replaced by one for `respx` -- pygeofilter is no longer a dependency, and respx ships no stubs and is imported only from the client's tests, which are now checked. --- .pre-commit-config.yaml | 2 +- pyproject.toml | 2 +- scripts/run-mypy.sh | 28 ++ stapi-fastapi/tests/application.py | 2 + stapi-fastapi/tests/backends.py | 45 +- stapi-fastapi/tests/conftest.py | 32 +- stapi-fastapi/tests/shared.py | 63 ++- stapi-fastapi/tests/test_capabilities.py | 52 +- stapi-fastapi/tests/test_datetime_interval.py | 10 +- stapi-fastapi/tests/test_openapi.py | 38 +- stapi-fastapi/tests/test_opportunity.py | 12 +- stapi-fastapi/tests/test_opportunity_async.py | 465 +++++++++--------- stapi-fastapi/tests/test_order.py | 87 ++-- stapi-fastapi/tests/test_pagination.py | 159 +++++- stapi-fastapi/tests/test_product.py | 120 +++-- stapi-fastapi/tests/test_root.py | 4 +- stapi-pydantic/tests/test_opportunity.py | 134 ++--- stapi-pydantic/tests/test_order.py | 113 +++-- stapi-pydantic/tests/test_product.py | 8 +- stapi-pydantic/tests/test_shared.py | 152 +++--- 20 files changed, 894 insertions(+), 634 deletions(-) create mode 100755 scripts/run-mypy.sh diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d27dcb5..e56c582 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,7 +17,7 @@ repos: - id: mypy name: Check typing with mypy - entry: uv run mypy + entry: ./scripts/run-mypy.sh language: system types: [python] pass_filenames: false diff --git a/pyproject.toml b/pyproject.toml index 62800e8..3066f22 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,7 +66,7 @@ files = [ ] [[tool.mypy.overrides]] -module = "pygeofilter.parsers.*" +module = "respx.*" ignore_missing_imports = true [tool.pymarkdown] diff --git a/scripts/run-mypy.sh b/scripts/run-mypy.sh new file mode 100755 index 0000000..c944518 --- /dev/null +++ b/scripts/run-mypy.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +# set -x # print each command before executing + +# Each package carries its own `tests` package, so a single mypy invocation +# over all of them collides on the module name. Check one package at a time, +# mirroring run-tests.sh. + +failed=() + +for path in stapi-fastapi pystapi-validator pystapi-client stapi-pydantic; do + name=$(basename "$path") + + set +e + echo "Type checking package $name" + uv run mypy "$path" + code=$? + set -e + + if [ "$code" -ne 0 ]; then + failed+=("$name") + fi +done + +if [ "${#failed[@]}" -ne 0 ]; then + echo "mypy failed in: ${failed[*]}" + exit 1 +fi diff --git a/stapi-fastapi/tests/application.py b/stapi-fastapi/tests/application.py index 6a34a5d..3d035d5 100644 --- a/stapi-fastapi/tests/application.py +++ b/stapi-fastapi/tests/application.py @@ -8,6 +8,7 @@ from tests.backends import ( mock_get_opportunity_search_record, + mock_get_opportunity_search_record_statuses, mock_get_opportunity_search_records, mock_get_order, mock_get_order_statuses, @@ -35,6 +36,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[dict[str, Any]]: get_order_statuses=mock_get_order_statuses, get_opportunity_search_records=mock_get_opportunity_search_records, get_opportunity_search_record=mock_get_opportunity_search_record, + get_opportunity_search_record_statuses=mock_get_opportunity_search_record_statuses, conformances=[API.core], ) root_router.add_product(product_test_spotlight_sync_opportunity) diff --git a/stapi-fastapi/tests/backends.py b/stapi-fastapi/tests/backends.py index ec0f6fd..2b5b631 100644 --- a/stapi-fastapi/tests/backends.py +++ b/stapi-fastapi/tests/backends.py @@ -1,19 +1,24 @@ from datetime import UTC, datetime +from typing import TypeAlias from uuid import uuid4 from fastapi import Request from returns.maybe import Maybe, Nothing, Some from returns.result import Failure, ResultE, Success -from stapi_fastapi import Page from stapi_fastapi.errors import PaginationTokenError +from stapi_fastapi.pagination import Page from stapi_fastapi.routers.product_router import ProductRouter from stapi_pydantic import ( + Geometry, Opportunity, + OpportunityCollection, + OpportunityProperties, OpportunityRequest, OpportunitySearchRecord, OpportunitySearchStatus, OpportunitySearchStatusCode, Order, + OrderParameters, OrderProperties, OrderRequest, OrderStatus, @@ -21,17 +26,24 @@ StoredOrderRequest, ) +# These mocks are shared by every test product, so they are parameterized at +# the generic bounds rather than at one product's concrete geometry/properties +# models. +AnyOpportunity: TypeAlias = Opportunity[Geometry, OpportunityProperties] +AnyOpportunityCollection: TypeAlias = OpportunityCollection[Geometry, OpportunityProperties] + def _offset(token: str) -> int: - """Read a page offset out of a pagination token. + """The offset a pagination token names, as these mocks encode it. - The mocks encode the offset in the token itself, so anything unparseable is - a token that identifies no page rather than an incidental error. + The failure is reported as a `PaginationTokenError` rather than the bare + `ValueError` `int()` raises, so that the other `ValueError`s these backends + can raise stay 500s. """ try: return int(token) except ValueError: - raise PaginationTokenError(f"unusable pagination token: {token!r}") from None + raise PaginationTokenError(f"not a pagination token: {token!r}") from None async def mock_get_orders( @@ -102,12 +114,14 @@ async def mock_get_order_statuses( return Failure(e) -async def mock_create_order(product_router: ProductRouter, payload: OrderRequest, request: Request) -> ResultE[Order]: +async def mock_create_order( + product_router: ProductRouter, payload: OrderRequest[OrderParameters], request: Request +) -> ResultE[Order]: """ Create a new order. """ try: - status = OrderStatus( + status: OrderStatus = OrderStatus( timestamp=datetime.now(UTC), status_code=OrderStatusCode.received, ) @@ -141,7 +155,7 @@ async def mock_search_opportunities( next: str | None, limit: int, request: Request, -) -> ResultE[Page[Opportunity]]: +) -> ResultE[Page[AnyOpportunity]]: try: start = 0 limit = min(limit, 100) @@ -173,7 +187,7 @@ async def mock_search_opportunities_async( request: Request, ) -> ResultE[OpportunitySearchRecord]: try: - received_status = OpportunitySearchStatus( + received_status: OpportunitySearchStatus = OpportunitySearchStatus( timestamp=datetime.now(UTC), status_code=OpportunitySearchStatusCode.received, ) @@ -196,7 +210,7 @@ async def mock_get_opportunity_collection( next: str | None, limit: int, request: Request, -) -> ResultE[Maybe[Page[Opportunity]]]: +) -> ResultE[Maybe[Page[AnyOpportunity]]]: try: collection = request.state._opportunities_db.get_opportunity_collection(opportunity_collection_id) if collection is None: @@ -260,18 +274,19 @@ async def mock_get_opportunity_search_record( async def mock_get_opportunity_search_record_statuses( - search_record_id: str, - next: str | None, - limit: int, - request: Request, + search_record_id: str, next: str | None, limit: int, request: Request ) -> ResultE[Maybe[Page[OpportunitySearchStatus]]]: try: statuses = request.state._opportunities_db.get_search_record_statuses(search_record_id) if statuses is None: return Success(Nothing) - start = _offset(next) if next else 0 + start = 0 + limit = min(limit, 100) + if next: + start = _offset(next) end = start + limit + return Success( Some( Page( diff --git a/stapi-fastapi/tests/conftest.py b/stapi-fastapi/tests/conftest.py index c6cccae..53b6ffd 100644 --- a/stapi-fastapi/tests/conftest.py +++ b/stapi-fastapi/tests/conftest.py @@ -7,15 +7,14 @@ import pytest from fastapi import FastAPI from fastapi.testclient import TestClient +from stapi_fastapi.conformance import API from stapi_fastapi.models.product import ( Product, ) from stapi_fastapi.routers.root_router import RootRouter -from stapi_pydantic import ( - Opportunity, -) from .backends import ( + AnyOpportunity, mock_get_opportunity_search_record, mock_get_opportunity_search_record_statuses, mock_get_opportunity_search_records, @@ -24,6 +23,7 @@ mock_get_orders, ) from .shared import ( + AssertLink, InMemoryOpportunityDB, InMemoryOrderDB, create_mock_opportunity, @@ -40,9 +40,11 @@ def base_url() -> Iterator[str]: @pytest.fixture -def mock_products(request) -> list[Product]: - if request.node.get_closest_marker("mock_products") is not None: - return request.node.get_closest_marker("mock_products").args[0] +def mock_products(request: pytest.FixtureRequest) -> list[Product]: + marker = request.node.get_closest_marker("mock_products") + if marker is not None: + marked_products: list[Product] = marker.args[0] + return marked_products return [ product_test_spotlight_sync_opportunity, product_test_satellite_provider_sync_opportunity, @@ -50,7 +52,7 @@ def mock_products(request) -> list[Product]: @pytest.fixture -def mock_opportunities() -> list[Opportunity]: +def mock_opportunities() -> list[AnyOpportunity]: return [create_mock_opportunity()] @@ -75,7 +77,7 @@ def _root_router(overrides: dict[str, Any], **defaults: Any) -> RootRouter: def stapi_client( mock_products: list[Product], base_url: str, - mock_opportunities: list[Opportunity], + mock_opportunities: list[AnyOpportunity], root_router_kwargs: dict[str, Any], ) -> Generator[TestClient, None, None]: @asynccontextmanager @@ -93,6 +95,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[dict[str, Any]]: get_orders=mock_get_orders, get_order=mock_get_order, get_order_statuses=mock_get_order_statuses, + conformances=[API.core], ) for mock_product in mock_products: @@ -109,7 +112,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[dict[str, Any]]: def stapi_client_async_opportunity( mock_products: list[Product], base_url: str, - mock_opportunities: list[Opportunity], + mock_opportunities: list[AnyOpportunity], root_router_kwargs: dict[str, Any], ) -> Generator[TestClient, None, None]: @asynccontextmanager @@ -131,6 +134,11 @@ async def lifespan(app: FastAPI) -> AsyncIterator[dict[str, Any]]: get_opportunity_search_records=mock_get_opportunity_search_records, get_opportunity_search_record=mock_get_opportunity_search_record, get_opportunity_search_record_statuses=mock_get_opportunity_search_record_statuses, + conformances=[ + API.core, + API.searches_opportunity, + API.searches_opportunity_statuses, + ], ) for mock_product in mock_products: @@ -155,7 +163,7 @@ def url_for(value: str) -> str: @pytest.fixture -def assert_link(url_for) -> Callable: +def assert_link(url_for: Callable[[str], str]) -> AssertLink: def _assert_link( req: str, body: dict[str, Any], @@ -163,7 +171,7 @@ def _assert_link( path: str, media_type: str = "application/json", method: str | None = None, - ): + ) -> None: link = find_link(body["links"], rel) assert link, f"{req} Link[rel={rel}] should exist" assert link["type"] == media_type @@ -180,7 +188,7 @@ def limit() -> int: @pytest.fixture -def opportunity_search(limit) -> dict[str, Any]: +def opportunity_search(limit: int) -> dict[str, Any]: now = datetime.now(UTC) end = now + timedelta(days=5) format = "%Y-%m-%dT%H:%M:%S.%f%z" diff --git a/stapi-fastapi/tests/shared.py b/stapi-fastapi/tests/shared.py index 48bfa9b..a40a624 100644 --- a/stapi-fastapi/tests/shared.py +++ b/stapi-fastapi/tests/shared.py @@ -1,7 +1,7 @@ from collections import defaultdict from copy import deepcopy from datetime import UTC, datetime, timedelta -from typing import Any, Literal, Self, TypeAlias +from typing import Any, Literal, Protocol, Self, TypeAlias from urllib.parse import parse_qs, urlparse from uuid import uuid4 @@ -16,7 +16,6 @@ from stapi_fastapi.models.product import Product from stapi_pydantic import ( Opportunity, - OpportunityCollection, OpportunityProperties, OpportunitySearchRecord, OpportunitySearchStatus, @@ -29,6 +28,8 @@ ) from .backends import ( + AnyOpportunity, + AnyOpportunityCollection, mock_create_order, mock_get_opportunity_collection, mock_search_opportunities, @@ -42,6 +43,20 @@ def find_link(links: list[link_dict], rel: str) -> link_dict | None: return next((link for link in links if link["rel"] == rel), None) +class AssertLink(Protocol): + """Call signature of the `assert_link` fixture.""" + + def __call__( + self, + req: str, + body: dict[str, Any], + rel: str, + path: str, + media_type: str = "application/json", + method: str | None = None, + ) -> None: ... + + class InMemoryOrderDB: def __init__(self) -> None: self._orders: dict[str, Order] = {} @@ -66,15 +81,13 @@ def put_order_status(self, order_id: str, status: OrderStatus) -> None: class InMemoryOpportunityDB: def __init__(self) -> None: self._search_records: dict[str, OpportunitySearchRecord] = {} - self._statuses: defaultdict[str, list[OpportunitySearchStatus]] = defaultdict(list) - self._collections: dict[str, OpportunityCollection] = {} + self._statuses: dict[str, list[OpportunitySearchStatus]] = defaultdict(list) + self._collections: dict[str, AnyOpportunityCollection] = {} def get_search_record(self, search_id: str) -> OpportunitySearchRecord | None: return deepcopy(self._search_records.get(search_id)) def get_search_record_statuses(self, search_id: str) -> list[OpportunitySearchStatus] | None: - # None only when the record is unknown: a known record always has at - # least the status it was created with. if search_id not in self._search_records: return None return deepcopy(self._statuses[search_id]) @@ -83,13 +96,14 @@ def get_search_records(self) -> list[OpportunitySearchRecord]: return deepcopy(list(self._search_records.values())) def put_search_record(self, search_record: OpportunitySearchRecord) -> None: + """Store a search record, accumulating its status history.""" self._search_records[search_record.id] = deepcopy(search_record) self._statuses[search_record.id].append(deepcopy(search_record.status)) - def get_opportunity_collection(self, collection_id) -> OpportunityCollection | None: + def get_opportunity_collection(self, collection_id: str) -> AnyOpportunityCollection | None: return deepcopy(self._collections.get(collection_id)) - def put_opportunity_collection(self, collection: OpportunityCollection) -> None: + def put_opportunity_collection(self, collection: AnyOpportunityCollection) -> None: if collection.id is None: raise ValueError("collection must have an id") self._collections[collection.id] = deepcopy(collection) @@ -125,7 +139,6 @@ class MyOrderParameters(OrderParameters): description="A provider for Test data", roles=[ProviderRole.producer], # Example role url="https://test-provider.example.com", # Must be a valid URL - conforms_to=[PRODUCT.geojson_point], ) product_test_spotlight = Product( @@ -183,7 +196,6 @@ class MyOrderParameters(OrderParameters): conforms_to=[PRODUCT.geojson_point], ) - # Declares the opportunity conformance classes itself, the way a provider # publishing an async-search product would. What is actually advertised must # still depend on which routes the router ends up registering. @@ -242,7 +254,28 @@ class MyOrderParameters(OrderParameters): ) -def create_mock_opportunity() -> Opportunity: +# Declares no geojson conformance, which a Product must do to say what geometry +# it accepts. Registering it is an error. +product_test_spotlight_no_geojson_conformance = Product( + id="test-spotlight-no-geojson", + title="Test Spotlight Product", + description="Test product declaring no geojson conformance", + license="CC-BY-4.0", + keywords=["test", "satellite"], + providers=[provider], + links=[], + create_order=mock_create_order, + search_opportunities=mock_search_opportunities, + search_opportunities_async=None, + get_opportunity_collection=None, + queryables=MyProductQueryables, + opportunity_properties=MyOpportunityProperties, + order_parameters=MyOrderParameters, + conforms_to=[PRODUCT.opportunities], +) + + +def create_mock_opportunity() -> AnyOpportunity: now = datetime.now(UTC) # Use timezone-aware datetime start = now end = start + timedelta(days=5) @@ -272,10 +305,10 @@ def pagination_tester( method: str, limit: int, target: str, - expected_returns: list, - body: dict | None = None, + expected_returns: list[dict[str, Any]], + body: dict[str, Any] | None = None, ) -> None: - retrieved = [] + retrieved: list[dict[str, Any]] = [] res = make_request(stapi_client, url, method, body, limit) assert res.status_code == status.HTTP_200_OK @@ -311,7 +344,7 @@ def make_request( stapi_client: TestClient, url: str, method: str, - body: dict | None, + body: dict[str, Any] | None, limit: int, ) -> Response: """request wrapper for pagination tests""" diff --git a/stapi-fastapi/tests/test_capabilities.py b/stapi-fastapi/tests/test_capabilities.py index 93cce54..3d44df1 100644 --- a/stapi-fastapi/tests/test_capabilities.py +++ b/stapi-fastapi/tests/test_capabilities.py @@ -56,6 +56,32 @@ def test_order_statuses_advertised_with_backend(stapi_client: TestClient) -> Non assert find_link(res.json()["links"], "monitor") is not None +@pytest.mark.root_router_kwargs({"get_order_statuses": None}) +def test_order_statuses_not_advertised_without_backend(stapi_client: TestClient) -> None: + assert ORDER_STATUSES_PATH not in route_paths(stapi_client) + + assert API.order_statuses not in stapi_client.get("/conformance").json()["conformsTo"] + assert API.order_statuses not in stapi_client.get("/").json()["conformsTo"] + + +@pytest.mark.root_router_kwargs({"get_order_statuses": None}) +def test_no_monitor_link_on_orders_without_statuses_backend(stapi_client: TestClient) -> None: + create_res = stapi_client.post("/products/test-spotlight/orders", json=CREATE_ORDER_PAYLOAD) + assert create_res.status_code == status.HTTP_201_CREATED + create_body = create_res.json() + assert find_link(create_body["links"], "self") is not None + assert find_link(create_body["links"], "monitor") is None + + get_res = stapi_client.get(f"/orders/{create_body['id']}") + assert get_res.status_code == status.HTTP_200_OK + assert find_link(get_res.json()["links"], "monitor") is None + + list_res = stapi_client.get("/orders") + assert list_res.status_code == status.HTTP_200_OK + order = next(o for o in list_res.json()["features"] if o["id"] == create_body["id"]) + assert find_link(order["links"], "monitor") is None + + OPPORTUNITIES_PATH = "/products/test-spotlight/opportunities" @@ -92,29 +118,3 @@ def test_async_product_on_async_root_advertises_async_opportunities( body = client.get("/products/test-spotlight").json() assert find_link(body["links"], "opportunities") is not None - - -@pytest.mark.root_router_kwargs({"get_order_statuses": None}) -def test_order_statuses_not_advertised_without_backend(stapi_client: TestClient) -> None: - assert ORDER_STATUSES_PATH not in route_paths(stapi_client) - - assert API.order_statuses not in stapi_client.get("/conformance").json()["conformsTo"] - assert API.order_statuses not in stapi_client.get("/").json()["conformsTo"] - - -@pytest.mark.root_router_kwargs({"get_order_statuses": None}) -def test_no_monitor_link_on_orders_without_statuses_backend(stapi_client: TestClient) -> None: - create_res = stapi_client.post("/products/test-spotlight/orders", json=CREATE_ORDER_PAYLOAD) - assert create_res.status_code == status.HTTP_201_CREATED - create_body = create_res.json() - assert find_link(create_body["links"], "self") is not None - assert find_link(create_body["links"], "monitor") is None - - get_res = stapi_client.get(f"/orders/{create_body['id']}") - assert get_res.status_code == status.HTTP_200_OK - assert find_link(get_res.json()["links"], "monitor") is None - - list_res = stapi_client.get("/orders") - assert list_res.status_code == status.HTTP_200_OK - order = next(o for o in list_res.json()["features"] if o["id"] == create_body["id"]) - assert find_link(order["links"], "monitor") is None diff --git a/stapi-fastapi/tests/test_datetime_interval.py b/stapi-fastapi/tests/test_datetime_interval.py index aa2d69b..b9a99d9 100644 --- a/stapi-fastapi/tests/test_datetime_interval.py +++ b/stapi-fastapi/tests/test_datetime_interval.py @@ -1,4 +1,4 @@ -from datetime import UTC, datetime, timedelta +from datetime import UTC, datetime, timedelta, tzinfo from itertools import product from zoneinfo import ZoneInfo @@ -14,7 +14,7 @@ class Model(BaseModel): # format_timezone was removed from pyrfc3339 (MIT) in v2.1, so included here now -def format_timezone(utcoffset): +def format_timezone(utcoffset: int) -> str: """ Return a string representing the timezone offset. Remaining seconds are rounded to the nearest minute. @@ -52,7 +52,7 @@ def rfc3339_strftime(dt: datetime, format: str) -> str: "2024-01-29T12:00:00Z/2024-01-28T12:00:00Z", ), ) -def test_invalid_values(value: str): +def test_invalid_values(value: str) -> None: with raises(ValidationError): Model.model_validate_strings({"datetime": value}) @@ -70,7 +70,7 @@ def test_invalid_values(value: str): ), ), ) -def test_deserialization(tz: ZoneInfo, format: str): +def test_deserialization(tz: tzinfo, format: str) -> None: start = datetime.now(tz) end = start + timedelta(hours=1) value = f"{rfc3339_strftime(start, format)}/{rfc3339_strftime(end, format)}" @@ -81,7 +81,7 @@ def test_deserialization(tz: ZoneInfo, format: str): @mark.parametrize("tz", (UTC, EUROPE_BERLIN)) -def test_serialize(tz): +def test_serialize(tz: tzinfo) -> None: start = datetime.now(tz) end = start + timedelta(hours=1) model = Model(datetime=(start, end)) diff --git a/stapi-fastapi/tests/test_openapi.py b/stapi-fastapi/tests/test_openapi.py index 06e1400..6c30029 100644 --- a/stapi-fastapi/tests/test_openapi.py +++ b/stapi-fastapi/tests/test_openapi.py @@ -47,6 +47,30 @@ def test_routes_declare_only_the_errors_they_produce(spec: dict[str, Any]) -> No assert {"404", "500"} <= set(spec["paths"]["/orders"]["get"]["responses"]) +@pytest.mark.mock_products([product_test_spotlight_sync_async_opportunity]) +@pytest.mark.parametrize("code", ["200", "201"]) +def test_preference_applied_declared_on_opportunity_search(spec: dict[str, Any], code: str) -> None: + """`Preference-Applied` is a spec MUST, and which codes carry it depends on + the capabilities of the product actually mounted. + """ + header = spec["paths"][SEARCH_OPPORTUNITIES_PATH]["post"]["responses"][code]["headers"]["Preference-Applied"] + assert set(header["schema"]["enum"]) == {"wait", "respond-async"} + + +def test_operation_ids_are_unique_and_named_for_their_route(spec: dict[str, Any]) -> None: + """Every generated client names its methods from these. + + They come from the prefixed route name, which is what makes them unique: + `get-product` is registered once per product, and FastAPI would emit the + collision without complaint. + """ + ids = [operation["operationId"] for _, _, operation in operations(spec)] + + assert len(ids) == len(set(ids)), sorted(id_ for id_ in ids if ids.count(id_) > 1) + assert {"root_test_spotlight_get_queryables", "root_test_satellite_provider_get_queryables"} <= set(ids) + assert all(operation_id.isidentifier() for operation_id in ids) + + def test_no_response_schema_carries_a_mangled_auto_title(spec: dict[str, Any]) -> None: """FastAPI auto-titles an *inline* response schema after the operation that returns it, yielding names like `Response Root Test Spotlight Get @@ -89,11 +113,11 @@ def collect(node: Any) -> None: assert set(schemas) - referenced == set() -@pytest.mark.mock_products([product_test_spotlight_sync_async_opportunity]) -@pytest.mark.parametrize("code", ["200", "201"]) -def test_preference_applied_declared_on_opportunity_search(spec: dict[str, Any], code: str) -> None: - """`Preference-Applied` is a spec MUST, and which codes carry it depends on - the capabilities of the product actually mounted. +def test_generic_component_names_stay_readable(spec: dict[str, Any]) -> None: + """Pydantic names a parameterization after the repr of its parameters, which + for the `Geometry` union runs to 204 characters. """ - header = spec["paths"][SEARCH_OPPORTUNITIES_PATH]["post"]["responses"][code]["headers"]["Preference-Applied"] - assert set(header["schema"]["enum"]) == {"wait", "respond-async"} + names = set(spec["components"]["schemas"]) + + assert "OpportunityCollection_Geometry__MyOpportunityProperties_" in names + assert max(len(name) for name in names) < 80 diff --git a/stapi-fastapi/tests/test_opportunity.py b/stapi-fastapi/tests/test_opportunity.py index a1dd037..19392c2 100644 --- a/stapi-fastapi/tests/test_opportunity.py +++ b/stapi-fastapi/tests/test_opportunity.py @@ -7,10 +7,12 @@ OpportunityCollection, ) -from .shared import create_mock_opportunity, pagination_tester +from .shared import AssertLink, create_mock_opportunity, pagination_tester -def test_search_opportunities_response(stapi_client: TestClient, assert_link, opportunity_search) -> None: +def test_search_opportunities_response( + stapi_client: TestClient, assert_link: AssertLink, opportunity_search: dict[str, Any] +) -> None: product_id = "test-spotlight" url = f"/products/{product_id}/opportunities" @@ -40,14 +42,12 @@ def test_search_opportunities_response(stapi_client: TestClient, assert_link, op def test_search_opportunities_pagination( limit: int, stapi_client: TestClient, - opportunity_search, + opportunity_search: dict[str, Any], ) -> None: mock_pagination_opportunities = [create_mock_opportunity() for __ in range(3)] stapi_client.app_state["_opportunities"] = mock_pagination_opportunities product_id = "test-spotlight" - expected_returns = [] - if limit != 0: - expected_returns = [x.model_dump(mode="json") for x in mock_pagination_opportunities] + expected_returns = [x.model_dump(mode="json") for x in mock_pagination_opportunities] pagination_tester( stapi_client=stapi_client, diff --git a/stapi-fastapi/tests/test_opportunity_async.py b/stapi-fastapi/tests/test_opportunity_async.py index 8c3414c..0f04795 100644 --- a/stapi-fastapi/tests/test_opportunity_async.py +++ b/stapi-fastapi/tests/test_opportunity_async.py @@ -15,7 +15,9 @@ OpportunitySearchStatusCode, ) -from .backends import mock_get_opportunity_search_record_statuses +from .backends import ( + mock_get_opportunity_search_record_statuses, +) from .shared import ( create_mock_opportunity, find_link, @@ -27,6 +29,125 @@ ) +@pytest.mark.mock_products([product_test_spotlight_async_opportunity]) +def test_monitor_link_present_on_search_records( + stapi_client_async_opportunity: TestClient, + opportunity_search: dict[str, Any], + url_for: Callable[[str], str], +) -> None: + client = stapi_client_async_opportunity + product_id = "test-spotlight" + + # 201 create + create_res = client.post(f"/products/{product_id}/opportunities", json=opportunity_search) + assert create_res.status_code == 201 + create_body = create_res.json() + record_id = create_body["id"] + statuses_href = url_for(f"/searches/opportunities/{record_id}/statuses") + + monitor = find_link(create_body["links"], "monitor") + assert monitor + assert monitor["href"] == statuses_href + + # GET single record + get_res = client.get(f"/searches/opportunities/{record_id}") + assert get_res.status_code == 200 + get_monitor = find_link(get_res.json()["links"], "monitor") + assert get_monitor + assert get_monitor["href"] == statuses_href + + # GET record list + list_res = client.get("/searches/opportunities") + assert list_res.status_code == 200 + record = next(r for r in list_res.json()["records"] if r["id"] == record_id) + list_monitor = find_link(record["links"], "monitor") + assert list_monitor + assert list_monitor["href"] == statuses_href + + +@pytest.mark.mock_products([product_test_spotlight_async_opportunity]) +@pytest.mark.root_router_kwargs({"get_opportunity_search_record_statuses": None}) +def test_monitor_link_absent_without_statuses_backend( + stapi_client_async_opportunity: TestClient, + opportunity_search: dict[str, Any], +) -> None: + client = stapi_client_async_opportunity + product_id = "test-spotlight" + + create_res = client.post(f"/products/{product_id}/opportunities", json=opportunity_search) + assert create_res.status_code == 201 + record_id = create_res.json()["id"] + assert find_link(create_res.json()["links"], "monitor") is None + + get_res = client.get(f"/searches/opportunities/{record_id}") + assert find_link(get_res.json()["links"], "monitor") is None + + list_res = client.get("/searches/opportunities") + record = next(r for r in list_res.json()["records"] if r["id"] == record_id) + assert find_link(record["links"], "monitor") is None + + +@pytest.mark.mock_products([product_test_spotlight_async_opportunity]) +def test_openapi_async_search_201_metadata(stapi_client_async_opportunity: TestClient) -> None: + from stapi_fastapi.constants import TYPE_JSON + + # TestClient types `app` as the bare ASGI callable; the fixture always + # builds a FastAPI app, which is what exposes `openapi()`. + spec = cast(FastAPI, stapi_client_async_opportunity.app).openapi() + responses = spec["paths"]["/products/test-spotlight/opportunities"]["post"]["responses"] + + # 201 documents the OpportunitySearchRecord as application/json (not geo+json) + r201 = responses["201"] + assert set(r201["content"].keys()) == {TYPE_JSON} + assert r201["content"][TYPE_JSON]["schema"]["$ref"].endswith("/OpportunitySearchRecord") + # Location header documented + assert "Location" in r201["headers"] + + # This product cannot search synchronously, so it can only ever answer 201. + # Documenting a 200 OpportunityCollection would promise a response that no + # request to this deployment can elicit. + assert "200" not in responses + + +@pytest.mark.mock_products([product_test_spotlight_async_opportunity]) +def test_openapi_create_order_201_location_header(stapi_client_async_opportunity: TestClient) -> None: + from stapi_fastapi.constants import TYPE_GEOJSON + + spec = cast(FastAPI, stapi_client_async_opportunity.app).openapi() + r201 = spec["paths"]["/products/test-spotlight/orders"]["post"]["responses"]["201"] + assert "Location" in r201["headers"] + # Order is GeoJSON, content stays geo+json + assert set(r201["content"].keys()) == {TYPE_GEOJSON} + + +@pytest.mark.mock_products([product_test_spotlight_async_opportunity]) +def test_statuses_unknown_id_returns_404( + stapi_client_async_opportunity: TestClient, +) -> None: + res = stapi_client_async_opportunity.get("/searches/opportunities/does-not-exist/statuses") + assert res.status_code == status.HTTP_404_NOT_FOUND + + +@pytest.mark.mock_products([product_test_spotlight_async_opportunity]) +@pytest.mark.root_router_kwargs( + { + # A statuses backend is supplied but the async search record backends + # are withheld, so the statuses route must not be registered and its + # conformance must be absent. + "get_opportunity_search_records": None, + "get_opportunity_search_record": None, + "get_opportunity_search_record_statuses": mock_get_opportunity_search_record_statuses, + "conformances": [API.core], + } +) +def test_statuses_endpoint_gated_on_async_support(stapi_client_async_opportunity: TestClient) -> None: + res = stapi_client_async_opportunity.get("/searches/opportunities/anything/statuses") + assert res.status_code == status.HTTP_404_NOT_FOUND + + conformance = stapi_client_async_opportunity.get("/conformance").json()["conformsTo"] + assert API.searches_opportunity_statuses not in conformance + + @pytest.mark.mock_products([product_test_spotlight]) def test_no_opportunity_search_advertised(stapi_client: TestClient) -> None: product_id = "test-spotlight" @@ -79,6 +200,37 @@ def test_async_search_advertised(stapi_client_async_opportunity: TestClient) -> assert find_link(root_body["links"], "search-records") +@pytest.mark.mock_products([product_test_spotlight_sync_opportunity]) +def test_sync_only_product_conformance(stapi_client: TestClient) -> None: + product_id = "test-spotlight" + res = stapi_client.get(f"/products/{product_id}/conformance") + assert res.status_code == status.HTTP_200_OK + conforms_to = res.json()["conformsTo"] + assert PRODUCT.opportunities in conforms_to + assert PRODUCT.opportunities_async not in conforms_to + + +@pytest.mark.mock_products([product_test_spotlight_async_opportunity]) +def test_async_only_product_conformance(stapi_client_async_opportunity: TestClient) -> None: + product_id = "test-spotlight" + res = stapi_client_async_opportunity.get(f"/products/{product_id}/conformance") + assert res.status_code == status.HTTP_200_OK + conforms_to = res.json()["conformsTo"] + # async capability does not imply sync class + assert PRODUCT.opportunities_async in conforms_to + assert PRODUCT.opportunities not in conforms_to + + +@pytest.mark.mock_products([product_test_spotlight_sync_async_opportunity]) +def test_sync_async_product_conformance(stapi_client_async_opportunity: TestClient) -> None: + product_id = "test-spotlight" + res = stapi_client_async_opportunity.get(f"/products/{product_id}/conformance") + assert res.status_code == status.HTTP_200_OK + conforms_to = res.json()["conformsTo"] + assert PRODUCT.opportunities in conforms_to + assert PRODUCT.opportunities_async in conforms_to + + @pytest.mark.mock_products([product_test_spotlight_async_opportunity]) def test_async_search_response( stapi_client_async_opportunity: TestClient, @@ -148,6 +300,84 @@ def test_prefer_header( pytest.fail("response is not an opportunity search record") +@pytest.mark.parametrize("prefer", ["respond-sync", "wait, respond-async", "WAIT", ""]) +@pytest.mark.mock_products([product_test_spotlight_sync_async_opportunity]) +def test_unsupported_prefer_header_is_rejected( + prefer: str, + stapi_client_async_opportunity: TestClient, + opportunity_search: dict[str, Any], +) -> None: + """A `Prefer` value outside the enum is a 400, as the published document promises.""" + res = stapi_client_async_opportunity.post( + "/products/test-spotlight/opportunities", + json=opportunity_search, + headers={"Prefer": prefer}, + ) + assert res.status_code == status.HTTP_400_BAD_REQUEST + + +@pytest.mark.mock_products([product_test_spotlight_sync_opportunity]) +def test_unsupported_prefer_header_is_rejected_on_sync_only_product( + stapi_client: TestClient, + opportunity_search: dict[str, Any], +) -> None: + """The check guards every opportunity search route, not just the async one.""" + res = stapi_client.post( + "/products/test-spotlight/opportunities", + json=opportunity_search, + headers={"Prefer": "respond-sync"}, + ) + assert res.status_code == status.HTTP_400_BAD_REQUEST + + +@pytest.mark.mock_products([product_test_spotlight_sync_async_opportunity]) +def test_preference_applied_match_wait( + stapi_client_async_opportunity: TestClient, + opportunity_search: dict[str, Any], +) -> None: + # prefer=wait honored by a sync+async product -> wait applied + url = "/products/test-spotlight/opportunities" + res = stapi_client_async_opportunity.post(url, json=opportunity_search, headers={"Prefer": "wait"}) + assert res.status_code == 200 + assert res.headers["Preference-Applied"] == "wait" + + +@pytest.mark.mock_products([product_test_spotlight_sync_async_opportunity]) +def test_preference_applied_match_respond_async( + stapi_client_async_opportunity: TestClient, + opportunity_search: dict[str, Any], +) -> None: + # prefer=respond-async honored by a sync+async product -> respond-async applied + url = "/products/test-spotlight/opportunities" + res = stapi_client_async_opportunity.post(url, json=opportunity_search, headers={"Prefer": "respond-async"}) + assert res.status_code == 201 + assert res.headers["Preference-Applied"] == "respond-async" + + +@pytest.mark.mock_products([product_test_spotlight_sync_opportunity]) +def test_preference_applied_mismatch_respond_async_on_sync_only( + stapi_client: TestClient, + opportunity_search: dict[str, Any], +) -> None: + # respond-async requested but product only supports sync -> wait applied + url = "/products/test-spotlight/opportunities" + res = stapi_client.post(url, json=opportunity_search, headers={"Prefer": "respond-async"}) + assert res.status_code == 200 + assert res.headers["Preference-Applied"] == "wait" + + +@pytest.mark.mock_products([product_test_spotlight_async_opportunity]) +def test_preference_applied_mismatch_wait_on_async_only( + stapi_client_async_opportunity: TestClient, + opportunity_search: dict[str, Any], +) -> None: + # wait requested but product only supports async -> respond-async applied + url = "/products/test-spotlight/opportunities" + res = stapi_client_async_opportunity.post(url, json=opportunity_search, headers={"Prefer": "wait"}) + assert res.status_code == 201 + assert res.headers["Preference-Applied"] == "respond-async" + + @pytest.mark.mock_products([product_test_spotlight_async_opportunity]) def test_async_search_record_retrieval( stapi_client_async_opportunity: TestClient, @@ -306,9 +536,7 @@ def test_get_search_records_pagination( setup_search_record_pagination: list[dict[str, Any]], limit: int, ) -> None: - expected_returns = [] - if limit > 0: - expected_returns = setup_search_record_pagination + expected_returns: list[dict[str, Any]] = setup_search_record_pagination pagination_tester( stapi_client=stapi_client_async_opportunity, @@ -320,6 +548,7 @@ def test_get_search_records_pagination( ) +@pytest.mark.mock_products([product_test_spotlight_async_opportunity]) def test_async_search_rejects_missing_required_queryable_predicate( stapi_client_async_opportunity: TestClient, ) -> None: @@ -336,231 +565,3 @@ def test_async_search_rejects_missing_required_queryable_predicate( }, ) assert response.status_code == 400 - - -@pytest.mark.mock_products([product_test_spotlight_sync_opportunity]) -def test_sync_only_product_conformance(stapi_client: TestClient) -> None: - product_id = "test-spotlight" - res = stapi_client.get(f"/products/{product_id}/conformance") - assert res.status_code == status.HTTP_200_OK - conforms_to = res.json()["conformsTo"] - assert PRODUCT.opportunities in conforms_to - assert PRODUCT.opportunities_async not in conforms_to - - -@pytest.mark.mock_products([product_test_spotlight_async_opportunity]) -def test_async_only_product_conformance(stapi_client_async_opportunity: TestClient) -> None: - product_id = "test-spotlight" - res = stapi_client_async_opportunity.get(f"/products/{product_id}/conformance") - assert res.status_code == status.HTTP_200_OK - conforms_to = res.json()["conformsTo"] - # async capability does not imply sync class - assert PRODUCT.opportunities_async in conforms_to - assert PRODUCT.opportunities not in conforms_to - - -@pytest.mark.mock_products([product_test_spotlight_sync_async_opportunity]) -def test_sync_async_product_conformance(stapi_client_async_opportunity: TestClient) -> None: - product_id = "test-spotlight" - res = stapi_client_async_opportunity.get(f"/products/{product_id}/conformance") - assert res.status_code == status.HTTP_200_OK - conforms_to = res.json()["conformsTo"] - assert PRODUCT.opportunities in conforms_to - assert PRODUCT.opportunities_async in conforms_to - - -@pytest.mark.mock_products([product_test_spotlight_async_opportunity]) -def test_monitor_link_present_on_search_records( - stapi_client_async_opportunity: TestClient, - opportunity_search: dict[str, Any], - url_for: Callable[[str], str], -) -> None: - client = stapi_client_async_opportunity - product_id = "test-spotlight" - - # 201 create - create_res = client.post(f"/products/{product_id}/opportunities", json=opportunity_search) - assert create_res.status_code == 201 - create_body = create_res.json() - record_id = create_body["id"] - statuses_href = url_for(f"/searches/opportunities/{record_id}/statuses") - - monitor = find_link(create_body["links"], "monitor") - assert monitor - assert monitor["href"] == statuses_href - - # GET single record - get_res = client.get(f"/searches/opportunities/{record_id}") - assert get_res.status_code == 200 - get_monitor = find_link(get_res.json()["links"], "monitor") - assert get_monitor - assert get_monitor["href"] == statuses_href - - # GET record list - list_res = client.get("/searches/opportunities") - assert list_res.status_code == 200 - record = next(r for r in list_res.json()["records"] if r["id"] == record_id) - list_monitor = find_link(record["links"], "monitor") - assert list_monitor - assert list_monitor["href"] == statuses_href - - -@pytest.mark.mock_products([product_test_spotlight_async_opportunity]) -@pytest.mark.root_router_kwargs({"get_opportunity_search_record_statuses": None}) -def test_monitor_link_absent_without_statuses_backend( - stapi_client_async_opportunity: TestClient, - opportunity_search: dict[str, Any], -) -> None: - client = stapi_client_async_opportunity - product_id = "test-spotlight" - - create_res = client.post(f"/products/{product_id}/opportunities", json=opportunity_search) - assert create_res.status_code == 201 - record_id = create_res.json()["id"] - assert find_link(create_res.json()["links"], "monitor") is None - - get_res = client.get(f"/searches/opportunities/{record_id}") - assert find_link(get_res.json()["links"], "monitor") is None - - list_res = client.get("/searches/opportunities") - record = next(r for r in list_res.json()["records"] if r["id"] == record_id) - assert find_link(record["links"], "monitor") is None - - -@pytest.mark.mock_products([product_test_spotlight_async_opportunity]) -def test_statuses_unknown_id_returns_404( - stapi_client_async_opportunity: TestClient, -) -> None: - res = stapi_client_async_opportunity.get("/searches/opportunities/does-not-exist/statuses") - assert res.status_code == status.HTTP_404_NOT_FOUND - - -@pytest.mark.mock_products([product_test_spotlight_async_opportunity]) -@pytest.mark.root_router_kwargs( - { - # A statuses backend is supplied but the async search record backends - # are withheld, so the statuses route must not be registered and its - # conformance must be absent. - "get_opportunity_search_records": None, - "get_opportunity_search_record": None, - "get_opportunity_search_record_statuses": mock_get_opportunity_search_record_statuses, - "conformances": [API.core], - } -) -def test_statuses_endpoint_gated_on_async_support(stapi_client_async_opportunity: TestClient) -> None: - res = stapi_client_async_opportunity.get("/searches/opportunities/anything/statuses") - assert res.status_code == status.HTTP_404_NOT_FOUND - - conformance = stapi_client_async_opportunity.get("/conformance").json()["conformsTo"] - assert API.searches_opportunity_statuses not in conformance - - -@pytest.mark.parametrize("prefer", ["respond-sync", "wait, respond-async", "WAIT", ""]) -@pytest.mark.mock_products([product_test_spotlight_sync_async_opportunity]) -def test_unsupported_prefer_header_is_rejected( - prefer: str, - stapi_client_async_opportunity: TestClient, - opportunity_search: dict[str, Any], -) -> None: - """A `Prefer` value outside the enum is a 400, as the published document promises.""" - res = stapi_client_async_opportunity.post( - "/products/test-spotlight/opportunities", - json=opportunity_search, - headers={"Prefer": prefer}, - ) - assert res.status_code == status.HTTP_400_BAD_REQUEST - - -@pytest.mark.mock_products([product_test_spotlight_sync_opportunity]) -def test_unsupported_prefer_header_is_rejected_on_sync_only_product( - stapi_client: TestClient, - opportunity_search: dict[str, Any], -) -> None: - """The check guards every opportunity search route, not just the async one.""" - res = stapi_client.post( - "/products/test-spotlight/opportunities", - json=opportunity_search, - headers={"Prefer": "respond-sync"}, - ) - assert res.status_code == status.HTTP_400_BAD_REQUEST - - -@pytest.mark.mock_products([product_test_spotlight_sync_async_opportunity]) -def test_preference_applied_match_wait( - stapi_client_async_opportunity: TestClient, - opportunity_search: dict[str, Any], -) -> None: - # prefer=wait honored by a sync+async product -> wait applied - url = "/products/test-spotlight/opportunities" - res = stapi_client_async_opportunity.post(url, json=opportunity_search, headers={"Prefer": "wait"}) - assert res.status_code == 200 - assert res.headers["Preference-Applied"] == "wait" - - -@pytest.mark.mock_products([product_test_spotlight_sync_async_opportunity]) -def test_preference_applied_match_respond_async( - stapi_client_async_opportunity: TestClient, - opportunity_search: dict[str, Any], -) -> None: - # prefer=respond-async honored by a sync+async product -> respond-async applied - url = "/products/test-spotlight/opportunities" - res = stapi_client_async_opportunity.post(url, json=opportunity_search, headers={"Prefer": "respond-async"}) - assert res.status_code == 201 - assert res.headers["Preference-Applied"] == "respond-async" - - -@pytest.mark.mock_products([product_test_spotlight_sync_opportunity]) -def test_preference_applied_mismatch_respond_async_on_sync_only( - stapi_client: TestClient, - opportunity_search: dict[str, Any], -) -> None: - # respond-async requested but product only supports sync -> wait applied - url = "/products/test-spotlight/opportunities" - res = stapi_client.post(url, json=opportunity_search, headers={"Prefer": "respond-async"}) - assert res.status_code == 200 - assert res.headers["Preference-Applied"] == "wait" - - -@pytest.mark.mock_products([product_test_spotlight_async_opportunity]) -def test_preference_applied_mismatch_wait_on_async_only( - stapi_client_async_opportunity: TestClient, - opportunity_search: dict[str, Any], -) -> None: - # wait requested but product only supports async -> respond-async applied - url = "/products/test-spotlight/opportunities" - res = stapi_client_async_opportunity.post(url, json=opportunity_search, headers={"Prefer": "wait"}) - assert res.status_code == 201 - assert res.headers["Preference-Applied"] == "respond-async" - - -@pytest.mark.mock_products([product_test_spotlight_async_opportunity]) -def test_openapi_async_search_201_metadata(stapi_client_async_opportunity: TestClient) -> None: - from stapi_fastapi.constants import TYPE_JSON - - # TestClient types `app` as the bare ASGI callable; the fixture always - # builds a FastAPI app, which is what exposes `openapi()`. - spec = cast(FastAPI, stapi_client_async_opportunity.app).openapi() - responses = spec["paths"]["/products/test-spotlight/opportunities"]["post"]["responses"] - - # 201 documents the OpportunitySearchRecord as application/json (not geo+json) - r201 = responses["201"] - assert set(r201["content"].keys()) == {TYPE_JSON} - assert r201["content"][TYPE_JSON]["schema"]["$ref"].endswith("/OpportunitySearchRecord") - # Location header documented - assert "Location" in r201["headers"] - - # This product cannot search synchronously, so it can only ever answer 201. - # Documenting a 200 OpportunityCollection would promise a response that no - # request to this deployment can elicit. - assert "200" not in responses - - -@pytest.mark.mock_products([product_test_spotlight_async_opportunity]) -def test_openapi_create_order_201_location_header(stapi_client_async_opportunity: TestClient) -> None: - from stapi_fastapi.constants import TYPE_GEOJSON - - spec = cast(FastAPI, stapi_client_async_opportunity.app).openapi() - r201 = spec["paths"]["/products/test-spotlight/orders"]["post"]["responses"]["201"] - assert "Location" in r201["headers"] - # Order is GeoJSON, content stays geo+json - assert set(r201["content"].keys()) == {TYPE_GEOJSON} diff --git a/stapi-fastapi/tests/test_order.py b/stapi-fastapi/tests/test_order.py index c1705d7..f0b91f8 100644 --- a/stapi-fastapi/tests/test_order.py +++ b/stapi-fastapi/tests/test_order.py @@ -1,4 +1,5 @@ from datetime import UTC, datetime, timedelta +from typing import Any, cast import pytest from fastapi import status @@ -6,16 +7,24 @@ from geojson_pydantic import Point from geojson_pydantic.types import Position2D from httpx import Response -from stapi_pydantic import STAPI_VERSION, Order, OrderRequest, OrderStatus, OrderStatusCode, SearchParameters +from stapi_pydantic import STAPI_VERSION, OrderRequest, OrderStatus, OrderStatusCode, SearchParameters -from .shared import MyOrderParameters, find_link, pagination_tester +from .shared import AssertLink, MyOrderParameters, find_link, pagination_tester + +REQUIRED_QUERYABLE_FILTER = { + "op": "and", + "args": [ + {"op": ">", "args": [{"property": "off_nadir"}, 0]}, + {"op": "<", "args": [{"property": "off_nadir"}, 45]}, + ], +} NOW = datetime.now(UTC) START = NOW END = START + timedelta(days=5) -def test_empty_order(stapi_client: TestClient): +def test_empty_order(stapi_client: TestClient) -> None: res = stapi_client.get("/orders") assert res.status_code == status.HTTP_200_OK assert res.headers["Content-Type"] == "application/geo+json" @@ -35,23 +44,14 @@ def test_empty_order(stapi_client: TestClient): } -REQUIRED_QUERYABLE_FILTER = { - "op": "and", - "args": [ - {"op": ">", "args": [{"property": "off_nadir"}, 0]}, - {"op": "<", "args": [{"property": "off_nadir"}, 45]}, - ], -} - - @pytest.fixture -def create_order_payloads() -> list[OrderRequest]: +def create_order_payloads() -> list[OrderRequest[MyOrderParameters]]: datetimes = [ ("2024-10-09T18:55:33Z", "2024-10-12T18:55:33Z"), ("2024-10-15T18:55:33Z", "2024-10-18T18:55:33Z"), ("2024-10-20T18:55:33Z", "2024-10-23T18:55:33Z"), ] - payloads = [] + payloads: list[OrderRequest[MyOrderParameters]] = [] for start, end in datetimes: payload = OrderRequest( search_parameters=SearchParameters( @@ -72,7 +72,7 @@ def create_order_payloads() -> list[OrderRequest]: def new_order_response( product_id: str, stapi_client: TestClient, - create_order_payloads: list[OrderRequest], + create_order_payloads: list[OrderRequest[MyOrderParameters]], ) -> Response: res = stapi_client.post( f"products/{product_id}/orders", @@ -95,7 +95,7 @@ def test_new_order_location_header_matches_self_link( @pytest.mark.parametrize("product_id", ["test-spotlight"]) -def test_new_order_links(new_order_response: Response, assert_link) -> None: +def test_new_order_links(new_order_response: Response, assert_link: AssertLink) -> None: order = new_order_response.json() assert_link( f"GET /orders/{order['id']}", @@ -124,27 +124,35 @@ def get_order_response(stapi_client: TestClient, new_order_response: Response) - @pytest.mark.parametrize("product_id", ["test-spotlight"]) -def test_get_order_properties(get_order_response: Response, create_order_payloads) -> None: +def test_get_order_properties( + get_order_response: Response, create_order_payloads: list[OrderRequest[MyOrderParameters]] +) -> None: order = get_order_response.json() + payload_search_parameters = create_order_payloads[0].search_parameters + # SearchParameters.geometry is the full GeoJSON Geometry union; the payloads + # this fixture builds always use a Point. + payload_geometry = cast(Point, payload_search_parameters.geometry) assert order["geometry"] == { "type": "Point", - "coordinates": list(create_order_payloads[0].search_parameters.geometry.coordinates), + "coordinates": list(payload_geometry.coordinates), } assert order["properties"]["order_request"]["search_parameters"]["geometry"] == { "type": "Point", - "coordinates": list(create_order_payloads[0].search_parameters.geometry.coordinates), + "coordinates": list(payload_geometry.coordinates), } assert ( order["properties"]["order_request"]["search_parameters"]["datetime"] - == create_order_payloads[0].search_parameters.model_dump(mode="json")["datetime"] + == payload_search_parameters.model_dump(mode="json")["datetime"] ) @pytest.mark.parametrize("product_id", ["test-spotlight"]) -def test_order_status_after_create(get_order_response: Response, stapi_client: TestClient, assert_link) -> None: +def test_order_status_after_create( + get_order_response: Response, stapi_client: TestClient, assert_link: AssertLink +) -> None: body = get_order_response.json() assert_link(f"GET /orders/{body['id']}", body, "monitor", f"/orders/{body['id']}/statuses") link = find_link(body["links"], "monitor") @@ -157,9 +165,11 @@ def test_order_status_after_create(get_order_response: Response, stapi_client: T @pytest.fixture -def setup_orders_pagination(stapi_client: TestClient, create_order_payloads) -> list[Order]: +def setup_orders_pagination( + stapi_client: TestClient, create_order_payloads: list[OrderRequest[MyOrderParameters]] +) -> list[dict[str, Any]]: product_id = "test-spotlight" - orders = [] + orders: list[dict[str, Any]] = [] for order in create_order_payloads: res = stapi_client.post( f"products/{product_id}/orders", @@ -175,10 +185,13 @@ def setup_orders_pagination(stapi_client: TestClient, create_order_payloads) -> @pytest.mark.parametrize("limit", [1, 2, 4]) -def test_get_orders_pagination(limit, setup_orders_pagination, create_order_payloads, stapi_client: TestClient) -> None: - expected_returns = [] - if limit > 0: - expected_returns = setup_orders_pagination +def test_get_orders_pagination( + limit: int, + setup_orders_pagination: list[dict[str, Any]], + create_order_payloads: list[OrderRequest[MyOrderParameters]], + stapi_client: TestClient, +) -> None: + expected_returns: list[dict[str, Any]] = setup_orders_pagination pagination_tester( stapi_client=stapi_client, @@ -197,7 +210,7 @@ def test_token_not_found(stapi_client: TestClient) -> None: @pytest.fixture def order_statuses() -> dict[str, list[OrderStatus]]: - statuses = { + statuses: dict[str, list[OrderStatus]] = { "test_order_id": [ OrderStatus( timestamp=datetime(2025, 1, 14, 2, 21, 48, 466726, tzinfo=UTC), @@ -230,9 +243,7 @@ def test_get_order_status_pagination( stapi_client.app_state["_orders_db"].put_order_status(id, s) order_id = "test_order_id" - expected_returns = [] - if limit != 0: - expected_returns = [x.model_dump(mode="json") for x in order_statuses[order_id]] + expected_returns: list[dict[str, Any]] = [x.model_dump(mode="json") for x in order_statuses[order_id]] pagination_tester( stapi_client=stapi_client, @@ -271,3 +282,17 @@ def test_create_order_rejects_missing_required_queryable_predicate(stapi_client: }, ) assert response.status_code == 400 + + +@pytest.mark.parametrize("product_id", ["test-spotlight"]) +def test_get_order_statuses_is_collection(get_order_response: Response, stapi_client: TestClient) -> None: + body = get_order_response.json() + link = find_link(body["links"], "monitor") + assert link is not None + + res = stapi_client.get(link["href"]) + assert res.status_code == status.HTTP_200_OK + + statuses_body = res.json() + assert statuses_body["stapi_type"] == "OrderStatusCollection" + assert "statuses" in statuses_body diff --git a/stapi-fastapi/tests/test_pagination.py b/stapi-fastapi/tests/test_pagination.py index ba5e811..f4a9f3a 100644 --- a/stapi-fastapi/tests/test_pagination.py +++ b/stapi-fastapi/tests/test_pagination.py @@ -1,6 +1,7 @@ """Tests for the shared pagination query parameters, `self` links and totals.""" -from collections.abc import Callable +from collections.abc import AsyncIterator, Callable +from contextlib import asynccontextmanager from datetime import UTC, datetime from typing import Any, cast from urllib.parse import parse_qs, urlsplit @@ -9,6 +10,7 @@ from fastapi import FastAPI, Request, status from fastapi.testclient import TestClient from returns.result import Failure, ResultE +from stapi_fastapi.constants import TYPE_GEOJSON, TYPE_JSON from stapi_fastapi.pagination import Page from stapi_fastapi.query_params import MAX_LIMIT, clamp_limit from stapi_fastapi.routers.root_router import RootRouter @@ -22,9 +24,16 @@ ) from .backends import ( + mock_get_opportunity_search_record, + mock_get_opportunity_search_record_statuses, + mock_get_opportunity_search_records, mock_get_order, + mock_get_order_statuses, + mock_get_orders, ) from .shared import ( + InMemoryOpportunityDB, + InMemoryOrderDB, create_mock_opportunity, find_link, product_test_spotlight_async_opportunity, @@ -103,6 +112,34 @@ def test_clamp_limit_caps_at_the_maximum() -> None: assert clamp_limit(1) == 1 +@pytest.mark.parametrize("path", PAGINATED_PATHS) +def test_unusable_pagination_token_is_a_404(path: str, stapi_client: TestClient) -> None: + """A token that identifies no page is a missing resource, not a bad request.""" + res = stapi_client.get(path, params={"next": "not-a-token"}) + assert res.status_code == status.HTTP_404_NOT_FOUND + + +async def _orders_raising(next: str | None, limit: int, request: Request) -> ResultE[Page[Order[OrderStatus]]]: + return Failure(ValueError("a backend failed for some other reason")) + + +def test_an_incidental_value_error_is_a_500_not_a_404() -> None: + """Only `PaginationTokenError` means "no such page". + + A backend raising a plain `ValueError` -- an `int()` on bad input, a + `list.index` miss on anything but the token -- has failed, and saying "not + found" would hide that from the operator and lie to the client. + """ + root_router = RootRouter(get_orders=_orders_raising, get_order=mock_get_order) + app = FastAPI() + app.include_router(root_router) + + with TestClient(app, raise_server_exceptions=False) as client: + res = client.get("/orders") + + assert res.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR, res.text + + def query_of(href: str) -> dict[str, list[str]]: return parse_qs(urlsplit(href).query) @@ -163,32 +200,32 @@ def test_search_records_collection_has_self_link(stapi_client_async_opportunity: assert self_link["href"] == "http://stapiserver/searches/opportunities" -@pytest.mark.parametrize("path", PAGINATED_PATHS) -def test_unusable_pagination_token_is_a_404(path: str, stapi_client: TestClient) -> None: - """A token that identifies no page is a missing resource, not a bad request.""" - res = stapi_client.get(path, params={"next": "not-a-token"}) - assert res.status_code == status.HTTP_404_NOT_FOUND - - -async def _orders_raising(next: str | None, limit: int, request: Request) -> ResultE[Page[Order[OrderStatus]]]: - return Failure(ValueError("a backend failed for some other reason")) - - -def test_an_incidental_value_error_is_a_500_not_a_404() -> None: - """Only `PaginationTokenError` means "no such page". - - A backend raising a plain `ValueError` -- an `int()` on bad input, a - `list.index` miss on anything but the token -- has failed, and saying "not - found" would hide that from the operator and lie to the client. - """ - root_router = RootRouter(get_orders=_orders_raising, get_order=mock_get_order) - app = FastAPI() - app.include_router(root_router) - - with TestClient(app, raise_server_exceptions=False) as client: - res = client.get("/orders") +def test_order_statuses_self_link_carries_query_params( + stapi_client: TestClient, +) -> None: + body: dict[str, Any] = { + "search_parameters": { + "datetime": "2024-10-09T18:55:33Z/2024-10-12T18:55:33Z", + "geometry": {"type": "Point", "coordinates": [0, 0]}, + "filter": { + "op": "and", + "args": [ + {"op": ">", "args": [{"property": "off_nadir"}, 0]}, + {"op": "<", "args": [{"property": "off_nadir"}, 45]}, + ], + }, + }, + "order_parameters": {"s3_path": "s3://my-bucket"}, + } + created = stapi_client.post("/products/test-spotlight/orders", json=body) + assert created.status_code == status.HTTP_201_CREATED + order_id = created.json()["id"] - assert res.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR, res.text + res = stapi_client.get(f"/orders/{order_id}/statuses", params={"limit": 1}) + assert res.status_code == status.HTTP_200_OK + self_link = find_link(res.json()["links"], "self") + assert self_link is not None + assert query_of(self_link["href"]) == {"limit": ["1"]} def follow_pages( @@ -364,3 +401,73 @@ def test_opportunity_collection_publishes_number_matched( body = client.get(f"/products/{PRODUCT_ID}/opportunities/{collection.id}", params={"limit": 1}).json() assert len(body["features"]) == 1 assert body["numberMatched"] == 3 + + +def _async_opportunity_app() -> FastAPI: + """A root router serving one async-capable product. + + Built directly rather than through the marker-driven fixtures, so the test + is pinned to the route whose media type it checks. + """ + + @asynccontextmanager + async def lifespan(app: FastAPI) -> AsyncIterator[dict[str, Any]]: + yield { + "_orders_db": InMemoryOrderDB(), + "_opportunities_db": InMemoryOpportunityDB(), + "_opportunities": [], + } + + root_router = RootRouter( + get_orders=mock_get_orders, + get_order=mock_get_order, + get_order_statuses=mock_get_order_statuses, + get_opportunity_search_records=mock_get_opportunity_search_records, + get_opportunity_search_record=mock_get_opportunity_search_record, + get_opportunity_search_record_statuses=mock_get_opportunity_search_record_statuses, + ) + root_router.add_product(product_test_spotlight_async_opportunity) + app = FastAPI(lifespan=lifespan) + app.include_router(root_router) + return app + + +def test_opportunity_collection_links_match_the_media_type_it_serves() -> None: + """A link's `type` must describe what the target actually returns. + + The Opportunity Collection is served as geo+json, so its own `self` and + `next` links have to agree. + """ + with TestClient(_async_opportunity_app()) as client: + collection = OpportunityCollection( + id="a-collection", + features=[create_mock_opportunity() for _ in range(3)], + ) + client.app_state["_opportunities_db"].put_opportunity_collection(collection) + + response = client.get(f"/products/test-spotlight/opportunities/{collection.id}", params={"limit": 1}) + + assert response.status_code == status.HTTP_200_OK, response.text + assert response.headers["content-type"].startswith(TYPE_GEOJSON) + typed = {link["rel"]: link.get("type") for link in response.json()["links"]} + assert typed["self"] == TYPE_GEOJSON + assert typed["next"] == TYPE_GEOJSON + + +@pytest.mark.parametrize( + ("url", "media_type"), + [("/orders", TYPE_GEOJSON), ("/products", TYPE_JSON)], +) +def test_collection_links_match_the_media_type_served( + url: str, + media_type: str, + stapi_client: TestClient, +) -> None: + """Both `self` and `next` describe the representation their target returns.""" + response = stapi_client.get(url, params={"limit": 1}) + + assert response.status_code == status.HTTP_200_OK, response.text + assert response.headers["content-type"].startswith(media_type) + for link in response.json()["links"]: + if link["rel"] in ("self", "next"): + assert link.get("type") == media_type, f"{url} {link['rel']}" diff --git a/stapi-fastapi/tests/test_product.py b/stapi-fastapi/tests/test_product.py index ef16ea1..8122cc9 100644 --- a/stapi-fastapi/tests/test_product.py +++ b/stapi-fastapi/tests/test_product.py @@ -1,3 +1,5 @@ +from typing import Any + import pytest from fastapi import status from fastapi.testclient import TestClient @@ -6,10 +8,15 @@ from stapi_pydantic import Conformance from .backends import mock_get_order, mock_get_orders -from .shared import pagination_tester, product_test_spotlight_sync_opportunity +from .shared import ( + AssertLink, + pagination_tester, + product_test_spotlight_no_geojson_conformance, + product_test_spotlight_sync_opportunity, +) -def test_products_response(stapi_client: TestClient): +def test_products_response(stapi_client: TestClient) -> None: res = stapi_client.get("/products") assert res.status_code == status.HTTP_200_OK @@ -25,8 +32,8 @@ def test_products_response(stapi_client: TestClient): def test_product_response_links( product_id: str, stapi_client: TestClient, - assert_link, -): + assert_link: AssertLink, +) -> None: res = stapi_client.get(f"/products/{product_id}") assert res.status_code == status.HTTP_200_OK assert res.headers["Content-Type"] == "application/json" @@ -48,7 +55,7 @@ def test_product_response_links( def test_product_conformance_response( product_id: str, stapi_client: TestClient, -): +) -> None: res = stapi_client.get(f"/products/{product_id}/conformance") assert res.status_code == status.HTTP_200_OK assert res.headers["Content-Type"] == "application/json" @@ -61,7 +68,7 @@ def test_product_conformance_response( def test_product_queryables_response( product_id: str, stapi_client: TestClient, -): +) -> None: res = stapi_client.get(f"/products/{product_id}/queryables") assert res.status_code == status.HTTP_200_OK assert res.headers["Content-Type"] == "application/json" @@ -75,7 +82,7 @@ def test_product_queryables_response( def test_product_order_parameters_response( product_id: str, stapi_client: TestClient, -): +) -> None: res = stapi_client.get(f"/products/{product_id}/order-parameters") assert res.status_code == status.HTTP_200_OK assert res.headers["Content-Type"] == "application/json" @@ -90,46 +97,45 @@ def test_get_products_pagination( limit: int, stapi_client: TestClient, mock_products: list[Product], -): - expected_returns = [] - if limit != 0: - for product in mock_products: - prod = product.model_dump(mode="json", by_alias=True) - product_id = prod["id"] - prod["links"] = [ - { - "href": f"http://stapiserver/products/{product_id}", - "rel": "self", - "type": "application/json", - }, - { - "href": f"http://stapiserver/products/{product_id}/conformance", - "rel": "conformance", - "type": "application/json", - }, - { - "href": f"http://stapiserver/products/{product_id}/queryables", - "rel": "queryables", - "type": "application/json", - }, - { - "href": f"http://stapiserver/products/{product_id}/order-parameters", - "rel": "order-parameters", - "type": "application/json", - }, - { - "href": f"http://stapiserver/products/{product_id}/orders", - "rel": "create-order", - "type": "application/json", - "method": "POST", - }, - { - "href": f"http://stapiserver/products/{product_id}/opportunities", - "rel": "opportunities", - "type": "application/json", - }, - ] - expected_returns.append(prod) +) -> None: + expected_returns: list[dict[str, Any]] = [] + for product in mock_products: + prod = product.model_dump(mode="json", by_alias=True) + product_id = prod["id"] + prod["links"] = [ + { + "href": f"http://stapiserver/products/{product_id}", + "rel": "self", + "type": "application/json", + }, + { + "href": f"http://stapiserver/products/{product_id}/conformance", + "rel": "conformance", + "type": "application/json", + }, + { + "href": f"http://stapiserver/products/{product_id}/queryables", + "rel": "queryables", + "type": "application/json", + }, + { + "href": f"http://stapiserver/products/{product_id}/order-parameters", + "rel": "order-parameters", + "type": "application/json", + }, + { + "href": f"http://stapiserver/products/{product_id}/orders", + "rel": "create-order", + "type": "application/json", + "method": "POST", + }, + { + "href": f"http://stapiserver/products/{product_id}/opportunities", + "rel": "opportunities", + "type": "application/json", + }, + ] + expected_returns.append(prod) pagination_tester( stapi_client=stapi_client, @@ -147,17 +153,23 @@ def test_token_not_found(stapi_client: TestClient) -> None: @pytest.mark.mock_products([]) -def test_no_products(stapi_client: TestClient): +def test_no_products(stapi_client: TestClient) -> None: res = stapi_client.get("/products") body = res.json() - print("hold") assert res.status_code == status.HTTP_200_OK assert len(body["products"]) == 0 -def test_add_product_rejects_a_duplicate_id() -> None: - root_router = RootRouter(get_orders=mock_get_orders, get_order=mock_get_order) - root_router.add_product(product_test_spotlight_sync_opportunity) +def _bare_root_router() -> RootRouter: + return RootRouter(get_orders=mock_get_orders, get_order=mock_get_order) + + +def test_product_without_geojson_conformance_is_rejected() -> None: + """A Product must declare a geojson conformance to say what geometry it takes.""" + with pytest.raises(ValueError, match="geojson conformance"): + _bare_root_router().add_product(product_test_spotlight_no_geojson_conformance) + - with pytest.raises(ValueError, match="already registered"): - root_router.add_product(product_test_spotlight_sync_opportunity) +def test_product_with_geojson_conformance_is_accepted() -> None: + """The guard rejects only products missing the declaration.""" + _bare_root_router().add_product(product_test_spotlight_sync_opportunity) diff --git a/stapi-fastapi/tests/test_root.py b/stapi-fastapi/tests/test_root.py index 00b4ace..cee1eb7 100644 --- a/stapi-fastapi/tests/test_root.py +++ b/stapi-fastapi/tests/test_root.py @@ -2,8 +2,10 @@ from fastapi.testclient import TestClient from stapi_fastapi.conformance import API +from .shared import AssertLink -def test_root(stapi_client: TestClient, assert_link) -> None: + +def test_root(stapi_client: TestClient, assert_link: AssertLink) -> None: res = stapi_client.get("/") assert res.status_code == status.HTTP_200_OK diff --git a/stapi-pydantic/tests/test_opportunity.py b/stapi-pydantic/tests/test_opportunity.py index 9b0b495..6af79fd 100644 --- a/stapi-pydantic/tests/test_opportunity.py +++ b/stapi-pydantic/tests/test_opportunity.py @@ -22,6 +22,30 @@ } +def test_opportunity_search_status_accepts_extension_status_code() -> None: + status = OpportunitySearchStatus.model_validate({"timestamp": "2024-04-10T09:15:00Z", "status_code": "queued"}) + assert status.status_code == "queued" + assert status.model_dump(mode="json")["status_code"] == "queued" + + +def test_opportunity_search_status_code_constrainable_with_custom_enum() -> None: + from enum import StrEnum + + class NarrowCodes(StrEnum): + special = "special" + + with pytest.raises(pydantic.ValidationError): + OpportunitySearchStatus[NarrowCodes].model_validate( + {"timestamp": "2024-04-10T09:15:00Z", "status_code": "received"} + ) + + +def test_create_properties() -> None: + _ = OpportunityProperties.model_validate( + {"datetime": "2025-04-01T00:00:00Z/2025-04-01T23:59:59Z", "product_id": "foo"} + ) + + def test_opportunity_request_shape() -> None: """An unspecified page size stays unspecified: the default is the server's to choose, not the model's. @@ -56,12 +80,15 @@ def test_opportunity_request_body_includes_pagination() -> None: "id": "search-1", "product_id": "umbra_spotlight", "search_parameters": SEARCH_PARAMS, - "status": {"timestamp": "2024-04-10T09:15:00Z", "status_code": "received"}, + "status": { + "timestamp": "2024-04-18T11:00:00Z", + "status_code": "received", + "links": [], + }, } def test_opportunity_search_record_request_field() -> None: - """A record says what was searched for, not which request body carried it.""" record = OpportunitySearchRecord.model_validate(SEARCH_RECORD_DICT) assert record.search_parameters.geometry.type == "Point" dumped = record.model_dump(mode="json") @@ -70,42 +97,27 @@ def test_opportunity_search_record_request_field() -> None: def test_opportunity_search_record_collection() -> None: - collection = OpportunitySearchRecordCollection.model_validate( - {"records": [SEARCH_RECORD_DICT]}, - ) + collection = OpportunitySearchRecordCollection(records=[OpportunitySearchRecord.model_validate(SEARCH_RECORD_DICT)]) dumped = collection.model_dump(mode="json") assert dumped["stapi_type"] == "OpportunitySearchRecordCollection" assert len(dumped["records"]) == 1 -def test_opportunity_search_status_accepts_extension_status_code() -> None: - status = OpportunitySearchStatus.model_validate({"timestamp": "2024-04-10T09:15:00Z", "status_code": "queued"}) - assert status.status_code == "queued" - assert status.model_dump(mode="json")["status_code"] == "queued" - - -def test_opportunity_search_status_code_constrainable_with_custom_enum() -> None: - from enum import StrEnum - - class NarrowCodes(StrEnum): - special = "special" - - with pytest.raises(pydantic.ValidationError): - OpportunitySearchStatus[NarrowCodes].model_validate( - {"timestamp": "2024-04-10T09:15:00Z", "status_code": "received"} - ) - - def test_opportunity_search_status_collection() -> None: - collection = OpportunitySearchStatusCollection.model_validate( - {"statuses": [{"timestamp": "2024-04-10T09:15:00Z", "status_code": "received"}]} - ) + status = OpportunitySearchStatus.model_validate(SEARCH_RECORD_DICT["status"]) + collection = OpportunitySearchStatusCollection(statuses=[status]) dumped = collection.model_dump(mode="json") assert dumped["stapi_type"] == "OpportunitySearchStatusCollection" - assert len(dumped["statuses"]) == 1 -OPPORTUNITY_DICT = { +def test_opportunity_collection_stapi_fields() -> None: + collection: OpportunityCollection[Any, Any] = OpportunityCollection(features=[]) + dumped = collection.model_dump(mode="json") + assert dumped["stapi_type"] == "OpportunityCollection" + assert dumped["stapi_version"] == "0.2.0" + + +OPPORTUNITY_DICT: dict[str, Any] = { "type": "Feature", "geometry": {"type": "Point", "coordinates": [13.4, 52.5]}, "properties": { @@ -115,40 +127,6 @@ def test_opportunity_search_status_collection() -> None: } -def test_opportunity_id_is_string_only() -> None: - opportunity = Opportunity[Point, OpportunityProperties].model_validate({**OPPORTUNITY_DICT, "id": "opp-1"}) - assert opportunity.id == "opp-1" - with pytest.raises(pydantic.ValidationError): - Opportunity[Point, OpportunityProperties].model_validate({**OPPORTUNITY_DICT, "id": 1}) - - -def test_opportunity_collection_omits_null_id() -> None: - collection = OpportunityCollection[Point, OpportunityProperties].model_validate( - {"type": "FeatureCollection", "features": []} - ) - assert "id" not in collection.model_dump(mode="json") - - -def test_opportunity_geometry_required_non_null() -> None: - """Feature types geometry as nullable, which would let a spec-violating - response validate and dump. - """ - with pytest.raises(pydantic.ValidationError): - Opportunity[Point, OpportunityProperties].model_validate({**OPPORTUNITY_DICT, "geometry": None}) - - -def test_opportunity_properties_required() -> None: - with pytest.raises(pydantic.ValidationError): - Opportunity[Point, OpportunityProperties].model_validate({**OPPORTUNITY_DICT, "properties": None}) - - -def test_opportunity_collection_stapi_fields() -> None: - collection: OpportunityCollection[Any, Any] = OpportunityCollection(features=[]) - dumped = collection.model_dump(mode="json") - assert dumped["stapi_type"] == "OpportunityCollection" - assert dumped["stapi_version"] == "0.2.0" - - def test_opportunity_bbox_3d_geometry() -> None: opportunity_dict: dict[str, Any] = { **OPPORTUNITY_DICT, @@ -208,6 +186,18 @@ def test_opportunity_collection_bbox_unions_features() -> None: assert collection.bbox == (13.4, 52.5, 14.4, 53.5) +def test_opportunity_id_is_string_only() -> None: + schema = Opportunity[Point, OpportunityProperties].model_json_schema(mode="validation") + id_types = {member.get("type") for member in schema["properties"]["id"].get("anyOf", [])} + assert "integer" not in id_types + + +def test_opportunity_collection_omits_null_id() -> None: + collection: OpportunityCollection[Any, Any] = OpportunityCollection(features=[]) + assert "id" not in collection.model_dump(mode="json") + assert '"id":null' not in collection.model_dump_json() + + def test_opportunity_collection_number_matched() -> None: collection: OpportunityCollection[Any, Any] = OpportunityCollection(features=[], number_matched=3) assert collection.model_dump(mode="json")["numberMatched"] == 3 @@ -217,3 +207,23 @@ def test_opportunity_collection_number_matched() -> None: def test_search_record_collection_number_matched() -> None: collection = OpportunitySearchRecordCollection(records=[], number_matched=0) assert collection.model_dump(mode="json")["numberMatched"] == 0 + + +def test_opportunity_geometry_required_non_null() -> None: + with pytest.raises(pydantic.ValidationError): + Opportunity[Point, OpportunityProperties].model_validate( + { + **OPPORTUNITY_DICT, + "geometry": None, + } + ) + + +def test_opportunity_properties_required() -> None: + with pytest.raises(pydantic.ValidationError): + Opportunity[Point, OpportunityProperties].model_validate( + { + **OPPORTUNITY_DICT, + "properties": None, + } + ) diff --git a/stapi-pydantic/tests/test_order.py b/stapi-pydantic/tests/test_order.py index 21ddf05..d3d697f 100644 --- a/stapi-pydantic/tests/test_order.py +++ b/stapi-pydantic/tests/test_order.py @@ -15,6 +15,20 @@ StoredOrderRequest, ) +SEARCH_PARAMS = { + "datetime": "2024-04-18T10:56:00Z/2024-04-25T10:56:00Z", + "geometry": {"type": "Point", "coordinates": [13.4, 52.5]}, +} + + +def test_order_status_new() -> None: + status = OrderStatus.new(OrderStatusCode.accepted) + assert status.timestamp.tzinfo == datetime.UTC + assert status.status_code == OrderStatusCode.accepted + assert status.reason_code is None + assert status.reason_text is None + assert status.links == [] + def test_order_status_new_uses_cls() -> None: class NarrowCodes(StrEnum): @@ -60,37 +74,10 @@ def test_order_status_code_schema_allows_extension_strings() -> None: assert any("$ref" in member for member in status_code_schema["anyOf"]) -def test_order_status_new() -> None: - status = OrderStatus.new(OrderStatusCode.accepted) - assert status.timestamp.tzinfo == datetime.UTC - assert status.status_code == OrderStatusCode.accepted - assert status.reason_code is None - assert status.reason_text is None - assert status.links == [] - - -SEARCH_PARAMS = { - "datetime": "2024-04-18T10:56:00Z/2024-04-25T10:56:00Z", - "geometry": {"type": "Point", "coordinates": [13.4, 52.5]}, -} - - class RequiredParams(OrderParameters): delivery_format: str -def test_concrete_order_parameters_are_base_order_parameters() -> None: - assert isinstance(RequiredParams(delivery_format="GEOTIFF"), BaseOrderParameters) - - # RequiredParams (via OrderParameters) forbids extra fields... - with pytest.raises(pydantic.ValidationError): - RequiredParams.model_validate({"delivery_format": "GEOTIFF", "unexpected_field": "value"}) - - # ...while BaseOrderParameters allows and preserves them. - base = BaseOrderParameters.model_validate({"unexpected_field": "value"}) - assert base.model_dump()["unexpected_field"] == "value" - - def test_order_request_shape() -> None: req = OrderRequest[OrderParameters].model_validate({"search_parameters": SEARCH_PARAMS, "order_parameters": {}}) assert req.search_parameters.filter is None @@ -147,35 +134,21 @@ def test_stored_order_parameters_preserve_provider_fields() -> None: assert params.model_dump()["deliveryFormat"] == "GEOTIFF" -def test_order_extra_properties_allowed() -> None: - order = Order[OrderStatus].model_validate(ORDER_DICT) - assert order.properties.model_dump()["owner"] == {"organization": "ACME"} - - -def test_stored_order_request_preserves_unknown_fields() -> None: - stored = StoredOrderRequest.model_validate({"search_parameters": SEARCH_PARAMS, "provider_extra": 1}) - assert stored.model_dump()["provider_extra"] == 1 +def test_concrete_order_parameters_are_base_order_parameters() -> None: + assert isinstance(RequiredParams(delivery_format="GEOTIFF"), BaseOrderParameters) + # RequiredParams (via OrderParameters) forbids extra fields... + with pytest.raises(pydantic.ValidationError): + RequiredParams.model_validate({"delivery_format": "GEOTIFF", "unexpected_field": "value"}) -def test_search_parameters_preserve_unknown_fields() -> None: - order = Order[OrderStatus].model_validate( - { - **ORDER_DICT, - "properties": { - **ORDER_DICT["properties"], - "order_request": {"search_parameters": {**SEARCH_PARAMS, "vendor:priority": "high"}}, - }, - } - ) - dumped = order.model_dump(mode="json") - assert dumped["properties"]["order_request"]["search_parameters"]["vendor:priority"] == "high" + # ...while BaseOrderParameters allows and preserves them. + base = BaseOrderParameters.model_validate({"unexpected_field": "value"}) + assert base.model_dump()["unexpected_field"] == "value" -def test_order_collection_stapi_fields() -> None: - collection = OrderCollection[OrderStatus](features=[Order[OrderStatus].model_validate(ORDER_DICT)]) - dumped = collection.model_dump(mode="json") - assert dumped["stapi_type"] == "OrderCollection" - assert dumped["stapi_version"] == "0.2.0" +def test_order_extra_properties_allowed() -> None: + order = Order[OrderStatus].model_validate(ORDER_DICT) + assert order.properties.model_dump()["owner"] == {"organization": "ACME"} def test_order_bbox_computed_and_serialized() -> None: @@ -251,8 +224,6 @@ def _order_with(geometry: dict[str, Any], id_: str) -> Order[OrderStatus]: LINE_3D = {"type": "LineString", "coordinates": [[13.0, 52.0, 10.0], [16.0, 55.0, 200.0]]} - - LINE_3D_LOWER = {"type": "LineString", "coordinates": [[12.0, 51.0, 5.0], [12.5, 51.5, 100.0]]} @@ -274,12 +245,38 @@ def test_order_collection_bbox_degrades_to_2d_when_members_are_mixed() -> None: assert collection.bbox == (12.0, 51.0, 16.0, 55.0) +def test_order_collection_number_matched_not_serialization_required() -> None: + schema = OrderCollection[OrderStatus].model_json_schema(mode="serialization") + assert {"type", "stapi_type", "stapi_version", "links", "features"} <= set(schema["required"]) + assert "numberMatched" not in schema["required"] + + +def test_stored_order_request_preserves_unknown_fields() -> None: + stored = StoredOrderRequest.model_validate({"search_parameters": SEARCH_PARAMS, "provider_extra": 1}) + assert stored.model_dump()["provider_extra"] == 1 + + +def test_search_parameters_preserve_unknown_fields() -> None: + order = Order[OrderStatus].model_validate( + { + **ORDER_DICT, + "properties": { + **ORDER_DICT["properties"], + "order_request": {"search_parameters": {**SEARCH_PARAMS, "vendor:priority": "high"}}, + }, + } + ) + dumped = order.model_dump(mode="json") + assert dumped["properties"]["order_request"]["search_parameters"]["vendor:priority"] == "high" + + def test_order_empty_geometry_bbox_error_is_clear() -> None: with pytest.raises(pydantic.ValidationError, match="bbox"): Order[OrderStatus].model_validate({**ORDER_DICT, "geometry": {"type": "MultiPoint", "coordinates": []}}) -def test_order_collection_number_matched_not_serialization_required() -> None: - schema = OrderCollection[OrderStatus].model_json_schema(mode="serialization") - assert {"type", "stapi_type", "stapi_version", "links", "features"} <= set(schema["required"]) - assert "numberMatched" not in schema["required"] +def test_order_collection_stapi_fields() -> None: + collection = OrderCollection[OrderStatus](features=[Order[OrderStatus].model_validate(ORDER_DICT)]) + dumped = collection.model_dump(mode="json") + assert dumped["stapi_type"] == "OrderCollection" + assert dumped["stapi_version"] == "0.2.0" diff --git a/stapi-pydantic/tests/test_product.py b/stapi-pydantic/tests/test_product.py index 733f270..ef4fcf8 100644 --- a/stapi-pydantic/tests/test_product.py +++ b/stapi-pydantic/tests/test_product.py @@ -38,11 +38,11 @@ def test_products_collection_dumps_nested_product_by_alias() -> None: assert collection.model_dump()["products"][0]["type"] == "Collection" -def test_product_collection_is_named_for_its_stapi_type() -> None: - assert ProductCollection.model_fields["stapi_type"].default == "ProductCollection" - - def test_products_collection_number_matched() -> None: collection = ProductCollection(products=[], number_matched=12) assert collection.model_dump(mode="json")["numberMatched"] == 12 assert "numberMatched" not in ProductCollection(products=[]).model_dump(mode="json") + + +def test_product_collection_is_named_for_its_stapi_type() -> None: + assert ProductCollection.model_fields["stapi_type"].default == "ProductCollection" diff --git a/stapi-pydantic/tests/test_shared.py b/stapi-pydantic/tests/test_shared.py index 3974c9b..11b2c63 100644 --- a/stapi-pydantic/tests/test_shared.py +++ b/stapi-pydantic/tests/test_shared.py @@ -18,84 +18,6 @@ ) -def test_link_serialization_schema_is_structured() -> None: - schema = Link.model_json_schema(mode="serialization") - assert {"href", "rel"} <= set(schema["required"]) - assert "href" in schema["properties"] - - -def test_link_json_dump_omits_none_fields() -> None: - link = Link(href="https://example.com/orders/1", rel="self") - dumped = link.model_dump(mode="json") - assert dumped["rel"] == "self" - assert "title" not in dumped - assert "body" not in dumped - - -def test_link_preserves_extra_fields() -> None: - link = Link.model_validate({"href": "https://example.com", "rel": "self", "vendor:hint": "x"}) - assert link.model_dump(mode="json")["vendor:hint"] == "x" - - -def test_root_response_serialization_schema_marks_spec_required_fields() -> None: - schema = RootResponse.model_json_schema(mode="serialization") - assert {"id", "conformsTo", "description", "links"} <= set(schema["required"]) - - -def test_conformance_serialization_schema_requires_conforms_to() -> None: - schema = Conformance.model_json_schema(mode="serialization", by_alias=True) - assert "conformsTo" in schema.get("required", []) - - -def test_conformance_dumps_by_alias() -> None: - # Conformance is nested in responses, and model config is not inherited by - # nested models, so it has to serialize by alias itself. - assert Conformance(conforms_to=["a"]).model_dump(mode="json") == {"conformsTo": ["a"]} - assert Conformance(conforms_to=["a"]).model_dump() == {"conformsTo": ["a"]} - - -#: Models whose spec-OPTIONAL fields must be omitted rather than published as -#: null, and so must not appear in the serialization-required set. -OMIT_WHEN_UNSET = [ - (Link, {"href": "https://example.test", "rel": "self"}, {"type", "title", "method", "headers", "body"}), - (Provider, {"name": "n"}, {"description", "roles", "url"}), - # conformsTo is spec-REQUIRED on a Product, so it is always published and - # is deliberately not in this set. - ( - Product, - {"id": "p", "description": "d", "license": "proprietary"}, - {"title", "keywords", "providers"}, - ), - (RootResponse, {"id": "x", "description": "d"}, {"title"}), -] - - -@pytest.mark.parametrize(("model", "minimal", "optional"), OMIT_WHEN_UNSET, ids=lambda v: getattr(v, "__name__", "")) -def test_unset_optional_fields_are_omitted_not_published_as_null( - model: type[BaseModel], minimal: dict[str, Any], optional: set[str] -) -> None: - """A spec-OPTIONAL field is absent when unset, and so is not required.""" - dumped = model(**minimal).model_dump(mode="json") - assert optional.isdisjoint(dumped), f"unset optional fields present: {sorted(optional & set(dumped))}" - - schema = model.model_json_schema(mode="serialization") - assert optional.isdisjoint(schema.get("required", [])) - - -@pytest.mark.parametrize("model", [OrderCollection, OpportunityCollection], ids=lambda m: m.__name__) -def test_collection_bbox_is_omitted_when_there_is_no_extent(model: type[BaseModel]) -> None: - """A collection bbox is absent when unknown, not null, which is also what - keeps it out of the serialization-required set. - """ - assert "bbox" not in model(features=[]).model_dump(mode="json") - for mode in ("validation", "serialization"): - assert "bbox" not in model.model_json_schema(mode=mode).get("required", []) - - -#: Models whose spec-OPTIONAL fields must be omitted rather than published as -#: null, and so must not appear in the serialization-required set. - - def _number_matched_collections() -> list[tuple[type[BaseModel], dict[str, Any]]]: """Every exported model carrying the shared NumberMatched field. @@ -129,6 +51,35 @@ def test_number_matched_collections_were_discovered() -> None: assert all(payload for _, payload in NUMBER_MATCHED_COLLECTIONS) +def test_link_serialization_schema_is_structured() -> None: + schema = Link.model_json_schema(mode="serialization") + assert {"href", "rel"} <= set(schema["required"]) + assert "href" in schema["properties"] + + +def test_link_json_dump_omits_none_fields() -> None: + link = Link(href="https://example.com/orders/1", rel="self") + dumped = link.model_dump(mode="json") + assert dumped["rel"] == "self" + assert "title" not in dumped + assert "body" not in dumped + + +def test_link_preserves_extra_fields() -> None: + link = Link.model_validate({"href": "https://example.com", "rel": "self", "vendor:hint": "x"}) + assert link.model_dump(mode="json")["vendor:hint"] == "x" + + +def test_root_response_serialization_schema_marks_spec_required_fields() -> None: + schema = RootResponse.model_json_schema(mode="serialization") + assert {"id", "conformsTo", "description", "links"} <= set(schema["required"]) + + +def test_conformance_serialization_schema_requires_conforms_to() -> None: + schema = Conformance.model_json_schema(mode="serialization", by_alias=True) + assert "conformsTo" in schema.get("required", []) + + def test_every_aliased_model_dumps_by_alias() -> None: # a model that declares an alias but does not dump by it emits field names # that contradict its own published schema whenever it is dumped outside a @@ -156,6 +107,13 @@ def test_every_aliased_model_dumps_by_alias() -> None: assert {"Conformance.conforms_to", "Product.type_", "ProductCollection.number_matched"} <= set(checked) +def test_conformance_dumps_by_alias() -> None: + # Conformance is nested in responses, and model config is not inherited by + # nested models, so it has to serialize by alias itself. + assert Conformance(conforms_to=["a"]).model_dump(mode="json") == {"conformsTo": ["a"]} + assert Conformance(conforms_to=["a"]).model_dump() == {"conformsTo": ["a"]} + + @pytest.mark.parametrize(("model", "payload"), NUMBER_MATCHED_COLLECTIONS, ids=lambda v: getattr(v, "__name__", "")) def test_number_matched_round_trips_under_wire_name(model: type[BaseModel], payload: dict[str, Any]) -> None: collection = model.model_validate({**payload, "links": [], "numberMatched": 7}) @@ -185,6 +143,44 @@ def test_number_matched_stays_optional_and_aliased_in_json_schema( assert "numberMatched" not in serialization.get("required", []) +@pytest.mark.parametrize("model", [OrderCollection, OpportunityCollection], ids=lambda m: m.__name__) +def test_collection_bbox_is_omitted_when_there_is_no_extent(model: type[BaseModel]) -> None: + """A collection bbox is absent when unknown, not null, which is also what + keeps it out of the serialization-required set. + """ + assert "bbox" not in model(features=[]).model_dump(mode="json") + for mode in ("validation", "serialization"): + assert "bbox" not in model.model_json_schema(mode=mode).get("required", []) + + +#: Models whose spec-OPTIONAL fields must be omitted rather than published as +#: null, and so must not appear in the serialization-required set. +OMIT_WHEN_UNSET = [ + (Link, {"href": "https://example.test", "rel": "self"}, {"type", "title", "method", "headers", "body"}), + (Provider, {"name": "n"}, {"description", "roles", "url"}), + # conformsTo is spec-REQUIRED on a Product, so it is always published and + # is deliberately not in this set. + ( + Product, + {"id": "p", "description": "d", "license": "proprietary"}, + {"title", "keywords", "providers"}, + ), + (RootResponse, {"id": "x", "description": "d"}, {"title"}), +] + + +@pytest.mark.parametrize(("model", "minimal", "optional"), OMIT_WHEN_UNSET, ids=lambda v: getattr(v, "__name__", "")) +def test_unset_optional_fields_are_omitted_not_published_as_null( + model: type[BaseModel], minimal: dict[str, Any], optional: set[str] +) -> None: + """A spec-OPTIONAL field is absent when unset, and so is not required.""" + dumped = model(**minimal).model_dump(mode="json") + assert optional.isdisjoint(dumped), f"unset optional fields present: {sorted(optional & set(dumped))}" + + schema = model.model_json_schema(mode="serialization") + assert optional.isdisjoint(schema.get("required", [])) + + def test_parameterized_generics_are_named_readably() -> None: """Pydantic names a parameterization after the *repr* of its parameters, which for the `Geometry` union is 150 characters. Only the parameter spelling From 7a3baafa2cc5e6571edb1ba11a2bc292871831fb Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 6 Aug 2026 19:23:11 -0700 Subject: [PATCH 47/48] docs: record the 0.2.0 and 0.9.0 releases with migration guides The entries accumulated under `## [Unreleased]` as each change landed. They become `## [0.2.0]` and `## [0.9.0]`, each with an opening paragraph saying who the release breaks and how the **BREAKING** markers work. Each gets a `### Migrating` section: an ordered list of what a reader has to change, rather than a restatement of what happened. stapi-fastapi's is split by audience -- backend implementers, model users, and API callers -- because those three sets of tasks barely overlap. The stapi-pydantic migration tasks are repeated in stapi-fastapi's guide. They are duplication, deliberately: upgrading stapi-fastapi always drags the model changes with it, and sending a reader to a second changelog to find the other half of their upgrade is worse than saying it twice. stapi-pydantic's own changelog stays the detailed reference and is linked as such. --- stapi-fastapi/CHANGELOG.md | 81 ++++++++++++++++++++++++++++++++++++- stapi-pydantic/CHANGELOG.md | 53 +++++++++++++++++++++++- 2 files changed, 132 insertions(+), 2 deletions(-) diff --git a/stapi-fastapi/CHANGELOG.md b/stapi-fastapi/CHANGELOG.md index 01e4ba2..d44bf9f 100644 --- a/stapi-fastapi/CHANGELOG.md +++ b/stapi-fastapi/CHANGELOG.md @@ -4,7 +4,86 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.9.0] - 2026-08-07 + +The routers implement STAPI v0.2.0. This is a breaking release for anyone implementing a backend, generating a client, or calling the API: the backend protocols, the request bodies, and the published OpenAPI document all changed. Every item that will break existing code is marked **BREAKING** and says what to do about it. + +The request and response models come from stapi-pydantic 0.2.0. The model-level changes you have to make are repeated below so that everything a stapi-fastapi upgrade requires is in one place; [the stapi-pydantic changelog](../stapi-pydantic/CHANGELOG.md) has the full detail and the rationale for each. + +### Migrating + +If you implement a backend: + +1. Return a `Page` from every list backend. The token and total move out of the tuple and into fields. + + | Backend | Before | After | + | --- | --- | --- | + | `GetOrders` | `tuple[list[Order], Maybe[str], Maybe[int]]` | `Page[Order]` | + | `GetOrderStatuses` | `Maybe[tuple[list[OrderStatus], Maybe[str]]]` | `Maybe[Page[OrderStatus]]` | + | `SearchOpportunities` | `tuple[list[Opportunity], Maybe[str]]` | `Page[Opportunity]` | + | `GetOpportunitySearchRecords` | `tuple[list[OpportunitySearchRecord], Maybe[str]]` | `Page[OpportunitySearchRecord]` | + | `GetOpportunitySearchRecordStatuses` | `Maybe[list[OpportunitySearchStatus]]` | `Maybe[Page[OpportunitySearchStatus]]` | + | `GetOpportunityCollection` | `Maybe[OpportunityCollection]` | `Maybe[Page[Opportunity]]` | + + ```python + # before + return Success((orders, Some(token), Some(total))) + # after + return Success(Page(items=orders, next_token=Some(token), number_matched=Some(total))) + ``` + + `GetOpportunityCollection` no longer builds the collection: return the opportunities and any collection-level links, and the handler sets the collection's `id` and its `self`/`next` links. + +2. Accept `next` and `limit` in `GetOpportunitySearchRecordStatuses` and `GetOpportunityCollection`, which are now paginated. + +3. Return `PaginationTokenError` inside a `Failure` when a pagination token identifies no page. A bare `ValueError` is no longer read as a missing page, so an incidental one is now correctly a 500 rather than a 404. + +4. Pass camelCase keywords to `url_for`: `orderId=`, `searchRecordId=`, `opportunityCollectionId=`. Request URLs are unchanged; only the parameter names are. + +5. Rename `GET_OPPORTUNITY_SEARCH_RECORD_STATUSES` to `LIST_OPPORTUNITY_SEARCH_RECORD_STATUSES`, and `ProductRouter.pagination_link` to `search_pagination_link`. + +6. Declare `summary`, `tag` and `errors` on any `Route` you register yourself. `errors` is the set of error responses that route can actually produce, e.g. `errors=NOT_FOUND | SERVER_ERROR`, or `{}` for a route that produces none. + +7. Register each product id once. `RootRouter.add_product` now raises on a duplicate rather than silently leaving the first product's routes serving. + +8. Depend directly on `httpx`, `pygeofilter`, `nox`, `pydantic-settings` or `uvicorn` if your application imports them. They are no longer runtime dependencies of this library. + +If you use the models directly (from stapi-pydantic 0.2.0): + +1. Rename the classes that moved. The pre-0.2.0 compatibility aliases are gone, so these are import errors rather than deprecation warnings. + + | Before | After | + | --- | --- | + | `OrderPayload` | `OrderRequest` | + | `OpportunityPayload` | `OpportunityRequest` | + | `OrderSearchParameters` | `SearchParameters` | + | `OrderStatuses` | `OrderStatusCollection` | + | `OpportunitySearchRecords` | `OpportunitySearchRecordCollection` | + | `ProductsCollection` | `ProductCollection` | + +2. Nest search parameters inside requests: `OpportunityRequest(search_parameters=SearchParameters(...))` in place of top-level `datetime`, `geometry` and `filter`. + +3. Follow the fields that moved on response entities. + + | Before | After | + | --- | --- | + | `OrderProperties.search_parameters`, `.opportunity_properties`, `.order_parameters` | `OrderProperties.order_request` (a `StoredOrderRequest`) | + | `OpportunitySearchRecord.opportunity_request` | `OpportunitySearchRecord.search_parameters` | + | `OpportunitySearchRecordCollection.search_records` | `.records` | + +4. Rename `conformsTo` to `conforms_to` where you construct or read `Product` and `RootResponse` in Python, and replace `JsonSchemaModel` with `JsonSchema.from_model(YourModel)`. + +5. Iterate collections with `collection.iter()` and `collection.length`; supply `Product.description`, which is now required; and handle a plain `str` from `status_code`, or parameterize with your own `StrEnum` (`OrderStatus[MyCodes]`). + +6. Switch to `BoundedDatetimeInterval` anywhere you relied on both ends of an interval being present, and import `geojson_pydantic.geometries.Geometry` directly if you need `GeometryCollection`. + +If you call the API or generate a client: + +1. Nest request bodies. `POST /products/{productId}/opportunities` and `POST /products/{productId}/orders` take a `search_parameters` object in place of top-level `datetime`, `geometry` and `filter`; `order_parameters` is optional. + +2. Regenerate clients. Path parameters are camelCase, every `operationId` changed, and parameterized component names are now readable rather than 200-character reprs, so a client generated against 0.8.x binds to names that no longer exist. + +3. Expect a 422 for a `limit` below 1 (an over-large one is clamped, not rejected), a 400 (was 422) when a required queryable has no filter predicate, and the `search-records` rel on the landing page in place of `opportunity-search-records`. ### Added diff --git a/stapi-pydantic/CHANGELOG.md b/stapi-pydantic/CHANGELOG.md index 9b8584b..4b2d2d7 100644 --- a/stapi-pydantic/CHANGELOG.md +++ b/stapi-pydantic/CHANGELOG.md @@ -6,7 +6,57 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.2.0] - 2026-08-07 + +Models are aligned with STAPI v0.2.0. This is a breaking release: request and response shapes, several class names, and serialization behaviour all changed. Every item that will break existing code is marked **BREAKING** and says what to do about it. + +### Migrating + +1. Rename the classes that moved. The pre-0.2.0 compatibility aliases are gone, so these are import errors rather than deprecation warnings. + + | Before | After | + | --- | --- | + | `OrderPayload` | `OrderRequest` | + | `OpportunityPayload` | `OpportunityRequest` | + | `OrderSearchParameters` | `SearchParameters` | + | `OrderStatuses` | `OrderStatusCollection` | + | `OpportunitySearchRecords` | `OpportunitySearchRecordCollection` | + | `ProductsCollection` | `ProductCollection` | + +2. Nest search parameters inside requests. + + ```python + # before + OpportunityRequest(datetime=interval, geometry=point, filter=cql2) + # after + OpportunityRequest(search_parameters=SearchParameters(datetime=interval, geometry=point, filter=cql2)) + ``` + +3. Follow the fields that moved on response entities. + + | Before | After | + | --- | --- | + | `OrderProperties.search_parameters`, `.opportunity_properties`, `.order_parameters` | `OrderProperties.order_request` (a `StoredOrderRequest`) | + | `OpportunitySearchRecord.opportunity_request` | `OpportunitySearchRecord.search_parameters` | + | `OpportunitySearchRecordCollection.search_records` | `.records` | + +4. Rename `conformsTo` to `conforms_to` where you construct or read `Product` and `RootResponse` in Python. The wire name is unchanged: both still validate from either spelling and serialize as `conformsTo`. + +5. Replace `JsonSchemaModel` with `JsonSchema`. A model class no longer stands in for its own schema; build one with `JsonSchema.from_model(YourModel)`. + +6. Iterate collections with `collection.iter()` and `collection.length`. `iter(collection)`, `len(collection)` and `collection[i]` no longer work on `OrderCollection`. + +7. Read wire names out of dumps. `model_dump()` now emits `conformsTo`, `type` and `stapi_type` rather than the Python field names, so anything reading `dump["conforms_to"]` or `dump["type_"]` must change. + +8. Switch to `BoundedDatetimeInterval` anywhere you relied on both ends of an interval being present. `DatetimeInterval` now permits an open end and validates as `tuple[AwareDatetime | None, AwareDatetime | None]`. + +9. Handle a plain `str` from `status_code` on `OrderStatus` and `OpportunitySearchStatus`, or parameterize the model with your own `StrEnum` (`OrderStatus[MyCodes]`) to constrain it. + +10. Supply `Product.description`. It is required and no longer defaults to the empty string. + +11. Import `geojson_pydantic.geometries.Geometry` directly if you need `GeometryCollection`; the STAPI `Geometry` union is the six types the spec enumerates. + +12. Regenerate anything derived from the JSON Schema. Parameterized generics are published under readable component names (`OpportunityCollection[Geometry, MyProperties]`) in place of the previous 200-character reprs. ### Added @@ -108,6 +158,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), Initial release. [unreleased]: https://github.com/stapi-spec/pystapi/compare/stac-pydantic/stapi-pydantic%2Fv0.0.4...main +[0.2.0]: https://github.com/stapi-spec/pystapi/compare/stac-pydantic/stapi-pydantic%2Fv0.1.0...stapi-pydantic%2Fv0.2.0 [0.1.0]: https://github.com/stapi-spec/pystapi/compare/stac-pydantic/stapi-pydantic%2Fv0.0.4...stapi-pydantic%2Fv0.1.0 [0.0.4]: https://github.com/stapi-spec/pystapi/compare/stac-pydantic/stapi-pydantic%2Fv0.0.3...stapi-pydantic%2Fv0.0.4 [0.0.3]: https://github.com/stapi-spec/pystapi/compare/stac-pydantic/stapi-pydantic%2Fv0.0.2...stapi-pydantic%2Fv0.0.3 From 30b78201167ec3c88de43aa2f5854a6b7752c716 Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 6 Aug 2026 20:19:47 -0700 Subject: [PATCH 48/48] docs: state what the tests check, not what they used to catch Five comments described a prior state of the code rather than the code as it is, which is only legible to someone who remembers the bug. Three test docstrings narrated the defect they were written for -- `?self=` "used to 500", a repeated param "used to collapse to the last", a product's declaration that "must still depend" on the routes registered. Each now states the invariant it holds. A comment in the client's pagination mock was a sentence fragment describing the off-by-one it replaced; it now names the two values being compared. The same starlette note appeared in two files with different wordings, one of them temporal. They agree now. `pystapi-validator/README.md` was wrong in three places: the spec is supplied by the caller rather than fetched from a GitHub URL, there is no `BASE_URL` to edit, and `tests/validate_api.py` no longer exists under that name. It now documents the console script and the two environment variables the suite actually reads. --- pystapi-client/tests/conftest.py | 3 +- pystapi-validator/README.md | 37 +++++++++++++++++++------ stapi-fastapi/tests/shared.py | 4 +-- stapi-fastapi/tests/test_opportunity.py | 2 +- stapi-fastapi/tests/test_pagination.py | 7 +++-- 5 files changed, 39 insertions(+), 14 deletions(-) diff --git a/pystapi-client/tests/conftest.py b/pystapi-client/tests/conftest.py index cd3b066..e3b0c51 100644 --- a/pystapi-client/tests/conftest.py +++ b/pystapi-client/tests/conftest.py @@ -37,7 +37,8 @@ def mock_products_response(request: Request) -> Response: start_index = (page - 1) * int(limit) end_index = start_index + int(limit) products_limited["products"] = products_limited["products"][start_index:end_index] - # against the full fixture, not the page just sliced out of it + # `products` is the whole fixture; `products_limited` has already + # been sliced down to this page has_next_page = end_index < len(products["products"]) if has_next_page: products_limited["links"].append( diff --git a/pystapi-validator/README.md b/pystapi-validator/README.md index 2143c62..0862cd4 100644 --- a/pystapi-validator/README.md +++ b/pystapi-validator/README.md @@ -1,14 +1,17 @@ # pystapi-validator -This project provides API validation for STAPI FastAPI implementations using Schemathesis against the latest STAPI OpenAPI -specification. +This project provides API validation for STAPI FastAPI implementations using Schemathesis against an OpenAPI +specification you supply. ## Configuration -- The STAPI OpenAPI specification is fetched from: - [STAPI OpenAPI Spec](https://raw.githubusercontent.com/stapi-spec/stapi-spec/refs/heads/main/openapi.yaml) -- The base URL for the API being tested is set to `http://localhost:8000`. Update the `BASE_URL` in `tests/validate_api.py` - if your API is hosted elsewhere. +The OpenAPI document to validate against is supplied by the caller, so this checks conformance to the spec rather than +that a document matches the application it was exported from. + +- `STAPI_OPENAPI_SCHEMA` — path to the OpenAPI document. Required; without it the suite skips every check. +- `STAPI_BASE_URL` — base URL of the running server. Defaults to `http://localhost:8000`. + +The `pystapi-validator` console script sets both from its arguments, so you do not normally set them yourself. ## Setup @@ -18,10 +21,28 @@ specification. uv sync ``` -1. Run tests and generate report: +1. Start the server you want to validate, then run the validator against a document: + +```bash +uv run pystapi-validator path/to/openapi.yaml +``` + +Pass `--base-url` if the server is not on `http://localhost:8000`. + +1. To generate an HTML report, invoke pytest directly with the environment the console script would have set: ```bash -uv run pytest tests/validate_api.py --html=report.html --self-contained-html +STAPI_OPENAPI_SCHEMA=path/to/openapi.yaml \ + uv run pytest tests/test_validate_api.py --html=report.html --self-contained-html ``` 1. Open `report.html` in your browser to view the detailed test report. + +## Validating the stapi-fastapi test server + +`scripts/validate-stapi-fastapi` in the repository root starts the stapi-fastapi test application and runs this suite +against it. It takes the OpenAPI document as its one argument: + +```bash +scripts/validate-stapi-fastapi path/to/openapi.yaml +``` diff --git a/stapi-fastapi/tests/shared.py b/stapi-fastapi/tests/shared.py index a40a624..4473322 100644 --- a/stapi-fastapi/tests/shared.py +++ b/stapi-fastapi/tests/shared.py @@ -197,8 +197,8 @@ class MyOrderParameters(OrderParameters): ) # Declares the opportunity conformance classes itself, the way a provider -# publishing an async-search product would. What is actually advertised must -# still depend on which routes the router ends up registering. +# publishing an async-search product would. What is actually advertised depends +# on which routes the router ends up registering, not on this declaration. product_test_spotlight_async_opportunity_declared_conformances = Product( id="test-spotlight", title="Test Spotlight Product", diff --git a/stapi-fastapi/tests/test_opportunity.py b/stapi-fastapi/tests/test_opportunity.py index 19392c2..6b4a55b 100644 --- a/stapi-fastapi/tests/test_opportunity.py +++ b/stapi-fastapi/tests/test_opportunity.py @@ -72,7 +72,7 @@ def test_search_opportunities_rejects_limit_below_the_minimum( json={**opportunity_search, "limit": limit}, ) # 422 is spelled out: starlette renamed its constant for this status code, - # and the old name now raises a DeprecationWarning. + # so referencing either name warns on one version or breaks on the other. assert response.status_code == 422 diff --git a/stapi-fastapi/tests/test_pagination.py b/stapi-fastapi/tests/test_pagination.py index f4a9f3a..8fa50fc 100644 --- a/stapi-fastapi/tests/test_pagination.py +++ b/stapi-fastapi/tests/test_pagination.py @@ -165,7 +165,10 @@ def test_self_link_of_second_page_points_at_second_page(stapi_client: TestClient def test_self_link_survives_a_query_param_named_self(stapi_client: TestClient) -> None: - """Query param names are data, not Python keywords: `?self=` used to 500.""" + """A query param named `self` is a name like any other. + + It must not collide with the `self` of the method building the link. + """ res = stapi_client.get("/products", params={"self": "x"}) assert res.status_code == status.HTTP_200_OK, res.text @@ -175,7 +178,7 @@ def test_self_link_survives_a_query_param_named_self(stapi_client: TestClient) - def test_self_link_preserves_repeated_query_params(stapi_client: TestClient) -> None: - """A repeated query param keeps every value; it used to collapse to the last.""" + """A repeated query param keeps every value in the `self` link.""" res = stapi_client.get("/products", params=[("limit", "2"), ("a", "1"), ("a", "2")]) assert res.status_code == status.HTTP_200_OK, res.text