diff --git a/CHANGES.txt b/CHANGES.txt index df98c16b3..9ab2b45ca 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,4 +1,8 @@ -1.3.4 (2019-06-27) +1.3.0 (2019-07-10) +------------------------------------- +- Added support for Analytic Feeds + +1.2.4 (2019-06-27) ------------------------------------- - Add new UDM2 and related asset types diff --git a/planet/api/__version__.py b/planet/api/__version__.py index daab838ba..19b4f1d60 100644 --- a/planet/api/__version__.py +++ b/planet/api/__version__.py @@ -1 +1 @@ -__version__ = '1.2.4' +__version__ = '1.3.0' diff --git a/planet/api/client.py b/planet/api/client.py index 2807a66de..53cd8ec52 100644 --- a/planet/api/client.py +++ b/planet/api/client.py @@ -23,6 +23,7 @@ class _Base(object): '''High-level access to Planet's API.''' + def __init__(self, api_key=None, base_url='https://api.planet.com/', workers=4): ''' @@ -272,13 +273,30 @@ def get_assets_by_id(self, item_type, id): url = 'data/v1/item-types/%s/items/%s/assets' % (item_type, id) return self._get(url).get_body() - def get_mosaics(self): + def get_mosaic_series(self, series_id): + '''Get information pertaining to a mosaics series + :returns: :py:Class:`planet.api.models.JSON` + ''' + url = self._url('basemaps/v1/series/{}'.format(series_id)) + return self._get(url, models.JSON).get_body() + + def get_mosaics_for_series(self, series_id): + '''Get list of mosaics available for a series + :returns: :py:Class:`planet.api.models.Mosaics` + ''' + url = self._url('basemaps/v1/series/{}/mosaics'.format(series_id)) + return self._get(url, models.Mosaics).get_body() + + def get_mosaics(self, name_contains=None): '''Get information for all mosaics accessible by the current user. :returns: :py:Class:`planet.api.models.Mosaics` ''' + params = {} + if name_contains: + params['name__contains'] = name_contains url = self._url('basemaps/v1/mosaics') - return self._get(url, models.Mosaics).get_body() + return self._get(url, models.Mosaics, params=params).get_body() def get_mosaic_by_name(self, name): '''Get the API representation of a mosaic by name. @@ -344,3 +362,126 @@ def download_quad(self, quad, callback=None): ''' download_url = quad['_links']['download'] return self._get(download_url, models.Body, callback=callback) + + def check_analytics_connection(self): + ''' + Validate that we can use the Analytics API. Useful to test connectivity + to test environments. + :returns: :py:Class:`planet.api.models.JSON` + ''' + return self._get(self._url('health')).get_body() + + def wfs_conformance(self): + ''' + Details about WFS3 conformance + :returns: :py:Class:`planet.api.models.JSON` + ''' + return self._get(self._url('conformance')).get_body() + + def list_analytic_subscriptions(self, feed_id): + ''' + Get subscriptions that the authenticated user has access to + :param feed_id str: Return subscriptions associated with a particular + feed only. + :raises planet.api.exceptions.APIException: On API error. + :returns: :py:Class:`planet.api.models.Subscriptions` + ''' + params = {'feedID': feed_id} + url = self._url('subscriptions') + return self._get(url, models.Subscriptions, params=params).get_body() + + def get_subscription_info(self, subscription_id): + ''' + Get the information describing a specific subscription. + :param subscription_id: + :raises planet.api.exceptions.APIException: On API error. + :returns: :py:Class:`planet.api.models.JSON` + ''' + url = self._url('subscriptions/{}'.format(subscription_id)) + return self._get(url, models.JSON).get_body() + + def list_analytic_feeds(self, stats): + ''' + Get collections that the authenticated user has access to + :raises planet.api.exceptions.APIException: On API error. + :returns: :py:Class:`planet.api.models.Feeds` + ''' + params = {'stats': stats} + url = self._url('feeds') + return self._get(url, models.Feeds, params=params).get_body() + + def get_feed_info(self, feed_id): + ''' + Get the information describing a specific collection. + :param subscription_id: + :raises planet.api.exceptions.APIException: On API error. + :returns: :py:Class:`planet.api.models.JSON` + ''' + url = self._url('feeds/{}'.format(feed_id)) + return self._get(url, models.JSON).get_body() + + def list_analytic_collections(self): + ''' + Get collections that the authenticated user has access to + :raises planet.api.exceptions.APIException: On API error. + :returns: :py:Class:`planet.api.models.WFS3Collections` + ''' + params = {} + url = self._url('collections') + return self._get(url, models.WFS3Collections, + params=params).get_body() + + def get_collection_info(self, subscription_id): + ''' + Get the information describing a specific collection. + :param subscription_id: + :raises planet.api.exceptions.APIException: On API error. + :returns: :py:Class:`planet.api.models.JSON` + ''' + url = 'collections/{}'.format(subscription_id) + return self._get(self._url(url), models.JSON).get_body() + + def list_collection_features(self, + subscription_id, + bbox, + time_range, + ): + ''' + List features for an analytic subscription. + :param subscription_id: + :param time_range str: ISO format datetime interval. + :param bbox tuple: A lon_min, lat_min, lon_max, lat_max area to search + :raises planet.api.exceptions.APIException: On API error. + :returns: :py:Class:`planet.api.models.WFS3Features` + ''' + params = { + 'time': time_range, + } + if bbox: + params['bbox'] = ','.join([str(b) for b in bbox]) + url = self._url('collections/{}/items'.format(subscription_id)) + return self._get(url, models.WFS3Features, params=params).get_body() + + def get_associated_resource_for_analytic_feature(self, + subscription_id, + feature_id, + resource_type): + ''' + Get resource associated with some feature in an analytic subscription. + Response might be JSON or a TIF, depending on requested resource. + :param subscription_id str: ID of subscription + :param feature_id str: ID of feature + :param resource_type str: Type of resource to request. + :raises planet.api.exceptions.APIException: On API error or resource + type unavailable. + :returns: :py:Class:`planet.api.models.JSON` for resource type + `source-image-info`, but can also return + :py:Class:`planet.api.models.Response` containing a + :py:Class:`planet.api.models.Body` of the resource. + ''' + url = self._url( + 'collections/{}/items/{}/resources/{}'.format(subscription_id, + feature_id, + resource_type)) + response = self._get(url).get_body() + return response diff --git a/planet/api/models.py b/planet/api/models.py index 8314b3213..aac336d86 100644 --- a/planet/api/models.py +++ b/planet/api/models.py @@ -184,9 +184,9 @@ class Paged(JSON): def next(self): links = self.get()[self.LINKS_KEY] - next = links.get(self.NEXT_KEY, None) - if next: - request = Request(next, self._request.auth, body_type=type(self)) + next_ = links.get(self.NEXT_KEY, None) + if next_: + request = Request(next_, self._request.auth, body_type=type(self)) return self._dispatcher.response(request).get_body() def _pages(self): @@ -249,6 +249,7 @@ def _json_stream(self, limit): } +# GeoJSON feature class Features(Paged): def _json_stream(self, limit): @@ -275,3 +276,45 @@ class Mosaics(Paged): class MosaicQuads(Paged): ITEM_KEY = 'items' + + +class AnalyticsPaged(Paged): + LINKS_KEY = 'links' + NEXT_KEY = 'next' + ITEM_KEY = 'data' + + def next(self): + links = self.get()[self.LINKS_KEY] + next_ = None + for link in links: + if link['rel'] == self.NEXT_KEY: + next_ = link['href'] + if next_: + request = Request(next_, self._request.auth, body_type=type(self)) + return self._dispatcher.response(request).get_body() + + +# The analytics API returns two conceptual types of objects: WFS3-compliant +# objects and everything else. There may be some overlap (ex. subscriptions and +# collections). +class Feeds(AnalyticsPaged): + pass + + +class Subscriptions(AnalyticsPaged): + pass + + +class WFS3Paged(AnalyticsPaged): + pass + + +class WFS3Collections(AnalyticsPaged): + ITEM_KEY = 'collections' + + +class WFS3Features(AnalyticsPaged): + # Explicitly disambiguate between WFS3 and GeoJSON features because the + # differences in the structure of the response envelope result in paging + # slightly differently. + ITEM_KEY = 'features' diff --git a/planet/scripts/cli.py b/planet/scripts/cli.py index 89d187e05..7b2991df9 100644 --- a/planet/scripts/cli.py +++ b/planet/scripts/cli.py @@ -25,9 +25,22 @@ def clientv1(): + client_params.pop('analytics_base_url', None) return api.ClientV1(**client_params) +def analytics_client_v1(): + # Non-default analytics base URL doesn't have the analytics postfix + params = dict(**client_params) + if client_params.get('analytics_base_url') is not None: + params['base_url'] = params.pop('analytics_base_url') + else: + params['base_url'] = 'https://api.planet.com/analytics/' + + client = api.ClientV1(**params) + return client + + def configure_logging(verbosity): '''configure logging via verbosity level of between 0 and 2 corresponding to log levels warning, info and debug respectfully.''' @@ -62,8 +75,13 @@ def configure_logging(verbosity): @click.option('-u', '--base-url', envvar='PL_API_BASE_URL', help='Change the base Planet API URL or ENV PL_API_BASE_URL' ' - Default https://api.planet.com/') +@click.option('-au', '--analytics-base-url', + envvar='PL_ANALYTICS_API_BASE_URL', + help=('Change the base Planet API URL or ENV ' + 'PL_ANALYTICS_API_BASE_URL' + ' - Default https://api.planet.com/analytics')) @click.version_option(version=__version__, message='%(version)s') -def cli(context, verbose, api_key, base_url, workers): +def cli(context, verbose, api_key, base_url, analytics_base_url, workers): '''Planet API Client''' configure_logging(verbose) @@ -73,6 +91,8 @@ def cli(context, verbose, api_key, base_url, workers): client_params['workers'] = workers if base_url: client_params['base_url'] = base_url + if analytics_base_url: + client_params['analytics_base_url'] = analytics_base_url @cli.command('help') diff --git a/planet/scripts/types.py b/planet/scripts/types.py index 7bccaf161..3bfc3ace8 100644 --- a/planet/scripts/types.py +++ b/planet/scripts/types.py @@ -285,3 +285,16 @@ def convert(self, val, param, ctx): except (TypeError, ValueError): raise click.BadParameter('Invalid bounding box') return (xmin, ymin, xmax, ymax) + + +class DateInterval(click.ParamType): + name = 'date interval' + + def convert(self, val, param, ctx): + dates = val.split('/') + if len(dates) > 2: + raise click.BadParameter('Too many dates') + + for date in dates: + if date != '..' and strp_lenient(date) is None: + raise click.BadParameter('Invalid date: {}'.format(date)) diff --git a/planet/scripts/v1.py b/planet/scripts/v1.py index 299cd4b8d..58d1d0963 100644 --- a/planet/scripts/v1.py +++ b/planet/scripts/v1.py @@ -17,7 +17,8 @@ import json from .cli import ( cli, - clientv1 + clientv1, + analytics_client_v1 ) from .opts import ( asset_type_option, @@ -31,7 +32,8 @@ from .types import ( AssetTypePerm, BoundingBox, - metavar_docs + metavar_docs, + DateInterval ) from .util import ( call_and_wrap, @@ -47,7 +49,7 @@ handle_interrupt ) from planet.api import downloader - +from planet.api.utils import write_to_file filter_opts_epilog = '\nFilter Formats:\n\n' + \ '\n'.join(['%s\n\n%s' % (k, v.replace(' ', '') @@ -153,17 +155,17 @@ def _disable_item_type(ctx, param, value): @click.option('--search-id', is_eager=True, callback=_disable_item_type, type=str, help='Use the specified search') @click.option('--dry-run', is_flag=True, help=( - 'Only report the number of items that would be downloaded.' + 'Only report the number of items that would be downloaded.' )) @click.option('--activate-only', is_flag=True, help=( - 'Only activate the items. Outputs URLS for downloading.' + 'Only activate the items. Outputs URLS for downloading.' )) @click.option('--quiet', is_flag=True, help=( - 'Disable ANSI control output' + 'Disable ANSI control output' )) @click.option('--dest', default='.', help=( - 'Location to download files to'), type=click.Path( - exists=True, resolve_path=True, writable=True, file_okay=False)) + 'Location to download files to'), type=click.Path( + exists=True, resolve_path=True, writable=True, file_okay=False)) @limit_option(None) @data.command('download', epilog=filter_opts_epilog) def download(asset_type, dest, limit, sort, search_id, dry_run, activate_only, @@ -227,19 +229,43 @@ def mosaics(): pass +@mosaics.group('series') +def series(): + '''Commands for interacting with Mosaic Series through the Mosaics API''' + pass + + +@series.command('describe') +@click.argument('series_id') +@pretty +def describe(series_id, pretty): + cl = clientv1() + echo_json_response(call_and_wrap(cl.get_mosaic_series, series_id), pretty) + + +@series.command('list-mosaics') +@click.argument('series_id') +@pretty +def list_mosaics_for_series(series_id, pretty): + client = clientv1() + series = client.get_mosaics_for_series(series_id) + echo_json_response(series, pretty) + + @mosaics.command('list') +@click.option('--prefix', default=None) @pretty -def list_mosaics(pretty): +def list_mosaics(pretty, prefix): '''List information for all available mosaics''' cl = clientv1() - echo_json_response(call_and_wrap(cl.get_mosaics), pretty) + echo_json_response(call_and_wrap(cl.get_mosaics, prefix), pretty) @mosaics.command('search') @click.argument('name') @click.option('--bbox', type=BoundingBox(), help=( - 'Region to query as a comma-delimited string:' - ' lon_min,lat_min,lon_max,lat_max' + 'Region to query as a comma-delimited string:' + ' lon_min,lat_min,lon_max,lat_max' )) @click.option('--rbox', type=BoundingBox(), help='Alias for --bbox') @limit_option(None) @@ -289,16 +315,16 @@ def quad_contributions(name, quad, pretty): @mosaics.command('download') @click.argument('name') @click.option('--bbox', type=BoundingBox(), help=( - 'Region to download as a comma-delimited string:' - ' lon_min,lat_min,lon_max,lat_max' + 'Region to download as a comma-delimited string:' + ' lon_min,lat_min,lon_max,lat_max' )) @click.option('--rbox', type=BoundingBox(), help='Alias for --bbox') @click.option('--quiet', is_flag=True, help=( - 'Disable ANSI control output' + 'Disable ANSI control output' )) @click.option('--dest', default='.', help=( - 'Location to download files to'), type=click.Path( - exists=True, resolve_path=True, writable=True, file_okay=False + 'Location to download files to'), type=click.Path( + exists=True, resolve_path=True, writable=True, file_okay=False )) @limit_option(None) def download_quads(name, bbox, rbox, quiet, dest, limit): @@ -318,3 +344,292 @@ def download_quads(name, bbox, rbox, quiet, dest, limit): # invoke the function within an interrupt handler that will shut everything # down properly handle_interrupt(dl.shutdown, dl.download, items, [], dest) + + +@cli.group('analytics') +def analytics(): + '''Commands for interacting with the Analytics Feed API''' + pass + + +@analytics.command('check-connection') +@pretty +def health(pretty): + '''Check that we can connect to the API''' + cl = analytics_client_v1() + click.echo('Using base URL: {}'.format(cl.base_url)) + response = cl.check_analytics_connection() + echo_json_response(response, pretty) + + +@analytics.command('wfs-conformance') +@pretty +def conformance(pretty): + ''' + Details about WFS3 conformance. + :param pretty: + :return: + ''' + cl = analytics_client_v1() + response = cl.wfs_conformance() + echo_json_response(response, pretty) + + +@analytics.group('feeds') +def feeds(): + '''Commands for interacting with the Analytics Feed API for collections''' + pass + + +@feeds.command('list') +@limit_option(None) +@click.option('--stats', is_flag=True, default=False, + help='Include feed stats') +@pretty +def list_feeds(pretty, limit, stats): + '''List all subscriptions user has access to.''' + cl = analytics_client_v1() + response = cl.list_analytic_feeds(stats) + echo_json_response(response, pretty, limit) + + +@feeds.command('list-mosaics') +@click.argument('feed_id') +def get_mosaic_list_for_feed(feed_id): + '''List mosaics linked to feed''' + analytics_client = analytics_client_v1() + feed_info = analytics_client.get_feed_info(feed_id).get() + + for type_ in ['target', 'source']: + feed_image_conf = feed_info.get(type_) + + if feed_image_conf['type'] != 'mosaic': + msg_format = 'The {} for this feed is not a mosaic type.' + click.ClickException(msg_format.format(type_)) + continue + + mosaic_series = feed_image_conf['config']['series_id'] + + client = clientv1() + mosaics = client.get_mosaics_for_series(mosaic_series) + + click.echo('{} mosaics:'.format(type_)) + for mosaic in mosaics.get()['mosaics']: + click.echo('\t{}'.format(mosaic['name'])) + + +@feeds.command('describe') +@click.argument('feed_id') +@pretty +def get_feed_info(feed_id, pretty): + '''Get metadata for specific feed.''' + cl = analytics_client_v1() + feed_info = cl.get_feed_info(feed_id) + echo_json_response(feed_info, pretty) + + +@analytics.group('subscriptions') +def subscriptions(): + ''' + Commands for interacting with the Analytics Feed API for subscriptions + ''' + pass + + +@subscriptions.command('list') +@click.option('--feed-id', type=str) +@limit_option(None) +@pretty +def list_subscriptions(pretty, limit, feed_id): + '''List all subscriptions user has access to.''' + cl = analytics_client_v1() + response = cl.list_analytic_subscriptions(feed_id) + echo_json_response(response, pretty, limit) + + +@subscriptions.command('list-mosaics') +@click.argument('subscription_id') +def get_mosaic_list_for_subscription(subscription_id): + '''List mosaics linked to feed''' + analytics_client = analytics_client_v1() + sub_info = analytics_client.get_subscription_info(subscription_id).get() + feed_info = analytics_client.get_feed_info(sub_info['feedID']).get() + + for type_ in ['target', 'source']: + feed_image_conf = feed_info.get(type_) + + if feed_image_conf['type'] != 'mosaic': + msg_format = 'The {} for this feed is not a mosaic type.' + click.ClickException(msg_format.format(type_)) + continue + + mosaic_series = feed_image_conf['config']['series_id'] + + client = clientv1() + mosaics = client.get_mosaics_for_series(mosaic_series) + + click.echo('{} mosaics:'.format(type_)) + for mosaic in mosaics.get()['mosaics']: + click.echo('\t{}'.format(mosaic['name'])) + + +@subscriptions.command('describe') +@click.argument('subscription_id') +@pretty +def get_subscription_info(subscription_id, pretty): + '''Get metadata for specific subscription.''' + cl = analytics_client_v1() + sub_info = cl.get_subscription_info(subscription_id) + echo_json_response(sub_info, pretty) + + +@analytics.group('collections') +def collections(): + '''Commands for interacting with the Analytics Feed API for collections''' + pass + + +@collections.command('list') +@limit_option(None) +@pretty +def list_collections(pretty, limit): + '''List all collections user has access to.''' + cl = analytics_client_v1() + response = cl.list_analytic_collections() + echo_json_response(response, pretty, limit) + + +@collections.command('list-mosaics') +@click.argument('subscription_id') +def get_mosaic_list_for_collection(subscription_id): + '''List mosaics linked to feed''' + analytics_client = analytics_client_v1() + sub_info = analytics_client.get_subscription_info(subscription_id).get() + feed_info = analytics_client.get_feed_info(sub_info['feedID']).get() + + for type_ in ['target', 'source']: + feed_image_conf = feed_info.get(type_) + + if feed_image_conf['type'] != 'mosaic': + msg_format = 'The {} for this feed is not a mosaic type.' + click.ClickException(msg_format.format(type_)) + continue + + mosaic_series = feed_image_conf['config']['series_id'] + + client = clientv1() + mosaics = client.get_mosaics_for_series(mosaic_series) + + click.echo('{} mosaics:'.format(type_)) + for mosaic in mosaics.get()['mosaics']: + click.echo('\t{}'.format(mosaic['name'])) + + +@collections.command('describe') +@click.argument('subscription_id') +@pretty +def get_collection_info(subscription_id, pretty): + '''Get metadata for specific collection.''' + cl = analytics_client_v1() + sub_info = cl.get_collection_info(subscription_id) + echo_json_response(sub_info, pretty) + + +@collections.command('resource-types') +@click.argument('subscription_id') +@pretty +def get_resource_types(subscription_id, pretty): + '''Get available resource types.''' + cl = analytics_client_v1() + # Assumes that all features in a collection have the same list of + # associated resource types + features = cl.list_collection_features(subscription_id, + None, + None) + feature_list = features.get()['features'] + if not feature_list: + click.ClickException( + 'No features found, cannot determine resource types.').show() + click.Abort() + types = {item['rel'] for item in features.get()['features'][0]['links']} + + # The client and API only support these three, but there may be more link + # types, ex. to things like tiles + supported_types = {'source-image-info', 'target-quad', 'source-quad'} + + found_types = types.intersection(supported_types) + click.echo('Found resource types: {}'.format(list(found_types))) + + +@collections.group('features') +def features(): + '''Commands for interacting with the Analytics Feed API for features''' + pass + + +@features.command('list') +@click.argument('subscription_id') +@limit_option(None) +@click.option('--bbox', type=BoundingBox(), help=( + 'Region to query as a comma-delimited string:' + ' lon_min,lat_min,lon_max,lat_max' +)) +@click.option('--rbox', type=BoundingBox(), help='Alias for --bbox') +@click.option('--time-range', type=DateInterval(), help=( + 'Time interval. Can be open or closed interval, start times are ' + 'inclusive and end times are exclusive: ' + '2019-01-01T00:00:00.00Z/2019-02-01T00:00:00.00Z (Closed interval for ' + 'January 2019), 2019-01-01T00:00:00.00Z/.. (Open interval for all ' + 'items since the start of January 2019), 2019-01-01T00:00:00.00Z ' + '(instant)' +)) +@pretty +def list_features(subscription_id, pretty, limit, rbox, bbox, time_range): + '''Request feature list for a particular subscription.''' + cl = analytics_client_v1() + bbox = bbox or rbox + features = cl.list_collection_features(subscription_id, bbox, time_range) + echo_json_response(features, pretty, limit) + + +@features.command('get') +@click.argument('resource_type', type=click.Choice( + ['source-image-info', 'target-quad', 'source-quad'])) +@click.argument('subscription_id') +@click.argument('feature_id') +@click.option('--dest', default='.', help=( + 'Location to download files to'), type=click.Path( + exists=True, resolve_path=True, writable=True, file_okay=False +)) +@pretty +def get_associated_resource(subscription_id, feature_id, resource_type, pretty, + dest): + '''Request resources for a particular subscription/feature combination.''' + # Note that this command will not work for a custom analytics URL, as the + # underlying API call is a redirect to the Data API and Mosaics API. + # See https://github.com/kennethreitz/requests/issues/2949 for more info. + cl = analytics_client_v1() + if resource_type in ['target-quad', 'source-quad']: + msg_format = 'Requesting {} for {}/{}, destination directory is: {}' + click.echo(msg_format.format( + resource_type, + subscription_id, + feature_id, + dest + )) + + resource = cl.get_associated_resource_for_analytic_feature(subscription_id, + feature_id, + resource_type) + + if resource_type == 'source-image-info': + echo_json_response(resource, pretty) + + if resource_type in ['target-quad', 'source-quad']: + writer = write_to_file(dest, None) + writer(resource) + click.echo('{} written, available at: {}/{}'.format( + resource_type, + dest, + resource.name + )) diff --git a/setup.py b/setup.py index 54306c75f..7af3a8025 100644 --- a/setup.py +++ b/setup.py @@ -47,6 +47,8 @@ 'pytest-cov', 'sphinx', 'wheel', + 'mock', + 'requests-mock', ] setup(name='planet', diff --git a/tests/fixtures/af_features.json b/tests/fixtures/af_features.json new file mode 100644 index 000000000..38fe099c1 --- /dev/null +++ b/tests/fixtures/af_features.json @@ -0,0 +1,63 @@ +{ + "features": [ + { + "created": "2019-06-21T06:17:17.503Z", + "geometry": { + "coordinates": [ + [ + [ + -5.36217571105314, + 36.089533763333 + ], + [ + -5.36213229651505, + 36.0880835477 + ], + [ + -5.36370663936721, + 36.0880524885812 + ], + [ + -5.36375008277508, + 36.0895027025726 + ], + [ + -5.36217571105314, + 36.089533763333 + ] + ] + ], + "type": "Polygon" + }, + "id": "feature-id", + "links": [ + { + "href": "https://api.planet.com/analytics/collections/collection-id/items/feature-id", + "rel": "self" + }, + { + "href": "https://api.planet.com/analytics/collections/collection-id/items/feature-id/resources/source-image-info", + "rel": "source-image-info" + }, + { + "href": "https://tiles.planet.com/data/v1/PSScene3Band/scene-id/{z}/{x}/{y}.png?api_key=key", + "rel": "source-tiles" + } + ], + "properties": { + "observed": "2019-06-15T10:37:54.572192Z", + "score": 0.9949000477790833, + "source_asset_type": "visual", + "source_cloud_cover": 0, + "source_item": "scene-id", + "source_item_id": "scene-id", + "source_item_type": "PSScene3Band", + "xmax_px": 7961, + "xmin_px": 7913, + "ymax_px": 1869, + "ymin_px": 1815 + }, + "type": "Feature" + } + ] +} diff --git a/tests/fixtures/feeds.json b/tests/fixtures/feeds.json new file mode 100644 index 000000000..92c91ac4d --- /dev/null +++ b/tests/fixtures/feeds.json @@ -0,0 +1,41 @@ +{ + "data": [ + { + "created": "2019-06-25T00:23:31.033Z", + "description": "Hello World\n", + "id": "feed_identifier", + "interval": 60, + "links": [ + { + "href": "https://api.planet.com/analytics/feeds/feed_identifier", + "rel": "self" + }, + { + "href": "https://api.planet.com/analytics/feeds", + "rel": "feeds" + } + ], + "process": { + "actions": [ + ], + "annotations": { + "service": "sif" + }, + "args": {}, + "namespace": "{{ .Namespace }}" + }, + "source": { + "config": { + "series_id": "mosaic_series_id" + }, + "type": "mosaic" + }, + "status": "active", + "target": { + "type": "collection" + }, + "title": "Feed Title", + "updated": "2019-06-25T00:23:31.033Z" + } + ] +} diff --git a/tests/fixtures/subscriptions.json b/tests/fixtures/subscriptions.json new file mode 100644 index 000000000..a7757f1f3 --- /dev/null +++ b/tests/fixtures/subscriptions.json @@ -0,0 +1,59 @@ +{ + "data": [ + { + "created": "2019-06-25T00:27:06.365Z", + "description": "Subscription Description", + "endTime": "2019-07-15T00:00:00.000Z", + "feedID": "5125e592-64c2-48db-a354-dd0128fd4fd2", + "geometry": { + "coordinates": [ + [ + [ + -105.10620117187499, + 40.513277131087484 + ], + [ + -105.10620117187499, + 40.58997103470645 + ], + [ + -104.96475219726562, + 40.58997103470645 + ], + [ + -104.96475219726562, + 40.513277131087484 + ], + [ + -105.10620117187499, + 40.513277131087484 + ] + ] + ], + "type": "Polygon" + }, + "id": "subscription_identifier", + "links": [ + { + "href": "https://api.planet.com/analytics/subscriptions/subscription_identifier", + "rel": "self" + }, + { + "href": "https://api.planet.com/analytics/collections/subscription_identifier/items", + "rel": "results" + }, + { + "href": "https://api.planet.com/analytics/feeds/feed_identifier", + "rel": "feed" + }, + { + "href": "https://api.planet.com/analytics/subscriptions", + "rel": "subscriptions" + } + ], + "startTime": "2019-03-01T00:00:00.000Z", + "title": "Subscription Title", + "updated": "2019-07-09T00:34:22.096Z" + } + ] +} diff --git a/tests/test_client.py b/tests/test_client.py index 2bad84a3c..25a3f49c4 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -196,3 +196,66 @@ def match(req): return body['filter']['config'] == {'gt': '1970-01-01T00:00:00Z'} assert_simple_request(url, client.stats, ({'request': True},), method='post', match=match) + + +def test_list_analytic_feeds(client): + url = client.base_url + 'feeds' + assert_simple_request(url, + client.list_analytic_feeds, + (False,)) + + +def test_get_feed_info(client): + url = client.base_url + 'feeds/feed-id' + assert_simple_request(url, + client.get_feed_info, + ('feed-id',)) + + +@pytest.mark.parametrize('feed_id', [None, 'feed-id']) +def test_list_analytic_subscriptions(client, feed_id): + url = client.base_url + 'subscriptions' + assert_simple_request(url, + client.list_analytic_subscriptions, + (feed_id,)) + + +def test_get_subscription_info(client): + url = client.base_url + 'subscriptions/sub-id' + assert_simple_request(url, + client.get_subscription_info, + ('sub-id',)) + + +def test_list_collections(client): + url = client.base_url + 'collections' + assert_simple_request(url, + client.list_analytic_collections, + ()) + + +def test_get_collection_info(client): + url = client.base_url + 'collections/sub-id' + assert_simple_request(url, + client.get_collection_info, + ('sub-id',)) + + +def test_list_features(client): + url = client.base_url + 'collections/sub-id/items' + assert_simple_request(url, + client.list_collection_features, + ('sub-id', None, None)) + + +# only testing source-image-info here, the other types output a file +def test_get_associated_resource(client): + sid = 'sub-id' + fid = 'feature-id' + rid = 'source-image-info' + url = client.base_url + 'collections/{}/items/{}/resources/{}'.format( + sid, fid, rid + ) + assert_simple_request(url, + client.get_associated_resource_for_analytic_feature, + (sid, fid, rid)) diff --git a/tests/test_models.py b/tests/test_models.py index 4a5b81b30..a2347dcdc 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -14,10 +14,8 @@ import io import json -from planet.api.models import Features -from planet.api.models import Paged -from planet.api.models import Request -from planet.api.models import Response +import pytest +from planet.api.models import Features, Paged, Request, Response, WFS3Features from mock import MagicMock # try: # from StringIO import StringIO as Buffy @@ -33,24 +31,37 @@ def mock_http_response(json, iter_content=None): return m -def make_page(cnt, start, key, next): +def make_page(cnt, start, key, next, af): '''fake paged content''' - return start + cnt, { - '_links': { - '_next': next - }, - key: [{ - 'thingee': start + t - } for t in range(cnt)] - } - - -def make_pages(cnt, num, key): + if af: + envelope = { + 'links': [{ + 'href': next, + 'rel': 'next', + 'title': 'NEXT' + }], + key: [{ + 'thingee': start + t + } for t in range(cnt)] + } + else: + envelope = { + '_links': { + '_next': next + }, + key: [{ + 'thingee': start + t + } for t in range(cnt)] + } + return start + cnt, envelope + + +def make_pages(cnt, num, key, af): '''generator of 'cnt' pages containing 'num' content''' start = 0 for p in range(num): next = 'page %d' % (p + 1,) if p + 1 < num else None - start, page = make_page(cnt, start, key, next) + start, page = make_page(cnt, start, key, next, af=af) yield page @@ -58,12 +69,12 @@ class Thingees(Paged): ITEM_KEY = 'thingees' -def thingees(cnt, num, key='thingees', body=Thingees): +def thingees(cnt, num, key='thingees', body=Thingees, af=False): req = Request('url', 'auth') dispatcher = MagicMock(name='dispatcher', ) # make 5 pages with 5 items on each page - pages = make_pages(5, 5, key=key) + pages = make_pages(5, 5, key=key, af=af) # initial the paged object with the first page paged = body(req, mock_http_response(json=next(pages)), dispatcher) # the remaining 4 get used here @@ -123,3 +134,18 @@ def test_features(): features_json = json.loads(buf.getvalue()) assert features_json['type'] == 'FeatureCollection' assert len(features_json['features']) == 13 + + +@pytest.mark.parametrize('limit', [None, 13]) +def test_wf3_features(limit): + pages = 5 + page_size = 6 + num_items = pages * page_size + features = thingees(page_size, pages, + body=WFS3Features, + key='features', + af=True) + buf = io.StringIO() + features.json_encode(buf, limit) + features_json = json.loads(buf.getvalue()) + assert len(features_json['features']) == limit if limit else num_items diff --git a/tests/test_v1_cli.py b/tests/test_v1_cli.py index 838ff07f9..d3e8bb26c 100644 --- a/tests/test_v1_cli.py +++ b/tests/test_v1_cli.py @@ -9,6 +9,7 @@ from planet.api import ClientV1 from planet.api import models import pytest +from _common import read_fixture # have to clear in case key is picked up via env if api.auth.ENV_KEY in os.environ: @@ -27,6 +28,13 @@ def client(): yield client +@pytest.fixture(scope="module") +def analytics_client(): + client = MagicMock(name='analytics_client', spec=ClientV1) + with patch('planet.scripts.v1.analytics_client_v1', lambda: client): + yield client + + def assert_success(result, expected_output, exit_code=0): if result.exception: print(result.output) @@ -260,3 +268,94 @@ def test_geom_filter(runner, client): args, kw = client.create_search.call_args req = args[0] assert req['filter']['config'][0]['type'] == 'GeometryFilter' + + +# For analytics entrypoints, only testing those entrypoints that have any logic +# beyond just "make client function call" + +def test_get_mosaics_list_for_feed(runner, analytics_client, client): + feeds_blob = json.loads(read_fixture('feeds.json')) + mosaics_blob = json.loads(read_fixture('list-mosaics.json')) + + configure_response(analytics_client.get_feed_info, + json.dumps(feeds_blob['data'][0])) + configure_response(client.get_mosaics_for_series, + json.dumps(mosaics_blob)) + + expected = 'source mosaics:\n\tcolor_balance_mosaic\n' + assert_success( + runner.invoke(main, [ + 'analytics', 'feeds', 'list-mosaics', 'feed-id' + ]), + expected + ) + + +def test_get_mosaics_list_for_subscription(runner, analytics_client, client): + sub_blob = json.loads(read_fixture('subscriptions.json')) + feeds_blob = json.loads(read_fixture('feeds.json')) + mosaics_blob = json.loads(read_fixture('list-mosaics.json')) + + configure_response(analytics_client.get_subscription_info, + json.dumps(sub_blob['data'][0])) + configure_response(analytics_client.get_feed_info, + json.dumps(feeds_blob['data'][0])) + configure_response(client.get_mosaics_for_series, + json.dumps(mosaics_blob)) + + expected = 'source mosaics:\n\tcolor_balance_mosaic\n' + assert_success( + runner.invoke(main, [ + 'analytics', 'subscriptions', 'list-mosaics', 'sub-id' + ]), + expected + ) + + +def test_get_mosaics_list_for_collection(runner, analytics_client, client): + sub_blob = json.loads(read_fixture('subscriptions.json')) + feeds_blob = json.loads(read_fixture('feeds.json')) + mosaics_blob = read_fixture('list-mosaics.json') + + configure_response(analytics_client.get_subscription_info, + json.dumps(sub_blob['data'][0])) + configure_response(analytics_client.get_feed_info, + json.dumps(feeds_blob['data'][0])) + configure_response(client.get_mosaics_for_series, + mosaics_blob) + + expected = 'source mosaics:\n\tcolor_balance_mosaic\n' + assert_success( + runner.invoke(main, [ + 'analytics', 'collections', 'list-mosaics', 'collection-id' + ]), + expected + ) + + +def test_get_collection_resource_types(runner, analytics_client): + feature_blob = read_fixture('af_features.json') + configure_response(analytics_client.list_collection_features, + feature_blob) + + expected_output = "Found resource types: ['source-image-info']\n" + assert_success( + runner.invoke(main, [ + 'analytics', 'collections', 'resource-types', 'collection-id' + ]), + expected_output + ) + + +def test_get_associated_resource(runner, analytics_client): + configure_response( + analytics_client.get_associated_resource_for_analytic_feature, + '{"chowda":true}', + ) + assert_success( + runner.invoke(main, [ + 'analytics', 'collections', 'features', 'get', 'source-image-info', + 'sub-id', 'feature-id' + ]), + '{"chowda":true}\n' + )