Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
32d4010
feat!: bump STAPI_VERSION to 0.2.0 and export it
jkeifer Aug 7, 2026
6925e9a
refactor!: hold a JSON Schema in a model instead of annotating a class
jkeifer Aug 7, 2026
0cb81d0
feat: report the queryables a filter must supply a predicate for
jkeifer Aug 7, 2026
8b3576f
feat!: narrow Geometry to the six types the spec enumerates
jkeifer Aug 7, 2026
0d65c75
feat!: allow a datetime interval to be open at one end
jkeifer Aug 7, 2026
885db33
fix!: reject a malformed CQL2 filter as a validation error
jkeifer Aug 7, 2026
c570646
feat: add SearchParameters, the shared Search Parameters Object
jkeifer Aug 7, 2026
6d8588b
fix!: omit unset Link fields from every dump, not only JSON
jkeifer Aug 7, 2026
18da4a3
feat!: serialize response models by alias and declare one conformsTo
jkeifer Aug 7, 2026
f285d7e
feat!: rename ProductsCollection to ProductCollection
jkeifer Aug 7, 2026
065d8aa
feat: split order parameters into a permissive base and a strict subc…
jkeifer Aug 7, 2026
ff463f5
feat!: compose SearchParameters into the opportunity and order requests
jkeifer Aug 7, 2026
3c6c944
feat!: record an order's request as a single StoredOrderRequest
jkeifer Aug 7, 2026
cd448b4
feat!: record what an opportunity search was for, not the request tha…
jkeifer Aug 7, 2026
2ead0c8
feat!: let an implementation constrain its own status code set
jkeifer Aug 7, 2026
0d3a474
feat!: give order and search statuses matching collection wrappers
jkeifer Aug 7, 2026
26fe7a7
refactor!: derive Order from Feature again, and harden Opportunity
jkeifer Aug 7, 2026
799f496
feat: publish stapi_type and stapi_version on every entity
jkeifer Aug 7, 2026
a9ab373
feat!: derive a required bbox from geometry, and union it across coll…
jkeifer Aug 7, 2026
c1291f4
feat: publish numberMatched on every collection
jkeifer Aug 7, 2026
0fdbb8d
refactor: name parameterized generics readably in published schemas
jkeifer Aug 7, 2026
78a16ce
build!: drop the runtime dependencies stapi-fastapi never imported
jkeifer Aug 7, 2026
3e2aeda
fix!: dedupe and rename the route name constants
jkeifer Aug 7, 2026
debcb7b
refactor: spell a route's registered name in one place
jkeifer Aug 7, 2026
ff35635
refactor!: let a Route describe itself, including how it can fail
jkeifer Aug 7, 2026
d05cee9
feat!: publish camelCase path parameters
jkeifer Aug 7, 2026
152e693
feat!: bound the pagination limit and publish what it accepts
jkeifer Aug 7, 2026
ec2ef0e
refactor!: move the shared pagination link onto the base router
jkeifer Aug 7, 2026
ba7b479
feat!: return a Page from every list backend
jkeifer Aug 7, 2026
544829a
fix: carry query params and the served media type on collection links
jkeifer Aug 7, 2026
e8a788c
fix!: report an unusable pagination token with its own error
jkeifer Aug 7, 2026
579999b
feat!: paginate the search record statuses endpoint
jkeifer Aug 7, 2026
1a4c3bd
feat!: paginate the stored opportunity collection
jkeifer Aug 7, 2026
c3a0e5e
feat!: reject a search whose filter omits a required queryable
jkeifer Aug 7, 2026
74ae4a4
fix: advertise only the conformance classes actually served
jkeifer Aug 7, 2026
a57c5ab
fix: withhold order statuses when no backend supplies them
jkeifer Aug 7, 2026
06decfe
feat!: publish the search-records rel and gate the statuses endpoint
jkeifer Aug 7, 2026
4c4bcd3
fix: reject a product registered under an id already taken
jkeifer Aug 7, 2026
a1290a0
fix: send Preference-Applied whenever a Prefer header arrived
jkeifer Aug 7, 2026
e4d1a99
feat: declare what the opportunity search and order creation return
jkeifer Aug 7, 2026
4fb46e4
fix!: type the client against the v0.2.0 models
jkeifer Aug 7, 2026
d2cceef
fix!: correct the client's conformance URI matching and scope
jkeifer Aug 7, 2026
61d8a73
build: cap schemathesis below 4
jkeifer Aug 7, 2026
5b2665d
fix!: make the conformance gate demand an explicit document
jkeifer Aug 7, 2026
7f17cd8
docs: state the pagination-token contract on the backend protocols
jkeifer Aug 7, 2026
ef25672
build: type-check the test suites
jkeifer Aug 7, 2026
7a3baaf
docs: record the 0.2.0 and 0.9.0 releases with migration guides
jkeifer Aug 7, 2026
30b7820
docs: state what the tests check, not what they used to catch
jkeifer Aug 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,6 @@ jobs:
run: uv run pre-commit run --all-files
- name: Test
run: ./scripts/run-tests.sh
- name: Validate test server
run: uv run scripts/validate-stapi-fastapi
Comment thread
jkeifer marked this conversation as resolved.
- name: Docs
run: uv run mkdocs build --strict
- uses: actions/upload-pages-artifact@v3
Expand Down
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ repos:

- id: mypy
name: Check typing with mypy
entry: uv run mypy
entry: ./scripts/run-mypy.sh
language: system
types: [python]
pass_filenames: false
Expand Down
6 changes: 5 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ files = [
]

[[tool.mypy.overrides]]
module = "pygeofilter.parsers.*"
module = "respx.*"
ignore_missing_imports = true

[tool.pymarkdown]
Expand All @@ -81,7 +81,11 @@ filterwarnings = [
"ignore:The 'app' shortcut is now deprecated.:DeprecationWarning",
"ignore:Pydantic serializer warnings:UserWarning",
"ignore:jsonschema.exceptions.RefResolutionError is deprecated:DeprecationWarning",
# Both raised by schemathesis<4, which still uses the pre-4.18 jsonschema
# referencing API. Removable once the validator moves to schemathesis 4.
"ignore:jsonschema.RefResolver is deprecated:DeprecationWarning",
]
markers = [
"mock_products",
"root_router_kwargs",
]
4 changes: 2 additions & 2 deletions pystapi-client/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "pystapi-client"
version = "0.0.1"
version = "0.0.2"
description = "Python library for searching Satellite Tasking API (STAPI) APIs."
readme = "README.md"
authors = [
Expand All @@ -14,7 +14,7 @@ license = { text = "MIT" }
requires-python = ">=3.11"
dependencies = [
"httpx>=0.28.1",
"stapi-pydantic",
"stapi-pydantic>=0.2.0",
"python-dateutil>=2.8.2",
"click>=8.1.8",
]
Expand Down
92 changes: 66 additions & 26 deletions pystapi-client/src/pystapi_client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,18 @@
from pydantic import AnyUrl
from stapi_pydantic import (
CQL2Filter,
Geometry,
Link,
Opportunity,
OpportunityCollection,
OpportunityPayload,
OpportunityProperties,
OpportunityRequest,
Order,
OrderCollection,
OrderPayload,
OrderParameters,
OrderRequest,
Product,
ProductsCollection,
ProductCollection,
)

from pystapi_client.conformance import ConformanceClasses
Expand Down Expand Up @@ -257,13 +260,52 @@ def has_conformance(self, conformance_class: ConformanceClasses | str) -> bool:

return any(re.match(conformance_class.pattern, uri) for uri in self.get_conforms_to())

def _supports_opportunities(self) -> bool:
"""Check if the API supports opportunities"""
return self.has_conformance(ConformanceClasses.OPPORTUNITIES)
def _product_has_conformance(
self,
product: str | Product,
conformance_class: ConformanceClasses,
) -> bool:
"""Check whether a Product advertises the given conformance class.

Opportunity capability classes are advertised per-Product, not in the
root landing page.

Args:
product: A Product ID or an already-fetched
:class:`~stapi_pydantic.Product`. If an ID is given the Product
is fetched from the API.
conformance_class: The conformance class to check for.

Return:
Whether the Product conforms to the given class.
"""
if isinstance(product, str):
product = self.get_product(product)
return any(re.match(conformance_class.pattern, uri) for uri in product.conforms_to)

def product_supports_opportunities(self, product: str | Product) -> bool:
"""Check if a Product supports synchronous opportunity search.

Args:
product: A Product ID or an already-fetched
:class:`~stapi_pydantic.Product`.

Return:
Whether the Product supports synchronous opportunity search.
"""
return self._product_has_conformance(product, ConformanceClasses.OPPORTUNITIES)

def product_supports_async_opportunities(self, product: str | Product) -> bool:
"""Check if a Product supports asynchronous opportunity search.

def _supports_async_opportunities(self) -> bool:
"""Check if the API supports asynchronous opportunities"""
return self.has_conformance(ConformanceClasses.ASYNC_OPPORTUNITIES)
Args:
product: A Product ID or an already-fetched
:class:`~stapi_pydantic.Product`.

Return:
Whether the Product supports asynchronous opportunity search.
"""
return self._product_has_conformance(product, ConformanceClasses.ASYNC_OPPORTUNITIES)

def get_products(self, limit: int | None = None) -> Iterator[Product]:
"""Get all products from this STAPI API
Expand All @@ -282,7 +324,7 @@ def get_products(self, limit: int | None = None) -> Iterator[Product]:

products_collection_iterator = self.stapi_io.get_pages(link=products_link, lookup_key="products")
for products_collection in products_collection_iterator:
yield from ProductsCollection.model_validate(products_collection).products
yield from ProductCollection.model_validate(products_collection).products

def get_product(self, product_id: str) -> Product:
"""Get a single product from this STAPI API
Expand All @@ -302,10 +344,9 @@ def get_product_opportunities(
product_id: str,
date_range: tuple[str, str],
geometry: dict[str, Any],
cql2_filter: CQL2Filter | None = None, # type: ignore[type-arg]
cql2_filter: CQL2Filter | None = None,
limit: int = 10,
) -> Iterator[Opportunity]: # type: ignore[type-arg]
# TODO Update return type after the pydantic model generic type is fixed
) -> Iterator[Opportunity[Geometry, OpportunityProperties]]:
"""Get all opportunities for a product from this STAPI API
Args:
product_id: The Product ID to get opportunities for
Expand All @@ -316,14 +357,16 @@ def get_product_opportunities(
"""
product_opportunities_endpoint = self._get_products_href(product_id, subpath="opportunities")

opportunity_parameters = OpportunityPayload.model_validate(
opportunity_parameters = OpportunityRequest.model_validate(
{
"datetime": (
datetime.fromisoformat(date_range[0]),
datetime.fromisoformat(date_range[1]),
),
"geometry": geometry,
"filter": cql2_filter,
"search_parameters": {
"datetime": (
datetime.fromisoformat(date_range[0]),
datetime.fromisoformat(date_range[1]),
),
"geometry": geometry,
"filter": cql2_filter,
},
"limit": limit,
}
)
Expand All @@ -348,8 +391,7 @@ def get_product_opportunities(
for opportunity_collection in product_opportunities_json:
yield from OpportunityCollection.model_validate(opportunity_collection).features

def create_product_order(self, product_id: str, order_parameters: OrderPayload) -> Order: # type: ignore[type-arg]
# TODO Update return type after the pydantic model generic type is fixed
def create_product_order(self, product_id: str, order_parameters: OrderRequest[OrderParameters]) -> Order:
"""Create an order for a product

Args:
Expand Down Expand Up @@ -393,8 +435,7 @@ def _get_products_href(self, product_id: str | None = None, subpath: str | None

return str(product_url)

def get_orders(self, limit: int | None = None) -> Iterator[Order]: # type: ignore[type-arg]
# TODO Update return type after the pydantic model generic type is fixed
def get_orders(self, limit: int | None = None) -> Iterator[Order]:
"""Get orders from this STAPI API

Args:
Expand All @@ -416,8 +457,7 @@ def get_orders(self, limit: int | None = None) -> Iterator[Order]: # type: igno
for orders_collection in orders_collection_iterator:
yield from OrderCollection.model_validate(orders_collection).features

def get_order(self, order_id: str) -> Order: # type: ignore[type-arg]
# TODO Update return type after the pydantic model generic type is fixed
def get_order(self, order_id: str) -> Order:
"""Get a single order from this STAPI API

Args:
Expand Down
9 changes: 7 additions & 2 deletions pystapi-client/src/pystapi_client/conformance.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,14 @@ class ConformanceClasses(Enum):
"""Enumeration class for Conformance Classes"""

# defined conformance classes regexes
# API-level classes (advertised in the root landing page / `/conformance`)
CORE = "/core"
ORDER_STATUSES = "/order-statuses"
SEARCHES_OPPORTUNITY = "/searches-opportunity"
SEARCHES_OPPORTUNITY_STATUSES = "/searches-opportunity-statuses"
# Product-level classes (advertised in a Product's own `conformsTo`)
OPPORTUNITIES = "/opportunities"
ASYNC_OPPORTUNITIES = "/async-opportunities"
ASYNC_OPPORTUNITIES = "/opportunities-async"

@classmethod
def get_by_name(cls, name: str) -> "ConformanceClasses":
Expand All @@ -29,4 +34,4 @@ def valid_uri(self) -> str:

@property
def pattern(self) -> re.Pattern[str]:
return re.compile(rf"{re.escape('https://stapi.example.com/v')}(.*){re.escape(self.value)}")
return re.compile(rf"{re.escape('https://stapi.example.com/v')}[^/]+{re.escape(self.value)}\Z")
9 changes: 7 additions & 2 deletions pystapi-client/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

def load_fixture(name: str) -> dict[str, Any]:
with open(WORKING_DIR / "fixtures" / f"{name}.json") as f:
return cast(dict, json.load(f))
return cast(dict[str, Any], json.load(f))


@pytest.fixture
Expand All @@ -37,7 +37,9 @@ def mock_products_response(request: Request) -> Response:
start_index = (page - 1) * int(limit)
end_index = start_index + int(limit)
products_limited["products"] = products_limited["products"][start_index:end_index]
has_next_page = end_index < len(products_limited["products"]) + 1
# `products` is the whole fixture; `products_limited` has already
# been sliced down to this page
has_next_page = end_index < len(products["products"])
if has_next_page:
products_limited["links"].append(
{
Expand All @@ -51,4 +53,7 @@ def mock_products_response(request: Request) -> Response:
respx_mock.get("/products").mock(side_effect=mock_products_response)
respx_mock.get("/products", params={"limit": 1}).mock(side_effect=mock_products_response)

for product in products["products"]:
respx_mock.get(f"/products/{product['id']}").return_value = Response(200, json=product)

yield respx_mock
7 changes: 4 additions & 3 deletions pystapi-client/tests/fixtures/landing_page.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
"title": "A simple STAPI Example",
"description": "This API demonstrated the landing page for a SpatioTemporal Asset Tasking API",
"conformsTo": [
"https://stapi.example.com/v0.1.0/core",
"https://geojson.org/schema/Point.json",
"https://geojson.org/schema/Polygon.json"
"https://stapi.example.com/v0.2.0/core",
"https://stapi.example.com/v0.2.0/order-statuses",
"https://stapi.example.com/v0.2.0/searches-opportunity",
"https://stapi.example.com/v0.2.0/searches-opportunity-statuses"
],
"links": [
{
Expand Down
16 changes: 16 additions & 0 deletions pystapi-client/tests/fixtures/products.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
{
"stapi_type": "ProductCollection",
"stapi_version": "0.2.0",
"products": [
{
"type": "Collection",
"stapi_type": "Product",
"stapi_version": "0.2.0",
"id": "multispectral",
"conformsTo": [
"https://stapi.example.com/v0.2.0/opportunities",
"https://stapi.example.com/v0.2.0/opportunities-async",
"https://geojson.org/schema/Point.json",
"https://geojson.org/schema/Polygon.json"
],
"title": "Multispectral",
"description": "Full color EO image",
"keywords": [
Expand Down Expand Up @@ -103,7 +113,13 @@
},
{
"type": "Collection",
"stapi_type": "Product",
"stapi_version": "0.2.0",
"id": "spotlight",
"conformsTo": [
"https://geojson.org/schema/Point.json",
"https://geojson.org/schema/Polygon.json"
],
"title": "Spotlight",
"description": "SAR Spotlight frame",
"keywords": [
Expand Down
Loading
Loading