Repository: planetlabs/planet-client-python
Pull request #594 (2022-06-17 20:58:28)

planet data filter CLI command with all options
Implements `planet data filter` cli command with permission and standard-quality filters as defaults.

part of #535


Patch:
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/planet/cli/data.py b/planet/cli/data.py
index 481f039..54057a8 100644
--- a/planet/cli/data.py
+++ b/planet/cli/data.py
@@ -1,11 +1,25 @@
+# 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, Session
 
 from .cmds import coro, translate_exceptions
 from .io import echo_json
@@ -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/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/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


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/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')