Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
1bbdd72
feat!: bump STAPI_VERSION to 0.2.0 and add shared SearchParameters model
jkeifer Jul 24, 2026
4d79941
feat!: OrderRequest composes SearchParameters with optional order_par…
jkeifer Jul 24, 2026
7fb0c2f
feat!: OpportunityRequest composes SearchParameters
jkeifer Jul 24, 2026
bcaaed3
feat!: Order properties nest order_request; require bbox; OrderCollec…
jkeifer Jul 24, 2026
2e2d351
feat!: opportunity entities gain stapi fields; search record uses req…
jkeifer Jul 24, 2026
ebb0764
feat!: ProductsCollection stapi fields; add cql2_property_names helper
jkeifer Jul 24, 2026
6465ce8
feat!: stapi-fastapi v0.2.0 request/response shapes and required-quer…
jkeifer Jul 24, 2026
62da1e4
fix!: match opportunities-async conformance URI; adopt v0.2.0 request…
jkeifer Jul 24, 2026
9e92c77
chore!: bump packages for STAPI v0.2.0; add openapi export script
jkeifer Jul 24, 2026
aefd84e
feat!: remove pre-0.2.0 compatibility aliases
jkeifer Jul 24, 2026
b33a611
feat: add pystapi-schema-generator, the reference app and OpenAPI export
jkeifer Jul 24, 2026
f0ec68b
style: format stapi-pydantic tests with ruff
jkeifer Jul 24, 2026
8aeeebb
feat: support singly-open datetime intervals in SearchParameters
jkeifer Jul 24, 2026
e1e8443
feat!: allow provider extension status codes; make code sets constrai…
jkeifer Jul 24, 2026
a5d356c
fix!: align model schemas with spec required-ness and extensibility
jkeifer Jul 24, 2026
491730d
fix!: spec-conformance fixes for capability advertisement and links
jkeifer Jul 24, 2026
607cc27
fix!: correct conformance checking scope and URI matching
jkeifer Jul 24, 2026
a45a994
feat: add bbox and required-queryable helpers
jkeifer Aug 4, 2026
2598632
fix!: correct status required-ness, OrderStatus schema, bbox, and int…
jkeifer Aug 4, 2026
c6fce9d
fix!: use camelCase path parameters and move queryables validation to…
jkeifer Aug 4, 2026
34071f9
test: guard schema invariants and make root router config marker-driven
jkeifer Aug 4, 2026
55aacfb
fix: repair the pystapi-validator entrypoint
jkeifer Aug 4, 2026
7edb313
build: type-check the test suites
jkeifer Aug 4, 2026
3f66f55
fix!: gate capabilities on their backends and validate pagination
jkeifer Aug 5, 2026
dd21d8d
fix!: correct model schemas, aliases, and bbox semantics
jkeifer Aug 5, 2026
5f6f5c8
feat!: unify the page contract, route registration, and geometry union
jkeifer Aug 5, 2026
57e6ccf
fix: make the spec-conformance gate able to fail
jkeifer Aug 5, 2026
8248420
fix!: rename ProductsCollection to ProductCollection
jkeifer Aug 5, 2026
2f1fcf4
refactor!: let a Route describe itself, and say which errors it can p…
jkeifer Aug 5, 2026
85ef5ee
docs: record the 0.2.0 and 0.9.0 releases
jkeifer Aug 5, 2026
51dab6c
build: require stapi-pydantic 0.2.0
jkeifer Aug 5, 2026
bbb0eac
build: require stapi-pydantic 0.2.0 in the client
jkeifer Aug 5, 2026
9515c66
fix!: omit spec-OPTIONAL fields instead of publishing them as null or…
jkeifer Aug 5, 2026
349620a
fix: publish a usable OpenAPI document from every deployment
jkeifer Aug 5, 2026
71fd6b1
build!: make the conformance gate demand an explicit document
jkeifer Aug 6, 2026
63ac9ad
fix!: correct model contracts, router bugs, and test gaps from review
jkeifer Aug 6, 2026
ec2a8d4
fix!: record what an opportunity search was for, not the request that…
jkeifer Aug 6, 2026
4e3aa78
docs: lead each 0.2.0 changelog with migration instructions
jkeifer Aug 6, 2026
35d498f
feat: add pystapi-schema-generator, the reference app and OpenAPI export
jkeifer Aug 6, 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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than short-circuiting this check in the script so that the script is a noop, I figured it would be better to make the script functional and remove this from CI.

- 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
15 changes: 12 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ dependencies = [
"pystapi-validator",
"stapi-pydantic",
"stapi-fastapi",
"pystapi-schema-generator",
]

[dependency-groups]
Expand All @@ -19,6 +20,8 @@ dev = [
"pre-commit>=4.2.0",
"pre-commit-hooks>=5.0.0",
"pygithub>=2.6.1",
"pyyaml>=6.0",
"types-pyyaml>=6.0",
]
docs = [
"mkdocs-material>=9.6.11",
Expand All @@ -29,13 +32,14 @@ docs = [
default-groups = ["dev", "docs"]

[tool.uv.workspace]
members = ["pystapi-validator", "stapi-pydantic", "pystapi-client", "stapi-fastapi"]
members = ["pystapi-validator", "stapi-pydantic", "pystapi-client", "stapi-fastapi", "pystapi-schema-generator"]

[tool.uv.sources]
pystapi-client.workspace = true
pystapi-validator.workspace = true
stapi-pydantic.workspace = true
stapi-fastapi.workspace = true
pystapi-schema-generator.workspace = true

[tool.ruff]
line-length = 120
Expand All @@ -62,11 +66,12 @@ files = [
"pystapi-client/src/pystapi_client/**/*.py",
"pystapi-validator/src/pystapi_validator/**/*.py",
"stapi-pydantic/src/stapi_pydantic/**/*.py",
"stapi-fastapi/src/stapi_fastapi/**/*.py"
"stapi-fastapi/src/stapi_fastapi/**/*.py",
"pystapi-schema-generator/src/pystapi_schema_generator/**/*.py"
]

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

[tool.pymarkdown]
Expand All @@ -81,7 +86,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")
8 changes: 6 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,8 @@ def mock_products_response(request: Request) -> Response:
start_index = (page - 1) * int(limit)
end_index = start_index + int(limit)
products_limited["products"] = products_limited["products"][start_index:end_index]
has_next_page = end_index < len(products_limited["products"]) + 1
# against the full fixture, not the page just sliced out of it
has_next_page = end_index < len(products["products"])
if has_next_page:
products_limited["links"].append(
{
Expand All @@ -51,4 +52,7 @@ def mock_products_response(request: Request) -> Response:
respx_mock.get("/products").mock(side_effect=mock_products_response)
respx_mock.get("/products", params={"limit": 1}).mock(side_effect=mock_products_response)

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

yield respx_mock
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