Repository: planetlabs/planet-client-python (python)

Issue:
implement data api support for creating a data search filter
similar to `order_request` ([ref](https://github.com/planetlabs/planet-client-python/blob/main/planet/order_request.py)), implement `data_filter`, which supports creation of a data search filter. This is the backend for `planet data filter` CLI command

Pull request description:
Data filter python api
Closes #533 and #534


Patch:
diff --git a/docs/api.md b/docs/api.md
index 147ef1c..9c8b27a 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -16,6 +16,14 @@
     rendering:
       show_root_full_path: false
 
+## ::: planet.DataClient
+    rendering:
+      show_root_full_path: false
+
+## ::: planet.data_filter
+    rendering:
+      show_root_full_path: false
+
 ## ::: planet.reporting
     rendering:
       show_root_full_path: false
diff --git a/docs/guide.md b/docs/guide.md
index 5e3106a..3eb33ee 100644
--- a/docs/guide.md
+++ b/docs/guide.md
@@ -189,22 +189,22 @@ the context of a `Session` with the `OrdersClient`:
 
 ```
 
-#### Polling and Downloading an Order
+#### Waiting and Downloading an Order
 
 Once an order is created, the Orders API takes some time to create the order
 and thus we must wait a while before downloading the order.
-We can use polling to watch the order creation process and find out when the
+We can use waiting to watch the order creation process and find out when the
 order is created successfully and ready to download.
 
-With polling and download, it is often desired to track progress as these
+With wait and download, it is often desired to track progress as these
 processes can take a long time. Therefore, in this example, we use a progress
-bar from the `reporting` module to report poll status. `download_order` has
+bar from the `reporting` module to report wait status. `download_order` has
 reporting built in.
 
 ```python
 from planet import reporting
 
->>> async def create_poll_and_download():
+>>> async def create_wait_and_download():
 ...     async with Session() as sess:
 ...         cl = OrdersClient(sess)
 ...         with reporting.StateBar(state='creating') as bar:
@@ -221,6 +221,71 @@ from planet import reporting
 >>> asyncio.run(create_poll_and_download())
 ```
 
+### Data Client
+
+The Data Client mostly mirrors the
+[Data API](https://developers.planet.com/docs/apis/data/reference/),
+with the only difference being the addition of functionality to activate an
+asset, poll for when activation is complete, and download the asset.
+
+```python
+>>> from planet import DataClient
+>>>
+>>> async def main():
+...     async with Session() as sess:
+...         client = DataClient(sess)
+...         # perform operations here
+...
+>>> asyncio.run(main())
+
+```
+
+#### Filter
+
+When performing a quick search, creating or updating a saved search, or
+requesting stats, the data search filter must be provided to the API
+as a JSON blob. This JSON blob can be built up manually or by using the
+`data_filter` module.
+
+An example of creating the request JSON with `data_filter`:
+
+```python
+>>> from datetime import datetime
+>>> from planet import data_filter
+>>> sfilter = data_filter.and_filter([
+...     data_filter.permission_filter(),
+...     data_filter.date_range_filter('acquired', gt=datetime(2022, 6, 1, 1))
+... ])
+```
+
+The same thing, expressed as a `JSON` blob:
+
+```python
+>>> sfilter = {
+...     'type': 'AndFilter',
+...     'config': [
+...         {'type': 'PermissionFilter', 'config': ['assets:download']},
+...         {
+...             'type': 'DateRangeFilter',
+...             'field_name': 'acquired',
+...             'config': {'gt': '2022-06-01T01:00:00Z'}
+...         }
+...     ]
+... }
+```
+
+Once the filter is built up, performing a search is done within
+the context of a `Session` with the `DataClient`:
+
+```python
+>>> async def main():
+...     async with Session() as sess:
+...         cl = DataClient(sess)
+...         items = await cl.quick_search(sfilter)
+...
+>>> asyncio.run(main())
+```
+
 ## CLI
 
 ### Authentication
diff --git a/planet/data_filter.py b/planet/data_filter.py
new file mode 100644
index 0000000..bf22b15
--- /dev/null
+++ b/planet/data_filter.py
@@ -0,0 +1,296 @@
+# 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.
+"""Functionality for preparing a data search filter"""
+from datetime import datetime
+import logging
+from typing import Any, Callable, List, Union
+
+from planet import exceptions
+
+LOGGER = logging.getLogger(__name__)
+
+
+def and_filter(nested_filters: List[dict]) -> dict:
+    """Create an AndFilter
+
+    The AndFilter can be used to limit results to items with properties or
+    permissions which match all nested filters.
+
+    It is most commonly used as a top-level filter to ensure criteria across
+    all field and permission filters are met.
+
+    Parameters:
+        nested_filters: Filters to AND together
+    """
+    return {'type': 'AndFilter', 'config': nested_filters}
+
+
+def or_filter(nested_filters: List[dict]) -> dict:
+    """Create an OrFilter
+
+    The OrFilter can be used to match items with properties or permissions
+    which match at least one of the nested filters.
+
+    Parameters:
+        nested_filters: Filters to OR together
+    """
+    return {'type': 'OrFilter', 'config': nested_filters}
+
+
+def not_filter(nested_filter: dict):
+    """Create a NotFilter
+
+    The NotFilter can be used to match items with properties or permissions
+    which do not match the nested filter.
+
+    This filter only supports a single nested filter. Multiple NotFilters can
+    be nested within an AndFilter to filter across multiple fields or
+    permission values.
+
+    Parameters:
+        nested_filter: Filter to NOT
+    """
+    return {'type': 'NotFilter', 'config': nested_filter}
+
+
+def _field_filter(ftype: str, field_name: str, config: Union[dict,
+                                                             list]) -> dict:
+    return {'type': ftype, 'field_name': field_name, 'config': config}
+
+
+def _range_filter(
+    ftype: str,
+    field_name: str,
+    gt: Any,
+    lt: Any,
+    gte: Any,
+    lte: Any,
+    callback: Callable = None,
+) -> dict:
+    """Base for creating range filters.
+
+    Parameters:
+        ftype: Type of the filter
+        field_name: Name of field to filter on.
+        gt: Greater than conditional value.
+        lt: Less than conditional value.
+        gte: Greater than or equal to conditional value.
+        lte: Less than or equal to conditional value.
+        callback: Function to apply to preprocess conditional values.
+
+    Raises:
+        exceptions.PlanetError: If no conditional parameter is specified.
+    """
+    conditionals = {'gt': gt, 'lt': lt, 'gte': gte, 'lte': lte}
+
+    # if callback isn't specified, just use a passthrough
+    callback = callback if callback else lambda x: x
+
+    config = {
+        key: callback(value)
+        for (key, value) in conditionals.items() if value is not None
+    }
+
+    if not config:
+        raise exceptions.PlanetError("No conditional parameters specified.")
+
+    return _field_filter(ftype, field_name=field_name, config=config)
+
+
+def date_range_filter(field_name: str,
+                      gt: datetime = None,
+                      lt: datetime = None,
+                      gte: datetime = None,
+                      lte: datetime = None) -> dict:
+    """Create a DateRangeFilter
+
+    The DateRangeFilter can be used to search on any property with a timestamp
+    such as acquired or published.
+
+    One or more of the conditional parameters `gt`, `lt`, `gte`, `lte` must be
+    specified. Conditionals are combined in a logical AND, so only items that
+    match all specified conditionals are returned.
+
+    Parameters:
+        field_name: Name of field to filter on.
+        gt: Filter to field timestamp later than this datetime.
+        lt: Filter to field timestamp earlier than this datetime.
+        gte: Filter to field timestamp at or later than this datetime.
+        lte: Filter to field timestamp at or earlier than this datetime.
+
+    Raises:
+        exceptions.PlanetError: If no conditional parameter is specified.
+    """
+    return _range_filter('DateRangeFilter',
+                         field_name,
+                         gt,
+                         lt,
+                         gte,
+                         lte,
+                         callback=_datetime_to_rfc3339)
+
+
+def _datetime_to_rfc3339(value: datetime) -> str:
+    """Converts the datetime to an RFC3339 string"""
+    iso = value.isoformat()
+    if not value.utcoffset():
+        # rfc3339 needs a Z if there is no timezone offset
+        iso += 'Z'
+    return iso
+
+
+def range_filter(field_name: str,
+                 gt: float = None,
+                 lt: float = None,
+                 gte: float = None,
+                 lte: float = None) -> dict:
+    """Create a RangeFilter
+
+    The RangeFilter can be used to search for items with numerical properties.
+    It is useful for matching fields that have a continuous range of values
+    such as cloud_cover or view_angle.
+
+    One or more of the conditional parameters `gt`, `lt`, `gte`, `lte` must be
+    specified. Conditionals are combined in a logical AND, so only items that
+    match all specified conditionals are returned.
+
+    Parameters:
+        field_name: Name of field to filter on.
+        gt: Filter to field value greater than this number.
+        lt: Filter to field value less than this number.
+        gte: Filter to field value greater than or equal to this number.
+        lte: Filter to field value less than or equal to this number.
+
+    Raises:
+        exceptions.PlanetError: If no conditional parameter is specified.
+    """
+    return _range_filter('RangeFilter', field_name, gt, lt, gte, lte)
+
+
+def update_filter(field_name: str,
+                  gt: float = None,
+                  lt: float = None,
+                  gte: float = None,
+                  lte: float = None) -> dict:
+    """Create an UpdateFilter
+
+    The UpdateFilter can be used to filter items by changes to a specified
+    metadata field value made after a specified date, due to a republishing
+    event. This feature allows you identify items which may have been
+    republished with improvements or fixes, enabling you to keep your internal
+    catalogs up-to-date and make more informed redownload decisions. The filter
+    works for all items published on or after April 10, 2020.
+
+    One or more of the conditional parameters `gt` or `gte` must be
+    specified. Conditionals are combined in a logical AND, so only items that
+    match all specified conditionals are returned.
+
+    Parameters:
+        field_name: Name of field to filter on.
+        gt: Filter to changes to field metadata after this datetime.
+        gte: Filter to changes to field metadata at or after this datetime.
+
+    Raises:
+        exceptions.PlanetError: If no conditional parameter is specified.
+    """
+    return _range_filter('UpdateFilter',
+                         field_name,
+                         gt,
+                         None,
+                         gte,
+                         None,
+                         callback=_datetime_to_rfc3339)
+
+
+def geometry_filter(geom: dict) -> dict:
+    """Create a GeometryFilter
+
+    The GeometryFilter can be used to search for items with a footprint
+    geometry which intersects with the specified geometry.
+
+    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
+    Specification requirements. If an invalid GeoJSON object is supplied, the
+    API will automatically attempt to correct the geometry and return matching
+    search results.
+
+    Parameters:
+        geom: GeoJSON describing the filter geometry.
+    """
+    return _field_filter('GeometryFilter', field_name='geometry', config=geom)
+
+
+def number_in_filter(field_name: str, values: List[float]) -> dict:
+    """Create a NumberInFilter
+
+    The NumberInFilter can be used to search for items with numerical
+    poperties. It is useful for matching fields such as gsd.
+
+    Parameters:
+        field_name: Name of field to filter on.
+        values: List of numbers to filter on.
+    """
+    return _field_filter('NumberInFilter',
+                         field_name=field_name,
+                         config=values)
+
+
+def string_in_filter(field_name: str, values: List[str]) -> dict:
+    """Create a StringInFilter
+
+    The StringInFilter can be used to search for items with string properties
+    such as instrument or quality_category. Boolean properties such as
+    ground_control are also supported with the StringInFilter.
+
+    Filters to items with the given field matching any of the values.
+
+    Parameters:
+        field_name: Name of field to filter on.
+        values: List of strings to filter on.
+    """
+    return _field_filter('StringInFilter',
+                         field_name=field_name,
+                         config=values)
+
+
+def asset_filter(asset_types: List[str]) -> dict:
+    """Create an AssetFilter
+
+    The AssetFilter can be used to search for items which have published a
+    specified asset_type. This filter is commonly used to filter items by
+    published asset types which:
+
+    * May be published at delay after an item's first publish. analytic_sr,
+    for instance, may be published up to 12 hours after an item first becomes
+    available.
+    * May not be available for the full catalog. udm2, for instance, is only
+    available globally through July 2018.
+
+    Filters to all items which include any of the listed asset types. An
+    AndFilter can be used to filter items by multiple asset types.
+
+    Parameters:
+        asset_types: List of the names of the asset type to filter on.
+    """
+    return {'type': 'AssetFilter', 'config': asset_types}
+
+
+def permission_filter() -> dict:
+    """Create a PermissionFilter
+
+    The PermissionFilter limits results to items that a user has permission to
+    download.
+    """
+    return {'type': 'PermissionFilter', 'config': ['assets:download']}


Test patch:
diff --git a/tests/unit/test_data_filter.py b/tests/unit/test_data_filter.py
new file mode 100644
index 0000000..31e78c0
--- /dev/null
+++ b/tests/unit/test_data_filter.py
@@ -0,0 +1,212 @@
+# Copyright 2020 Planet Labs PBC.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+from datetime import datetime, timedelta, tzinfo
+import logging
+import pytest
+
+from planet import data_filter, exceptions
+
+LOGGER = logging.getLogger(__name__)
+
+
+def test_and_filter():
+    f1 = {'type': 'testf1'}
+    f2 = {'type': 'testf2'}
+    res = data_filter.and_filter([f1, f2])
+    expected = {'type': 'AndFilter', 'config': [f1, f2]}
+    assert res == expected
+
+
+def test_or_filter():
+    f1 = {'type': 'testf1'}
+    f2 = {'type': 'testf2'}
+    res = data_filter.or_filter([f1, f2])
+    expected = {'type': 'OrFilter', 'config': [f1, f2]}
+    assert res == expected
+
+
+def test_not_filter():
+    f1 = {'type': 'testf1'}
+    res = data_filter.not_filter(f1)
+    expected = {'type': 'NotFilter', 'config': f1}
+    assert res == expected
+
+
+def test__range_filter_success():
+
+    def _test_callback(x):
+        return x + 'a'
+
+    res = data_filter._range_filter('testfilter',
+                                    'testfield',
+                                    gt='a',
+                                    gte=None,
+                                    lt='b',
+                                    lte='c',
+                                    callback=_test_callback)
+    expected = {
+        'type': 'testfilter',
+        'field_name': 'testfield',
+        'config': {
+            'gt': 'aa', 'lt': 'ba', 'lte': 'ca'
+        }
+    }
+    assert expected == res
+
+
+def test__range_filter_nocallback():
+    res = data_filter._range_filter('testfilter',
+                                    'testfield',
+                                    gt='a',
+                                    gte=None,
+                                    lt='b',
+                                    lte='c')
+    expected = {
+        'type': 'testfilter',
+        'field_name': 'testfield',
+        'config': {
+            'gt': 'a', 'lt': 'b', 'lte': 'c'
+        }
+    }
+    assert expected == res
+
+
+def test__range_filter_no_conditionals():
+
+    def _test_callback(x):  # pragma: no cover
+        return x + 'a'
+
+    with pytest.raises(exceptions.PlanetError):
+        data_filter._range_filter('testfilter',
+                                  'testfield',
+                                  gt=None,
+                                  gte=None,
+                                  lt=None,
+                                  lte=None,
+                                  callback=_test_callback)
+
+
+class TZTest(tzinfo):
+
+    def __init__(self, offset=None):
+        self.offset = offset
+        super().__init__()
+
+    def utcoffset(self, dt):
+        return timedelta(hours=self.offset) if self.offset else None
+
+
+@pytest.mark.parametrize(
+    "dtime,expected",
+    [(datetime(2022, 5, 1, 1, 0, 0, 1), '2022-05-01T01:00:00.000001Z'),
+     (datetime(2022, 5, 1, 1, 0, 1), '2022-05-01T01:00:01Z'),
+     (datetime(2022, 6, 1, 1, 1), '2022-06-01T01:01:00Z'),
+     (datetime(2022, 6, 1, 1), '2022-06-01T01:00:00Z'),
+     (datetime(2022, 6, 1, 1, tzinfo=TZTest(0)), '2022-06-01T01:00:00Z'),
+     (datetime(2022, 6, 1, 1, tzinfo=TZTest(1)), '2022-06-01T01:00:00+01:00')])
+def test__datetime_to_rfc3339_basic(dtime, expected):
+    assert data_filter._datetime_to_rfc3339(dtime) == expected
+
+
+def test_date_range_filter_success():
+    res = data_filter.date_range_filter('testfield',
+                                        gt=datetime(2022, 6, 1, 1),
+                                        lt=datetime(2022, 7, 1, 1))
+    expected = {
+        'type': 'DateRangeFilter',
+        'field_name': 'testfield',
+        'config': {
+            'gt': '2022-06-01T01:00:00Z', 'lt': '2022-07-01T01:00:00Z'
+        }
+    }
+    assert res == expected
+
+
+def test_range_filter_noconditionals():
+    with pytest.raises(exceptions.PlanetError):
+        data_filter.range_filter('acquired')
+
+
+def test_range_filter_success():
+    res = data_filter.range_filter('testfield', gt=0.1, lt=0.9)
+    expected = {
+        'type': 'RangeFilter',
+        'field_name': 'testfield',
+        'config': {
+            'gt': 0.1, 'lt': 0.9
+        }
+    }
+    assert res == expected
+
+
+def test_date_range_filter_noconditionals():
+    with pytest.raises(exceptions.PlanetError):
+        data_filter.date_range_filter('acquired')
+
+
+def test_update_filter_success():
+    res = data_filter.update_filter('testfield', gt=datetime(2022, 6, 1, 1))
+    expected = {
+        'type': 'UpdateFilter',
+        'field_name': 'testfield',
+        'config': {
+            'gt': '2022-06-01T01:00:00Z'
+        }
+    }
+    assert res == expected
+
+
+def test_update_filter_noconditionals():
+    with pytest.raises(exceptions.PlanetError):
+        data_filter.update_filter('acquired')
+
+
+def test_geometry_filter(geom_geojson):
+    res = data_filter.geometry_filter(geom_geojson)
+    expected = {
+        'type': 'GeometryFilter',
+        'field_name': 'geometry',
+        'config': geom_geojson
+    }
+    assert res == expected
+
+
+def test_number_in_filter():
+    res = data_filter.number_in_filter('testfield', [3, 3])
+    expected = {
+        'type': 'NumberInFilter', 'field_name': 'testfield', 'config': [3, 3]
+    }
+    assert res == expected
+
+
+def test_string_in_filter():
+    res = data_filter.string_in_filter('testfield', ['three', 'three'])
+    expected = {
+        'type': 'StringInFilter',
+        'field_name': 'testfield',
+        'config': ['three', 'three']
+    }
+    assert res == expected
+
+
+def test_asset_filter():
+    res = data_filter.asset_filter(['asset1', 'asset2'])
+    expected = {'type': 'AssetFilter', 'config': ['asset1', 'asset2']}
+    assert res == expected
+
+
+def test_permission_filter():
+    res = data_filter.permission_filter()
+    expected = {'type': 'PermissionFilter', 'config': ['assets:download']}
+    assert res == expected