Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
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
53 changes: 53 additions & 0 deletions pystapi-client/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# pystapi-client

PySTAPI Client is a Python library for interacting with [STAPI](https://github.com/stapi-spec/stapi-spec) endpoints. Below is a overview of the supported endpoints and examples.

## Installation

Install from PyPi.
The dependencies for **pystapi-client** are the Python [httpx](https://www.python-httpx.org/) and [dateutil](https://dateutil.readthedocs.io) libraries.

```shell
python -m pip install pystapi-client
```

## Currently Supported Endpoints

These endpoints are fully implemented and available in the current version of PySTAPI Client.

| Category | Endpoint | Description |
|----------|----------|-------------|
| Root | `/` | Root endpoint (for links and conformance) |
| Root | `/conformance` | Conformance information |
| Products | `/products` | List all products |
| Products | `/products/{product_id}` | Get specific product |
| Orders | `/orders` | List all orders |
| Orders | `/orders/{order_id}` | Get specific order |

Copy link
Member

Choose a reason for hiding this comment

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

I'm a little wary about including this table, as we'll have to keep it continually updated. I think I'd prefer to just point to the API docs, which will be generated from the code?

## Usage Example

The `pystapi_client.Client` class is the main interface for working with services that conform to the STAPI API spec.

Pre-request: The app should be accessible at `http://localhost:8000`.

Copy link
Member

Choose a reason for hiding this comment

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

Don't need this, since in the examples we're hitting a dummy endpoint.

Suggested change
Pre-request: The app should be accessible at `http://localhost:8000`.

```python
from pystapi_client import Client

# Initialize client
client = Client.open("https://api.example.com/stapi")

# List all products
products = list(client.get_products())

# Get specific product
product = client.get_product(product_id="test-spotlight")

# List all Opportunities for a Product
opportunities = client.get_product_opportunities(product_id="test-spotlight")

# List orders
orders = client.get_orders()

# Get specific order
order = client.get_order(order_id="test-order")
Copy link
Member

Choose a reason for hiding this comment

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

Nit: let's make the call simpler, to show you don't have to pass by keyword:

Suggested change
product = client.get_product(product_id="test-spotlight")
# List all Opportunities for a Product
opportunities = client.get_product_opportunities(product_id="test-spotlight")
# List orders
orders = client.get_orders()
# Get specific order
order = client.get_order(order_id="test-order")
product = client.get_product("test-spotlight")
# List all Opportunities for a Product
opportunities = client.get_product_opportunities("test-spotlight")
# List orders
orders = client.get_orders()
# Get specific order
order = client.get_order("test-order")

```
3 changes: 2 additions & 1 deletion pystapi-client/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ description = "Python library for searching Satellite Tasking API (STAPI) APIs."
readme = "README.md"
authors = [
{ name = "Kaveh Karimi-Asli", email = "[email protected]" },
{ name = "Philip Weiss", email = "[email protected]" }
{ name = "Philip Weiss", email = "[email protected]" },
{ name = "Stella Reinhardt", email = "[email protected]"}
]
maintainers = [{ name = "Pete Gadomski", email = "[email protected]" }]
keywords = ["stapi"]
Expand Down
80 changes: 61 additions & 19 deletions pystapi-client/src/pystapi_client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ def open(
"""Opens a STAPI API client

Args:
url : The URL of a STAPI API.
headers : A dictionary of additional headers to use in all requests
url: The URL of a STAPI API.
headers: A dictionary of additional headers to use in all requests
made to any part of this STAPI API.
parameters: Optional dictionary of query string parameters to
include in all requests.
Expand All @@ -71,14 +71,17 @@ def open(
using AWS SigV4).

The callable should expect a single argument, which will be an instance
of :class:`requests.Request`.
of :class:`httpx.Request`.

If the callable returns a `requests.Request`, that will be used.
If the callable returns a `httpx.Request`, that will be used.
Alternately, the callable may simply modify the provided request object
and return `None`.
timeout: Optional float or (float, float) tuple following the semantics
defined by `Requests
<https://requests.readthedocs.io/en/latest/api/#main-interface>`__.
timeout: Optional timeout configuration. Can be:
- None to disable timeouts
- float or None for a default timeout
- tuple of (connect, read, write, pool) timeouts, each being float or None
- httpx.Timeout instance for fine-grained control
See `httpx timeouts <https://www.python-httpx.org/advanced/timeouts/>`__ for details.

Return:
client : A :class:`Client` instance for this STAPI API
Expand Down Expand Up @@ -108,11 +111,11 @@ def get_single_link(
"""Get a single :class:`~stapi_pydantic.Link` instance associated with this object.

Args:
rel : If set, filter links such that only those
matching this relationship are returned.
media_type: If set, filter the links such that only
those matching media_type are returned. media_type can
be a single value or a list of values.
rel: Optional relationship filter. If set, only links matching this
relationship are considered.
media_type: Optional media type filter. If set, only links matching
this media type are considered. Can be a single value or an
iterable of values.

Returns:
:class:`~stapi_pydantic.Link` | None: First link that matches ``rel``
Expand Down Expand Up @@ -151,6 +154,13 @@ def read_links(self) -> None:
]

def read_conformance(self) -> None:
"""Read the API conformance from the root of the STAPI API

The conformance is stored in `Client.conforms_to`.

Raises:
No exceptions (silently continues if endpoints return APIError)
"""
conformance: list[str] = []
for endpoint in ["/conformance", "/"]:
try:
Expand All @@ -163,22 +173,26 @@ def read_conformance(self) -> None:
self.set_conforms_to(conformance)

def has_conforms_to(self) -> bool:
"""Whether server contains list of ``"conformsTo"`` URIs"""
"""Whether server contains list of ``"conformsTo"`` URIs

Return:
Whether the server contains list of ``"conformsTo"`` URIs
"""
return bool(self.conforms_to)

def get_conforms_to(self) -> list[str]:
"""List of ``"conformsTo"`` URIs

Return:
list[str]: List of URIs that the server conforms to
List of URIs that the server conforms to
"""
return self.conforms_to.copy()

def set_conforms_to(self, conformance_uris: list[str]) -> None:
"""Set list of ``"conformsTo"`` URIs

Args:
conformance_uris : URIs indicating what the server conforms to
conformance_uris: URIs indicating what the server conforms to
"""
self.conforms_to = conformance_uris

Expand All @@ -194,7 +208,7 @@ def add_conforms_to(self, name: str) -> None:
"""Add ``"conformsTo"`` by name.

Args:
name : name of :py:class:`ConformanceClasses` keys to add.
name: Name of :py:class:`ConformanceClasses` keys to add.
"""
conformance_class = ConformanceClasses.get_by_name(name)

Expand All @@ -205,7 +219,7 @@ def remove_conforms_to(self, name: str) -> None:
"""Remove ``"conformsTo"`` by name.

Args:
name : name of :py:class:`ConformanceClasses` keys to remove.
name: Name of :py:class:`ConformanceClasses` keys to remove.
"""
conformance_class = ConformanceClasses.get_by_name(name)

Expand All @@ -220,21 +234,23 @@ def has_conformance(self, conformance_class: ConformanceClasses | str) -> bool:
provides such an endpoint.

Args:
name : name of :py:class:`ConformanceClasses` keys to check
name: Name of :py:class:`ConformanceClasses` keys to check
conformance against.

Return:
bool: Indicates if the API conforms to the given spec or URI.
Indicates if the API conforms to the given spec or URI.
"""
if isinstance(conformance_class, str):
conformance_class = ConformanceClasses.get_by_name(conformance_class)

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 _supports_async_opportunities(self) -> bool:
"""Check if the API supports asynchronous opportunities"""
return self.has_conformance(ConformanceClasses.ASYNC_OPPORTUNITIES)

def get_products(self, limit: int | None = None) -> Iterator[ProductsCollection]:
Expand Down Expand Up @@ -278,6 +294,17 @@ def get_product(self, product_id: str) -> Product:
return Product.model_validate(product_json)

def _get_products_href(self, product_id: str | None = None) -> str:
"""Get the href for the products endpoint

Args:
product_id: Optional product ID to get the href for

Returns:
str: The href for the products endpoint

Raises:
ValueError if no products link is found
"""
product_link = self.get_single_link("products")
if product_link is None:
raise ValueError("No products link found")
Expand All @@ -290,6 +317,9 @@ def get_orders(self, limit: int | None = None) -> Iterator[OrderCollection]: #
# TODO Update return type after the pydantic model generic type is fixed
"""Get orders from this STAPI API

Args:
limit: Optional limit on the number of orders to return

Returns:
OrderCollection: A collection of STAPI Orders
"""
Expand Down Expand Up @@ -329,6 +359,18 @@ def get_order(self, order_id: str) -> Order: # type: ignore[type-arg]
return Order.model_validate(order_json)

def _get_orders_href(self, order_id: str | None = None) -> str:
"""Get the href for the orders endpoint

Args:
order_id: Optional order ID to get the href for

Returns:
The href for the orders endpoint

Raises:
ValueError if no orders link is found
"""

order_link = self.get_single_link("orders")
if order_link is None:
raise ValueError("No orders link found")
Expand Down
80 changes: 49 additions & 31 deletions pystapi-client/src/pystapi_client/stapi_api_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import httpx
from httpx import Client as Session
from httpx import Request
from httpx._types import TimeoutTypes
from pydantic import AnyUrl
from stapi_pydantic import Link

Expand All @@ -23,24 +24,28 @@ def __init__(
headers: dict[str, str] | None = None,
parameters: dict[str, Any] | None = None,
request_modifier: Callable[[Request], Request] | None = None,
timeout: httpx._types.TimeoutTypes | None = None,
timeout: TimeoutTypes | None = None,
max_retries: int | None = 5,
):
"""Initialize class for API IO

Args:
headers : Optional dictionary of headers to include in all requests
root_url: The root URL of the STAPI API
headers: Optional dictionary of headers to include in all requests
parameters: Optional dictionary of query string parameters to
include in all requests.
request_modifier: Optional callable that can be used to modify Request
objects before they are sent. If provided, the callable receives a
`request.Request` and must either modify the object directly or return
`httpx.Request` and must either modify the object directly or return
a new / modified request instance.
timeout: Optional float or (float, float) tuple following the semantics
defined by `Requests
<https://requests.readthedocs.io/en/latest/api/#main-interface>`__.
max_retries: The number of times to retry requests. Set to ``None`` to
disable retries.
timeout: Optional timeout configuration. Can be:
- None to disable timeouts
- float for a default timeout
- tuple of (connect, read, write, pool) timeouts, each being float or None
- httpx.Timeout instance for fine-grained control
See `httpx timeouts <https://www.python-httpx.org/advanced/timeouts/>`__ for details.
max_retries: Optional number of times to retry requests. Set to ``None`` to
disable retries. Defaults to 5.

Return:
StapiIO : StapiIO instance
Expand All @@ -66,16 +71,13 @@ def update(
"""Updates this Stapi's headers, parameters, and/or request_modifier.

Args:
headers : Optional dictionary of headers to include in all requests
headers: Optional dictionary of headers to include in all requests.
parameters: Optional dictionary of query string parameters to
include in all requests.
request_modifier: Optional callable that can be used to modify Request
objects before they are sent. If provided, the callable receives a
`request.Request` and must either modify the object directly or return
`httpx.Request` and must either modify the object directly or return
a new / modified request instance.
timeout: Optional float or (float, float) tuple following the semantics
defined by `Requests
<https://requests.readthedocs.io/en/latest/api/#main-interface>`__.
"""
self.session.headers.update(headers or {})
self.session.params.merge(parameters or {})
Expand All @@ -90,10 +92,17 @@ def _read_text(
) -> str:
"""Read text from the given URI.

Overwrites the default method for reading text from a URL or file to allow
:class:`urllib.request.Request` instances as input. This method also raises
any :exc:`urllib.error.HTTPError` exceptions rather than catching
them to allow us to handle different response status codes as needed.
Args:
href: The URL to read from
method: The HTTP method to use. Defaults to "GET"
headers: Optional dictionary of additional headers to include in the request
parameters: Optional dictionary of parameters to include in the request.
For GET requests, these are added as query parameters.
For POST requests, these are sent as JSON in the request body


Returns:
str: The response text from the server
"""

return self.request(href, method=method, headers=headers, parameters=parameters if parameters else None)
Expand All @@ -108,19 +117,18 @@ def request(
"""Makes a request to an http endpoint

Args:
href (str): The request URL
method (Optional[str], optional): The http method to use, 'GET' or 'POST'.
Defaults to None, which will result in 'GET' being used.
headers (Optional[Dict[str, str]], optional): Additional headers to include
in request. Defaults to None.
parameters (Optional[Dict[str, Any]], optional): parameters to send with
request. Defaults to None.
href: The request URL
method: The http method to use, 'GET' or 'POST'. Defaults to None, which will result in 'GET' being used.
headers: Additional headers to include in request. Defaults to None.
parameters: Optional dictionary of parameters to include in the request.
For GET requests, these are added as query parameters.
For POST requests, these are sent as JSON in the request body.

Raises:
APIError: raised if the server returns an error response

Return:
str: The decoded response from the endpoint
Returns:
The decoded response text from the endpoint
"""
if method == "POST":
request = Request(method=method, url=href, headers=headers, json=parameters)
Expand Down Expand Up @@ -155,9 +163,11 @@ def read_json(self, endpoint: str, method: str = "GET", parameters: dict[str, An
"""Read JSON from a URL.

Args:
url: The URL to read from
method: The HTTP method to use
parameters: Parameters to include in the request
endpoint: The URL to read from
method: The HTTP method to use. Defaults to "GET"
parameters: Optional dictionary of parameters to include in the request.
For GET requests, these are added as query parameters.
For POST requests, these are sent as JSON in the request body

Returns:
The parsed JSON response
Expand All @@ -176,10 +186,18 @@ def get_pages(
"""Iterator that yields dictionaries for each page at a STAPI paging
endpoint.

Args:
url: The URL to read from
method: The HTTP method to use. Defaults to "GET"
parameters: Optional dictionary of parameters to include in the request.
For GET requests, these are added as query parameters.
For POST requests, these are sent as JSON in the request body
lookup_key: The key in the response JSON that contains the iterable data.

# TODO update endpoint examples

Return:
dict[str, Any] : JSON content from a single page
Returns:
Iterator that yields dictionaries for each page
"""
# TODO update this

Expand Down