Repository: planetlabs/planet-client-python
Pull request #179 (2019-07-10 19:43:30)

Analytics release 0.1


Patch:
diff --git a/CHANGES.txt b/CHANGES.txt
index df98c16..9ab2b45 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 daab838..19b4f1d 100644
--- a/planet/api/__version__.py
+++ b/planet/api/__version__.py
@@ -1,1 +1,1 @@
-__version__ = '1.2.4'
+__version__ = '1.3.0'
diff --git a/planet/api/client.py b/planet/api/client.py
index 2807a66..53cd8ec 100644
--- a/planet/api/client.py
+++ b/planet/api/client.py
@@ -23,6 +23,7 @@ from . import filters
 
 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 @@ class ClientV1(_Base):
         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 @@ class ClientV1(_Base):
         '''
         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 8314b32..aac336d 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 @@ class Paged(JSON):
         }
 
 
+# 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 89d187e..7b2991d 100644
--- a/planet/scripts/cli.py
+++ b/planet/scripts/cli.py
@@ -25,9 +25,22 @@ client_params = {}
 
 
 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 7bccaf1..3bfc3ac 100644
--- a/planet/scripts/types.py
+++ b/planet/scripts/types.py
@@ -285,3 +285,16 @@ class BoundingBox(click.ParamType):
         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 299cd4b..58d1d09 100644
--- a/planet/scripts/v1.py
+++ b/planet/scripts/v1.py
@@ -17,7 +17,8 @@ from itertools import chain
 import json
 from .cli import (
     cli,
-    clientv1
+    clientv1,
+    analytics_client_v1
 )
 from .opts import (
     asset_type_option,
@@ -31,7 +32,8 @@ from .opts import (
 from .types import (
     AssetTypePerm,
     BoundingBox,
-    metavar_docs
+    metavar_docs,
+    DateInterval
 )
 from .util import (
     call_and_wrap,
@@ -47,7 +49,7 @@ from planet.api.utils import (
     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 54306c7..7af3a80 100644
--- a/setup.py
+++ b/setup.py
@@ -47,6 +47,8 @@ dev_requires = [
     'pytest-cov',
     'sphinx',
     'wheel',
+    'mock',
+    'requests-mock',
 ]
 
 setup(name='planet',


Test patch:
diff --git a/tests/fixtures/af_features.json b/tests/fixtures/af_features.json
new file mode 100644
index 0000000..38fe099
--- /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 0000000..92c91ac
--- /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 0000000..a7757f1
--- /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 2bad84a..25a3f49 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -196,3 +196,66 @@ def test_stats(client):
         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 4a5b81b..a2347dc 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 838ff07..d3e8bb2 100644
--- a/tests/test_v1_cli.py
+++ b/tests/test_v1_cli.py
@@ -9,6 +9,7 @@ from planet.scripts import main
 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'
+    )