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/.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 ae04ce5..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] @@ -81,7 +81,11 @@ 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", + "root_router_kwargs", ] 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 d38c452..9a39a80 100644 --- a/pystapi-client/src/pystapi_client/client.py +++ b/pystapi-client/src/pystapi_client/client.py @@ -11,15 +11,18 @@ from pydantic import AnyUrl from stapi_pydantic import ( CQL2Filter, + Geometry, Link, Opportunity, OpportunityCollection, - OpportunityPayload, + OpportunityProperties, + OpportunityRequest, Order, OrderCollection, - OrderPayload, + OrderParameters, + OrderRequest, Product, - ProductsCollection, + ProductCollection, ) from pystapi_client.conformance import ConformanceClasses @@ -257,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 @@ -282,7 +324,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 @@ -302,10 +344,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 @@ -316,14 +357,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,8 +391,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] - # 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: @@ -393,8 +435,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: @@ -416,8 +457,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/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..e3b0c51 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,9 @@ 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 + # `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( { @@ -51,4 +53,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") 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/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/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/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/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" diff --git a/stapi-fastapi/CHANGELOG.md b/stapi-fastapi/CHANGELOG.md index 23e23da..d44bf9f 100644 --- a/stapi-fastapi/CHANGELOG.md +++ b/stapi-fastapi/CHANGELOG.md @@ -4,6 +4,141 @@ 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). +## [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 + +- `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` 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`. +- `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. +- 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 + +- **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. +- **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`. +- 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. +- 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 + +- 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. +- 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. + +### 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. + ## [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/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 aa74510..8ed544b 100644 --- a/stapi-fastapi/src/stapi_fastapi/backends/product_backend.py +++ b/stapi-fastapi/src/stapi_fastapi/backends/product_backend.py @@ -8,18 +8,18 @@ from returns.result import ResultE from stapi_pydantic import ( Opportunity, - OpportunityCollection, - OpportunityPayload, + OpportunityRequest, OpportunitySearchRecord, Order, - OrderPayload, + OrderRequest, ) +from stapi_fastapi.pagination import Page from stapi_fastapi.routers.product_router import ProductRouter SearchOpportunities = Callable[ - [ProductRouter, OpportunityPayload, str | None, int, Request], - Coroutine[Any, Any, ResultE[tuple[list[Opportunity], Maybe[str]]]], # type: ignore + [ProductRouter, OpportunityRequest, str | None, int, Request], + Coroutine[Any, Any, ResultE[Page[Opportunity]]], # type: ignore ] """ Type alias for an async function that searches for ordering opportunities for the given @@ -27,18 +27,17 @@ 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. 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: @@ -47,7 +46,7 @@ """ SearchOpportunitiesAsync = Callable[ - [ProductRouter, OpportunityPayload, Request], + [ProductRouter, OpportunityRequest, Request], Coroutine[Any, Any, ResultE[OpportunitySearchRecord]], ] """ @@ -56,7 +55,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: @@ -68,35 +67,42 @@ """ 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. """ -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/backends/root_backend.py b/stapi-fastapi/src/stapi_fastapi/backends/root_backend.py index f13e5cc..0f61c60 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,11 @@ 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[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. """ @@ -53,10 +54,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 +67,31 @@ 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[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. """ 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[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. """ @@ -112,21 +112,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/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/errors.py b/stapi-fastapi/src/stapi_fastapi/errors.py index 1ae870c..e7d6656 100644 --- a/stapi-fastapi/src/stapi_fastapi/errors.py +++ b/stapi-fastapi/src/stapi_fastapi/errors.py @@ -9,9 +9,21 @@ 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): 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/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/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/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/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/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/base.py b/stapi-fastapi/src/stapi_fastapi/routers/base.py index 717bb1a..c21d626 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/base.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/base.py @@ -1,13 +1,182 @@ -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_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. +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): + #: 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)) + + 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. + """ + 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=media_type) + + def page_links( + self, + request: Request, + 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`. + + Backend-supplied links come first: they describe the collection rather + than this page of it. + """ + 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, media_type=media_type, **path_params)) + return links diff --git a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py index 430ae00..2256f4c 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py @@ -13,18 +13,17 @@ 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, - JsonSchemaModel, + Geometry, + JsonSchema, Link, OpportunityCollection, - OpportunityPayload, - OpportunitySearchRecord, + OpportunityRequest, Order, - OrderPayload, + OrderRequest, OrderStatus, Prefer, ) @@ -33,11 +32,20 @@ ) from stapi_fastapi.conformance import PRODUCT as PRODUCT_CONFORMACES -from stapi_fastapi.constants import TYPE_JSON -from stapi_fastapi.errors import NotFoundError, QueryablesError +from stapi_fastapi.constants import TYPE_GEOJSON, TYPE_JSON +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, Limit, NextToken, clamp_limit 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, + Responses, + Route, + StapiFastapiBaseRouter, +) from stapi_fastapi.routers.route_names import ( CONFORMANCE, CREATE_ORDER, @@ -46,6 +54,7 @@ GET_PRODUCT, GET_QUERYABLES, SEARCH_OPPORTUNITIES, + Tag, ) from stapi_fastapi.routers.utils import json_link @@ -70,24 +79,28 @@ 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) + # 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): - # FIXME ruff is complaining that the init is too complex - def __init__( # noqa + def __init__( self, product: Product, root_router: RootRouter, @@ -98,42 +111,48 @@ 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}", - 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=f"{self.root_router.name}:{self.product.id}:{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=f"{self.root_router.name}:{self.product.id}:{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=f"{self.root_router.name}:{self.product.id}:{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` @@ -144,75 +163,124 @@ 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 ] - self.add_api_route( - path="/orders", - endpoint=_create_order, - name=f"{self.root_router.name}:{self.product.id}:{CREATE_ORDER}", - methods=["POST"], - response_class=GeoJSONResponse, - status_code=status.HTTP_201_CREATED, - summary="Create an order for the product", - tags=["Products"], - ) - - 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=f"{self.root_router.name}:{self.product.id}:{SEARCH_OPPORTUNITIES}", - methods=["POST"], + self.register_route( + Route( + name=CREATE_ORDER, + tag=Tag.ORDERS, + path="/orders", + endpoint=_create_order, + errors=BAD_REQUEST | SERVER_ERROR, + summary="Create an order for the product", + 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 - ], + status_code=status.HTTP_201_CREATED, responses={ 201: { - "model": OpportunitySearchRecord, - "content": {TYPE_JSON: {}}, - } + "headers": { + "Location": { + "description": "URL of the created Order.", + "schema": {"type": "string", "format": "uri"}, + }, + }, + }, }, - summary="Search Opportunities for the product", - tags=["Products"], + ) + ) + + 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, + errors=BAD_REQUEST | NOT_FOUND | SERVER_ERROR, + summary="Search Opportunities for the product", + 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 + ] + if product.supports_opportunity_search + else None + ), + responses=extra_responses, + ) ) 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=f"{self.root_router.name}:{self.product.id}:{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/{opportunityCollectionId}", + 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: 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 +293,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)), ), ) @@ -233,7 +301,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,28 +332,35 @@ async def search_opportunities( async def search_opportunities_sync( self, - search: OpportunityPayload, + search: OpportunityRequest, request: Request, 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) + + self.product.validate_required_queryables(search.search_parameters) links: list[Link] = [] match await self.product.search_opportunities( self, search, search.next, - search.limit, + 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.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(PaginationTokenError()): + raise NotFoundError(detail="Error finding pagination token") case Failure(e): logger.error( "An error occurred while searching opportunities: %s", @@ -298,20 +373,25 @@ 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(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, - search: OpportunityPayload, + search: OpportunityRequest, 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)) + 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) @@ -341,24 +421,25 @@ 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) -> 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 + async def create_order(self, payload: OrderRequest, request: Request, response: Response) -> Order: # type: ignore """ Create a new order. """ + self.product.validate_required_queryables(payload.search_parameters) match await self.product.create_order( self, payload, @@ -383,28 +464,39 @@ 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}"), + href=self.url_for(request, self.route_name(CREATE_ORDER)), rel="create-order", type=TYPE_JSON, method="POST", body=opp_req.search_body(), ) - def pagination_link(self, request: Request, opp_req: OpportunityPayload, pagination_token: str) -> Link: + def search_pagination_link(self, request: Request, opp_req: OpportunityRequest, pagination_token: str) -> Link: + """The `next` link of a paged synchronous opportunity search. + + 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", - type=TYPE_JSON, + # a next link only appears on the synchronous result, which is an + # Opportunity Collection + type=TYPE_GEOJSON, method="POST", body=body, ) async def get_opportunity_collection( - self, opportunity_collection_id: str, 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. @@ -412,22 +504,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, - f"{self.root_router.name}:{self.product.id}:{GET_OPPORTUNITY_COLLECTION}", - opportunity_collection_id=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/src/stapi_fastapi/routers/root_router.py b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py index c33abc1..64bf637 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py @@ -4,19 +4,19 @@ 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, Link, OpportunitySearchRecord, - OpportunitySearchRecords, - OpportunitySearchStatus, + OpportunitySearchRecordCollection, + OpportunitySearchStatusCollection, Order, OrderCollection, OrderStatus, - OrderStatuses, - ProductsCollection, + OrderStatusCollection, + ProductCollection, RootResponse, ) @@ -30,21 +30,25 @@ ) 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 +from stapi_fastapi.query_params import DEFAULT_LIMIT, Limit, NextToken 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, 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, LIST_PRODUCTS, ROOT, + Tag, ) from stapi_fastapi.routers.utils import json_link @@ -60,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", @@ -69,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 @@ -78,6 +89,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] = [] @@ -88,96 +100,115 @@ def __init__( # added. self.product_routers: dict[str, ProductRouter] = {} - self.add_api_route( - "/", - self.get_root, - methods=["GET"], - name=f"{self.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=f"{self.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=f"{self.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=f"{self.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=f"{self.name}:{GET_ORDER}", - response_class=GeoJSONResponse, - tags=["Orders"], + self.register_route( + Route( + name=GET_ORDER, + tag=Tag.ORDERS, + path="/orders/{orderId}", + 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: + 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=f"{self.name}:{LIST_ORDER_STATUSES}", - tags=["Orders"], + self.register_route( + Route( + name=LIST_ORDER_STATUSES, + tag=Tag.ORDERS, + path="/orders/{orderId}/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=f"{self.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=f"{self.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/{searchRecordId}", + 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=f"{self.name}:{GET_OPPORTUNITY_SEARCH_RECORD_STATUSES}", - summary="Get an Opportunity Search Record statuses by ID", - tags=["Opportunities"], - ) + 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 = list(_conformances) + self.conformances = sorted(_conformances) 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 +219,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, ), ] @@ -200,64 +231,53 @@ def get_root(self, request: Request) -> RootResponse: if self.supports_async_opportunity_search: links.append( json_link( - "opportunity-search-records", - self.url_for(request, f"{self.name}:{LIST_OPPORTUNITY_SEARCH_RECORDS}"), + "search-records", + self.url_for(request, self.route_name(LIST_OPPORTUNITY_SEARCH_RECORDS)), ), ) return RootResponse( id="STAPI API", - conformsTo=self.conformances, + conforms_to=self.conformances, links=links, ) 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: NextToken = None, limit: Limit = DEFAULT_LIMIT) -> ProductCollection: start = 0 - limit = min(limit, 100) - 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, f"{self.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)) - return ProductsCollection( - products=[self.product_routers[product_id].get_product(request) for product_id in ids], - links=links, + 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=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 - self, request: Request, next: str | None = None, limit: int = 10 + 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, f"{self.name}:{LIST_ORDERS}", next_, limit)) - case Maybe.empty: - pass - match maybe_orders_count: - case Some(x): - orders_count = x - case Maybe.empty: - pass - case Failure(ValueError()): + return OrderCollection( + features=page.items, + 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(PaginationTokenError()): raise NotFoundError(detail="Error finding pagination token") case Failure(e): logger.error( @@ -271,15 +291,9 @@ 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: str, request: Request) -> Order[OrderStatus]: + 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)): @@ -302,27 +316,21 @@ 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, - ) -> OrderStatuses: # type: ignore - links: list[Link] = [] + next: NextToken = None, + limit: Limit = DEFAULT_LIMIT, + ) -> 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, f"{self.name}:{LIST_ORDER_STATUSES}", next_, limit, order_id=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()): + case Failure(PaginationTokenError()): raise NotFoundError("Error finding pagination token") case Failure(e): logger.error( @@ -335,9 +343,14 @@ async def get_order_statuses( ) case _: raise AssertionError("Expected code to be unreachable") - return OrderStatuses(statuses=statuses, links=links) 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}") @@ -345,51 +358,42 @@ 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), orderId=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), 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), - ), ] - - 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)) - - 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), - ) + 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: str | None = None, limit: int = 10 - ) -> OpportunitySearchRecords: - links: list[Link] = [] + self, request: Request, next: NextToken = None, limit: Limit = DEFAULT_LIMIT + ) -> OpportunitySearchRecordCollection: match await self._get_opportunity_search_records(next, limit, request): - case Success((records, maybe_pagination_token)): - for record in records: - record.links.append(self.opportunity_search_record_self_link(record, request)) - match maybe_pagination_token: - case Some(next_): - links.append( - self.pagination_link( - request, f"{self.name}:{LIST_OPPORTUNITY_SEARCH_RECORDS}", next_, limit - ) - ) - case Maybe.empty: - pass - case Failure(ValueError()): + case Success(page): + for record in page.items: + 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), + number_matched=page.number_matched.value_or(None), + ) + case Failure(PaginationTokenError()): raise NotFoundError(detail="Error finding pagination token") case Failure(e): logger.error( @@ -402,15 +406,16 @@ async def get_opportunity_search_records( ) case _: raise AssertionError("Expected code to be unreachable") - return OpportunitySearchRecords(search_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`. + Get the Opportunity Search Record with `searchRecordId`. """ 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") @@ -428,16 +433,32 @@ 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 - ) -> 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", @@ -454,8 +475,8 @@ 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}", - search_record_id=search_record_id, + self.route_name(GET_OPPORTUNITY_SEARCH_RECORD), + searchRecordId=search_record_id, ) def opportunity_search_record_self_link( @@ -463,6 +484,27 @@ def opportunity_search_record_self_link( ) -> 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, + 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 + @property def _get_order_statuses(self) -> GetOrderStatuses: # type: ignore if not self.__get_order_statuses: @@ -487,6 +529,16 @@ 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 + + @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-fastapi/src/stapi_fastapi/routers/route_names.py b/stapi-fastapi/src/stapi_fastapi/routers/route_names.py index 3283c57..45e92d3 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/route_names.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/route_names.py @@ -1,10 +1,11 @@ +from enum import StrEnum + # Root ROOT = "root" CONFORMANCE = "conformance" # 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 +13,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" @@ -21,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/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 f902fb1..2b5b631 100644 --- a/stapi-fastapi/tests/backends.py +++ b/stapi-fastapi/tests/backends.py @@ -1,34 +1,61 @@ 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.errors import PaginationTokenError +from stapi_fastapi.pagination import Page from stapi_fastapi.routers.product_router import ProductRouter from stapi_pydantic import ( + Geometry, Opportunity, OpportunityCollection, - OpportunityPayload, + OpportunityProperties, + OpportunityRequest, OpportunitySearchRecord, OpportunitySearchStatus, OpportunitySearchStatusCode, Order, - OrderPayload, + OrderParameters, OrderProperties, - OrderSearchParameters, + OrderRequest, OrderStatus, OrderStatusCode, + 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: + """The offset a pagination token names, as these mocks encode it. + + 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"not a pagination token: {token!r}") from None + 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 @@ -36,14 +63,16 @@ 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] - 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 +89,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) @@ -69,43 +98,46 @@ async def mock_get_order_statuses( return Success(Nothing) if next: - start = int(next) + start = _offset(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) -async def mock_create_order(product_router: ProductRouter, payload: OrderPayload, 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, ) 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, + 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=[], ) @@ -119,39 +151,50 @@ 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, -) -> ResultE[tuple[list[Opportunity], Maybe[str]]]: +) -> ResultE[Page[AnyOpportunity]]: try: start = 0 limit = min(limit, 100) if next: - start = int(next) + start = _offset(next) end = start + limit - opportunities = [o.model_copy(update=search.model_dump()) 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)) + # 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] + ] + 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) async def mock_search_opportunities_async( product_router: ProductRouter, - search: OpportunityPayload, + search: OpportunityRequest, request: Request, ) -> ResultE[OpportunitySearchRecord]: try: - received_status = OpportunitySearchStatus( + received_status: OpportunitySearchStatus = OpportunitySearchStatus( timestamp=datetime.now(UTC), status_code=OpportunitySearchStatusCode.received, ) search_record = OpportunitySearchRecord( id=str(uuid4()), product_id=product_router.product.id, - opportunity_request=search, + search_parameters=search.search_parameters, status=received_status, links=[], ) @@ -162,11 +205,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[AnyOpportunity]]]: 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) @@ -176,20 +243,23 @@ 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) search_records = request.state._opportunities_db.get_search_records() if next: - start = int(next) + start = _offset(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) @@ -204,11 +274,27 @@ 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 = 0 + limit = min(limit, 100) + if next: + start = _offset(next) + 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/conftest.py b/stapi-fastapi/tests/conftest.py index b0be83c..53b6ffd 100644 --- a/stapi-fastapi/tests/conftest.py +++ b/stapi-fastapi/tests/conftest.py @@ -7,16 +7,14 @@ import pytest from fastapi import FastAPI from fastapi.testclient import TestClient -from stapi_fastapi.conformance import API, PRODUCT +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, @@ -25,6 +23,7 @@ mock_get_orders, ) from .shared import ( + AssertLink, InMemoryOpportunityDB, InMemoryOrderDB, create_mock_opportunity, @@ -41,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, @@ -51,15 +52,33 @@ def mock_products(request) -> list[Product]: @pytest.fixture -def mock_opportunities() -> list[Opportunity]: +def mock_opportunities() -> list[AnyOpportunity]: 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], + mock_opportunities: list[AnyOpportunity], + root_router_kwargs: dict[str, Any], ) -> Generator[TestClient, None, None]: @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[dict[str, Any]]: @@ -71,7 +90,8 @@ 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, @@ -79,7 +99,6 @@ 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] root_router.add_product(mock_product) app = FastAPI(lifespan=lifespan) @@ -93,7 +112,8 @@ 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 async def lifespan(app: FastAPI) -> AsyncIterator[dict[str, Any]]: @@ -106,7 +126,8 @@ 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, @@ -121,7 +142,6 @@ 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] root_router.add_product(mock_product) app = FastAPI(lifespan=lifespan) @@ -143,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], @@ -151,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 @@ -168,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" @@ -176,17 +196,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/shared.py b/stapi-fastapi/tests/shared.py index 7be0c1d..4473322 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, @@ -25,9 +24,12 @@ OrderStatus, Provider, ProviderRole, + Queryables, ) from .backends import ( + AnyOpportunity, + AnyOpportunityCollection, mock_create_order, mock_get_opportunity_collection, mock_search_opportunities, @@ -41,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] = {} @@ -65,33 +81,35 @@ def put_order_status(self, order_id: str, status: OrderStatus) -> None: class InMemoryOpportunityDB: def __init__(self) -> None: self._search_records: dict[str, OpportunitySearchRecord] = {} - 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: - if search_record := self.get_search_record(search_id): - return [deepcopy(search_record.status)] - else: + 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: + """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) -class MyProductQueryables(BaseModel): +class MyProductQueryables(Queryables): off_nadir: int @@ -121,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 - conformsTo=[PRODUCT.geojson_point], ) product_test_spotlight = Product( @@ -139,7 +156,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 +174,7 @@ class MyOrderParameters(OrderParameters): queryables=MyProductQueryables, opportunity_properties=MyOpportunityProperties, order_parameters=MyOrderParameters, - conformsTo=[PRODUCT.geojson_point, PRODUCT.opportunities], + conforms_to=[PRODUCT.geojson_point], ) @@ -176,7 +193,28 @@ class MyOrderParameters(OrderParameters): queryables=MyProductQueryables, opportunity_properties=MyOpportunityProperties, order_parameters=MyOrderParameters, - conformsTo=[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 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", + 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( @@ -194,7 +232,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_test_satellite_provider_sync_opportunity = Product( @@ -212,11 +250,32 @@ class MyOrderParameters(OrderParameters): queryables=MyProductQueryables, opportunity_properties=MyOpportunityProperties, order_parameters=MyOrderParameters, - conformsTo=[PRODUCT.geojson_point, PRODUCT.opportunities], + conforms_to=[PRODUCT.geojson_point], +) + + +# 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() -> Opportunity: +def create_mock_opportunity() -> AnyOpportunity: now = datetime.now(UTC) # Use timezone-aware datetime start = now end = start + timedelta(days=5) @@ -246,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 @@ -285,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 new file mode 100644 index 0000000..3d44df1 --- /dev/null +++ b/stapi-fastapi/tests/test_capabilities.py @@ -0,0 +1,120 @@ +"""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 + + +@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" + + +@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_datetime_interval.py b/stapi-fastapi/tests/test_datetime_interval.py index e4aa3a3..b9a99d9 100644 --- a/stapi-fastapi/tests/test_datetime_interval.py +++ b/stapi-fastapi/tests/test_datetime_interval.py @@ -1,20 +1,20 @@ -from datetime import UTC, datetime, timedelta +from datetime import UTC, datetime, timedelta, tzinfo from itertools import product from zoneinfo import ZoneInfo 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 -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 new file mode 100644 index 0000000..6c30029 --- /dev/null +++ b/stapi-fastapi/tests/test_openapi.py @@ -0,0 +1,123 @@ +"""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 + +from .shared import product_test_spotlight_sync_async_opportunity + +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"]) + + +@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 + 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"] + 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() + + +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. + """ + 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 cffe525..6b4a55b 100644 --- a/stapi-fastapi/tests/test_opportunity.py +++ b/stapi-fastapi/tests/test_opportunity.py @@ -1,13 +1,18 @@ +from typing import Any + import pytest from fastapi.testclient import TestClient +from stapi_fastapi.query_params import MAX_LIMIT from stapi_pydantic import ( 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" @@ -33,18 +38,16 @@ 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, - 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, @@ -55,3 +58,49 @@ 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, + # so referencing either name warns on one version or breaks on the other. + 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 + + +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 ea34eb1..0f04795 100644 --- a/stapi-fastapi/tests/test_opportunity_async.py +++ b/stapi-fastapi/tests/test_opportunity_async.py @@ -1,11 +1,12 @@ from collections.abc import Callable -from datetime import UTC, datetime, timedelta -from typing import Any +from datetime import UTC, datetime +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 ( Link, OpportunityCollection, @@ -14,6 +15,9 @@ OpportunitySearchStatusCode, ) +from .backends import ( + mock_get_opportunity_search_record_statuses, +) from .shared import ( create_mock_opportunity, find_link, @@ -23,7 +27,125 @@ 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_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]) @@ -38,7 +160,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 +175,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 +197,38 @@ 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_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]) @@ -147,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, @@ -170,7 +401,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]) @@ -196,7 +427,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", ) ) @@ -234,7 +465,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 @@ -282,34 +515,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,22 +529,39 @@ 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, 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, url="/searches/opportunities", method="GET", limit=limit, - target="search_records", + target="records", expected_returns=expected_returns, ) + + +@pytest.mark.mock_products([product_test_spotlight_async_opportunity]) +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 d33291c..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,38 +7,61 @@ 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 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" - assert res.json() == {"type": "FeatureCollection", "features": [], "links": [], "numberMatched": 314} + assert res.json() == { + "type": "FeatureCollection", + "stapi_type": "OrderCollection", + "stapi_version": STAPI_VERSION, + "features": [], + "links": [ + { + "href": "http://stapiserver/orders", + "rel": "self", + "type": "application/geo+json", + }, + ], + "numberMatched": 314, + } @pytest.fixture -def create_order_payloads() -> list[OrderPayload]: +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 = 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=REQUIRED_QUERYABLE_FILTER, ), - filter=None, order_parameters=MyOrderParameters(s3_path="s3://my-bucket"), ) payloads.append(payload) @@ -48,7 +72,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[MyOrderParameters]], ) -> Response: res = stapi_client.post( f"products/{product_id}/orders", @@ -71,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']}", @@ -100,24 +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].geometry.coordinates), + "coordinates": list(payload_geometry.coordinates), } - assert order["properties"]["search_parameters"]["geometry"] == { + assert order["properties"]["order_request"]["search_parameters"]["geometry"] == { "type": "Point", - "coordinates": list(create_order_payloads[0].geometry.coordinates), + "coordinates": list(payload_geometry.coordinates), } - assert order["properties"]["search_parameters"]["datetime"] == create_order_payloads[0].model_dump()["datetime"] + assert ( + order["properties"]["order_request"]["search_parameters"]["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") @@ -130,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", @@ -147,11 +184,14 @@ def setup_orders_pagination(stapi_client: TestClient, create_order_payloads) -> return orders -@pytest.mark.parametrize("limit", [0, 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 +@pytest.mark.parametrize("limit", [1, 2, 4]) +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, @@ -170,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), @@ -192,7 +232,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, @@ -203,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, @@ -227,3 +265,34 @@ 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 + + +@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 new file mode 100644 index 0000000..8fa50fc --- /dev/null +++ b/stapi-fastapi/tests/test_pagination.py @@ -0,0 +1,476 @@ +"""Tests for the shared pagination query parameters, `self` links and totals.""" + +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 + +import pytest +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 +from stapi_pydantic import ( + OpportunityCollection, + OpportunitySearchStatus, + OpportunitySearchStatusCode, + Order, + OrderStatus, + OrderStatusCode, +) + +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, + product_test_spotlight_sync_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 + + +@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) + + +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: + """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 + + 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 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 + + 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" + + +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"] + + 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( + 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"] + + +@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 + + +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 1fee345..8122cc9 100644 --- a/stapi-fastapi/tests/test_product.py +++ b/stapi-fastapi/tests/test_product.py @@ -1,13 +1,22 @@ +from typing import Any + import pytest 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 ( + 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 @@ -15,7 +24,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) @@ -23,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" @@ -46,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" @@ -59,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" @@ -73,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" @@ -83,51 +92,50 @@ 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, 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, @@ -145,9 +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 _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) + + +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/CHANGELOG.md b/stapi-pydantic/CHANGELOG.md index ec9a7c2..4b2d2d7 100644 --- a/stapi-pydantic/CHANGELOG.md +++ b/stapi-pydantic/CHANGELOG.md @@ -6,6 +6,119 @@ 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). +## [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 + +- `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`. +- `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). +- `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]`. +- `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. +- `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. +- **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. +- **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. +- **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 + +- `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. +- `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. + +### Removed + +- **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. + ## [0.1.0] - 2025-12-18 ### Changed @@ -45,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 diff --git a/stapi-pydantic/pyproject.toml b/stapi-pydantic/pyproject.toml index 8fc6e50..2a16c73 100644 --- a/stapi-pydantic/pyproject.toml +++ b/stapi-pydantic/pyproject.toml @@ -1,14 +1,15 @@ [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"] +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/__init__.py b/stapi-pydantic/src/stapi_pydantic/__init__.py index 44ecbd0..043ca5b 100644 --- a/stapi-pydantic/src/stapi_pydantic/__init__.py +++ b/stapi-pydantic/src/stapi_pydantic/__init__.py @@ -1,62 +1,74 @@ from .conformance import Conformance -from .datetime_interval import DatetimeInterval -from .filter import CQL2Filter -from .json_schema_model import JsonSchemaModel +from .constants import STAPI_VERSION +from .datetime_interval import BoundedDatetimeInterval, DatetimeInterval +from .filter import CQL2Filter, cql2_property_names +from .geometry import Geometry +from .json_schema import JsonSchema from .opportunity import ( Opportunity, OpportunityCollection, - OpportunityPayload, OpportunityProperties, + OpportunityRequest, OpportunitySearchRecord, - OpportunitySearchRecords, + OpportunitySearchRecordCollection, OpportunitySearchStatus, OpportunitySearchStatusCode, + OpportunitySearchStatusCollection, Prefer, ) from .order import ( + BaseOrderParameters, Order, OrderCollection, OrderParameters, - OrderPayload, OrderProperties, - OrderSearchParameters, + OrderRequest, OrderStatus, OrderStatusCode, - OrderStatuses, + OrderStatusCollection, + StoredOrderRequest, ) -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 from .shared import Link __all__ = [ + "Geometry", + "BaseOrderParameters", + "BoundedDatetimeInterval", "Conformance", "CQL2Filter", "DatetimeInterval", - "JsonSchemaModel", + "JsonSchema", "Link", "Opportunity", "OpportunityCollection", - "OpportunityPayload", "OpportunityProperties", + "OpportunityRequest", "OpportunitySearchRecord", - "OpportunitySearchRecords", + "OpportunitySearchRecordCollection", "OpportunitySearchStatus", "OpportunitySearchStatusCode", + "OpportunitySearchStatusCollection", "Order", "OrderCollection", "OrderParameters", - "OrderPayload", "OrderProperties", - "OrderSearchParameters", + "OrderRequest", "OrderStatus", "OrderStatusCode", - "OrderStatuses", + "OrderStatusCollection", "Prefer", "Product", - "ProductsCollection", + "ProductCollection", "Provider", "ProviderRole", "Queryables", "RootResponse", + "SearchParameters", + "StoredOrderRequest", + "STAPI_VERSION", + "cql2_property_names", ] 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/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/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/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/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/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/src/stapi_pydantic/opportunity.py b/stapi-pydantic/src/stapi_pydantic/opportunity.py index a20a9fc..2717dc6 100644 --- a/stapi-pydantic/src/stapi_pydantic/opportunity.py +++ b/stapi-pydantic/src/stapi_pydantic/opportunity.py @@ -1,34 +1,58 @@ +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 geojson_pydantic.geometries import Geometry from pydantic import AwareDatetime, BaseModel, ConfigDict, Field - -from .datetime_interval import DatetimeInterval -from .filter import CQL2Filter -from .shared import Link +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, + UNSET_BBOX, + ComputedBBox, + DerivedCollectionBBox, + DerivedItemBBox, + Link, + NumberMatched, + OptionalBBox, + StapiGenericModel, + omitted_when_none, +) # 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") -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") @@ -38,15 +62,29 @@ def body(self) -> dict[str, Any]: P = TypeVar("P", bound=OpportunityProperties) -class Opportunity(Feature[G, P]): +class Opportunity(Feature[G, P], StapiGenericModel, DerivedItemBBox): + model_config = STAPI_RESPONSE_CONFIG + + id: str | None = omitted_when_none() type: Literal["Feature"] = "Feature" + 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]], StapiGenericModel, 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 = None + id: str | None = omitted_when_none() + number_matched: NumberMatched = None class OpportunitySearchStatusCode(StrEnum): @@ -57,25 +95,55 @@ 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(StapiGenericModel, 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) class OpportunitySearchRecord(BaseModel): + model_config = STAPI_RESPONSE_CONFIG + id: str product_id: str - opportunity_request: OpportunityPayload + search_parameters: SearchParameters status: OpportunitySearchStatus + stapi_type: Literal["OpportunitySearchRecord"] = "OpportunitySearchRecord" + stapi_version: str = STAPI_VERSION + links: list[Link] = Field(default_factory=list) + + +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) + number_matched: NumberMatched = None + +class OpportunitySearchStatusCollection(BaseModel): + model_config = STAPI_RESPONSE_CONFIG -class OpportunitySearchRecords(BaseModel): - search_records: list[OpportunitySearchRecord] + stapi_type: Literal["OpportunitySearchStatusCollection"] = "OpportunitySearchStatusCollection" + 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 159b341..d54af51 100644 --- a/stapi-pydantic/src/stapi_pydantic/order.py +++ b/stapi-pydantic/src/stapi_pydantic/order.py @@ -1,36 +1,52 @@ from __future__ import annotations 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 geojson_pydantic.geometries import Geometry +from geojson_pydantic import Feature, FeatureCollection from pydantic import ( AwareDatetime, BaseModel, ConfigDict, Field, StrictStr, - field_validator, ) +from typing_extensions import TypeVar as DefaultTypeVar from .constants import STAPI_VERSION -from .datetime_interval import DatetimeInterval -from .filter import CQL2Filter -from .opportunity import OpportunityProperties -from .shared import Link +from .geometry import Geometry +from .search_parameters import SearchParameters +from .shared import ( + STAPI_RESPONSE_CONFIG, + STAPI_RESPONSE_CONFIG_ALLOW_EXTRA, + UNSET_BBOX, + ComputedBBox, + DerivedCollectionBBox, + DerivedItemBBox, + Link, + NumberMatched, + OptionalBBox, + StapiGenericModel, + omitted_when_none, +) + + +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") -Props = TypeVar("Props", bound=dict[str, Any] | BaseModel) -Geom = TypeVar("Geom", bound=Geometry) +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) @@ -50,57 +66,79 @@ 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(StapiGenericModel, 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 OrderStatusCollection(StapiGenericModel, Generic[T]): + model_config = STAPI_RESPONSE_CONFIG -class OrderStatuses(BaseModel, Generic[T]): + stapi_type: Literal["OrderStatusCollection"] = "OrderStatusCollection" + stapi_version: str = STAPI_VERSION statuses: list[T] links: list[Link] = Field(default_factory=list) + number_matched: NumberMatched = None + + +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. + """ -class OrderSearchParameters(BaseModel): - datetime: DatetimeInterval - geometry: Geometry - # TODO: validate the CQL2 filter? - filter: CQL2Filter | None = None # type: ignore [type-arg] + model_config = STAPI_RESPONSE_CONFIG_ALLOW_EXTRA + search_parameters: SearchParameters + order_parameters: BaseOrderParameters = Field(default_factory=BaseOrderParameters) + + +class OrderProperties(StapiGenericModel, Generic[T]): + model_config = STAPI_RESPONSE_CONFIG_ALLOW_EXTRA -class OrderProperties(BaseModel, Generic[T]): product_id: str created: AwareDatetime status: T + order_request: StoredOrderRequest - search_parameters: OrderSearchParameters - opportunity_properties: dict[str, Any] - order_parameters: dict[str, Any] - - model_config = ConfigDict(extra="allow") +class Order(Feature[Geometry, OrderProperties[T]], StapiGenericModel, DerivedItemBBox, Generic[T]): + model_config = STAPI_RESPONSE_CONFIG -# derived from geojson_pydantic.Feature -class Order(_GeoJsonBase, Generic[T]): # We need to enforce that orders have an id defined, as that is required to # retrieve them via the API id: StrictStr @@ -109,49 +147,31 @@ class Order(_GeoJsonBase, 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) - __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]], StapiGenericModel, DerivedCollectionBBox, 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]] + 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 - ) - - 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) + number_matched: NumberMatched = None - def __getitem__(self, index: int) -> Order[T]: - """get feature at a given index""" - return self.features[index] +class OrderRequest(StapiGenericModel, Generic[ORP]): + """STAPI Order Request Object. -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] + 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/src/stapi_pydantic/product.py b/stapi-pydantic/src/stapi_pydantic/product.py index 54b946f..dc41507 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, NumberMatched, 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 = "" - description: str = "" - keywords: list[str] = Field(default_factory=list) + title: str = omitted_when_empty(default="") + description: str + 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: @@ -48,7 +51,11 @@ 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] + number_matched: NumberMatched = None 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/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/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/src/stapi_pydantic/shared.py b/stapi-pydantic/src/stapi_pydantic/shared.py index 51558a8..a53c727 100644 --- a/stapi-pydantic/src/stapi_pydantic/shared.py +++ b/stapi-pydantic/src/stapi_pydantic/shared.py @@ -1,32 +1,169 @@ -from typing import Any +from typing import TYPE_CHECKING, Annotated, Any, Self, TypeAlias, cast +from geojson_pydantic.types import BBox from pydantic import ( + AliasChoices, AnyUrl, BaseModel, ConfigDict, - SerializerFunctionWrapHandler, - model_serializer, + Field, + model_validator, ) +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 +# 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"} + + +#: 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. + + `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) + + +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) + + +# 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 + + +# 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 - 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_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"} 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" diff --git a/stapi-pydantic/tests/test_opportunity.py b/stapi-pydantic/tests/test_opportunity.py index 922e9dd..6af79fd 100644 --- a/stapi-pydantic/tests/test_opportunity.py +++ b/stapi-pydantic/tests/test_opportunity.py @@ -1,7 +1,229 @@ -from stapi_pydantic import OpportunityProperties +from typing import Any + +import pydantic +import pytest +from geojson_pydantic.geometries import Point +from stapi_pydantic import ( + Opportunity, + OpportunityCollection, + OpportunityProperties, + OpportunityRequest, + OpportunitySearchRecord, + OpportunitySearchRecordCollection, + OpportunitySearchStatus, + OpportunitySearchStatusCollection, + 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_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. + """ + 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 + + +SEARCH_RECORD_DICT = { + "id": "search-1", + "product_id": "umbra_spotlight", + "search_parameters": SEARCH_PARAMS, + "status": { + "timestamp": "2024-04-18T11:00:00Z", + "status_code": "received", + "links": [], + }, +} + + +def test_opportunity_search_record_request_field() -> None: + 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(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_collection() -> None: + status = OpportunitySearchStatus.model_validate(SEARCH_RECORD_DICT["status"]) + collection = OpportunitySearchStatusCollection(statuses=[status]) + dumped = collection.model_dump(mode="json") + assert dumped["stapi_type"] == "OpportunitySearchStatusCollection" + + +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": { + "datetime": "2024-04-18T10:56:00Z/2024-04-25T10:56:00Z", + "product_id": "umbra_spotlight", + }, +} + + +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) + + +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 + 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 + + +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 83c4778..d3d697f 100644 --- a/stapi-pydantic/tests/test_order.py +++ b/stapi-pydantic/tests/test_order.py @@ -1,6 +1,24 @@ import datetime +from enum import StrEnum +from typing import Any -from stapi_pydantic import OrderStatus, OrderStatusCode +import pydantic +import pytest +from stapi_pydantic import ( + BaseOrderParameters, + Order, + OrderCollection, + OrderParameters, + OrderRequest, + OrderStatus, + OrderStatusCode, + 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: @@ -10,3 +28,255 @@ def test_order_status_new() -> None: 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): + 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"]) + + +class RequiredParams(OrderParameters): + delivery_format: str + + +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}) + + +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_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_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: + 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_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_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 new file mode 100644 index 0000000..ef4fcf8 --- /dev/null +++ b/stapi-pydantic/tests/test_product.py @@ -0,0 +1,48 @@ +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: + 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() + + +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_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_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() 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 diff --git a/stapi-pydantic/tests/test_shared.py b/stapi-pydantic/tests/test_shared.py new file mode 100644 index 0000000..11b2c63 --- /dev/null +++ b/stapi-pydantic/tests/test_shared.py @@ -0,0 +1,213 @@ +from typing import Annotated, Any + +import pytest +import stapi_pydantic +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, +) + + +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_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 + # 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) + + +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}) + 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", []) + + +@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 + 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 diff --git a/uv.lock b/uv.lock index 56e280c..3e3faed 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" @@ -97,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" @@ -293,18 +293,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 +390,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 +725,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 +874,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" @@ -1221,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" @@ -1278,24 +1338,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" @@ -1369,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" @@ -1495,46 +1648,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" @@ -1804,7 +1917,7 @@ docs = [ [[package]] name = "pystapi-client" -version = "0.0.1" +version = "0.0.2" source = { editable = "pystapi-client" } dependencies = [ { name = "click" }, @@ -1855,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'" }, @@ -1869,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]] @@ -1943,15 +2056,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 +2137,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" @@ -2503,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" }, @@ -2520,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]] @@ -2573,24 +2587,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,30 +2608,27 @@ 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" }, ] [[package]] name = "stapi-pydantic" -version = "0.1.0" +version = "0.2.0" source = { editable = "stapi-pydantic" } dependencies = [ { name = "cql2" }, { name = "geojson-pydantic" }, { name = "pydantic" }, + { name = "typing-extensions" }, ] [package.dev-dependencies] @@ -2634,6 +2641,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] @@ -2665,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" @@ -2723,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" @@ -2786,18 +2794,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" @@ -3077,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" }, +]