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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ uv run pytest
Check formatting and other lints:

```shell
uv run pre-commit --all-files
uv run pre-commit run --all
```

If you don't want to type `uv run` all the time:
Expand Down
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ dependencies = [
"pystapi-client",
"pystapi-validator",
"stapi-pydantic",
"stapi-fastapi"
"stapi-fastapi",
"types-click>=7.1.8",
]

[dependency-groups]
Expand All @@ -21,6 +22,7 @@ dev = [
"pre-commit>=4.2.0",
"pre-commit-hooks>=5.0.0",
"fastapi[standard]>=0.115.12",
"types-click>=7.1.8",
"pygithub>=2.6.1",
]
docs = [
Expand Down
1 change: 1 addition & 0 deletions pystapi-client/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ dependencies = [
"httpx>=0.28.1",
"stapi-pydantic",
"python-dateutil>=2.8.2",
"click>=8.1.8",
]

[project.scripts]
Expand Down
145 changes: 145 additions & 0 deletions pystapi-client/src/pystapi_client/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import itertools
import json

import click

from pystapi_client.client import Client
from pystapi_client.exceptions import APIError

CONTEXT_SETTINGS = dict(default_map={"cli": {"url": "http://localhost:8000"}})


@click.group(context_settings=CONTEXT_SETTINGS)
@click.option("--url", type=str, required=True, help="Base URL for STAPI server")
@click.pass_context
def cli(ctx: click.Context, url: str) -> None:
"""Command line interface for STAPI client. Group ensures client is created."""

client = Client.open(url)
ctx.obj = {"client": client}


@click.command()
@click.pass_context
@click.option("--max-items", "max_items", type=click.IntRange(min=1), help="Max number of products to display")
@click.option("--limit", type=click.IntRange(min=1), help="Limit number of products to request")
def products(ctx: click.Context, limit: int | None, max_items: int | None) -> None:
"""List products."""

client: Client = ctx.obj["client"]

products_iter = client.get_products(limit=limit)

if max_items:
products_iter = itertools.islice(products_iter, max_items)

products_list = list(products_iter)
if len(products_list) == 0:
click.echo("No products found.")
return

# FIXME: to get around AnyUrl not being JSON serializable, this does loads(pydantic.model_dump_json()). Should be
# fixed with a custom JSON serializer for AnyUrl.
click.echo(json.dumps([json.loads(p.model_dump_json()) for p in products_list]))


@click.command()
@click.pass_context
@click.option("--id", type=str, required=True, help="Product ID to retrieve")
def product(ctx: click.Context, id: str) -> None:
"""Get product by ID."""

client: Client = ctx.obj["client"]

product = client.get_product(product_id=id)
if not product:
click.echo("Product not found.")
return

click.echo(product.model_dump_json())


@click.command()
@click.pass_context
@click.option("--max-items", "max_items", type=click.IntRange(min=1), help="Max number of products to display")
@click.option("--limit", type=click.IntRange(min=1), help="Limit number of products to request")
def orders(ctx: click.Context, max_items: int | None, limit: int | None) -> None:
"""List orders."""

client: Client = ctx.obj["client"]

orders_iter = client.get_orders(limit=limit)

if max_items:
orders_iter = itertools.islice(orders_iter, max_items)

orders_list = list(orders_iter)
if len(orders_list) == 0:
click.echo("No orders found.")
return

# FIXME: to get around AnyUrl not being JSON serializable, this does loads(pydantic.model_dump_json()). Should be
# fixed with a custom JSON serializer for AnyUrl.
click.echo(json.dumps([json.loads(o.model_dump_json()) for o in orders_list]))


@click.command()
@click.pass_context
@click.option("--id", type=str, required=True, help="Order ID to retrieve")
def order(ctx: click.Context, id: str) -> None:
"""Get order by ID."""

client: Client = ctx.obj["client"]

try:
order = client.get_order(order_id=id)
click.echo(order.model_dump_json())
except APIError as e:
if e.status_code == 404:
click.echo("Order not found.")
else:
raise e


@click.command()
@click.pass_context
@click.option("--product-id", "product_id", type=str, required=True, help="Product ID for opportunities")
@click.option("--max-items", "max_items", type=click.IntRange(min=1), help="Max number of opportunities to display")
@click.option("--limit", type=click.IntRange(min=1), default=10, help="Max number of opportunities to display")
def opportunities(ctx: click.Context, product_id: str, limit: int, max_items: None) -> None:
"""List opportunities for a product."""

client: Client = ctx.obj["client"]

date_range = ("2025-01-03T15:18:11Z", "2025-04-03T15:18:11Z")
geometry = {"type": "Point", "coordinates": [-122.4194, 37.7749]}

opportunities_iter = client.get_product_opportunities(
product_id=product_id, geometry=geometry, date_range=date_range, limit=limit
)

if max_items:
opportunities_iter = itertools.islice(opportunities_iter, max_items)

opportunities_list = list(opportunities_iter)
if len(opportunities_list) == 0:
click.echo("No opportunities found.")
return

# FIXME: to get around AnyUrl not being JSON serializable, this does loads(pydantic.model_dump_json()). Should be
# fixed with a custom JSON serializer for AnyUrl.
click.echo(json.dumps([json.loads(o.model_dump_json()) for o in opportunities_list]))


cli.add_command(products)
cli.add_command(product)
cli.add_command(opportunities)
cli.add_command(orders)
cli.add_command(order)

if __name__ == "__main__":
try:
cli()
except Exception as e:
click.echo(f"Error: {e=}", err=True)
raise e
2 changes: 1 addition & 1 deletion pystapi-client/src/pystapi_client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
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
from pystapi_client.warns import NoConformsTo

DEFAULT_LINKS = [
{
Expand Down
2 changes: 1 addition & 1 deletion pystapi-client/src/pystapi_client/stapi_api_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ def request(
resp = self.session.send(modified)
except Exception as err:
logger.debug(err)
raise APIError.from_response(resp)
raise APIError(f"Error sending request: {err=}")

# NOTE what about other successful status codes?
if resp.status_code != 200:
Expand Down
15 changes: 15 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.