-
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 5 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,25 @@ | ||
| [project] | ||
| name = "pystapi-client" | ||
| version = "0.0.1" | ||
| description = "Python library for searching Satellite Tasking API (STAPI) APIs." | ||
| readme = "README.md" | ||
| authors = [] | ||
ka7eh marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| 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,9 @@ | ||
| __all__ = [ | ||
| "Client", | ||
| "ConformanceClasses", | ||
| "__version__", | ||
ka7eh marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| ] | ||
|
|
||
| from pystapi_client.client import Client | ||
| from pystapi_client.conformance import ConformanceClasses | ||
| from pystapi_client.version import __version__ | ||
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,283 @@ | ||
| import re | ||
| import urllib | ||
| import urllib.parse | ||
| import warnings | ||
| from collections.abc import Callable, Iterable, Iterator | ||
| from typing import ( | ||
| Any, | ||
| cast, | ||
| ) | ||
|
|
||
| import httpx | ||
| from pydantic import AnyUrl | ||
| from stapi_pydantic import Link, Product | ||
| from stapi_pydantic.product import 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>`_. | ||
ka7eh marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| """ | ||
|
|
||
| _stapi_io: StapiIO | ||
| _conforms_to: list[str] = [] | ||
| _extra_fields: dict[str, Any] = {} | ||
ka7eh marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| def __repr__(self) -> str: | ||
| return f"<Client {self._stapi_io.root_url}>" | ||
|
|
||
| @classmethod | ||
| def open( | ||
| cls, | ||
| url: AnyUrl, | ||
ka7eh marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| headers: dict[str, str] | None = None, | ||
| parameters: dict[str, Any] | None = None, | ||
| request_modifier: Callable[[httpx.Request], httpx.Request] | None = None, | ||
ka7eh marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| timeout: httpx._types.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=url, | ||
| headers=headers, | ||
| parameters=parameters, | ||
| request_modifier=request_modifier, | ||
| timeout=timeout, | ||
| ) | ||
ka7eh marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| 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._extra_fields["links"]), None) | ||
| if media_type and isinstance(media_type, str): | ||
| media_type = [media_type] | ||
| return next( | ||
| ( | ||
| link | ||
| for link in self._extra_fields["links"] | ||
| if (rel is None or link.rel == rel) and (media_type is None or link.media_type in media_type) | ||
| ), | ||
| None, | ||
| ) | ||
|
|
||
| def read_links(self) -> None: | ||
| """ """ | ||
ka7eh marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| links = self._stapi_io._read_json("/").get("links", []) | ||
| if links: | ||
| self._extra_fields["links"] = [Link(**link) for link in links] | ||
| else: | ||
| warnings.warn("No links found in the root of the STAPI API") | ||
| self._extra_fields["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", []) | ||
| 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 "conformsTo" in self._extra_fields | ||
|
|
||
| def get_conforms_to(self) -> list[str]: | ||
| """List of ``"conformsTo"`` URIs | ||
| Return: | ||
| List[str]: List of URIs that the server conforms to | ||
| """ | ||
| return cast(list[str], self._extra_fields.get("conformsTo", []).copy()) | ||
ka7eh marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| 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._extra_fields["conformsTo"] = 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._extra_fields.pop("conformsTo", None) | ||
|
|
||
| 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)]) | ||
|
|
||
| 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._products_href() | ||
ka7eh marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| 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._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 _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) | ||
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,32 @@ | ||
| import re | ||
| from enum import Enum | ||
|
|
||
|
|
||
| class ConformanceClasses(Enum): | ||
| """Enumeration class for Conformance Classes""" | ||
|
|
||
| # defined conformance classes regexes | ||
| CORE = "/core" | ||
| OPPORTUNITIES = "/opportunities" | ||
| ASYNC_OPPORTUNITIES = "/async-opportunities" | ||
|
|
||
| @classmethod | ||
| def get_by_name(cls, name: str) -> "ConformanceClasses": | ||
| for member in cls: | ||
| if member.name == name.upper(): | ||
| return member | ||
| raise ValueError(f"Invalid conformance class '{name}'. Options are: {list(cls)}") | ||
|
|
||
| def __str__(self) -> str: | ||
| return f"{self.name}" | ||
|
|
||
| def __repr__(self) -> str: | ||
| return str(self) | ||
|
|
||
| @property | ||
| def valid_uri(self) -> str: | ||
| return f"https://stapi.example.com/v0.1.*{self.value}" | ||
ka7eh marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| @property | ||
| def pattern(self) -> re.Pattern[str]: | ||
| return re.compile(rf"{re.escape('https://stapi.example.com/v0.1.')}(.*){re.escape(self.value)}") | ||
Oops, something went wrong.
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.