diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b2feffa..37dc613 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -19,8 +19,8 @@ jobs: - uses: astral-sh/setup-uv@v5 - name: Sync run: uv sync - - name: Lint - run: scripts/lint + - name: Pre-Commit Hooks + run: uv run pre-commit run --all-files - name: Test run: uv run pytest - name: Docs @@ -28,6 +28,7 @@ jobs: - uses: actions/upload-pages-artifact@v3 with: path: site/ + docs: name: Deploy docs runs-on: ubuntu-latest diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..e934da6 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,85 @@ +repos: + - repo: local + hooks: + + - id: ruff-check + name: Lint with ruff + entry: uv run ruff check --fix + language: system + types: [python] + pass_filenames: false + verbose: true + + - id: ruff-format + name: Format with ruff + entry: uv run ruff format + language: system + types: [python] + pass_filenames: false + verbose: true + + - id: mypy + name: Check typing with mypy + entry: uv run mypy + language: system + types: [python] + pass_filenames: false + verbose: true + + - id: pymarkdown + name: Markdownlint + description: Run markdownlint on Markdown files + entry: uv run pymarkdown scan + language: system + files: \.(md|mdown|markdown)$ + + - id: check-added-large-files + name: Check for added large files + entry: uv run check-added-large-files + language: system + + - id: check-toml + name: Check Toml + entry: uv run check-toml + language: system + types: [toml] + + - id: check-yaml + name: Check Yaml + entry: uv run check-yaml + language: system + types: [yaml] + exclude: ^mkdocs.yml$ + + - id: mixed-line-ending + name: Check mixed line endings + entry: uv run mixed-line-ending + language: system + types: [text] + stages: [pre-commit, pre-push, manual] + + - id: end-of-file-fixer + name: Fix End of Files + entry: uv run end-of-file-fixer + language: system + types: [text] + stages: [pre-commit, pre-push, manual] + + - id: trailing-whitespace + name: Trim Trailing Whitespace + entry: uv run trailing-whitespace-fixer + language: system + types: [text] + stages: [pre-commit, pre-push, manual] + + - id: check-merge-conflict + name: Check merge conflicts + entry: uv run check-merge-conflict + language: system + + - id: no-commit-to-branch + name: Check not committting to main + entry: uv run no-commit-to-branch + language: system + args: ["--branch", "main"] + pass_filenames: false diff --git a/RELEASING.md b/RELEASING.md index 2e79ae0..6bb33e6 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -4,8 +4,8 @@ 2. Determine the next version, following [semantic versioning](https://semver.org/) 3. Create a release branch: `git checkout -b release/{package}-v{version}` 4. Update that package's CHANGELOG with: - - A new header with the new version - - A new link at the bottom of the CHANGELOG for that header + - A new header with the new version + - A new link at the bottom of the CHANGELOG for that header 5. `git push -u origin` 6. Once approved, merge the PR 7. `git checkout main && git pull && git tag {package}/v{version} && git push {package}/v{version}` diff --git a/pyproject.toml b/pyproject.toml index 944e98f..ef1f51a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,13 +6,19 @@ readme = "README.md" requires-python = ">=3.10" dependencies = [ "stapi-pydantic", + "stapi-fastapi", ] [dependency-groups] dev = [ + "pytest>=8.1.1", + "pytest-coverage>=0.0", "mypy>=1.15.0", "pytest>=8.3.5", "ruff>=0.11.2", + "pymarkdownlnt>=0.9.25", + "pre-commit>=4.2.0", + "pre-commit-hooks>=5.0.0", ] docs = [ "mkdocs-material>=9.6.11", @@ -23,11 +29,61 @@ docs = [ default-groups = ["dev", "docs"] [tool.uv.workspace] -members = ["stapi-pydantic"] +members = ["stapi-pydantic", "stapi-fastapi"] [tool.uv.sources] stapi-pydantic.workspace = true +stapi-fastapi.workspace = true + +[tool.ruff] +line-length = 120 + +[tool.ruff.format] +quote-style = 'double' + +[tool.ruff.lint] +select = [ + "E", # pydocstyle error + "W", # pydocstyle warning + "F", # Pyflakes + "I", # isort + "UP", # pyupgrade + "C9" # mccabe complexity +] + +[tool.ruff.lint.mccabe] +max-complexity = 8 # default 10 [tool.mypy] strict = true -files = "stapi-pydantic/src/stapi_pydantic/**/*.py" +files = [ + "stapi-pydantic/src/stapi_pydantic/**/*.py", + "stapi-fastapi/src/stapi_fastapi/**/*.py" +] + +[[tool.mypy.overrides]] +module = "pygeofilter.parsers.*" +ignore_missing_imports = true + +[tool.pymarkdown] +plugins.md013.line_length = 120 +plugins.md024.enabled = false # duplicate headers in changelog + +[tool.coverage.report] +show_missing = true +skip_empty = true +sort = "Cover" +omit = [ + "stapi-pydantic/tests/**/*.py", + "stapi-fastapi/tests/**/*.py", +] + +[tool.pytest.ini_options] +addopts="--cov=stapi-pydantic/src/stapi_pydantic --cov=stapi-fastapi/src/stapi_fastapi" +filterwarnings = [ + "ignore:The 'app' shortcut is now deprecated.:DeprecationWarning", + "ignore:Pydantic serializer warnings:UserWarning", +] +markers = [ + "mock_products", +] diff --git a/scripts/format b/scripts/format deleted file mode 100755 index 8b3e0c9..0000000 --- a/scripts/format +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env sh - -uv run ruff check --fix -uv run ruff format diff --git a/scripts/lint b/scripts/lint deleted file mode 100755 index 8902375..0000000 --- a/scripts/lint +++ /dev/null @@ -1,6 +0,0 @@ - -#!/usr/bin/env sh - -uv run ruff check -uv run ruff format --check -uv run mypy diff --git a/stapi-fastapi/CHANGELOG.md b/stapi-fastapi/CHANGELOG.md new file mode 100644 index 0000000..18858f0 --- /dev/null +++ b/stapi-fastapi/CHANGELOG.md @@ -0,0 +1,188 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) +and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). + +## unreleased + +### Fixed + +- Add parameter method as "POST" to create-order link + +## Added + +- Add constants for route names to be used in link href generation + +## [v0.6.0] - 2025-02-11 + +### Added + +- Added token-based pagination to `GET /orders`, `GET /products`, + `GET /orders/{order_id}/statuses`, and `POST /products/{product_id}/opportunities`. +- Optional and Extension STAPI Status Codes "scheduled", "held", "processing", "reserved", "tasked", + and "user_canceled" +- Asynchronous opportunity search. If the root router supports asynchronous opportunity + search, all products must support it. If asynchronous opportunity search is + supported, `POST` requests to the `/products/{productId}/opportunities` endpoint will + default to asynchronous opportunity search unless synchronous search is also supported + by the `product` and a `Prefer` header in the `POST` request is set to `wait`. +- Added the `/products/{productId}/opportunities/` and `/searches/opportunities` + endpoints to support asynchronous opportunity search. + +### Changed + +- Replaced the root and product backend Protocol classes with Callable type aliases to + enable future changes to make product opportunity searching, product ordering, and/or + asynchronous (stateful) product opportunity searching optional. +- Backend methods that support pagination now return tuples to include the pagination + token. +- Moved `OrderCollection` construction from the root backend to the `RootRouter` + `get_orders` method. +- Renamed `OpportunityRequest` to `OpportunityPayload` so that would not be confused as + being a subclass of the Starlette/FastAPI Request class. + +### Fixed + +- Opportunities Search result now has the search body in the `create-order` link. + +## [v0.5.0] - 2025-01-08 + +### Added + +- Endpoint `/orders/{order_id}/statuses` supporting `GET` for retrieving statuses. The entity returned by this conforms + to the change proposed in [stapi-spec#239](https://github.com/stapi-spec/stapi-spec/pull/239). +- RootBackend has new method `get_order_statuses` +- `*args`/`**kwargs` support in RootRouter's `add_product` allows to configure underlyinging ProductRouter + +### Changed + +- OrderRequest renamed to OrderPayload + +### Deprecated + +none + +### Removed + +- Endpoint `/orders/{order_id}/statuses` supporting `POST` for updating current status was added and then + removed prior to release +- RootBackend method `set_order_status` was added and then removed + +### Fixed + +- Exception logging + +### Security + +none + +## [v0.4.0] - 2024-12-11 + +### Added + +none + +### Changed + +- The concepts of Opportunity search Constraint and Opportunity search result Opportunity Properties are now separate, + recognizing that they have related attributes, but not neither the same attributes or the same values for those attributes. + +### Deprecated + +none + +### Removed + +none + +### Fixed + +none + +### Security + +none + +## [v0.3.0] - 2024-12-6 + +### Added + +none + +### Changed + +- OrderStatusCode and ProviderRole are now StrEnum instead of (str, Enum) +- All types using `Result[A, Exception]` have been replace with the equivalent type `ResultE[A]` +- Order and OrderCollection extend \_GeoJsonBase instead of Feature and FeatureCollection, to allow for tighter + constraints on fields + +### Deprecated + +none + +### Removed + +none + +### Fixed + +none + +### Security + +none + +## [v0.2.0] - 2024-11-23 + +### Added + +none + +### Changed + +- RootBackend and ProductBackend protocols use `returns` library types Result and Maybe instead of + raising exceptions. +- Create Order endpoint from `.../order` to `.../orders` +- Order field `id` must be a string, instead of previously allowing int. This is because while an + order ID may an integral numeric value, it is not a "number" in the sense that math will be performed + order ID values, so string represents this better. + +### Deprecated + +none + +### Removed + +none + +### Fixed + +none + +### Security + +none + +## [v0.1.0] - 2024-11-15 + +Initial release + +### Added + +- Conformance endpoint `/conformance` and root body `conformsTo` attribute. +- Field `product_id` to Opportunity and Order Properties +- Endpoint /product/{productId}/order-parameters. +- Links in Product entity to order-parameters and constraints endpoints for + that product. +- Add links `opportunities` and `create-order` to Product +- Add link `create-order` to OpportunityCollection + + +[v0.6.0]: https://github.com/stapi-spec/stapi-fastapi/tree/v0.6.0 +[v0.5.0]: https://github.com/stapi-spec/stapi-fastapi/tree/v0.5.0 +[v0.4.0]: https://github.com/stapi-spec/stapi-fastapi/tree/v0.4.0 +[v0.3.0]: https://github.com/stapi-spec/stapi-fastapi/tree/v0.3.0 +[v0.2.0]: https://github.com/stapi-spec/stapi-fastapi/tree/v0.2.0 +[v0.1.0]: https://github.com/stapi-spec/stapi-fastapi/tree/v0.1.0 diff --git a/stapi-fastapi/CONTRIBUTING.md b/stapi-fastapi/CONTRIBUTING.md new file mode 100644 index 0000000..9fcd02c --- /dev/null +++ b/stapi-fastapi/CONTRIBUTING.md @@ -0,0 +1,20 @@ +# Contributing + +TODO: Move most of the readme into here. + +## Design Principles + +### Route Names and Links + +The route names used in route defintions should be constants in `stapi_fastapi.routers.route_names`. This +makes it easier to populate these links in numerous places, including in apps that use this library. + +The general scheme for route names should follow: + +- `create-{x}` - create a resource `x` +- `create-{x}-for-{y}` - create a resource `x` as a sub-resource or associated resource of `y` +- `get-{x}` - retrieve a resource `x` +- `list-{xs}` - retrieve a list of resources of type `x` +- `list-{xs}-for-{y}` - retrieve a list of subresources of type `x` of a resource `y` +- `set-{x}` - update an existing resource `x` +- `set-{x}-for-{y}` - set a sub-resource `x` of a resource `y`, e.g., `set-status-for-order` diff --git a/stapi-fastapi/README.md b/stapi-fastapi/README.md new file mode 100644 index 0000000..627164f --- /dev/null +++ b/stapi-fastapi/README.md @@ -0,0 +1,77 @@ +# STAPI FastAPI - Sensor Tasking API with FastAPI + +WARNING: The whole [STAPI spec] is very much a work in progress, so things are +guaranteed to be not correct. + +## Usage + +STAPI FastAPI provides an `fastapi.APIRouter` which must be included in +`fastapi.FastAPI` instance. + +### Pagination + +4 endpoints currently offer pagination: +`GET`: `'/orders`, `/products`, `/orders/{order_id}/statuses` +`POST`: `/opportunities`. + +Pagination is token based and follows recommendations in the [STAC API pagination]. +Limit and token are passed in as query params for `GET` endpoints, and via the body as +separate key/value pairs for `POST` requests. + +If pagination is available and more records remain the response object will contain a +`next` link object that can be used to get the next page of results. No `next` `Link` +returned indicates there are no further records available. + +`limit` defaults to 10 and maxes at 100. + +## ADRs + +ADRs can be found in in the [adrs](./adrs/README.md) directory. + +## Development + +It's 2024 and we still need to pick our poison for a 2024 dependency management +solution. This project picks [poetry] for now. + +### Dev Setup + +Setup is managed with `poetry` and `pre-commit`. It's recommended to install +the project into a virtual environment. Bootstrapping a development environment +could look something like this: + +```commandline +python -m venv .venv +source .venv/bin/activate +pip install poetry # if not already installed to the system +poetry install --with dev +pre-commit install +``` + +### Test Suite + +A `pytest` based test suite is provided, and can be run simply using the +command `pytest`. + +### Dev Server + +This project cannot be run on its own because it does not have any backend +implementations. However, a minimal test implementation is provided in +[`./tests/application.py`](./tests/application.py). It can be run with +`uvicorn` as a way to interact with the API and to view the OpenAPI +documentation. Run it like so from the project root: + +```commandline +uvicorn application:app --app-dir ./tests --reload +``` + +With the `uvicorn` defaults the app should be accessible at +`http://localhost:8000`. + +### Implementing a backend + +- The test suite assumes the backend can be instantiated without any parameters + required by the constructor. + +[STAPI spec]: https://github.com/stapi-spec/stapi-spec +[poetry]: https://python-poetry.org/ +[STAC API pagination]: https://github.com/radiantearth/stac-api-spec/blob/release/v1.0.0/item-search/examples.md#paging-examples diff --git a/stapi-fastapi/RELEASE.md b/stapi-fastapi/RELEASE.md new file mode 100644 index 0000000..badb855 --- /dev/null +++ b/stapi-fastapi/RELEASE.md @@ -0,0 +1,28 @@ +# Releasing stapi-fastapi + +Publishing a stapi-fastapi package build to PyPI is triggered by publishing a +GitHub release. Tags are the [semantic version number](https://semver.org/) +proceeded by a `v`, such as `v0.0.1`. + +Release notes for the changes for each release should be tracked in +[CHANGELOG.md](./CHANGELOG.md). The notes for each release in GitHub should +generally match those in the CHANGELOG. + +## Release process + +1. Prepare the release. + 1. Figure out the next release version (following semantic versioning + conventions). + 1. Ensure [CHANGELOG.md](./CHANGELOG.md) has all necessary changes and + release notes under this next release version. Typically this step is + simply a matter of adding the header for the next version below + `Unreleased` then reviewing the list of changes therein. + 1. Ensure links are tracked as best as possible to relevant commits and/or + PRs. +1. Make a PR with the release prep changes, get it reviewed, and merge. +1. Draft a new GitHub release. + 1. Create a new tag with the release version prefixed with the character `v`. + 1. Title the release the same name as the tag. + 1. Copy the release notes from [CHANGELOG.md](./CHANGELOG.md) for this + release version into the release description. +1. Publish the release and ensure it builds and pushes to PyPI successfully. diff --git a/stapi-fastapi/adrs/README.md b/stapi-fastapi/adrs/README.md new file mode 100644 index 0000000..e68819c --- /dev/null +++ b/stapi-fastapi/adrs/README.md @@ -0,0 +1,3 @@ +# ADRs + +- [Constraints and Opportunity Properties](./constraints.md) diff --git a/stapi-fastapi/adrs/constraints.md b/stapi-fastapi/adrs/constraints.md new file mode 100644 index 0000000..da9c1ea --- /dev/null +++ b/stapi-fastapi/adrs/constraints.md @@ -0,0 +1,142 @@ +# Constraints and Opportunity Properties + +Previously, the Constraints and Opportunity Properties were the same +concept/representation. However, these represent distinct but related +attributes. Constraints represents the terms that can be used in the filter sent +to the Opportunities Search and Order Create endpoints. These are frequently the +same or related values that will be part of the STAC Items that are used to +fulfill an eventual Order. Opportunity Properties represent the expected range +of values that these STAC Items are expected to have. An opportunity is a +prediction about the future, and as such, the values for the Opportunity are +fuzzy. For example, the sun azimuth angle will (likely) be within a predictable +range of values, but the exact value will not be known until after the capture +occurs. Therefore, it is necessary to describe the Opportunity in a way that +describes these ranges. + +For example, for the concept of "off_nadir": + +The Constraint will be a term "off_nadir" that can be a value 0 to 45. +This is used in a CQL2 filter to the Opportunities Search endpoint to restrict the allowable values from 0 to 15 +The Opportunity that is returned from Search has an Opportunity Property +"off_nadir" with a description that the value of this field in the resulting +STAC Items will be between 4 and 8, which falls within the filter restriction of 0-15. +An Order is created with the original filter and other fields. +The Order is fulfilled with a STAC Item that has an off_nadir value of 4.8. + +As of Dec 2024, the STAPI spec says only that the Opportunity Properties must +have a datetime interval field `datetime` and a `product_id` field. The +remainder of the Opportunity description proprietary is up to the provider to +define. The example given this this repo for `off_nadir` is of a custom format +with a "minimum" and "maximum" field describing the limits. + +## JSON Schema + +Another option would be to use either a full JSON Schema definition for an +attribute value in the properties (e.g., `schema`) or individual attribute +definitions for the properties values. This option should be investigated +further in the future. + +JSON Schema is a well-defined specification language that can support this type +of data description. It is already used as the language for OGC API Queryables +to define the constraints on various terms that may be used in CQL2 expressions, +and likewise within STAPI for the Constraints that are used in Opportunity +Search and the Order Parameters that are set on an order. The use of JSON Schema +for Constraints (as with Queryables) is not to specify validation for a JSON +document, but rather to well-define a set of typed and otherwise-constrained +terms. Similarly, JSON Schema would be used for the Opportunity to define the +predicted ranges of properties within the Opportunity that is bound to fulfill +an Order. + +The geometry is not one of the fields that will be expressed as a schema +constraint, since this is part of the Opportunity/Item/Feature top-level. The +Opportunity geometry will express both uncertainty about the actual capture area +and a “maximum extent” of capture, e.g., a small area within a larger data strip +– this is intentionally vague so it can be used to express whatever semantics +the provider wants. + +The ranges of predicted Opportunity values can be expressed using JSON in the following way: + +- numeric value - number with const, enum, or minimum/maximum/exclusiveMinimum/exclusiveMaximum +- string value - string with const or enum +- datetime - type string using format date-time. The limitation wit this is that + these values are not treated with JSON Schema as temporal, but rather a string + pattern. As such, there is no formal way to define a temporal interval that the + instance value must be within. Instead, we will repurpose the description field + as a datetime interval in the same format as a search datetime field, e.g., + 2024-01-01T00:00:00Z/2024-01-07T00:00:00Z. Optionally, the pattern field can be + defined if the valid datetime values also match a regular expression, e.g., + 2024-01-0[123456]T.*, which while not as useful semantically as the description + interval does provide a formal validation of the resulting object, which waving + hand might be useful in some way waving hand. + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "schema.json", + "type": "object", + "properties": { + "datetime": { + "title": "Datetime", + "type": "string", + "format": "date-time", + "description": "2024-01-01T00:00:00Z/2024-01-07T00:00:00Z", + "pattern": "2024-01-0[123456]T.*" + }, + "sensor_type": { + "title": "Sensor Type", + "type": "string", + "const": "2" + }, + "craft_id": { + "title": "Spacecraft ID", + "type": "string", + "enum": [ + "7", + "8" + ] + }, + "view:sun_elevation": { + "title": "View:Sun Elevation", + "type": "number", + "minimum": 30.0, + "maximum": 35.0 + }, + "view:azimuth": { + "title": "View:Azimuth", + "type": "number", + "exclusiveMinimum": 104.0, + "exclusiveMaximum": 115.0 + }, + "view:off_nadir": { + "title": "View:Off Nadir", + "type": "number", + "minimum": 0.0, + "maximum": 9.0 + }, + "eo:cloud_cover": { + "title": "Eo:Cloud Cover", + "type": "number", + "minimum": 5.0, + "maximum": 15.0 + } + } +} +``` + +The Item that fulfills and Order placed on this Opportunity might be like: + +```json +{ + "type": "Feature", + ... + "properties": { + "datetime": "2024-01-01T00:00:00Z", + "sensor_type": "2", + "craft_id": "7", + "view:sun_elevation": 30.0, + "view:azimuth": 105.0, + "view:off_nadir": 9.0, + "eo:cloud_cover": 10.0 + } +} +``` diff --git a/stapi-fastapi/pyproject.toml b/stapi-fastapi/pyproject.toml new file mode 100644 index 0000000..3d5573b --- /dev/null +++ b/stapi-fastapi/pyproject.toml @@ -0,0 +1,39 @@ +[project] +name = "stapi-fastapi" +version = "0.6.0" +description = "Sensor Tasking API (STAPI) with FastAPI" +authors = [ + { name = "Christian Wygoda", email = "christian.wygoda@wygoda.net" }, + { name = "Phil Varner", email = "phil@philvarner.com" } +] +readme = "README.md" +license = "MIT" + +requires-python = ">=3.10" + +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", + "pyrfc3339>=1.1", + "types-pyRFC3339>=1.1.1", + "uvicorn>=0.29.0", +] + +[tool.hatch.build.targets.sdist] +include = ["src/stapi_fastapi"] + +[tool.hatch.build.targets.wheel] +include = ["src/stapi_fastapi"] + +[tool.hatch.build.targets.wheel.sources] +"src/stapi_fastapi" = "stapi_fastapi" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/stapi-fastapi/src/stapi_fastapi/__init__.py b/stapi-fastapi/src/stapi_fastapi/__init__.py new file mode 100644 index 0000000..44b98ae --- /dev/null +++ b/stapi-fastapi/src/stapi_fastapi/__init__.py @@ -0,0 +1,18 @@ +from .models import ( + Link, + OpportunityProperties, + Product, + Provider, + ProviderRole, +) +from .routers import ProductRouter, RootRouter + +__all__ = [ + "Link", + "OpportunityProperties", + "Product", + "ProductRouter", + "Provider", + "ProviderRole", + "RootRouter", +] diff --git a/stapi-fastapi/src/stapi_fastapi/backends/__init__.py b/stapi-fastapi/src/stapi_fastapi/backends/__init__.py new file mode 100644 index 0000000..bdaed6a --- /dev/null +++ b/stapi-fastapi/src/stapi_fastapi/backends/__init__.py @@ -0,0 +1,25 @@ +from .product_backend import ( + CreateOrder, + GetOpportunityCollection, + SearchOpportunities, + SearchOpportunitiesAsync, +) +from .root_backend import ( + GetOpportunitySearchRecord, + GetOpportunitySearchRecords, + GetOrder, + GetOrders, + GetOrderStatuses, +) + +__all__ = [ + "CreateOrder", + "GetOpportunityCollection", + "GetOpportunitySearchRecord", + "GetOpportunitySearchRecords", + "GetOrder", + "GetOrders", + "GetOrderStatuses", + "SearchOpportunities", + "SearchOpportunitiesAsync", +] diff --git a/stapi-fastapi/src/stapi_fastapi/backends/product_backend.py b/stapi-fastapi/src/stapi_fastapi/backends/product_backend.py new file mode 100644 index 0000000..d324ae0 --- /dev/null +++ b/stapi-fastapi/src/stapi_fastapi/backends/product_backend.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from typing import Any + +from fastapi import Request +from returns.maybe import Maybe +from returns.result import ResultE + +from stapi_fastapi.models.opportunity import ( + Opportunity, + OpportunityCollection, + OpportunityPayload, + OpportunitySearchRecord, +) +from stapi_fastapi.models.order import Order, OrderPayload +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 +] +""" +Type alias for an async function that searches for ordering opportunities for the given +search parameters. + +Args: + product_router (ProductRouter): The product router. + search (OpportunityPayload): 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 + - Returning returns.result.Failure[Exception] will result in a 500. + +Note: + Backends must validate search constraints and return + returns.result.Failure[stapi_fastapi.exceptions.ConstraintsException] if not valid. +""" + +SearchOpportunitiesAsync = Callable[ + [ProductRouter, OpportunityPayload, Request], + Coroutine[Any, Any, ResultE[OpportunitySearchRecord]], +] +""" +Type alias for an async function that starts an asynchronous search for ordering +opportunities for the given search parameters. + +Args: + product_router (ProductRouter): The product router. + search (OpportunityPayload): The search parameters. + request (Request): FastAPI's Request object. + +Returns: + - Should return returns.result.Success[OpportunitySearchRecord] + - Returning returns.result.Failure[Exception] will result in a 500. + +Backends must validate search constraints and return +returns.result.Failure[stapi_fastapi.exceptions.ConstraintsException] if not valid. +""" + +GetOpportunityCollection = Callable[ + [ProductRouter, str, Request], + Coroutine[Any, Any, ResultE[Maybe[OpportunityCollection]]], # type: ignore +] +""" +Type alias for an async function that retrieves the opportunity collection with +`opportunity_collection_id`. + +The opportunity collection is generated by an asynchronous opportunity search. + +Args: + product_router (ProductRouter): The product router. + opportunity_collection_id (str): The ID of the opportunity collection. + request (Request): FastAPI's Request object. + +Returns: + - Should return returns.result.Success[returns.maybe.Some[OpportunityCollection]] + 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[Exception] will result in a 500. +""" + +CreateOrder = Callable[[ProductRouter, OrderPayload, 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. + request (Request): FastAPI's Request object. + +Returns: + - Should return returns.result.Success[Order] + - Returning returns.result.Failure[Exception] will result in a 500. + +Note: + Backends must validate order payload and return + returns.result.Failure[stapi_fastapi.exceptions.ConstraintsException] if not valid. +""" diff --git a/stapi-fastapi/src/stapi_fastapi/backends/root_backend.py b/stapi-fastapi/src/stapi_fastapi/backends/root_backend.py new file mode 100644 index 0000000..9d2e459 --- /dev/null +++ b/stapi-fastapi/src/stapi_fastapi/backends/root_backend.py @@ -0,0 +1,112 @@ +from collections.abc import Callable, Coroutine +from typing import Any, TypeVar + +from fastapi import Request +from returns.maybe import Maybe +from returns.result import ResultE + +from stapi_fastapi.models.opportunity import OpportunitySearchRecord +from stapi_fastapi.models.order import ( + Order, + OrderStatus, +) + +GetOrders = Callable[ + [str | None, int, Request], + Coroutine[Any, Any, ResultE[tuple[list[Order], Maybe[str]]]], +] +""" +Type alias for an async function that returns a list of existing Orders. + +Args: + next (str | None): A pagination token. + limit (int): The maximum number of orders to return in a page. + 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 + - Returning returns.result.Failure[Exception] will result in a 500. +""" + +GetOrder = Callable[[str, Request], Coroutine[Any, Any, ResultE[Maybe[Order]]]] +""" +Type alias for an async function that gets details for the order with `order_id`. + +Args: + order_id (str): The order ID. + request (Request): FastAPI's Request object. + +Returns: + - Should return returns.result.Success[returns.maybe.Some[Order]] if order is found. + - Should return returns.result.Success[returns.maybe.Nothing] if the order is not found or if access is denied. + - Returning returns.result.Failure[Exception] will result in a 500. +""" + + +T = TypeVar("T", bound=OrderStatus) + + +GetOrderStatuses = Callable[ + [str, str | None, int, Request], + Coroutine[Any, Any, ResultE[Maybe[tuple[list[T], Maybe[str]]]]], +] +""" +Type alias for an async function that gets statuses for the order with `order_id`. + +Args: + order_id (str): The order ID. + 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: + 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.Nothing] if the order is not found or if access is denied. + - Returning returns.result.Failure[Exception] will result in a 500. +""" + +GetOpportunitySearchRecords = Callable[ + [str | None, int, Request], + Coroutine[Any, Any, ResultE[tuple[list[OpportunitySearchRecord], Maybe[str]]]], +] +""" +Type alias for an async function that gets 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. + +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 + - Returning returns.result.Failure[Exception] will result in a 500. +""" + +GetOpportunitySearchRecord = Callable[[str, Request], Coroutine[Any, Any, ResultE[Maybe[OpportunitySearchRecord]]]] +""" +Type alias for an async function that gets the OpportunitySearchRecord with +`search_record_id`. + +Args: + search_record_id (str): The ID of the OpportunitySearchRecord. + request (Request): FastAPI's Request object. + +Returns: + - Should return returns.result.Success[returns.maybe.Some[OpportunitySearchRecord]] 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[Exception] will result in a 500. +""" diff --git a/stapi-fastapi/src/stapi_fastapi/constants.py b/stapi-fastapi/src/stapi_fastapi/constants.py new file mode 100644 index 0000000..0fc2f4d --- /dev/null +++ b/stapi-fastapi/src/stapi_fastapi/constants.py @@ -0,0 +1,2 @@ +TYPE_JSON = "application/json" +TYPE_GEOJSON = "application/geo+json" diff --git a/stapi-fastapi/src/stapi_fastapi/exceptions.py b/stapi-fastapi/src/stapi_fastapi/exceptions.py new file mode 100644 index 0000000..32d512b --- /dev/null +++ b/stapi-fastapi/src/stapi_fastapi/exceptions.py @@ -0,0 +1,17 @@ +from typing import Any + +from fastapi import HTTPException, status + + +class StapiException(HTTPException): + pass + + +class ConstraintsException(StapiException): + def __init__(self, detail: Any) -> None: + super().__init__(status.HTTP_422_UNPROCESSABLE_ENTITY, detail) + + +class NotFoundException(StapiException): + def __init__(self, detail: Any | None = None) -> None: + super().__init__(status.HTTP_404_NOT_FOUND, detail) diff --git a/stapi-fastapi/src/stapi_fastapi/models/__init__.py b/stapi-fastapi/src/stapi_fastapi/models/__init__.py new file mode 100644 index 0000000..084f481 --- /dev/null +++ b/stapi-fastapi/src/stapi_fastapi/models/__init__.py @@ -0,0 +1,11 @@ +from .opportunity import OpportunityProperties +from .product import Product, Provider, ProviderRole +from .shared import Link + +__all__ = [ + "Link", + "OpportunityProperties", + "Product", + "Provider", + "ProviderRole", +] diff --git a/stapi-fastapi/src/stapi_fastapi/models/conformance.py b/stapi-fastapi/src/stapi_fastapi/models/conformance.py new file mode 100644 index 0000000..c3d9d4a --- /dev/null +++ b/stapi-fastapi/src/stapi_fastapi/models/conformance.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel, Field + +CORE = "https://stapi.example.com/v0.1.0/core" +OPPORTUNITIES = "https://stapi.example.com/v0.1.0/opportunities" +ASYNC_OPPORTUNITIES = "https://stapi.example.com/v0.1.0/async-opportunities" + + +class Conformance(BaseModel): + conforms_to: list[str] = Field(default_factory=list, serialization_alias="conformsTo") diff --git a/stapi-fastapi/src/stapi_fastapi/models/constraints.py b/stapi-fastapi/src/stapi_fastapi/models/constraints.py new file mode 100644 index 0000000..ad3e6de --- /dev/null +++ b/stapi-fastapi/src/stapi_fastapi/models/constraints.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel, ConfigDict + + +class Constraints(BaseModel): + model_config = ConfigDict(extra="allow") diff --git a/stapi-fastapi/src/stapi_fastapi/models/opportunity.py b/stapi-fastapi/src/stapi_fastapi/models/opportunity.py new file mode 100644 index 0000000..0257694 --- /dev/null +++ b/stapi-fastapi/src/stapi_fastapi/models/opportunity.py @@ -0,0 +1,83 @@ +from enum import StrEnum +from typing import Any, Literal, TypeVar + +from geojson_pydantic import Feature, FeatureCollection +from geojson_pydantic.geometries import Geometry +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field + +from stapi_fastapi.models.shared import Link +from stapi_fastapi.types.datetime_interval import DatetimeInterval +from stapi_fastapi.types.filter import CQL2Filter + + +# Copied and modified from https://github.com/stac-utils/stac-pydantic/blob/main/stac_pydantic/item.py#L11 +class OpportunityProperties(BaseModel): + datetime: DatetimeInterval + product_id: str + model_config = ConfigDict(extra="allow") + + +class OpportunityPayload(BaseModel): + datetime: DatetimeInterval + geometry: Geometry + filter: CQL2Filter | None = None + + next: str | None = None + limit: int = 10 + + model_config = ConfigDict(strict=True) + + def search_body(self) -> dict[str, Any]: + return self.model_dump(mode="json", include={"datetime", "geometry", "filter"}) + + def body(self) -> dict[str, Any]: + return self.model_dump(mode="json") + + +G = TypeVar("G", bound=Geometry) +P = TypeVar("P", bound=OpportunityProperties) + + +class Opportunity(Feature[G, P]): + type: Literal["Feature"] = "Feature" + links: list[Link] = Field(default_factory=list) + + +class OpportunityCollection(FeatureCollection[Opportunity[G, P]]): + type: Literal["FeatureCollection"] = "FeatureCollection" + links: list[Link] = Field(default_factory=list) + id: str | None = None + + +class OpportunitySearchStatusCode(StrEnum): + received = "received" + in_progress = "in_progress" + failed = "failed" + canceled = "canceled" + completed = "completed" + + +class OpportunitySearchStatus(BaseModel): + timestamp: AwareDatetime + status_code: OpportunitySearchStatusCode + reason_code: str | None = None + reason_text: str | None = None + links: list[Link] = Field(default_factory=list) + + +class OpportunitySearchRecord(BaseModel): + id: str + product_id: str + opportunity_request: OpportunityPayload + status: OpportunitySearchStatus + links: list[Link] = Field(default_factory=list) + + +class OpportunitySearchRecords(BaseModel): + search_records: list[OpportunitySearchRecord] + links: list[Link] = Field(default_factory=list) + + +class Prefer(StrEnum): + respond_async = "respond-async" + wait = "wait" diff --git a/stapi-fastapi/src/stapi_fastapi/models/order.py b/stapi-fastapi/src/stapi_fastapi/models/order.py new file mode 100644 index 0000000..121df01 --- /dev/null +++ b/stapi-fastapi/src/stapi_fastapi/models/order.py @@ -0,0 +1,131 @@ +from collections.abc import Iterator +from enum import StrEnum +from typing import Any, Generic, Literal, TypeVar + +from geojson_pydantic.base import _GeoJsonBase +from geojson_pydantic.geometries import Geometry +from pydantic import ( + AwareDatetime, + BaseModel, + ConfigDict, + Field, + StrictStr, + field_validator, +) + +from stapi_fastapi.models.opportunity import OpportunityProperties +from stapi_fastapi.models.shared import Link +from stapi_fastapi.types.datetime_interval import DatetimeInterval +from stapi_fastapi.types.filter import CQL2Filter + +Props = TypeVar("Props", bound=dict[str, Any] | BaseModel) +Geom = TypeVar("Geom", bound=Geometry) + + +class OrderParameters(BaseModel): + model_config = ConfigDict(extra="forbid") + + +OPP = TypeVar("OPP", bound=OpportunityProperties) +ORP = TypeVar("ORP", bound=OrderParameters) + + +class OrderStatusCode(StrEnum): + received = "received" + accepted = "accepted" + rejected = "rejected" + completed = "completed" + canceled = "canceled" + scheduled = "scheduled" + held = "held" + processing = "processing" + reserved = "reserved" + tasked = "tasked" + user_canceled = "user_canceled" + + +class OrderStatus(BaseModel): + timestamp: AwareDatetime + status_code: OrderStatusCode + reason_code: str | None = None + reason_text: str | None = None + links: list[Link] = Field(default_factory=list) + + model_config = ConfigDict(extra="allow") + + +class OrderStatuses[T: OrderStatus](BaseModel): + statuses: list[T] + links: list[Link] = Field(default_factory=list) + + +class OrderSearchParameters(BaseModel): + datetime: DatetimeInterval + geometry: Geometry + # TODO: validate the CQL2 filter? + filter: CQL2Filter | None = None + + +class OrderProperties[T: OrderStatus](BaseModel): + product_id: str + created: AwareDatetime + status: T + + search_parameters: OrderSearchParameters + opportunity_properties: dict[str, Any] + order_parameters: dict[str, Any] + + model_config = ConfigDict(extra="allow") + + +# derived from geojson_pydantic.Feature +class Order(_GeoJsonBase): + # We need to enforce that orders have an id defined, as that is required to + # retrieve them via the API + id: StrictStr + type: Literal["Feature"] = "Feature" + + geometry: Geometry = Field(...) + properties: OrderProperties[OrderStatus] = 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 + + +# derived from geojson_pydantic.FeatureCollection +class OrderCollection(_GeoJsonBase): + type: Literal["FeatureCollection"] = "FeatureCollection" + features: list[Order] + links: list[Link] = Field(default_factory=list) + + def __iter__(self) -> Iterator[Order]: # type: ignore [override] + """iterate over features""" + return iter(self.features) + + def __len__(self) -> int: + """return features length""" + return len(self.features) + + def __getitem__(self, index: int) -> Order: + """get feature at a given index""" + return self.features[index] + + +class OrderPayload(BaseModel, Generic[ORP]): + datetime: DatetimeInterval + geometry: Geometry + # TODO: validate the CQL2 filter? + filter: CQL2Filter | None = None + + order_parameters: ORP + + model_config = ConfigDict(strict=True) diff --git a/stapi-fastapi/src/stapi_fastapi/models/product.py b/stapi-fastapi/src/stapi_fastapi/models/product.py new file mode 100644 index 0000000..180455b --- /dev/null +++ b/stapi-fastapi/src/stapi_fastapi/models/product.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +from enum import StrEnum +from typing import TYPE_CHECKING, Any, Literal, Self + +from pydantic import AnyHttpUrl, BaseModel, Field + +from stapi_fastapi.models.opportunity import OpportunityProperties +from stapi_fastapi.models.order import OrderParameters +from stapi_fastapi.models.shared import Link + +if TYPE_CHECKING: + from stapi_fastapi.backends.product_backend import ( + CreateOrder, + GetOpportunityCollection, + SearchOpportunities, + SearchOpportunitiesAsync, + ) + + +type Constraints = BaseModel + + +class ProviderRole(StrEnum): + licensor = "licensor" + producer = "producer" + processor = "processor" + host = "host" + + +class Provider(BaseModel): + name: str + description: str | None = None + roles: list[ProviderRole] + url: AnyHttpUrl + + # 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: + super().__init__(url=url, **kwargs) + + +class Product(BaseModel): + type_: Literal["Product"] = Field(default="Product", alias="type") + conformsTo: list[str] = Field(default_factory=list) + id: str + title: str = "" + description: str = "" + keywords: list[str] = Field(default_factory=list) + license: str + providers: list[Provider] = Field(default_factory=list) + links: list[Link] = Field(default_factory=list) + + # we don't want to include these in the model fields + _constraints: type[Constraints] + _opportunity_properties: type[OpportunityProperties] + _order_parameters: type[OrderParameters] + _create_order: CreateOrder + _search_opportunities: SearchOpportunities | None + _search_opportunities_async: SearchOpportunitiesAsync | None + _get_opportunity_collection: GetOpportunityCollection | None + + def __init__( + self, + *args: Any, + constraints: type[Constraints], + opportunity_properties: type[OpportunityProperties], + order_parameters: type[OrderParameters], + create_order: CreateOrder, + search_opportunities: SearchOpportunities | None = None, + search_opportunities_async: SearchOpportunitiesAsync | None = None, + get_opportunity_collection: GetOpportunityCollection | None = None, + **kwargs: Any, + ) -> None: + super().__init__(*args, **kwargs) + + if bool(search_opportunities_async) != bool(get_opportunity_collection): + raise ValueError( + "Both the `search_opportunities_async` and `get_opportunity_collection` " + "arguments must be provided if either is provided" + ) + + self._constraints = constraints + self._opportunity_properties = opportunity_properties + self._order_parameters = order_parameters + self._create_order = create_order + self._search_opportunities = search_opportunities + self._search_opportunities_async = search_opportunities_async + self._get_opportunity_collection = get_opportunity_collection + + @property + def create_order(self) -> CreateOrder: + return self._create_order + + @property + def search_opportunities(self) -> SearchOpportunities: + if not self._search_opportunities: + raise AttributeError("This product does not support opportunity search") + return self._search_opportunities + + @property + def search_opportunities_async(self) -> SearchOpportunitiesAsync: + if not self._search_opportunities_async: + raise AttributeError("This product does not support async opportunity search") + return self._search_opportunities_async + + @property + def get_opportunity_collection(self) -> GetOpportunityCollection: + if not self._get_opportunity_collection: + raise AttributeError("This product does not support async opportunity search") + return self._get_opportunity_collection + + @property + def constraints(self) -> type[Constraints]: + return self._constraints + + @property + def opportunity_properties(self) -> type[OpportunityProperties]: + return self._opportunity_properties + + @property + def order_parameters(self) -> type[OrderParameters]: + return self._order_parameters + + @property + def supports_opportunity_search(self) -> bool: + return self._search_opportunities is not None + + @property + def supports_async_opportunity_search(self) -> bool: + return self._search_opportunities_async is not None and self._get_opportunity_collection is not None + + def with_links(self, links: list[Link] | None = None) -> Self: + if not links: + return self + + new = self.model_copy(deep=True) + new.links.extend(links) + return new + + +class ProductsCollection(BaseModel): + type_: Literal["ProductCollection"] = Field(default="ProductCollection", alias="type") + links: list[Link] = Field(default_factory=list) + products: list[Product] diff --git a/stapi-fastapi/src/stapi_fastapi/models/root.py b/stapi-fastapi/src/stapi_fastapi/models/root.py new file mode 100644 index 0000000..0a4480c --- /dev/null +++ b/stapi-fastapi/src/stapi_fastapi/models/root.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel, Field + +from stapi_fastapi.models.shared 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/models/shared.py b/stapi-fastapi/src/stapi_fastapi/models/shared.py new file mode 100644 index 0000000..5564a79 --- /dev/null +++ b/stapi-fastapi/src/stapi_fastapi/models/shared.py @@ -0,0 +1,32 @@ +from typing import Any + +from pydantic import ( + AnyUrl, + BaseModel, + ConfigDict, + SerializerFunctionWrapHandler, + model_serializer, +) + + +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 + + 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: AnyUrl | str, **kwargs: Any) -> None: + super().__init__(href=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-fastapi/src/stapi_fastapi/py.typed b/stapi-fastapi/src/stapi_fastapi/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/stapi-fastapi/src/stapi_fastapi/responses.py b/stapi-fastapi/src/stapi_fastapi/responses.py new file mode 100644 index 0000000..4a6d551 --- /dev/null +++ b/stapi-fastapi/src/stapi_fastapi/responses.py @@ -0,0 +1,7 @@ +from fastapi.responses import JSONResponse + +from stapi_fastapi.constants import TYPE_GEOJSON + + +class GeoJSONResponse(JSONResponse): + media_type = TYPE_GEOJSON diff --git a/stapi-fastapi/src/stapi_fastapi/routers/__init__.py b/stapi-fastapi/src/stapi_fastapi/routers/__init__.py new file mode 100644 index 0000000..3919e6b --- /dev/null +++ b/stapi-fastapi/src/stapi_fastapi/routers/__init__.py @@ -0,0 +1,7 @@ +from .product_router import ProductRouter +from .root_router import RootRouter + +__all__ = [ + "ProductRouter", + "RootRouter", +] diff --git a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py new file mode 100644 index 0000000..ade0b69 --- /dev/null +++ b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py @@ -0,0 +1,433 @@ +from __future__ import annotations + +import logging +import traceback +from typing import TYPE_CHECKING, Any + +from fastapi import ( + APIRouter, + Depends, + Header, + HTTPException, + Request, + Response, + 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_fastapi.constants import TYPE_JSON +from stapi_fastapi.exceptions import ConstraintsException, NotFoundException +from stapi_fastapi.models.opportunity import ( + OpportunityCollection, + OpportunityPayload, + OpportunitySearchRecord, + Prefer, +) +from stapi_fastapi.models.order import Order, OrderPayload +from stapi_fastapi.models.product import Product +from stapi_fastapi.models.shared import Link +from stapi_fastapi.responses import GeoJSONResponse +from stapi_fastapi.routers.route_names import ( + CREATE_ORDER, + GET_CONSTRAINTS, + GET_OPPORTUNITY_COLLECTION, + GET_ORDER_PARAMETERS, + GET_PRODUCT, + SEARCH_OPPORTUNITIES, +) +from stapi_fastapi.types.json_schema_model import JsonSchemaModel + +if TYPE_CHECKING: + from stapi_fastapi.routers import RootRouter + +logger = logging.getLogger(__name__) + + +def get_prefer(prefer: str | None = Header(None)) -> str | None: + if prefer is None: + return None + + if prefer not in Prefer: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid Prefer header value: {prefer}", + ) + + return Prefer(prefer) + + +class ProductRouter(APIRouter): + def __init__( + self, + product: Product, + root_router: RootRouter, + *args: Any, + **kwargs: Any, + ) -> None: + super().__init__(*args, **kwargs) + + if root_router.supports_async_opportunity_search and not product.supports_async_opportunity_search: + raise ValueError( + f"Product '{product.id}' must support async opportunity search since the root router does", + ) + + self.product = product + self.root_router = 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.add_api_route( + path="/constraints", + endpoint=self.get_product_constraints, + name=f"{self.root_router.name}:{self.product.id}:{GET_CONSTRAINTS}", + methods=["GET"], + summary="Get constraints for the product", + tags=["Products"], + ) + + 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"], + ) + + # This wraps `self.create_order` to explicitly parameterize `OrderRequest` + # for this Product. This must be done programmatically instead of with a type + # annotation because it's setting the type dynamically instead of statically, and + # pydantic needs this type annotation when doing object conversion. This cannot be done + # directly to `self.create_order` because doing it there changes + # the annotation on every `ProductRouter` instance's `create_order`, not just + # this one's. + async def _create_order( + payload: OrderPayload, # type: ignore + request: Request, + response: Response, + ) -> Order: + return await self.create_order(payload, request, response) + + _create_order.__annotations__["payload"] = OrderPayload[ + 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 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"], + response_class=GeoJSONResponse, + # unknown why mypy can't see the constraints property on Product, ignoring + response_model=OpportunityCollection[ + Geometry, + self.product.opportunity_properties, # type: ignore + ], + responses={ + 201: { + "model": OpportunitySearchRecord, + "content": {TYPE_JSON: {}}, + } + }, + summary="Search Opportunities for the product", + tags=["Products"], + ) + + if 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"], + ) + + def get_product(self, request: Request) -> Product: + links = [ + Link( + href=str( + request.url_for( + f"{self.root_router.name}:{self.product.id}:{GET_PRODUCT}", + ), + ), + rel="self", + type=TYPE_JSON, + ), + Link( + href=str( + request.url_for( + f"{self.root_router.name}:{self.product.id}:{GET_CONSTRAINTS}", + ), + ), + rel="constraints", + type=TYPE_JSON, + ), + Link( + href=str( + request.url_for( + f"{self.root_router.name}:{self.product.id}:{GET_ORDER_PARAMETERS}", + ), + ), + rel="order-parameters", + type=TYPE_JSON, + ), + Link( + href=str( + request.url_for( + f"{self.root_router.name}:{self.product.id}:{CREATE_ORDER}", + ), + ), + rel="create-order", + type=TYPE_JSON, + method="POST", + ), + ] + + if self.product.supports_opportunity_search or self.root_router.supports_async_opportunity_search: + links.append( + Link( + href=str( + request.url_for( + f"{self.root_router.name}:{self.product.id}:{SEARCH_OPPORTUNITIES}", + ), + ), + rel="opportunities", + type=TYPE_JSON, + ), + ) + + return self.product.with_links(links=links) + + async def search_opportunities( + self, + search: OpportunityPayload, + request: Request, + response: Response, + prefer: Prefer | None = Depends(get_prefer), + ) -> OpportunityCollection | Response: # type: ignore + """ + Explore the opportunities available for a particular set of constraints + """ + # sync + if not self.root_router.supports_async_opportunity_search or ( + prefer is Prefer.wait and self.product.supports_opportunity_search + ): + return await self.search_opportunities_sync( + search, + request, + response, + prefer, + ) + + # async + if ( + prefer is None + or prefer is Prefer.respond_async + or (prefer is Prefer.wait and not self.product.supports_opportunity_search) + ): + return await self.search_opportunities_async(search, request, prefer) + + raise AssertionError("Expected code to be unreachable") + + async def search_opportunities_sync( + self, + search: OpportunityPayload, + request: Request, + response: Response, + prefer: Prefer | None, + ) -> OpportunityCollection: # type: ignore + links: list[Link] = [] + match await self.product.search_opportunities( + self, + search, + search.next, + search.limit, + request, + ): + case Success((features, maybe_pagination_token)): + 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 + case Failure(e) if isinstance(e, ConstraintsException): + raise e + case Failure(e): + logger.error( + "An error occurred while searching opportunities: %s", + traceback.format_exception(e), + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error searching opportunities", + ) + case x: + raise AssertionError(f"Expected code to be unreachable {x}") + + if prefer is Prefer.wait and self.root_router.supports_async_opportunity_search: + response.headers["Preference-Applied"] = "wait" + + return OpportunityCollection(features=features, links=links) + + async def search_opportunities_async( + self, + search: OpportunityPayload, + request: Request, + prefer: Prefer | None, + ) -> JSONResponse: + 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)) + headers = {} + headers["Location"] = str( + self.root_router.generate_opportunity_search_record_href(request, search_record.id) + ) + if prefer is not None: + headers["Preference-Applied"] = "respond-async" + return JSONResponse( + status_code=201, + content=search_record.model_dump(mode="json"), + headers=headers, + ) + case Failure(e) if isinstance(e, ConstraintsException): + raise e + case Failure(e): + logger.error( + "An error occurred while initiating an asynchronous opportunity search: %s", + traceback.format_exception(e), + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error initiating an asynchronous opportunity search", + ) + case x: + raise AssertionError(f"Expected code to be unreachable: {x}") + + def get_product_constraints(self) -> JsonSchemaModel: + """ + Return supported constraints of a specific product + """ + return self.product.constraints + + def get_product_order_parameters(self) -> JsonSchemaModel: + """ + Return supported constraints of a specific product + """ + return self.product.order_parameters + + async def create_order(self, payload: OrderPayload, request: Request, response: Response) -> Order: # type: ignore + """ + Create a new order. + """ + match await self.product.create_order( + self, + payload, + request, + ): + case Success(order): + order.links.extend(self.root_router.order_links(order, request)) + location = str(self.root_router.generate_order_href(request, order.id)) + response.headers["Location"] = location + return order # type: ignore + case Failure(e) if isinstance(e, ConstraintsException): + raise e + case Failure(e): + logger.error( + "An error occurred while creating order: %s", + traceback.format_exception(e), + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error creating order", + ) + case x: + raise AssertionError(f"Expected code to be unreachable {x}") + + def order_link(self, request: Request, opp_req: OpportunityPayload) -> Link: + return Link( + href=str( + request.url_for( + f"{self.root_router.name}:{self.product.id}:{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: + body = opp_req.body() + body["next"] = pagination_token + return Link( + href=str(request.url), + rel="next", + type=TYPE_JSON, + method="POST", + body=body, + ) + + async def get_opportunity_collection( + self, opportunity_collection_id: str, request: Request + ) -> OpportunityCollection: # type: ignore + """ + Fetch an opportunity collection generated by an asynchronous opportunity search. + """ + match await self.product.get_opportunity_collection( + self, + opportunity_collection_id, + request, + ): + case Success(Some(opportunity_collection)): + opportunity_collection.links.append( + Link( + href=str( + request.url_for( + f"{self.root_router.name}:{self.product.id}:{GET_OPPORTUNITY_COLLECTION}", + opportunity_collection_id=opportunity_collection_id, + ), + ), + rel="self", + type=TYPE_JSON, + ), + ) + return opportunity_collection # type: ignore + case Success(Maybe.empty): + raise NotFoundException("Opportunity Collection not found") + case Failure(e): + logger.error( + "An error occurred while fetching opportunity collection: '%s': %s", + opportunity_collection_id, + traceback.format_exception(e), + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error fetching Opportunity Collection", + ) + case x: + raise AssertionError(f"Expected code to be unreachable {x}") diff --git a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py new file mode 100644 index 0000000..aa57ff1 --- /dev/null +++ b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py @@ -0,0 +1,452 @@ +import logging +import traceback +from typing import Any + +from fastapi import APIRouter, HTTPException, Request, status +from fastapi.datastructures import URL +from returns.maybe import Maybe, Some +from returns.result import Failure, Success + +from stapi_fastapi.backends.root_backend import ( + GetOpportunitySearchRecord, + GetOpportunitySearchRecords, + GetOrder, + GetOrders, + GetOrderStatuses, +) +from stapi_fastapi.constants import TYPE_GEOJSON, TYPE_JSON +from stapi_fastapi.exceptions import NotFoundException +from stapi_fastapi.models.conformance import ( + ASYNC_OPPORTUNITIES, + CORE, + Conformance, +) +from stapi_fastapi.models.opportunity import ( + OpportunitySearchRecord, + OpportunitySearchRecords, +) +from stapi_fastapi.models.order import ( + Order, + OrderCollection, + OrderStatuses, +) +from stapi_fastapi.models.product import Product, ProductsCollection +from stapi_fastapi.models.root import RootResponse +from stapi_fastapi.models.shared import Link +from stapi_fastapi.responses import GeoJSONResponse +from stapi_fastapi.routers.product_router import ProductRouter +from stapi_fastapi.routers.route_names import ( + CONFORMANCE, + GET_OPPORTUNITY_SEARCH_RECORD, + GET_ORDER, + LIST_OPPORTUNITY_SEARCH_RECORDS, + LIST_ORDER_STATUSES, + LIST_ORDERS, + LIST_PRODUCTS, + ROOT, +) + +logger = logging.getLogger(__name__) + + +class RootRouter(APIRouter): + def __init__( + self, + get_orders: GetOrders, + get_order: GetOrder, + get_order_statuses: GetOrderStatuses, # type: ignore + get_opportunity_search_records: GetOpportunitySearchRecords | None = None, + get_opportunity_search_record: GetOpportunitySearchRecord | None = None, + conformances: list[str] = [CORE], + name: str = "root", + openapi_endpoint_name: str = "openapi", + docs_endpoint_name: str = "swagger_ui_html", + *args: Any, + **kwargs: Any, + ) -> None: + super().__init__(*args, **kwargs) + + if ASYNC_OPPORTUNITIES in conformances and ( + not get_opportunity_search_records or not get_opportunity_search_record + ): + raise ValueError( + "`get_opportunity_search_records` and `get_opportunity_search_record` " + "are required when advertising async opportunity search conformance" + ) + + self._get_orders = get_orders + self._get_order = get_order + self._get_order_statuses = get_order_statuses + self.__get_opportunity_search_records = get_opportunity_search_records + self.__get_opportunity_search_record = get_opportunity_search_record + self.conformances = conformances + self.name = name + self.openapi_endpoint_name = openapi_endpoint_name + self.docs_endpoint_name = docs_endpoint_name + self.product_ids: list[str] = [] + + # A dict is used to track the product routers so we can ensure + # idempotentcy in case a product is added multiple times, and also to + # manage clobbering if multiple products with the same product_id are + # added. + self.product_routers: dict[str, ProductRouter] = {} + + self.add_api_route( + "/", + self.get_root, + methods=["GET"], + name=f"{self.name}:{ROOT}", + tags=["Root"], + ) + + self.add_api_route( + "/conformance", + self.get_conformance, + methods=["GET"], + name=f"{self.name}:{CONFORMANCE}", + tags=["Conformance"], + ) + + self.add_api_route( + "/products", + self.get_products, + methods=["GET"], + name=f"{self.name}:{LIST_PRODUCTS}", + tags=["Products"], + ) + + self.add_api_route( + "/orders", + self.get_orders, + methods=["GET"], + name=f"{self.name}:{LIST_ORDERS}", + response_class=GeoJSONResponse, + tags=["Orders"], + ) + + self.add_api_route( + "/orders/{order_id}", + self.get_order, + methods=["GET"], + name=f"{self.name}:{GET_ORDER}", + response_class=GeoJSONResponse, + tags=["Orders"], + ) + + self.add_api_route( + "/orders/{order_id}/statuses", + self.get_order_statuses, + methods=["GET"], + name=f"{self.name}:{LIST_ORDER_STATUSES}", + tags=["Orders"], + ) + + if ASYNC_OPPORTUNITIES in conformances: + 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.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"], + ) + + def get_root(self, request: Request) -> RootResponse: + links = [ + Link( + href=str(request.url_for(f"{self.name}:{ROOT}")), + rel="self", + type=TYPE_JSON, + ), + Link( + href=str(request.url_for(self.openapi_endpoint_name)), + rel="service-description", + type=TYPE_JSON, + ), + Link( + href=str(request.url_for(self.docs_endpoint_name)), + rel="service-docs", + type="text/html", + ), + Link( + href=str(request.url_for(f"{self.name}:{CONFORMANCE}")), + rel="conformance", + type=TYPE_JSON, + ), + Link( + href=str(request.url_for(f"{self.name}:{LIST_PRODUCTS}")), + rel="products", + type=TYPE_JSON, + ), + Link( + href=str(request.url_for(f"{self.name}:{LIST_ORDERS}")), + rel="orders", + type=TYPE_GEOJSON, + ), + ] + + if self.supports_async_opportunity_search: + links.append( + Link( + href=str(request.url_for(f"{self.name}:{LIST_OPPORTUNITY_SEARCH_RECORDS}")), + rel="opportunity-search-records", + type=TYPE_JSON, + ), + ) + + return RootResponse( + id="STAPI API", + conformsTo=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: + start = 0 + limit = min(limit, 100) + try: + if next: + start = self.product_ids.index(next) + except ValueError: + logger.exception("An error occurred while retrieving products") + raise NotFoundException(detail="Error finding pagination token for products") from None + end = start + limit + ids = self.product_ids[start:end] + links = [ + Link( + href=str(request.url_for(f"{self.name}:{LIST_PRODUCTS}")), + rel="self", + type=TYPE_JSON, + ), + ] + if end > 0 and end < len(self.product_ids): + links.append(self.pagination_link(request, self.product_ids[end], limit)) + return ProductsCollection( + products=[self.product_routers[product_id].get_product(request) for product_id in ids], + links=links, + ) + + async def get_orders(self, request: Request, next: str | None = None, limit: int = 10) -> OrderCollection: + links: list[Link] = [] + match await self._get_orders(next, limit, request): + case Success((orders, maybe_pagination_token)): + for order in orders: + order.links.extend(self.order_links(order, request)) + match maybe_pagination_token: + case Some(x): + links.append(self.pagination_link(request, x, limit)) + case Maybe.empty: + pass + case Failure(ValueError()): + raise NotFoundException(detail="Error finding pagination token") + case Failure(e): + logger.error( + "An error occurred while retrieving orders: %s", + traceback.format_exception(e), + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error finding Orders", + ) + case _: + raise AssertionError("Expected code to be unreachable") + return OrderCollection(features=orders, links=links) + + async def get_order(self, order_id: str, request: Request) -> Order: + """ + Get details for order with `order_id`. + """ + match await self._get_order(order_id, request): + case Success(Some(order)): + order.links.extend(self.order_links(order, request)) + return order # type: ignore + case Success(Maybe.empty): + raise NotFoundException("Order not found") + case Failure(e): + logger.error( + "An error occurred while retrieving order '%s': %s", + order_id, + traceback.format_exception(e), + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error finding Order", + ) + case _: + raise AssertionError("Expected code to be unreachable") + + async def get_order_statuses( + self, + order_id: str, + request: Request, + next: str | None = None, + limit: int = 10, + ) -> OrderStatuses: # type: ignore + links: list[Link] = [] + 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(x): + links.append(self.pagination_link(request, x, limit)) + case Maybe.empty: + pass + case Success(Maybe.empty): + raise NotFoundException("Order not found") + case Failure(ValueError()): + raise NotFoundException("Error finding pagination token") + case Failure(e): + logger.error( + "An error occurred while retrieving order statuses: %s", + traceback.format_exception(e), + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error finding 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: + # 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}") + self.product_routers[product.id] = product_router + self.product_ids = [*self.product_routers.keys()] + + def generate_order_href(self, request: Request, order_id: str) -> URL: + return request.url_for(f"{self.name}:{GET_ORDER}", order_id=order_id) + + def generate_order_statuses_href(self, request: Request, order_id: str) -> URL: + return request.url_for(f"{self.name}:{LIST_ORDER_STATUSES}", order_id=order_id) + + def order_links(self, order: Order, request: Request) -> list[Link]: + return [ + Link( + href=str(self.generate_order_href(request, order.id)), + rel="self", + type=TYPE_GEOJSON, + ), + Link( + href=str(self.generate_order_statuses_href(request, order.id)), + rel="monitor", + type=TYPE_JSON, + ), + ] + + def order_statuses_link(self, request: Request, order_id: str) -> Link: + return Link( + href=str( + request.url_for( + f"{self.name}:{LIST_ORDER_STATUSES}", + order_id=order_id, + ) + ), + rel="self", + type=TYPE_JSON, + ) + + def pagination_link(self, request: Request, pagination_token: str, limit: int) -> Link: + return Link( + href=str(request.url.include_query_params(next=pagination_token, limit=limit)), + rel="next", + type=TYPE_JSON, + ) + + async def get_opportunity_search_records( + self, request: Request, next: str | None = None, limit: int = 10 + ) -> OpportunitySearchRecords: + links: list[Link] = [] + 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(x): + links.append(self.pagination_link(request, x, limit)) + case Maybe.empty: + pass + case Failure(ValueError()): + raise NotFoundException(detail="Error finding pagination token") + case Failure(e): + logger.error( + "An error occurred while retrieving opportunity search records: %s", + traceback.format_exception(e), + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error finding 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: + """ + Get the Opportunity Search Record with `search_record_id`. + """ + 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)) + return search_record # type: ignore + case Success(Maybe.empty): + raise NotFoundException("Opportunity Search Record not found") + case Failure(e): + logger.error( + "An error occurred while retrieving opportunity search record '%s': %s", + search_record_id, + traceback.format_exception(e), + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error finding Opportunity Search Record", + ) + case _: + raise AssertionError("Expected code to be unreachable") + + def generate_opportunity_search_record_href(self, request: Request, search_record_id: str) -> URL: + return request.url_for( + f"{self.name}:{GET_OPPORTUNITY_SEARCH_RECORD}", + search_record_id=search_record_id, + ) + + def opportunity_search_record_self_link( + self, opportunity_search_record: OpportunitySearchRecord, request: Request + ) -> Link: + return Link( + href=str(self.generate_opportunity_search_record_href(request, opportunity_search_record.id)), + rel="self", + type=TYPE_JSON, + ) + + @property + def _get_opportunity_search_records(self) -> GetOpportunitySearchRecords: + if not self.__get_opportunity_search_records: + raise AttributeError("Root router does not support async opportunity search") + return self.__get_opportunity_search_records + + @property + def _get_opportunity_search_record(self) -> GetOpportunitySearchRecord: + if not self.__get_opportunity_search_record: + raise AttributeError("Root router does not support async opportunity search") + return self.__get_opportunity_search_record + + @property + def supports_async_opportunity_search(self) -> bool: + return ( + ASYNC_OPPORTUNITIES in self.conformances + and self._get_opportunity_search_records is not None + and self._get_opportunity_search_record 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 new file mode 100644 index 0000000..292bc54 --- /dev/null +++ b/stapi-fastapi/src/stapi_fastapi/routers/route_names.py @@ -0,0 +1,22 @@ +# Root +ROOT = "root" +CONFORMANCE = "conformance" + +# Product +LIST_PRODUCTS = "list-products" +LIST_PRODUCTS = "list-products" +GET_PRODUCT = "get-product" +GET_CONSTRAINTS = "get-constraints" +GET_ORDER_PARAMETERS = "get-order-parameters" + +# Opportunity +LIST_OPPORTUNITY_SEARCH_RECORDS = "list-opportunity-search-records" +GET_OPPORTUNITY_SEARCH_RECORD = "get-opportunity-search-record" +SEARCH_OPPORTUNITIES = "search-opportunities" +GET_OPPORTUNITY_COLLECTION = "get-opportunity-collection" + +# Order +LIST_ORDERS = "list-orders" +GET_ORDER = "get-order" +LIST_ORDER_STATUSES = "list-order-statuses" +CREATE_ORDER = "create-order" diff --git a/stapi-fastapi/src/stapi_fastapi/types/__init__.py b/stapi-fastapi/src/stapi_fastapi/types/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/stapi-fastapi/src/stapi_fastapi/types/datetime_interval.py b/stapi-fastapi/src/stapi_fastapi/types/datetime_interval.py new file mode 100644 index 0000000..ffc6d32 --- /dev/null +++ b/stapi-fastapi/src/stapi_fastapi/types/datetime_interval.py @@ -0,0 +1,41 @@ +from collections.abc import Callable +from datetime import datetime +from typing import Annotated, Any + +from pydantic import ( + AfterValidator, + AwareDatetime, + BeforeValidator, + WithJsonSchema, + WrapSerializer, +) + + +def validate_before(value: Any) -> Any: + if isinstance(value, str): + start, end = value.split("/", 1) + return (datetime.fromisoformat(start), datetime.fromisoformat(end)) + return value + + +def validate_after(value: tuple[datetime, datetime]) -> tuple[datetime, datetime]: + if value[1] < value[0]: + raise ValueError("end before start") + return value + + +def serialize( + value: tuple[datetime, datetime], + serializer: Callable[[tuple[datetime, datetime]], tuple[str, str]], +) -> str: + del serializer # unused + return f"{value[0].isoformat()}/{value[1].isoformat()}" + + +type DatetimeInterval = Annotated[ + tuple[AwareDatetime, AwareDatetime], + BeforeValidator(validate_before), + AfterValidator(validate_after), + WrapSerializer(serialize, return_type=str), + WithJsonSchema({"type": "string"}, mode="serialization"), +] diff --git a/stapi-fastapi/src/stapi_fastapi/types/filter.py b/stapi-fastapi/src/stapi_fastapi/types/filter.py new file mode 100644 index 0000000..084f14f --- /dev/null +++ b/stapi-fastapi/src/stapi_fastapi/types/filter.py @@ -0,0 +1,19 @@ +from typing import Annotated, Any + +from pydantic import BeforeValidator +from pygeofilter.parsers import cql2_json + + +def validate(v: dict[str, Any]) -> dict[str, Any]: + if v: + try: + cql2_json.parse({"filter": v}) + except Exception as e: + raise ValueError("Filter is not valid cql2-json") from e + return v + + +type CQL2Filter = Annotated[ + dict, + BeforeValidator(validate), +] diff --git a/stapi-fastapi/src/stapi_fastapi/types/json_schema_model.py b/stapi-fastapi/src/stapi_fastapi/types/json_schema_model.py new file mode 100644 index 0000000..95f0203 --- /dev/null +++ b/stapi-fastapi/src/stapi_fastapi/types/json_schema_model.py @@ -0,0 +1,26 @@ +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() + + +type JsonSchemaModel = Annotated[ + type[BaseModel], + PlainValidator(validate), + PlainSerializer(serialize), + WithJsonSchema({"type": "object"}), +] diff --git a/stapi-fastapi/tests/__init__.py b/stapi-fastapi/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/stapi-fastapi/tests/application.py b/stapi-fastapi/tests/application.py new file mode 100644 index 0000000..fb88ad7 --- /dev/null +++ b/stapi-fastapi/tests/application.py @@ -0,0 +1,51 @@ +import os +import sys +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from typing import Any + +from fastapi import FastAPI + +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from stapi_fastapi.models.conformance import ASYNC_OPPORTUNITIES, CORE, OPPORTUNITIES +from stapi_fastapi.routers.root_router import RootRouter + +from tests.backends import ( + mock_get_opportunity_search_record, + mock_get_opportunity_search_records, + mock_get_order, + mock_get_order_statuses, + mock_get_orders, +) +from tests.shared import ( + InMemoryOpportunityDB, + InMemoryOrderDB, + product_test_satellite_provider_sync_opportunity, + product_test_spotlight_sync_async_opportunity, +) + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncIterator[dict[str, Any]]: + try: + yield { + "_orders_db": InMemoryOrderDB(), + "_opportunities_db": InMemoryOpportunityDB(), + } + finally: + pass + + +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, + conformances=[CORE, OPPORTUNITIES, ASYNC_OPPORTUNITIES], +) +root_router.add_product(product_test_spotlight_sync_async_opportunity) +root_router.add_product(product_test_satellite_provider_sync_opportunity) +app: FastAPI = FastAPI(lifespan=lifespan) +app.include_router(root_router, prefix="") diff --git a/stapi-fastapi/tests/backends.py b/stapi-fastapi/tests/backends.py new file mode 100644 index 0000000..0b0d62d --- /dev/null +++ b/stapi-fastapi/tests/backends.py @@ -0,0 +1,200 @@ +from datetime import datetime, timezone +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.models.opportunity import ( + Opportunity, + OpportunityCollection, + OpportunityPayload, + OpportunitySearchRecord, + OpportunitySearchStatus, + OpportunitySearchStatusCode, +) +from stapi_fastapi.models.order import ( + Order, + OrderPayload, + OrderProperties, + OrderSearchParameters, + OrderStatus, + OrderStatusCode, +) +from stapi_fastapi.routers.product_router import ProductRouter + + +async def mock_get_orders(next: str | None, limit: int, request: Request) -> ResultE[tuple[list[Order], Maybe[str]]]: + """ + Return orders from backend. Handle pagination/limit if applicable + """ + try: + start = 0 + limit = min(limit, 100) + order_ids = [*request.state._orders_db._orders.keys()] + + if next: + start = order_ids.index(next) + 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))) + return Success((orders, Nothing)) + except Exception as e: + return Failure(e) + + +async def mock_get_order(order_id: str, request: Request) -> ResultE[Maybe[Order]]: + """ + Show details for order with `order_id`. + """ + try: + return Success(Maybe.from_optional(request.state._orders_db.get_order(order_id))) + except Exception as e: + return Failure(e) + + +async def mock_get_order_statuses( + order_id: str, next: str | None, limit: int, request: Request +) -> ResultE[Maybe[tuple[list[OrderStatus], Maybe[str]]]]: + try: + start = 0 + limit = min(limit, 100) + statuses = request.state._orders_db.get_order_statuses(order_id) + if statuses is None: + return Success(Nothing) + + if next: + start = int(next) + end = start + limit + stati = statuses[start:end] + + if end > 0 and end < len(statuses): + return Success(Some((stati, Some(str(end))))) + return Success(Some((stati, Nothing))) + except Exception as e: + return Failure(e) + + +async def mock_create_order(product_router: ProductRouter, payload: OrderPayload, request: Request) -> ResultE[Order]: + """ + Create a new order. + """ + try: + status = OrderStatus( + timestamp=datetime.now(timezone.utc), + status_code=OrderStatusCode.received, + ) + order = Order( + id=str(uuid4()), + geometry=payload.geometry, + properties=OrderProperties( + product_id=product_router.product.id, + created=datetime.now(timezone.utc), + status=status, + search_parameters=OrderSearchParameters( + geometry=payload.geometry, + datetime=payload.datetime, + filter=payload.filter, + ), + order_parameters=payload.order_parameters.model_dump(), + opportunity_properties={ + "datetime": "2024-01-29T12:00:00Z/2024-01-30T12:00:00Z", + "off_nadir": 10, + }, + ), + links=[], + ) + + request.state._orders_db.put_order(order) + request.state._orders_db.put_order_status(order.id, status) + return Success(order) + except Exception as e: + return Failure(e) + + +async def mock_search_opportunities( + product_router: ProductRouter, + search: OpportunityPayload, + next: str | None, + limit: int, + request: Request, +) -> ResultE[tuple[list[Opportunity], Maybe[str]]]: + try: + start = 0 + limit = min(limit, 100) + if next: + start = int(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)) + except Exception as e: + return Failure(e) + + +async def mock_search_opportunities_async( + product_router: ProductRouter, + search: OpportunityPayload, + request: Request, +) -> ResultE[OpportunitySearchRecord]: + try: + received_status = OpportunitySearchStatus( + timestamp=datetime.now(timezone.utc), + status_code=OpportunitySearchStatusCode.received, + ) + search_record = OpportunitySearchRecord( + id=str(uuid4()), + product_id=product_router.product.id, + opportunity_request=search, + status=received_status, + links=[], + ) + request.state._opportunities_db.put_search_record(search_record) + return Success(search_record) + except Exception as e: + return Failure(e) + + +async def mock_get_opportunity_collection( + product_router: ProductRouter, opportunity_collection_id: str, request: Request +) -> ResultE[Maybe[OpportunityCollection]]: + try: + return Success( + Maybe.from_optional(request.state._opportunities_db.get_opportunity_collection(opportunity_collection_id)) + ) + except Exception as e: + return Failure(e) + + +async def mock_get_opportunity_search_records( + next: str | None, + limit: int, + request: Request, +) -> ResultE[tuple[list[OpportunitySearchRecord], Maybe[str]]]: + try: + start = 0 + limit = min(limit, 100) + search_records = request.state._opportunities_db.get_search_records() + + if next: + start = int(next) + end = start + limit + page = search_records[start:end] + + if end > 0 and end < len(search_records): + return Success((page, Some(str(end)))) + return Success((page, Nothing)) + except Exception as e: + return Failure(e) + + +async def mock_get_opportunity_search_record( + search_record_id: str, request: Request +) -> ResultE[Maybe[OpportunitySearchRecord]]: + try: + return Success(Maybe.from_optional(request.state._opportunities_db.get_search_record(search_record_id))) + except Exception as e: + return Failure(e) diff --git a/stapi-fastapi/tests/conftest.py b/stapi-fastapi/tests/conftest.py new file mode 100644 index 0000000..e527dfd --- /dev/null +++ b/stapi-fastapi/tests/conftest.py @@ -0,0 +1,184 @@ +from collections.abc import AsyncIterator, Callable, Generator, Iterator +from contextlib import asynccontextmanager +from datetime import UTC, datetime, timedelta +from typing import Any +from urllib.parse import urljoin + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from stapi_fastapi.models.conformance import ASYNC_OPPORTUNITIES, CORE, OPPORTUNITIES +from stapi_fastapi.models.opportunity import ( + Opportunity, +) +from stapi_fastapi.models.product import ( + Product, +) +from stapi_fastapi.routers.root_router import RootRouter + +from .backends import ( + mock_get_opportunity_search_record, + 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_satellite_provider_sync_opportunity, + product_test_spotlight_sync_opportunity, +) +from .test_datetime_interval import rfc3339_strftime + + +@pytest.fixture(scope="session") +def base_url() -> Iterator[str]: + yield "http://stapiserver" + + +@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] + return [ + product_test_spotlight_sync_opportunity, + product_test_satellite_provider_sync_opportunity, + ] + + +@pytest.fixture +def mock_opportunities() -> list[Opportunity]: + return [create_mock_opportunity()] + + +@pytest.fixture +def stapi_client( + mock_products: list[Product], + base_url: str, + mock_opportunities: list[Opportunity], +) -> Generator[TestClient, None, None]: + @asynccontextmanager + async def lifespan(app: FastAPI) -> AsyncIterator[dict[str, Any]]: + try: + yield { + "_orders_db": InMemoryOrderDB(), + "_opportunities": mock_opportunities, + } + finally: + pass + + root_router = RootRouter( + get_orders=mock_get_orders, + get_order=mock_get_order, + get_order_statuses=mock_get_order_statuses, + conformances=[CORE], + ) + + for mock_product in mock_products: + root_router.add_product(mock_product) + + app = FastAPI(lifespan=lifespan) + app.include_router(root_router, prefix="") + + with TestClient(app, base_url=f"{base_url}") as client: + yield client + + +@pytest.fixture +def stapi_client_async_opportunity( + mock_products: list[Product], + base_url: str, + mock_opportunities: list[Opportunity], +) -> Generator[TestClient, None, None]: + @asynccontextmanager + async def lifespan(app: FastAPI) -> AsyncIterator[dict[str, Any]]: + try: + yield { + "_orders_db": InMemoryOrderDB(), + "_opportunities_db": InMemoryOpportunityDB(), + "_opportunities": mock_opportunities, + } + finally: + pass + + 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, + conformances=[CORE, OPPORTUNITIES, ASYNC_OPPORTUNITIES], + ) + + for mock_product in mock_products: + root_router.add_product(mock_product) + + app = FastAPI(lifespan=lifespan) + app.include_router(root_router, prefix="") + + with TestClient(app, base_url=f"{base_url}") as client: + yield client + + +@pytest.fixture(scope="session") +def url_for(base_url: str) -> Iterator[Callable[[str], str]]: + def with_trailing_slash(value: str) -> str: + return value if value.endswith("/") else f"{value}/" + + def url_for(value: str) -> str: + return urljoin(with_trailing_slash(base_url), f"./{value.lstrip('/')}") + + yield url_for + + +@pytest.fixture +def assert_link(url_for) -> Callable: + def _assert_link( + req: str, + body: dict[str, Any], + rel: str, + path: str, + media_type: str = "application/json", + method: str | None = None, + ): + link = find_link(body["links"], rel) + assert link, f"{req} Link[rel={rel}] should exist" + assert link["type"] == media_type + assert link["href"] == url_for(path) + if method: + assert link["method"] == method + + return _assert_link + + +@pytest.fixture +def limit() -> int: + return 10 + + +@pytest.fixture +def opportunity_search(limit) -> dict[str, Any]: + 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) + + 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]}, + ], + }, + "limit": limit, + } diff --git a/stapi-fastapi/tests/shared.py b/stapi-fastapi/tests/shared.py new file mode 100644 index 0000000..58b708b --- /dev/null +++ b/stapi-fastapi/tests/shared.py @@ -0,0 +1,298 @@ +from collections import defaultdict +from copy import deepcopy +from datetime import datetime, timedelta, timezone +from typing import Any, Literal, Self +from urllib.parse import parse_qs, urlparse +from uuid import uuid4 + +from fastapi import status +from fastapi.testclient import TestClient +from geojson_pydantic import Point +from geojson_pydantic.types import Position2D +from httpx import Response +from pydantic import BaseModel, Field, model_validator +from pytest import fail +from stapi_fastapi.models.opportunity import ( + Opportunity, + OpportunityCollection, + OpportunityProperties, + OpportunitySearchRecord, +) +from stapi_fastapi.models.order import ( + Order, + OrderParameters, + OrderStatus, +) +from stapi_fastapi.models.product import ( + Product, + Provider, + ProviderRole, +) + +from .backends import ( + mock_create_order, + mock_get_opportunity_collection, + mock_search_opportunities, + mock_search_opportunities_async, +) + +type link_dict = dict[str, Any] + + +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 InMemoryOrderDB: + def __init__(self) -> None: + self._orders: dict[str, Order] = {} + self._statuses: dict[str, list[OrderStatus]] = defaultdict(list) + + def get_order(self, order_id: str) -> Order | None: + return deepcopy(self._orders.get(order_id)) + + def get_orders(self) -> list[Order]: + return deepcopy(list(self._orders.values())) + + def put_order(self, order: Order) -> None: + self._orders[order.id] = deepcopy(order) + + def get_order_statuses(self, order_id: str) -> list[OrderStatus] | None: + return deepcopy(self._statuses.get(order_id)) + + def put_order_status(self, order_id: str, status: OrderStatus) -> None: + self._statuses[order_id].append(deepcopy(status)) + + +class InMemoryOpportunityDB: + def __init__(self) -> None: + self._search_records: dict[str, OpportunitySearchRecord] = {} + self._collections: dict[str, OpportunityCollection] = {} + + def get_search_record(self, search_id: str) -> OpportunitySearchRecord | None: + return deepcopy(self._search_records.get(search_id)) + + def get_search_records(self) -> list[OpportunitySearchRecord]: + return deepcopy(list(self._search_records.values())) + + def put_search_record(self, search_record: OpportunitySearchRecord) -> None: + self._search_records[search_record.id] = deepcopy(search_record) + + def get_opportunity_collection(self, collection_id) -> OpportunityCollection | None: + return deepcopy(self._collections.get(collection_id)) + + def put_opportunity_collection(self, collection: OpportunityCollection) -> None: + if collection.id is None: + raise ValueError("collection must have an id") + self._collections[collection.id] = deepcopy(collection) + + +class MyProductConstraints(BaseModel): + off_nadir: int + + +class OffNadirRange(BaseModel): + minimum: int = Field(ge=0, le=45) + maximum: int = Field(ge=0, le=45) + + @model_validator(mode="after") + def validate_range(self) -> Self: + if self.minimum > self.maximum: + raise ValueError("range minimum cannot be greater than maximum") + return self + + +class MyOpportunityProperties(OpportunityProperties): + off_nadir: OffNadirRange + vehicle_id: list[Literal[1, 2, 5, 7, 8]] + platform: Literal["platform_id"] + + +class MyOrderParameters(OrderParameters): + s3_path: str | None = None + + +provider = Provider( + name="Test Provider", + description="A provider for Test data", + roles=[ProviderRole.producer], # Example role + url="https://test-provider.example.com", # Must be a valid URL +) + +product_test_spotlight = 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=None, + get_opportunity_collection=None, + constraints=MyProductConstraints, + opportunity_properties=MyOpportunityProperties, + order_parameters=MyOrderParameters, +) + +product_test_spotlight_sync_opportunity = 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=mock_search_opportunities, + search_opportunities_async=None, + get_opportunity_collection=None, + constraints=MyProductConstraints, + opportunity_properties=MyOpportunityProperties, + order_parameters=MyOrderParameters, +) + + +product_test_spotlight_async_opportunity = 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, + constraints=MyProductConstraints, + opportunity_properties=MyOpportunityProperties, + order_parameters=MyOrderParameters, +) + +product_test_spotlight_sync_async_opportunity = 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=mock_search_opportunities, + search_opportunities_async=mock_search_opportunities_async, + get_opportunity_collection=mock_get_opportunity_collection, + constraints=MyProductConstraints, + opportunity_properties=MyOpportunityProperties, + order_parameters=MyOrderParameters, +) + +product_test_satellite_provider_sync_opportunity = Product( + id="test-satellite-provider", + title="Satellite Product", + description="A product by a satellite provider", + license="CC-BY-4.0", + keywords=["test", "satellite", "provider"], + providers=[provider], + links=[], + create_order=mock_create_order, + search_opportunities=mock_search_opportunities, + search_opportunities_async=None, + get_opportunity_collection=None, + constraints=MyProductConstraints, + opportunity_properties=MyOpportunityProperties, + order_parameters=MyOrderParameters, +) + + +def create_mock_opportunity() -> Opportunity: + now = datetime.now(timezone.utc) # Use timezone-aware datetime + start = now + end = start + timedelta(days=5) + + # Create a list of mock opportunities for the given product + return Opportunity( + id=str(uuid4()), + type="Feature", + geometry=Point( + type="Point", + coordinates=Position2D(longitude=0.0, latitude=0.0), + ), + properties=MyOpportunityProperties( + product_id="xyz123", + datetime=(start, end), + off_nadir=OffNadirRange(minimum=20, maximum=22), + vehicle_id=[1], + platform="platform_id", + other_thing="abcd1234", # type: ignore + ), + ) + + +def pagination_tester( + stapi_client: TestClient, + url: str, + method: str, + limit: int, + target: str, + expected_returns: list, + body: dict | None = None, +) -> None: + retrieved = [] + + res = make_request(stapi_client, url, method, body, limit) + assert res.status_code == status.HTTP_200_OK + resp_body = res.json() + + assert len(resp_body[target]) <= limit + retrieved.extend(resp_body[target]) + next_url = next((d["href"] for d in resp_body["links"] if d["rel"] == "next"), None) + + while next_url: + if method == "POST": + body = next((d["body"] for d in resp_body["links"] if d["rel"] == "next"), None) + + res = make_request(stapi_client, next_url, method, body, limit) + + assert res.status_code == status.HTTP_200_OK, res.status_code + assert len(resp_body[target]) <= limit + + resp_body = res.json() + retrieved.extend(resp_body[target]) + + # get url w/ query params for next call if exists, and POST body if necessary + if resp_body["links"]: + next_url = next((d["href"] for d in resp_body["links"] if d["rel"] == "next"), None) + else: + next_url = None + + assert len(retrieved) == len(expected_returns) + assert retrieved == expected_returns + + +def make_request( + stapi_client: TestClient, + url: str, + method: str, + body: dict | None, + limit: int, +) -> Response: + """request wrapper for pagination tests""" + + match method: + case "GET": + o = urlparse(url) + base_url = f"{o.scheme}://{o.netloc}{o.path}" + parsed_qs = parse_qs(o.query) + params: dict[str, Any] = {} + if "next" in parsed_qs: + params["next"] = parsed_qs["next"][0] + params["limit"] = int(parsed_qs.get("limit", [None])[0] or limit) + res = stapi_client.get(base_url, params=params) + case "POST": + res = stapi_client.post(url, json=body) + case _: + fail(f"method {method} not supported in make request") + + return res diff --git a/stapi-fastapi/tests/test_conformance.py b/stapi-fastapi/tests/test_conformance.py new file mode 100644 index 0000000..9b1f840 --- /dev/null +++ b/stapi-fastapi/tests/test_conformance.py @@ -0,0 +1,14 @@ +from fastapi import status +from fastapi.testclient import TestClient +from stapi_fastapi.models.conformance import CORE + + +def test_conformance(stapi_client: TestClient) -> None: + res = stapi_client.get("/conformance") + + assert res.status_code == status.HTTP_200_OK + assert res.headers["Content-Type"] == "application/json" + + body = res.json() + + assert body["conformsTo"] == [CORE] diff --git a/stapi-fastapi/tests/test_datetime_interval.py b/stapi-fastapi/tests/test_datetime_interval.py new file mode 100644 index 0000000..309cbeb --- /dev/null +++ b/stapi-fastapi/tests/test_datetime_interval.py @@ -0,0 +1,73 @@ +from datetime import UTC, datetime, timedelta +from itertools import product +from zoneinfo import ZoneInfo + +from pydantic import BaseModel, ValidationError +from pyrfc3339.utils import format_timezone +from pytest import mark, raises +from stapi_fastapi.types.datetime_interval import DatetimeInterval + +EUROPE_BERLIN = ZoneInfo("Europe/Berlin") + + +class Model(BaseModel): + datetime: DatetimeInterval + + +def rfc3339_strftime(dt: datetime, format: str) -> str: + tds = int(round(dt.tzinfo.utcoffset(dt).total_seconds())) # type: ignore + long = format_timezone(tds) + short = "Z" + + format = format.replace("%z", long).replace("%Z", short if tds == 0 else long) + return dt.strftime(format) + + +@mark.parametrize( + "value", + ( + "", + "2024-01-29/2024-01-30", + "2024-01-29T12:00:00/2024-01-30T12:00:00", + "2024-01-29T12:00:00Z/2024-01-28T12:00:00Z", + ), +) +def test_invalid_values(value: str): + with raises(ValidationError): + Model.model_validate_strings({"datetime": value}) + + +@mark.parametrize( + "tz, format", + product( + (UTC, EUROPE_BERLIN), + ( + "%Y-%m-%dT%H:%M:%S.%f%Z", + "%Y-%m-%dT%H:%M:%S.%f%z", + "%Y-%m-%d %H:%M:%S.%f%Z", + "%Y-%m-%dt%H:%M:%S.%f%Z", + "%Y-%m-%d_%H:%M:%S.%f%Z", + ), + ), +) +def test_deserialization(tz: ZoneInfo, format: str): + start = datetime.now(tz) + end = start + timedelta(hours=1) + value = f"{rfc3339_strftime(start, format)}/{rfc3339_strftime(end, format)}" + + model = Model.model_validate_json(f'{{"datetime":"{value}"}}') + + assert model.datetime == (start, end) + + +@mark.parametrize("tz", (UTC, EUROPE_BERLIN)) +def test_serialize(tz): + start = datetime.now(tz) + end = start + timedelta(hours=1) + model = Model(datetime=(start, end)) + + format = "%Y-%m-%dT%H:%M:%S.%f%z" + expected = f"{rfc3339_strftime(start, format)}/{rfc3339_strftime(end, format)}" + + obj = model.model_dump() + assert obj["datetime"] == expected diff --git a/stapi-fastapi/tests/test_opportunity.py b/stapi-fastapi/tests/test_opportunity.py new file mode 100644 index 0000000..7cf187f --- /dev/null +++ b/stapi-fastapi/tests/test_opportunity.py @@ -0,0 +1,57 @@ +import pytest +from fastapi.testclient import TestClient +from stapi_fastapi.models.opportunity import ( + OpportunityCollection, +) + +from .shared import create_mock_opportunity, pagination_tester + + +def test_search_opportunities_response(stapi_client: TestClient, assert_link, opportunity_search) -> None: + product_id = "test-spotlight" + url = f"/products/{product_id}/opportunities" + + response = stapi_client.post(url, json=opportunity_search) + + assert response.status_code == 200, f"Failed for product: {product_id}" + body = response.json() + + # Validate the opportunity was returned + assert len(body["features"]) == 1 + + try: + _ = OpportunityCollection(**body) + except Exception as _: + pytest.fail("response is not an opportunity collection") + + assert_link( + f"POST {url}", + body, + "create-order", + f"/products/{product_id}/orders", + method="POST", + ) + + +@pytest.mark.parametrize("limit", [0, 1, 2, 4]) +def test_search_opportunities_pagination( + limit: int, + stapi_client: TestClient, + opportunity_search, +) -> 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] + + pagination_tester( + stapi_client=stapi_client, + url=f"/products/{product_id}/opportunities", + method="POST", + limit=limit, + target="features", + expected_returns=expected_returns, + body=opportunity_search, + ) diff --git a/stapi-fastapi/tests/test_opportunity_async.py b/stapi-fastapi/tests/test_opportunity_async.py new file mode 100644 index 0000000..2f45755 --- /dev/null +++ b/stapi-fastapi/tests/test_opportunity_async.py @@ -0,0 +1,332 @@ +from collections.abc import Callable +from datetime import UTC, datetime, timedelta, timezone +from typing import Any +from uuid import uuid4 + +import pytest +from fastapi import status +from fastapi.testclient import TestClient +from stapi_fastapi.models.opportunity import ( + OpportunityCollection, + OpportunitySearchRecord, + OpportunitySearchStatus, + OpportunitySearchStatusCode, +) +from stapi_fastapi.models.shared import Link + +from .shared import ( + create_mock_opportunity, + find_link, + pagination_tester, + product_test_spotlight, + product_test_spotlight_async_opportunity, + 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]) +def test_no_opportunity_search_advertised(stapi_client: TestClient) -> None: + product_id = "test-spotlight" + + # the `/products/{productId}/opportunities link should not be advertised on the product + product_response = stapi_client.get(f"/products/{product_id}") + product_body = product_response.json() + assert find_link(product_body["links"], "opportunities") is 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 + + +@pytest.mark.mock_products([product_test_spotlight_sync_opportunity]) +def test_only_sync_search_advertised(stapi_client: TestClient) -> None: + product_id = "test-spotlight" + + # the `/products/{productId}/opportunities link should be advertised on the product + product_response = stapi_client.get(f"/products/{product_id}") + product_body = product_response.json() + assert find_link(product_body["links"], "opportunities") + + # 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 + + +# test async search offered +@pytest.mark.parametrize( + "mock_products", + [ + [product_test_spotlight_async_opportunity], + [product_test_spotlight_sync_async_opportunity], + ], +) +def test_async_search_advertised(stapi_client_async_opportunity: TestClient) -> None: + product_id = "test-spotlight" + + # the `/products/{productId}/opportunities link should be advertised on the product + product_response = stapi_client_async_opportunity.get(f"/products/{product_id}") + product_body = product_response.json() + assert find_link(product_body["links"], "opportunities") + + # 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") + + +@pytest.mark.mock_products([product_test_spotlight_async_opportunity]) +def test_async_search_response( + stapi_client_async_opportunity: TestClient, + opportunity_search: dict[str, Any], +) -> None: + product_id = "test-spotlight" + url = f"/products/{product_id}/opportunities" + + response = stapi_client_async_opportunity.post(url, json=opportunity_search) + assert response.status_code == 201 + + body = response.json() + try: + _ = OpportunitySearchRecord(**body) + except Exception as _: + pytest.fail("response is not an opportunity search record") + + assert find_link(body["links"], "self") + + +@pytest.mark.mock_products([product_test_spotlight_async_opportunity]) +def test_async_search_is_default( + stapi_client_async_opportunity: TestClient, + opportunity_search: dict[str, Any], +) -> None: + product_id = "test-spotlight" + url = f"/products/{product_id}/opportunities" + + response = stapi_client_async_opportunity.post(url, json=opportunity_search) + assert response.status_code == 201 + + body = response.json() + try: + _ = OpportunitySearchRecord(**body) + except Exception as _: + pytest.fail("response is not an opportunity search record") + + +@pytest.mark.mock_products([product_test_spotlight_sync_async_opportunity]) +def test_prefer_header( + stapi_client_async_opportunity: TestClient, + opportunity_search: dict[str, Any], +) -> None: + product_id = "test-spotlight" + url = f"/products/{product_id}/opportunities" + + # prefer = "wait" + response = stapi_client_async_opportunity.post(url, json=opportunity_search, headers={"Prefer": "wait"}) + assert response.status_code == 200 + assert response.headers["Preference-Applied"] == "wait" + + body = response.json() + try: + OpportunityCollection(**body) + except Exception as _: + pytest.fail("response is not an opportunity collection") + + # prefer = "respond-async" + response = stapi_client_async_opportunity.post(url, json=opportunity_search, headers={"Prefer": "respond-async"}) + assert response.status_code == 201 + assert response.headers["Preference-Applied"] == "respond-async" + + body = response.json() + try: + OpportunitySearchRecord(**body) + except Exception as _: + pytest.fail("response is not an opportunity search record") + + +@pytest.mark.mock_products([product_test_spotlight_async_opportunity]) +def test_async_search_record_retrieval( + stapi_client_async_opportunity: TestClient, + opportunity_search: dict[str, Any], +) -> None: + # post an async search + product_id = "test-spotlight" + url = f"/products/{product_id}/opportunities" + search_response = stapi_client_async_opportunity.post(url, json=opportunity_search) + assert search_response.status_code == 201 + search_response_body = search_response.json() + + # get the search record by id and verify it matches the original response + search_record_id = search_response_body["id"] + record_response = stapi_client_async_opportunity.get(f"/searches/opportunities/{search_record_id}") + assert record_response.status_code == 200 + record_response_body = record_response.json() + assert record_response_body == search_response_body + + # verify the search record is in the list of all search records + 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"]] + + +@pytest.mark.mock_products([product_test_spotlight_async_opportunity]) +def test_async_opportunity_search_to_completion( + stapi_client_async_opportunity: TestClient, + opportunity_search: dict[str, Any], + url_for: Callable[[str], str], +) -> None: + # Post a request for an async search + product_id = "test-spotlight" + url = f"/products/{product_id}/opportunities" + search_response = stapi_client_async_opportunity.post(url, json=opportunity_search) + assert search_response.status_code == 201 + search_record = OpportunitySearchRecord(**search_response.json()) + + # Simulate the search being completed by some external process: + # - an OpportunityCollection is created and stored in the database + collection = OpportunityCollection( + id=str(uuid4()), + features=[create_mock_opportunity()], + ) + collection.links.append( + Link( + rel="create-order", + href=url_for(f"/products/{product_id}/orders"), + body=search_record.opportunity_request.model_dump(), + method="POST", + ) + ) + collection.links.append( + Link( + rel="search-record", + href=url_for(f"/searches/opportunities/{search_record.id}"), + ) + ) + + stapi_client_async_opportunity.app_state["_opportunities_db"].put_opportunity_collection(collection) + + # - the OpportunitySearchRecord links and status are updated in the database + search_record.links.append( + Link( + rel="opportunities", + href=url_for(f"/products/{product_id}/opportunities/{collection.id}"), + ) + ) + search_record.status = OpportunitySearchStatus( + timestamp=datetime.now(timezone.utc), + status_code=OpportunitySearchStatusCode.completed, + ) + + stapi_client_async_opportunity.app_state["_opportunities_db"].put_search_record(search_record) + + # Verify we can retrieve the OpportunitySearchRecord by its id and its status is + # `completed` + url = f"/searches/opportunities/{search_record.id}" + retrieved_search_response = stapi_client_async_opportunity.get(url) + assert retrieved_search_response.status_code == 200 + retrieved_search_record = OpportunitySearchRecord(**retrieved_search_response.json()) + assert retrieved_search_record.status.status_code == OpportunitySearchStatusCode.completed + + # Verify we can retrieve the OpportunityCollection from the + # OpportunitySearchRecord's `opportunities` link; verify the retrieved + # OpportunityCollection contains an order link and a link pointing back to the + # OpportunitySearchRecord + opportunities_link = next(x for x in retrieved_search_record.links if x.rel == "opportunities") + url = str(opportunities_link.href) + retrieved_collection_response = stapi_client_async_opportunity.get(url) + assert retrieved_collection_response.status_code == 200 + retrieved_collection = OpportunityCollection(**retrieved_collection_response.json()) + assert any(x for x in retrieved_collection.links if x.rel == "create-order") + assert any(x for x in retrieved_collection.links if x.rel == "search-record") + + +@pytest.mark.mock_products([product_test_spotlight_async_opportunity]) +def test_new_search_location_header_matches_self_link( + stapi_client_async_opportunity: TestClient, + opportunity_search: dict[str, Any], +) -> None: + product_id = "test-spotlight" + url = f"/products/{product_id}/opportunities" + search_response = stapi_client_async_opportunity.post(url, json=opportunity_search) + assert search_response.status_code == 201 + + search_record = search_response.json() + link = find_link(search_record["links"], "self") + assert link + assert search_response.headers["Location"] == str(link["href"]) + + +@pytest.mark.mock_products([product_test_spotlight_async_opportunity]) +def test_bad_ids(stapi_client_async_opportunity: TestClient) -> None: + search_record_id = "bad_id" + res = stapi_client_async_opportunity.get(f"/searches/opportunities/{search_record_id}") + assert res.status_code == status.HTTP_404_NOT_FOUND + + product_id = "test-spotlight" + opportunity_collection_id = "bad_id" + res = stapi_client_async_opportunity.get(f"/products/{product_id}/opportunities/{opportunity_collection_id}") + assert res.status_code == status.HTTP_404_NOT_FOUND + + +@pytest.fixture +def setup_search_record_pagination( + stapi_client_async_opportunity: TestClient, +) -> 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 + ) + assert response.status_code == 201 + + body = response.json() + search_records.append(body) + + return search_records + + +@pytest.mark.parametrize("limit", [0, 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 + + pagination_tester( + stapi_client=stapi_client_async_opportunity, + url="/searches/opportunities", + method="GET", + limit=limit, + target="search_records", + expected_returns=expected_returns, + ) diff --git a/stapi-fastapi/tests/test_order.py b/stapi-fastapi/tests/test_order.py new file mode 100644 index 0000000..aee6586 --- /dev/null +++ b/stapi-fastapi/tests/test_order.py @@ -0,0 +1,230 @@ +from datetime import UTC, datetime, timedelta, timezone + +import pytest +from fastapi import status +from fastapi.testclient import TestClient +from geojson_pydantic import Point +from geojson_pydantic.types import Position2D +from httpx import Response +from stapi_fastapi.models.order import Order, OrderPayload, OrderStatus, OrderStatusCode + +from .shared import MyOrderParameters, find_link, pagination_tester + +NOW = datetime.now(UTC) +START = NOW +END = START + timedelta(days=5) + + +def test_empty_order(stapi_client: TestClient): + res = stapi_client.get("/orders") + default_orders = {"type": "FeatureCollection", "features": [], "links": []} + assert res.status_code == status.HTTP_200_OK + assert res.headers["Content-Type"] == "application/geo+json" + assert res.json() == default_orders + + +@pytest.fixture +def create_order_payloads() -> list[OrderPayload]: + 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 = [] + 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), + ), + filter=None, + order_parameters=MyOrderParameters(s3_path="s3://my-bucket"), + ) + payloads.append(payload) + return payloads + + +@pytest.fixture +def new_order_response( + product_id: str, + stapi_client: TestClient, + create_order_payloads: list[OrderPayload], +) -> Response: + res = stapi_client.post( + f"products/{product_id}/orders", + json=create_order_payloads[0].model_dump(), + ) + + assert res.status_code == status.HTTP_201_CREATED, res.text + assert res.headers["Content-Type"] == "application/geo+json" + return res + + +@pytest.mark.parametrize("product_id", ["test-spotlight"]) +def test_new_order_location_header_matches_self_link( + new_order_response: Response, +) -> None: + order = new_order_response.json() + link = find_link(order["links"], "self") + assert link + assert new_order_response.headers["Location"] == str(link["href"]) + + +@pytest.mark.parametrize("product_id", ["test-spotlight"]) +def test_new_order_links(new_order_response: Response, assert_link) -> None: + order = new_order_response.json() + assert_link( + f"GET /orders/{order['id']}", + order, + "monitor", + f"/orders/{order['id']}/statuses", + ) + + assert_link( + f"GET /orders/{order['id']}", + order, + "self", + f"/orders/{order['id']}", + media_type="application/geo+json", + ) + + +@pytest.fixture +def get_order_response(stapi_client: TestClient, new_order_response: Response) -> Response: + order_id = new_order_response.json()["id"] + + res = stapi_client.get(f"/orders/{order_id}") + assert res.status_code == status.HTTP_200_OK + assert res.headers["Content-Type"] == "application/geo+json" + return res + + +@pytest.mark.parametrize("product_id", ["test-spotlight"]) +def test_get_order_properties(get_order_response: Response, create_order_payloads) -> None: + order = get_order_response.json() + + assert order["geometry"] == { + "type": "Point", + "coordinates": list(create_order_payloads[0].geometry.coordinates), + } + + assert order["properties"]["search_parameters"]["geometry"] == { + "type": "Point", + "coordinates": list(create_order_payloads[0].geometry.coordinates), + } + + assert order["properties"]["search_parameters"]["datetime"] == create_order_payloads[0].model_dump()["datetime"] + + +@pytest.mark.parametrize("product_id", ["test-spotlight"]) +def test_order_status_after_create(get_order_response: Response, stapi_client: TestClient, assert_link) -> 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") + assert link is not None + + res = stapi_client.get(link["href"]) + assert res.status_code == status.HTTP_200_OK + assert res.headers["Content-Type"] == "application/json" + assert len(res.json()["statuses"]) == 1 + + +@pytest.fixture +def setup_orders_pagination(stapi_client: TestClient, create_order_payloads) -> list[Order]: + product_id = "test-spotlight" + orders = [] + for order in create_order_payloads: + res = stapi_client.post( + f"products/{product_id}/orders", + json=order.model_dump(), + ) + body = res.json() + orders.append(body) + + assert res.status_code == status.HTTP_201_CREATED, res.text + assert res.headers["Content-Type"] == "application/geo+json" + + 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 + + pagination_tester( + stapi_client=stapi_client, + url="/orders", + method="GET", + limit=limit, + target="features", + expected_returns=expected_returns, + ) + + +def test_token_not_found(stapi_client: TestClient) -> None: + res = stapi_client.get("/orders", params={"next": "a_token"}) + assert res.status_code == status.HTTP_404_NOT_FOUND + + +@pytest.fixture +def order_statuses() -> dict[str, list[OrderStatus]]: + statuses = { + "test_order_id": [ + OrderStatus( + timestamp=datetime(2025, 1, 14, 2, 21, 48, 466726, tzinfo=timezone.utc), + status_code=OrderStatusCode.received, + links=[], + ), + OrderStatus( + timestamp=datetime(2025, 1, 15, 5, 20, 48, 466726, tzinfo=timezone.utc), + status_code=OrderStatusCode.accepted, + links=[], + ), + OrderStatus( + timestamp=datetime(2025, 1, 16, 10, 15, 32, 466726, tzinfo=timezone.utc), + status_code=OrderStatusCode.completed, + links=[], + ), + ] + } + return statuses + + +@pytest.mark.parametrize("limit", [0, 1, 2, 4]) +def test_get_order_status_pagination( + limit: int, + stapi_client: TestClient, + order_statuses: dict[str, list[OrderStatus]], +) -> None: + for id, statuses in order_statuses.items(): + for s in statuses: + 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]] + + pagination_tester( + stapi_client=stapi_client, + url=f"/orders/{order_id}/statuses", + method="GET", + limit=limit, + target="statuses", + expected_returns=expected_returns, + ) + + +def test_get_order_statuses_bad_token( + stapi_client: TestClient, + order_statuses: dict[str, list[OrderStatus]], + limit: int = 2, +) -> None: + stapi_client.app_state["_orders_db"]._statuses = order_statuses + + order_id = "non_existing_order_id" + res = stapi_client.get(f"/orders/{order_id}/statuses") + assert res.status_code == status.HTTP_404_NOT_FOUND diff --git a/stapi-fastapi/tests/test_product.py b/stapi-fastapi/tests/test_product.py new file mode 100644 index 0000000..980baea --- /dev/null +++ b/stapi-fastapi/tests/test_product.py @@ -0,0 +1,131 @@ +import pytest +from fastapi import status +from fastapi.testclient import TestClient +from stapi_fastapi.models.product import Product + +from .shared import pagination_tester + + +def test_products_response(stapi_client: TestClient): + res = stapi_client.get("/products") + + assert res.status_code == status.HTTP_200_OK + assert res.headers["Content-Type"] == "application/json" + + data = res.json() + + assert data["type"] == "ProductCollection" + assert isinstance(data["products"], list) + + +@pytest.mark.parametrize("product_id", ["test-spotlight"]) +def test_product_response_self_link( + product_id: str, + stapi_client: TestClient, + assert_link, +): + res = stapi_client.get(f"/products/{product_id}") + assert res.status_code == status.HTTP_200_OK + assert res.headers["Content-Type"] == "application/json" + + body = res.json() + + url = "GET /products" + assert_link(url, body, "self", f"/products/{product_id}") + assert_link(url, body, "constraints", f"/products/{product_id}/constraints") + assert_link(url, body, "order-parameters", f"/products/{product_id}/order-parameters") + assert_link(url, body, "opportunities", f"/products/{product_id}/opportunities") + assert_link(url, body, "create-order", f"/products/{product_id}/orders", method="POST") + + +@pytest.mark.parametrize("product_id", ["test-spotlight"]) +def test_product_constraints_response( + product_id: str, + stapi_client: TestClient, +): + res = stapi_client.get(f"/products/{product_id}/constraints") + assert res.status_code == status.HTTP_200_OK + assert res.headers["Content-Type"] == "application/json" + + json_schema = res.json() + assert "properties" in json_schema + assert "off_nadir" in json_schema["properties"] + + +@pytest.mark.parametrize("product_id", ["test-spotlight"]) +def test_product_order_parameters_response( + product_id: str, + stapi_client: TestClient, +): + 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" + + json_schema = res.json() + assert "properties" in json_schema + assert "s3_path" in json_schema["properties"] + + +@pytest.mark.parametrize("limit", [0, 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}/constraints", + "rel": "constraints", + "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, + url="/products", + method="GET", + limit=limit, + target="products", + expected_returns=expected_returns, + ) + + +def test_token_not_found(stapi_client: TestClient) -> None: + res = stapi_client.get("/products", params={"next": "a_token"}) + assert res.status_code == status.HTTP_404_NOT_FOUND + + +@pytest.mark.mock_products([]) +def test_no_products(stapi_client: TestClient): + res = stapi_client.get("/products") + body = res.json() + print("hold") + assert res.status_code == status.HTTP_200_OK + assert len(body["products"]) == 0 diff --git a/stapi-fastapi/tests/test_root.py b/stapi-fastapi/tests/test_root.py new file mode 100644 index 0000000..4583f7c --- /dev/null +++ b/stapi-fastapi/tests/test_root.py @@ -0,0 +1,21 @@ +from fastapi import status +from fastapi.testclient import TestClient +from stapi_fastapi.models.conformance import CORE + + +def test_root(stapi_client: TestClient, assert_link) -> None: + res = stapi_client.get("/") + + assert res.status_code == status.HTTP_200_OK + assert res.headers["Content-Type"] == "application/json" + + body = res.json() + + assert body["conformsTo"] == [CORE] + + assert_link("GET /", body, "self", "/") + assert_link("GET /", body, "service-description", "/openapi.json") + assert_link("GET /", body, "service-docs", "/docs", media_type="text/html") + assert_link("GET /", body, "conformance", "/conformance") + assert_link("GET /", body, "products", "/products") + assert_link("GET /", body, "orders", "/orders", media_type="application/geo+json") diff --git a/stapi-pydantic/pyproject.toml b/stapi-pydantic/pyproject.toml index cee2153..ecbcb16 100644 --- a/stapi-pydantic/pyproject.toml +++ b/stapi-pydantic/pyproject.toml @@ -16,6 +16,15 @@ dependencies = [ [project.scripts] stapi-pydantic = "stapi_pydantic:main" +[tool.hatch.build.targets.sdist] +include = ["src/stapi_pydantic"] + +[tool.hatch.build.targets.wheel] +include = ["src/stapi_pydantic"] + +[tool.hatch.build.targets.wheel.sources] +"src/stapi_pydantic" = "stapi_pydantic" + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" diff --git a/stapi-pydantic/src/stapi_pydantic/conformance.py b/stapi-pydantic/src/stapi_pydantic/conformance.py index d211fc8..c3d9d4a 100644 --- a/stapi-pydantic/src/stapi_pydantic/conformance.py +++ b/stapi-pydantic/src/stapi_pydantic/conformance.py @@ -6,6 +6,4 @@ class Conformance(BaseModel): - conforms_to: list[str] = Field( - default_factory=list, serialization_alias="conformsTo" - ) + conforms_to: list[str] = Field(default_factory=list, serialization_alias="conformsTo") diff --git a/stapi-pydantic/src/stapi_pydantic/datetime_interval.py b/stapi-pydantic/src/stapi_pydantic/datetime_interval.py index 83be44a..bf021f8 100644 --- a/stapi-pydantic/src/stapi_pydantic/datetime_interval.py +++ b/stapi-pydantic/src/stapi_pydantic/datetime_interval.py @@ -1,5 +1,6 @@ +from collections.abc import Callable from datetime import datetime -from typing import Annotated, Callable +from typing import Annotated from pydantic import ( AfterValidator, diff --git a/stapi-pydantic/src/stapi_pydantic/order.py b/stapi-pydantic/src/stapi_pydantic/order.py index 69ef2a4..9ed02c8 100644 --- a/stapi-pydantic/src/stapi_pydantic/order.py +++ b/stapi-pydantic/src/stapi_pydantic/order.py @@ -1,6 +1,6 @@ from collections.abc import Iterator from enum import StrEnum -from typing import Any, Generic, Literal, Optional, TypeVar, Union +from typing import Any, Generic, Literal, TypeVar from geojson_pydantic.base import _GeoJsonBase from geojson_pydantic.geometries import Geometry @@ -18,7 +18,7 @@ from .opportunity import OpportunityProperties from .shared import Link -Props = TypeVar("Props", bound=Union[dict[str, Any], BaseModel]) +Props = TypeVar("Props", bound=dict[str, Any] | BaseModel) Geom = TypeVar("Geom", bound=Geometry) @@ -47,8 +47,8 @@ class OrderStatusCode(StrEnum): class OrderStatus(BaseModel): timestamp: AwareDatetime status_code: OrderStatusCode - reason_code: Optional[str] = None - reason_text: Optional[str] = None + reason_code: str | None = None + reason_text: str | None = None links: list[Link] = Field(default_factory=list) model_config = ConfigDict(extra="allow") diff --git a/stapi-pydantic/src/stapi_pydantic/product.py b/stapi-pydantic/src/stapi_pydantic/product.py index dcfdcfd..4a01415 100644 --- a/stapi-pydantic/src/stapi_pydantic/product.py +++ b/stapi-pydantic/src/stapi_pydantic/product.py @@ -1,5 +1,5 @@ from enum import StrEnum -from typing import Any, Literal, Optional, Self +from typing import Any, Literal, Self from pydantic import AnyHttpUrl, BaseModel, Field @@ -19,7 +19,7 @@ class ProviderRole(StrEnum): class Provider(BaseModel): name: str - description: Optional[str] = None + description: str | None = None roles: list[ProviderRole] url: AnyHttpUrl @@ -81,8 +81,6 @@ def with_links(self, links: list[Link] | None = None) -> Self: class ProductsCollection(BaseModel): - type_: Literal["ProductCollection"] = Field( - default="ProductCollection", alias="type" - ) + type_: Literal["ProductCollection"] = Field(default="ProductCollection", alias="type") links: list[Link] = Field(default_factory=list) products: list[Product] diff --git a/uv.lock b/uv.lock index c380f51..c8c8849 100644 --- a/uv.lock +++ b/uv.lock @@ -5,6 +5,7 @@ requires-python = ">=3.10" [manifest] members = [ "pystapi", + "stapi-fastapi", "stapi-pydantic", ] @@ -17,6 +18,53 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, ] +[[package]] +name = "anyio" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916 }, +] + +[[package]] +name = "application-properties" +version = "0.8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, + { name = "tomli" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/5e/29ec0fa553ee5befc6a1e47eb0c8ffea75eed941251524678700e2d3e747/application_properties-0.8.2.tar.gz", hash = "sha256:e5e6918c8e29ab57175567d51dfa39c00a1d75b3205625559bb02250f50f0420", size = 29595 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/27/ea2b77232385ec1c4b5cb766ee13b5a3085ed2fa789d61374e7af36b79e1/application_properties-0.8.2-py3-none-any.whl", hash = "sha256:a4fe684e4d95fc45054d3316acf763a7b0f29342ccea02eee09de53004f0139c", size = 18399 }, +] + +[[package]] +name = "argcomplete" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/35/aacd2207c79d95e4ace44292feedff8fccfd8b48135f42d84893c24cc39b/argcomplete-3.6.1.tar.gz", hash = "sha256:927531c2fbaa004979f18c2316f6ffadcfc5cc2de15ae2624dfe65deaf60e14f", size = 73474 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/c8/9fa0e6fa97c328d44e089278399b0a1a08268b06a4a71f7448c6b6effb9f/argcomplete-3.6.1-py3-none-any.whl", hash = "sha256:cef54d7f752560570291214f0f1c48c3b8ef09aca63d65de7747612666725dbc", size = 43984 }, +] + +[[package]] +name = "attrs" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815 }, +] + [[package]] name = "babel" version = "2.17.0" @@ -48,6 +96,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 }, ] +[[package]] +name = "cfgv" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249 }, +] + [[package]] name = "charset-normalizer" version = "3.4.1" @@ -130,6 +187,96 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, ] +[[package]] +name = "colorlog" +version = "6.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/7a/359f4d5df2353f26172b3cc39ea32daa39af8de522205f512f458923e677/colorlog-6.9.0.tar.gz", hash = "sha256:bfba54a1b93b94f54e1f4fe48395725a3d92fd2a4af702f6bd70946bdc0c6ac2", size = 16624 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/51/9b208e85196941db2f0654ad0357ca6388ab3ed67efdbfc799f35d1f83aa/colorlog-6.9.0-py3-none-any.whl", hash = "sha256:5906e71acd67cb07a71e779c47c4bcb45fb8c2993eebe9e5adcd6a6f1b283eff", size = 11424 }, +] + +[[package]] +name = "columnar" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "toolz" }, + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/0d/a0b2fd781050d29c9df64ac6df30b5f18b775724b79779f56fc5a8298fe9/Columnar-1.4.1.tar.gz", hash = "sha256:c3cb57273333b2ff9cfaafc86f09307419330c97faa88dcfe23df05e6fbb9c72", size = 11386 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/00/a17a5657bf090b9dffdb310ac273c553a38f9252f60224da9fe62d9b60e9/Columnar-1.4.1-py3-none-any.whl", hash = "sha256:8efb692a7e6ca07dcc8f4ea889960421331a5dffa8e5af81f0a67ad8ea1fc798", size = 11845 }, +] + +[[package]] +name = "coverage" +version = "7.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/4f/2251e65033ed2ce1e68f00f91a0294e0f80c80ae8c3ebbe2f12828c4cd53/coverage-7.8.0.tar.gz", hash = "sha256:7a3d62b3b03b4b6fd41a085f3574874cf946cb4604d2b4d3e8dca8cd570ca501", size = 811872 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/01/1c5e6ee4ebaaa5e079db933a9a45f61172048c7efa06648445821a201084/coverage-7.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2931f66991175369859b5fd58529cd4b73582461877ecfd859b6549869287ffe", size = 211379 }, + { url = "https://files.pythonhosted.org/packages/e9/16/a463389f5ff916963471f7c13585e5f38c6814607306b3cb4d6b4cf13384/coverage-7.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52a523153c568d2c0ef8826f6cc23031dc86cffb8c6aeab92c4ff776e7951b28", size = 211814 }, + { url = "https://files.pythonhosted.org/packages/b8/b1/77062b0393f54d79064dfb72d2da402657d7c569cfbc724d56ac0f9c67ed/coverage-7.8.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c8a5c139aae4c35cbd7cadca1df02ea8cf28a911534fc1b0456acb0b14234f3", size = 240937 }, + { url = "https://files.pythonhosted.org/packages/d7/54/c7b00a23150083c124e908c352db03bcd33375494a4beb0c6d79b35448b9/coverage-7.8.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5a26c0c795c3e0b63ec7da6efded5f0bc856d7c0b24b2ac84b4d1d7bc578d676", size = 238849 }, + { url = "https://files.pythonhosted.org/packages/f7/ec/a6b7cfebd34e7b49f844788fda94713035372b5200c23088e3bbafb30970/coverage-7.8.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:821f7bcbaa84318287115d54becb1915eece6918136c6f91045bb84e2f88739d", size = 239986 }, + { url = "https://files.pythonhosted.org/packages/21/8c/c965ecef8af54e6d9b11bfbba85d4f6a319399f5f724798498387f3209eb/coverage-7.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a321c61477ff8ee705b8a5fed370b5710c56b3a52d17b983d9215861e37b642a", size = 239896 }, + { url = "https://files.pythonhosted.org/packages/40/83/070550273fb4c480efa8381735969cb403fa8fd1626d74865bfaf9e4d903/coverage-7.8.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ed2144b8a78f9d94d9515963ed273d620e07846acd5d4b0a642d4849e8d91a0c", size = 238613 }, + { url = "https://files.pythonhosted.org/packages/07/76/fbb2540495b01d996d38e9f8897b861afed356be01160ab4e25471f4fed1/coverage-7.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:042e7841a26498fff7a37d6fda770d17519982f5b7d8bf5278d140b67b61095f", size = 238909 }, + { url = "https://files.pythonhosted.org/packages/a3/7e/76d604db640b7d4a86e5dd730b73e96e12a8185f22b5d0799025121f4dcb/coverage-7.8.0-cp310-cp310-win32.whl", hash = "sha256:f9983d01d7705b2d1f7a95e10bbe4091fabc03a46881a256c2787637b087003f", size = 213948 }, + { url = "https://files.pythonhosted.org/packages/5c/a7/f8ce4aafb4a12ab475b56c76a71a40f427740cf496c14e943ade72e25023/coverage-7.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:5a570cd9bd20b85d1a0d7b009aaf6c110b52b5755c17be6962f8ccd65d1dbd23", size = 214844 }, + { url = "https://files.pythonhosted.org/packages/2b/77/074d201adb8383addae5784cb8e2dac60bb62bfdf28b2b10f3a3af2fda47/coverage-7.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e7ac22a0bb2c7c49f441f7a6d46c9c80d96e56f5a8bc6972529ed43c8b694e27", size = 211493 }, + { url = "https://files.pythonhosted.org/packages/a9/89/7a8efe585750fe59b48d09f871f0e0c028a7b10722b2172dfe021fa2fdd4/coverage-7.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf13d564d310c156d1c8e53877baf2993fb3073b2fc9f69790ca6a732eb4bfea", size = 211921 }, + { url = "https://files.pythonhosted.org/packages/e9/ef/96a90c31d08a3f40c49dbe897df4f1fd51fb6583821a1a1c5ee30cc8f680/coverage-7.8.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5761c70c017c1b0d21b0815a920ffb94a670c8d5d409d9b38857874c21f70d7", size = 244556 }, + { url = "https://files.pythonhosted.org/packages/89/97/dcd5c2ce72cee9d7b0ee8c89162c24972fb987a111b92d1a3d1d19100c61/coverage-7.8.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e5ff52d790c7e1628241ffbcaeb33e07d14b007b6eb00a19320c7b8a7024c040", size = 242245 }, + { url = "https://files.pythonhosted.org/packages/b2/7b/b63cbb44096141ed435843bbb251558c8e05cc835c8da31ca6ffb26d44c0/coverage-7.8.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d39fc4817fd67b3915256af5dda75fd4ee10621a3d484524487e33416c6f3543", size = 244032 }, + { url = "https://files.pythonhosted.org/packages/97/e3/7fa8c2c00a1ef530c2a42fa5df25a6971391f92739d83d67a4ee6dcf7a02/coverage-7.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b44674870709017e4b4036e3d0d6c17f06a0e6d4436422e0ad29b882c40697d2", size = 243679 }, + { url = "https://files.pythonhosted.org/packages/4f/b3/e0a59d8df9150c8a0c0841d55d6568f0a9195692136c44f3d21f1842c8f6/coverage-7.8.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8f99eb72bf27cbb167b636eb1726f590c00e1ad375002230607a844d9e9a2318", size = 241852 }, + { url = "https://files.pythonhosted.org/packages/9b/82/db347ccd57bcef150c173df2ade97976a8367a3be7160e303e43dd0c795f/coverage-7.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b571bf5341ba8c6bc02e0baeaf3b061ab993bf372d982ae509807e7f112554e9", size = 242389 }, + { url = "https://files.pythonhosted.org/packages/21/f6/3f7d7879ceb03923195d9ff294456241ed05815281f5254bc16ef71d6a20/coverage-7.8.0-cp311-cp311-win32.whl", hash = "sha256:e75a2ad7b647fd8046d58c3132d7eaf31b12d8a53c0e4b21fa9c4d23d6ee6d3c", size = 213997 }, + { url = "https://files.pythonhosted.org/packages/28/87/021189643e18ecf045dbe1e2071b2747901f229df302de01c998eeadf146/coverage-7.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:3043ba1c88b2139126fc72cb48574b90e2e0546d4c78b5299317f61b7f718b78", size = 214911 }, + { url = "https://files.pythonhosted.org/packages/aa/12/4792669473297f7973518bec373a955e267deb4339286f882439b8535b39/coverage-7.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bbb5cc845a0292e0c520656d19d7ce40e18d0e19b22cb3e0409135a575bf79fc", size = 211684 }, + { url = "https://files.pythonhosted.org/packages/be/e1/2a4ec273894000ebedd789e8f2fc3813fcaf486074f87fd1c5b2cb1c0a2b/coverage-7.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4dfd9a93db9e78666d178d4f08a5408aa3f2474ad4d0e0378ed5f2ef71640cb6", size = 211935 }, + { url = "https://files.pythonhosted.org/packages/f8/3a/7b14f6e4372786709a361729164125f6b7caf4024ce02e596c4a69bccb89/coverage-7.8.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f017a61399f13aa6d1039f75cd467be388d157cd81f1a119b9d9a68ba6f2830d", size = 245994 }, + { url = "https://files.pythonhosted.org/packages/54/80/039cc7f1f81dcbd01ea796d36d3797e60c106077e31fd1f526b85337d6a1/coverage-7.8.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0915742f4c82208ebf47a2b154a5334155ed9ef9fe6190674b8a46c2fb89cb05", size = 242885 }, + { url = "https://files.pythonhosted.org/packages/10/e0/dc8355f992b6cc2f9dcd5ef6242b62a3f73264893bc09fbb08bfcab18eb4/coverage-7.8.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a40fcf208e021eb14b0fac6bdb045c0e0cab53105f93ba0d03fd934c956143a", size = 245142 }, + { url = "https://files.pythonhosted.org/packages/43/1b/33e313b22cf50f652becb94c6e7dae25d8f02e52e44db37a82de9ac357e8/coverage-7.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a1f406a8e0995d654b2ad87c62caf6befa767885301f3b8f6f73e6f3c31ec3a6", size = 244906 }, + { url = "https://files.pythonhosted.org/packages/05/08/c0a8048e942e7f918764ccc99503e2bccffba1c42568693ce6955860365e/coverage-7.8.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:77af0f6447a582fdc7de5e06fa3757a3ef87769fbb0fdbdeba78c23049140a47", size = 243124 }, + { url = "https://files.pythonhosted.org/packages/5b/62/ea625b30623083c2aad645c9a6288ad9fc83d570f9adb913a2abdba562dd/coverage-7.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f2d32f95922927186c6dbc8bc60df0d186b6edb828d299ab10898ef3f40052fe", size = 244317 }, + { url = "https://files.pythonhosted.org/packages/62/cb/3871f13ee1130a6c8f020e2f71d9ed269e1e2124aa3374d2180ee451cee9/coverage-7.8.0-cp312-cp312-win32.whl", hash = "sha256:769773614e676f9d8e8a0980dd7740f09a6ea386d0f383db6821df07d0f08545", size = 214170 }, + { url = "https://files.pythonhosted.org/packages/88/26/69fe1193ab0bfa1eb7a7c0149a066123611baba029ebb448500abd8143f9/coverage-7.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:e5d2b9be5b0693cf21eb4ce0ec8d211efb43966f6657807f6859aab3814f946b", size = 214969 }, + { url = "https://files.pythonhosted.org/packages/f3/21/87e9b97b568e223f3438d93072479c2f36cc9b3f6b9f7094b9d50232acc0/coverage-7.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ac46d0c2dd5820ce93943a501ac5f6548ea81594777ca585bf002aa8854cacd", size = 211708 }, + { url = "https://files.pythonhosted.org/packages/75/be/882d08b28a0d19c9c4c2e8a1c6ebe1f79c9c839eb46d4fca3bd3b34562b9/coverage-7.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:771eb7587a0563ca5bb6f622b9ed7f9d07bd08900f7589b4febff05f469bea00", size = 211981 }, + { url = "https://files.pythonhosted.org/packages/7a/1d/ce99612ebd58082fbe3f8c66f6d8d5694976c76a0d474503fa70633ec77f/coverage-7.8.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42421e04069fb2cbcbca5a696c4050b84a43b05392679d4068acbe65449b5c64", size = 245495 }, + { url = "https://files.pythonhosted.org/packages/dc/8d/6115abe97df98db6b2bd76aae395fcc941d039a7acd25f741312ced9a78f/coverage-7.8.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:554fec1199d93ab30adaa751db68acec2b41c5602ac944bb19187cb9a41a8067", size = 242538 }, + { url = "https://files.pythonhosted.org/packages/cb/74/2f8cc196643b15bc096d60e073691dadb3dca48418f08bc78dd6e899383e/coverage-7.8.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5aaeb00761f985007b38cf463b1d160a14a22c34eb3f6a39d9ad6fc27cb73008", size = 244561 }, + { url = "https://files.pythonhosted.org/packages/22/70/c10c77cd77970ac965734fe3419f2c98665f6e982744a9bfb0e749d298f4/coverage-7.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:581a40c7b94921fffd6457ffe532259813fc68eb2bdda60fa8cc343414ce3733", size = 244633 }, + { url = "https://files.pythonhosted.org/packages/38/5a/4f7569d946a07c952688debee18c2bb9ab24f88027e3d71fd25dbc2f9dca/coverage-7.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f319bae0321bc838e205bf9e5bc28f0a3165f30c203b610f17ab5552cff90323", size = 242712 }, + { url = "https://files.pythonhosted.org/packages/bb/a1/03a43b33f50475a632a91ea8c127f7e35e53786dbe6781c25f19fd5a65f8/coverage-7.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04bfec25a8ef1c5f41f5e7e5c842f6b615599ca8ba8391ec33a9290d9d2db3a3", size = 244000 }, + { url = "https://files.pythonhosted.org/packages/6a/89/ab6c43b1788a3128e4d1b7b54214548dcad75a621f9d277b14d16a80d8a1/coverage-7.8.0-cp313-cp313-win32.whl", hash = "sha256:dd19608788b50eed889e13a5d71d832edc34fc9dfce606f66e8f9f917eef910d", size = 214195 }, + { url = "https://files.pythonhosted.org/packages/12/12/6bf5f9a8b063d116bac536a7fb594fc35cb04981654cccb4bbfea5dcdfa0/coverage-7.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:a9abbccd778d98e9c7e85038e35e91e67f5b520776781d9a1e2ee9d400869487", size = 214998 }, + { url = "https://files.pythonhosted.org/packages/2a/e6/1e9df74ef7a1c983a9c7443dac8aac37a46f1939ae3499424622e72a6f78/coverage-7.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:18c5ae6d061ad5b3e7eef4363fb27a0576012a7447af48be6c75b88494c6cf25", size = 212541 }, + { url = "https://files.pythonhosted.org/packages/04/51/c32174edb7ee49744e2e81c4b1414ac9df3dacfcb5b5f273b7f285ad43f6/coverage-7.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:95aa6ae391a22bbbce1b77ddac846c98c5473de0372ba5c463480043a07bff42", size = 212767 }, + { url = "https://files.pythonhosted.org/packages/e9/8f/f454cbdb5212f13f29d4a7983db69169f1937e869a5142bce983ded52162/coverage-7.8.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e013b07ba1c748dacc2a80e69a46286ff145935f260eb8c72df7185bf048f502", size = 256997 }, + { url = "https://files.pythonhosted.org/packages/e6/74/2bf9e78b321216d6ee90a81e5c22f912fc428442c830c4077b4a071db66f/coverage-7.8.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d766a4f0e5aa1ba056ec3496243150698dc0481902e2b8559314368717be82b1", size = 252708 }, + { url = "https://files.pythonhosted.org/packages/92/4d/50d7eb1e9a6062bee6e2f92e78b0998848a972e9afad349b6cdde6fa9e32/coverage-7.8.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad80e6b4a0c3cb6f10f29ae4c60e991f424e6b14219d46f1e7d442b938ee68a4", size = 255046 }, + { url = "https://files.pythonhosted.org/packages/40/9e/71fb4e7402a07c4198ab44fc564d09d7d0ffca46a9fb7b0a7b929e7641bd/coverage-7.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b87eb6fc9e1bb8f98892a2458781348fa37e6925f35bb6ceb9d4afd54ba36c73", size = 256139 }, + { url = "https://files.pythonhosted.org/packages/49/1a/78d37f7a42b5beff027e807c2843185961fdae7fe23aad5a4837c93f9d25/coverage-7.8.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d1ba00ae33be84066cfbe7361d4e04dec78445b2b88bdb734d0d1cbab916025a", size = 254307 }, + { url = "https://files.pythonhosted.org/packages/58/e9/8fb8e0ff6bef5e170ee19d59ca694f9001b2ec085dc99b4f65c128bb3f9a/coverage-7.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f3c38e4e5ccbdc9198aecc766cedbb134b2d89bf64533973678dfcf07effd883", size = 255116 }, + { url = "https://files.pythonhosted.org/packages/56/b0/d968ecdbe6fe0a863de7169bbe9e8a476868959f3af24981f6a10d2b6924/coverage-7.8.0-cp313-cp313t-win32.whl", hash = "sha256:379fe315e206b14e21db5240f89dc0774bdd3e25c3c58c2c733c99eca96f1ada", size = 214909 }, + { url = "https://files.pythonhosted.org/packages/87/e9/d6b7ef9fecf42dfb418d93544af47c940aa83056c49e6021a564aafbc91f/coverage-7.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2e4b6b87bb0c846a9315e3ab4be2d52fac905100565f4b92f02c445c8799e257", size = 216068 }, + { url = "https://files.pythonhosted.org/packages/c4/f1/1da77bb4c920aa30e82fa9b6ea065da3467977c2e5e032e38e66f1c57ffd/coverage-7.8.0-pp39.pp310.pp311-none-any.whl", hash = "sha256:b8194fb8e50d556d5849753de991d390c5a1edeeba50f68e3a9253fbd8bf8ccd", size = 203443 }, + { url = "https://files.pythonhosted.org/packages/59/f1/4da7717f0063a222db253e7121bd6a56f6fb1ba439dcc36659088793347c/coverage-7.8.0-py3-none-any.whl", hash = "sha256:dbf364b4c5e7bae9250528167dfe40219b62e2d573c854d74be213e1e52069f7", size = 203435 }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + [[package]] name = "cql2" version = "0.3.6" @@ -220,6 +367,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/13/53194cfb61f9d844059f3c3ceb987877ae639257270dc7bf2a52e094ae4c/cql2-0.3.6-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:4db7d426c9dc4c7475856ef51a1766ef71a65bb92e291b08c26965bc1bf85f5f", size = 2958585 }, ] +[[package]] +name = "dateparser" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "regex" }, + { name = "tzlocal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/3f/d3207a05f5b6a78c66d86631e60bfba5af163738a599a5b9aa2c2737a09e/dateparser-1.2.1.tar.gz", hash = "sha256:7e4919aeb48481dbfc01ac9683c8e20bfe95bb715a38c1e9f6af889f4f30ccc3", size = 309924 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/0a/981c438c4cd84147c781e4e96c1d72df03775deb1bc76c5a6ee8afa89c62/dateparser-1.2.1-py3-none-any.whl", hash = "sha256:bdcac262a467e6260030040748ad7c10d6bacd4f3b9cdb4cfd2251939174508c", size = 295658 }, +] + +[[package]] +name = "dependency-groups" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/57/cd53c3e335eafbb0894449af078e2b71db47e9939ce2b45013e5a9fe89b7/dependency_groups-1.3.0.tar.gz", hash = "sha256:5b9751d5d98fbd6dfd038a560a69c8382e41afcbf7ffdbcc28a2a3f85498830f", size = 9832 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/2c/3e3afb1df3dc8a8deeb143f6ac41acbfdfae4f03a54c760871c56832a554/dependency_groups-1.3.0-py3-none-any.whl", hash = "sha256:1abf34d712deda5581e80d507512664d52b35d1c2d7caf16c85e58ca508547e0", size = 8597 }, +] + +[[package]] +name = "distlib" +version = "0.3.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/dd/1bec4c5ddb504ca60fc29472f3d27e8d4da1257a854e1d96742f15c1d02d/distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403", size = 613923 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973 }, +] + [[package]] name = "exceptiongroup" version = "1.2.2" @@ -229,6 +413,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 }, ] +[[package]] +name = "fastapi" +version = "0.115.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/55/ae499352d82338331ca1e28c7f4a63bfd09479b16395dce38cf50a39e2c2/fastapi-0.115.12.tar.gz", hash = "sha256:1e2c2a2646905f9e83d32f04a3f86aff4a286669c6c950ca95b5fd68c2602681", size = 295236 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/b3/b51f09c2ba432a576fe63758bddc81f78f0c6309d9e5c10d194313bf021e/fastapi-0.115.12-py3-none-any.whl", hash = "sha256:e94613d6c05e27be7ffebdd6ea5f388112e5e430c8f7d6494a9d1d88d43e814d", size = 95164 }, +] + +[[package]] +name = "filelock" +version = "3.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/10/c23352565a6544bdc5353e0b15fc1c563352101f30e24bf500207a54df9a/filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2", size = 18075 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215 }, +] + [[package]] name = "geojson-pydantic" version = "1.2.0" @@ -265,6 +472,52 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1d/94/48e28b1c7402f750200e9e3ef4834c862ea85c64f426a231a6dc312f61a9/griffe-1.7.1-py3-none-any.whl", hash = "sha256:37a7f15233937d723ddc969fa4117fdd03988885c16938dc43bccdfe8fa4d02d", size = 129134 }, ] +[[package]] +name = "h11" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 }, +] + +[[package]] +name = "httpcore" +version = "1.0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551 }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, +] + +[[package]] +name = "identify" +version = "2.6.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/a71ab060daec766acc30fb47dfca219d03de34a70d616a79a38c6066c5bf/identify-2.6.9.tar.gz", hash = "sha256:d40dfe3142a1421d8518e3d3985ef5ac42890683e32306ad614a29490abeb6bf", size = 99249 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/ce/0845144ed1f0e25db5e7a79c2354c1da4b5ce392b8966449d5db8dca18f1/identify-2.6.9-py2.py3-none-any.whl", hash = "sha256:c98b4322da415a8e5a70ff6e51fbc2d2932c015532d77e9f8537b4ba7813b150", size = 99101 }, +] + [[package]] name = "idna" version = "3.10" @@ -295,6 +548,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 }, ] +[[package]] +name = "lark" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/60/bc7622aefb2aee1c0b4ba23c1446d3e30225c8770b38d7aedbfb65ca9d5a/lark-1.2.2.tar.gz", hash = "sha256:ca807d0162cd16cef15a8feecb862d7319e7a09bdb13aef927968e45040fed80", size = 252132 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/00/d90b10b962b4277f5e64a78b6609968859ff86889f5b898c1a778c06ec00/lark-1.2.2-py3-none-any.whl", hash = "sha256:c2276486b02f0f1b90be155f2c8ba4a8e194d42775786db622faccd652d8e80c", size = 111036 }, +] + [[package]] name = "markdown" version = "3.7" @@ -533,6 +795,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 }, ] +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 }, +] + +[[package]] +name = "nox" +version = "2025.2.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argcomplete" }, + { name = "attrs" }, + { name = "colorlog" }, + { name = "dependency-groups" }, + { name = "packaging" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/22/84a2d3442cb33e6fb1af18172a15deb1eea3f970417f1f4c5fa1600143e8/nox-2025.2.9.tar.gz", hash = "sha256:d50cd4ca568bd7621c2e6cbbc4845b3b7f7697f25d5fb0190ce8f4600be79768", size = 4021103 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/ca/64e634c056cba463cac743735660a772ab78eb26ec9759e88de735f2cd27/nox-2025.2.9-py3-none-any.whl", hash = "sha256:7d1e92d1918c6980d70aee9cf1c1d19d16faa71c4afe338fffd39e8a460e2067", size = 71315 }, +] + [[package]] name = "packaging" version = "24.2" @@ -578,6 +867,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, ] +[[package]] +name = "pre-commit" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/39/679ca9b26c7bb2999ff122d50faa301e49af82ca9c066ec061cfbc0c6784/pre_commit-4.2.0.tar.gz", hash = "sha256:601283b9757afd87d40c4c4a9b2b5de9637a8ea02eaff7adc2d0fb4e04841146", size = 193424 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/74/a88bf1b1efeae488a0c0b7bdf71429c313722d1fc0f377537fbe554e6180/pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd", size = 220707 }, +] + +[[package]] +name = "pre-commit-hooks" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ruamel-yaml" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/7d/3299241a753c738d114600c360d754550b28c285281dc6a5132c4ccfae65/pre_commit_hooks-5.0.0.tar.gz", hash = "sha256:10626959a9eaf602fbfc22bc61b6e75801436f82326bfcee82bb1f2fc4bc646e", size = 29747 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/29/db1d855a661c02dbde5cab3057969133fcc62e7a0c6393e48fe9d0e81679/pre_commit_hooks-5.0.0-py2.py3-none-any.whl", hash = "sha256:8d71cfb582c5c314a5498d94e0104b6567a8b93fb35903ea845c491f4e290a7a", size = 41245 }, +] + [[package]] name = "pydantic" version = "2.11.1" @@ -680,6 +998,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2f/f8/66f328e411f1c9574b13c2c28ab01f308b53688bbbe6ca8fb981e6cabc42/pydantic_core-2.33.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:4b6d77c75a57f041c5ee915ff0b0bb58eabb78728b69ed967bc5b780e8f701b8", size = 2082099 }, ] +[[package]] +name = "pydantic-settings" +version = "2.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/88/82/c79424d7d8c29b994fb01d277da57b0a9b09cc03c3ff875f9bd8a86b2145/pydantic_settings-2.8.1.tar.gz", hash = "sha256:d5c663dfbe9db9d5e1c646b2e161da12f0d734d422ee56f567d0ea2cee4e8585", size = 83550 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/53/a64f03044927dc47aafe029c42a5b7aabc38dfb813475e0e1bf71c4a59d0/pydantic_settings-2.8.1-py3-none-any.whl", hash = "sha256:81942d5ac3d905f7f3ee1a70df5dfb62d5569c12f51a5a647defc1c3d9ee2e9c", size = 30839 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/5a/ff8edd2ea65c13b9dfaefe7953509def5c37545a6b5dc5bb17bee1e901bb/pygeofilter-0.3.1-py2.py3-none-any.whl", hash = "sha256:f13dcdd685bdca32cf9e6665bf2938522276bee140abff321ab04abb12f5761d", size = 87339 }, +] + +[[package]] +name = "pygeoif" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/2e/9f2e369b293e84acabd34df5cc189ac519d7c1bebfd56f5b3ca5bfe1a57b/pygeoif-1.5.1.tar.gz", hash = "sha256:f27a6b6a1ecb87aeacc2b51bcc59ca79be70ed12a0104de8601478b213dd5d81", size = 41210 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/16/58211b90cc314f7fa4f7d7124c770a6ca67ffd57e2b3937ed557f9ac9f57/pygeoif-1.5.1-py3-none-any.whl", hash = "sha256:2210cca8707c2858885525330db2164321218bb676fe0900123eed94624aab34", size = 28075 }, +] + [[package]] name = "pygments" version = "2.19.1" @@ -689,6 +1046,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, ] +[[package]] +name = "pymarkdownlnt" +version = "0.9.29" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "application-properties" }, + { name = "columnar" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b6/37/0fe2a78d2040cd4cbdb389d92d1cfac313e24f28848e3afb7fb6b0af5833/pymarkdownlnt-0.9.29.tar.gz", hash = "sha256:cfa37fe778fbaa11714bcc617e90f7293c628cfadce9c2ec3ea89d9b24aca086", size = 399715 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/e6/048d91c5954c0447dedc6622388bcaf301339d8d14d31cbd7016dda012e5/pymarkdownlnt-0.9.29-py3-none-any.whl", hash = "sha256:8492992c75c94d1d6dd64a8c49c8ce63a171bffb45a2a054235440fe30ebdb34", size = 482105 }, +] + [[package]] name = "pymdown-extensions" version = "10.14.3" @@ -702,38 +1073,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/f5/b9e2a42aa8f9e34d52d66de87941ecd236570c7ed2e87775ed23bbe4e224/pymdown_extensions-10.14.3-py3-none-any.whl", hash = "sha256:05e0bee73d64b9c71a4ae17c72abc2f700e8bc8403755a00580b49a4e9f189e9", size = 264467 }, ] +[[package]] +name = "pyrfc3339" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/d2/6587e8ec3951cbd97c56333d11e0f8a3a4cb64c0d6ed101882b7b31c431f/pyrfc3339-2.0.1.tar.gz", hash = "sha256:e47843379ea35c1296c3b6c67a948a1a490ae0584edfcbdea0eaffb5dd29960b", size = 4573 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/ba/d778be0f6d8d583307b47ba481c95f88a59d5c6dfd5d136bc56656d1d17f/pyRFC3339-2.0.1-py3-none-any.whl", hash = "sha256:30b70a366acac3df7386b558c21af871522560ed7f3f73cf344b8c2cbb8b0c9d", size = 5777 }, +] + [[package]] name = "pystapi" version = "0.0.0" source = { virtual = "." } dependencies = [ - { name = "mkdocstrings-python" }, + { name = "stapi-fastapi" }, { name = "stapi-pydantic" }, ] [package.dev-dependencies] dev = [ { name = "mypy" }, + { name = "pre-commit" }, + { name = "pre-commit-hooks" }, + { name = "pymarkdownlnt" }, { name = "pytest" }, + { name = "pytest-coverage" }, { name = "ruff" }, ] docs = [ { name = "mkdocs-material" }, + { name = "mkdocstrings-python" }, ] [package.metadata] requires-dist = [ - { name = "mkdocstrings-python", specifier = ">=1.16.8" }, + { name = "stapi-fastapi", editable = "stapi-fastapi" }, { name = "stapi-pydantic", editable = "stapi-pydantic" }, ] [package.metadata.requires-dev] dev = [ { name = "mypy", specifier = ">=1.15.0" }, + { name = "pre-commit", specifier = ">=4.2.0" }, + { name = "pre-commit-hooks", specifier = ">=5.0.0" }, + { name = "pymarkdownlnt", specifier = ">=0.9.25" }, + { name = "pytest", specifier = ">=8.1.1" }, { name = "pytest", specifier = ">=8.3.5" }, + { name = "pytest-coverage", specifier = ">=0.0" }, { name = "ruff", specifier = ">=0.11.2" }, ] -docs = [{ name = "mkdocs-material", specifier = ">=9.6.11" }] +docs = [ + { name = "mkdocs-material", specifier = ">=9.6.11" }, + { name = "mkdocstrings-python", specifier = ">=1.16.8" }, +] [[package]] name = "pytest" @@ -752,6 +1145,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634 }, ] +[[package]] +name = "pytest-cov" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/8c/039a7793f23f5cb666c834da9e944123f498ccc0753bed5fbfb2e2c11f87/pytest_cov-6.1.0.tar.gz", hash = "sha256:ec55e828c66755e5b74a21bd7cc03c303a9f928389c0563e50ba454a6dbe71db", size = 66651 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/c5/8d6ffe9fc8f7f57b3662156ae8a34f2b8e7a754c73b48e689ce43145e98c/pytest_cov-6.1.0-py3-none-any.whl", hash = "sha256:cd7e1d54981d5185ef2b8d64b50172ce97e6f357e6df5cb103e828c7f993e201", size = 23743 }, +] + +[[package]] +name = "pytest-cover" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest-cov" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/27/20964101a7cdb260f8d6c4e854659026968321d10c90552b1fe7f6c5f913/pytest-cover-3.0.0.tar.gz", hash = "sha256:5bdb6c1cc3dd75583bb7bc2c57f5e1034a1bfcb79d27c71aceb0b16af981dbf4", size = 3211 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/9b/7b4700c462628e169bd859c6368d596a6aedc87936bde733bead9f875fce/pytest_cover-3.0.0-py2.py3-none-any.whl", hash = "sha256:578249955eb3b5f3991209df6e532bb770b647743b7392d3d97698dc02f39ebb", size = 3769 }, +] + +[[package]] +name = "pytest-coverage" +version = "0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest-cover" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/81/1d954849aed17b254d1c397eb4447a05eedce612a56b627c071df2ce00c1/pytest-coverage-0.0.tar.gz", hash = "sha256:db6af2cbd7e458c7c9fd2b4207cee75258243c8a81cad31a7ee8cfad5be93c05", size = 873 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/4b/d95b052f87db89a2383233c0754c45f6d3b427b7a4bcb771ac9316a6fae1/pytest_coverage-0.0-py2.py3-none-any.whl", hash = "sha256:dedd084c5e74d8e669355325916dc011539b190355021b037242514dee546368", size = 2013 }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -764,6 +1194,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, ] +[[package]] +name = "python-dotenv" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/88/2c/7bb1416c5620485aa793f2de31d3df393d3686aa8a8506d11e10e13c5baf/python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", size = 39920 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225 }, +] + [[package]] name = "pyyaml" version = "6.0.2" @@ -820,6 +1268,75 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/66/bbb1dd374f5c870f59c5bb1db0e18cbe7fa739415a24cbd95b2d1f5ae0c4/pyyaml_env_tag-0.1-py3-none-any.whl", hash = "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069", size = 3911 }, ] +[[package]] +name = "regex" +version = "2024.11.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/5f/bd69653fbfb76cf8604468d3b4ec4c403197144c7bfe0e6a5fc9e02a07cb/regex-2024.11.6.tar.gz", hash = "sha256:7ab159b063c52a0333c884e4679f8d7a85112ee3078fe3d9004b2dd875585519", size = 399494 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/3c/4651f6b130c6842a8f3df82461a8950f923925db8b6961063e82744bddcc/regex-2024.11.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ff590880083d60acc0433f9c3f713c51f7ac6ebb9adf889c79a261ecf541aa91", size = 482674 }, + { url = "https://files.pythonhosted.org/packages/15/51/9f35d12da8434b489c7b7bffc205c474a0a9432a889457026e9bc06a297a/regex-2024.11.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:658f90550f38270639e83ce492f27d2c8d2cd63805c65a13a14d36ca126753f0", size = 287684 }, + { url = "https://files.pythonhosted.org/packages/bd/18/b731f5510d1b8fb63c6b6d3484bfa9a59b84cc578ac8b5172970e05ae07c/regex-2024.11.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:164d8b7b3b4bcb2068b97428060b2a53be050085ef94eca7f240e7947f1b080e", size = 284589 }, + { url = "https://files.pythonhosted.org/packages/78/a2/6dd36e16341ab95e4c6073426561b9bfdeb1a9c9b63ab1b579c2e96cb105/regex-2024.11.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3660c82f209655a06b587d55e723f0b813d3a7db2e32e5e7dc64ac2a9e86fde", size = 782511 }, + { url = "https://files.pythonhosted.org/packages/1b/2b/323e72d5d2fd8de0d9baa443e1ed70363ed7e7b2fb526f5950c5cb99c364/regex-2024.11.6-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d22326fcdef5e08c154280b71163ced384b428343ae16a5ab2b3354aed12436e", size = 821149 }, + { url = "https://files.pythonhosted.org/packages/90/30/63373b9ea468fbef8a907fd273e5c329b8c9535fee36fc8dba5fecac475d/regex-2024.11.6-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f1ac758ef6aebfc8943560194e9fd0fa18bcb34d89fd8bd2af18183afd8da3a2", size = 809707 }, + { url = "https://files.pythonhosted.org/packages/f2/98/26d3830875b53071f1f0ae6d547f1d98e964dd29ad35cbf94439120bb67a/regex-2024.11.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:997d6a487ff00807ba810e0f8332c18b4eb8d29463cfb7c820dc4b6e7562d0cf", size = 781702 }, + { url = "https://files.pythonhosted.org/packages/87/55/eb2a068334274db86208ab9d5599ffa63631b9f0f67ed70ea7c82a69bbc8/regex-2024.11.6-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:02a02d2bb04fec86ad61f3ea7f49c015a0681bf76abb9857f945d26159d2968c", size = 771976 }, + { url = "https://files.pythonhosted.org/packages/74/c0/be707bcfe98254d8f9d2cff55d216e946f4ea48ad2fd8cf1428f8c5332ba/regex-2024.11.6-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f02f93b92358ee3f78660e43b4b0091229260c5d5c408d17d60bf26b6c900e86", size = 697397 }, + { url = "https://files.pythonhosted.org/packages/49/dc/bb45572ceb49e0f6509f7596e4ba7031f6819ecb26bc7610979af5a77f45/regex-2024.11.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:06eb1be98df10e81ebaded73fcd51989dcf534e3c753466e4b60c4697a003b67", size = 768726 }, + { url = "https://files.pythonhosted.org/packages/5a/db/f43fd75dc4c0c2d96d0881967897926942e935d700863666f3c844a72ce6/regex-2024.11.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:040df6fe1a5504eb0f04f048e6d09cd7c7110fef851d7c567a6b6e09942feb7d", size = 775098 }, + { url = "https://files.pythonhosted.org/packages/99/d7/f94154db29ab5a89d69ff893159b19ada89e76b915c1293e98603d39838c/regex-2024.11.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabbfc59f2c6edba2a6622c647b716e34e8e3867e0ab975412c5c2f79b82da2", size = 839325 }, + { url = "https://files.pythonhosted.org/packages/f7/17/3cbfab1f23356fbbf07708220ab438a7efa1e0f34195bf857433f79f1788/regex-2024.11.6-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8447d2d39b5abe381419319f942de20b7ecd60ce86f16a23b0698f22e1b70008", size = 843277 }, + { url = "https://files.pythonhosted.org/packages/7e/f2/48b393b51900456155de3ad001900f94298965e1cad1c772b87f9cfea011/regex-2024.11.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:da8f5fc57d1933de22a9e23eec290a0d8a5927a5370d24bda9a6abe50683fe62", size = 773197 }, + { url = "https://files.pythonhosted.org/packages/45/3f/ef9589aba93e084cd3f8471fded352826dcae8489b650d0b9b27bc5bba8a/regex-2024.11.6-cp310-cp310-win32.whl", hash = "sha256:b489578720afb782f6ccf2840920f3a32e31ba28a4b162e13900c3e6bd3f930e", size = 261714 }, + { url = "https://files.pythonhosted.org/packages/42/7e/5f1b92c8468290c465fd50c5318da64319133231415a8aa6ea5ab995a815/regex-2024.11.6-cp310-cp310-win_amd64.whl", hash = "sha256:5071b2093e793357c9d8b2929dfc13ac5f0a6c650559503bb81189d0a3814519", size = 274042 }, + { url = "https://files.pythonhosted.org/packages/58/58/7e4d9493a66c88a7da6d205768119f51af0f684fe7be7bac8328e217a52c/regex-2024.11.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5478c6962ad548b54a591778e93cd7c456a7a29f8eca9c49e4f9a806dcc5d638", size = 482669 }, + { url = "https://files.pythonhosted.org/packages/34/4c/8f8e631fcdc2ff978609eaeef1d6994bf2f028b59d9ac67640ed051f1218/regex-2024.11.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2c89a8cc122b25ce6945f0423dc1352cb9593c68abd19223eebbd4e56612c5b7", size = 287684 }, + { url = "https://files.pythonhosted.org/packages/c5/1b/f0e4d13e6adf866ce9b069e191f303a30ab1277e037037a365c3aad5cc9c/regex-2024.11.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:94d87b689cdd831934fa3ce16cc15cd65748e6d689f5d2b8f4f4df2065c9fa20", size = 284589 }, + { url = "https://files.pythonhosted.org/packages/25/4d/ab21047f446693887f25510887e6820b93f791992994f6498b0318904d4a/regex-2024.11.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1062b39a0a2b75a9c694f7a08e7183a80c63c0d62b301418ffd9c35f55aaa114", size = 792121 }, + { url = "https://files.pythonhosted.org/packages/45/ee/c867e15cd894985cb32b731d89576c41a4642a57850c162490ea34b78c3b/regex-2024.11.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:167ed4852351d8a750da48712c3930b031f6efdaa0f22fa1933716bfcd6bf4a3", size = 831275 }, + { url = "https://files.pythonhosted.org/packages/b3/12/b0f480726cf1c60f6536fa5e1c95275a77624f3ac8fdccf79e6727499e28/regex-2024.11.6-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d548dafee61f06ebdb584080621f3e0c23fff312f0de1afc776e2a2ba99a74f", size = 818257 }, + { url = "https://files.pythonhosted.org/packages/bf/ce/0d0e61429f603bac433910d99ef1a02ce45a8967ffbe3cbee48599e62d88/regex-2024.11.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2a19f302cd1ce5dd01a9099aaa19cae6173306d1302a43b627f62e21cf18ac0", size = 792727 }, + { url = "https://files.pythonhosted.org/packages/e4/c1/243c83c53d4a419c1556f43777ccb552bccdf79d08fda3980e4e77dd9137/regex-2024.11.6-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bec9931dfb61ddd8ef2ebc05646293812cb6b16b60cf7c9511a832b6f1854b55", size = 780667 }, + { url = "https://files.pythonhosted.org/packages/c5/f4/75eb0dd4ce4b37f04928987f1d22547ddaf6c4bae697623c1b05da67a8aa/regex-2024.11.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9714398225f299aa85267fd222f7142fcb5c769e73d7733344efc46f2ef5cf89", size = 776963 }, + { url = "https://files.pythonhosted.org/packages/16/5d/95c568574e630e141a69ff8a254c2f188b4398e813c40d49228c9bbd9875/regex-2024.11.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:202eb32e89f60fc147a41e55cb086db2a3f8cb82f9a9a88440dcfc5d37faae8d", size = 784700 }, + { url = "https://files.pythonhosted.org/packages/8e/b5/f8495c7917f15cc6fee1e7f395e324ec3e00ab3c665a7dc9d27562fd5290/regex-2024.11.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:4181b814e56078e9b00427ca358ec44333765f5ca1b45597ec7446d3a1ef6e34", size = 848592 }, + { url = "https://files.pythonhosted.org/packages/1c/80/6dd7118e8cb212c3c60b191b932dc57db93fb2e36fb9e0e92f72a5909af9/regex-2024.11.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:068376da5a7e4da51968ce4c122a7cd31afaaec4fccc7856c92f63876e57b51d", size = 852929 }, + { url = "https://files.pythonhosted.org/packages/11/9b/5a05d2040297d2d254baf95eeeb6df83554e5e1df03bc1a6687fc4ba1f66/regex-2024.11.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ac10f2c4184420d881a3475fb2c6f4d95d53a8d50209a2500723d831036f7c45", size = 781213 }, + { url = "https://files.pythonhosted.org/packages/26/b7/b14e2440156ab39e0177506c08c18accaf2b8932e39fb092074de733d868/regex-2024.11.6-cp311-cp311-win32.whl", hash = "sha256:c36f9b6f5f8649bb251a5f3f66564438977b7ef8386a52460ae77e6070d309d9", size = 261734 }, + { url = "https://files.pythonhosted.org/packages/80/32/763a6cc01d21fb3819227a1cc3f60fd251c13c37c27a73b8ff4315433a8e/regex-2024.11.6-cp311-cp311-win_amd64.whl", hash = "sha256:02e28184be537f0e75c1f9b2f8847dc51e08e6e171c6bde130b2687e0c33cf60", size = 274052 }, + { url = "https://files.pythonhosted.org/packages/ba/30/9a87ce8336b172cc232a0db89a3af97929d06c11ceaa19d97d84fa90a8f8/regex-2024.11.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:52fb28f528778f184f870b7cf8f225f5eef0a8f6e3778529bdd40c7b3920796a", size = 483781 }, + { url = "https://files.pythonhosted.org/packages/01/e8/00008ad4ff4be8b1844786ba6636035f7ef926db5686e4c0f98093612add/regex-2024.11.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdd6028445d2460f33136c55eeb1f601ab06d74cb3347132e1c24250187500d9", size = 288455 }, + { url = "https://files.pythonhosted.org/packages/60/85/cebcc0aff603ea0a201667b203f13ba75d9fc8668fab917ac5b2de3967bc/regex-2024.11.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:805e6b60c54bf766b251e94526ebad60b7de0c70f70a4e6210ee2891acb70bf2", size = 284759 }, + { url = "https://files.pythonhosted.org/packages/94/2b/701a4b0585cb05472a4da28ee28fdfe155f3638f5e1ec92306d924e5faf0/regex-2024.11.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b85c2530be953a890eaffde05485238f07029600e8f098cdf1848d414a8b45e4", size = 794976 }, + { url = "https://files.pythonhosted.org/packages/4b/bf/fa87e563bf5fee75db8915f7352e1887b1249126a1be4813837f5dbec965/regex-2024.11.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bb26437975da7dc36b7efad18aa9dd4ea569d2357ae6b783bf1118dabd9ea577", size = 833077 }, + { url = "https://files.pythonhosted.org/packages/a1/56/7295e6bad94b047f4d0834e4779491b81216583c00c288252ef625c01d23/regex-2024.11.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:abfa5080c374a76a251ba60683242bc17eeb2c9818d0d30117b4486be10c59d3", size = 823160 }, + { url = "https://files.pythonhosted.org/packages/fb/13/e3b075031a738c9598c51cfbc4c7879e26729c53aa9cca59211c44235314/regex-2024.11.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b7fa6606c2881c1db9479b0eaa11ed5dfa11c8d60a474ff0e095099f39d98e", size = 796896 }, + { url = "https://files.pythonhosted.org/packages/24/56/0b3f1b66d592be6efec23a795b37732682520b47c53da5a32c33ed7d84e3/regex-2024.11.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0c32f75920cf99fe6b6c539c399a4a128452eaf1af27f39bce8909c9a3fd8cbe", size = 783997 }, + { url = "https://files.pythonhosted.org/packages/f9/a1/eb378dada8b91c0e4c5f08ffb56f25fcae47bf52ad18f9b2f33b83e6d498/regex-2024.11.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:982e6d21414e78e1f51cf595d7f321dcd14de1f2881c5dc6a6e23bbbbd68435e", size = 781725 }, + { url = "https://files.pythonhosted.org/packages/83/f2/033e7dec0cfd6dda93390089864732a3409246ffe8b042e9554afa9bff4e/regex-2024.11.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a7c2155f790e2fb448faed6dd241386719802296ec588a8b9051c1f5c481bc29", size = 789481 }, + { url = "https://files.pythonhosted.org/packages/83/23/15d4552ea28990a74e7696780c438aadd73a20318c47e527b47a4a5a596d/regex-2024.11.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:149f5008d286636e48cd0b1dd65018548944e495b0265b45e1bffecce1ef7f39", size = 852896 }, + { url = "https://files.pythonhosted.org/packages/e3/39/ed4416bc90deedbfdada2568b2cb0bc1fdb98efe11f5378d9892b2a88f8f/regex-2024.11.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e5364a4502efca094731680e80009632ad6624084aff9a23ce8c8c6820de3e51", size = 860138 }, + { url = "https://files.pythonhosted.org/packages/93/2d/dd56bb76bd8e95bbce684326302f287455b56242a4f9c61f1bc76e28360e/regex-2024.11.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0a86e7eeca091c09e021db8eb72d54751e527fa47b8d5787caf96d9831bd02ad", size = 787692 }, + { url = "https://files.pythonhosted.org/packages/0b/55/31877a249ab7a5156758246b9c59539abbeba22461b7d8adc9e8475ff73e/regex-2024.11.6-cp312-cp312-win32.whl", hash = "sha256:32f9a4c643baad4efa81d549c2aadefaeba12249b2adc5af541759237eee1c54", size = 262135 }, + { url = "https://files.pythonhosted.org/packages/38/ec/ad2d7de49a600cdb8dd78434a1aeffe28b9d6fc42eb36afab4a27ad23384/regex-2024.11.6-cp312-cp312-win_amd64.whl", hash = "sha256:a93c194e2df18f7d264092dc8539b8ffb86b45b899ab976aa15d48214138e81b", size = 273567 }, + { url = "https://files.pythonhosted.org/packages/90/73/bcb0e36614601016552fa9344544a3a2ae1809dc1401b100eab02e772e1f/regex-2024.11.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a6ba92c0bcdf96cbf43a12c717eae4bc98325ca3730f6b130ffa2e3c3c723d84", size = 483525 }, + { url = "https://files.pythonhosted.org/packages/0f/3f/f1a082a46b31e25291d830b369b6b0c5576a6f7fb89d3053a354c24b8a83/regex-2024.11.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:525eab0b789891ac3be914d36893bdf972d483fe66551f79d3e27146191a37d4", size = 288324 }, + { url = "https://files.pythonhosted.org/packages/09/c9/4e68181a4a652fb3ef5099e077faf4fd2a694ea6e0f806a7737aff9e758a/regex-2024.11.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:086a27a0b4ca227941700e0b31425e7a28ef1ae8e5e05a33826e17e47fbfdba0", size = 284617 }, + { url = "https://files.pythonhosted.org/packages/fc/fd/37868b75eaf63843165f1d2122ca6cb94bfc0271e4428cf58c0616786dce/regex-2024.11.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bde01f35767c4a7899b7eb6e823b125a64de314a8ee9791367c9a34d56af18d0", size = 795023 }, + { url = "https://files.pythonhosted.org/packages/c4/7c/d4cd9c528502a3dedb5c13c146e7a7a539a3853dc20209c8e75d9ba9d1b2/regex-2024.11.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b583904576650166b3d920d2bcce13971f6f9e9a396c673187f49811b2769dc7", size = 833072 }, + { url = "https://files.pythonhosted.org/packages/4f/db/46f563a08f969159c5a0f0e722260568425363bea43bb7ae370becb66a67/regex-2024.11.6-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c4de13f06a0d54fa0d5ab1b7138bfa0d883220965a29616e3ea61b35d5f5fc7", size = 823130 }, + { url = "https://files.pythonhosted.org/packages/db/60/1eeca2074f5b87df394fccaa432ae3fc06c9c9bfa97c5051aed70e6e00c2/regex-2024.11.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3cde6e9f2580eb1665965ce9bf17ff4952f34f5b126beb509fee8f4e994f143c", size = 796857 }, + { url = "https://files.pythonhosted.org/packages/10/db/ac718a08fcee981554d2f7bb8402f1faa7e868c1345c16ab1ebec54b0d7b/regex-2024.11.6-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0d7f453dca13f40a02b79636a339c5b62b670141e63efd511d3f8f73fba162b3", size = 784006 }, + { url = "https://files.pythonhosted.org/packages/c2/41/7da3fe70216cea93144bf12da2b87367590bcf07db97604edeea55dac9ad/regex-2024.11.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:59dfe1ed21aea057a65c6b586afd2a945de04fc7db3de0a6e3ed5397ad491b07", size = 781650 }, + { url = "https://files.pythonhosted.org/packages/a7/d5/880921ee4eec393a4752e6ab9f0fe28009435417c3102fc413f3fe81c4e5/regex-2024.11.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b97c1e0bd37c5cd7902e65f410779d39eeda155800b65fc4d04cc432efa9bc6e", size = 789545 }, + { url = "https://files.pythonhosted.org/packages/dc/96/53770115e507081122beca8899ab7f5ae28ae790bfcc82b5e38976df6a77/regex-2024.11.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f9d1e379028e0fc2ae3654bac3cbbef81bf3fd571272a42d56c24007979bafb6", size = 853045 }, + { url = "https://files.pythonhosted.org/packages/31/d3/1372add5251cc2d44b451bd94f43b2ec78e15a6e82bff6a290ef9fd8f00a/regex-2024.11.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:13291b39131e2d002a7940fb176e120bec5145f3aeb7621be6534e46251912c4", size = 860182 }, + { url = "https://files.pythonhosted.org/packages/ed/e3/c446a64984ea9f69982ba1a69d4658d5014bc7a0ea468a07e1a1265db6e2/regex-2024.11.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f51f88c126370dcec4908576c5a627220da6c09d0bff31cfa89f2523843316d", size = 787733 }, + { url = "https://files.pythonhosted.org/packages/2b/f1/e40c8373e3480e4f29f2692bd21b3e05f296d3afebc7e5dcf21b9756ca1c/regex-2024.11.6-cp313-cp313-win32.whl", hash = "sha256:63b13cfd72e9601125027202cad74995ab26921d8cd935c25f09c630436348ff", size = 262122 }, + { url = "https://files.pythonhosted.org/packages/45/94/bc295babb3062a731f52621cdc992d123111282e291abaf23faa413443ea/regex-2024.11.6-cp313-cp313-win_amd64.whl", hash = "sha256:2b3361af3198667e99927da8b84c1b010752fa4b1115ee30beaa332cabc3ef1a", size = 273545 }, +] + [[package]] name = "requests" version = "2.32.3" @@ -835,6 +1352,74 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, ] +[[package]] +name = "returns" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/2c/90479667b4e46759c11d7c0e04f2ffa47e3cdabb1984d06a004e6c523ef2/returns-0.25.0.tar.gz", hash = "sha256:1bf547311c0ade25435ce3bbe81642c325ea6b86beaf5d624cd410f0dee3ff50", size = 105128 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/95/3fd02e08fa0d9ec9127b8f1379eda36b0b070096eee1a7038042508ae381/returns-0.25.0-py3-none-any.whl", hash = "sha256:bdc6ec52d28e74d6965f6de5a3af5e39427e67266014b605865fe2e194a75ed0", size = 160145 }, +] + +[[package]] +name = "ruamel-yaml" +version = "0.18.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ruamel-yaml-clib", marker = "python_full_version < '3.13' and platform_python_implementation == 'CPython'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ea/46/f44d8be06b85bc7c4d8c95d658be2b68f27711f279bf9dd0612a5e4794f5/ruamel.yaml-0.18.10.tar.gz", hash = "sha256:20c86ab29ac2153f80a428e1254a8adf686d3383df04490514ca3b79a362db58", size = 143447 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/36/dfc1ebc0081e6d39924a2cc53654497f967a084a436bb64402dfce4254d9/ruamel.yaml-0.18.10-py3-none-any.whl", hash = "sha256:30f22513ab2301b3d2b577adc121c6471f28734d3d9728581245f1e76468b4f1", size = 117729 }, +] + +[[package]] +name = "ruamel-yaml-clib" +version = "0.2.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/84/80203abff8ea4993a87d823a5f632e4d92831ef75d404c9fc78d0176d2b5/ruamel.yaml.clib-0.2.12.tar.gz", hash = "sha256:6c8fbb13ec503f99a91901ab46e0b07ae7941cd527393187039aec586fdfd36f", size = 225315 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/57/40a958e863e299f0c74ef32a3bde9f2d1ea8d69669368c0c502a0997f57f/ruamel.yaml.clib-0.2.12-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:11f891336688faf5156a36293a9c362bdc7c88f03a8a027c2c1d8e0bcde998e5", size = 131301 }, + { url = "https://files.pythonhosted.org/packages/98/a8/29a3eb437b12b95f50a6bcc3d7d7214301c6c529d8fdc227247fa84162b5/ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:a606ef75a60ecf3d924613892cc603b154178ee25abb3055db5062da811fd969", size = 633728 }, + { url = "https://files.pythonhosted.org/packages/35/6d/ae05a87a3ad540259c3ad88d71275cbd1c0f2d30ae04c65dcbfb6dcd4b9f/ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd5415dded15c3822597455bc02bcd66e81ef8b7a48cb71a33628fc9fdde39df", size = 722230 }, + { url = "https://files.pythonhosted.org/packages/7f/b7/20c6f3c0b656fe609675d69bc135c03aac9e3865912444be6339207b6648/ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f66efbc1caa63c088dead1c4170d148eabc9b80d95fb75b6c92ac0aad2437d76", size = 686712 }, + { url = "https://files.pythonhosted.org/packages/cd/11/d12dbf683471f888d354dac59593873c2b45feb193c5e3e0f2ebf85e68b9/ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:22353049ba4181685023b25b5b51a574bce33e7f51c759371a7422dcae5402a6", size = 663936 }, + { url = "https://files.pythonhosted.org/packages/72/14/4c268f5077db5c83f743ee1daeb236269fa8577133a5cfa49f8b382baf13/ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:932205970b9f9991b34f55136be327501903f7c66830e9760a8ffb15b07f05cd", size = 696580 }, + { url = "https://files.pythonhosted.org/packages/30/fc/8cd12f189c6405a4c1cf37bd633aa740a9538c8e40497c231072d0fef5cf/ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a52d48f4e7bf9005e8f0a89209bf9a73f7190ddf0489eee5eb51377385f59f2a", size = 663393 }, + { url = "https://files.pythonhosted.org/packages/80/29/c0a017b704aaf3cbf704989785cd9c5d5b8ccec2dae6ac0c53833c84e677/ruamel.yaml.clib-0.2.12-cp310-cp310-win32.whl", hash = "sha256:3eac5a91891ceb88138c113f9db04f3cebdae277f5d44eaa3651a4f573e6a5da", size = 100326 }, + { url = "https://files.pythonhosted.org/packages/3a/65/fa39d74db4e2d0cd252355732d966a460a41cd01c6353b820a0952432839/ruamel.yaml.clib-0.2.12-cp310-cp310-win_amd64.whl", hash = "sha256:ab007f2f5a87bd08ab1499bdf96f3d5c6ad4dcfa364884cb4549aa0154b13a28", size = 118079 }, + { url = "https://files.pythonhosted.org/packages/fb/8f/683c6ad562f558cbc4f7c029abcd9599148c51c54b5ef0f24f2638da9fbb/ruamel.yaml.clib-0.2.12-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:4a6679521a58256a90b0d89e03992c15144c5f3858f40d7c18886023d7943db6", size = 132224 }, + { url = "https://files.pythonhosted.org/packages/3c/d2/b79b7d695e2f21da020bd44c782490578f300dd44f0a4c57a92575758a76/ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:d84318609196d6bd6da0edfa25cedfbabd8dbde5140a0a23af29ad4b8f91fb1e", size = 641480 }, + { url = "https://files.pythonhosted.org/packages/68/6e/264c50ce2a31473a9fdbf4fa66ca9b2b17c7455b31ef585462343818bd6c/ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb43a269eb827806502c7c8efb7ae7e9e9d0573257a46e8e952f4d4caba4f31e", size = 739068 }, + { url = "https://files.pythonhosted.org/packages/86/29/88c2567bc893c84d88b4c48027367c3562ae69121d568e8a3f3a8d363f4d/ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:811ea1594b8a0fb466172c384267a4e5e367298af6b228931f273b111f17ef52", size = 703012 }, + { url = "https://files.pythonhosted.org/packages/11/46/879763c619b5470820f0cd6ca97d134771e502776bc2b844d2adb6e37753/ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:cf12567a7b565cbf65d438dec6cfbe2917d3c1bdddfce84a9930b7d35ea59642", size = 704352 }, + { url = "https://files.pythonhosted.org/packages/02/80/ece7e6034256a4186bbe50dee28cd032d816974941a6abf6a9d65e4228a7/ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7dd5adc8b930b12c8fc5b99e2d535a09889941aa0d0bd06f4749e9a9397c71d2", size = 737344 }, + { url = "https://files.pythonhosted.org/packages/f0/ca/e4106ac7e80efbabdf4bf91d3d32fc424e41418458251712f5672eada9ce/ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1492a6051dab8d912fc2adeef0e8c72216b24d57bd896ea607cb90bb0c4981d3", size = 714498 }, + { url = "https://files.pythonhosted.org/packages/67/58/b1f60a1d591b771298ffa0428237afb092c7f29ae23bad93420b1eb10703/ruamel.yaml.clib-0.2.12-cp311-cp311-win32.whl", hash = "sha256:bd0a08f0bab19093c54e18a14a10b4322e1eacc5217056f3c063bd2f59853ce4", size = 100205 }, + { url = "https://files.pythonhosted.org/packages/b4/4f/b52f634c9548a9291a70dfce26ca7ebce388235c93588a1068028ea23fcc/ruamel.yaml.clib-0.2.12-cp311-cp311-win_amd64.whl", hash = "sha256:a274fb2cb086c7a3dea4322ec27f4cb5cc4b6298adb583ab0e211a4682f241eb", size = 118185 }, + { url = "https://files.pythonhosted.org/packages/48/41/e7a405afbdc26af961678474a55373e1b323605a4f5e2ddd4a80ea80f628/ruamel.yaml.clib-0.2.12-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:20b0f8dc160ba83b6dcc0e256846e1a02d044e13f7ea74a3d1d56ede4e48c632", size = 133433 }, + { url = "https://files.pythonhosted.org/packages/ec/b0/b850385604334c2ce90e3ee1013bd911aedf058a934905863a6ea95e9eb4/ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:943f32bc9dedb3abff9879edc134901df92cfce2c3d5c9348f172f62eb2d771d", size = 647362 }, + { url = "https://files.pythonhosted.org/packages/44/d0/3f68a86e006448fb6c005aee66565b9eb89014a70c491d70c08de597f8e4/ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95c3829bb364fdb8e0332c9931ecf57d9be3519241323c5274bd82f709cebc0c", size = 754118 }, + { url = "https://files.pythonhosted.org/packages/52/a9/d39f3c5ada0a3bb2870d7db41901125dbe2434fa4f12ca8c5b83a42d7c53/ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:749c16fcc4a2b09f28843cda5a193e0283e47454b63ec4b81eaa2242f50e4ccd", size = 706497 }, + { url = "https://files.pythonhosted.org/packages/b0/fa/097e38135dadd9ac25aecf2a54be17ddf6e4c23e43d538492a90ab3d71c6/ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bf165fef1f223beae7333275156ab2022cffe255dcc51c27f066b4370da81e31", size = 698042 }, + { url = "https://files.pythonhosted.org/packages/ec/d5/a659ca6f503b9379b930f13bc6b130c9f176469b73b9834296822a83a132/ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:32621c177bbf782ca5a18ba4d7af0f1082a3f6e517ac2a18b3974d4edf349680", size = 745831 }, + { url = "https://files.pythonhosted.org/packages/db/5d/36619b61ffa2429eeaefaab4f3374666adf36ad8ac6330d855848d7d36fd/ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b82a7c94a498853aa0b272fd5bc67f29008da798d4f93a2f9f289feb8426a58d", size = 715692 }, + { url = "https://files.pythonhosted.org/packages/b1/82/85cb92f15a4231c89b95dfe08b09eb6adca929ef7df7e17ab59902b6f589/ruamel.yaml.clib-0.2.12-cp312-cp312-win32.whl", hash = "sha256:e8c4ebfcfd57177b572e2040777b8abc537cdef58a2120e830124946aa9b42c5", size = 98777 }, + { url = "https://files.pythonhosted.org/packages/d7/8f/c3654f6f1ddb75daf3922c3d8fc6005b1ab56671ad56ffb874d908bfa668/ruamel.yaml.clib-0.2.12-cp312-cp312-win_amd64.whl", hash = "sha256:0467c5965282c62203273b838ae77c0d29d7638c8a4e3a1c8bdd3602c10904e4", size = 115523 }, + { url = "https://files.pythonhosted.org/packages/29/00/4864119668d71a5fa45678f380b5923ff410701565821925c69780356ffa/ruamel.yaml.clib-0.2.12-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4c8c5d82f50bb53986a5e02d1b3092b03622c02c2eb78e29bec33fd9593bae1a", size = 132011 }, + { url = "https://files.pythonhosted.org/packages/7f/5e/212f473a93ae78c669ffa0cb051e3fee1139cb2d385d2ae1653d64281507/ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:e7e3736715fbf53e9be2a79eb4db68e4ed857017344d697e8b9749444ae57475", size = 642488 }, + { url = "https://files.pythonhosted.org/packages/1f/8f/ecfbe2123ade605c49ef769788f79c38ddb1c8fa81e01f4dbf5cf1a44b16/ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b7e75b4965e1d4690e93021adfcecccbca7d61c7bddd8e22406ef2ff20d74ef", size = 745066 }, + { url = "https://files.pythonhosted.org/packages/e2/a9/28f60726d29dfc01b8decdb385de4ced2ced9faeb37a847bd5cf26836815/ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96777d473c05ee3e5e3c3e999f5d23c6f4ec5b0c38c098b3a5229085f74236c6", size = 701785 }, + { url = "https://files.pythonhosted.org/packages/84/7e/8e7ec45920daa7f76046578e4f677a3215fe8f18ee30a9cb7627a19d9b4c/ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:3bc2a80e6420ca8b7d3590791e2dfc709c88ab9152c00eeb511c9875ce5778bf", size = 693017 }, + { url = "https://files.pythonhosted.org/packages/c5/b3/d650eaade4ca225f02a648321e1ab835b9d361c60d51150bac49063b83fa/ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e188d2699864c11c36cdfdada94d781fd5d6b0071cd9c427bceb08ad3d7c70e1", size = 741270 }, + { url = "https://files.pythonhosted.org/packages/87/b8/01c29b924dcbbed75cc45b30c30d565d763b9c4d540545a0eeecffb8f09c/ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f6f3eac23941b32afccc23081e1f50612bdbe4e982012ef4f5797986828cd01", size = 709059 }, + { url = "https://files.pythonhosted.org/packages/30/8c/ed73f047a73638257aa9377ad356bea4d96125b305c34a28766f4445cc0f/ruamel.yaml.clib-0.2.12-cp313-cp313-win32.whl", hash = "sha256:6442cb36270b3afb1b4951f060eccca1ce49f3d087ca1ca4563a6eb479cb3de6", size = 98583 }, + { url = "https://files.pythonhosted.org/packages/b0/85/e8e751d8791564dd333d5d9a4eab0a7a115f7e349595417fd50ecae3395c/ruamel.yaml.clib-0.2.12-cp313-cp313-win_amd64.whl", hash = "sha256:e5b8daf27af0b90da7bb903a876477a9e6d7270be6146906b276605997c7e9a3", size = 115190 }, +] + [[package]] name = "ruff" version = "0.11.2" @@ -869,6 +1454,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, ] +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, +] + +[[package]] +name = "stapi-fastapi" +version = "0.6.0" +source = { editable = "stapi-fastapi" } +dependencies = [ + { name = "fastapi" }, + { name = "geojson-pydantic" }, + { name = "httpx" }, + { name = "nox" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pygeofilter" }, + { name = "pyrfc3339" }, + { name = "returns" }, + { name = "types-pyrfc3339" }, + { name = "uvicorn" }, +] + +[package.metadata] +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 = "pyrfc3339", specifier = ">=1.1" }, + { name = "returns", specifier = ">=0.23" }, + { name = "types-pyrfc3339", specifier = ">=1.1.1" }, + { name = "uvicorn", specifier = ">=0.29.0" }, +] + [[package]] name = "stapi-pydantic" version = "0.0.1" @@ -884,6 +1511,18 @@ requires-dist = [ { name = "geojson-pydantic", specifier = ">=1.2.0" }, ] +[[package]] +name = "starlette" +version = "0.46.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/1b/52b27f2e13ceedc79a908e29eac426a63465a1a01248e5f24aa36a62aeb3/starlette-0.46.1.tar.gz", hash = "sha256:3c88d58ee4bd1bb807c0d1acb381838afc7752f9ddaec81bbe4383611d833230", size = 2580102 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/4b/528ccf7a982216885a1ff4908e886b8fb5f19862d1962f56a3fce2435a70/starlette-0.46.1-py3-none-any.whl", hash = "sha256:77c74ed9d2720138b25875133f3a2dae6d854af2ec37dceb56aef370c1d8a227", size = 71995 }, +] + [[package]] name = "tomli" version = "2.2.1" @@ -923,6 +1562,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 }, ] +[[package]] +name = "toolz" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/0b/d80dfa675bf592f636d1ea0b835eab4ec8df6e9415d8cfd766df54456123/toolz-1.0.0.tar.gz", hash = "sha256:2c86e3d9a04798ac556793bced838816296a2f085017664e4995cb40a1047a02", size = 66790 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/98/eb27cc78ad3af8e302c9d8ff4977f5026676e130d28dd7578132a457170c/toolz-1.0.0-py3-none-any.whl", hash = "sha256:292c8f1c4e7516bf9086f8850935c799a874039c8bcf959d47b600e4c44a6236", size = 56383 }, +] + +[[package]] +name = "types-pyrfc3339" +version = "2.0.1.20241107" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/04/40e8cd1e690f708c063bc28191fbd7fa868efa2d50fbd603b466ec509e81/types-pyRFC3339-2.0.1.20241107.tar.gz", hash = "sha256:0f84380fc93c1c65fd45f06afc5ae0ef90874411780863525490c7924d85bc5d", size = 2916 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/82/9b8bedab4720f28dcf701c7dc071016b5c4ebab347a3b25dba2d004d88d7/types_pyRFC3339-2.0.1.20241107-py3-none-any.whl", hash = "sha256:caa6cf287a29b6150db531bf0eddab2ecf2f1a32534a1f7122a2daa173a51ac2", size = 3346 }, +] + [[package]] name = "typing-extensions" version = "4.13.0" @@ -944,6 +1601,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/31/08/aa4fdfb71f7de5176385bd9e90852eaf6b5d622735020ad600f2bab54385/typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f", size = 14125 }, ] +[[package]] +name = "tzdata" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026 }, +] + [[package]] name = "urllib3" version = "2.3.0" @@ -953,6 +1631,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 }, ] +[[package]] +name = "uvicorn" +version = "0.34.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4b/4d/938bd85e5bf2edeec766267a5015ad969730bb91e31b44021dfe8b22df6c/uvicorn-0.34.0.tar.gz", hash = "sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9", size = 76568 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/14/33a3a1352cfa71812a3a21e8c9bfb83f60b0011f5e36f2b1399d51928209/uvicorn-0.34.0-py3-none-any.whl", hash = "sha256:023dc038422502fa28a09c7a30bf2b6991512da7dcdb8fd35fe57cfc154126f4", size = 62315 }, +] + +[[package]] +name = "virtualenv" +version = "20.30.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/e0/633e369b91bbc664df47dcb5454b6c7cf441e8f5b9d0c250ce9f0546401e/virtualenv-20.30.0.tar.gz", hash = "sha256:800863162bcaa5450a6e4d721049730e7f2dae07720e0902b0e4040bd6f9ada8", size = 4346945 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/ed/3cfeb48175f0671ec430ede81f628f9fb2b1084c9064ca67ebe8c0ed6a05/virtualenv-20.30.0-py3-none-any.whl", hash = "sha256:e34302959180fca3af42d1800df014b35019490b119eba981af27f2fa486e5d6", size = 4329461 }, +] + [[package]] name = "watchdog" version = "6.0.0" @@ -984,3 +1690,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070 }, { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067 }, ] + +[[package]] +name = "wcwidth" +version = "0.2.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/63/53559446a878410fc5a5974feb13d31d78d752eb18aeba59c7fef1af7598/wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5", size = 101301 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166 }, +]