-
Notifications
You must be signed in to change notification settings - Fork 9
feat: add pystapi-client #21
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 8 commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
7c2e096
Add pystapi-client
ka7eh c547883
Merge branch 'main' into pystapi-client
ka7eh 3953e6e
Merge branch 'main' into pystapi-client
ka7eh 65af727
Cleanup stac client refs and add products methods to client
ka7eh 0ca4e33
Merge branch 'main' into pystapi-client
ka7eh 9a78670
Fix mypy issues
ka7eh 71ec4a6
Address comments
ka7eh 0be73ba
Add orders fetch methods
ka7eh fd077be
Changes based on feedback
ka7eh File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| [project] | ||
| name = "pystapi-client" | ||
| version = "0.0.1" | ||
| 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]" } | ||
| ] | ||
| maintainers = [{ name = "Pete Gadomski", email = "[email protected]" }] | ||
| keywords = ["stapi"] | ||
| license = { text = "MIT" } | ||
| requires-python = ">=3.10" | ||
| dependencies = [ | ||
| "httpx>=0.28.1", | ||
| "stapi-pydantic", | ||
| "python-dateutil>=2.8.2", | ||
| ] | ||
|
|
||
| [project.scripts] | ||
| stapi-client = "pystapi_client.cli:cli" | ||
|
|
||
| [tool.uv.sources] | ||
| stapi-pydantic = { workspace = true } | ||
|
|
||
| [build-system] | ||
| requires = ["hatchling"] | ||
| build-backend = "hatchling.build" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| __all__ = [ | ||
| "Client", | ||
| "ConformanceClasses", | ||
| ] | ||
|
|
||
| from pystapi_client.client import Client | ||
| from pystapi_client.conformance import ConformanceClasses |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,333 @@ | ||
| import re | ||
| import urllib | ||
| import urllib.parse | ||
| import warnings | ||
| from collections.abc import Callable, Iterable, Iterator | ||
| from typing import ( | ||
| Any, | ||
| ) | ||
|
|
||
| from httpx import Request | ||
| from httpx._types import TimeoutTypes | ||
| from pydantic import AnyUrl | ||
| from stapi_pydantic import Link, Order, OrderCollection, Product, ProductsCollection | ||
|
|
||
| from pystapi_client.conformance import ConformanceClasses | ||
| from pystapi_client.exceptions import APIError | ||
| from pystapi_client.stapi_api_io import StapiIO | ||
| from pystapi_client.warnings import NoConformsTo | ||
|
|
||
| DEFAULT_LINKS = [ | ||
| { | ||
| "endpoint": "/conformance", | ||
| "rel": "conformance", | ||
| "method": "GET", | ||
| }, | ||
| { | ||
| "endpoint": "/products", | ||
| "rel": "products", | ||
| "method": "GET", | ||
| }, | ||
| ] | ||
|
|
||
|
|
||
| class Client: | ||
| """A Client for interacting with the root of a STAPI | ||
|
|
||
| Instances of the ``Client`` class provide a convenient way of interacting | ||
| with STAPI APIs that conform to the [STAPI API spec](https://github.com/stapi-spec/stapi-spec). | ||
| """ | ||
|
|
||
| stapi_io: StapiIO | ||
| _conforms_to: list[str] | ||
| _links: list[Link] | ||
|
|
||
| def __repr__(self) -> str: | ||
| return f"<Client {self.stapi_io.root_url}>" | ||
|
|
||
| @classmethod | ||
| def open( | ||
| cls, | ||
| url: str, | ||
| headers: dict[str, str] | None = None, | ||
| parameters: dict[str, Any] | None = None, | ||
| request_modifier: Callable[[Request], Request] | None = None, | ||
| timeout: TimeoutTypes | None = None, | ||
| ) -> "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. | ||
| parameters: Optional dictionary of query string parameters to | ||
| include in all requests. | ||
| request_modifier: A callable that either 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. | ||
| 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>`__. | ||
|
|
||
| Return: | ||
| client : A :class:`Client` instance for this STAPI API | ||
| """ | ||
| client = Client() | ||
| client.stapi_io = StapiIO( | ||
| root_url=AnyUrl(url), | ||
| headers=headers, | ||
| parameters=parameters, | ||
| request_modifier=request_modifier, | ||
| timeout=timeout, | ||
| ) | ||
|
|
||
| client.read_links() | ||
| client.read_conformance() | ||
|
|
||
| if not client.has_conforms_to(): | ||
| warnings.warn(NoConformsTo()) | ||
|
|
||
| return client | ||
|
|
||
| def get_single_link( | ||
| self, | ||
| rel: str | None = None, | ||
| media_type: str | Iterable[str] | None = None, | ||
| ) -> Link | None: | ||
| """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. | ||
|
|
||
| Returns: | ||
| :class:`~stapi_pydantic.Link` | None: First link that matches ``rel`` | ||
| and/or ``media_type``, or else the first link associated with | ||
| this object. | ||
| """ | ||
| if rel is None and media_type is None: | ||
| return next(iter(self._links), None) | ||
| if media_type and isinstance(media_type, str): | ||
| media_type = [media_type] | ||
| return next( | ||
| ( | ||
| link | ||
| for link in self._links | ||
| if (rel is None or link.rel == rel) and (media_type is None or (link.type or "") in media_type) | ||
| ), | ||
| None, | ||
| ) | ||
|
|
||
| def read_links(self) -> None: | ||
| """Read the API links from the root of the STAPI API | ||
|
|
||
| The links are stored in `Client._links`.""" | ||
| links = self.stapi_io._read_json("/").get("links", []) | ||
| if links: | ||
| self._links = [Link(**link) for link in links] | ||
| else: | ||
| warnings.warn("No links found in the root of the STAPI API") | ||
| self._links = [ | ||
| Link( | ||
| href=urllib.parse.urljoin(str(self.stapi_io.root_url), link["endpoint"]), | ||
| rel=link["rel"], | ||
| method=link["method"], | ||
| ) | ||
| for link in DEFAULT_LINKS | ||
| ] | ||
|
|
||
| def read_conformance(self) -> None: | ||
| conformance: list[str] = [] | ||
| for endpoint in ["/conformance", "/"]: | ||
ka7eh marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| try: | ||
| conformance = self.stapi_io._read_json("conformance").get("conformsTo", []) | ||
ka7eh marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| break | ||
| except APIError: | ||
| continue | ||
|
|
||
| if conformance: | ||
| self.set_conforms_to(conformance) | ||
|
|
||
| def has_conforms_to(self) -> bool: | ||
| """Whether 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 | ||
| """ | ||
| 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 | ||
| """ | ||
| self._conforms_to = conformance_uris | ||
|
|
||
| def clear_conforms_to(self) -> None: | ||
| """Clear list of ``"conformsTo"`` urls | ||
|
|
||
| Removes the entire list, so :py:meth:`has_conforms_to` will | ||
| return False after using this method. | ||
| """ | ||
| self._conforms_to = [] | ||
|
|
||
| def add_conforms_to(self, name: str) -> None: | ||
| """Add ``"conformsTo"`` by name. | ||
|
|
||
| Args: | ||
| name : name of :py:class:`ConformanceClasses` keys to add. | ||
| """ | ||
| conformance_class = ConformanceClasses.get_by_name(name) | ||
|
|
||
| if not self.conforms_to(conformance_class): | ||
| self.set_conforms_to([*self.get_conforms_to(), conformance_class.valid_uri]) | ||
|
|
||
| def remove_conforms_to(self, name: str) -> None: | ||
| """Remove ``"conformsTo"`` by name. | ||
|
|
||
| Args: | ||
| name : name of :py:class:`ConformanceClasses` keys to remove. | ||
| """ | ||
| conformance_class = ConformanceClasses.get_by_name(name) | ||
|
|
||
| self.set_conforms_to([uri for uri in self.get_conforms_to() if not re.match(conformance_class.pattern, uri)]) | ||
|
|
||
ka7eh marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| def conforms_to(self, conformance_class: ConformanceClasses | str) -> bool: | ||
| """Checks whether the API conforms to the given standard. | ||
|
|
||
| This method only checks | ||
| against the ``"conformsTo"`` property from the API landing page and does not | ||
| make any additional calls to a ``/conformance`` endpoint even if the API | ||
| provides such an endpoint. | ||
|
|
||
| Args: | ||
| name : name of :py:class:`ConformanceClasses` keys to check | ||
| conformance against. | ||
|
|
||
| Return: | ||
| bool: 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: | ||
| return self.conforms_to(ConformanceClasses.OPPORTUNITIES) | ||
|
|
||
| def _supports_async_opportunities(self) -> bool: | ||
| return self.conforms_to(ConformanceClasses.ASYNC_OPPORTUNITIES) | ||
|
|
||
| def get_products(self, limit: int | None = None) -> Iterator[ProductsCollection]: | ||
| """Get all products from this STAPI API | ||
|
|
||
| Returns: | ||
| ProductsCollection: A collection of STAPI Products | ||
| """ | ||
| products_endpoint = self._get_products_href() | ||
|
|
||
| if limit is None: | ||
| parameters = {} | ||
| else: | ||
| parameters = {"limit": limit} | ||
|
|
||
| products_collection_iterator = self.stapi_io.get_pages( | ||
| products_endpoint, parameters=parameters, lookup_key="products" | ||
| ) | ||
| for products_collection in products_collection_iterator: | ||
| yield ProductsCollection.model_validate(products_collection) | ||
|
|
||
| def get_product(self, product_id: str) -> Product: | ||
| """Get a single product from this STAPI API | ||
|
|
||
| Args: | ||
| product_id: The Product ID to get | ||
|
|
||
| Returns: | ||
| Product: A STAPI Product | ||
|
|
||
| Raises: | ||
| ValueError if product_id does not exist. | ||
| """ | ||
|
|
||
| product_endpoint = self._get_products_href(product_id) | ||
| product_json = self.stapi_io._read_json(product_endpoint) | ||
|
|
||
| if product_json is None: | ||
| raise ValueError(f"Product {product_id} not found") | ||
|
|
||
| return Product.model_validate(product_json) | ||
|
|
||
| def _get_products_href(self, product_id: str | None = None) -> str: | ||
| href = self.get_single_link("products") | ||
| if href is None: | ||
| raise ValueError("No products link found") | ||
| if product_id is not None: | ||
| return urllib.parse.urljoin(str(href.href), product_id) | ||
| return str(href.href) | ||
|
|
||
| def get_orders(self, limit: int | None = None) -> Iterator[OrderCollection]: # type: ignore[type-arg] | ||
| # TODO Update return type after the pydantic model generic type is fixed | ||
| """Get orders from this STAPI API | ||
|
|
||
| Returns: | ||
| OrderCollection: A collection of STAPI Orders | ||
| """ | ||
| orders_endpoint = self._get_orders_href() | ||
|
|
||
| if limit is None: | ||
| parameters = {} | ||
| else: | ||
| parameters = {"limit": limit} | ||
|
|
||
| orders_collection_iterator = self.stapi_io.get_pages( | ||
| orders_endpoint, parameters=parameters, lookup_key="features" | ||
| ) | ||
| for orders_collection in orders_collection_iterator: | ||
| yield OrderCollection.model_validate(orders_collection) | ||
|
|
||
| def get_order(self, order_id: str) -> Order: # type: ignore[type-arg] | ||
| # TODO Update return type after the pydantic model generic type is fixed | ||
| """Get a single order from this STAPI API | ||
|
|
||
| Args: | ||
| order_id: The Order ID to get | ||
|
|
||
| Returns: | ||
| Order: A STAPI Order | ||
|
|
||
| Raises: | ||
| ValueError if order_id does not exist. | ||
| """ | ||
|
|
||
| order_endpoint = self._get_orders_href(order_id) | ||
| order_json = self.stapi_io._read_json(order_endpoint) | ||
|
|
||
| if order_json is None: | ||
| raise ValueError(f"Order {order_id} not found") | ||
|
|
||
| return Order.model_validate(order_json) | ||
|
|
||
| def _get_orders_href(self, order_id: str | None = None) -> str: | ||
| href = self.get_single_link("orders") | ||
| if href is None: | ||
| raise ValueError("No orders link found") | ||
| if order_id is not None: | ||
| return urllib.parse.urljoin(str(href.href), order_id) | ||
| return str(href.href) | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.