diff --git a/docs/pystapi-client/api.md b/docs/pystapi-client/api.md new file mode 100644 index 0000000..784d5e2 --- /dev/null +++ b/docs/pystapi-client/api.md @@ -0,0 +1,3 @@ +# API + +::: pystapi-client diff --git a/docs/pystapi-client/index.md b/docs/pystapi-client/index.md new file mode 100644 index 0000000..6a394b4 --- /dev/null +++ b/docs/pystapi-client/index.md @@ -0,0 +1 @@ +# pystapi-client diff --git a/docs/stapi-client/index.md b/docs/stapi-client/index.md deleted file mode 100644 index a256a74..0000000 --- a/docs/stapi-client/index.md +++ /dev/null @@ -1,13 +0,0 @@ -# stapi-client - -A Python client (CLI tool) for interfacing with a [Satellite Tasking API (STAPI)](https://github.com/stapi-spec) server. - -!!! note - - This is a work in progress. - -## Installation - -```shell -python -m pip install pystapi-client -``` diff --git a/docs/stapi-pydantic/index.md b/docs/stapi-pydantic/index.md index 11eef04..d215d77 100644 --- a/docs/stapi-pydantic/index.md +++ b/docs/stapi-pydantic/index.md @@ -5,4 +5,4 @@ !!! note This repository intentionally has no input/output (IO) functionality. - For making requests to a STAPI API, use **[pystapi-client](../stapi-client/index.md)**. + For making requests to a STAPI API, use **[pystapi-client](../pystapi-client/index.md)**. diff --git a/mkdocs.yml b/mkdocs.yml index 8a8baf6..0fc7be9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -11,11 +11,12 @@ theme: nav: - index.md + - pystapi-client: + - pystapi-client/index.md + - pystapi-client/api.md - stapi-pydantic: - stapi-pydantic/index.md - stapi-pydantic/api.md - - stapi-client: - - stapi-client/index.md plugins: - mkdocstrings: diff --git a/pystapi-client/README.md b/pystapi-client/README.md index 42a44b5..25552f0 100644 --- a/pystapi-client/README.md +++ b/pystapi-client/README.md @@ -12,6 +12,10 @@ A Python client for working with [STAPI](https://stapi-spec.github.io/pystapi/) Install from PyPi. Other than [stapi-pydantic](https://stapi-spec.github.io/pystapi/stapi-pydantic/) itself, the only dependencies for **pystapi-client** are the Python [httpx](https://www.python-httpx.org/) and [python-dateutil](https://dateutil.readthedocs.io) libraries. +```shell +python -m pip install pystapi-client +``` + ## Development See the instructions in the [pystapi monorepo](https://github.com/stapi-spec/pystapi?tab=readme-ov-file#development). @@ -19,3 +23,29 @@ See the instructions in the [pystapi monorepo](https://github.com/stapi-spec/pys ## Documentation See the [documentation page](https://stapi-spec.github.io/pystapi/stapi-client/) for the latest docs. + +## Usage Example + +The `pystapi_client.Client` class is the main interface for working with services that conform to the STAPI API spec. + +```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("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") +``` diff --git a/pystapi-client/pyproject.toml b/pystapi-client/pyproject.toml index 75ded78..729a05a 100644 --- a/pystapi-client/pyproject.toml +++ b/pystapi-client/pyproject.toml @@ -5,7 +5,8 @@ description = "Python library for searching Satellite Tasking API (STAPI) APIs." readme = "README.md" authors = [ { name = "Kaveh Karimi-Asli", email = "ka7eh@pm.me" }, - { name = "Philip Weiss", email = "philip.weiss@orbitalsidekick.com" } + { name = "Philip Weiss", email = "philip.weiss@orbitalsidekick.com" }, + { name = "Stella Reinhardt", email = "stella@stellamaria.de"} ] maintainers = [{ name = "Pete Gadomski", email = "pete.gadomski@gmail.com" }] keywords = ["stapi"] diff --git a/pystapi-client/src/pystapi_client/client.py b/pystapi-client/src/pystapi_client/client.py index 4202591..b6e9436 100644 --- a/pystapi-client/src/pystapi_client/client.py +++ b/pystapi-client/src/pystapi_client/client.py @@ -67,31 +67,33 @@ def open( request_modifier: Callable[[Request], Request] | None = None, timeout: TimeoutTypes | None = None, ) -> "Client": - """Opens a STAPI API client + """Opens a STAPI API client. Args: - 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. + url: The URL of a STAPI API + headers: Optional 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. - request_modifier: A callable that either modifies a `Request` instance or + include in all requests + request_modifier: Optional callable that modifies a Request instance or returns a new one. This can be useful for injecting Authentication headers and/or signing fully-formed requests (e.g. signing requests using AWS SigV4). - The callable should expect a single argument, which will be an instance - of :class:`requests.Request`. - - If the callable returns a `requests.Request`, that will be used. + of :class:`httpx.Request`. + 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 - `__. + and return `None` + 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 `__ + for details - Return: - client : A :class:`Client` instance for this STAPI API + Returns: + Client: A :class:`Client` instance for this STAPI API """ stapi_io = StapiIO( root_url=AnyUrl(url), @@ -118,11 +120,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`` @@ -161,6 +163,15 @@ 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`. This method attempts to read + from "/conformance" endpoint first, then falls back to the root endpoint "/". + + Note: + This method silently continues if endpoints return APIError, no exceptions + are raised. + """ conformance: list[str] = [] for endpoint in ["/conformance", "/"]: try: @@ -173,14 +184,18 @@ 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() @@ -188,7 +203,7 @@ 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 @@ -204,7 +219,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) @@ -215,7 +230,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) @@ -230,11 +245,12 @@ def has_conformance(self, conformance_class: ConformanceClasses | str) -> bool: provides such an endpoint. Args: - name : name of :py:class:`ConformanceClasses` keys to check - conformance against. + conformance_class: Either a ConformanceClasses enum member or a + string name of a conformance class to check 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) @@ -242,9 +258,11 @@ 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 _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[Product]: @@ -346,6 +364,17 @@ def create_product_order(self, product_id: str, order_parameters: OrderPayload) return Order.model_validate(product_order_json) def _get_products_href(self, product_id: str | None = None, subpath: 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: When no products link is found in the API + """ product_link = self.get_single_link("products") if product_link is None: raise ValueError("No products link found") @@ -368,6 +397,9 @@ def get_orders(self, limit: int | None = None) -> Iterator[Order]: # type: igno # 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: Iterator[Order]: An iterator of STAPI Orders """ @@ -395,7 +427,7 @@ def get_order(self, order_id: str) -> Order: # type: ignore[type-arg] Order: A STAPI Order Raises: - ValueError if order_id does not exist. + ValueError: When the specified order_id does not exist """ order_endpoint = self._get_orders_href(order_id) @@ -407,6 +439,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: When no orders link is found in the API + """ + order_link = self.get_single_link("orders") if order_link is None: raise ValueError("No orders link found") diff --git a/pystapi-client/src/pystapi_client/stapi_api_io.py b/pystapi-client/src/pystapi_client/stapi_api_io.py index a0ec157..2a3fecb 100644 --- a/pystapi-client/src/pystapi_client/stapi_api_io.py +++ b/pystapi-client/src/pystapi_client/stapi_api_io.py @@ -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 @@ -23,27 +24,29 @@ 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 + ) -> None: + """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. + 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 - a new / modified request instance. - timeout: Optional float or (float, float) tuple following the semantics - defined by `Requests - `__. - max_retries: The number of times to retry requests. Set to ``None`` to - disable retries. - - Return: - StapiIO : StapiIO instance + objects before they are sent. If provided, the callable receives a + `httpx.Request` and must either modify the object directly or return + a new / modified request instance + 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 `__ + for details + max_retries: Optional number of times to retry requests. Set to ``None`` to + disable retries. Defaults to 5 """ self.root_url = root_url transport = None @@ -66,16 +69,14 @@ 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. + 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 - a new / modified request instance. - timeout: Optional float or (float, float) tuple following the semantics - defined by `Requests - `__. + objects before they are sent. If provided, the callable receives a + `httpx.Request` and must either modify the object directly or return + a new / modified request instance + """ self.session.headers.update(headers or {}) self.session.params.merge(parameters or {}) @@ -90,10 +91,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) @@ -108,19 +116,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) @@ -155,9 +162,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 @@ -190,10 +199,14 @@ def get_pages( """Iterator that yields dictionaries for each page at a STAPI paging endpoint. + Args: + link: The link to read from + 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 """ if not lookup_key: lookup_key = "features"