Repository: planetlabs/planet-client-python

Issue:
documentation for pre-release
For the first alpha release of v2 we need to be sure some documentation is in place:

* Instructions on the pip command to get it (plus also how to install from source)
* Instructions on using venv to have v1 and v2 there at the same time.

Discussion:

Patch:
diff --git a/CHANGES.txt b/CHANGES.txt
index 1c1aa42..4fe7560 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,3 +1,14 @@
+2.0a1 (2022-06-28)
+
+This is the first pre-release of the new features and interfaces coming in
+version 2.0.0 later this year. A more complete listing of changes in this
+package is coming soon. The highlights are
+
+- Requirement of Python version 3.7 or newer.
+- Support for Planet Data, Orders, and Subscription APIs.
+- A Python package (the SDK) with asyncio support.
+- A new and improved command line interface (the CLI). 
+
 1.4.6 (2020-05-18)
 
 - New: you can now pass a search string to the Orders CLI via `--ids_from_search`
diff --git a/README.md b/README.md
index 04b3f6a..31d75d6 100644
--- a/README.md
+++ b/README.md
@@ -17,17 +17,67 @@ The Planet SDK for Python is in 'pre-release' stages, working towards an
 initial release around July. Active development is tracked in the [Planet SDK 
 for Python Project](https://github.com/planetlabs/planet-client-python/projects/2). 
 The initial release will support Orders, Data and Subscription API's in the 
-command-line interface, with corresponding Python libraries. We expect 'beta' 
-milestones to be released in some form for each of the API's. After the 
+command-line interface, with corresponding Python libraries. After the 
 initial July release there will be additional work to support the remaining 
 Planet API's ([analytics](https://developers.planet.com/docs/analytics/), 
 [basemaps](https://developers.planet.com/docs/basemaps/) and 
 [tasking](https://developers.planet.com/docs/tasking/)).
 
+## Installation
+
+There are two options for installation: installing from PyPi or installing from source.
+
+Be aware that using either route will by default update your `planet` command-line and
+python library to v2, so you will no longer be able to use v1. As v2 is in a pre-release
+stage, with not all functionality implemented, we strongly recommend installing it in a 
+virtual environment - either [venv](https://python.land/virtual-environments/virtualenv) 
+or [conda](https://docs.conda.io/projects/conda/en/latest/user-guide/getting-started.html). 
+If you've never worked with either then check out our [getting started with venv and Planet 
+SDK](docs/venv-tutorial.md). For anyone planning to contribute to the SDK the instructuctions
+are a bit different, see the [testing section of CONTRIBUTING.md](https://github.com/planetlabs/planet-client-python/blob/main/CONTRIBUTING.md#testing).
+
+Both installation routes use [pip](https://pip.pypa.io/en/stable/getting-started/), Python's
+package installer.
+
+The [--user](https://pip.pypa.io/en/stable/user_guide/#user-installs)
+flag is highly recommended for those new to pip.
+
+The Planet SDK for Python requires Python 3.7+.
+
+### Installing from PyPi
+
+(Note this is not yet working, as the pre-release has not happened yet, but will soon)
+
+To get version 2 of the Planet SDK you just use pip with the `--pre` command:
+
+```console
+$ pip install planet --pre 
+```
+
+### Installing from source
+
+To install you must [clone](https://docs.github.com/en/repositories/creating-and-managing-repositories/cloning-a-repository)
+the [planet-client-python](https://github.com/planetlabs/planet-client-python) repository 
+to your local computer. After you have the repo local just navigate to the root
+directory, where this readme lives.
+
+Then you can install locally with pip:
+
+```console
+$ pip install . 
+```
+
+### Confirming its working
+
+After installation you should be able to use the command-line interface. Just type
+`planet` into the command-line and the usage and options should appear. If it doesn't 
+work don't hesitate to ask for help in the [discussions](https://github.com/planetlabs/planet-client-python/discussions/categories/q-a)
+forum.
+
 ## Documentation
 
 Documentation is currently [hosted online](https://planet-sdk-for-python-v2.readthedocs.io/en/latest/)
-It should be considered 'in progress', with many updated to come. It can also
+It should be considered 'in progress', with many updates to come. It can also
 be built and hosted locally (see [CONTRIBUTING.md](CONTRIBUTING.md)) or can be 
 read from source in the `[docs](docs/)` directory.
 
@@ -37,9 +87,8 @@ The Planet SDK includes both a Python API and a command-line interface (CLI)
 to make use of the following Planet APIs:
 
 * [orders](https://developers.planet.com/docs/orders/)
-* [data](https://developers.planet.com/docs/data/) (in progress)
-* [subscriptions](https://developers.planet.com/docs/subscriptions/) (not 
- yet implemented)
+* [data](https://developers.planet.com/docs/data/) (mostly complete)
+* [subscriptions](https://developers.planet.com/docs/subscriptions/) (in progress)
 
 This quickstart focuses on getting started with the CLI to place an order.
 For information on the Python API see the 
@@ -114,32 +163,11 @@ also lots of good information in the docs, in the
 [User Guide](https://planet-sdk-for-python.readthedocs.io/en/latest/guide/#cli)
 and the [CLI Reference](https://planet-sdk-for-python.readthedocs.io/en/latest/cli/).
 
-## Installation
-
-This repository is not yet available on PyPi or any other distribution channels. To 
-install you must [clone this repository](https://docs.github.com/en/repositories/creating-and-managing-repositories/cloning-a-repository)
-to your local computer. After you have the repo local just navigate to the root
-directory.
-
-Then you can install locally with [pip](https://pip.pypa.io):
-
-```console
-$ python -m pip install . 
-```
-
-The [--user](https://pip.pypa.io/en/stable/user_guide/#user-installs)
-flag is highly recommended for those new to pip.
-
-The Planet SDK for Python requires Python 3.7+.
-
-After installation you should be able to use the command-line interface. Just type
-`planet` into the command-line and the usage and options should appear. If it doesn't 
-work don't hesitate to ask for help in the [discussions](https://github.com/planetlabs/planet-client-python/discussions/categories/q-a)
-
 ## Authentication
 
 Planet's APIs require an account for use.
-[Sign up here](https://www.planet.com/explorer/?signup).
+[Sign up here](https://www.planet.com/signup/) or if you are developer try the 
+[developer trial](https://developers.planet.com/devtrial/).
 
 ## Development
 
diff --git a/docs/guide.md b/docs/guide.md
index 5126565..73a7a91 100644
--- a/docs/guide.md
+++ b/docs/guide.md
@@ -481,3 +481,33 @@ Example: `tools.json`
 ]
 ```
 
+### Data API
+
+Most `data` cli commands are simple wrappers around the
+[Planet Data API](https://developers.planet.com/docs/apis/data/reference/)
+commands with the only difference being the addition of functionality to create
+a search filter, activate an asset, poll for when activation is complete, and
+download the asset.
+
+
+#### Filter
+
+The search-related Data API CLI commands require a search filter. The filter
+CLI command provides basic functionality for generating this filter. For
+more advanced functionality, use the Python API `data_filter` commands.
+
+The following is an example of using the filter command to generate a filter
+that specifies an aquired date range and AOI:
+
+```console
+$ planet data filter \
+    --date-range acquired gte 2022-01-01 \
+    --date-range acquired lt 2022-02-01 \
+    --geom aoi.json
+```
+
+This can be fed directly into a search command e.g.:
+
+```console
+$ planet data filter --geom aoi.json | planet data search-quick PSScene -
+```
diff --git a/docs/venv-tutorial.md b/docs/venv-tutorial.md
new file mode 100644
index 0000000..08ca418
--- /dev/null
+++ b/docs/venv-tutorial.md
@@ -0,0 +1,125 @@
+## Getting started with venv & Planet SDK
+
+There are lots of great resources online about virtual environments for python.
+This tutorial is aimed at users of the Planet SDK who have little or no experience
+with virtual environments but want to be early users of v2 (and still use
+their v1 when testing).
+
+### Conda vs venv
+
+There are two main options in the Python world for virtual environments:
+
+* [venv](https://docs.python.org/3/library/venv.html)
+* [conda](https://conda.io)
+
+Both are great options. Venv is built into python (version 3.3 and above), so if you've got python
+then it's already there. Conda does a bit 'more', as venv just works for python, while conda
+creates virtual environments for all libraries and languages (but was designed for python). We
+recommend venv, as [GDAL](https://gdal.org/), arguably the most useful geospatial command-lines,
+can sometimes be a pain with Conda. And our releases are not (yet) targeting conda packaging.
+
+### venv pre-requisites
+
+The main pre-requisite for venv is that you have Python 3.3 and above. To check your python
+version type:
+
+```console
+ $ python --version
+```
+
+ If you installed on Windows or Linux you most likely have python 3. Mac OS ships by default with
+ python 2, so you'll need to install python 3. 
+
+#### Installing Python 3 on a Mac.
+
+ Our recommended route is to install [homebrew](https://brew.sh/). To do that you just run the 
+ following on your command-line:
+
+```console
+ $ /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
+```
+
+After it download and installs you just run:
+
+```console
+ $ brew install python
+```
+
+ That will set up a command called `python3` that you can use to call python3.
+
+### Setting your venvs
+
+It's good to read up on how venv works, we recommend 
+[this tutorial](https://www.dataquest.io/blog/a-complete-guide-to-python-virtual-environments/)
+to get a solid understanding.
+
+To be able to work with both the v1 stable and v2 pre-release we recommend setting up 2 virtual enviroments.
+A good place to put them is in a .venv directory.
+
+```console
+ $ mkdir ~/.venv
+ $ cd ~/.venv
+```
+
+ Then you'll want to create a venv for v1 and v2. First create the
+ v1 virtual environment with:
+
+```console
+  $ python3 -m venv ~/.venv/planet-v1
+```
+
+ Then you can activate on a mac or linux with:
+
+```console
+ $ source ~/.venv/planet-v1/bin/activate
+```
+
+and on windows with:
+
+```console
+  $ planet-v1\Scripts\activate.bat
+```
+
+Then you'll be in your new virtual environment. If you already have v1 installed
+it will likely work. But you can install it with:
+
+```console
+  $ pip install planet
+```
+
+Run `planet --version` to check which version it is. If you don't see a v1 then you may
+need to adjust your path (ask in [discussions](https://github.com/planetlabs/planet-client-python/discussions)
+if you're having trouble, and we can add more info here).
+
+Then you'll want to deactivate, and set up v2:
+
+```console
+ $ deactivate
+```
+
+ Takes you out of the virtual environment, to your base install.
+
+ Then set up v2, like you did v1:
+
+```console
+ $ python3 -m venv ~/.venv/planet-v2
+```
+
+And activate the same way. Then install as instructed in [the readme](../README.md#installation).
+
+### Working with virtual environments
+
+It's easy to switch in and out of the virtual environments, just `activate` and `deactivate`. You can 
+install additional tools with pip on each. The core directories and files remain the same, so you can 
+use both v1 and v2 in the same directory. You can even work with them side by side, just open two 
+different terminal windows, and activate v1 in one, and v2 in the other.
+
+You should see a different command-line prompt for each, to easily tell which one you are in:
+
+```console
+ $ (planet-v1) user@computer ~ $
+ ```
+
+Note that non-python programs (like from node, or C/C++) will be available in both environments. If you want
+to manage those in a virtual environment then Conda is the way to go, or even full on [docker](https://www.docker.com/)
+containers.
\ No newline at end of file
diff --git a/planet/__version__.py b/planet/__version__.py
index 0e77cbd..5df003e 100644
--- a/planet/__version__.py
+++ b/planet/__version__.py
@@ -1,1 +1,1 @@
-__version__ = '2.0dev'
+__version__ = '2.0a1'
diff --git a/planet/cli/data.py b/planet/cli/data.py
index 481f039..a09b0b5 100644
--- a/planet/cli/data.py
+++ b/planet/cli/data.py
@@ -1,14 +1,28 @@
+# Copyright 2022 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.
 """The Planet Data CLI."""
-
+from datetime import datetime
 import json
-from typing import List
+from typing import List, Optional
 from contextlib import asynccontextmanager
 
 import click
-from planet import DataClient, Session
 
+from planet import data_filter, exceptions, io, DataClient
 from .cmds import coro, translate_exceptions
 from .io import echo_json
+from .session import CliSession
 
 pretty = click.option('--pretty', is_flag=True, help='Pretty-print output.')
 
@@ -17,7 +31,7 @@ pretty = click.option('--pretty', is_flag=True, help='Pretty-print output.')
 async def data_client(ctx):
     auth = ctx.obj['AUTH']
     base_url = ctx.obj['BASE_URL']
-    async with Session(auth=auth) as sess:
+    async with CliSession(auth=auth) as sess:
         cl = DataClient(sess, base_url=base_url)
         yield cl
 
@@ -66,7 +80,272 @@ def parse_filter(ctx, param, value: str) -> dict:
         return json_value
 
 
-# TODO: filter().
+def geom_to_filter(ctx, param, value: str) -> dict:
+    if value is None:
+        return value
+
+    geom = _parse_geom(ctx, param, value)
+    return data_filter.geometry_filter(geom)
+
+
+def _parse_geom(ctx, param, value: str) -> dict:
+    """Turn geom JSON into a dict."""
+    # read from raw json
+    if value.startswith('{'):
+        try:
+            json_value = json.loads(value)
+        except json.decoder.JSONDecodeError:
+            raise click.BadParameter('geom does not contain valid json.',
+                                     ctx=ctx,
+                                     param=param)
+        if json_value == {}:
+            raise click.BadParameter('geom is empty.', ctx=ctx, param=param)
+    # read from stdin or file
+    else:
+        try:
+            with click.open_file(value) as f:
+                json_value = json.load(f)
+        except json.decoder.JSONDecodeError:
+            raise click.BadParameter('geom does not contain valid json.',
+                                     ctx=ctx,
+                                     param=param)
+    return json_value
+
+
+class FieldType(click.ParamType):
+    """Clarify that this entry is for a field"""
+    name = 'field'
+
+
+class ComparisonType(click.ParamType):
+    name = 'comp'
+    valid = ['lt', 'lte', 'gt', 'gte']
+
+    def convert(self, value, param, ctx) -> str:
+        if value not in self.valid:
+            self.fail(f'COMP ({value}) must be one of {",".join(self.valid)}',
+                      param,
+                      ctx)
+        return value
+
+
+class GTComparisonType(ComparisonType):
+    """Only support gt or gte comparison"""
+    valid = ['gt', 'gte']
+
+
+class DateTimeType(click.ParamType):
+    name = 'datetime'
+
+    def convert(self, value, param, ctx) -> datetime:
+        if not isinstance(value, datetime):
+            try:
+                value = io.str_to_datetime(value)
+            except exceptions.PlanetError as e:
+                self.fail(str(e))
+
+        return value
+
+
+class CommaSeparatedString(click.types.StringParamType):
+    """A list of strings that is extracted from a comma-separated string."""
+
+    def convert(self, value, param, ctx) -> List[str]:
+        value = super().convert(value, param, ctx)
+
+        if not isinstance(value, list):
+            value = [part.strip() for part in value.split(",")]
+
+        return value
+
+
+class CommaSeparatedFloat(click.types.StringParamType):
+    """A list of floats that is extracted from a comma-separated string."""
+    name = 'VALUE'
+
+    def convert(self, value, param, ctx) -> List[float]:
+        values = CommaSeparatedString().convert(value, param, ctx)
+
+        try:
+            ret = [float(v) for v in values]
+        except ValueError:
+            self.fail(f'Cound not convert all entries in {value} to float.')
+
+        return ret
+
+
+def assets_to_filter(ctx, param, assets: List[str]) -> Optional[dict]:
+    # TODO: validate and normalize
+    return data_filter.asset_filter(assets) if assets else None
+
+
+def date_range_to_filter(ctx, param, values) -> Optional[List[dict]]:
+
+    def _func(obj):
+        field, comp, value = obj
+        kwargs = {'field_name': field, comp: value}
+        return data_filter.date_range_filter(**kwargs)
+
+    return [_func(v) for v in values] if values else None
+
+
+def range_to_filter(ctx, param, values) -> Optional[List[dict]]:
+
+    def _func(obj):
+        field, comp, value = obj
+        kwargs = {'field_name': field, comp: value}
+        return data_filter.range_filter(**kwargs)
+
+    return [_func(v) for v in values] if values else None
+
+
+def update_to_filter(ctx, param, values) -> Optional[List[dict]]:
+
+    def _func(obj):
+        field, comp, value = obj
+        kwargs = {'field_name': field, comp: value}
+        return data_filter.update_filter(**kwargs)
+
+    return [_func(v) for v in values] if values else None
+
+
+def number_in_to_filter(ctx, param, values) -> Optional[List[dict]]:
+
+    def _func(obj):
+        field, values = obj
+        return data_filter.number_in_filter(field_name=field, values=values)
+
+    return [_func(v) for v in values] if values else None
+
+
+def string_in_to_filter(ctx, param, values) -> Optional[List[dict]]:
+
+    def _func(obj):
+        field, values = obj
+        return data_filter.string_in_filter(field_name=field, values=values)
+
+    return [_func(v) for v in values] if values else None
+
+
+@data.command()
+@click.pass_context
+@translate_exceptions
+@pretty
+@click.option('--asset',
+              type=CommaSeparatedString(),
+              default=None,
+              callback=assets_to_filter,
+              help="""Filter to items with one or more of specified assets.
+    VALUE is a comma-separated list of entries.
+    When multiple entries are specified, an implicit 'or' logic is applied.""")
+@click.option('--date-range',
+              type=click.Tuple([FieldType(), ComparisonType(),
+                                DateTimeType()]),
+              callback=date_range_to_filter,
+              multiple=True,
+              help="""Filter by date range in field.
+    FIELD is the name of the field to filter on.
+    COMP can be lt, lte, gt, or gte.
+    DATETIME can be an RFC3339 or ISO 8601 string.""")
+@click.option('--geom',
+              type=str,
+              default=None,
+              callback=geom_to_filter,
+              help='Filter to items that overlap a given geometry.')
+@click.option('--number-in',
+              type=click.Tuple([FieldType(), CommaSeparatedFloat()]),
+              multiple=True,
+              callback=number_in_to_filter,
+              help="""Filter field by numeric in.
+    FIELD is the name of the field to filter on.
+    VALUE is a comma-separated list of entries.
+    When multiple entries are specified, an implicit 'or' logic is applied.""")
+@click.option('--range',
+              'nrange',
+              type=click.Tuple([FieldType(), ComparisonType(), float]),
+              callback=range_to_filter,
+              multiple=True,
+              help="""Filter by date range in field.
+    FIELD is the name of the field to filter on.
+    COMP can be lt, lte, gt, or gte.
+    DATETIME can be an RFC3339 or ISO 8601 string.""")
+@click.option('--string-in',
+              type=click.Tuple([FieldType(), CommaSeparatedString()]),
+              multiple=True,
+              callback=string_in_to_filter,
+              help="""Filter field by numeric in.
+    FIELD is the name of the field to filter on.
+    VALUE is a comma-separated list of entries.
+    When multiple entries are specified, an implicit 'or' logic is applied.""")
+@click.option(
+    '--update',
+    type=click.Tuple([FieldType(), GTComparisonType(), DateTimeType()]),
+    callback=update_to_filter,
+    multiple=True,
+    help="""Filter to items with changes to a specified field value made after
+    a specified date.
+    FIELD is the name of the field to filter on.
+    COMP can be gt or gte.
+    DATETIME can be an RFC3339 or ISO 8601 string.""")
+@click.option('--permission',
+              type=bool,
+              default=True,
+              show_default=True,
+              help='Filter to assets with download permissions.')
+@click.option('--std-quality',
+              type=bool,
+              default=True,
+              show_default=True,
+              help='Filter to standard quality.')
+def filter(ctx,
+           asset,
+           date_range,
+           geom,
+           number_in,
+           nrange,
+           string_in,
+           update,
+           permission,
+           pretty,
+           std_quality):
+    """Create a structured item search filter.
+
+    This command provides basic functionality for specifying a filter by
+    creating an AndFilter with the filters identified with the options as
+    inputs. This is only a subset of the complex filtering supported by the
+    API. For advanced filter creation, either create the filter by hand or use
+    the Python API.
+    """
+    permission = data_filter.permission_filter() if permission else None
+    std_quality = data_filter.std_quality_filter() if std_quality else None
+
+    filter_options = (asset,
+                      date_range,
+                      geom,
+                      number_in,
+                      nrange,
+                      string_in,
+                      update,
+                      permission,
+                      std_quality)
+
+    # options allowing multiples are broken up so one filter is created for
+    # each time the option is specified
+    # unspecified options are skipped
+    filters = []
+    for f in filter_options:
+        if f:
+            if isinstance(f, list):
+                filters.extend(f)
+            else:
+                filters.append(f)
+
+    if filters:
+        if len(filters) > 1:
+            filt = data_filter.and_filter(filters)
+        else:
+            filt = filters[0]
+        echo_json(filt, pretty)
 
 
 @data.command()
@@ -85,8 +364,11 @@ def parse_filter(ctx, param, value: str) -> dict:
               default=100,
               help='Maximum number of results to return. Defaults to 100.')
 async def search_quick(ctx, item_types, filter, name, limit, pretty):
-    """This function executes a structured item search using the item_types,
+    """Execute a structured item search.
+
+    This function executes a structured item search using the item_types,
     and json filter specified (using file or stdin).
+
     Quick searches are stored for approximately 30 days and the --name
     parameter will be applied to the stored quick search. This function
     outputs a series of GeoJSON descriptions, one for each of the returned
@@ -118,13 +400,14 @@ async def search_quick(ctx, item_types, filter, name, limit, pretty):
               is_flag=True,
               help='Send a daily email when new results are added.')
 async def search_create(ctx, name, item_types, filter, daily_email, pretty):
-    """ This function creates a new saved structured item search, using the
+    """Create a new saved structured item search.
+
+    This function creates a new saved structured item search, using the
     name of the search, item_types, and json filter specified (using file or
     stdin). If specified, the "--daily_email" option enables users to recieve
     an email when new results are available each day. This function outputs a
     full JSON description of the created search. The output can also be
     optionally pretty-printed using "--pretty".
-
     """
     async with data_client(ctx) as cl:
         items = await cl.create_search(name=name,
@@ -141,7 +424,7 @@ async def search_create(ctx, name, item_types, filter, daily_email, pretty):
 @pretty
 @click.argument('search_id')
 async def search_get(ctx, search_id, pretty):
-    """Get saved search.
+    """Get a saved search.
 
     This function obtains an existing saved search, using the search_id.
     This function outputs a full JSON description of the identified saved
diff --git a/planet/cli/orders.py b/planet/cli/orders.py
index 92bdc6c..f357bd6 100644
--- a/planet/cli/orders.py
+++ b/planet/cli/orders.py
@@ -20,10 +20,11 @@ from pathlib import Path
 import click
 
 import planet
-from planet import OrdersClient, Session  # allow mocking
+from planet import OrdersClient  # allow mocking
 from .cmds import coro, translate_exceptions
 from .io import echo_json
 from .options import pretty
+from .session import CliSession
 
 LOGGER = logging.getLogger(__name__)
 
@@ -32,7 +33,7 @@ LOGGER = logging.getLogger(__name__)
 async def orders_client(ctx):
     auth = ctx.obj['AUTH']
     base_url = ctx.obj['BASE_URL']
-    async with Session(auth=auth) as sess:
+    async with CliSession(auth=auth) as sess:
         cl = OrdersClient(sess, base_url=base_url)
         yield cl
 
diff --git a/planet/cli/session.py b/planet/cli/session.py
new file mode 100644
index 0000000..47baac1
--- /dev/null
+++ b/planet/cli/session.py
@@ -0,0 +1,11 @@
+"""CLI HTTP/auth sessions."""
+
+from planet.auth import AuthType
+from planet.http import Session
+
+
+class CliSession(Session):
+
+    def __init__(self, auth: AuthType = None):
+        super().__init__(auth)
+        self._client.headers.update({'X-Planet-App': 'python-cli'})
diff --git a/planet/cli/subscriptions.py b/planet/cli/subscriptions.py
index 0b9fe0b..3f465a0 100644
--- a/planet/cli/subscriptions.py
+++ b/planet/cli/subscriptions.py
@@ -4,9 +4,9 @@ import json
 
 import click
 
-from planet import Session
-from planet.cli.cmds import coro, translate_exceptions
-from planet.cli.io import echo_json
+from .cmds import coro, translate_exceptions
+from .io import echo_json
+from .session import CliSession
 from planet.clients.subscriptions import SubscriptionsClient
 
 
@@ -39,7 +39,7 @@ def subscriptions(ctx):
 @coro
 async def list_subscriptions_cmd(ctx, status, limit, pretty):
     """Prints a sequence of JSON-encoded Subscription descriptions."""
-    async with Session(auth=ctx.obj['AUTH']) as session:
+    async with CliSession(auth=ctx.obj['AUTH']) as session:
         client = SubscriptionsClient(session)
         filtered_subs = client.list_subscriptions(status=status, limit=limit)
         async for sub in filtered_subs:
@@ -75,7 +75,7 @@ def parse_request(ctx, param, value: str) -> dict:
 @coro
 async def create_subscription_cmd(ctx, request, pretty):
     """Submits a subscription request and prints the API response."""
-    async with Session(auth=ctx.obj['AUTH']) as session:
+    async with CliSession(auth=ctx.obj['AUTH']) as session:
         client = SubscriptionsClient(session)
         sub = await client.create_subscription(request)
         echo_json(sub, pretty)
@@ -89,7 +89,7 @@ async def create_subscription_cmd(ctx, request, pretty):
 @coro
 async def cancel_subscription_cmd(ctx, subscription_id, pretty):
     """Cancels a subscription and prints the API response."""
-    async with Session(auth=ctx.obj['AUTH']) as session:
+    async with CliSession(auth=ctx.obj['AUTH']) as session:
         client = SubscriptionsClient(session)
         sub = await client.cancel_subscription(subscription_id)
         echo_json(sub, pretty)
@@ -104,7 +104,7 @@ async def cancel_subscription_cmd(ctx, subscription_id, pretty):
 @coro
 async def update_subscription_cmd(ctx, subscription_id, request, pretty):
     """Cancels a subscription and prints the API response."""
-    async with Session(auth=ctx.obj['AUTH']) as session:
+    async with CliSession(auth=ctx.obj['AUTH']) as session:
         client = SubscriptionsClient(session)
         sub = await client.update_subscription(subscription_id, request)
         echo_json(sub, pretty)
@@ -125,7 +125,7 @@ async def describe_subscription_cmd(ctx, subscription_id, pretty):
     After we refactor we will change to mocking the API.
 
     """
-    async with Session(auth=ctx.obj['AUTH']) as session:
+    async with CliSession(auth=ctx.obj['AUTH']) as session:
         client = SubscriptionsClient(session)
         sub = await client.get_subscription(subscription_id)
         echo_json(sub, pretty)
@@ -161,7 +161,7 @@ async def list_subscription_results_cmd(ctx,
                                         status,
                                         limit):
     """Gets results of a subscription and prints the API response."""
-    async with Session(auth=ctx.obj['AUTH']) as session:
+    async with CliSession(auth=ctx.obj['AUTH']) as session:
         client = SubscriptionsClient(session)
         filtered_results = client.get_results(subscription_id,
                                               status=status,
diff --git a/planet/clients/subscriptions.py b/planet/clients/subscriptions.py
index e042e57..990105c 100644
--- a/planet/clients/subscriptions.py
+++ b/planet/clients/subscriptions.py
@@ -1,77 +1,79 @@
 """Planet Subscriptions API Python client."""
 
-import itertools
-from typing import AsyncIterator, Dict, Optional, Set
-import uuid
+import logging
+from typing import AsyncIterator, Optional, Set
 
-from planet.exceptions import APIError
+from httpx import URL
 
-# Collections of fake subscriptions and results for testing. Tests will
-# monkeypatch these attributes.
-_fake_subs: Dict[str, dict] = {}
-_fake_sub_results: Dict[str, list] = {}
+from planet.exceptions import APIError, ClientError, PagingError
+from planet.http import Session
+from planet.models import Paged
 
+LOGGER = logging.getLogger()
 
-async def _server_subscriptions_post(request):
-    missing_keys = {'name', 'delivery', 'source'} - request.keys()
-    if missing_keys:
-        raise RuntimeError(f"Request lacks required members: {missing_keys!r}")
 
-    id = str(uuid.uuid4())
-    _fake_subs[id] = request
-    sub = _fake_subs[id].copy()
-    sub.update(id=id)
-    return sub
+class _Pager(Paged):
+    """Navigates pages of messages about subscriptions or results."""
 
+    # This constructor overrides Paged's and takes an httpx
+    # async client as the first argument.
+    def __init__(self, client, request, limit=None):
+        self.request = request
+        self.client = client
+        self._pages = None
+        self._items = []
+        self.i = 0
+        self.limit = limit
 
-async def _server_subscriptions_get(status=None, limit=None):
-    select_subs = (dict(**sub, id=sub_id) for sub_id,
-                   sub in _fake_subs.items()
-                   if not status or sub['status'] in status)
-    filtered_subs = itertools.islice(select_subs, limit)
-    for sub in filtered_subs:
-        yield sub
+    # This method uses the instance's httpx client to build and
+    # send requests. It's less abstracted than Paged's method.
+    async def _get_pages(self):
+        LOGGER.debug('getting first page')
+        resp = await self.client.send(self.request)
+        page = resp.json()
+        yield page
 
+        next_url = self._next_link(page)
 
-async def _server_subscriptions_id_cancel_post(subscription_id):
-    sub = _fake_subs.pop(subscription_id)
-    sub.update(id=subscription_id)
-    return sub
+        while (next_url):  # pragma: no branch
+            LOGGER.debug('getting next page')
+            request = self.client.build_request('GET', next_url)
+            resp = await self.client.send(request)
+            page = resp.json()
+            yield page
 
+            # If the next URL is the same as the previous URL we will
+            # get the same response and be stuck in a page cycle. This
+            # has happened in development and could happen in the case
+            # of a bug in the production API.
+            prev_url = next_url
+            next_url = self._next_link(page)
 
-async def _server_subscriptions_id_put(subscription_id, request):
-    _fake_subs[subscription_id].update(**request)
-    sub = _fake_subs[subscription_id].copy()
-    sub.update(id=subscription_id)
-    return sub
-
-
-async def _server_subscriptions_id_get(subscription_id):
-    sub = _fake_subs[subscription_id].copy()
-    sub.update(id=subscription_id)
-    return sub
-
-
-async def _server_subscriptions_id_results_get(subscription_id,
-                                               status=None,
-                                               limit=None):
-    select_results = (result for result in _fake_sub_results[subscription_id]
-                      if not status or result['status'] in status)
-    filtered_results = itertools.islice(select_results, limit)
-    for result in filtered_results:
-        yield result
+            if next_url == prev_url:
+                raise PagingError(
+                    "Page cycle detected at {!r}".format(next_url))
 
 
 class SubscriptionsClient:
-    """The Planet Subscriptions API client.
+    """A Planet Subscriptions Service API 1.0.0 client.
 
-    TODO: make requests to the production API. Currently, the backend
-    is fake and exists at the top of this class's module.
+    The methods of this class forward request parameters to the
+    operations described in the Planet Subscriptions Service API 1.0.0
+    (https://api.planet.com/subscriptions/v1/spec) using HTTP 1.1.
 
+    The methods generally return or yield Python dicts with the same
+    structure as the JSON messages used by the service API. Many of the
+    exceptions raised by this class are categorized by HTTP client
+    (4xx) or server (5xx) errors. This client's level of abstraction is
+    low.
+
+    Attributes:
+        session (Session): an authenticated session which wraps the
+            low-level HTTP client.
     """
 
-    def __init__(self, session=None) -> None:
-        self._session = session
+    def __init__(self, session: Session) -> None:
+        self.session = session
 
     async def list_subscriptions(self,
                                  status: Optional[Set[str]] = None,
@@ -79,9 +81,9 @@ class SubscriptionsClient:
         """Get account subscriptions with optional filtering.
 
         Note:
-            The name of this method is based on the API's method name. This
-            method provides iteration over subcriptions, it does not return
-            a list.
+            The name of this method is based on the API's method name.
+            This method provides iteration over subcriptions, it does
+            not return a list.
 
         Args:
             status (Set[str]): pass subscriptions with status in this
@@ -97,18 +99,27 @@ class SubscriptionsClient:
         Raises:
             APIError: on an API server error.
             ClientError: on a client error.
-
         """
+
+        class _SubscriptionsPager(_Pager):
+            """Navigates pages of messages about subscriptions."""
+            ITEMS_KEY = 'subscriptions'
+
+        params = {'status': [val for val in status or {}]}
+        url = URL('https://api.planet.com/subscriptions/v1', params=params)
+        req = self.session._client.build_request('GET', url)
+
         try:
-            # TODO: replace with httpx request.
-            async for sub in _server_subscriptions_get(status=status,
-                                                       limit=limit):
+            async for sub in _SubscriptionsPager(self.session._client,
+                                                 req,
+                                                 limit=limit):
                 yield sub
-        except Exception as server_error:
-            # TODO: remove "from server_error" clause. It's useful
-            # during development but may invite users to depend on the
-            # value of server_error.
-            raise APIError("Subscription failure") from server_error
+        # Forward APIError. We don't strictly need this clause, but it
+        # makes our intent clear.
+        except APIError:
+            raise
+        except ClientError:  # pragma: no cover
+            raise
 
     async def create_subscription(self, request: dict) -> dict:
         """Create a Subscription.
@@ -122,43 +133,49 @@ class SubscriptionsClient:
         Raises:
             APIError: on an API server error.
             ClientError: on a client error.
-
         """
+
+        url = URL('https://api.planet.com/subscriptions/v1')
+        req = self.session._client.build_request('POST', url, json=request)
+
         try:
-            # TODO: replace with httpx request.
-            sub = await _server_subscriptions_post(request)
-        except Exception as server_error:
-            # TODO: remove "from server_error" clause. It's useful
-            # during development but may invite users to depend on the
-            # value of server_error.
-            raise APIError("Subscription failure") from server_error
+            resp = await self.session._client.send(req)
+        # Forward APIError. We don't strictly need this clause, but it
+        # makes our intent clear.
+        except APIError:
+            raise
+        except ClientError:  # pragma: no cover
+            raise
         else:
+            sub = resp.json()
             return sub
 
-    async def cancel_subscription(self, subscription_id: str) -> dict:
+    async def cancel_subscription(self, subscription_id: str) -> None:
         """Cancel a Subscription.
 
         Args:
             subscription_id (str): id of subscription to cancel.
 
         Returns:
-            dict: description of cancelled subscription.
+            None
 
         Raises:
             APIError: on an API server error.
             ClientError: on a client error.
-
         """
+        url = URL(
+            f'https://api.planet.com/subscriptions/v1/{subscription_id}/cancel'
+        )
+        req = self.session._client.build_request('POST', url)
+
         try:
-            # TODO: replace with httpx request.
-            sub = await _server_subscriptions_id_cancel_post(subscription_id)
-        except Exception as server_error:
-            # TODO: remove "from server_error" clause. It's useful
-            # during development but may invite users to depend on the
-            # value of server_error.
-            raise APIError("Subscription failure.") from server_error
-        else:
-            return sub
+            _ = await self.session._client.send(req)
+        # Forward APIError. We don't strictly need this clause, but it
+        # makes our intent clear.
+        except APIError:
+            raise
+        except ClientError:  # pragma: no cover
+            raise
 
     async def update_subscription(self, subscription_id: str,
                                   request: dict) -> dict:
@@ -174,17 +191,20 @@ class SubscriptionsClient:
         Raises:
             APIError: on an API server error.
             ClientError: on a client error.
-
         """
+        url = URL(f'https://api.planet.com/subscriptions/v1/{subscription_id}')
+        req = self.session._client.build_request('PUT', url, json=request)
+
         try:
-            # TODO: replace with httpx request.
-            sub = await _server_subscriptions_id_put(subscription_id, request)
-        except Exception as server_error:
-            # TODO: remove "from server_error" clause. It's useful
-            # during development but may invite users to depend on the
-            # value of server_error.
-            raise APIError("Subscription failure.") from server_error
+            resp = await self.session._client.send(req)
+        # Forward APIError. We don't strictly need this clause, but it
+        # makes our intent clear.
+        except APIError:
+            raise
+        except ClientError:  # pragma: no cover
+            raise
         else:
+            sub = resp.json()
             return sub
 
     async def get_subscription(self, subscription_id: str) -> dict:
@@ -199,17 +219,20 @@ class SubscriptionsClient:
         Raises:
             APIError: on an API server error.
             ClientError: on a client error.
-
         """
+        url = URL(f'https://api.planet.com/subscriptions/v1/{subscription_id}')
+        req = self.session._client.build_request('GET', url)
+
         try:
-            # TODO: replace with httpx request.
-            sub = await _server_subscriptions_id_get(subscription_id)
-        except Exception as server_error:
-            # TODO: remove "from server_error" clause. It's useful
-            # during development but may invite users to depend on the
-            # value of server_error.
-            raise APIError("Subscription failure.") from server_error
+            resp = await self.session._client.send(req)
+        # Forward APIError. We don't strictly need this clause, but it
+        # makes our intent clear.
+        except APIError:
+            raise
+        except ClientError:  # pragma: no cover
+            raise
         else:
+            sub = resp.json()
             return sub
 
     async def get_results(self,
@@ -237,15 +260,27 @@ class SubscriptionsClient:
         Raises:
             APIError: on an API server error.
             ClientError: on a client error.
-
         """
+
+        class _ResultsPager(_Pager):
+            """Navigates pages of messages about subscription results."""
+            NEXT_KEY = '_next'
+            ITEMS_KEY = 'results'
+
+        params = {'status': [val for val in status or {}]}
+        url = URL(
+            'https://api.planet.com/subscriptions/v1/{subscription_id}/results',
+            params=params)
+        req = self.session._client.build_request('GET', url)
+
         try:
-            # TODO: replace with httpx request.
-            async for result in _server_subscriptions_id_results_get(
-                    subscription_id, status=status, limit=limit):
-                yield result
-        except Exception as server_error:
-            # TODO: remove "from server_error" clause. It's useful
-            # during development but may invite users to depend on the
-            # value of server_error.
-            raise APIError("Subscription failure.") from server_error
+            async for sub in _ResultsPager(self.session._client,
+                                           req,
+                                           limit=limit):
+                yield sub
+        # Forward APIError. We don't strictly need this clause, but it
+        # makes our intent clear.
+        except APIError:
+            raise
+        except ClientError:  # pragma: no cover
+            raise
diff --git a/planet/constants.py b/planet/constants.py
index 5b67736..8cafd02 100644
--- a/planet/constants.py
+++ b/planet/constants.py
@@ -16,6 +16,8 @@
 import os
 from pathlib import Path
 
+# NOTE: entries are given in alphabetical order
+
 DATA_DIR = Path(os.path.dirname(__file__)) / 'data'
 
 PLANET_BASE_URL = 'https://api.planet.com'
diff --git a/planet/data_filter.py b/planet/data_filter.py
index bf22b15..c822deb 100644
--- a/planet/data_filter.py
+++ b/planet/data_filter.py
@@ -16,7 +16,7 @@ from datetime import datetime
 import logging
 from typing import Any, Callable, List, Union
 
-from planet import exceptions
+from planet import exceptions, geojson
 
 LOGGER = logging.getLogger(__name__)
 
@@ -219,6 +219,9 @@ def geometry_filter(geom: dict) -> dict:
     The GeometryFilter can be used to search for items with a footprint
     geometry which intersects with the specified geometry.
 
+    In cases where a GeoJSON Feature or FeatureCollection are provided, the
+    GeoJSON geometry will be extracted and used in the filter definition.
+
     The filter's configuration supports Point, MultiPoint, LineString,
     MultiLineString, Polygon, and MultiPolygon GeoJSON object. For best
     results, the geometry should meet OpenGIS Simple Features Interface
@@ -227,9 +230,12 @@ def geometry_filter(geom: dict) -> dict:
     search results.
 
     Parameters:
-        geom: GeoJSON describing the filter geometry.
+        geom: GeoJSON describing the filter geometry, feature, or feature
+            collection.
     """
-    return _field_filter('GeometryFilter', field_name='geometry', config=geom)
+    return _field_filter('GeometryFilter',
+                         field_name='geometry',
+                         config=geojson.as_geom(geom))
 
 
 def number_in_filter(field_name: str, values: List[float]) -> dict:
@@ -294,3 +300,12 @@ def permission_filter() -> dict:
     download.
     """
     return {'type': 'PermissionFilter', 'config': ['assets:download']}
+
+
+def std_quality_filter() -> dict:
+    """Create a filter for standard-quality items.
+
+    This is a custom filter which filters to items that are categorized as
+    standard quality.
+    """
+    return string_in_filter('quality_category', ['standard'])
diff --git a/planet/exceptions.py b/planet/exceptions.py
index d5129a6..7fcf32f 100644
--- a/planet/exceptions.py
+++ b/planet/exceptions.py
@@ -78,7 +78,7 @@ class AuthException(ClientError):
     pass
 
 
-class PagingError(PlanetError):
+class PagingError(ClientError):
     '''For errors that occur during paging.'''
     pass
 
diff --git a/planet/io.py b/planet/io.py
index 0fd22bc..4ec82a3 100644
--- a/planet/io.py
+++ b/planet/io.py
@@ -12,6 +12,7 @@
 # License for the specific language governing permissions and limitations under
 # the License.
 """Functionality for processing inputs and outputs."""
+from datetime import datetime
 import logging
 import typing
 
@@ -19,6 +20,8 @@ from . import exceptions, geojson
 
 LOGGER = logging.getLogger(__name__)
 
+RFC3339_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ"
+
 
 async def collect(
     values: typing.AsyncIterator[dict]
@@ -40,3 +43,22 @@ async def collect(
         ret = as_list
 
     return ret
+
+
+def str_to_datetime(string: str) -> datetime:
+    """Convert a string to a datetime.
+
+    First, tries parsing the string as a RFC 3339 format as returned from the
+    Planet APIs (e.g. '2021-01-01T01:40:07.359Z'), then tries parsing
+    as an IS0 8601 format using `datetime.fromisoformat()`.
+    """
+    try:
+        dt = datetime.strptime(string, RFC3339_FORMAT)
+    except ValueError:
+        LOGGER.debug(f'{string} does not match format {RFC3339_FORMAT}')
+        try:
+            dt = datetime.fromisoformat(string)
+        except ValueError:
+            raise exceptions.PlanetError(
+                f'{string} does not match RFC 3339 or ISO 8601 formats')
+    return dt
diff --git a/setup.cfg b/setup.cfg
index aad6c3d..14e2987 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -20,3 +20,6 @@ fail_under = 98
 [yapf]
 based_on_style = pep8
 split_all_top_level_comma_separated_values=true
+
+[flake8]
+ignore = E126,E501,W503,W504
diff --git a/setup.py b/setup.py
index 3c50ac8..ec9c3df 100644
--- a/setup.py
+++ b/setup.py
@@ -63,7 +63,8 @@ setup(
     ],
     keywords='planet api sdk client',
     author='Jennifer Reiber Kyle',
-    author_email='jennifer.kyle@planet.com',
+    maintainer='Planet Dev Rel Team',
+    maintainer_email='developers@planet.com',
     url='https://github.com/planetlabs/planet-client-python',
     license='Apache 2.0',
     packages=find_packages(exclude=['examples', 'tests']),


Test patch:
diff --git a/tests/integration/test_data_cli.py b/tests/integration/test_data_cli.py
index 7df6208..f4da209 100644
--- a/tests/integration/test_data_cli.py
+++ b/tests/integration/test_data_cli.py
@@ -1,12 +1,31 @@
+# Copyright 2022 Planet Labs PBC.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not
+# use this file except in compliance with the License. You may obtain a copy of
+# the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations under
+# the License.
 """Tests of the Data CLI."""
-from click.testing import CliRunner
-import pytest
+from http import HTTPStatus
 import json
+import logging
+
 import httpx
 import respx
-from http import HTTPStatus
+
+from click.testing import CliRunner
+import pytest
+
 from planet.cli import cli
 
+LOGGER = logging.getLogger(__name__)
+
 TEST_URL = 'https://api.planet.com/data/v1'
 TEST_QUICKSEARCH_URL = f'{TEST_URL}/quick-search'
 TEST_SEARCHES_URL = f'{TEST_URL}/searches'
@@ -35,6 +54,296 @@ def test_data_command_registered(invoke):
     # Add other sub-commands here.
 
 
+PERMISSION_FILTER = {"type": "PermissionFilter", "config": ["assets:download"]}
+STD_QUALITY_FILTER = {
+    "type": "StringInFilter",
+    "field_name": "quality_category",
+    "config": ["standard"]
+}
+
+
+@pytest.fixture()
+def default_filters():
+    return [PERMISSION_FILTER, STD_QUALITY_FILTER]
+
+
+@pytest.fixture
+def assert_and_filters_equal():
+    """Check for equality when the order of the config list doesn't matter"""
+
+    def _func(filter1, filter2):
+        assert filter1.keys() == filter2.keys()
+        assert filter1['type'] == filter2['type']
+
+        assert len(filter1['config']) == len(filter2['config'])
+        for c in filter1['config']:
+            assert c in filter2['config']
+
+    return _func
+
+
+@respx.mock
+@pytest.mark.asyncio
+@pytest.mark.parametrize("permission, p_remove",
+                         [(None, None),
+                          ('--permission=False', PERMISSION_FILTER)])
+@pytest.mark.parametrize("std_quality, s_remove",
+                         [(None, None),
+                          ('--std-quality=False', STD_QUALITY_FILTER)])
+def test_data_filter_defaults(permission,
+                              p_remove,
+                              std_quality,
+                              s_remove,
+                              invoke,
+                              default_filters,
+                              assert_and_filters_equal):
+    runner = CliRunner()
+
+    args = [arg for arg in [permission, std_quality] if arg]
+    result = invoke(["filter", *args], runner=runner)
+    assert result.exit_code == 0
+
+    [default_filters.remove(rem) for rem in [p_remove, s_remove] if rem]
+    if len(default_filters) > 1:
+        expected_filt = {"type": "AndFilter", "config": default_filters}
+        assert_and_filters_equal(json.loads(result.output), expected_filt)
+    elif len(default_filters) == 1:
+        assert json.loads(result.output) == default_filters[0]
+    else:
+        assert result.output == ''
+
+
+@respx.mock
+@pytest.mark.asyncio
+@pytest.mark.parametrize("asset, expected",
+                         [('ortho_analytic_8b_sr', ['ortho_analytic_8b_sr']),
+                          ('ortho_analytic_8b_sr,ortho_analytic_4b_sr',
+                           ['ortho_analytic_8b_sr', 'ortho_analytic_4b_sr']),
+                          ('ortho_analytic_8b_sr , ortho_analytic_4b_sr',
+                           ['ortho_analytic_8b_sr', 'ortho_analytic_4b_sr'])])
+def test_data_filter_asset(asset,
+                           expected,
+                           invoke,
+                           default_filters,
+                           assert_and_filters_equal):
+    runner = CliRunner()
+
+    result = invoke(["filter", f'--asset={asset}'], runner=runner)
+    assert result.exit_code == 0
+
+    asset_filter = {"type": "AssetFilter", "config": expected}
+    expected_filt = {
+        "type": "AndFilter", "config": default_filters + [asset_filter]
+    }
+    assert_and_filters_equal(json.loads(result.output), expected_filt)
+
+
+@respx.mock
+@pytest.mark.asyncio
+def test_data_filter_date_range(invoke,
+                                assert_and_filters_equal,
+                                default_filters):
+    """Check filter is created correctly and that multiple options results in
+    multiple filters"""
+    runner = CliRunner()
+
+    result = invoke(["filter"] + '--date-range field gt 2021-01-01'.split() +
+                    '--date-range field2 lt 2022-01-01'.split(),
+                    runner=runner)
+    assert result.exit_code == 0
+
+    date_range_filter1 = {
+        "type": "DateRangeFilter",
+        "field_name": "field",
+        "config": {
+            "gt": "2021-01-01T00:00:00Z"
+        }
+    }
+    date_range_filter2 = {
+        "type": "DateRangeFilter",
+        "field_name": "field2",
+        "config": {
+            "lt": "2022-01-01T00:00:00Z"
+        }
+    }
+
+    expected_filt = {
+        "type": "AndFilter",
+        "config": default_filters + [date_range_filter1, date_range_filter2]
+    }
+
+    assert_and_filters_equal(json.loads(result.output), expected_filt)
+
+
+@respx.mock
+@pytest.mark.asyncio
+@pytest.mark.parametrize("geom_fixture",
+                         [('geom_geojson'), ('feature_geojson'),
+                          ('featurecollection_geojson')])
+def test_data_filter_geom(geom_fixture,
+                          request,
+                          invoke,
+                          geom_geojson,
+                          assert_and_filters_equal,
+                          default_filters):
+    """Ensure that all GeoJSON forms of describing a geometry are handled
+    and all result in the same, valid GeometryFilter being created"""
+    runner = CliRunner()
+
+    geom = request.getfixturevalue(geom_fixture)
+    geom_str = json.dumps(geom)
+    result = invoke(["filter", f'--geom={geom_str}'], runner=runner)
+    assert result.exit_code == 0
+
+    geom_filter = {
+        "type": "GeometryFilter",
+        "field_name": "geometry",
+        "config": geom_geojson
+    }
+
+    expected_filt = {
+        "type": "AndFilter", "config": default_filters + [geom_filter]
+    }
+
+    assert_and_filters_equal(json.loads(result.output), expected_filt)
+
+
+@respx.mock
+@pytest.mark.asyncio
+def test_data_filter_number_in_success(invoke,
+                                       assert_and_filters_equal,
+                                       default_filters):
+    runner = CliRunner()
+
+    result = invoke(["filter"] + '--number-in field 1'.split() +
+                    '--number-in field2 2,3.5'.split(),
+                    runner=runner)
+    assert result.exit_code == 0
+
+    number_in_filter1 = {
+        "type": "NumberInFilter", "field_name": "field", "config": [1.0]
+    }
+    number_in_filter2 = {
+        "type": "NumberInFilter", "field_name": "field2", "config": [2.0, 3.5]
+    }
+
+    expected_filt = {
+        "type": "AndFilter",
+        "config": default_filters + [number_in_filter1, number_in_filter2]
+    }
+
+    assert_and_filters_equal(json.loads(result.output), expected_filt)
+
+
+@respx.mock
+@pytest.mark.asyncio
+def test_data_filter_number_in_badparam(invoke,
+                                        assert_and_filters_equal,
+                                        default_filters):
+    runner = CliRunner()
+
+    result = invoke(["filter"] + '--number-in field 1,str'.split(),
+                    runner=runner)
+    assert result.exit_code == 2
+
+
+@respx.mock
+@pytest.mark.asyncio
+def test_data_filter_range(invoke, assert_and_filters_equal, default_filters):
+    """Check filter is created correctly, that multiple options results in
+    multiple filters, and that floats are processed correctly."""
+    runner = CliRunner()
+
+    result = invoke(["filter"] + '--range field gt 70'.split() +
+                    '--range cloud_cover lt 0.5'.split(),
+                    runner=runner)
+    assert result.exit_code == 0
+
+    range_filter1 = {
+        "type": "RangeFilter", "field_name": "field", "config": {
+            "gt": 70.0
+        }
+    }
+    range_filter2 = {
+        "type": "RangeFilter",
+        "field_name": "cloud_cover",
+        "config": {
+            "lt": 0.5
+        }
+    }
+
+    expected_filt = {
+        "type": "AndFilter",
+        "config": default_filters + [range_filter1, range_filter2]
+    }
+
+    assert_and_filters_equal(json.loads(result.output), expected_filt)
+
+
+@respx.mock
+@pytest.mark.asyncio
+def test_data_filter_string_in(invoke,
+                               assert_and_filters_equal,
+                               default_filters):
+    runner = CliRunner()
+
+    result = invoke(["filter"] + '--string-in field foo'.split() +
+                    '--string-in field2 foo,bar'.split(),
+                    runner=runner)
+    assert result.exit_code == 0
+
+    string_in_filter1 = {
+        "type": "StringInFilter", "field_name": "field", "config": ["foo"]
+    }
+    string_in_filter2 = {
+        "type": "StringInFilter",
+        "field_name": "field2",
+        "config": ["foo", "bar"]
+    }
+
+    expected_filt = {
+        "type": "AndFilter",
+        "config": default_filters + [string_in_filter1, string_in_filter2]
+    }
+
+    assert_and_filters_equal(json.loads(result.output), expected_filt)
+
+
+@respx.mock
+@pytest.mark.asyncio
+def test_data_filter_update(invoke, assert_and_filters_equal, default_filters):
+    """Check filter is created correctly and that multiple options results in
+    multiple filters"""
+    runner = CliRunner()
+
+    result = invoke(["filter"] + '--update field gt 2021-01-01'.split() +
+                    '--update field2 gte 2022-01-01'.split(),
+                    runner=runner)
+    assert result.exit_code == 0
+
+    update_filter1 = {
+        "type": "UpdateFilter",
+        "field_name": "field",
+        "config": {
+            "gt": "2021-01-01T00:00:00Z"
+        }
+    }
+    update_filter2 = {
+        "type": "UpdateFilter",
+        "field_name": "field2",
+        "config": {
+            "gte": "2022-01-01T00:00:00Z"
+        }
+    }
+
+    expected_filt = {
+        "type": "AndFilter",
+        "config": default_filters + [update_filter1, update_filter2]
+    }
+
+    assert_and_filters_equal(json.loads(result.output), expected_filt)
+
+
 @respx.mock
 @pytest.mark.asyncio
 @pytest.mark.parametrize("filter", ['{1:1}', '{"foo"}'])
diff --git a/tests/integration/test_subscriptions_api.py b/tests/integration/test_subscriptions_api.py
index 3b059a8..ffc01ee 100644
--- a/tests/integration/test_subscriptions_api.py
+++ b/tests/integration/test_subscriptions_api.py
@@ -1,36 +1,166 @@
 """Tests of the Planet Subscriptions API client."""
 
+from datetime import datetime
+from itertools import zip_longest
+import json
+
+from httpx import Response
 import pytest
+import respx
+from respx.patterns import M
 
-import planet.clients.subscriptions
 from planet.clients.subscriptions import SubscriptionsClient
-from planet.exceptions import APIError
+from planet.exceptions import APIError, PagingError, ServerError
 from planet.http import Session
 
+# Mock router representing an API that returns only a 500
+# internal server error. The router is configured outside the test
+# to help keep our test more readable.
+failing_api_mock = respx.mock()
+failing_api_mock.route(
+    M(url__startswith='https://api.planet.com/subscriptions/v1')).mock(
+        return_value=Response(500, json={
+            "code": 0, "message": "string"
+        }))
+
+
+def result_pages(status=None, size=40):
+    """Helper for creating fake subscriptions listing pages."""
+    all_subs = [{'id': str(i), 'status': 'created'} for i in range(1, 101)]
+    select_subs = (sub for sub in all_subs
+                   if not status or sub['status'] in status)
+    pages = []
+    for group in zip_longest(*[iter(select_subs)] * size):
+        subs = list(sub for sub in group if sub is not None)
+        page = {'subscriptions': subs, '_links': {}}
+        pm = datetime.now().isoformat()
+        if len(subs) == size:
+            page['_links'][
+                'next'] = f'https://api.planet.com/subscriptions/v1?pm={pm}'
+        pages.append(page)
+    return pages
+
+
+# By default, respx's mock router asserts that all configured routes
+# are followed in a test. Each of our parameterized tests follows only
+# one route, so if we want to configure routes once and reuse them, we
+# must disable the default.
+api_mock = respx.mock(assert_all_called=False)
+
+# 1. Request for status: created. Response has three pages.
+api_mock.route(M(url__startswith='https://api.planet.com/subscriptions/v1'),
+               M(params__contains={'status': 'created'})).mock(side_effect=[
+                   Response(200, json=page)
+                   for page in result_pages(status={'created'}, size=40)
+               ])
+
+# 2. Request for status: failed. Response has a single empty page.
+api_mock.route(M(url__startswith='https://api.planet.com/subscriptions/v1'),
+               M(params__contains={'status': 'failed'})).mock(
+                   side_effect=[Response(200, json={'subscriptions': []})])
+
+# 3. Request for status: queued. Response has a single empty page.
+api_mock.route(M(url__startswith='https://api.planet.com/subscriptions/v1'),
+               M(params__contains={'status': 'queued'})).mock(
+                   side_effect=[Response(200, json={'subscriptions': []})])
+
+# 4. No status requested. Response is the same as for 1.
+api_mock.route(
+    M(url__startswith='https://api.planet.com/subscriptions/v1')
+).mock(
+    side_effect=[Response(200, json=page) for page in result_pages(size=40)])
+
+
+# This mock API returns posted data to the caller. It's used to test
+# methods that rely on POST or PUT.
+def modify_response(request):
+    if request.content:
+        return Response(200, json=json.loads(request.content))
+    else:
+        return Response(200)
+
+
+modify_api_mock = respx.mock()
+modify_api_mock.route(
+    M(url__startswith='https://api.planet.com/subscriptions/v1'),
+    M(method__in=['POST', 'PUT'])).mock(side_effect=modify_response)
+
+sub_api_mock = respx.mock()
+sub_api_mock.route(
+    M(url__startswith='https://api.planet.com/subscriptions/v1'),
+    M(method__in=['GET'])).mock(
+        return_value=Response(200,
+                              json={
+                                  'id': '42',
+                                  'name': 'test',
+                                  'delivery': 'yes, please',
+                                  'source': 'test'
+                              }))
+
+
+def result_pages(status=None, size=40):
+    """Helper for creating fake result listing pages."""
+    all_results = [{'id': str(i), 'status': 'created'} for i in range(1, 101)]
+    select_results = (result for result in all_results
+                      if not status or result['status'] in status)
+    pages = []
+    for group in zip_longest(*[iter(select_results)] * size):
+        results = list(result for result in group if result is not None)
+        page = {'results': results, '_links': {}}
+        pm = datetime.now().isoformat()
+        if len(results) == size:
+            page['_links'][
+                '_next'] = f'https://api.planet.com/subscriptions/v1/42/results?pm={pm}'
+        pages.append(page)
+    return pages
+
+
+# By default, respx's mock router asserts that all configured routes
+# are followed in a test. Each of our parameterized tests follows only
+# one route, so if we want to configure routes once and reuse them, we
+# must disable the default.
+res_api_mock = respx.mock(assert_all_called=False)
+
+# 1. Request for status: created. Response has three pages.
+res_api_mock.route(
+    M(url__startswith='https://api.planet.com/subscriptions/v1'),
+    M(params__contains={'status': 'created'})).mock(side_effect=[
+        Response(200, json=page)
+        for page in result_pages(status={'created'}, size=40)
+    ])
+
+# 2. Request for status: queued. Response has a single empty page.
+res_api_mock.route(
+    M(url__startswith='https://api.planet.com/subscriptions/v1'),
+    M(params__contains={'status': 'queued'})).mock(
+        side_effect=[Response(200, json={'results': []})])
+
+# 3. No status requested. Response is the same as for 1.
+res_api_mock.route(
+    M(url__startswith='https://api.planet.com/subscriptions/v1')
+).mock(
+    side_effect=[Response(200, json=page) for page in result_pages(size=40)])
+
 
-@pytest.mark.xfail(reason="Client/server interaction not yet implemented")
 @pytest.mark.asyncio
-async def test_list_subscriptions_failure(monkeypatch):
-    """APIError is raised if there is a server error."""
-    monkeypatch.setattr(planet.clients.subscriptions, "_fake_subs", 0)
-    with pytest.raises(APIError):
+@failing_api_mock
+async def test_list_subscriptions_failure():
+    """ServerError is raised if there is an internal server error (500)."""
+    with pytest.raises(ServerError):
         async with Session() as session:
             client = SubscriptionsClient(session)
             _ = [sub async for sub in client.list_subscriptions()]
 
 
-@pytest.mark.xfail(reason="Client/server interaction not yet implemented")
 @pytest.mark.parametrize("status, count", [({"created"}, 100), ({"failed"}, 0),
                                            (None, 100)])
 @pytest.mark.asyncio
-async def test_list_subscriptions_success(status, count, monkeypatch):
+@api_mock
+async def test_list_subscriptions_success(
+    status,
+    count,
+):
     """Account subscriptions iterator yields expected descriptions."""
-    monkeypatch.setattr(planet.clients.subscriptions,
-                        '_fake_subs',
-                        {str(i): {
-                            'status': 'created'
-                        }
-                         for i in range(101)})
     async with Session() as session:
         client = SubscriptionsClient(session)
         assert len([
@@ -38,22 +168,20 @@ async def test_list_subscriptions_success(status, count, monkeypatch):
         ]) == count
 
 
-@pytest.mark.xfail(reason="Client/server interaction not yet implemented")
 @pytest.mark.asyncio
-async def test_create_subscription_failure(monkeypatch):
+@failing_api_mock
+async def test_create_subscription_failure():
     """APIError is raised if there is a server error."""
-    monkeypatch.setattr(planet.clients.subscriptions, "_fake_subs", {})
-    with pytest.raises(APIError):
+    with pytest.raises(ServerError):
         async with Session() as session:
             client = SubscriptionsClient(session)
             _ = await client.create_subscription({"lol": "wut"})
 
 
-@pytest.mark.xfail(reason="Client/server interaction not yet implemented")
 @pytest.mark.asyncio
-async def test_create_subscription_success(monkeypatch):
+@modify_api_mock
+async def test_create_subscription_success():
     """Subscription is created, description has the expected items."""
-    monkeypatch.setattr(planet.clients.subscriptions, "_fake_subs", {})
     async with Session() as session:
         client = SubscriptionsClient(session)
         sub = await client.create_subscription({
@@ -62,56 +190,39 @@ async def test_create_subscription_success(monkeypatch):
         assert sub['name'] == 'test'
 
 
-@pytest.mark.xfail(reason="Client/server interaction not yet implemented")
 @pytest.mark.asyncio
-async def test_cancel_subscription_failure(monkeypatch):
+@failing_api_mock
+async def test_cancel_subscription_failure():
     """APIError is raised if there is a server error."""
-    monkeypatch.setattr(planet.clients.subscriptions, "_fake_subs", {})
-    with pytest.raises(APIError):
+    with pytest.raises(ServerError):
         async with Session() as session:
             client = SubscriptionsClient(session)
             _ = await client.cancel_subscription("lolwut")
 
 
-@pytest.mark.xfail(reason="Client/server interaction not yet implemented")
 @pytest.mark.asyncio
-async def test_cancel_subscription_success(monkeypatch):
+@modify_api_mock
+async def test_cancel_subscription_success():
     """Subscription is canceled, description has the expected items."""
-    monkeypatch.setattr(
-        planet.clients.subscriptions,
-        "_fake_subs", {
-            "test": {
-                'name': 'test', 'delivery': 'yes, please', 'source': 'test'
-            }
-        })
     async with Session() as session:
         client = SubscriptionsClient(session)
-        sub = await client.cancel_subscription("test")
-        assert sub['name'] == 'test'
+        _ = await client.cancel_subscription("test")
 
 
-@pytest.mark.xfail(reason="Client/server interaction not yet implemented")
 @pytest.mark.asyncio
-async def test_update_subscription_failure(monkeypatch):
+@failing_api_mock
+async def test_update_subscription_failure():
     """APIError is raised if there is a server error."""
-    monkeypatch.setattr(planet.clients.subscriptions, "_fake_subs", {})
-    with pytest.raises(APIError):
+    with pytest.raises(ServerError):
         async with Session() as session:
             client = SubscriptionsClient(session)
             _ = await client.update_subscription("lolwut", {})
 
 
-@pytest.mark.xfail(reason="Client/server interaction not yet implemented")
 @pytest.mark.asyncio
-async def test_update_subscription_success(monkeypatch):
+@modify_api_mock
+async def test_update_subscription_success():
     """Subscription is created, description has the expected items."""
-    monkeypatch.setattr(
-        planet.clients.subscriptions,
-        "_fake_subs", {
-            "test": {
-                'name': 'test', 'delivery': 'yes, please', 'source': 'test'
-            }
-        })
     async with Session() as session:
         client = SubscriptionsClient(session)
         sub = await client.update_subscription(
@@ -121,56 +232,78 @@ async def test_update_subscription_success(monkeypatch):
         assert sub['delivery'] == "no, thanks"
 
 
-@pytest.mark.xfail(reason="Client/server interaction not yet implemented")
 @pytest.mark.asyncio
-async def test_get_subscription_failure(monkeypatch):
+@failing_api_mock
+async def test_get_subscription_failure():
     """APIError is raised if there is a server error."""
-    monkeypatch.setattr(planet.clients.subscriptions, "_fake_subs", {})
-    with pytest.raises(APIError):
+    with pytest.raises(ServerError):
         async with Session() as session:
             client = SubscriptionsClient(session)
             _ = await client.get_subscription("lolwut")
 
 
-@pytest.mark.xfail(reason="Client/server interaction not yet implemented")
 @pytest.mark.asyncio
+@sub_api_mock
 async def test_get_subscription_success(monkeypatch):
     """Subscription description fetched, has the expected items."""
-    monkeypatch.setattr(
-        planet.clients.subscriptions,
-        "_fake_subs", {
-            "test": {
-                'name': 'test', 'delivery': 'yes, please', 'source': 'test'
-            }
-        })
     async with Session() as session:
         client = SubscriptionsClient(session)
         sub = await client.get_subscription("test")
         assert sub['delivery'] == "yes, please"
 
 
-@pytest.mark.xfail(reason="Client/server interaction not yet implemented")
 @pytest.mark.asyncio
-async def test_get_results_failure(monkeypatch):
+@failing_api_mock
+async def test_get_results_failure():
     """APIError is raised if there is a server error."""
-    monkeypatch.setattr(planet.clients.subscriptions, "_fake_sub_results", {})
     with pytest.raises(APIError):
         async with Session() as session:
             client = SubscriptionsClient(session)
             _ = [res async for res in client.get_results("lolwut")]
 
 
-@pytest.mark.xfail(reason="Client/server interaction not yet implemented")
 @pytest.mark.asyncio
-async def test_get_results_success(monkeypatch):
+@res_api_mock
+async def test_get_results_success():
     """Subscription description fetched, has the expected items."""
-    monkeypatch.setattr(
-        planet.clients.subscriptions,
-        '_fake_sub_results',
-        {'42': [{
-            'id': f'r{i}', 'status': 'created'
-        } for i in range(101)]})
     async with Session() as session:
         client = SubscriptionsClient(session)
         results = [res async for res in client.get_results("42")]
         assert len(results) == 100
+
+
+paging_cycle_api_mock = respx.mock()
+
+# Identical next links is a hangup we want to avoid.
+paging_cycle_api_mock.route(
+    M(url__startswith='https://api.planet.com/subscriptions/v1')).mock(
+        side_effect=[
+            Response(200,
+                     json={
+                         'subscriptions': [{
+                             'id': '1'
+                         }],
+                         '_links': {
+                             "next": 'https://api.planet.com/subscriptions/v1'
+                         }
+                     }),
+            Response(200,
+                     json={
+                         'subscriptions': [{
+                             'id': '2'
+                         }],
+                         '_links': {
+                             "next": 'https://api.planet.com/subscriptions/v1'
+                         }
+                     })
+        ])
+
+
+@pytest.mark.asyncio
+@paging_cycle_api_mock
+async def test_list_subscriptions_cycle_break():
+    """PagingError is raised if there is a paging cycle."""
+    with pytest.raises(PagingError):
+        async with Session() as session:
+            client = SubscriptionsClient(session)
+            _ = [sub async for sub in client.list_subscriptions()]
diff --git a/tests/integration/test_subscriptions_cli.py b/tests/integration/test_subscriptions_cli.py
index f1eb8d9..2d7e66b 100644
--- a/tests/integration/test_subscriptions_cli.py
+++ b/tests/integration/test_subscriptions_cli.py
@@ -13,33 +13,18 @@ TODO: tests for 3 options of the planet-subscriptions-results command.
 
 """
 
-import asyncio
 import json
-import uuid
 
 from click.testing import CliRunner
 import pytest
 
 from planet.cli import cli
-import planet.cli.subscriptions
-from planet.clients.subscriptions import SubscriptionsClient
 
-
-@pytest.fixture
-def subscription_count():
-    """Return a function that counts subscriptions in the system."""
-
-    def func():
-
-        async def count_em():
-            return len([
-                sub
-                async for sub in SubscriptionsClient().list_subscriptions()
-            ])
-
-        return asyncio.run(count_em())
-
-    return func
+from test_subscriptions_api import (api_mock,
+                                    failing_api_mock,
+                                    modify_api_mock,
+                                    sub_api_mock,
+                                    res_api_mock)
 
 
 # CliRunner doesn't agree with empty options, so a list of option
@@ -54,17 +39,10 @@ def subscription_count():
                           (['--limit=1', '--status=created'], 1),
                           (['--limit=2', '--pretty', '--status=created'], 2),
                           (['--limit=1', '--status=queued'], 0)])
+@api_mock
 # Remember, parameters come before fixtures in the function definition.
-def test_subscriptions_list_options(options, expected_count, monkeypatch):
+def test_subscriptions_list_options(options, expected_count):
     """Prints the expected sequence of subscriptions."""
-
-    monkeypatch.setattr(planet.clients.subscriptions,
-                        '_fake_subs',
-                        {str(i): {
-                            'status': 'created'
-                        }
-                         for i in range(101)})
-
     # While developing it is handy to have click's command invoker
     # *not* catch exceptions, so we can use the pytest --pdb option.
     result = CliRunner().invoke(cli.main,
@@ -77,11 +55,9 @@ def test_subscriptions_list_options(options, expected_count, monkeypatch):
     assert result.output.count('"id"') == expected_count
 
 
-def test_subscriptions_create_failure(monkeypatch, subscription_count):
+@failing_api_mock
+def test_subscriptions_create_failure():
     """An invalid subscription request fails to create a new subscription."""
-
-    monkeypatch.setattr(planet.clients.subscriptions, '_fake_subs', {})
-
     # This subscription request lacks the required "delivery" and
     # "source" members.
     sub = {'name': 'lol'}
@@ -98,7 +74,7 @@ def test_subscriptions_create_failure(monkeypatch, subscription_count):
         catch_exceptions=True)
 
     assert result.exit_code == 1  # failure.
-    assert subscription_count() == 0
+    assert "Error:" in result.output
 
 
 # This subscription request has the members required by our fake API.
@@ -110,14 +86,9 @@ GOOD_SUB_REQUEST = {'name': 'lol', 'delivery': True, 'source': 'wut'}
 @pytest.mark.parametrize('cmd_arg, runner_input',
                          [('-', json.dumps(GOOD_SUB_REQUEST)),
                           (json.dumps(GOOD_SUB_REQUEST), None)])
-def test_subscriptions_create_success(cmd_arg,
-                                      runner_input,
-                                      monkeypatch,
-                                      subscription_count):
+@modify_api_mock
+def test_subscriptions_create_success(cmd_arg, runner_input):
     """Subscriptions creation succeeds with a valid subscription request."""
-
-    monkeypatch.setattr(planet.clients.subscriptions, '_fake_subs', {})
-
     # The "-" argument says "read from stdin" and the input keyword
     # argument specifies what bytes go to the runner's stdin.
     result = CliRunner().invoke(
@@ -130,8 +101,6 @@ def test_subscriptions_create_success(cmd_arg,
         catch_exceptions=True)
 
     assert result.exit_code == 0  # success.
-    assert uuid.UUID(json.loads(result.output)['id'])
-    assert subscription_count() == 1
 
 
 # Invalid JSON.
@@ -140,9 +109,8 @@ BAD_SUB_REQUEST = '{0: "lolwut"}'
 
 @pytest.mark.parametrize('cmd_arg, runner_input', [('-', BAD_SUB_REQUEST),
                                                    (BAD_SUB_REQUEST, None)])
-def test_subscriptions_bad_request(cmd_arg, runner_input, monkeypatch):
+def test_subscriptions_bad_request(cmd_arg, runner_input):
     """Short circuit and print help message if request is bad."""
-
     # The "-" argument says "read from stdin" and the input keyword
     # argument specifies what bytes go to the runner's stdin.
     result = CliRunner().invoke(
@@ -158,11 +126,9 @@ def test_subscriptions_bad_request(cmd_arg, runner_input, monkeypatch):
     assert "Request does not contain valid json" in result.output
 
 
-def test_subscriptions_cancel_failure(monkeypatch):
+@failing_api_mock
+def test_subscriptions_cancel_failure():
     """Cancel command exits gracefully from an API error."""
-
-    monkeypatch.setattr(planet.clients.subscriptions, '_fake_subs', {})
-
     result = CliRunner().invoke(
         cli.main,
         args=['subscriptions', 'cancel', '42'],
@@ -174,15 +140,9 @@ def test_subscriptions_cancel_failure(monkeypatch):
     assert result.exit_code == 1  # failure.
 
 
-def test_subscriptions_cancel_success(monkeypatch, subscription_count):
+@modify_api_mock
+def test_subscriptions_cancel_success():
     """Cancel command succeeds."""
-
-    monkeypatch.setattr(planet.clients.subscriptions,
-                        '_fake_subs', {'42': GOOD_SUB_REQUEST})
-
-    # Let's check the state of the fake API before we try to cancel.
-    assert subscription_count() == 1
-
     result = CliRunner().invoke(
         cli.main,
         args=['subscriptions', 'cancel', '42'],
@@ -192,15 +152,11 @@ def test_subscriptions_cancel_success(monkeypatch, subscription_count):
         catch_exceptions=True)
 
     assert result.exit_code == 0  # success.
-    assert json.loads(result.output)['id'] == '42'
-    assert subscription_count() == 0
 
 
-def test_subscriptions_update_failure(monkeypatch):
+@failing_api_mock
+def test_subscriptions_update_failure():
     """Update command exits gracefully from an API error."""
-
-    monkeypatch.setattr(planet.clients.subscriptions, '_fake_subs', {})
-
     result = CliRunner().invoke(
         cli.main,
         args=['subscriptions', 'update', '42', json.dumps(GOOD_SUB_REQUEST)],
@@ -212,12 +168,9 @@ def test_subscriptions_update_failure(monkeypatch):
     assert result.exit_code == 1  # failure.
 
 
-def test_subscriptions_update_success(monkeypatch):
+@modify_api_mock
+def test_subscriptions_update_success():
     """Update command succeeds."""
-
-    monkeypatch.setattr(planet.clients.subscriptions,
-                        '_fake_subs', {'42': GOOD_SUB_REQUEST})
-
     request = GOOD_SUB_REQUEST.copy()
     request['name'] = 'new_name'
 
@@ -233,11 +186,9 @@ def test_subscriptions_update_success(monkeypatch):
     assert json.loads(result.output)['name'] == 'new_name'
 
 
-def test_subscriptions_describe_failure(monkeypatch):
+@failing_api_mock
+def test_subscriptions_describe_failure():
     """Describe command exits gracefully from an API error."""
-
-    monkeypatch.setattr(planet.clients.subscriptions, '_fake_subs', {})
-
     result = CliRunner().invoke(
         cli.main,
         args=['subscriptions', 'describe', '42'],
@@ -249,12 +200,9 @@ def test_subscriptions_describe_failure(monkeypatch):
     assert result.exit_code == 1  # failure.
 
 
-def test_subscriptions_describe_success(monkeypatch):
+@sub_api_mock
+def test_subscriptions_describe_success():
     """Describe command succeeds."""
-
-    monkeypatch.setattr(planet.clients.subscriptions,
-                        '_fake_subs', {'42': GOOD_SUB_REQUEST})
-
     result = CliRunner().invoke(
         cli.main,
         args=['subscriptions', 'describe', '42'],
@@ -267,11 +215,9 @@ def test_subscriptions_describe_success(monkeypatch):
     assert json.loads(result.output)['id'] == '42'
 
 
-def test_subscriptions_results_failure(monkeypatch):
+@failing_api_mock
+def test_subscriptions_results_failure():
     """Results command exits gracefully from an API error."""
-
-    monkeypatch.setattr(planet.clients.subscriptions, '_fake_sub_results', {})
-
     result = CliRunner().invoke(
         cli.main,
         args=['subscriptions', 'results', '42'],
@@ -281,7 +227,6 @@ def test_subscriptions_results_failure(monkeypatch):
         catch_exceptions=True)
 
     assert result.exit_code == 1  # failure.
-    assert "Subscription failure." in result.output
 
 
 @pytest.mark.parametrize('options,expected_count',
@@ -289,17 +234,10 @@ def test_subscriptions_results_failure(monkeypatch):
                           (['--limit=1', '--status=created'], 1),
                           (['--limit=2', '--pretty', '--status=created'], 2),
                           (['--limit=1', '--status=queued'], 0)])
+@res_api_mock
 # Remember, parameters come before fixtures in the function definition.
-def test_subscriptions_results_success(options, expected_count, monkeypatch):
+def test_subscriptions_results_success(options, expected_count):
     """Describe command succeeds."""
-
-    monkeypatch.setattr(
-        planet.clients.subscriptions,
-        '_fake_sub_results',
-        {'42': [{
-            'id': f'r{i}', 'status': 'created'
-        } for i in range(101)]})
-
     result = CliRunner().invoke(
         cli.main,
         args=['subscriptions', 'results', '42'] + options,
diff --git a/tests/unit/test_data_filter.py b/tests/unit/test_data_filter.py
index ecb1ea4..4e173a2 100644
--- a/tests/unit/test_data_filter.py
+++ b/tests/unit/test_data_filter.py
@@ -173,8 +173,12 @@ def test_update_filter_noconditionals():
         data_filter.update_filter('acquired')
 
 
-def test_geometry_filter(geom_geojson):
-    res = data_filter.geometry_filter(geom_geojson)
+@pytest.mark.parametrize("geom_fixture",
+                         [('geom_geojson'), ('feature_geojson'),
+                          ('featurecollection_geojson')])
+def test_geometry_filter(geom_fixture, request, geom_geojson):
+    geom = request.getfixturevalue(geom_fixture)
+    res = data_filter.geometry_filter(geom)
     expected = {
         'type': 'GeometryFilter',
         'field_name': 'geometry',
@@ -211,3 +215,13 @@ def test_permission_filter():
     res = data_filter.permission_filter()
     expected = {'type': 'PermissionFilter', 'config': ['assets:download']}
     assert res == expected
+
+
+def test_std_quality_filter():
+    res = data_filter.std_quality_filter()
+    expected = {
+        'type': 'StringInFilter',
+        'field_name': 'quality_category',
+        'config': ['standard']
+    }
+    assert res == expected
diff --git a/tests/unit/test_io.py b/tests/unit/test_io.py
index 1bac3f0..181976f 100644
--- a/tests/unit/test_io.py
+++ b/tests/unit/test_io.py
@@ -11,10 +11,11 @@
 # 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 datetime import datetime
 import logging
 import pytest
 
-from planet import io
+from planet import exceptions, io
 
 LOGGER = logging.getLogger(__name__)
 
@@ -52,3 +53,22 @@ async def test_collect_features(feature_geojson, make_aiter):
     res = await io.collect(values_aiter)
     expected = {'type': 'FeatureCollection', 'features': values}
     assert res == expected
+
+
+@pytest.mark.parametrize(
+    "string, expected",
+    [
+        # RFC 3339 string from API response
+        ("2021-02-03T01:40:07.359Z", datetime(2021, 2, 3, 1, 40, 7, 359000)),
+        # date-only ISO 8601
+        ("2021-01-01", datetime(2021, 1, 1)),
+        # date-time ISO 8601
+        ("2021-01-01", datetime(2021, 1, 1))
+    ])
+def test_str_to_datetime_success(string, expected):
+    assert io.str_to_datetime(string) == expected
+
+
+def test_str_to_datetime_failure():
+    with pytest.raises(exceptions.PlanetError):
+        io.str_to_datetime('20210101')