Skip to content

Commit fab618e

Browse files
ka7ehgadomski
andauthored
feat: add opportunities methods to STAPI Client (#82)
## What I'm changing - <!-- a list of changes, including any issues this might close or reference --> ## How I did it - <!-- more detail on decisions and choices --> ## Checklist - [ ] Tests pass: `uv run pytest` - [ ] Checks pass: `uv run pre-commit --all-files` - [ ] CHANGELOG is updated (if necessary) --------- Co-authored-by: Pete Gadomski <[email protected]>
1 parent e1f8b34 commit fab618e

File tree

4 files changed

+130
-45
lines changed

4 files changed

+130
-45
lines changed

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

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

stapi-pydantic/src/stapi_pydantic/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from .conformance import Conformance
22
from .constraints import Constraints
33
from .datetime_interval import DatetimeInterval
4+
from .filter import CQL2Filter
45
from .json_schema_model import JsonSchemaModel
56
from .opportunity import (
67
Opportunity,
@@ -31,6 +32,7 @@
3132
__all__ = [
3233
"Conformance",
3334
"Constraints",
35+
"CQL2Filter",
3436
"DatetimeInterval",
3537
"JsonSchemaModel",
3638
"Link",

0 commit comments

Comments
 (0)