From 5ff756936d28c47f275322bb72d0a2c6660c01d2 Mon Sep 17 00:00:00 2001 From: Ian Schneider Date: Mon, 12 May 2025 13:59:08 -0600 Subject: [PATCH 1/4] initial mosaics cli + async client --- docs/python/sdk-reference.md | 4 + examples/mosaics-cli.sh | 16 ++ planet/__init__.py | 3 +- planet/cli/cli.py | 2 + planet/cli/mosaics.py | 279 ++++++++++++++++++++++++ planet/cli/types.py | 11 + planet/clients/__init__.py | 8 +- planet/clients/mosaics.py | 397 +++++++++++++++++++++++++++++++++++ 8 files changed, 718 insertions(+), 2 deletions(-) create mode 100755 examples/mosaics-cli.sh create mode 100644 planet/cli/mosaics.py create mode 100644 planet/clients/mosaics.py diff --git a/docs/python/sdk-reference.md b/docs/python/sdk-reference.md index dee669b3..f1bf5257 100644 --- a/docs/python/sdk-reference.md +++ b/docs/python/sdk-reference.md @@ -10,6 +10,10 @@ title: Python SDK API Reference rendering: show_root_full_path: false +## ::: planet.MosaicsClient + rendering: + show_root_full_path: false + ## ::: planet.OrdersClient rendering: show_root_full_path: false diff --git a/examples/mosaics-cli.sh b/examples/mosaics-cli.sh new file mode 100755 index 00000000..91213511 --- /dev/null +++ b/examples/mosaics-cli.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +echo -e "Global Basemap Series" +planet mosaics series list --name-contains=Global | jq .[].name + +echo -e "\nLatest Global Monthly" +planet mosaics series list-mosaics "Global Monthly" --latest --pretty + +echo -e "\nHow Many Quads?" +planet mosaics search 09462e5a-2af0-4de3-a710-e9010d8d4e58 --bbox=-100,40,-100,40.1 + +echo -e "\nWhat Scenes Contributed to Quad?" +planet mosaics contributions 09462e5a-2af0-4de3-a710-e9010d8d4e58 455-1273 + +echo -e "\nDownload Them!" +planet mosaics download 09462e5a-2af0-4de3-a710-e9010d8d4e58 --bbox=-100,40,-100,40.1 --output-dir=quads \ No newline at end of file diff --git a/planet/__init__.py b/planet/__init__.py index fe2729fe..a7639b00 100644 --- a/planet/__init__.py +++ b/planet/__init__.py @@ -16,7 +16,7 @@ from . import data_filter, order_request, reporting, subscription_request from .__version__ import __version__ # NOQA from .auth import Auth -from .clients import DataClient, FeaturesClient, OrdersClient, SubscriptionsClient # NOQA +from .clients import DataClient, FeaturesClient, MosaicsClient, OrdersClient, SubscriptionsClient # NOQA from .io import collect from .sync import Planet @@ -26,6 +26,7 @@ 'DataClient', 'data_filter', 'FeaturesClient', + 'MosaicsClient', 'OrdersClient', 'order_request', 'Planet', diff --git a/planet/cli/cli.py b/planet/cli/cli.py index 328cf4f9..98571ec1 100644 --- a/planet/cli/cli.py +++ b/planet/cli/cli.py @@ -19,6 +19,7 @@ import click import planet +from planet.cli import mosaics from . import auth, collect, data, orders, subscriptions, features @@ -79,3 +80,4 @@ def _configure_logging(verbosity): main.add_command(subscriptions.subscriptions) # type: ignore main.add_command(collect.collect) # type: ignore main.add_command(features.features) +main.add_command(mosaics.mosaics) diff --git a/planet/cli/mosaics.py b/planet/cli/mosaics.py new file mode 100644 index 00000000..165b9481 --- /dev/null +++ b/planet/cli/mosaics.py @@ -0,0 +1,279 @@ +import asyncio +from contextlib import asynccontextmanager + +import click + +from planet.cli.cmds import command +from planet.cli.io import echo_json +from planet.cli.session import CliSession +from planet.cli.types import BoundingBox, DateTime, Geometry +from planet.cli.validators import check_geom +from planet.clients.mosaics import MosaicsClient + + +@asynccontextmanager +async def client(ctx): + async with CliSession() as sess: + cl = MosaicsClient(sess, base_url=ctx.obj['BASE_URL']) + yield cl + + +include_links = click.option("--links", + is_flag=True, + help=("If enabled, include API links")) + +name_contains = click.option( + "--name-contains", + type=str, + help=("Match if the name contains text, case-insensitive")) + +bbox = click.option('--bbox', + type=BoundingBox(), + help=("Region to download as comma-delimited strings: " + " lon_min,lat_min,lon_max,lat_max")) + +interval = click.option("--interval", + type=str, + help=("Match this interval, e.g. 1 mon")) + +acquired_gt = click.option("--acquired_gt", + type=DateTime(), + help=("Imagery acquisition after than this date")) + +acquired_lt = click.option("--acquired_lt", + type=DateTime(), + help=("Imagery acquisition before than this date")) + +geometry = click.option('--geometry', + type=Geometry(), + callback=check_geom, + help=("A geojson geometry to search with. " + "Can be a string, filename, or - for stdin.")) + + +def _strip_links(resource): + if isinstance(resource, dict): + resource.pop("_links", None) + return resource + + +async def _output(result, pretty, include_links=False): + if asyncio.iscoroutine(result): + result = await result + if result is None: + raise click.ClickException("not found") + if not include_links: + _strip_links(result) + echo_json(result, pretty) + else: + results = [_strip_links(r) async for r in result] + echo_json(results, pretty) + + +@click.group() # type: ignore +@click.pass_context +@click.option('-u', + '--base-url', + default=None, + help='Assign custom base Mosaics API URL.') +def mosaics(ctx, base_url): + """Commands for interacting with the Mosaics API""" + ctx.obj['BASE_URL'] = base_url + + +@mosaics.group() # type: ignore +def series(): + """Commands for interacting with Mosaic Series through the Mosaics API""" + + +@command(mosaics, name="contributions") +@click.argument("name") +@click.argument("quad") +async def quad_contributions(ctx, name, quad, pretty): + '''Get contributing scenes for a mosaic quad + + Example: + + planet mosaics contribution global_monthly_2025_04_mosaic 575-1300 + ''' + async with client(ctx) as cl: + item = await cl.get_quad(name, quad) + await _output(cl.get_quad_contributions(item), pretty) + + +@command(mosaics, name="info") +@click.argument("name", required=True) +@include_links +async def mosaic_info(ctx, name, pretty, links): + """Get information for a specific mosaic + + Example: + + planet mosaics info global_monthly_2025_04_mosaic + """ + async with client(ctx) as cl: + await _output(cl.get_mosaic(name), pretty, links) + + +@command(mosaics, name="list") +@name_contains +@interval +@acquired_gt +@acquired_lt +@include_links +async def mosaics_list(ctx, + name_contains, + interval, + acquired_gt, + acquired_lt, + pretty, + links): + """List all mosaics + + Example: + + planet mosaics info global_monthly_2025_04_mosaic + """ + async with client(ctx) as cl: + await _output( + cl.list_mosaics(name_contains=name_contains, + interval=interval, + acquired_gt=acquired_gt, + acquired_lt=acquired_lt), + pretty, + links) + + +@command(series, name="info") +@click.argument("name", required=True) +@include_links +async def series_info(ctx, name, pretty, links): + """Get information for a specific series + + Example: + + planet series info "Global Quarterly" + """ + async with client(ctx) as cl: + await _output(cl.get_series(name), pretty, links) + + +@command(series, name="list") +@name_contains +@interval +@acquired_gt +@acquired_lt +@include_links +async def series_list(ctx, + name_contains, + interval, + acquired_gt, + acquired_lt, + pretty, + links): + """List series + + Example: + + planet mosaics series list --name-contains=Global + """ + async with client(ctx) as cl: + await _output( + cl.list_series( + name_contains, + interval, + acquired_gt, + acquired_lt, + ), + pretty, + links) + + +@command(series, name="list-mosaics") +@click.argument("name", required=True) +@click.option("--latest", + is_flag=True, + help=("Get the latest mosaic in the series")) +@acquired_gt +@acquired_lt +@include_links +async def list_series_mosaics(ctx, + name, + acquired_gt, + acquired_lt, + latest, + links, + pretty): + """List mosaics in a series + + Example: + + planet mosaics series list-mosaics global_monthly_2025_04_mosaic + """ + async with client(ctx) as cl: + await _output( + cl.list_series_mosaics(name, + acquired_gt=acquired_gt, + acquired_lt=acquired_lt, + latest=latest), + pretty, + links) + + +@command(mosaics, name="search") +@click.argument("name", required=True) +@bbox +@geometry +@click.option("--summary", + is_flag=True, + help=("Get a count of how many quads would be returned")) +@include_links +async def list_quads(ctx, name, bbox, geometry, summary, links, pretty): + """Search quads + + Example: + + planet mosaics search global_monthly_2025_04_mosaic --bbox -100,40,-100,41 + """ + async with client(ctx) as cl: + mosaic = await cl.get_mosaic(name) + if mosaic is None: + raise click.ClickException("No mosaic named " + name) + await _output( + cl.list_quads(mosaic, + minimal=False, + bbox=bbox, + geometry=geometry, + summary=summary), + pretty, + links) + + +@command(mosaics, name="download") +@click.argument("name", required=True) +@click.option('--output-dir', + default='.', + help=('Directory for file download.'), + type=click.Path(exists=True, + resolve_path=True, + writable=True, + file_okay=False)) +@bbox +@geometry +async def download(ctx, name, output_dir, bbox, geometry, **kwargs): + """Download quads from a mosaic + + Example: + + planet mosaics search global_monthly_2025_04_mosaic --bbox -100,40,-100,41 + """ + quiet = ctx.obj['QUIET'] + async with client(ctx) as cl: + mosaic = await cl.get_mosaic(name) + if mosaic is None: + raise click.ClickException("No mosaic named " + name) + await cl.download_quads(mosaic, + bbox=bbox, + geometry=geometry, + directory=output_dir, + progress_bar=not quiet) diff --git a/planet/cli/types.py b/planet/cli/types.py index 6032fe70..c3168ea5 100644 --- a/planet/cli/types.py +++ b/planet/cli/types.py @@ -140,3 +140,14 @@ def convert(self, value, param, ctx) -> datetime: self.fail(str(e)) return value + + +class BoundingBox(click.ParamType): + name = 'bbox' + + def convert(self, val, param, ctx): + try: + xmin, ymin, xmax, ymax = map(float, val.split(',')) + except (TypeError, ValueError): + raise click.BadParameter('Invalid bounding box') + return (xmin, ymin, xmax, ymax) diff --git a/planet/clients/__init__.py b/planet/clients/__init__.py index b1304d8d..7cbd0467 100644 --- a/planet/clients/__init__.py +++ b/planet/clients/__init__.py @@ -14,17 +14,23 @@ # limitations under the License. from .data import DataClient from .features import FeaturesClient +from .mosaics import MosaicsClient from .orders import OrdersClient from .subscriptions import SubscriptionsClient __all__ = [ - 'DataClient', 'FeaturesClient', 'OrdersClient', 'SubscriptionsClient' + 'DataClient', + 'FeaturesClient', + 'MosaicsClient', + 'OrdersClient', + 'SubscriptionsClient' ] # Organize client classes by their module name to allow lookup. _client_directory = { 'data': DataClient, 'features': FeaturesClient, + 'mosaics': MosaicsClient, 'orders': OrdersClient, 'subscriptions': SubscriptionsClient } diff --git a/planet/clients/mosaics.py b/planet/clients/mosaics.py new file mode 100644 index 00000000..fb813ebc --- /dev/null +++ b/planet/clients/mosaics.py @@ -0,0 +1,397 @@ +import asyncio +from pathlib import Path +from typing import AsyncIterator, Awaitable, Optional, Tuple, Type, TypeVar, Union, cast +from planet.constants import PLANET_BASE_URL +from planet.http import Session +from planet.models import Paged, Response, StreamingBody +from uuid import UUID + +BASE_URL = f'{PLANET_BASE_URL}/basemaps/v1' + +T = TypeVar("T") + +Number = Union[int, float] + +BBox = Tuple[Number, Number, Number, Number] + + +class Series(Paged): + ITEMS_KEY = 'series' + NEXT_KEY = '_next' + + +class Mosaics(Paged): + ITEMS_KEY = 'mosaics' + NEXT_KEY = '_next' + + +class MosaicQuads(Paged): + ITEMS_KEY = 'items' + NEXT_KEY = '_next' + + +def _is_uuid(val: str) -> bool: + try: + UUID(val) + return True + except ValueError: + return False + + +class MosaicsClient: + """High-level asynchronous access to Planet's Mosaics API. + + Example: + ```python + >>> import asyncio + >>> from planet import Session + >>> + >>> async def main(): + ... async with Session() as sess: + ... cl = sess.client('data') + ... # use client here + ... + >>> asyncio.run(main()) + ``` + """ + + def __init__(self, session: Session, base_url: Optional[str] = None): + """ + Parameters: + session: Open session connected to server. + base_url: The base URL to use. Defaults to production orders API + base url. + """ + self._session = session + + self._base_url = base_url or BASE_URL + if self._base_url.endswith('/'): + self._base_url = self._base_url[:-1] + + def _call_sync(self, f: Awaitable[T]) -> T: + """block on an async function call, using the call_sync method of the session""" + return self._session._call_sync(f) + + def _url(self, path: str) -> str: + return f"{BASE_URL}/{path}" + + async def _get_by_name(self, path: str, pager: Type[Paged], + name: str) -> Optional[dict]: + response = await self._session.request( + method='GET', + url=self._url(path), + params={ + "name__is": name, + }, + ) + listing = response.json()[pager.ITEMS_KEY] + return listing[0] if listing else None + + async def _get_by_id(self, path: str, id: str) -> dict: + response = await self._session.request(method="GET", + url=self._url(f"{path}/{id}")) + return response.json() + + async def _get(self, name_or_id: str, path: str, + pager: Type[Paged]) -> Optional[dict]: + if _is_uuid(name_or_id): + return await self._get_by_id(path, name_or_id) + return await self._get_by_name(path, pager, name_or_id) + + async def get_mosaic(self, name_or_id: str) -> Optional[dict]: + """Get the API representation of a mosaic by name or id. + + :param name str: The name or id of the mosaic + :returns: dict or None (if searching by name) + :raises planet.api.exceptions.APIException: On API error. + """ + return await self._get(name_or_id, "mosaics", Mosaics) + + async def get_series(self, name_or_id: str) -> Optional[dict]: + """Get the API representation of a series by name or id. + + :param name str: The name or id of the series + :returns: dict or None (if searching by name) + :raises planet.api.exceptions.APIException: On API error. + """ + return await self._get(name_or_id, "series", Series) + + async def list_series( + self, + name_contains: Optional[str] = None, + interval: Optional[str] = None, + acquired_gt: Optional[str] = None, + acquired_lt: Optional[str] = None) -> AsyncIterator[dict]: + """ + List the series you have access to. + + Example: + + ``` + series = await client.list_series() + async for s in series: + print(s) + ``` + """ + params = {} + if name_contains: + params["name__contains"] = name_contains + if interval: + params["interval"] = interval + if acquired_gt: + params["acquired__gt"] = acquired_gt + if acquired_lt: + params["acquired__lt"] = acquired_lt + resp = await self._session.request( + method='GET', + url=self._url("series"), + params=params, + ) + async for item in Series(resp, self._session.request): + yield item + + async def list_mosaics( + self, + name_contains: Optional[str] = None, + interval: Optional[str] = None, + acquired_gt: Optional[str] = None, + acquired_lt: Optional[str] = None, + latest: bool = False, + ) -> AsyncIterator[dict]: + """ + List the mosaics you have access to. + + Example: + + ``` + mosaics = await client.list_mosacis() + async for m in mosaics: + print(m) + ``` + """ + params = {} + if name_contains: + params["name__contains"] = name_contains + if interval: + params["interval"] = interval + if acquired_gt: + params["acquired__gt"] = acquired_gt + if acquired_lt: + params["acquired__lt"] = acquired_lt + if latest: + params["latest"] = "yes" + resp = await self._session.request( + method='GET', + url=self._url("mosaics"), + params=params, + ) + async for item in Mosaics(resp, self._session.request): + yield item + + async def list_series_mosaics( + self, + name_or_id: str, + acquired_gt: Optional[str] = None, + acquired_lt: Optional[str] = None, + latest: bool = False, + ) -> AsyncIterator[dict]: + """ + List the mosaics in a series. + + Example: + + ``` + mosaics = await client.list_series_mosaics("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5") + async for m in mosaics: + print(m) + ``` + """ + if not _is_uuid(name_or_id): + series = await self._get_by_name("series", Series, name_or_id) + if series is None: + return + name_or_id = series["id"] + params = {} + if acquired_gt: + params["acquired__gt"] = acquired_gt + if acquired_lt: + params["acquired__lt"] = acquired_lt + if latest: + params["latest"] = "yes" + resp = await self._session.request( + method="GET", + url=self._url(f"series/{name_or_id}/mosaics"), + params=params, + ) + async for item in Mosaics(resp, self._session.request): + yield item + + async def list_quads(self, + mosaic: dict, + minimal: bool = False, + bbox: Optional[BBox] = None, + geometry: Optional[dict] = None, + summary: bool = False) -> AsyncIterator[dict]: + """ + List the a mosaic's quads. + + Example: + + ``` + mosaic = await client.get_mosaic("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5") + quads = await client.list_quads(mosaic) + async for q in quads: + print(q) + ``` + """ + if geometry: + resp = await self._quads_geometry(mosaic, + geometry, + minimal, + summary) + else: + if bbox is None: + xmin, ymin, xmax, ymax = cast(BBox, mosaic['bbox']) + search = (max(-180, xmin), + max(-85, ymin), + min(180, xmax), + min(85, ymax)) + else: + search = bbox + resp = await self._quads_bbox(mosaic, search, minimal, summary) + # kinda yucky - yields a different "shaped" dict + if summary: + yield resp.json()["summary"] + return + async for item in MosaicQuads(resp, self._session.request): + yield item + + async def _quads_geometry(self, + mosaic: dict, + geometry: dict, + minimal: bool, + summary: bool) -> Response: + params = {} + if minimal: + params["minimal"] = "true" + if summary: + params["summary"] = "true" + mosaic_id = mosaic["id"] + return await self._session.request( + method="POST", + url=self._url(f"mosaics/{mosaic_id}/quads/search"), + params=params, + json=geometry, + ) + + async def _quads_bbox(self, + mosaic: dict, + bbox: BBox, + minimal: bool, + summary: bool) -> Response: + quads_template = mosaic["_links"]["quads"] + # this is fully qualified URL, so don't use self._url + url = quads_template.replace("{lx},{ly},{ux},{uy}", + ",".join([str(f) for f in bbox])) + # params will overwrite the templated query + if minimal: + url += "&minimal=true" + if summary: + url += "&summary=true" + return await self._session.request( + method="GET", + url=url, + ) + + async def get_quad(self, name_or_id: str, quad_id: str) -> dict: + """ + Get a mosaic's quad information. + + Example: + + ``` + quad = await client.get_quad("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5", "1234-5678") + print(quad) + ``` + """ + if not _is_uuid(name_or_id): + mosaic = await self.get_mosaic(name_or_id) + if mosaic is None: + return {} + name_or_id = cast(str, mosaic["id"]) + resp = await self._session.request( + method="GET", + url=self._url(f"mosaics/{name_or_id}/quads/{quad_id}"), + ) + return resp.json() + + async def get_quad_contributions(self, quad: dict) -> list[dict]: + """ + Get a mosaic's quad information. + + Example: + + ``` + quad = await client.get_quad("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5", "1234-5678") + contributions = await client.get_quad_contributions(quad) + print(contributions) + ``` + """ + resp = await self._session.request( + "GET", + quad["_links"]["items"], + ) + return resp.json()["items"] + + async def download_quad(self, + quad: dict, + directory, + overwrite: bool = False, + progress_bar=False): + url = quad["_links"]["download"] + Path(directory).mkdir(exist_ok=True, parents=True) + async with self._session.stream(method='GET', url=url) as resp: + body = StreamingBody(resp) + dest = Path(directory, body.name) + await body.write(dest, + overwrite=overwrite, + progress_bar=progress_bar) + """ + Download a quad to a directory. + + Example: + + ``` + quad = await client.get_quad("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5", "1234-5678") + await client.download_quad(quad, ".") + ``` + """ + + async def download_quads(self, + mosaic: dict, + directory: str, + bbox: Optional[BBox] = None, + geometry: Optional[dict] = None, + progress_bar: bool = False, + concurrency: int = 4): + """ + Download a mosaics' quads to a directory. + + Example: + + ``` + mosaic = await cl.get_mosaic(name) + client.download_quads(mosaic, '.', bbox=(-100, 40, -100, 41)) + ``` + """ + jobs = [] + async for q in self.list_quads(mosaic, + minimal=True, + bbox=bbox, + geometry=geometry): + jobs.append( + self.download_quad(q, directory, progress_bar=progress_bar)) + if len(jobs) == concurrency: + await asyncio.gather(*jobs) + jobs = [] + await asyncio.gather(*jobs) From a67216dad14a296204db64d47d267c84948f65b8 Mon Sep 17 00:00:00 2001 From: Ian Schneider Date: Mon, 19 May 2025 11:16:47 -0600 Subject: [PATCH 2/4] address feedback and typos - make it clearer that name or ID can be used to lookup items - add typed dicts to represent API responses/resources - accept typed dict or name/ID when possible - raise MissingResource when attempting a get by name with no match - use positional/keyword-only parameters in client methods --- examples/mosaics-cli.sh | 12 ++-- planet/cli/mosaics.py | 73 ++++++++++++------------ planet/clients/mosaics.py | 116 +++++++++++++++++++++++--------------- planet/models.py | 12 ++++ 4 files changed, 124 insertions(+), 89 deletions(-) diff --git a/examples/mosaics-cli.sh b/examples/mosaics-cli.sh index 91213511..997c93c9 100755 --- a/examples/mosaics-cli.sh +++ b/examples/mosaics-cli.sh @@ -1,16 +1,16 @@ #!/bin/bash -echo -e "Global Basemap Series" +echo -e "List the mosaic series that have the word Global in their name" planet mosaics series list --name-contains=Global | jq .[].name -echo -e "\nLatest Global Monthly" +echo -e "\nWhat is the latest mosaic in the series named Global Monthly, with output indented" planet mosaics series list-mosaics "Global Monthly" --latest --pretty -echo -e "\nHow Many Quads?" -planet mosaics search 09462e5a-2af0-4de3-a710-e9010d8d4e58 --bbox=-100,40,-100,40.1 +echo -e "\nHow many quads are in the mosaic with this ID (name also accepted!)?" +planet mosaics search 09462e5a-2af0-4de3-a710-e9010d8d4e58 --bbox=-100,40,-100,40.1 | jq .[].id -echo -e "\nWhat Scenes Contributed to Quad?" +echo -e "\nWhat scenes contributed to this quad in the mosaic with this ID (name also accepted)?" planet mosaics contributions 09462e5a-2af0-4de3-a710-e9010d8d4e58 455-1273 -echo -e "\nDownload Them!" +echo -e "\nDownload them to a directory named quads!" planet mosaics download 09462e5a-2af0-4de3-a710-e9010d8d4e58 --bbox=-100,40,-100,40.1 --output-dir=quads \ No newline at end of file diff --git a/planet/cli/mosaics.py b/planet/cli/mosaics.py index 165b9481..c78e9d44 100644 --- a/planet/cli/mosaics.py +++ b/planet/cli/mosaics.py @@ -87,32 +87,32 @@ def series(): @command(mosaics, name="contributions") -@click.argument("name") +@click.argument("name_or_id") @click.argument("quad") -async def quad_contributions(ctx, name, quad, pretty): - '''Get contributing scenes for a mosaic quad +async def quad_contributions(ctx, name_or_id, quad, pretty): + '''Get contributing scenes for a quad in a mosaic specified by name or ID Example: planet mosaics contribution global_monthly_2025_04_mosaic 575-1300 ''' async with client(ctx) as cl: - item = await cl.get_quad(name, quad) + item = await cl.get_quad(name_or_id, quad) await _output(cl.get_quad_contributions(item), pretty) @command(mosaics, name="info") -@click.argument("name", required=True) +@click.argument("name_or_id", required=True) @include_links -async def mosaic_info(ctx, name, pretty, links): - """Get information for a specific mosaic +async def mosaic_info(ctx, name_or_id, pretty, links): + """Get information for a mosaic specified by name or ID Example: planet mosaics info global_monthly_2025_04_mosaic """ async with client(ctx) as cl: - await _output(cl.get_mosaic(name), pretty, links) + await _output(cl.get_mosaic(name_or_id), pretty, links) @command(mosaics, name="list") @@ -128,11 +128,11 @@ async def mosaics_list(ctx, acquired_lt, pretty, links): - """List all mosaics + """List information for all available mosaics Example: - planet mosaics info global_monthly_2025_04_mosaic + planet mosaics list --name-contains global_monthly """ async with client(ctx) as cl: await _output( @@ -145,17 +145,17 @@ async def mosaics_list(ctx, @command(series, name="info") -@click.argument("name", required=True) +@click.argument("name_or_id", required=True) @include_links -async def series_info(ctx, name, pretty, links): - """Get information for a specific series +async def series_info(ctx, name_or_id, pretty, links): + """Get information for a series specified by name or ID Example: planet series info "Global Quarterly" """ async with client(ctx) as cl: - await _output(cl.get_series(name), pretty, links) + await _output(cl.get_series(name_or_id), pretty, links) @command(series, name="list") @@ -171,7 +171,7 @@ async def series_list(ctx, acquired_lt, pretty, links): - """List series + """List information for available series Example: @@ -180,17 +180,17 @@ async def series_list(ctx, async with client(ctx) as cl: await _output( cl.list_series( - name_contains, - interval, - acquired_gt, - acquired_lt, + name_contains=name_contains, + interval=interval, + acquired_gt=acquired_gt, + acquired_lt=acquired_lt, ), pretty, links) @command(series, name="list-mosaics") -@click.argument("name", required=True) +@click.argument("name_or_id", required=True) @click.option("--latest", is_flag=True, help=("Get the latest mosaic in the series")) @@ -198,13 +198,13 @@ async def series_list(ctx, @acquired_lt @include_links async def list_series_mosaics(ctx, - name, + name_or_id, acquired_gt, acquired_lt, latest, - links, - pretty): - """List mosaics in a series + pretty, + links): + """List mosaics in a series specified by name or ID Example: @@ -212,7 +212,7 @@ async def list_series_mosaics(ctx, """ async with client(ctx) as cl: await _output( - cl.list_series_mosaics(name, + cl.list_series_mosaics(name_or_id, acquired_gt=acquired_gt, acquired_lt=acquired_lt, latest=latest), @@ -221,24 +221,24 @@ async def list_series_mosaics(ctx, @command(mosaics, name="search") -@click.argument("name", required=True) +@click.argument("name_or_id", required=True) @bbox @geometry @click.option("--summary", is_flag=True, help=("Get a count of how many quads would be returned")) @include_links -async def list_quads(ctx, name, bbox, geometry, summary, links, pretty): - """Search quads +async def list_quads(ctx, name_or_id, bbox, geometry, summary, pretty, links): + """Search quads in a mosaic specified by name or ID Example: planet mosaics search global_monthly_2025_04_mosaic --bbox -100,40,-100,41 """ async with client(ctx) as cl: - mosaic = await cl.get_mosaic(name) + mosaic = await cl.get_mosaic(name_or_id) if mosaic is None: - raise click.ClickException("No mosaic named " + name) + raise click.ClickException("No mosaic named " + name_or_id) await _output( cl.list_quads(mosaic, minimal=False, @@ -250,18 +250,17 @@ async def list_quads(ctx, name, bbox, geometry, summary, links, pretty): @command(mosaics, name="download") -@click.argument("name", required=True) +@click.argument("name_or_id", required=True) @click.option('--output-dir', - default='.', - help=('Directory for file download.'), + help=('Directory for file download. Defaults to mosaic name'), type=click.Path(exists=True, resolve_path=True, writable=True, file_okay=False)) @bbox @geometry -async def download(ctx, name, output_dir, bbox, geometry, **kwargs): - """Download quads from a mosaic +async def download(ctx, name_or_id, output_dir, bbox, geometry, **kwargs): + """Download quads from a mosaic by name or ID Example: @@ -269,9 +268,9 @@ async def download(ctx, name, output_dir, bbox, geometry, **kwargs): """ quiet = ctx.obj['QUIET'] async with client(ctx) as cl: - mosaic = await cl.get_mosaic(name) + mosaic = await cl.get_mosaic(name_or_id) if mosaic is None: - raise click.ClickException("No mosaic named " + name) + raise click.ClickException("No mosaic named " + name_or_id) await cl.download_quads(mosaic, bbox=bbox, geometry=geometry, diff --git a/planet/clients/mosaics.py b/planet/clients/mosaics.py index fb813ebc..48d08edf 100644 --- a/planet/clients/mosaics.py +++ b/planet/clients/mosaics.py @@ -2,8 +2,9 @@ from pathlib import Path from typing import AsyncIterator, Awaitable, Optional, Tuple, Type, TypeVar, Union, cast from planet.constants import PLANET_BASE_URL +from planet.exceptions import MissingResource from planet.http import Session -from planet.models import Paged, Response, StreamingBody +from planet.models import Mosaic, Paged, Quad, Response, Series, StreamingBody from uuid import UUID BASE_URL = f'{PLANET_BASE_URL}/basemaps/v1' @@ -15,17 +16,17 @@ BBox = Tuple[Number, Number, Number, Number] -class Series(Paged): +class _SeriesPage(Paged): ITEMS_KEY = 'series' NEXT_KEY = '_next' -class Mosaics(Paged): +class _MosaicsPage(Paged): ITEMS_KEY = 'mosaics' NEXT_KEY = '_next' -class MosaicQuads(Paged): +class _QuadsPage(Paged): ITEMS_KEY = 'items' NEXT_KEY = '_next' @@ -59,7 +60,7 @@ def __init__(self, session: Session, base_url: Optional[str] = None): """ Parameters: session: Open session connected to server. - base_url: The base URL to use. Defaults to production orders API + base_url: The base URL to use. Defaults to production Mosaics base url. """ self._session = session @@ -76,7 +77,7 @@ def _url(self, path: str) -> str: return f"{BASE_URL}/{path}" async def _get_by_name(self, path: str, pager: Type[Paged], - name: str) -> Optional[dict]: + name: str) -> dict: response = await self._session.request( method='GET', url=self._url(path), @@ -85,7 +86,9 @@ async def _get_by_name(self, path: str, pager: Type[Paged], }, ) listing = response.json()[pager.ITEMS_KEY] - return listing[0] if listing else None + if len(listing): + return listing[0] + raise MissingResource(f"{name} not found") async def _get_by_id(self, path: str, id: str) -> dict: response = await self._session.request(method="GET", @@ -93,31 +96,37 @@ async def _get_by_id(self, path: str, id: str) -> dict: return response.json() async def _get(self, name_or_id: str, path: str, - pager: Type[Paged]) -> Optional[dict]: + pager: Type[Paged]) -> dict: if _is_uuid(name_or_id): return await self._get_by_id(path, name_or_id) return await self._get_by_name(path, pager, name_or_id) - async def get_mosaic(self, name_or_id: str) -> Optional[dict]: + async def _resolve_mosaic(self, mosaic: Union[Mosaic, str]) -> Mosaic: + if isinstance(mosaic, Mosaic): + return mosaic + return await self.get_mosaic(mosaic) + + async def get_mosaic(self, name_or_id: str) -> Mosaic: """Get the API representation of a mosaic by name or id. :param name str: The name or id of the mosaic :returns: dict or None (if searching by name) :raises planet.api.exceptions.APIException: On API error. """ - return await self._get(name_or_id, "mosaics", Mosaics) + return Mosaic(await self._get(name_or_id, "mosaics", _MosaicsPage)) - async def get_series(self, name_or_id: str) -> Optional[dict]: + async def get_series(self, name_or_id: str) -> Series: """Get the API representation of a series by name or id. :param name str: The name or id of the series :returns: dict or None (if searching by name) :raises planet.api.exceptions.APIException: On API error. """ - return await self._get(name_or_id, "series", Series) + return Series(await self._get(name_or_id, "series", _SeriesPage)) async def list_series( self, + *, name_contains: Optional[str] = None, interval: Optional[str] = None, acquired_gt: Optional[str] = None, @@ -147,11 +156,12 @@ async def list_series( url=self._url("series"), params=params, ) - async for item in Series(resp, self._session.request): + async for item in _SeriesPage(resp, self._session.request): yield item async def list_mosaics( self, + *, name_contains: Optional[str] = None, interval: Optional[str] = None, acquired_gt: Optional[str] = None, @@ -164,7 +174,7 @@ async def list_mosaics( Example: ``` - mosaics = await client.list_mosacis() + mosaics = await client.list_mosaics() async for m in mosaics: print(m) ``` @@ -185,12 +195,14 @@ async def list_mosaics( url=self._url("mosaics"), params=params, ) - async for item in Mosaics(resp, self._session.request): + async for item in _MosaicsPage(resp, self._session.request): yield item async def list_series_mosaics( self, - name_or_id: str, + /, + series: Union[Series, str], + *, acquired_gt: Optional[str] = None, acquired_lt: Optional[str] = None, latest: bool = False, @@ -206,11 +218,13 @@ async def list_series_mosaics( print(m) ``` """ - if not _is_uuid(name_or_id): - series = await self._get_by_name("series", Series, name_or_id) - if series is None: - return - name_or_id = series["id"] + if isinstance(series, Series): + series_id = series["id"] + elif not _is_uuid(series): + series = Series(await self._get_by_name("series", + _SeriesPage, + series)) + series_id = series["id"] params = {} if acquired_gt: params["acquired__gt"] = acquired_gt @@ -220,18 +234,20 @@ async def list_series_mosaics( params["latest"] = "yes" resp = await self._session.request( method="GET", - url=self._url(f"series/{name_or_id}/mosaics"), + url=self._url(f"series/{series_id}/mosaics"), params=params, ) - async for item in Mosaics(resp, self._session.request): + async for item in _MosaicsPage(resp, self._session.request): yield item async def list_quads(self, - mosaic: dict, + /, + mosaic: Union[Mosaic, str], + *, minimal: bool = False, bbox: Optional[BBox] = None, geometry: Optional[dict] = None, - summary: bool = False) -> AsyncIterator[dict]: + summary: bool = False) -> AsyncIterator[Quad]: """ List the a mosaic's quads. @@ -244,6 +260,7 @@ async def list_quads(self, print(q) ``` """ + mosaic = await self._resolve_mosaic(mosaic) if geometry: resp = await self._quads_geometry(mosaic, geometry, @@ -263,11 +280,11 @@ async def list_quads(self, if summary: yield resp.json()["summary"] return - async for item in MosaicQuads(resp, self._session.request): - yield item + async for item in _QuadsPage(resp, self._session.request): + yield Quad(item) async def _quads_geometry(self, - mosaic: dict, + mosaic: Mosaic, geometry: dict, minimal: bool, summary: bool) -> Response: @@ -285,7 +302,7 @@ async def _quads_geometry(self, ) async def _quads_bbox(self, - mosaic: dict, + mosaic: Mosaic, bbox: BBox, minimal: bool, summary: bool) -> Response: @@ -303,7 +320,7 @@ async def _quads_bbox(self, url=url, ) - async def get_quad(self, name_or_id: str, quad_id: str) -> dict: + async def get_quad(self, mosaic: Union[Mosaic, str], quad_id: str) -> Quad: """ Get a mosaic's quad information. @@ -314,18 +331,15 @@ async def get_quad(self, name_or_id: str, quad_id: str) -> dict: print(quad) ``` """ - if not _is_uuid(name_or_id): - mosaic = await self.get_mosaic(name_or_id) - if mosaic is None: - return {} - name_or_id = cast(str, mosaic["id"]) + mosaic = await self._resolve_mosaic(mosaic) + mosaic_id = mosaic["id"] resp = await self._session.request( method="GET", - url=self._url(f"mosaics/{name_or_id}/quads/{quad_id}"), + url=self._url(f"mosaics/{mosaic_id}/quads/{quad_id}"), ) - return resp.json() + return Quad(resp.json()) - async def get_quad_contributions(self, quad: dict) -> list[dict]: + async def get_quad_contributions(self, quad: Quad) -> list[dict]: """ Get a mosaic's quad information. @@ -344,10 +358,12 @@ async def get_quad_contributions(self, quad: dict) -> list[dict]: return resp.json()["items"] async def download_quad(self, - quad: dict, - directory, + /, + quad: Quad, + *, + directory: str = ".", overwrite: bool = False, - progress_bar=False): + progress_bar: bool = False): url = quad["_links"]["download"] Path(directory).mkdir(exist_ok=True, parents=True) async with self._session.stream(method='GET', url=url) as resp: @@ -363,13 +379,16 @@ async def download_quad(self, ``` quad = await client.get_quad("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5", "1234-5678") - await client.download_quad(quad, ".") + await client.download_quad(quad) ``` """ async def download_quads(self, - mosaic: dict, - directory: str, + /, + mosaic: Union[Mosaic, str], + *, + directory: Optional[str] = None, + overwrite: bool = False, bbox: Optional[BBox] = None, geometry: Optional[dict] = None, progress_bar: bool = False, @@ -381,16 +400,21 @@ async def download_quads(self, ``` mosaic = await cl.get_mosaic(name) - client.download_quads(mosaic, '.', bbox=(-100, 40, -100, 41)) + client.download_quads(mosaic, bbox=(-100, 40, -100, 41)) ``` """ jobs = [] + mosaic = await self._resolve_mosaic(mosaic) + directory = directory or mosaic["name"] async for q in self.list_quads(mosaic, minimal=True, bbox=bbox, geometry=geometry): jobs.append( - self.download_quad(q, directory, progress_bar=progress_bar)) + self.download_quad(q, + directory=directory, + overwrite=overwrite, + progress_bar=progress_bar)) if len(jobs) == concurrency: await asyncio.gather(*jobs) jobs = [] diff --git a/planet/models.py b/planet/models.py index e7321e24..7e15cb7b 100644 --- a/planet/models.py +++ b/planet/models.py @@ -314,3 +314,15 @@ def ref(self): * an instance of a Planet Feature (e.g. the return value from `pl.features.get_items(collection_id)`) * an instance of a class that implements __geo_interface__ (Shapely, GeoPandas geometries) """ + + +class Mosaic(dict): + """The API representation of a Planet mosaic""" + + +class Series(dict): + """The API representation of a Planet mosaic series""" + + +class Quad(dict): + """The API representation of a mosaic quad""" From a4d35f0f14c6f8e251748149e26afd7e99481e36 Mon Sep 17 00:00:00 2001 From: Ian Schneider Date: Wed, 21 May 2025 15:25:13 -0600 Subject: [PATCH 3/4] add sync client and tests, minor fixups --- planet/cli/mosaics.py | 12 +- planet/clients/mosaics.py | 81 ++--- planet/sync/mosaics.py | 241 +++++++++++++++ tests/integration/test_mosaics_api.py | 41 +++ tests/integration/test_mosaics_cli.py | 410 ++++++++++++++++++++++++++ 5 files changed, 741 insertions(+), 44 deletions(-) create mode 100644 planet/sync/mosaics.py create mode 100644 tests/integration/test_mosaics_api.py create mode 100644 tests/integration/test_mosaics_cli.py diff --git a/planet/cli/mosaics.py b/planet/cli/mosaics.py index c78e9d44..f8bafd0e 100644 --- a/planet/cli/mosaics.py +++ b/planet/cli/mosaics.py @@ -60,8 +60,6 @@ def _strip_links(resource): async def _output(result, pretty, include_links=False): if asyncio.iscoroutine(result): result = await result - if result is None: - raise click.ClickException("not found") if not include_links: _strip_links(result) echo_json(result, pretty) @@ -236,11 +234,8 @@ async def list_quads(ctx, name_or_id, bbox, geometry, summary, pretty, links): planet mosaics search global_monthly_2025_04_mosaic --bbox -100,40,-100,41 """ async with client(ctx) as cl: - mosaic = await cl.get_mosaic(name_or_id) - if mosaic is None: - raise click.ClickException("No mosaic named " + name_or_id) await _output( - cl.list_quads(mosaic, + cl.list_quads(name_or_id, minimal=False, bbox=bbox, geometry=geometry, @@ -268,10 +263,7 @@ async def download(ctx, name_or_id, output_dir, bbox, geometry, **kwargs): """ quiet = ctx.obj['QUIET'] async with client(ctx) as cl: - mosaic = await cl.get_mosaic(name_or_id) - if mosaic is None: - raise click.ClickException("No mosaic named " + name_or_id) - await cl.download_quads(mosaic, + await cl.download_quads(name_or_id, bbox=bbox, geometry=geometry, directory=output_dir, diff --git a/planet/clients/mosaics.py b/planet/clients/mosaics.py index 48d08edf..9f2bd5e3 100644 --- a/planet/clients/mosaics.py +++ b/planet/clients/mosaics.py @@ -1,10 +1,25 @@ +# Copyright 2025 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. + import asyncio from pathlib import Path -from typing import AsyncIterator, Awaitable, Optional, Tuple, Type, TypeVar, Union, cast +from typing import AsyncIterator, Optional, Tuple, Type, TypeVar, Union, cast +from planet.clients.base import _BaseClient from planet.constants import PLANET_BASE_URL from planet.exceptions import MissingResource from planet.http import Session -from planet.models import Mosaic, Paged, Quad, Response, Series, StreamingBody +from planet.models import GeoInterface, Mosaic, Paged, Quad, Response, Series, StreamingBody from uuid import UUID BASE_URL = f'{PLANET_BASE_URL}/basemaps/v1' @@ -39,7 +54,7 @@ def _is_uuid(val: str) -> bool: return False -class MosaicsClient: +class MosaicsClient(_BaseClient): """High-level asynchronous access to Planet's Mosaics API. Example: @@ -49,7 +64,7 @@ class MosaicsClient: >>> >>> async def main(): ... async with Session() as sess: - ... cl = sess.client('data') + ... cl = sess.client('mosaics') ... # use client here ... >>> asyncio.run(main()) @@ -63,18 +78,10 @@ def __init__(self, session: Session, base_url: Optional[str] = None): base_url: The base URL to use. Defaults to production Mosaics base url. """ - self._session = session - - self._base_url = base_url or BASE_URL - if self._base_url.endswith('/'): - self._base_url = self._base_url[:-1] - - def _call_sync(self, f: Awaitable[T]) -> T: - """block on an async function call, using the call_sync method of the session""" - return self._session._call_sync(f) + super().__init__(session, base_url or BASE_URL) def _url(self, path: str) -> str: - return f"{BASE_URL}/{path}" + return f"{self._base_url}/{path}" async def _get_by_name(self, path: str, pager: Type[Paged], name: str) -> dict: @@ -88,7 +95,12 @@ async def _get_by_name(self, path: str, pager: Type[Paged], listing = response.json()[pager.ITEMS_KEY] if len(listing): return listing[0] - raise MissingResource(f"{name} not found") + # mimic the response for 404 when search is empty + resource = "Mosaic" + if path == "series": + resource = "Series" + raise MissingResource('{"message":"%s Not Found: %s"}' % + (resource, name)) async def _get_by_id(self, path: str, id: str) -> dict: response = await self._session.request(method="GET", @@ -130,7 +142,7 @@ async def list_series( name_contains: Optional[str] = None, interval: Optional[str] = None, acquired_gt: Optional[str] = None, - acquired_lt: Optional[str] = None) -> AsyncIterator[dict]: + acquired_lt: Optional[str] = None) -> AsyncIterator[Series]: """ List the series you have access to. @@ -157,7 +169,7 @@ async def list_series( params=params, ) async for item in _SeriesPage(resp, self._session.request): - yield item + yield Series(item) async def list_mosaics( self, @@ -166,8 +178,7 @@ async def list_mosaics( interval: Optional[str] = None, acquired_gt: Optional[str] = None, acquired_lt: Optional[str] = None, - latest: bool = False, - ) -> AsyncIterator[dict]: + ) -> AsyncIterator[Mosaic]: """ List the mosaics you have access to. @@ -188,15 +199,13 @@ async def list_mosaics( params["acquired__gt"] = acquired_gt if acquired_lt: params["acquired__lt"] = acquired_lt - if latest: - params["latest"] = "yes" resp = await self._session.request( method='GET', url=self._url("mosaics"), params=params, ) async for item in _MosaicsPage(resp, self._session.request): - yield item + yield Mosaic(item) async def list_series_mosaics( self, @@ -206,7 +215,7 @@ async def list_series_mosaics( acquired_gt: Optional[str] = None, acquired_lt: Optional[str] = None, latest: bool = False, - ) -> AsyncIterator[dict]: + ) -> AsyncIterator[Mosaic]: """ List the mosaics in a series. @@ -218,6 +227,7 @@ async def list_series_mosaics( print(m) ``` """ + series_id = series if isinstance(series, Series): series_id = series["id"] elif not _is_uuid(series): @@ -238,7 +248,7 @@ async def list_series_mosaics( params=params, ) async for item in _MosaicsPage(resp, self._session.request): - yield item + yield Mosaic(item) async def list_quads(self, /, @@ -246,7 +256,7 @@ async def list_quads(self, *, minimal: bool = False, bbox: Optional[BBox] = None, - geometry: Optional[dict] = None, + geometry: Optional[Union[dict, GeoInterface]] = None, summary: bool = False) -> AsyncIterator[Quad]: """ List the a mosaic's quads. @@ -262,6 +272,8 @@ async def list_quads(self, """ mosaic = await self._resolve_mosaic(mosaic) if geometry: + if isinstance(geometry, GeoInterface): + geometry = geometry.__geo_interface__ resp = await self._quads_geometry(mosaic, geometry, minimal, @@ -364,14 +376,6 @@ async def download_quad(self, directory: str = ".", overwrite: bool = False, progress_bar: bool = False): - url = quad["_links"]["download"] - Path(directory).mkdir(exist_ok=True, parents=True) - async with self._session.stream(method='GET', url=url) as resp: - body = StreamingBody(resp) - dest = Path(directory, body.name) - await body.write(dest, - overwrite=overwrite, - progress_bar=progress_bar) """ Download a quad to a directory. @@ -382,6 +386,14 @@ async def download_quad(self, await client.download_quad(quad) ``` """ + url = quad["_links"]["download"] + Path(directory).mkdir(exist_ok=True, parents=True) + async with self._session.stream(method='GET', url=url) as resp: + body = StreamingBody(resp) + dest = Path(directory, body.name) + await body.write(dest, + overwrite=overwrite, + progress_bar=progress_bar) async def download_quads(self, /, @@ -390,7 +402,8 @@ async def download_quads(self, directory: Optional[str] = None, overwrite: bool = False, bbox: Optional[BBox] = None, - geometry: Optional[dict] = None, + geometry: Optional[Union[dict, + GeoInterface]] = None, progress_bar: bool = False, concurrency: int = 4): """ diff --git a/planet/sync/mosaics.py b/planet/sync/mosaics.py new file mode 100644 index 00000000..12b300ee --- /dev/null +++ b/planet/sync/mosaics.py @@ -0,0 +1,241 @@ +# Copyright 2025 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. + +from typing import Iterator, Optional, TypeVar, Union +from planet.clients.mosaics import BBox, MosaicsClient +from planet.http import Session +from planet.models import GeoInterface, Mosaic, Quad, Series + +T = TypeVar("T") + + +class MosaicsAPI: + + _client: MosaicsClient + + def __init__(self, session: Session, base_url: Optional[str] = None): + """ + Parameters: + session: Open session connected to server. + base_url: The base URL to use. Defaults to production Mosaics API + base url. + """ + self._client = MosaicsClient(session, base_url) + + def get_mosaic(self, name_or_id: str) -> Mosaic: + """Get the API representation of a mosaic by name or id. + + :param name str: The name or id of the mosaic + :returns: dict or None (if searching by name) + :raises planet.api.exceptions.APIException: On API error. + """ + return self._client._call_sync(self._client.get_mosaic(name_or_id)) + + def get_series(self, name_or_id: str) -> Series: + """Get the API representation of a series by name or id. + + :param name str: The name or id of the series + :returns: dict or None (if searching by name) + :raises planet.api.exceptions.APIException: On API error. + """ + return self._client._call_sync(self._client.get_series(name_or_id)) + + def list_series(self, + *, + name_contains: Optional[str] = None, + interval: Optional[str] = None, + acquired_gt: Optional[str] = None, + acquired_lt: Optional[str] = None) -> Iterator[Series]: + """ + List the series you have access to. + + Example: + + ``` + series = client.list_series() + for s in series: + print(s) + ``` + """ + return self._client._aiter_to_iter( + self._client.list_series(name_contains=name_contains, + interval=interval, + acquired_gt=acquired_gt, + acquired_lt=acquired_lt)) + + def list_mosaics( + self, + *, + name_contains: Optional[str] = None, + interval: Optional[str] = None, + acquired_gt: Optional[str] = None, + acquired_lt: Optional[str] = None, + ) -> Iterator[Mosaic]: + """ + List the mosaics you have access to. + + Example: + + ``` + mosaics = client.list_mosaics() + for m in mosaics: + print(m) + ``` + """ + return self._client._aiter_to_iter( + self._client.list_mosaics( + name_contains=name_contains, + interval=interval, + acquired_gt=acquired_gt, + acquired_lt=acquired_lt, + )) + + def list_series_mosaics( + self, + /, + series: Union[Series, str], + *, + acquired_gt: Optional[str] = None, + acquired_lt: Optional[str] = None, + latest: bool = False, + ) -> Iterator[Mosaic]: + """ + List the mosaics in a series. + + Example: + + ``` + mosaics = client.list_series_mosaics("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5") + for m in mosaics: + print(m) + ``` + """ + return self._client._aiter_to_iter( + self._client.list_series_mosaics( + series, + acquired_gt=acquired_gt, + acquired_lt=acquired_lt, + latest=latest, + )) + + def list_quads(self, + /, + mosaic: Union[Mosaic, str], + *, + minimal: bool = False, + bbox: Optional[BBox] = None, + geometry: Optional[Union[dict, GeoInterface]] = None, + summary: bool = False) -> Iterator[Quad]: + """ + List the a mosaic's quads. + + Example: + + ``` + mosaic = await client.get_mosaic("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5") + quads = await client.list_quads(mosaic) + for q in quads: + print(q) + ``` + """ + return self._client._aiter_to_iter( + self._client.list_quads( + mosaic, + minimal=minimal, + bbox=bbox, + geometry=geometry, + summary=summary, + )) + + def get_quad(self, mosaic: Union[Mosaic, str], quad_id: str) -> Quad: + """ + Get a mosaic's quad information. + + Example: + + ``` + quad = await client.get_quad("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5", "1234-5678") + print(quad) + ``` + """ + return self._client._call_sync(self._client.get_quad(mosaic, quad_id)) + + def get_quad_contributions(self, quad: Quad) -> list[dict]: + """ + Get a mosaic's quad information. + + Example: + + ``` + quad = await client.get_quad("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5", "1234-5678") + contributions = await client.get_quad_contributions(quad) + print(contributions) + ``` + """ + return self._client._call_sync( + self._client.get_quad_contributions(quad)) + + def download_quad(self, + /, + quad: Quad, + *, + directory: str = ".", + overwrite: bool = False, + progress_bar: bool = False): + """ + Download a quad to a directory. + + Example: + + ``` + quad = await client.get_quad("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5", "1234-5678") + await client.download_quad(quad) + ``` + """ + self._client._call_sync( + self.download_quad(quad, + directory=directory, + overwrite=overwrite, + progress_bar=progress_bar)) + + def download_quads(self, + /, + mosaic: Union[Mosaic, str], + *, + directory: Optional[str] = None, + overwrite: bool = False, + bbox: Optional[BBox] = None, + geometry: Optional[Union[dict, GeoInterface]] = None, + progress_bar: bool = False, + concurrency: int = 4): + """ + Download a mosaics' quads to a directory. + + Example: + + ``` + mosaic = await cl.get_mosaic(name) + client.download_quads(mosaic, bbox=(-100, 40, -100, 41)) + ``` + """ + return self._client._call_sync( + self._client.download_quads( + mosaic, + directory=directory, + overwrite=overwrite, + bbox=bbox, + geometry=geometry, + progress_bar=progress_bar, + concurrency=concurrency, + )) diff --git a/tests/integration/test_mosaics_api.py b/tests/integration/test_mosaics_api.py new file mode 100644 index 00000000..a6a9a05a --- /dev/null +++ b/tests/integration/test_mosaics_api.py @@ -0,0 +1,41 @@ +import asyncio +import functools +import inspect +from unittest.mock import patch +from planet.sync.mosaics import MosaicsAPI +from tests.integration import test_mosaics_cli +import pytest + +from concurrent.futures import ThreadPoolExecutor + + +def async_wrap(api): + pool = ThreadPoolExecutor() + + def make_async(fn): + + @functools.wraps(fn) + async def wrapper(*args, **kwargs): + future = pool.submit(fn, *args, **kwargs) + res = await asyncio.wrap_future(future) + if inspect.isgenerator(res): + return list(res) + return res + + return wrapper + + members = inspect.getmembers(api, inspect.isfunction) + funcs = {m[0]: make_async(m[1]) for m in members if m[0][0] != "_"} + funcs["__init__"] = getattr(api, "__init__") + funcs["_pool"] = pool + return type("AsyncAPI", (object, ), funcs) + + +# @pytest.mark.skip +@pytest.mark.parametrize( + "tc", [pytest.param(tc, id=tc.id) for tc in test_mosaics_cli.test_cases]) +def test_api(tc): + api = async_wrap(MosaicsAPI) + with patch('planet.cli.mosaics.MosaicsClient', api): + test_mosaics_cli.run_test(tc) + api._pool.shutdown() diff --git a/tests/integration/test_mosaics_cli.py b/tests/integration/test_mosaics_cli.py new file mode 100644 index 00000000..839f1514 --- /dev/null +++ b/tests/integration/test_mosaics_cli.py @@ -0,0 +1,410 @@ +from dataclasses import dataclass +from pathlib import Path +import json +from typing import Optional +import httpx +import pytest + +import respx +from click.testing import CliRunner + +from planet.cli import cli + +baseurl = "http://basemaps.com/v1/" + +uuid = "09462e5a-2af0-4de3-a710-e9010d8d4e58" + + +def url(path: str) -> str: + return baseurl + path + + +def request(path: str, + json, + method="GET", + status=200, + headers=None, + stream=None): + + def go(): + respx.request(method, + url(path)).return_value = httpx.Response(status, + json=json, + headers=headers, + stream=stream) + + return go + + +def quad_item_downloads(cnt): + return [{ + "_links": { + "download": url(f"mosaics/download-a-quad/{i}") + } + } for i in range(cnt)] + + +def quad_item_download_requests(cnt): + return [ + request( + f"mosaics/download-a-quad/{i}", + None, + stream=stream(), + headers={ + "Content-Length": "100", + "Content-Disposition": f'attachment: filename="quad-{i}.tif"' + }) for i in range(cnt) + ] + + +async def stream(): + yield bytes("data" * 25, encoding="ascii") + + +@dataclass +class CLITestCase: + id: str + command: list[str] + args: list[str] + requests: list + exit_code: int = 0 + output: Optional[dict] = None + expect_files: Optional[list[str]] = None + exception: Optional[str] = None + + +info_cases = [ + CLITestCase(id="info", + command=["info"], + args=[uuid], + output={"name": "a mosaic"}, + requests=[ + request(f"mosaics/{uuid}", {"name": "a mosaic"}), + ]), + CLITestCase(id="info not exist by uuid", + command=["info"], + args=[uuid], + output='Error: {"message":"Mosaic Not Found: fff"}\n', + exit_code=1, + requests=[ + request(f"mosaics/{uuid}", + {"message": "Mosaic Not Found: fff"}, + status=404), + ]), + CLITestCase(id="info not exist by name", + command=["info"], + args=["fff"], + output='Error: {"message":"Mosaic Not Found: fff"}\n', + exit_code=1, + requests=[request("mosaics?name__is=fff", {"mosaics": []})]), +] + +list_mosaic_cases = [ + CLITestCase(id="list", + command=["list"], + args=[], + output=[{ + "name": "a mosaic" + }], + requests=[ + request("mosaics", {"mosaics": [{ + "name": "a mosaic" + }]}), + ]), + CLITestCase( + id="list with filters", + command=["list"], + args=[ + "--name-contains", + "name", + "--interval", + "1 day", + "--acquired_lt", + "2025-05-19", + "--acquired_gt", + "2024-05-19" + ], + output=[{ + "name": "a mosaic" + }], + requests=[ + request( + "mosaics?name__contains=name&interval=1+day&acquired__gt=2024-05-19+00%3A00%3A00&acquired__lt=2025-05-19+00%3A00%3A00", + {"mosaics": [{ + "name": "a mosaic" + }]}), + ]), +] + +series_info_cases = [ + CLITestCase( + id="series info", + command=["series", "info"], + args=["Global Monthly"], + output={"id": "123"}, + requests=[ + request("series?name__is=Global+Monthly", + {"series": [{ + "id": "123" + }]}) + ], + ), + CLITestCase( + id="series info by name does not exist", + command=["series", "info"], + args=["non-existing-series"], + output='Error: {"message":"Series Not Found: non-existing-series"}\n', + exit_code=1, + requests=[ + request("series?name__is=non-existing-series", {"series": []}) + ], + ), + CLITestCase( + id="series info by uuid does not exist", + command=["series", "info"], + args=[uuid], + output='Error: {"message":"Series Not Found: fff"}\n', + exit_code=1, + requests=[ + request(f"series/{uuid}", {"message": "Series Not Found: fff"}, + status=404), + ], + ), +] + +list_series_cases = [ + CLITestCase(id="series list", + command=["series", "list"], + args=[], + output=[{ + "name": "a series" + }], + requests=[ + request("series", {"series": [{ + "name": "a series" + }]}), + ]), + CLITestCase( + id="series list filters", + command=["series", "list"], + args=[ + "--name-contains", + "name", + "--interval", + "1 day", + "--acquired_lt", + "2025-05-19", + "--acquired_gt", + "2024-05-19" + ], + output=[{ + "name": "a series" + }], + requests=[ + request( + "series?name__contains=name&interval=1+day&acquired__gt=2024-05-19+00%3A00%3A00&acquired__lt=2025-05-19+00%3A00%3A00", + {"series": [{ + "name": "a series" + }]}), + ]), + CLITestCase(id="series list-mosaics", + command=["series", "list-mosaics"], + args=[uuid], + output=[{ + "name": "a mosaic" + }], + requests=[ + request( + "series/09462e5a-2af0-4de3-a710-e9010d8d4e58/mosaics", + {"mosaics": [{ + "name": "a mosaic" + }]}), + ]), + CLITestCase( + id="series list-mosaics filters", + command=["series", "list-mosaics"], + args=[ + "Some Series", + "--acquired_lt", + "2025-05-19", + "--acquired_gt", + "2024-05-19", + "--latest" + ], + output=[{ + "name": "a mosaic" + }], + requests=[ + request("series?name__is=Some+Series", {"series": [{ + "id": "123" + }]}), + request( + "series/123/mosaics?acquired__gt=2024-05-19+00%3A00%3A00&acquired__lt=2025-05-19+00%3A00%3A00&latest=yes", + {"mosaics": [{ + "name": "a mosaic" + }]}), + ]), +] + +search_cases = [ + CLITestCase( + id="mosaics search bbox", + command=["search"], + args=[uuid, "--bbox", "-100,40,-100,40"], + output=[{ + "id": "455-1272" + }], + requests=[ + request( + f"mosaics/{uuid}", + { + "_links": { + "quads": url( + "mosaics/09462e5a-2af0-4de3-a710-e9010d8d4e58/quads?bbox={lx},{ly},{ux},{uy}" + ) + } + }), + request( + "mosaics/09462e5a-2af0-4de3-a710-e9010d8d4e58/quads?bbox=-100.0,40.0,-100.0,40.0", + {"items": [{ + "id": "455-1272" + }]}), + ]), + CLITestCase( + id="mosaics search bbox summary", + command=["search"], + args=[uuid, "--bbox", "-100,40,-100,40", "--summary"], + output=[{ + "total_quads": 1234 + }], + requests=[ + request( + f"mosaics/{uuid}", + { + "_links": { + "quads": url( + "mosaics/09462e5a-2af0-4de3-a710-e9010d8d4e58/quads?bbox={lx},{ly},{ux},{uy}" + ) + } + }), + request( + "mosaics/09462e5a-2af0-4de3-a710-e9010d8d4e58/quads?bbox=-100.0,40.0,-100.0,40.0&summary=true", + { + # note this gets stripped from expected output + "items": [{ + "id": "455-1272" + }], + "summary": { + "total_quads": 1234 + } + }), + ]), +] + +download_cases = [ + CLITestCase( + id="mosaics download bbox", + command=["download"], + args=[uuid, "--bbox", '-100,40,-100,40'], + requests=[ + request( + f"mosaics/{uuid}", + { + "id": "123", + "name": "a mosaic", + "_links": { + "quads": url( + "mosaics/123/quads?bbox={lx},{ly},{ux},{uy}") + } + }), + request( + "mosaics/123/quads?bbox=-100.0,40.0,-100.0,40.0&minimal=true", + {"items": quad_item_downloads(1)}), + *quad_item_download_requests(1), + ], + expect_files=[ + "a mosaic/quad-0.tif", + ]), + CLITestCase( + id="mosaics download geometry", + command=["download"], + args=[uuid, "--geometry", '{"type": "Point", "coordinates": [0,0]}'], + requests=[ + request(f"mosaics/{uuid}", { + "id": "123", "name": "a mosaic" + }), + request("mosaics/123/quads/search?minimal=true", {}, + status=302, + method="POST", + headers={"Location": url("mosaics/search-link")}), + request("mosaics/search-link", {"items": quad_item_downloads(5)}), + *quad_item_download_requests(5), + ], + expect_files=[ + "a mosaic/quad-0.tif", + "a mosaic/quad-1.tif", + "a mosaic/quad-2.tif", + "a mosaic/quad-3.tif", + "a mosaic/quad-4.tif", + ]) +] + +other_cases = [ + CLITestCase( + id="quad contributions", + command=["contributions"], + args=["mosaic-name", "quad-id"], + output=[{ + "link": "https://api.planet.com/some/item" + }], + requests=[ + request("mosaics?name__is=mosaic-name", + {"mosaics": [{ + "id": "123" + }]}), + request( + "mosaics/123/quads/quad-id", + {"_links": { + "items": url("mosaics/123/quads/quad-id/items") + }}), + request("mosaics/123/quads/quad-id/items", + {"items": [{ + "link": "https://api.planet.com/some/item" + }]}) + ]), +] + +test_cases = info_cases + series_info_cases + list_mosaic_cases + list_series_cases + search_cases + download_cases + other_cases + + +@pytest.mark.parametrize("tc", + [pytest.param(tc, id=tc.id) for tc in test_cases]) +def test_cli(tc: CLITestCase): + run_test(tc) + + +@respx.mock +def run_test(tc: CLITestCase): + runner = CliRunner() + with runner.isolated_filesystem() as folder: + for r in tc.requests: + r() + + args = ["mosaics", "-u", baseurl] + tc.command + tc.args + result = runner.invoke(cli.main, args=args) + # result.exception may be SystemExit which we want to ignore + # but if we don't raise a "true error" exception, there's no + # stack trace, making it difficult to diagnose + if result.exception and tc.exit_code == 0: + raise result.exception + assert result.exit_code == tc.exit_code, result.output + if tc.output: + try: + # error output (always?) not JSON + output = json.loads(result.output) + except json.JSONDecodeError: + output = result.output + assert output == tc.output + if tc.expect_files: + for f in tc.expect_files: + assert Path(folder, f).exists(), f From 01ba3c4c6631cc8d8b99deb3b5de85c26a81d4da Mon Sep 17 00:00:00 2001 From: Ian Schneider Date: Tue, 17 Jun 2025 11:09:32 -0600 Subject: [PATCH 4/4] some tweaks - make separate summarize_quads function - use quad ID for download file name to avoid hitting download endpoint to determine name from content-disposition headers - add full_extent option for explicitly using the mosaic bbox for listing (rather than defaulting when bbox/geometry not provided) - required bbox or geometry for downloading - minor doc fixes add language for styling code blocks --- planet/cli/mosaics.py | 18 ++-- planet/clients/mosaics.py | 127 +++++++++++++++++++------- planet/sync/mosaics.py | 82 ++++++++++++----- tests/integration/test_mosaics_cli.py | 22 ++--- 4 files changed, 173 insertions(+), 76 deletions(-) diff --git a/planet/cli/mosaics.py b/planet/cli/mosaics.py index f8bafd0e..8254da44 100644 --- a/planet/cli/mosaics.py +++ b/planet/cli/mosaics.py @@ -234,14 +234,16 @@ async def list_quads(ctx, name_or_id, bbox, geometry, summary, pretty, links): planet mosaics search global_monthly_2025_04_mosaic --bbox -100,40,-100,41 """ async with client(ctx) as cl: - await _output( - cl.list_quads(name_or_id, - minimal=False, - bbox=bbox, - geometry=geometry, - summary=summary), - pretty, - links) + if summary: + result = cl.summarize_quads(name_or_id, + bbox=bbox, + geometry=geometry) + else: + result = cl.list_quads(name_or_id, + minimal=False, + bbox=bbox, + geometry=geometry) + await _output(result, pretty, links) @command(mosaics, name="download") diff --git a/planet/clients/mosaics.py b/planet/clients/mosaics.py index 9f2bd5e3..5e18abb1 100644 --- a/planet/clients/mosaics.py +++ b/planet/clients/mosaics.py @@ -14,10 +14,10 @@ import asyncio from pathlib import Path -from typing import AsyncIterator, Optional, Tuple, Type, TypeVar, Union, cast +from typing import AsyncIterator, Optional, Sequence, Tuple, Type, TypeVar, Union, cast from planet.clients.base import _BaseClient from planet.constants import PLANET_BASE_URL -from planet.exceptions import MissingResource +from planet.exceptions import ClientError, MissingResource from planet.http import Session from planet.models import GeoInterface, Mosaic, Paged, Quad, Response, Series, StreamingBody from uuid import UUID @@ -28,7 +28,11 @@ Number = Union[int, float] -BBox = Tuple[Number, Number, Number, Number] +BBox = Sequence[Number] +"""BBox is a rectangular area described by 2 corners +where the positional meaning in the sequence is +left, bottom, right, and top, respectively +""" class _SeriesPage(Paged): @@ -121,18 +125,16 @@ async def _resolve_mosaic(self, mosaic: Union[Mosaic, str]) -> Mosaic: async def get_mosaic(self, name_or_id: str) -> Mosaic: """Get the API representation of a mosaic by name or id. - :param name str: The name or id of the mosaic - :returns: dict or None (if searching by name) - :raises planet.api.exceptions.APIException: On API error. + Parameters: + name_or_id: The name or id of the mosaic """ return Mosaic(await self._get(name_or_id, "mosaics", _MosaicsPage)) async def get_series(self, name_or_id: str) -> Series: """Get the API representation of a series by name or id. - :param name str: The name or id of the series - :returns: dict or None (if searching by name) - :raises planet.api.exceptions.APIException: On API error. + Parameters: + name_or_id: The name or id of the mosaic """ return Series(await self._get(name_or_id, "series", _SeriesPage)) @@ -148,7 +150,7 @@ async def list_series( Example: - ``` + ```python series = await client.list_series() async for s in series: print(s) @@ -184,7 +186,7 @@ async def list_mosaics( Example: - ``` + ```python mosaics = await client.list_mosaics() async for m in mosaics: print(m) @@ -221,7 +223,7 @@ async def list_series_mosaics( Example: - ``` + ```python mosaics = await client.list_series_mosaics("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5") async for m in mosaics: print(m) @@ -250,26 +252,76 @@ async def list_series_mosaics( async for item in _MosaicsPage(resp, self._session.request): yield Mosaic(item) + async def summarize_quads(self, + /, + mosaic: Union[Mosaic, str], + *, + bbox: Optional[BBox] = None, + geometry: Optional[Union[dict, GeoInterface]] = None) -> dict: + """ + Get a summary of a quad list for a mosaic. + + If the bbox or geometry is not provided, the entire list is considered. + + Examples: + + Get the total number of quads in the mosaic. + + ```python + mosaic = await client.get_mosaic("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5") + summary = await client.summarize_quads(mosaic) + print(summary["total_quads"]) + ``` + """ + resp = await self._list_quads(mosaic, minimal=True, bbox=bbox, geometry=geometry, summary=True) + return resp.json()["summary"] + async def list_quads(self, /, mosaic: Union[Mosaic, str], *, minimal: bool = False, + full_extent: bool = False, bbox: Optional[BBox] = None, - geometry: Optional[Union[dict, GeoInterface]] = None, - summary: bool = False) -> AsyncIterator[Quad]: + geometry: Optional[Union[dict, GeoInterface]] = None) -> AsyncIterator[Quad]: """ List the a mosaic's quads. + Parameters: + mosaic: the mosaic to list + minimal: if False, response includes full metadata + full_extent: if True, the mosaic's extent will be used to list + bbox: only quads intersecting the bbox will be listed + geometry: only quads intersecting the geometry will be listed + + Raises: + ClientError: if `geometry`, `bbox` or `full_extent` is not specified. + Example: - ``` + List the quad at a single point (note the extent has the same corners) + + ```python mosaic = await client.get_mosaic("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5") - quads = await client.list_quads(mosaic) + quads = await client.list_quads(mosaic, bbox=[-100, 40, -100, 40]) async for q in quads: print(q) ``` """ + if not any((geometry, bbox, full_extent)): + raise ClientError("one of: geometry, bbox, full_extent required") + resp = await self._list_quads(mosaic, minimal=minimal, bbox=bbox, geometry=geometry) + async for item in _QuadsPage(resp, self._session.request): + yield Quad(item) + + async def _list_quads(self, + /, + mosaic: Union[Mosaic, str], + *, + minimal: bool = False, + bbox: Optional[BBox] = None, + geometry: Optional[Union[dict, GeoInterface]] = None, + summary: bool = False) -> Response: mosaic = await self._resolve_mosaic(mosaic) if geometry: if isinstance(geometry, GeoInterface): @@ -288,12 +340,7 @@ async def list_quads(self, else: search = bbox resp = await self._quads_bbox(mosaic, search, minimal, summary) - # kinda yucky - yields a different "shaped" dict - if summary: - yield resp.json()["summary"] - return - async for item in _QuadsPage(resp, self._session.request): - yield Quad(item) + return resp async def _quads_geometry(self, mosaic: Mosaic, @@ -305,6 +352,10 @@ async def _quads_geometry(self, params["minimal"] = "true" if summary: params["summary"] = "true" + # this could be fixed in the API ... + # for a summary, we don't need to get any listings + # zero is ignored, but in case that gets rejected, just use 1 + params["_page_size"] = 1 mosaic_id = mosaic["id"] return await self._session.request( method="POST", @@ -338,7 +389,7 @@ async def get_quad(self, mosaic: Union[Mosaic, str], quad_id: str) -> Quad: Example: - ``` + ```python quad = await client.get_quad("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5", "1234-5678") print(quad) ``` @@ -357,7 +408,7 @@ async def get_quad_contributions(self, quad: Quad) -> list[dict]: Example: - ``` + ```python quad = await client.get_quad("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5", "1234-5678") contributions = await client.get_quad_contributions(quad) print(contributions) @@ -381,19 +432,26 @@ async def download_quad(self, Example: - ``` + ```python quad = await client.get_quad("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5", "1234-5678") await client.download_quad(quad) ``` """ url = quad["_links"]["download"] Path(directory).mkdir(exist_ok=True, parents=True) + dest = Path(directory, quad["id"] + ".tif") + # this avoids a request to the download endpoint which would + # get counted as a download even if only the headers were read + # and the response content is ignored (like if when the file + # exists and overwrite is False) + if dest.exists() and not overwrite: + return async with self._session.stream(method='GET', url=url) as resp: - body = StreamingBody(resp) - dest = Path(directory, body.name) - await body.write(dest, - overwrite=overwrite, - progress_bar=progress_bar) + await StreamingBody(resp).write( + dest, + # pass along despite our manual handling + overwrite=overwrite, + progress_bar=progress_bar) async def download_quads(self, /, @@ -409,13 +467,18 @@ async def download_quads(self, """ Download a mosaics' quads to a directory. + Raises: + ClientError: if `geometry` or `bbox` is not specified. + Example: - ``` + ```python mosaic = await cl.get_mosaic(name) - client.download_quads(mosaic, bbox=(-100, 40, -100, 41)) + client.download_quads(mosaic, bbox=(-100, 40, -100, 40)) ``` """ + if not any((bbox, geometry)): + raise ClientError("bbox or geometry is required") jobs = [] mosaic = await self._resolve_mosaic(mosaic) directory = directory or mosaic["name"] diff --git a/planet/sync/mosaics.py b/planet/sync/mosaics.py index 12b300ee..bea28d51 100644 --- a/planet/sync/mosaics.py +++ b/planet/sync/mosaics.py @@ -36,18 +36,16 @@ def __init__(self, session: Session, base_url: Optional[str] = None): def get_mosaic(self, name_or_id: str) -> Mosaic: """Get the API representation of a mosaic by name or id. - :param name str: The name or id of the mosaic - :returns: dict or None (if searching by name) - :raises planet.api.exceptions.APIException: On API error. + Parameters: + name_or_id: The name or id of the mosaic """ return self._client._call_sync(self._client.get_mosaic(name_or_id)) def get_series(self, name_or_id: str) -> Series: """Get the API representation of a series by name or id. - :param name str: The name or id of the series - :returns: dict or None (if searching by name) - :raises planet.api.exceptions.APIException: On API error. + Parameters: + name_or_id: The name or id of the mosaic """ return self._client._call_sync(self._client.get_series(name_or_id)) @@ -62,7 +60,7 @@ def list_series(self, Example: - ``` + ```python series = client.list_series() for s in series: print(s) @@ -87,7 +85,7 @@ def list_mosaics( Example: - ``` + ```python mosaics = client.list_mosaics() for m in mosaics: print(m) @@ -115,7 +113,7 @@ def list_series_mosaics( Example: - ``` + ```python mosaics = client.list_series_mosaics("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5") for m in mosaics: print(m) @@ -129,22 +127,56 @@ def list_series_mosaics( latest=latest, )) + def summarize_quads(self, + /, + mosaic: Union[Mosaic, str], + *, + bbox: Optional[BBox] = None, + geometry: Optional[Union[dict, GeoInterface]] = None) -> dict: + """ + Get a summary of a quad list for a mosaic. + + If the bbox or geometry is not provided, the entire list is considered. + + Examples: + + Get the total number of quads in the mosaic. + + ```python + mosaic = client.get_mosaic("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5") + summary = client.summarize_quads(mosaic) + print(summary["total_quads"]) + ``` + """ + return self._client._call_sync(self._client.summarize_quads(mosaic, bbox=bbox, geometry=geometry)) + def list_quads(self, /, mosaic: Union[Mosaic, str], *, minimal: bool = False, + full_extent: bool = False, bbox: Optional[BBox] = None, - geometry: Optional[Union[dict, GeoInterface]] = None, - summary: bool = False) -> Iterator[Quad]: + geometry: Optional[Union[dict, GeoInterface]] = None) -> Iterator[Quad]: """ List the a mosaic's quads. + + Parameters: + mosaic: the mosaic to list + minimal: if False, response includes full metadata + full_extent: if True, the mosaic's extent will be used to list + bbox: only quads intersecting the bbox will be listed + geometry: only quads intersecting the geometry will be listed + + Raises: + ValueError: if `geometry`, `bbox` or `full_extent` is not specified. + Example: - ``` - mosaic = await client.get_mosaic("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5") - quads = await client.list_quads(mosaic) + ```python + mosaic = client.get_mosaic("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5") + quads = client.list_quads(mosaic) for q in quads: print(q) ``` @@ -153,9 +185,9 @@ def list_quads(self, self._client.list_quads( mosaic, minimal=minimal, + full_extent=full_extent, bbox=bbox, geometry=geometry, - summary=summary, )) def get_quad(self, mosaic: Union[Mosaic, str], quad_id: str) -> Quad: @@ -164,8 +196,8 @@ def get_quad(self, mosaic: Union[Mosaic, str], quad_id: str) -> Quad: Example: - ``` - quad = await client.get_quad("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5", "1234-5678") + ```python + quad = client.get_quad("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5", "1234-5678") print(quad) ``` """ @@ -177,9 +209,9 @@ def get_quad_contributions(self, quad: Quad) -> list[dict]: Example: - ``` - quad = await client.get_quad("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5", "1234-5678") - contributions = await client.get_quad_contributions(quad) + ```python + quad = client.get_quad("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5", "1234-5678") + contributions = client.get_quad_contributions(quad) print(contributions) ``` """ @@ -198,9 +230,9 @@ def download_quad(self, Example: - ``` - quad = await client.get_quad("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5", "1234-5678") - await client.download_quad(quad) + ```python + quad = client.get_quad("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5", "1234-5678") + client.download_quad(quad) ``` """ self._client._call_sync( @@ -224,8 +256,8 @@ def download_quads(self, Example: - ``` - mosaic = await cl.get_mosaic(name) + ```python + mosaic = cl.get_mosaic(name) client.download_quads(mosaic, bbox=(-100, 40, -100, 41)) ``` """ diff --git a/tests/integration/test_mosaics_cli.py b/tests/integration/test_mosaics_cli.py index 839f1514..d549a1af 100644 --- a/tests/integration/test_mosaics_cli.py +++ b/tests/integration/test_mosaics_cli.py @@ -40,7 +40,8 @@ def quad_item_downloads(cnt): return [{ "_links": { "download": url(f"mosaics/download-a-quad/{i}") - } + }, + "id": f"456-789{i}" } for i in range(cnt)] @@ -52,7 +53,6 @@ def quad_item_download_requests(cnt): stream=stream(), headers={ "Content-Length": "100", - "Content-Disposition": f'attachment: filename="quad-{i}.tif"' }) for i in range(cnt) ] @@ -274,9 +274,9 @@ class CLITestCase: id="mosaics search bbox summary", command=["search"], args=[uuid, "--bbox", "-100,40,-100,40", "--summary"], - output=[{ + output={ "total_quads": 1234 - }], + }, requests=[ request( f"mosaics/{uuid}", @@ -288,7 +288,7 @@ class CLITestCase: } }), request( - "mosaics/09462e5a-2af0-4de3-a710-e9010d8d4e58/quads?bbox=-100.0,40.0,-100.0,40.0&summary=true", + "mosaics/09462e5a-2af0-4de3-a710-e9010d8d4e58/quads?bbox=-100.0,40.0,-100.0,40.0&minimal=true&summary=true", { # note this gets stripped from expected output "items": [{ @@ -323,7 +323,7 @@ class CLITestCase: *quad_item_download_requests(1), ], expect_files=[ - "a mosaic/quad-0.tif", + "a mosaic/456-7890.tif", ]), CLITestCase( id="mosaics download geometry", @@ -341,11 +341,11 @@ class CLITestCase: *quad_item_download_requests(5), ], expect_files=[ - "a mosaic/quad-0.tif", - "a mosaic/quad-1.tif", - "a mosaic/quad-2.tif", - "a mosaic/quad-3.tif", - "a mosaic/quad-4.tif", + "a mosaic/456-7890.tif", + "a mosaic/456-7891.tif", + "a mosaic/456-7892.tif", + "a mosaic/456-7893.tif", + "a mosaic/456-7894.tif", ]) ]