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..997c93c9 --- /dev/null +++ b/examples/mosaics-cli.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +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 "\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 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 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 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/__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..8254da44 --- /dev/null +++ b/planet/cli/mosaics.py @@ -0,0 +1,272 @@ +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 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_or_id") +@click.argument("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_or_id, quad) + await _output(cl.get_quad_contributions(item), pretty) + + +@command(mosaics, name="info") +@click.argument("name_or_id", required=True) +@include_links +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_or_id), 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 information for all available mosaics + + Example: + + planet mosaics list --name-contains global_monthly + """ + 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_or_id", required=True) +@include_links +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_or_id), 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 information for available series + + Example: + + planet mosaics series list --name-contains=Global + """ + async with client(ctx) as cl: + await _output( + cl.list_series( + name_contains=name_contains, + interval=interval, + acquired_gt=acquired_gt, + acquired_lt=acquired_lt, + ), + pretty, + links) + + +@command(series, name="list-mosaics") +@click.argument("name_or_id", 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_or_id, + acquired_gt, + acquired_lt, + latest, + pretty, + links): + """List mosaics in a series specified by name or ID + + Example: + + planet mosaics series list-mosaics global_monthly_2025_04_mosaic + """ + async with client(ctx) as cl: + await _output( + cl.list_series_mosaics(name_or_id, + acquired_gt=acquired_gt, + acquired_lt=acquired_lt, + latest=latest), + pretty, + links) + + +@command(mosaics, name="search") +@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_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: + 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") +@click.argument("name_or_id", required=True) +@click.option('--output-dir', + 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_or_id, output_dir, bbox, geometry, **kwargs): + """Download quads from a mosaic by name or ID + + Example: + + planet mosaics search global_monthly_2025_04_mosaic --bbox -100,40,-100,41 + """ + quiet = ctx.obj['QUIET'] + async with client(ctx) as cl: + await cl.download_quads(name_or_id, + 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..5e18abb1 --- /dev/null +++ b/planet/clients/mosaics.py @@ -0,0 +1,497 @@ +# 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, Optional, Sequence, Tuple, Type, TypeVar, Union, cast +from planet.clients.base import _BaseClient +from planet.constants import PLANET_BASE_URL +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 + +BASE_URL = f'{PLANET_BASE_URL}/basemaps/v1' + +T = TypeVar("T") + +Number = Union[int, float] + +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): + ITEMS_KEY = 'series' + NEXT_KEY = '_next' + + +class _MosaicsPage(Paged): + ITEMS_KEY = 'mosaics' + NEXT_KEY = '_next' + + +class _QuadsPage(Paged): + ITEMS_KEY = 'items' + NEXT_KEY = '_next' + + +def _is_uuid(val: str) -> bool: + try: + UUID(val) + return True + except ValueError: + return False + + +class MosaicsClient(_BaseClient): + """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('mosaics') + ... # 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 Mosaics + base url. + """ + super().__init__(session, base_url or BASE_URL) + + def _url(self, path: str) -> str: + return f"{self._base_url}/{path}" + + async def _get_by_name(self, path: str, pager: Type[Paged], + name: str) -> dict: + response = await self._session.request( + method='GET', + url=self._url(path), + params={ + "name__is": name, + }, + ) + listing = response.json()[pager.ITEMS_KEY] + if len(listing): + return listing[0] + # 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", + url=self._url(f"{path}/{id}")) + return response.json() + + async def _get(self, name_or_id: str, path: str, + 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 _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. + + 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. + + Parameters: + name_or_id: The name or id of the mosaic + """ + 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, + acquired_lt: Optional[str] = None) -> AsyncIterator[Series]: + """ + List the series you have access to. + + Example: + + ```python + 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 _SeriesPage(resp, self._session.request): + yield Series(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, + ) -> AsyncIterator[Mosaic]: + """ + List the mosaics you have access to. + + Example: + + ```python + mosaics = await client.list_mosaics() + 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 + resp = await self._session.request( + method='GET', + url=self._url("mosaics"), + params=params, + ) + async for item in _MosaicsPage(resp, self._session.request): + yield Mosaic(item) + + async def list_series_mosaics( + self, + /, + series: Union[Series, str], + *, + acquired_gt: Optional[str] = None, + acquired_lt: Optional[str] = None, + latest: bool = False, + ) -> AsyncIterator[Mosaic]: + """ + List the mosaics in a series. + + Example: + + ```python + mosaics = await client.list_series_mosaics("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5") + async for m in mosaics: + print(m) + ``` + """ + series_id = series + 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 + 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/{series_id}/mosaics"), + params=params, + ) + 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) -> 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, 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): + geometry = geometry.__geo_interface__ + 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) + return resp + + async def _quads_geometry(self, + mosaic: Mosaic, + geometry: dict, + minimal: bool, + summary: bool) -> Response: + params = {} + if minimal: + 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", + url=self._url(f"mosaics/{mosaic_id}/quads/search"), + params=params, + json=geometry, + ) + + async def _quads_bbox(self, + mosaic: Mosaic, + 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, mosaic: Union[Mosaic, str], quad_id: str) -> Quad: + """ + Get a mosaic's quad information. + + Example: + + ```python + quad = await client.get_quad("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5", "1234-5678") + print(quad) + ``` + """ + mosaic = await self._resolve_mosaic(mosaic) + mosaic_id = mosaic["id"] + resp = await self._session.request( + method="GET", + url=self._url(f"mosaics/{mosaic_id}/quads/{quad_id}"), + ) + return Quad(resp.json()) + + async def get_quad_contributions(self, quad: Quad) -> list[dict]: + """ + Get a mosaic's quad information. + + Example: + + ```python + 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: Quad, + *, + directory: str = ".", + overwrite: bool = False, + progress_bar: bool = False): + """ + Download a quad to a directory. + + 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: + await StreamingBody(resp).write( + dest, + # pass along despite our manual handling + overwrite=overwrite, + progress_bar=progress_bar) + + async 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. + + 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, 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"] + async for q in self.list_quads(mosaic, + minimal=True, + bbox=bbox, + geometry=geometry): + jobs.append( + self.download_quad(q, + directory=directory, + overwrite=overwrite, + progress_bar=progress_bar)) + if len(jobs) == concurrency: + await asyncio.gather(*jobs) + jobs = [] + await asyncio.gather(*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""" diff --git a/planet/sync/mosaics.py b/planet/sync/mosaics.py new file mode 100644 index 00000000..bea28d51 --- /dev/null +++ b/planet/sync/mosaics.py @@ -0,0 +1,273 @@ +# 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. + + 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. + + Parameters: + name_or_id: The name or id of the mosaic + """ + 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: + + ```python + 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: + + ```python + 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: + + ```python + 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 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) -> 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: + + ```python + mosaic = client.get_mosaic("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5") + quads = client.list_quads(mosaic) + for q in quads: + print(q) + ``` + """ + return self._client._aiter_to_iter( + self._client.list_quads( + mosaic, + minimal=minimal, + full_extent=full_extent, + bbox=bbox, + geometry=geometry, + )) + + def get_quad(self, mosaic: Union[Mosaic, str], quad_id: str) -> Quad: + """ + Get a mosaic's quad information. + + Example: + + ```python + quad = 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: + + ```python + quad = client.get_quad("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5", "1234-5678") + contributions = 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: + + ```python + quad = client.get_quad("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5", "1234-5678") + 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: + + ```python + mosaic = 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..d549a1af --- /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}") + }, + "id": f"456-789{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", + }) 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&minimal=true&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/456-7890.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/456-7890.tif", + "a mosaic/456-7891.tif", + "a mosaic/456-7892.tif", + "a mosaic/456-7893.tif", + "a mosaic/456-7894.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