diff --git a/docs/cli/cli-catalog.md b/docs/cli/cli-catalog.md new file mode 100644 index 00000000..58ba8564 --- /dev/null +++ b/docs/cli/cli-catalog.md @@ -0,0 +1,204 @@ +--- +title: CLI for Catalog API Tutorial +--- + +## Introduction + +The `planet catalog` command provides an interface for browsing and searching the +[Planet Catalog API](https://docs.planet.com/develop/apis/catalog/), an +implementation of the [STAC](https://stacspec.org/) (SpatioTemporal Asset +Catalog) specification. This tutorial takes you through the main commands +available in the CLI. + +## Authentication + +!!! note + + Unlike the other Planet APIs, the Catalog API is not served from + `api.planet.com`. It is hosted by Sentinel Hub and authenticates with an + OAuth bearer token, so a **plain Planet API key will not work**. + +Log in with an OAuth profile before using these commands: + +```sh +planet auth login +``` + +See the [client authentication documentation](../auth/auth-overview.md) for the +available profiles and for machine-to-machine (M2M) credentials. + +## Deployments + +The API has two regional deployments. The CLI defaults to `eu-central-1`; use +`--base-url` to target `us-west-2`: + +```sh +planet catalog --base-url https://services-uswest2.sentinel-hub.com/catalog/v1 collections list +``` + +## Core Workflows + +### Explore the Catalog + +The landing page is the root STAC Catalog. It lists the conformance classes the +server implements and links to the collections and search endpoints. + +```sh +planet catalog landing-page --pretty +``` + +To see just the specifications the API conforms to: + +```sh +planet catalog conformance +``` + +### List Collections + +Every item in the catalog belongs to a collection. To see the collections +available to your account: + +```sh +planet catalog collections list +``` + +You can get nicer formatting with `--pretty` or pipe it into `jq`, just like the +other Planet CLIs. For example, to list only the collection IDs: + +```sh +planet catalog collections list | jq -r '.[].id' +``` + +To describe a single collection, including its spatial and temporal extents and +its `summaries`: + +```sh +planet catalog collections get sentinel-2-l2a +``` + +### Discover Filterable Properties + +Before writing a filter, check which properties a collection can be filtered on. +The `queryables` command returns a JSON Schema of the valid terms: + +```sh +planet catalog collections queryables sentinel-2-l2a --pretty +``` + +### List the Items in a Collection + +The `items list` command pages through a collection, printing one item per line: + +```sh +planet catalog items list sentinel-2-l2a \ + --bbox 13,45,14,46 \ + --datetime 2020-12-10T00:00:00Z/2020-12-30T00:00:00Z \ + --limit 5 +``` + +The `--datetime` option accepts an RFC 3339 instant or an interval. Open +intervals use double-dots, e.g. `2018-02-12T00:00:00Z/..`. + +To fetch one known item: + +```sh +planet catalog items get sentinel-2-l2a \ + S2B_MSIL2A_20201229T101329_N0214_R022_T33TUK_20201229T115442 +``` + +### Search + +There are two search commands, matching the two operations the API exposes. + +#### `search` (full-featured) + +`planet catalog search` uses `POST /search`. It searches multiple collections and +accepts CQL2 JSON filters and include/exclude field objects. + +```sh +planet catalog search \ + --collections sentinel-2-l2a \ + --datetime 2020-12-10T00:00:00Z/2020-12-30T00:00:00Z \ + --bbox 13,45,14,46 \ + --filter 'eo:cloud_cover>90' \ + --limit 5 +``` + +To supply a CQL2 JSON filter instead of CQL2 text, pass `--filter-lang cql2-json`: + +```sh +planet catalog search \ + --collections sentinel-2-l2a \ + --datetime 2020-12-10T00:00:00Z/2020-12-30T00:00:00Z \ + --filter '{"op": ">", "args": [{"property": "eo:cloud_cover"}, 90]}' \ + --filter-lang cql2-json +``` + +Trim the response payload with `--fields`, which takes a JSON object with +`include` and/or `exclude` lists: + +```sh +planet catalog search \ + --collections sentinel-2-l2a \ + --datetime 2020-12-10T00:00:00Z/2020-12-30T00:00:00Z \ + --fields '{"include": ["id", "bbox"], "exclude": ["geometry", "links", "assets"]}' +``` + +Search an area given as a GeoJSON geometry rather than a bounding box with +`--intersects`, which accepts a JSON string, a filename, or `-` for stdin: + +```sh +planet catalog search \ + --collections sentinel-2-l2a \ + --datetime 2020-12-10T00:00:00Z/2020-12-30T00:00:00Z \ + --intersects aoi.geojson +``` + +#### `simple-search` (shorthand) + +`planet catalog simple-search` uses `GET /search`. It takes exactly one +collection, a CQL2 **text** filter, and a comma-separated `--fields` string: + +```sh +planet catalog simple-search \ + --collections sentinel-2-l2a \ + --datetime 2020-12-10T00:00:00Z/2020-12-30T00:00:00Z \ + --bbox 13,45,14,46 \ + --fields 'id,type,-geometry,bbox,properties,-links,-assets' +``` + +### Distinct Values + +Both search commands support `--distinct`, which returns the unique values of a +single property instead of full item metadata. This is a cheap way to find out +which acquisition dates exist in an area and time range: + +```sh +planet catalog search \ + --collections sentinel-2-l2a \ + --datetime 2020-12-10T00:00:00Z/2020-12-30T00:00:00Z \ + --bbox 13,45,14,46 \ + --distinct date +``` + +As with `--filter`, the properties you can request depend on the collection. + +## Paging + +The listing and search commands page automatically and print one result per +line, so you can stream them straight into `jq` or a file. + +* `--limit` caps the **total** number of results returned. Set it to `0` for no + maximum. It defaults to 100. +* `--page-size` controls how many results are fetched per request. The API + accepts 1-100. + +For example, to pull every matching item rather than the first 100: + +```sh +planet catalog search \ + --collections sentinel-2-l2a \ + --datetime 2020-12-10T00:00:00Z/2020-12-30T00:00:00Z \ + --bbox 13,45,14,46 \ + --limit 0 > items.ndjson +``` diff --git a/docs/python/sdk-reference.md b/docs/python/sdk-reference.md index 97b3dea8..eb5b3bbc 100644 --- a/docs/python/sdk-reference.md +++ b/docs/python/sdk-reference.md @@ -42,6 +42,10 @@ title: Python SDK API Reference rendering: show_root_full_path: false +## ::: planet.CatalogClient + rendering: + show_root_full_path: false + ## ::: planet.DestinationsClient rendering: show_root_full_path: false diff --git a/mkdocs.yml b/mkdocs.yml index 540bf459..8c81a1ce 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -85,6 +85,7 @@ nav: - cli/cli-orders.md - cli/cli-subscriptions.md - cli/cli-destinations.md + - cli/cli-catalog.md - cli/cli-tips-tricks.md - cli/cli-reference.md - "Python": diff --git a/planet/__init__.py b/planet/__init__.py index 41a9e62b..200327b6 100644 --- a/planet/__init__.py +++ b/planet/__init__.py @@ -17,13 +17,14 @@ from .__version__ import __version__ # NOQA from .auth import Auth from .auth_builtins import PlanetOAuthScopes -from .clients import DataClient, DestinationsClient, FeaturesClient, MosaicsClient, OrdersClient, SubscriptionsClient # NOQA +from .clients import CatalogClient, DataClient, DestinationsClient, FeaturesClient, MosaicsClient, OrdersClient, SubscriptionsClient # NOQA from .io import collect from .sync import Planet __all__ = [ 'Auth', 'PlanetOAuthScopes', + 'CatalogClient', 'collect', 'DataClient', 'data_filter', diff --git a/planet/cli/catalog.py b/planet/cli/catalog.py new file mode 100644 index 00000000..eb295f27 --- /dev/null +++ b/planet/cli/catalog.py @@ -0,0 +1,363 @@ +# Copyright 2026 Planet Labs PBC. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. +"""Catalog API CLI""" +from contextlib import asynccontextmanager + +import click + +from planet.cli.io import echo_json +from planet.clients.catalog import CatalogClient + +from .cmds import command +from .options import limit +from .session import CliSession +from . import types + + +@asynccontextmanager +async def catalog_client(ctx): + async with CliSession(ctx) as sess: + cl = CatalogClient(sess, base_url=ctx.obj['BASE_URL']) + yield cl + + +@click.group() # type: ignore +@click.pass_context +@click.option('-u', + '--base-url', + default=None, + help='Assign custom base Catalog API URL (e.g. ' + 'https://services-uswest2.sentinel-hub.com/catalog/v1 for the ' + 'us-west-2 deployment).') +def catalog(ctx, base_url): + """Commands for interacting with the Catalog API. + + The Catalog API is a STAC API hosted by Sentinel Hub. It authenticates + with an OAuth bearer token, so a plain Planet API key will not work - log + in with an OAuth profile using `planet auth login` first. + """ + ctx.obj['BASE_URL'] = base_url + + +@command(catalog, name='landing-page') +async def landing_page(ctx, pretty): + """Get the Catalog API landing page. + + The landing page is the root STAC Catalog. It lists the conformance + classes the server implements and links to the collections and search + endpoints. + + Example: + + planet catalog landing-page --pretty + """ + async with catalog_client(ctx) as cl: + result = await cl.get_landing_page() + echo_json(result, pretty) + + +@command(catalog, name='conformance') +async def conformance(ctx, pretty): + """Get the specifications this API conforms to. + + Example: + + planet catalog conformance + """ + async with catalog_client(ctx) as cl: + result = await cl.get_conformance() + echo_json(result, pretty) + + +@catalog.group() +def collections(): + """Commands for inspecting catalog collections.""" + pass + + +@command(collections, name='list') +async def collections_list(ctx, pretty): + """List the collections available to your account. + + Example: + + planet catalog collections list + """ + async with catalog_client(ctx) as cl: + results = await cl.list_collections() + echo_json(results, pretty) + + +@command(collections, name='get') +@click.argument('collection_id') +async def collection_get(ctx, collection_id, pretty): + """Describe a single collection. + + Example: + + planet catalog collections get sentinel-2-l2a + """ + async with catalog_client(ctx) as cl: + result = await cl.get_collection(collection_id) + echo_json(result, pretty) + + +@command(collections, name='queryables') +@click.argument('collection_id') +async def collection_queryables(ctx, collection_id, pretty): + """Get the properties a collection can be filtered on. + + The returned JSON Schema describes the terms that are valid in the CQL2 + expressions accepted by `--filter`. + + Example: + + planet catalog collections queryables sentinel-2-l2a + """ + async with catalog_client(ctx) as cl: + result = await cl.get_collection_queryables(collection_id) + echo_json(result, pretty) + + +_bbox_opt = click.option( + '--bbox', + type=types.CommaSeparatedFloat(), + default=None, + help='Bounding box in CRS84 as west,south,east,north.') + +_datetime_opt = click.option( + '--datetime', + 'datetime_', + default=None, + help='RFC 3339 date-time or interval, e.g. ' + '2020-12-10T00:00:00Z/2020-12-30T00:00:00Z. Open intervals use `..`.') + +_page_size_opt = click.option('--page-size', + type=click.INT, + default=100, + show_default=True, + help='Number of results to fetch per request. ' + 'The API accepts 1-100.') + + +@catalog.group() +def items(): + """Commands for working with the items in a collection.""" + pass + + +@command(items, name='list', extra_args=[limit]) +@click.argument('collection_id') +@_bbox_opt +@_datetime_opt +@_page_size_opt +async def items_list(ctx, + collection_id, + bbox, + datetime_, + limit, + page_size, + pretty): + """List the items in a collection. + + Example: + + \b + planet catalog items list sentinel-2-l2a \\ + --bbox 13,45,14,46 \\ + --datetime 2020-12-10T00:00:00Z/2020-12-30T00:00:00Z + """ + async with catalog_client(ctx) as cl: + results = cl.list_items(collection_id, + bbox=bbox, + datetime=datetime_, + limit=limit, + page_size=page_size) + async for item in results: + echo_json(item, pretty) + + +@command(items, name='get') +@click.argument('collection_id') +@click.argument('item_id') +async def item_get(ctx, collection_id, item_id, pretty): + """Get a single item from a collection. + + Example: + + \b + planet catalog items get sentinel-2-l2a \\ + S2B_MSIL2A_20201229T101329_N0214_R022_T33TUK_20201229T115442 + """ + async with catalog_client(ctx) as cl: + result = await cl.get_item(collection_id, item_id) + echo_json(result, pretty) + + +_collections_opt = click.option( + '--collections', + type=types.CommaSeparatedString(), + required=True, + help='Comma-separated collection IDs to search.') + +_search_datetime_opt = click.option( + '--datetime', + 'datetime_', + required=True, + help='RFC 3339 date-time or interval, e.g. ' + '2020-12-10T00:00:00Z/2020-12-30T00:00:00Z. Open intervals use `..`.') + +_intersects_opt = click.option( + '--intersects', + type=types.JSON(), + default=None, + help='GeoJSON geometry to intersect (string, filename, or `-` for stdin).') + +_ids_opt = click.option('--ids', + type=types.CommaSeparatedString(), + default=None, + help='Comma-separated item IDs to return.') + +_distinct_opt = click.option( + '--distinct', + default=None, + help='Return the unique values of this property instead of full items.') + + +@command(catalog, name='search', extra_args=[limit]) +@_collections_opt +@_search_datetime_opt +@_bbox_opt +@_intersects_opt +@_ids_opt +@click.option('--fields', + type=types.JSON(), + default=None, + help='JSON object with `include` and/or `exclude` lists, e.g. ' + '\'{"include": ["id", "bbox"], "exclude": ["geometry"]}\'.') +@click.option('--filter', + 'filter_', + default=None, + help='A CQL2 filter. Text by default (e.g. `eo:cloud_cover>90`);' + ' pass --filter-lang cql2-json to supply CQL2 JSON.') +@click.option('--filter-lang', + type=click.Choice(['cql2-text', 'cql2-json']), + default=None, + help='The CQL2 encoding used by --filter.') +@click.option('--filter-crs', + default=None, + help='CRS used by spatial literals in --filter.') +@_distinct_opt +@_page_size_opt +async def search(ctx, + collections, + datetime_, + bbox, + intersects, + ids, + fields, + filter_, + filter_lang, + filter_crs, + distinct, + limit, + page_size, + pretty): + """Search items with full-featured filtering (POST /search). + + Example: + + \b + planet catalog search \\ + --collections sentinel-2-l2a \\ + --datetime 2020-12-10T00:00:00Z/2020-12-30T00:00:00Z \\ + --bbox 13,45,14,46 \\ + --filter 'eo:cloud_cover>90' + """ + if filter_ is not None and filter_lang == 'cql2-json': + filter_ = types.JSON().convert(filter_, None, ctx) + + async with catalog_client(ctx) as cl: + results = cl.search(collections, + datetime_, + bbox=bbox, + intersects=intersects, + ids=ids, + fields=fields, + filter=filter_, + filter_lang=filter_lang, + filter_crs=filter_crs, + distinct=distinct, + limit=limit, + page_size=page_size) + async for item in results: + echo_json(item, pretty) + + +@command(catalog, name='simple-search', extra_args=[limit]) +@_collections_opt +@_search_datetime_opt +@_bbox_opt +@_intersects_opt +@_ids_opt +@click.option('--fields', + default=None, + help='Comma-separated attributes to include or exclude, e.g. ' + '`id,type,-geometry,bbox,properties,-links,-assets`.') +@click.option('--filter', + 'filter_', + default=None, + help='A CQL2 text filter, e.g. `eo:cloud_cover>90`.') +@_distinct_opt +@_page_size_opt +async def simple_search(ctx, + collections, + datetime_, + bbox, + intersects, + ids, + fields, + filter_, + distinct, + limit, + page_size, + pretty): + """Search items with simple filtering (GET /search). + + This is the shorthand search operation: it takes exactly one collection, + a CQL2 text filter, and a comma-separated fields string. Use + `planet catalog search` for CQL2 JSON filters or include/exclude fields. + + Example: + + \b + planet catalog simple-search \\ + --collections sentinel-2-l2a \\ + --datetime 2020-12-10T00:00:00Z/2020-12-30T00:00:00Z \\ + --bbox 13,45,14,46 \\ + --distinct date + """ + async with catalog_client(ctx) as cl: + results = cl.simple_search(collections, + datetime_, + bbox=bbox, + intersects=intersects, + ids=ids, + fields=fields, + filter=filter_, + distinct=distinct, + limit=limit, + page_size=page_size) + async for item in results: + echo_json(item, pretty) diff --git a/planet/cli/cli.py b/planet/cli/cli.py index 467b1e5b..94617c15 100644 --- a/planet/cli/cli.py +++ b/planet/cli/cli.py @@ -22,7 +22,7 @@ import planet from planet.cli import mosaics -from . import auth, cmds, collect, data, destinations, orders, subscriptions, features +from . import auth, catalog, cmds, collect, data, destinations, orders, subscriptions, features LOGGER = logging.getLogger(__name__) @@ -131,6 +131,7 @@ def _configure_logging(verbosity): main.add_command(features.features) # type: ignore main.add_command(destinations.destinations) # type: ignore main.add_command(mosaics.mosaics) # type: ignore +main.add_command(catalog.catalog) # type: ignore if __name__ == "__main__": main() # pylint: disable=E1120 diff --git a/planet/clients/__init__.py b/planet/clients/__init__.py index 6aae646f..c9edf609 100644 --- a/planet/clients/__init__.py +++ b/planet/clients/__init__.py @@ -12,6 +12,7 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +from .catalog import CatalogClient from .data import DataClient from .destinations import DestinationsClient from .features import FeaturesClient @@ -20,6 +21,7 @@ from .subscriptions import SubscriptionsClient __all__ = [ + 'CatalogClient', 'DataClient', 'DestinationsClient', 'FeaturesClient', @@ -30,6 +32,7 @@ # Organize client classes by their module name to allow lookup. _client_directory = { + 'catalog': CatalogClient, 'data': DataClient, 'destinations': DestinationsClient, 'features': FeaturesClient, diff --git a/planet/clients/catalog.py b/planet/clients/catalog.py new file mode 100644 index 00000000..155c9370 --- /dev/null +++ b/planet/clients/catalog.py @@ -0,0 +1,514 @@ +# Copyright 2026 Planet Labs PBC. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. +"""Planet Catalog API Python client.""" + +import json +import logging +from typing import Any, AsyncGenerator, AsyncIterator, Dict, List, Optional, Union + +from planet.clients.base import _BaseClient +from planet.exceptions import APIError, ClientError, PagingError +from planet.http import Session +from planet.models import Paged +from ..constants import SENTINEL_HUB_BASE_URL, SENTINEL_HUB_US_WEST_2_BASE_URL + +BASE_URL = f'{SENTINEL_HUB_BASE_URL}/catalog/v1' +US_WEST_2_BASE_URL = f'{SENTINEL_HUB_US_WEST_2_BASE_URL}/catalog/v1' + +LOGGER = logging.getLogger() + + +class _CatalogPaged(Paged): + """Pager for Catalog API GET responses. + + Catalog API item responses are STAC ItemCollections: the items are under + `features` and the paging link is the entry with `"rel": "next"` in the + top-level `links` list. + """ + LINKS_KEY = 'links' + ITEMS_KEY = 'features' + + def _next_link(self, page): + for link in page.get(self.LINKS_KEY) or []: + if link.get('rel') == self.NEXT_KEY and link.get('href'): + LOGGER.debug(f'next: {link["href"]}') + return link['href'] + LOGGER.debug('end of the pages') + return False + + +class _CatalogSearchPaged(_CatalogPaged): + """Pager for `POST /search`, which cannot be paged by following a link. + + The Catalog API pages item search by returning a `context.next` token; the + next page is retrieved by re-sending the original query with `next` added. + """ + + def __init__(self, + response, + request_fcn, + url: str, + body: Dict[str, Any], + limit: int = 0): + self._url = url + self._body = body + super().__init__(response, request_fcn, limit=limit) + + @staticmethod + def _next_token(page) -> Union[str, bool]: + next_token = (page.get('context') or {}).get('next') + if not next_token: + LOGGER.debug('end of the pages') + return False + LOGGER.debug(f'next: {next_token}') + return next_token + + async def _get_pages(self, response) -> AsyncGenerator: + page = response.json() + yield page + + next_token = self._next_token(page) + while next_token: + LOGGER.debug('getting next page') + response = await self._request_fcn(method='POST', + url=self._url, + json={ + **self._body, + 'next': next_token + }) + page = response.json() + + # If the server echoes back the same token we would re-request the + # same page forever. Mirrors the guard in planet.models.Paged. + prev_token = next_token + next_token = self._next_token(page) + + if next_token == prev_token: + raise PagingError( + "Page cycle detected at {!r}".format(next_token)) + + yield page + + +class CatalogClient(_BaseClient): + """Asynchronous Catalog API client. + + The methods of this class forward request parameters to the operations + described in the Planet Catalog API specification + (https://docs.planet.com/develop/apis/catalog/reference/). The Catalog API + is an implementation of the STAC (SpatioTemporal Asset Catalog) + specification. + + Note: + Unlike the other Planet APIs, the Catalog API is not served from + `api.planet.com`. It is hosted by Sentinel Hub and authenticates with + an OAuth bearer token, so a plain Planet API key will not work. Use an + OAuth profile - see the client authentication documentation at + https://docs.planet.com/develop/authentication/ + + For more information, see the documentation at + https://docs.planet.com/develop/apis/catalog/ + + Example: + ```python + >>> import asyncio + >>> from planet import Session + >>> + >>> async def main(): + ... async with Session() as sess: + ... cl = sess.client('catalog') + ... # use client here + ... + >>> asyncio.run(main()) + ``` + """ + + def __init__(self, + session: Session, + base_url: Optional[str] = None) -> None: + """ + Parameters: + session: Open session connected to server. + base_url: The base URL to use. Defaults to the production Catalog + API base URL for the `eu-central-1` deployment + (`https://services.sentinel-hub.com/catalog/v1`). Pass + `planet.clients.catalog.US_WEST_2_BASE_URL` for the + `us-west-2` deployment. + """ + super().__init__(session, base_url or BASE_URL) + self._collections_url = f'{self._base_url}/collections' + self._search_url = f'{self._base_url}/search' + + @staticmethod + def _query_params(**kwargs) -> Dict[str, Any]: + """Build a query-string params dict, dropping unset values. + + The Catalog API declares its array and object query parameters with + `explode: false`, so lists are comma-joined and geometries are sent as + encoded JSON rather than as repeated parameters. + """ + params: Dict[str, Any] = {} + for key, value in kwargs.items(): + if value is None: + continue + if isinstance(value, (list, tuple)): + params[key] = ','.join(str(entry) for entry in value) + elif isinstance(value, dict): + params[key] = json.dumps(value) + else: + params[key] = value + return params + + async def get_landing_page(self) -> dict: + """Get the Catalog API landing page. + + The landing page is the root STAC Catalog. It is the entry point for + browsing or crawling the catalog and describes the conformance classes + the server implements. + + Returns: + dict: the root STAC Catalog, including `conformsTo` and `links`. + + Raises: + APIError: on an API server error. + ClientError: on a client error. + """ + try: + resp = await self._session.request(method='GET', + url=self._base_url) + except APIError: + raise + except ClientError: # pragma: no cover + raise + return resp.json() + + async def get_conformance(self) -> dict: + """Get the specifications this API conforms to. + + Returns: + dict: payload with a `conformsTo` list of conformance class URIs. + + Raises: + APIError: on an API server error. + ClientError: on a client error. + """ + url = f'{self._base_url}/conformance' + try: + resp = await self._session.request(method='GET', url=url) + except APIError: + raise + except ClientError: # pragma: no cover + raise + return resp.json() + + async def list_collections(self) -> List[dict]: + """List the collections available to your account. + + Note: + This endpoint is not paged - the API returns every accessible + collection in a single response. + + Returns: + list[dict]: the STAC Collections available to the requesting user. + + Raises: + APIError: on an API server error. + ClientError: on a client error. + """ + try: + resp = await self._session.request(method='GET', + url=self._collections_url) + except APIError: + raise + except ClientError: # pragma: no cover + raise + return resp.json().get('collections', []) + + async def get_collection(self, collection_id: str) -> dict: + """Describe a single collection. + + Parameters: + collection_id: Local identifier of the collection, e.g. + `sentinel-2-l2a`. + + Returns: + dict: the STAC Collection description, including its spatial and + temporal extents and its `summaries`. + + Raises: + APIError: on an API server error. + ClientError: on a client error. + """ + url = f'{self._collections_url}/{collection_id}' + try: + resp = await self._session.request(method='GET', url=url) + except APIError: + raise + except ClientError: # pragma: no cover + raise + return resp.json() + + async def get_collection_queryables(self, collection_id: str) -> dict: + """Get the properties a collection can be filtered on. + + The returned JSON Schema describes the variable terms that are valid + in the CQL2 expressions accepted by the `filter` parameter of + [planet.clients.catalog.CatalogClient.search][] and + [planet.clients.catalog.CatalogClient.simple_search][]. + + Parameters: + collection_id: Local identifier of the collection. + + Returns: + dict: a JSON Schema of the collection's queryable properties. + + Raises: + APIError: on an API server error. + ClientError: on a client error. + """ + url = f'{self._collections_url}/{collection_id}/queryables' + try: + resp = await self._session.request(method='GET', url=url) + except APIError: + raise + except ClientError: # pragma: no cover + raise + return resp.json() + + async def list_items( + self, + collection_id: str, + bbox: Optional[List[float]] = None, + datetime: Optional[str] = None, + limit: int = 100, + page_size: int = 100, + ) -> AsyncIterator[dict]: + """Iterate over the items in a collection. + + Parameters: + collection_id: Local identifier of the collection. + bbox: Only return items intersecting this bounding box, given in + CRS84 as `[west, south, east, north]`, or as six values when + the vertical bounds are included. + datetime: An RFC 3339 date-time or interval. Open intervals use + double-dots, e.g. `2018-02-12T00:00:00Z/..`. + limit: Maximum number of items to return. When set to 0, no + maximum is applied. + page_size: Number of items to fetch per request. The API accepts + 1-100 and defaults to 10. + + Yields: + dict: A STAC Item. + + Raises: + APIError: on an API server error. + ClientError: on a client error. + """ + params = self._query_params(bbox=bbox, + datetime=datetime, + limit=page_size) + + url = f'{self._collections_url}/{collection_id}/items' + try: + response = await self._session.request(method='GET', + url=url, + params=params) + async for item in _CatalogPaged(response, + self._session.request, + limit=limit): + yield item + except APIError: + raise + except ClientError: # pragma: no cover + raise + + async def get_item(self, collection_id: str, item_id: str) -> dict: + """Get a single item from a collection. + + Parameters: + collection_id: Local identifier of the collection. + item_id: Local identifier of the item. + + Returns: + dict: the STAC Item. + + Raises: + APIError: on an API server error. + ClientError: on a client error. + """ + url = f'{self._collections_url}/{collection_id}/items/{item_id}' + try: + resp = await self._session.request(method='GET', url=url) + except APIError: + raise + except ClientError: # pragma: no cover + raise + return resp.json() + + async def simple_search( + self, + collections: List[str], + datetime: str, + bbox: Optional[List[float]] = None, + intersects: Optional[dict] = None, + ids: Optional[List[str]] = None, + fields: Optional[str] = None, + filter: Optional[str] = None, + distinct: Optional[str] = None, + limit: int = 100, + page_size: int = 100, + ) -> AsyncIterator[dict]: + """Search items with simple filtering (`GET /search`). + + This is the shorthand search operation. Its `filter` is CQL2 text and + its `fields` is a comma-separated string. For CQL2 JSON filters, + include/exclude field objects, or searching more than one collection, + use [planet.clients.catalog.CatalogClient.search][] instead. + + Parameters: + collections: Collection IDs to search. This operation accepts + exactly one collection. + datetime: An RFC 3339 date-time or interval. Required. + bbox: Only return items intersecting this bounding box, in CRS84. + intersects: Only return items intersecting this GeoJSON geometry. + ids: Only return items with these IDs. + fields: Comma-separated attributes to include or exclude, e.g. + `id,type,-geometry,bbox,properties,-links,-assets`. + filter: A CQL2 text filter, e.g. `eo:cloud_cover>90`. The + filterable properties of a collection are given by + [planet.clients.catalog.CatalogClient.get_collection_queryables][]. + distinct: Return the unique values of this property instead of + full item metadata. The yielded values are the property values + themselves rather than STAC Items. + limit: Maximum number of results to return. When set to 0, no + maximum is applied. + page_size: Number of results to fetch per request. The API accepts + 1-100 and defaults to 10. + + Yields: + dict: A STAC Item, or a distinct property value when `distinct` is + given. + + Raises: + APIError: on an API server error. + ClientError: on a client error. + """ + params = self._query_params(collections=collections, + datetime=datetime, + bbox=bbox, + intersects=intersects, + ids=ids, + fields=fields, + filter=filter, + distinct=distinct, + limit=page_size) + + try: + response = await self._session.request(method='GET', + url=self._search_url, + params=params) + async for item in _CatalogPaged(response, + self._session.request, + limit=limit): + yield item + except APIError: + raise + except ClientError: # pragma: no cover + raise + + async def search( + self, + collections: List[str], + datetime: str, + bbox: Optional[List[float]] = None, + intersects: Optional[dict] = None, + ids: Optional[List[str]] = None, + fields: Optional[Union[str, dict]] = None, + filter: Optional[Union[str, dict]] = None, + filter_lang: Optional[str] = None, + filter_crs: Optional[str] = None, + distinct: Optional[str] = None, + limit: int = 100, + page_size: int = 100, + ) -> AsyncIterator[dict]: + """Search items with full-featured filtering (`POST /search`). + + Parameters: + collections: Collection IDs to search. + datetime: An RFC 3339 date-time or interval. Required. + bbox: Only return items intersecting this bounding box, in CRS84. + intersects: Only return items intersecting this GeoJSON geometry. + ids: Only return items with these IDs. + fields: Attributes to include in the response, either as a + comma-separated string or as a mapping with `include` and + `exclude` lists, e.g. + `{'include': ['id', 'bbox'], 'exclude': ['geometry']}`. + filter: A CQL2 filter, given as text (e.g. `eo:cloud_cover>90`) or + as a CQL2 JSON mapping. The filterable properties of a + collection are given by + [planet.clients.catalog.CatalogClient.get_collection_queryables][]. + filter_lang: The CQL2 encoding `filter` uses - `cql2-text` or + `cql2-json`. Sent as `filter-lang`. + filter_crs: The CRS used by spatial literals in `filter`. Sent as + `filter-crs`. + distinct: Return the unique values of this property instead of + full item metadata. The yielded values are the property values + themselves rather than STAC Items. + limit: Maximum number of results to return. When set to 0, no + maximum is applied. + page_size: Number of results to fetch per request. The API accepts + 1-100 and defaults to 10. + + Yields: + dict: A STAC Item, or a distinct property value when `distinct` is + given. + + Raises: + APIError: on an API server error. + ClientError: on a client error. + """ + body: Dict[str, Any] = { + 'collections': list(collections), + 'datetime': datetime, + 'limit': page_size, + } + optional: Dict[str, Any] = { + 'bbox': bbox, + 'intersects': intersects, + 'ids': ids, + 'fields': fields, + 'filter': filter, + 'filter-lang': filter_lang, + 'filter-crs': filter_crs, + 'distinct': distinct, + } + body.update({k: v for k, v in optional.items() if v is not None}) + + try: + response = await self._session.request(method='POST', + url=self._search_url, + json=body) + async for item in _CatalogSearchPaged(response, + self._session.request, + url=self._search_url, + body=body, + limit=limit): + yield item + except APIError: + raise + except ClientError: # pragma: no cover + raise + + +__all__ = ['CatalogClient'] diff --git a/planet/constants.py b/planet/constants.py index 288db7cb..6ffe317f 100644 --- a/planet/constants.py +++ b/planet/constants.py @@ -24,6 +24,11 @@ SECRET_FILE_PATH = Path(os.path.expanduser('~')) / '.planet.json' +# The Catalog API is hosted by Sentinel Hub rather than at PLANET_BASE_URL. +SENTINEL_HUB_BASE_URL = 'https://services.sentinel-hub.com' + +SENTINEL_HUB_US_WEST_2_BASE_URL = 'https://services-uswest2.sentinel-hub.com' + # Tool weights define the required processing order for subscription tools _SUBSCRIPTION_TOOL_WEIGHT = { "harmonize": 1, diff --git a/planet/http.py b/planet/http.py index f971bb8d..4b50fffb 100644 --- a/planet/http.py +++ b/planet/http.py @@ -462,12 +462,18 @@ async def stream( await response.aclose() def client(self, - name: Literal['data', 'orders', 'subscriptions'], + name: Literal['catalog', + 'data', + 'destinations', + 'features', + 'mosaics', + 'orders', + 'subscriptions'], base_url: Optional[str] = None) -> object: """Get a client by its module name. Parameters: - name: one of 'data', 'orders', or 'subscriptions'. + name: the module name of a client, e.g. 'data' or 'catalog'. Returns: A client instance. diff --git a/planet/sync/catalog.py b/planet/sync/catalog.py new file mode 100644 index 00000000..42065bc5 --- /dev/null +++ b/planet/sync/catalog.py @@ -0,0 +1,188 @@ +# Copyright 2026 Planet Labs PBC. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. +"""Synchronous Planet Catalog API client.""" + +from typing import Any, Dict, Iterator, List, Optional, Union + +from planet.clients.catalog import CatalogClient +from planet.http import Session + + +class CatalogAPI: + """Catalog API client. + + Note: + Unlike the other Planet APIs, the Catalog API is not served from + `api.planet.com`. It is hosted by Sentinel Hub and authenticates with + an OAuth bearer token, so a plain Planet API key will not work. + + Example: + ```python + >>> from planet import Planet + >>> + >>> pl = Planet() + >>> for item in pl.catalog.search( + ... collections=['sentinel-2-l2a'], + ... datetime='2020-12-10T00:00:00Z/2020-12-30T00:00:00Z', + ... bbox=[13, 45, 14, 46]): + ... print(item['id']) + ``` + """ + + _client: CatalogClient + + def __init__(self, + session: Session, + base_url: Optional[str] = None) -> None: + """ + Parameters: + session: Open session connected to server. + base_url: The base URL to use. Defaults to the production Catalog + API base URL for the `eu-central-1` deployment. + """ + self._client = CatalogClient(session, base_url) + + def get_landing_page(self) -> Dict[str, Any]: + """Get the Catalog API landing page - the root STAC Catalog. + + See [planet.clients.catalog.CatalogClient.get_landing_page][] for + details. + """ + return self._client._call_sync(self._client.get_landing_page()) + + def get_conformance(self) -> Dict[str, Any]: + """Get the specifications this API conforms to. + + See [planet.clients.catalog.CatalogClient.get_conformance][] for + details. + """ + return self._client._call_sync(self._client.get_conformance()) + + def list_collections(self) -> List[Dict[str, Any]]: + """List the collections available to your account. + + See [planet.clients.catalog.CatalogClient.list_collections][] for + details. + """ + return self._client._call_sync(self._client.list_collections()) + + def get_collection(self, collection_id: str) -> Dict[str, Any]: + """Describe a single collection. + + See [planet.clients.catalog.CatalogClient.get_collection][] for + details. + """ + return self._client._call_sync( + self._client.get_collection(collection_id)) + + def get_collection_queryables(self, collection_id: str) -> Dict[str, Any]: + """Get the properties a collection can be filtered on. + + See [planet.clients.catalog.CatalogClient.get_collection_queryables][] + for details. + """ + return self._client._call_sync( + self._client.get_collection_queryables(collection_id)) + + def list_items( + self, + collection_id: str, + bbox: Optional[List[float]] = None, + datetime: Optional[str] = None, + limit: int = 100, + page_size: int = 100, + ) -> Iterator[dict]: + """Iterate over the items in a collection. + + See [planet.clients.catalog.CatalogClient.list_items][] for parameter + details. + """ + return self._client._aiter_to_iter( + self._client.list_items(collection_id, + bbox=bbox, + datetime=datetime, + limit=limit, + page_size=page_size)) + + def get_item(self, collection_id: str, item_id: str) -> Dict[str, Any]: + """Get a single item from a collection. + + See [planet.clients.catalog.CatalogClient.get_item][] for details. + """ + return self._client._call_sync( + self._client.get_item(collection_id, item_id)) + + def simple_search( + self, + collections: List[str], + datetime: str, + bbox: Optional[List[float]] = None, + intersects: Optional[dict] = None, + ids: Optional[List[str]] = None, + fields: Optional[str] = None, + filter: Optional[str] = None, + distinct: Optional[str] = None, + limit: int = 100, + page_size: int = 100, + ) -> Iterator[dict]: + """Search items with simple filtering (`GET /search`). + + See [planet.clients.catalog.CatalogClient.simple_search][] for + parameter details. + """ + return self._client._aiter_to_iter( + self._client.simple_search(collections, + datetime, + bbox=bbox, + intersects=intersects, + ids=ids, + fields=fields, + filter=filter, + distinct=distinct, + limit=limit, + page_size=page_size)) + + def search( + self, + collections: List[str], + datetime: str, + bbox: Optional[List[float]] = None, + intersects: Optional[dict] = None, + ids: Optional[List[str]] = None, + fields: Optional[Union[str, dict]] = None, + filter: Optional[Union[str, dict]] = None, + filter_lang: Optional[str] = None, + filter_crs: Optional[str] = None, + distinct: Optional[str] = None, + limit: int = 100, + page_size: int = 100, + ) -> Iterator[dict]: + """Search items with full-featured filtering (`POST /search`). + + See [planet.clients.catalog.CatalogClient.search][] for parameter + details. + """ + return self._client._aiter_to_iter( + self._client.search(collections, + datetime, + bbox=bbox, + intersects=intersects, + ids=ids, + fields=fields, + filter=filter, + filter_lang=filter_lang, + filter_crs=filter_crs, + distinct=distinct, + limit=limit, + page_size=page_size)) diff --git a/planet/sync/client.py b/planet/sync/client.py index 993b3527..3907de6b 100644 --- a/planet/sync/client.py +++ b/planet/sync/client.py @@ -1,5 +1,6 @@ from typing import Optional +from .catalog import CatalogAPI from .features import FeaturesAPI from .data import DataAPI from .destinations import DestinationsAPI @@ -19,6 +20,7 @@ class Planet: Members: + - `catalog`: Catalog API. - `data`: for interacting with the Planet Data API. - `destinations`: Destinations API. - `orders`: Orders API. @@ -66,3 +68,7 @@ def __init__(self, self._session, f"{planet_base}/subscriptions/v1/") self.features = FeaturesAPI(self._session, f"{planet_base}/features/v1/ogc/my/") + + # The Catalog API is hosted by Sentinel Hub, not at planet_base, so it + # takes its own default base URL rather than a suffix of planet_base. + self.catalog = CatalogAPI(self._session) diff --git a/tests/integration/test_catalog_api.py b/tests/integration/test_catalog_api.py new file mode 100644 index 00000000..ca54bb02 --- /dev/null +++ b/tests/integration/test_catalog_api.py @@ -0,0 +1,543 @@ +# Copyright 2026 Planet Labs PBC. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. +"""Tests of the Planet Catalog API client.""" +from http import HTTPStatus +import json +from typing import Any, Optional +from urllib.parse import parse_qs, urlparse + +import httpx +import pytest +import respx + +from planet import CatalogClient, Session +from planet.auth import Auth +from planet.exceptions import APIError, PagingError +from planet.sync.catalog import CatalogAPI + +pytestmark = pytest.mark.anyio # noqa + +# Simulated host/path for testing purposes. Not a real subdomain. +TEST_URL = "http://test.catalog.com/catalog/v1" +COLLECTIONS_URL = f"{TEST_URL}/collections" +SEARCH_URL = f"{TEST_URL}/search" + +COLLECTION_ID = "sentinel-2-l2a" +DATETIME = "2020-12-10T00:00:00Z/2020-12-30T00:00:00Z" +BBOX = [13.0, 45.0, 14.0, 46.0] + +# Set up shared test clients (mirrors test_features_api.py). +test_session = Session(auth=Auth.from_key(key="test")) +cl_async = CatalogClient(test_session, base_url=TEST_URL) +cl_sync = CatalogAPI(test_session, base_url=TEST_URL) + + +def mock_response(url: str, + json: Any, + method: str = "get", + status_code: int = HTTPStatus.OK): + """Register a single canned response on the respx router.""" + respx.request(method, url).return_value = httpx.Response(status_code, + json=json) + + +def _item(item_id: str) -> dict: + return { + "type": "Feature", + "id": item_id, + "collection": COLLECTION_ID, + "bbox": BBOX, + "properties": { + "datetime": "2020-12-29T10:18:19Z", "eo:cloud_cover": 93.93 + }, + } + + +def _item_collection(start: int, + end: int, + next_url: Optional[str] = None, + next_token: Optional[str] = None) -> dict: + """A STAC ItemCollection page. + + `next_url` adds a `rel: next` link (how the GET endpoints page) and + `next_token` adds a `context.next` token (how POST /search pages). + """ + page: dict = { + "type": "FeatureCollection", + "features": [_item(f"item-{i}") for i in range(start, end)], + "links": [{ + "rel": "self", "href": SEARCH_URL + }], + } + if next_url is not None: + page["links"].append({"rel": "next", "href": next_url}) + page["context"] = {"limit": end - start, "returned": end - start} + if next_token is not None: + page["context"]["next"] = next_token + return page + + +def _request_bodies() -> list: + """The JSON bodies of every POST recorded by respx, in order.""" + return [ + json.loads(call.request.content) for call in respx.calls + if call.request.method == "POST" + ] + + +def _request_params(index: int = 0) -> dict: + """The parsed query string of the nth recorded request.""" + query = urlparse(str(respx.calls[index].request.url)).query + return { + k: v[0] + for k, v in parse_qs(query, keep_blank_values=True).items() + } + + +LANDING_PAGE = { + "type": "Catalog", + "stac_version": "1.0.0", + "id": "sentinel-hub", + "conformsTo": ["https://api.stacspec.org/v1.0.0/core"], + "links": [{ + "rel": "self", "href": TEST_URL + }], +} + +CONFORMANCE = {"conformsTo": ["https://api.stacspec.org/v1.0.0/core"]} + +COLLECTION = { + "type": "Collection", + "id": COLLECTION_ID, + "title": "Sentinel 2 L2A", + "license": "proprietary", +} + +QUERYABLES = { + "$schema": "https://json-schema.org/draft/2019-09/schema", + "type": "object", + "properties": { + "eo:cloud_cover": { + "type": "number", "minimum": 0, "maximum": 100 + } + }, + "additionalProperties": False, +} + + +@respx.mock +async def test_get_landing_page_async(): + mock_response(TEST_URL, LANDING_PAGE) + assert await cl_async.get_landing_page() == LANDING_PAGE + + +@respx.mock +def test_get_landing_page_sync(): + mock_response(TEST_URL, LANDING_PAGE) + assert cl_sync.get_landing_page() == LANDING_PAGE + + +@respx.mock +async def test_get_conformance_async(): + mock_response(f"{TEST_URL}/conformance", CONFORMANCE) + assert await cl_async.get_conformance() == CONFORMANCE + + +@respx.mock +def test_get_conformance_sync(): + mock_response(f"{TEST_URL}/conformance", CONFORMANCE) + assert cl_sync.get_conformance() == CONFORMANCE + + +@respx.mock +async def test_list_collections_unwraps_collections_key(): + mock_response(COLLECTIONS_URL, {"collections": [COLLECTION], "links": []}) + assert await cl_async.list_collections() == [COLLECTION] + + +@respx.mock +def test_list_collections_sync(): + mock_response(COLLECTIONS_URL, {"collections": [COLLECTION], "links": []}) + assert cl_sync.list_collections() == [COLLECTION] + + +@respx.mock +async def test_list_collections_missing_key_returns_empty(): + """A payload without a `collections` key yields an empty list, not a + KeyError.""" + mock_response(COLLECTIONS_URL, {"links": []}) + assert await cl_async.list_collections() == [] + + +@respx.mock +async def test_get_collection_async(): + mock_response(f"{COLLECTIONS_URL}/{COLLECTION_ID}", COLLECTION) + assert await cl_async.get_collection(COLLECTION_ID) == COLLECTION + + +@respx.mock +def test_get_collection_sync(): + mock_response(f"{COLLECTIONS_URL}/{COLLECTION_ID}", COLLECTION) + assert cl_sync.get_collection(COLLECTION_ID) == COLLECTION + + +@respx.mock +async def test_get_collection_queryables_async(): + mock_response(f"{COLLECTIONS_URL}/{COLLECTION_ID}/queryables", QUERYABLES) + result = await cl_async.get_collection_queryables(COLLECTION_ID) + assert result == QUERYABLES + + +@respx.mock +def test_get_collection_queryables_sync(): + mock_response(f"{COLLECTIONS_URL}/{COLLECTION_ID}/queryables", QUERYABLES) + assert cl_sync.get_collection_queryables(COLLECTION_ID) == QUERYABLES + + +@respx.mock +async def test_get_item_async(): + item = _item("item-1") + mock_response(f"{COLLECTIONS_URL}/{COLLECTION_ID}/items/item-1", item) + assert await cl_async.get_item(COLLECTION_ID, "item-1") == item + + +@respx.mock +def test_get_item_sync(): + item = _item("item-1") + mock_response(f"{COLLECTIONS_URL}/{COLLECTION_ID}/items/item-1", item) + assert cl_sync.get_item(COLLECTION_ID, "item-1") == item + + +@respx.mock +async def test_list_items_serializes_params(): + items_url = f"{COLLECTIONS_URL}/{COLLECTION_ID}/items" + mock_response(items_url, _item_collection(0, 2)) + + results = [ + item async for item in cl_async.list_items( + COLLECTION_ID, bbox=BBOX, datetime=DATETIME, page_size=25) + ] + + assert [item["id"] for item in results] == ["item-0", "item-1"] + + params = _request_params() + # `explode: false` - the bbox is one comma-joined value, not repeated. + assert params["bbox"] == "13.0,45.0,14.0,46.0" + assert params["datetime"] == DATETIME + # page_size is sent as the API's `limit`. + assert params["limit"] == "25" + + +@respx.mock +async def test_list_items_omits_unset_params(): + items_url = f"{COLLECTIONS_URL}/{COLLECTION_ID}/items" + mock_response(items_url, _item_collection(0, 1)) + + [item async for item in cl_async.list_items(COLLECTION_ID)] + + params = _request_params() + assert "bbox" not in params + assert "datetime" not in params + + +@respx.mock +async def test_list_items_follows_next_link(): + """GET endpoints page by following the `rel: next` link.""" + items_url = f"{COLLECTIONS_URL}/{COLLECTION_ID}/items" + page_2_url = f"{items_url}?next=1" + + respx.get(items_url, params={ + "limit": "100" + }).return_value = httpx.Response(HTTPStatus.OK, + json=_item_collection( + 0, 2, next_url=page_2_url)) + respx.get(items_url, params={ + "next": "1" + }).return_value = httpx.Response(HTTPStatus.OK, + json=_item_collection(2, 4)) + + results = [item async for item in cl_async.list_items(COLLECTION_ID)] + + assert [item["id"] + for item in results] == ["item-0", "item-1", "item-2", "item-3"] + + +@respx.mock +def test_list_items_sync(): + items_url = f"{COLLECTIONS_URL}/{COLLECTION_ID}/items" + mock_response(items_url, _item_collection(0, 3)) + + results = list(cl_sync.list_items(COLLECTION_ID)) + + assert [item["id"] for item in results] == ["item-0", "item-1", "item-2"] + + +@respx.mock +async def test_list_items_respects_limit(): + """`limit` caps the total number of items yielded, across pages.""" + items_url = f"{COLLECTIONS_URL}/{COLLECTION_ID}/items" + mock_response(items_url, _item_collection(0, 10)) + + results = [ + item async for item in cl_async.list_items(COLLECTION_ID, limit=3) + ] + + assert len(results) == 3 + + +@respx.mock +async def test_simple_search_serializes_params(): + mock_response(SEARCH_URL, _item_collection(0, 1)) + + [ + item async for item in cl_async.simple_search( + collections=[COLLECTION_ID], datetime=DATETIME, bbox=BBOX, + ids=["item-0", "item-1"], fields="id,type,-geometry", + filter="eo:cloud_cover>90", distinct="date", page_size=50) + ] + + params = _request_params() + assert params["collections"] == COLLECTION_ID + assert params["datetime"] == DATETIME + assert params["bbox"] == "13.0,45.0,14.0,46.0" + assert params["ids"] == "item-0,item-1" + assert params["fields"] == "id,type,-geometry" + assert params["filter"] == "eo:cloud_cover>90" + assert params["distinct"] == "date" + assert params["limit"] == "50" + + +@respx.mock +async def test_simple_search_encodes_intersects_as_json(): + geom = {"type": "Point", "coordinates": [13.0, 45.0]} + mock_response(SEARCH_URL, _item_collection(0, 1)) + + [ + item async for item in cl_async.simple_search( + collections=[COLLECTION_ID], datetime=DATETIME, intersects=geom) + ] + + assert json.loads(_request_params()["intersects"]) == geom + + +@respx.mock +def test_simple_search_sync(): + mock_response(SEARCH_URL, _item_collection(0, 2)) + + results = list( + cl_sync.simple_search(collections=[COLLECTION_ID], datetime=DATETIME)) + + assert [item["id"] for item in results] == ["item-0", "item-1"] + + +@respx.mock +async def test_search_builds_body(): + mock_response(SEARCH_URL, _item_collection(0, 1), method="post") + + [ + item async for item in cl_async.search( + collections=[COLLECTION_ID], datetime=DATETIME, bbox=BBOX, + ids=["item-0"], fields={ + "include": ["id", "bbox"], "exclude": ["geometry"] + }, filter={ + "op": ">", "args": [{ + "property": "eo:cloud_cover" + }, 90] + }, filter_lang="cql2-json", + filter_crs="http://www.opengis.net/def/crs/OGC/1.3/CRS84", + distinct="date", page_size=50) + ] + + body = _request_bodies()[0] + assert body["collections"] == [COLLECTION_ID] + assert body["datetime"] == DATETIME + # Unlike the GET variant, POST sends native JSON types. + assert body["bbox"] == BBOX + assert body["ids"] == ["item-0"] + assert body["fields"] == { + "include": ["id", "bbox"], "exclude": ["geometry"] + } + assert body["distinct"] == "date" + assert body["limit"] == 50 + # The spec names these with hyphens, not underscores. + assert body["filter-lang"] == "cql2-json" + assert body["filter-crs"] == "http://www.opengis.net/def/crs/OGC/1.3/CRS84" + assert "filter_lang" not in body + assert "filter_crs" not in body + + +@respx.mock +async def test_search_omits_unset_fields(): + mock_response(SEARCH_URL, _item_collection(0, 1), method="post") + + [ + item async for item in cl_async.search(collections=[COLLECTION_ID], + datetime=DATETIME) + ] + + body = _request_bodies()[0] + assert set(body) == {"collections", "datetime", "limit"} + + +@respx.mock +async def test_search_pages_with_context_next_token(): + """POST /search pages by re-sending the query with a `next` token.""" + respx.post(SEARCH_URL).side_effect = [ + httpx.Response(HTTPStatus.OK, + json=_item_collection(0, 2, next_token="2")), + httpx.Response(HTTPStatus.OK, + json=_item_collection(2, 4, next_token="4")), + httpx.Response(HTTPStatus.OK, json=_item_collection(4, 5)), + ] + + results = [ + item async for item in cl_async.search(collections=[COLLECTION_ID], + datetime=DATETIME, bbox=BBOX) + ] + + assert [item["id"] for item in results + ] == ["item-0", "item-1", "item-2", "item-3", "item-4"] + + bodies = _request_bodies() + assert len(bodies) == 3 + # The first request carries no token; later ones repeat the original + # query with `next` added. + assert "next" not in bodies[0] + assert bodies[1]["next"] == "2" + assert bodies[2]["next"] == "4" + assert bodies[2]["bbox"] == BBOX + assert bodies[2]["collections"] == [COLLECTION_ID] + + +@respx.mock +async def test_search_respects_limit_and_stops_paging(): + respx.post(SEARCH_URL).side_effect = [ + httpx.Response(HTTPStatus.OK, + json=_item_collection(0, 2, next_token="2")), + httpx.Response(HTTPStatus.OK, + json=_item_collection(2, 4, next_token="4")), + ] + + results = [ + item async for item in cl_async.search(collections=[COLLECTION_ID], + datetime=DATETIME, limit=3) + ] + + assert len(results) == 3 + + +@respx.mock +async def test_search_raises_on_page_cycle(): + """A server that echoes the same token must not loop forever.""" + respx.post(SEARCH_URL).side_effect = [ + httpx.Response(HTTPStatus.OK, + json=_item_collection(0, 2, next_token="2")), + httpx.Response(HTTPStatus.OK, + json=_item_collection(2, 4, next_token="2")), + ] + + with pytest.raises(PagingError): + [ + item async for item in cl_async.search(collections=[COLLECTION_ID], + datetime=DATETIME, limit=0) + ] + + +@respx.mock +async def test_search_distinct_yields_values(): + """With `distinct`, `features` holds property values rather than items.""" + page = { + "type": "FeatureCollection", + "features": ["2020-12-29", "2020-12-27"], + "links": [], + "context": { + "returned": 2 + }, + } + mock_response(SEARCH_URL, page, method="post") + + results = [ + item async for item in cl_async.search( + collections=[COLLECTION_ID], datetime=DATETIME, distinct="date") + ] + + assert results == ["2020-12-29", "2020-12-27"] + + +@respx.mock +def test_search_sync(): + mock_response(SEARCH_URL, _item_collection(0, 2), method="post") + + results = list( + cl_sync.search(collections=[COLLECTION_ID], datetime=DATETIME)) + + assert [item["id"] for item in results] == ["item-0", "item-1"] + + +async def _consume(result): + """Await a coroutine, or drain an async iterator.""" + if hasattr(result, "__aiter__"): + return [item async for item in result] + return await result + + +@pytest.mark.parametrize( + "url, method, call", + [ + (TEST_URL, "get", lambda: cl_async.get_landing_page()), + (f"{TEST_URL}/conformance", "get", lambda: cl_async.get_conformance()), + (COLLECTIONS_URL, "get", lambda: cl_async.list_collections()), + (f"{COLLECTIONS_URL}/{COLLECTION_ID}", + "get", lambda: cl_async.get_collection(COLLECTION_ID)), + (f"{COLLECTIONS_URL}/{COLLECTION_ID}/queryables", + "get", lambda: cl_async.get_collection_queryables(COLLECTION_ID)), + (f"{COLLECTIONS_URL}/{COLLECTION_ID}/items", + "get", lambda: cl_async.list_items(COLLECTION_ID)), + (f"{COLLECTIONS_URL}/{COLLECTION_ID}/items/item-1", + "get", lambda: cl_async.get_item(COLLECTION_ID, "item-1")), + (SEARCH_URL, + "get", lambda: cl_async.simple_search([COLLECTION_ID], DATETIME)), + (SEARCH_URL, + "post", lambda: cl_async.search([COLLECTION_ID], DATETIME)), + ]) +@respx.mock +async def test_api_errors_propagate(url, method, call): + """Every method surfaces a server error rather than swallowing it. + + This matters most for the iterating methods, where the request is made + inside an async generator and the error has to travel out through the + `async for`. + """ + mock_response(url, {"code": 500}, + method=method, + status_code=HTTPStatus.INTERNAL_SERVER_ERROR) + + with pytest.raises(APIError): + await _consume(call()) + + +def test_default_base_url_is_sentinel_hub(): + """The Catalog API is not hosted at api.planet.com.""" + from planet.clients.catalog import BASE_URL, US_WEST_2_BASE_URL + + assert BASE_URL == "https://services.sentinel-hub.com/catalog/v1" + assert US_WEST_2_BASE_URL == ( + "https://services-uswest2.sentinel-hub.com/catalog/v1") + assert CatalogClient(test_session)._base_url == BASE_URL + + +def test_base_url_trailing_slash_is_stripped(): + cl = CatalogClient(test_session, base_url=f"{TEST_URL}/") + assert cl._base_url == TEST_URL + assert cl._search_url == SEARCH_URL diff --git a/tests/integration/test_catalog_cli.py b/tests/integration/test_catalog_cli.py new file mode 100644 index 00000000..e615bc12 --- /dev/null +++ b/tests/integration/test_catalog_cli.py @@ -0,0 +1,310 @@ +# Copyright 2026 Planet Labs PBC. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. +"""Tests of the planet catalog CLI.""" +import json +from http import HTTPStatus + +import httpx +import respx +from click.testing import CliRunner + +from planet.cli import cli + +from tests.integration.test_catalog_api import ( + BBOX, + COLLECTION, + COLLECTION_ID, + COLLECTIONS_URL, + CONFORMANCE, + DATETIME, + LANDING_PAGE, + QUERYABLES, + SEARCH_URL, + TEST_URL, + _item, + _item_collection, + _request_bodies, + _request_params, + mock_response, +) + + +def invoke(*args, input=None): + runner = CliRunner() + full_args = ["catalog", "--base-url", TEST_URL] + list(args) + result = runner.invoke(cli.main, args=full_args, input=input) + assert result.exit_code == 0, result.output + return result + + +def _parse_json_lines(output: str): + """`echo_json` prints one JSON document per line - parse them all.""" + return [json.loads(line) for line in output.splitlines() if line.strip()] + + +@respx.mock +def test_cli_landing_page(): + mock_response(TEST_URL, LANDING_PAGE) + result = invoke("landing-page") + assert json.loads(result.output) == LANDING_PAGE + + +@respx.mock +def test_cli_conformance(): + mock_response(f"{TEST_URL}/conformance", CONFORMANCE) + result = invoke("conformance") + assert json.loads(result.output) == CONFORMANCE + + +@respx.mock +def test_cli_collections_list(): + mock_response(COLLECTIONS_URL, {"collections": [COLLECTION], "links": []}) + result = invoke("collections", "list") + assert json.loads(result.output) == [COLLECTION] + + +@respx.mock +def test_cli_collections_get(): + mock_response(f"{COLLECTIONS_URL}/{COLLECTION_ID}", COLLECTION) + result = invoke("collections", "get", COLLECTION_ID) + assert json.loads(result.output) == COLLECTION + + +@respx.mock +def test_cli_collections_queryables(): + mock_response(f"{COLLECTIONS_URL}/{COLLECTION_ID}/queryables", QUERYABLES) + result = invoke("collections", "queryables", COLLECTION_ID) + assert json.loads(result.output) == QUERYABLES + + +@respx.mock +def test_cli_items_get(): + item = _item("item-1") + mock_response(f"{COLLECTIONS_URL}/{COLLECTION_ID}/items/item-1", item) + result = invoke("items", "get", COLLECTION_ID, "item-1") + assert json.loads(result.output) == item + + +@respx.mock +def test_cli_items_list(): + items_url = f"{COLLECTIONS_URL}/{COLLECTION_ID}/items" + mock_response(items_url, _item_collection(0, 3)) + + result = invoke("items", + "list", + COLLECTION_ID, + "--bbox", + "13,45,14,46", + "--datetime", + DATETIME, + "--page-size", + "25") + + items = _parse_json_lines(result.output) + assert [item["id"] for item in items] == ["item-0", "item-1", "item-2"] + + params = _request_params() + assert params["bbox"] == "13.0,45.0,14.0,46.0" + assert params["datetime"] == DATETIME + assert params["limit"] == "25" + + +@respx.mock +def test_cli_items_list_respects_limit(): + items_url = f"{COLLECTIONS_URL}/{COLLECTION_ID}/items" + mock_response(items_url, _item_collection(0, 10)) + + result = invoke("items", "list", COLLECTION_ID, "--limit", "2") + + assert len(_parse_json_lines(result.output)) == 2 + + +@respx.mock +def test_cli_simple_search(): + mock_response(SEARCH_URL, _item_collection(0, 2)) + + result = invoke("simple-search", + "--collections", + COLLECTION_ID, + "--datetime", + DATETIME, + "--bbox", + "13,45,14,46", + "--filter", + "eo:cloud_cover>90", + "--fields", + "id,type,-geometry") + + items = _parse_json_lines(result.output) + assert [item["id"] for item in items] == ["item-0", "item-1"] + + params = _request_params() + assert params["collections"] == COLLECTION_ID + assert params["filter"] == "eo:cloud_cover>90" + assert params["fields"] == "id,type,-geometry" + + +@respx.mock +def test_cli_simple_search_distinct(): + page = { + "type": "FeatureCollection", + "features": ["2020-12-29", "2020-12-27"], + "links": [], + "context": { + "returned": 2 + }, + } + mock_response(SEARCH_URL, page) + + result = invoke("simple-search", + "--collections", + COLLECTION_ID, + "--datetime", + DATETIME, + "--distinct", + "date") + + assert _parse_json_lines(result.output) == ["2020-12-29", "2020-12-27"] + assert _request_params()["distinct"] == "date" + + +@respx.mock +def test_cli_search_sends_post_body(): + mock_response(SEARCH_URL, _item_collection(0, 2), method="post") + + result = invoke("search", + "--collections", + f"{COLLECTION_ID},sentinel-2-l1c", + "--datetime", + DATETIME, + "--bbox", + "13,45,14,46", + "--ids", + "item-0,item-1", + "--filter", + "eo:cloud_cover>90", + "--filter-lang", + "cql2-text", + "--page-size", + "50") + + items = _parse_json_lines(result.output) + assert [item["id"] for item in items] == ["item-0", "item-1"] + + body = _request_bodies()[0] + assert body["collections"] == [COLLECTION_ID, "sentinel-2-l1c"] + assert body["datetime"] == DATETIME + assert body["bbox"] == BBOX + assert body["ids"] == ["item-0", "item-1"] + assert body["filter"] == "eo:cloud_cover>90" + assert body["filter-lang"] == "cql2-text" + assert body["limit"] == 50 + + +@respx.mock +def test_cli_search_parses_cql2_json_filter(): + """With --filter-lang cql2-json the filter is sent as JSON, not a string.""" + mock_response(SEARCH_URL, _item_collection(0, 1), method="post") + + cql2 = {"op": ">", "args": [{"property": "eo:cloud_cover"}, 90]} + invoke("search", + "--collections", + COLLECTION_ID, + "--datetime", + DATETIME, + "--filter", + json.dumps(cql2), + "--filter-lang", + "cql2-json") + + body = _request_bodies()[0] + assert body["filter"] == cql2 + assert body["filter-lang"] == "cql2-json" + + +@respx.mock +def test_cli_search_fields_object(): + mock_response(SEARCH_URL, _item_collection(0, 1), method="post") + + fields = {"include": ["id", "bbox"], "exclude": ["geometry"]} + invoke("search", + "--collections", + COLLECTION_ID, + "--datetime", + DATETIME, + "--fields", + json.dumps(fields)) + + assert _request_bodies()[0]["fields"] == fields + + +@respx.mock +def test_cli_search_intersects(): + mock_response(SEARCH_URL, _item_collection(0, 1), method="post") + + geom = {"type": "Point", "coordinates": [13.0, 45.0]} + invoke("search", + "--collections", + COLLECTION_ID, + "--datetime", + DATETIME, + "--intersects", + json.dumps(geom)) + + assert _request_bodies()[0]["intersects"] == geom + + +@respx.mock +def test_cli_search_pages(): + respx.post(SEARCH_URL).side_effect = [ + httpx.Response(HTTPStatus.OK, + json=_item_collection(0, 2, next_token="2")), + httpx.Response(HTTPStatus.OK, json=_item_collection(2, 3)), + ] + + result = invoke("search", + "--collections", + COLLECTION_ID, + "--datetime", + DATETIME) + + items = _parse_json_lines(result.output) + assert [item["id"] for item in items] == ["item-0", "item-1", "item-2"] + + +def test_cli_search_requires_collections_and_datetime(): + """The spec marks both as required; click should enforce that.""" + runner = CliRunner() + result = runner.invoke(cli.main, args=["catalog", "search"]) + assert result.exit_code != 0 + assert "--collections" in result.output + + +@respx.mock +def test_cli_api_error_is_translated(): + mock_response(f"{COLLECTIONS_URL}/{COLLECTION_ID}", {"code": 404}, + status_code=HTTPStatus.NOT_FOUND) + + runner = CliRunner() + result = runner.invoke(cli.main, + args=[ + "catalog", + "--base-url", + TEST_URL, + "collections", + "get", + COLLECTION_ID + ]) + + assert result.exit_code != 0