Skip to content

Commit 620073f

Browse files
committed
Merge branch 'main' into ep/update-stapi-client-docs
2 parents 8ee66d0 + fab618e commit 620073f

File tree

24 files changed

+404
-81
lines changed

24 files changed

+404
-81
lines changed

.github/workflows/ci.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ jobs:
1717
strategy:
1818
matrix:
1919
python-version:
20+
- "3.11"
2021
- "3.12"
2122
- "3.13"
2223
steps:

.python-version

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
3.12
1+
3.11

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ name = "pystapi"
33
version = "0.0.0" # This package should never be released, only the workspace members should be
44
description = "Monorepo for Satellite Tasking API (STAPI) Specification Python packages"
55
readme = "README.md"
6-
requires-python = ">=3.10"
6+
requires-python = ">=3.11"
77
dependencies = [
88
"pystapi-client",
99
"pystapi-validator",

pystapi-client/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ authors = [
1010
maintainers = [{ name = "Pete Gadomski", email = "[email protected]" }]
1111
keywords = ["stapi"]
1212
license = { text = "MIT" }
13-
requires-python = ">=3.10"
13+
requires-python = ">=3.11"
1414
dependencies = [
1515
"httpx>=0.28.1",
1616
"stapi-pydantic",

pystapi-client/src/pystapi_client/client.py

Lines changed: 103 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,24 @@
33
import urllib.parse
44
import warnings
55
from collections.abc import Callable, Iterable, Iterator
6-
from typing import (
7-
Any,
8-
)
6+
from datetime import datetime
7+
from typing import Any
98

109
from httpx import URL, Request
1110
from httpx._types import TimeoutTypes
1211
from pydantic import AnyUrl
13-
from stapi_pydantic import Link, Order, OrderCollection, Product, ProductsCollection
12+
from stapi_pydantic import (
13+
CQL2Filter,
14+
Link,
15+
Opportunity,
16+
OpportunityCollection,
17+
OpportunityPayload,
18+
Order,
19+
OrderCollection,
20+
OrderPayload,
21+
Product,
22+
ProductsCollection,
23+
)
1424

1525
from pystapi_client.conformance import ConformanceClasses
1626
from pystapi_client.exceptions import APIError
@@ -237,11 +247,11 @@ def _supports_opportunities(self) -> bool:
237247
def _supports_async_opportunities(self) -> bool:
238248
return self.has_conformance(ConformanceClasses.ASYNC_OPPORTUNITIES)
239249

240-
def get_products(self, limit: int | None = None) -> Iterator[ProductsCollection]:
250+
def get_products(self, limit: int | None = None) -> Iterator[Product]:
241251
"""Get all products from this STAPI API
242252
243253
Returns:
244-
ProductsCollection: A collection of STAPI Products
254+
Iterator[Product]: An iterator of STAPI Products
245255
"""
246256
products_endpoint = self._get_products_href()
247257

@@ -250,11 +260,11 @@ def get_products(self, limit: int | None = None) -> Iterator[ProductsCollection]
250260
else:
251261
parameters = {"limit": limit}
252262

253-
products_collection_iterator = self.stapi_io.get_pages(
254-
products_endpoint, parameters=parameters, lookup_key="products"
255-
)
263+
products_link = Link(href=products_endpoint, method="GET", body=parameters, rel="")
264+
265+
products_collection_iterator = self.stapi_io.get_pages(link=products_link, lookup_key="products")
256266
for products_collection in products_collection_iterator:
257-
yield ProductsCollection.model_validate(products_collection)
267+
yield from ProductsCollection.model_validate(products_collection).products
258268

259269
def get_product(self, product_id: str) -> Product:
260270
"""Get a single product from this STAPI API
@@ -264,34 +274,102 @@ def get_product(self, product_id: str) -> Product:
264274
265275
Returns:
266276
Product: A STAPI Product
277+
"""
278+
product_endpoint = self._get_products_href(product_id)
279+
product_json = self.stapi_io.read_json(endpoint=product_endpoint)
280+
return Product.model_validate(product_json)
267281

268-
Raises:
269-
ValueError if product_id does not exist.
282+
def get_product_opportunities(
283+
self,
284+
product_id: str,
285+
date_range: tuple[str, str],
286+
geometry: dict[str, Any],
287+
cql2_filter: CQL2Filter | None = None, # type: ignore[type-arg]
288+
limit: int = 10,
289+
) -> Iterator[Opportunity]: # type: ignore[type-arg]
290+
# TODO Update return type after the pydantic model generic type is fixed
291+
"""Get all opportunities for a product from this STAPI API
292+
Args:
293+
product_id: The Product ID to get opportunities for
294+
opportunity_parameters: The parameters for the opportunities
295+
296+
Returns:
297+
Iterator[Opportunity]: An iterator of STAPI Opportunities
270298
"""
299+
product_opportunities_endpoint = self._get_products_href(product_id, subpath="opportunities")
300+
301+
opportunity_parameters = OpportunityPayload.model_validate(
302+
{
303+
"datetime": (
304+
datetime.fromisoformat(date_range[0]),
305+
datetime.fromisoformat(date_range[1]),
306+
),
307+
"geometry": geometry,
308+
"filter": cql2_filter,
309+
"limit": limit,
310+
}
311+
)
312+
opportunities_first_page_link = Link(
313+
href=product_opportunities_endpoint, method="POST", body=opportunity_parameters.model_dump(), rel=""
314+
)
315+
opportunities_first_page_json, next_link = self.stapi_io._get_next_page(
316+
opportunities_first_page_link, "features"
317+
)
271318

272-
product_endpoint = self._get_products_href(product_id)
273-
product_json = self.stapi_io.read_json(product_endpoint)
319+
if opportunities_first_page_json:
320+
opportunities_first_page = OpportunityCollection.model_validate(opportunities_first_page_json) # type:ignore[var-annotated]
321+
yield from opportunities_first_page.features
322+
else:
323+
return
274324

275-
if product_json is None:
276-
raise ValueError(f"Product {product_id} not found")
325+
if next_link is None:
326+
return
277327

278-
return Product.model_validate(product_json)
328+
product_opportunities_json = self.stapi_io.get_pages(link=next_link, lookup_key="features")
329+
330+
for opportunity_collection in product_opportunities_json:
331+
yield from OpportunityCollection.model_validate(opportunity_collection).features
332+
333+
def create_product_order(self, product_id: str, order_parameters: OrderPayload) -> Order: # type: ignore[type-arg]
334+
# TODO Update return type after the pydantic model generic type is fixed
335+
"""Create an order for a product
336+
337+
Args:
338+
product_id: The Product ID to place an order for
339+
order_parameters: The parameters for the order
340+
"""
341+
product_order_endpoint = self._get_products_href(product_id, subpath="orders")
342+
product_order_json = self.stapi_io.read_json(
343+
endpoint=product_order_endpoint, method="POST", parameters=order_parameters.model_dump()
344+
)
279345

280-
def _get_products_href(self, product_id: str | None = None) -> str:
346+
return Order.model_validate(product_order_json)
347+
348+
def _get_products_href(self, product_id: str | None = None, subpath: str | None = None) -> str:
281349
product_link = self.get_single_link("products")
282350
if product_link is None:
283351
raise ValueError("No products link found")
284352
product_url = URL(str(product_link.href))
353+
354+
path = None
355+
285356
if product_id is not None:
286-
product_url = product_url.copy_with(path=f"{product_url.path}/{product_id}")
357+
path = f"{product_url.path}/{product_id}"
358+
359+
if subpath is not None:
360+
path = f"{path}/{subpath}"
361+
362+
if path is not None:
363+
product_url = product_url.copy_with(path=path)
364+
287365
return str(product_url)
288366

289-
def get_orders(self, limit: int | None = None) -> Iterator[OrderCollection]: # type: ignore[type-arg]
367+
def get_orders(self, limit: int | None = None) -> Iterator[Order]: # type: ignore[type-arg]
290368
# TODO Update return type after the pydantic model generic type is fixed
291369
"""Get orders from this STAPI API
292370
293371
Returns:
294-
OrderCollection: A collection of STAPI Orders
372+
Iterator[Order]: An iterator of STAPI Orders
295373
"""
296374
orders_endpoint = self._get_orders_href()
297375

@@ -300,11 +378,11 @@ def get_orders(self, limit: int | None = None) -> Iterator[OrderCollection]: #
300378
else:
301379
parameters = {"limit": limit}
302380

303-
orders_collection_iterator = self.stapi_io.get_pages(
304-
orders_endpoint, parameters=parameters, lookup_key="features"
305-
)
381+
orders_link = Link(href=orders_endpoint, method="GET", body=parameters, rel="")
382+
383+
orders_collection_iterator = self.stapi_io.get_pages(link=orders_link, lookup_key="features")
306384
for orders_collection in orders_collection_iterator:
307-
yield OrderCollection.model_validate(orders_collection)
385+
yield from OrderCollection.model_validate(orders_collection).features
308386

309387
def get_order(self, order_id: str) -> Order: # type: ignore[type-arg]
310388
# TODO Update return type after the pydantic model generic type is fixed

pystapi-client/src/pystapi_client/stapi_api_io.py

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -163,14 +163,28 @@ def read_json(self, endpoint: str, method: str = "GET", parameters: dict[str, An
163163
The parsed JSON response
164164
"""
165165
href = urllib.parse.urljoin(str(self.root_url), endpoint)
166+
167+
if method == "POST" and parameters is None:
168+
parameters = {}
169+
166170
text = self._read_text(href, method=method, parameters=parameters)
167171
return json.loads(text) # type: ignore[no-any-return]
168172

173+
def _get_next_page(self, link: Link, lookup_key: str) -> tuple[dict[str, Any] | None, Link | None]:
174+
page = self.read_json(str(link.href), method=link.method or "GET", parameters=link.body)
175+
next_link = next((link for link in page.get("links", []) if link["rel"] == "next"), None)
176+
177+
if next_link is not None:
178+
next_link = Link.model_validate(next_link)
179+
180+
if page.get(lookup_key):
181+
return page, next_link
182+
183+
return None, None
184+
169185
def get_pages(
170186
self,
171-
url: str,
172-
method: str = "GET",
173-
parameters: dict[str, Any] | None = None,
187+
link: Link,
174188
lookup_key: str | None = None,
175189
) -> Iterator[dict[str, Any]]:
176190
"""Iterator that yields dictionaries for each page at a STAPI paging
@@ -181,23 +195,17 @@ def get_pages(
181195
Return:
182196
dict[str, Any] : JSON content from a single page
183197
"""
184-
# TODO update this
185-
186198
if not lookup_key:
187199
lookup_key = "features"
188200

189-
page = self.read_json(url, method=method, parameters=parameters)
190-
if not (page.get(lookup_key)):
201+
first_page, next_link = self._get_next_page(link, lookup_key)
202+
203+
if first_page is None:
191204
return None
192-
yield page
205+
yield first_page
193206

194-
next_link = next((link for link in page.get("links", []) if link["rel"] == "next"), None)
195207
while next_link:
196-
link = Link.model_validate(next_link)
197-
page = self.read_json(str(link.href), method=link.method or "GET")
198-
if not (page.get(lookup_key)):
208+
next_page, next_link = self._get_next_page(next_link, lookup_key)
209+
if next_page is None:
199210
return None
200-
yield page
201-
202-
# get the next link and make the next request
203-
next_link = next((link for link in page.get("links", []) if link["rel"] == "next"), None)
211+
yield next_page

pystapi-validator/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ authors = [
77
]
88
license = "MIT"
99
readme = "README.md"
10-
requires-python = ">=3.10"
10+
requires-python = ">=3.11"
1111
dependencies = [
1212
"schemathesis>=3.37.0",
1313
"pytest>=8.3.3",

stapi-fastapi/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ authors = [
99
readme = "README.md"
1010
license = "MIT"
1111

12-
requires-python = ">=3.12"
12+
requires-python = ">=3.11"
1313

1414
dependencies = [
1515
"httpx>=0.27.0",

stapi-fastapi/src/stapi_fastapi/routers/product_router.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@
2828
OrderStatus,
2929
Prefer,
3030
)
31+
from stapi_pydantic import (
32+
Product as ProductPydantic,
33+
)
3134

3235
from stapi_fastapi.constants import TYPE_JSON
3336
from stapi_fastapi.exceptions import ConstraintsException, NotFoundException
@@ -52,7 +55,7 @@ def get_prefer(prefer: str | None = Header(None)) -> str | None:
5255
if prefer is None:
5356
return None
5457

55-
if prefer not in Prefer:
58+
if prefer not in Prefer._value2member_map_:
5659
raise HTTPException(
5760
status_code=status.HTTP_400_BAD_REQUEST,
5861
detail=f"Invalid Prefer header value: {prefer}",
@@ -168,7 +171,7 @@ async def _create_order(
168171
tags=["Products"],
169172
)
170173

171-
def get_product(self, request: Request) -> Product:
174+
def get_product(self, request: Request) -> ProductPydantic:
172175
links = [
173176
Link(
174177
href=str(

stapi-fastapi/tests/application.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,7 @@
2424
@asynccontextmanager
2525
async def lifespan(app: FastAPI) -> AsyncIterator[dict[str, Any]]:
2626
try:
27-
yield {
28-
"_orders_db": InMemoryOrderDB(),
29-
"_opportunities_db": InMemoryOpportunityDB(),
30-
}
27+
yield {"_orders_db": InMemoryOrderDB(), "_opportunities_db": InMemoryOpportunityDB(), "_opportunities": []}
3128
finally:
3229
pass
3330

0 commit comments

Comments
 (0)