Repository: planetlabs/planet-client-python

Issue:
Helper methods for subscriptions
For the MVP of subscriptions we just have users directly form the entire json request, so we could ship something and iterate.

Now that it's out we should consider what we want to do to help users. To have one or more options like `planet data filter` or `planet order request`.

A user should be able to construct a decent subscription without having to get into JSON. When I have some time I'll map out some proposals, but just opening this to make sure we track and prioritize. 

#613 #601 are related - ideas on generating some of the JSON parts. 

Discussion:

Patch:
diff --git a/design-docs/CLI-Subscriptions.md b/design-docs/CLI-Subscriptions.md
index 043b858..599a740 100644
--- a/design-docs/CLI-Subscriptions.md
+++ b/design-docs/CLI-Subscriptions.md
@@ -184,7 +184,7 @@ planet subscriptions request [OPTIONS]
 
 Generate a subscriptions request.
 
-This command provides support for building the subscription request JSON used to create or 
+This command provides support for building the subscription request JSON used to create or
 update a subscription. It outputs the subscription request.
 
 Options:
@@ -207,7 +207,10 @@ Options:
 ### Usage Examples
 
 ```
-planet subscription request --source source.json --clip geom.json --delivery delivery.json | planet subscriptions create -
+planet subscription request \
+    --name test \
+    --source source.json \
+    --delivery delivery.json | planet subscriptions create -
 ```
 
 ## Request-catalog
@@ -222,13 +225,13 @@ Generate a subscriptions request source JSON for a catalog.
 Options:
   --asset-types TEXT              One or more comma-separated asset types. Required.
   --item-types TEXT               One or more comma-separated item-types. Required.
-  --geom JSON                 geometry of the area of interest of the subscription that will be used to determine matches. 
+  --geometry JSON                 geometry of the area of interest of the subscription that will be used to determine matches.
                                   Can be a json string, filename, or '-' for stdin.
   --start-time DATETIME           Start date and time to begin subscription.
   --end-time DATETIME             Date and time to end the subscription.
-  --rrule TEXT                    iCalendar recurrance rule to specify recurrances. 
-  --filter JSON                   A search filter can be specified a json string, 
-                                  filename, or '-' for stdin. 
+  --rrule TEXT                    iCalendar recurrance rule to specify recurrances.
+  --filter JSON                   A search filter can be specified a json string,
+                                  filename, or '-' for stdin.
   --pretty                        Format JSON output.
   --help                          Show this message and exit.
 ```
@@ -236,13 +239,12 @@ Options:
 ### Usage Examples
 
 ```
-planet subscriptions request \
-    --source $(planet subscriptions request-catalog
-        --item-type PSScene
-        --asset-types ortho_analytic_8b_sr,ortho_udm2
-        --geom aoi.json
-        --start-time 05/01/2022)
-    --delivery delivery.json | planet subscriptions create -
+planet subscriptions request-catalog \
+        --item-types PSScene \
+        --asset-types ortho_analytic_8b_sr,ortho_udm2 \
+        --geometry aoi.geojson \
+        --start-time 2022-01-01) \
+    --delivery delivery.json > source.json
 ```
 
 ## Request-other
@@ -257,7 +259,7 @@ Generate a subscriptions request source JSON for another product.
 Options:
   --type                        Type.
   --id                          Id.
-  --geom JSON                      geometry of the area of interest of the subscription that will be used to determine matches. 
+  --geometry JSON                      geometry of the area of interest of the subscription that will be used to determine matches.
                                   Can be a json string, filename, or '-' for stdin.
   --start-time DATETIME           Start date and time to begin subscription.
   --end-time DATETIME             Date and time to end the subscription.
@@ -266,18 +268,6 @@ Options:
   --help                          Show this message and exit.
 ```
 
-### Usage Examples
-
-```
-planet subscriptions request \
-    --source $(planet subscriptions request-other
-        --type othertype
-        --id otherid
-        --geom aoi.json
-        --start-time 05/01/2022)
-    --delivery delivery.json | planet subscriptions create -
-```
-
 
 ## Update
 
diff --git a/docs/cli/cli-data.md b/docs/cli/cli-data.md
index a000342..316927e 100644
--- a/docs/cli/cli-data.md
+++ b/docs/cli/cli-data.md
@@ -17,10 +17,10 @@ At this point you should have completed [Step 5](../get-started/quick-start-guid
 of the quick start guide, and run your first full data search command:
 
 ```
-planet data search PSScene --filter filter.json > recent-psscene.json
+planet data search PSScene filter.json > recent-psscene.json
 ```
 
-This saves the descriptions of the latest 100 standard-quality scenes you have permissions to download in a file, that you can open and look at.
+This saves the latest 100 scenes in a file, that you can open and look at.
 
 ### Pretty printing
 
@@ -28,7 +28,7 @@ You will likely notice that this file is quite wide, with one very long line for
 item returned. You can make for a more readable file by using the `--pretty` flag:
 
 ```
-planet data search --pretty PSScene --filter filter.json > recent-psscene.json
+planet data search --pretty PSScene filter.json > recent-psscene.json
 ```
 
 The `--pretty` flag is built into most of the CLI calls. But you can also achieve the
@@ -38,7 +38,7 @@ piping any JSON output through it prints it in a more readable form. So the foll
 command will do the same thing as the previous one:
 
 ```
-planet data search PSScene --filter filter.json | jq > recent-psscene.json
+planet data search PSScene filter.json | jq > recent-psscene.json
 ```
 
 You can read a bit [more about jq]((cli-intro.md#jq) in the CLI intro.
@@ -49,14 +49,14 @@ You also don't have to save the output to a file. If you don't redirect it into
 it will just print out on the console.
 
 ```
-planet data search PSScene --filter filter.json
+planet data search PSScene filter.json 
 ```
 
 If you enter this command you'll see the output stream by. Here you can use jq again, and
 it'll often give you nice syntax highlighting in addition to formatting.
 
 ```
-planet data search PSScene --filter filter.json | jq
+planet data search PSScene filter.json | jq
 ```
 
 ### Create filter and search in one call
@@ -66,7 +66,7 @@ passing the output of the `data filter` command directly to be the input of the
 command:
 
 ```
-planet data filter --permission --std-quality | planet data search --pretty PSScene --filter -
+planet data filter | planet data search --pretty PSScene -
 ```
 
 Note the dash (`-`), which explicitly tells the CLI to use the output from the call that is piped into it.
@@ -74,17 +74,6 @@ Note the dash (`-`), which explicitly tells the CLI to use the output from the c
 You can learn more about the pipe command, as well as the `>` command above in the 
 [Piping & redirection section](cli-intro.md#piping-redirection) of the CLI Introduction.
 
-### Search without filtering
-
-If no filtering is required, the search command can be called directly:
-
-```
-planet data search PSScene
-```
-
-This outputs the last 100 scenes.
-
-
 ### Search on Item Type
 
 These first searches were done on the [PSScene](https://developers.planet.com/docs/data/psscene/) 'item type', but you
@@ -93,7 +82,7 @@ its catalog. The item type is the first argument of the `search` command, follow
 you can specify any number of item types here:
 
 ```
-planet data search PSScene,Sentinel2L1C,Landsat8L1G,SkySatCollect
+planet data filter | planet data search PSScene,Sentinel2L1C,Landsat8L1G,SkySatCollect -
 ```
 
 This will search for all the most recent images captured by PlanetScope, SkySat, Sentinel 2 and Landsat 8 satellites. 
@@ -107,7 +96,7 @@ By default the `search` command returns only the 100 first scenes. But with the
 under the hood will automatically page through all the results from the API. 
 
 ```
-planet data search --limit 3000 PSScene
+planet data filter | planet data search --limit 3000 PSScene
 ```
 
 Note you can also do a call with no limits if you set the limit to `0`. Though don't use this haphazardly, or you'll be
@@ -122,13 +111,13 @@ the `planet collect` method to transform the output from the Data API to valid G
 output to it:
 
 ```console
-planet data search PSScene | planet collect -
+planet data filter | planet data search PSScene - | planet collect -
 ```
 
 If you want to visualize this you can save it as a file:
 
 ```console
-planet data search PSScene | planet collect - > planet-search.geojson
+planet data filter | planet data search PSScene - | planet collect - > planet-search.geojson
 ```
 
 This you can then open with your favorite GIS program, or see this 
@@ -149,13 +138,13 @@ descending order. The options are are:
 The lets you do things like get the id of the most recent skysat image taken (that you have download access to):
 
 ```console
-planet data search SkySatCollect --sort 'acquired desc' --limit 1
+planet data filter | planet data search SkySatCollect --sort 'acquired desc' --limit 1 - 
 ```
 
 And you can also just get the ID, using `jq`
 
 ```console
-planet data search SkySatCollect --sort 'acquired desc' --limit 1 - | jq -r .id
+planet data filter | planet data search SkySatCollect --sort 'acquired desc' --limit 1 - | jq -r .id
 ```
 
 
@@ -213,14 +202,14 @@ of Iowa. You can copy it and save as a file called `geometry.geojson`
 And then run it with this command:
 
 ```console
-planet data filter --geom geometry.geojson | planet data search PSScene --filter -
+planet data filter --geom geometry.geojson | planet data search PSScene -
 ```
 
 Note that by default all searches with the command-line return 100 results, but you can easily increase that with
 the `--limit` flag:
 
 ```console
-planet data filter --geom geometry.geojson | planet data search --limit 500 PSScene --filter -
+planet data filter --geom geometry.geojson | planet data search --limit 500 PSScene -
 ```
 
 Creating geometries for search can be annoying in a command-line workflow, but there are some ideas in the
@@ -231,7 +220,7 @@ Creating geometries for search can be annoying in a command-line workflow, but t
 Some of the most common filtering is by date. You could get all imagery acquired before August 2021:
 
 ```console
-planet data filter --date-range acquired lt 2021-08-01 | planet data search PSScene --filter -
+planet data filter --date-range acquired lt 2021-08-01 | planet data search PSScene -
 ```
 
 The 'operator' in this case is 'less than' (`lt`). The options are:
@@ -247,7 +236,7 @@ do a search for all images in July of 2021:
 
 ```console
 planet data filter --date-range acquired gte 2021-07-01 --date-range acquired lt 2021-08-01 | \
-planet data search PSScene --filter -
+planet data search PSScene -
 ```
 
 The date input understands [RFC 3339](https://datatracker.ietf.org/doc/html/rfc3339) and 
@@ -257,7 +246,7 @@ on July 1st 2021:
 
 ```console
 planet data filter --date-range acquired gte 2021-07-01:06:20:10 --date-range acquired lt 2021-07-01:06:20:15 | \
-planet data search PSScene --filter -
+planet data search PSScene - 
 ```
 
 ### Range Filter
@@ -266,7 +255,7 @@ The range filter uses the same operators as the date filter, but works against a
 of these tend to be ones about cloudy pixels. For example you can search for data with clear pixels greater than 90%:
 
 ```console
-planet data filter --range clear_percent gt 90 | planet data search PSScene --filter -
+planet data filter --range clear_percent gt 90
 ```
 
 ### String-In Filter
@@ -275,20 +264,19 @@ For properties that are strings you can use the `string-in` filter. For example
 with PS2 instrument:
 
 ```console
-planet data filter --string-in instrument PS2 | planet data search PSScene --filter -
+planet data filter --string-in instrument PS2 | planet data search PSScene -
 ```
 
 You can specify multiple strings to match, with a comma:
 
 ```console
-planet data filter --string-in instrument PS2,PSB.SD | planet data search PSScene --filter -
-
+planet data filter --string-in instrument PS2,PSB.SD | planet data search PSScene -
 ```
 
 Another example is to select all data in a single strip:
 
 ```console
-planet data filter --string-in strip_id 5743640 | planet data search PSScene --filter -
+planet data filter --string-in strip_id 5743640 | planet data search PSScene -
 ```
 
 Note that in all these commands we are piping the results into the search. If you don't include the pipe then you'll
@@ -299,13 +287,13 @@ get the filter output, which can be interested to inspect to see exactly what is
 You can limit your search to only data with a particular asset, for example search just for 8-band analytic assets:
 
 ```console
-planet data filter --asset ortho_analytic_8b_sr | planet data search PSScene --filter -
+planet data filter --asset ortho_analytic_8b_sr | planet data search PSScene -
 ```
 
 Or 8-band assets that also have a UDM.
 
 ```console
-planet data filter --asset ortho_analytic_8b_sr --asset udm2 | planet data search PSScene --filter -
+planet data filter --asset ortho_analytic_8b_sr --asset udm2 | planet data search PSScene -
 ```
 
 You can find the list of available assets in each Item Type Page, like 
@@ -318,14 +306,14 @@ sure you got the asset right, and it's valid for the item-types you're searching
 
 ### Permission Filter
 
-By default, no search filters are applied. However, many people want to search only for data they have access to download
-that are of standard (aka not test) quality. Therefore, these filters can be easily added with the `--permission` and
-`--std-quality` flags. To use the permission and standard quality filters:
+The 'permission filter' is set to true by default, since most people want to search only for data they have access to
+and are able to download. But if you'd like to just get search Planet's catalog and get a sense of what is out there
+you can set the permission filter to false:
 
 ```console
-planet data filter --permission --std-quality --asset ortho_analytic_8b_sr | planet data search PSScene --filter -
+planet data filter --permission false --asset ortho_analytic_8b_sr | planet data search PSScene -
 ```
 
 ## Stats
 
-TODO
+TODO
\ No newline at end of file
diff --git a/docs/cli/cli-intro.md b/docs/cli/cli-intro.md
index 6100876..7301208 100644
--- a/docs/cli/cli-intro.md
+++ b/docs/cli/cli-intro.md
@@ -84,13 +84,14 @@ one command to be the input for the next one. So instead of having to save to a
 then referring to it you can just do it all in one call:
 
 ```
-planet data filter --range cloud_percent lt 10 | planet data search PSScene --filter -
+planet data filter --range cloud_percent lt 10 | planet data search-quick PSScene -
 ```
 
 The pipe says to take the output of the first command and pass it to the input of 
 the second. You'll notice that the planet command has a dash (`-`), this is a convention
 that is often used by different CLI programs to explicitly say 'read from
-standard out'. Using the dash to mean
+standard out'. Most Planet CLI commands require it, but one or two will implicitly
+read from standard out if it's not explicitly included. Using the dash to mean
 'read from standard out' is a general convention used by many programs, but it's 
 not universal, so check the docs of the program you're using as to how it reads 
 from piped input. For example GDAL/OGR uses a specific `/vsistdin/` convention to 
@@ -173,7 +174,7 @@ represent GeoJSON features, the JSON blob is a GeoJSON FeatureCollection.
 Otherwise, the JSON blob is a list of the individual results.
 
 ```console
-$ planet data search PSScene | planet collect -
+$ planet data filter | planet data search PSScene - | planet collect -
 ```
 
 This gives you a fully compliant GeoJSON FeatureCollection, which is 
diff --git a/docs/cli/cli-tips-tricks.md b/docs/cli/cli-tips-tricks.md
index 4da63c3..050e6b8 100644
--- a/docs/cli/cli-tips-tricks.md
+++ b/docs/cli/cli-tips-tricks.md
@@ -53,7 +53,7 @@ JSON from the text box:
 On a mac you can use `pbpaste` to grab whatever is currently in your clipboard:
 
 ```console
-pbpaste | planet data filter --geom -  | planet data search SkySatCollect --filter -
+pbpaste | planet data filter --geom -  | planet data search SkySatCollect -
 ```
 
 (TODO: Find alternatives for windows and linux)
@@ -94,7 +94,7 @@ Or also on Placemark, which tends to perform a bit better (especially when you g
 For both it's recommended to pass the output through `planet collect` to get properly formatted GeoJSON:
 
 ```console
-planet data filter --string-in strip_id 5743669 | planet data search PSScene --filter - | planet collect - | pbcopy
+planet data filter --string-in strip_id 5743669 | planet data search PSScene - | planet collect - | pbcopy
 ```
 
 (TODO: Get pbcopy equivalents for windows and linux)
@@ -108,7 +108,7 @@ The following command will get the latest SkySat image captured, upload to githu
 your browser to see it:
 
 ```console
-planet data search SkySatCollect --sort 'acquired desc' --limit 1 \
+planet data filter | planet data search SkySatCollect - --sort 'acquired desc' --limit 1 \
 | planet collect - | jq | gh gist create -f latest-skysat.geojson -w
 ```
 
@@ -116,7 +116,7 @@ Or you can show all ps-scenes in a strip on github gist.
 (You may need to reload the page, for some reason it doesn't always showing up immediately after open)
 
 ```console
-planet data filter --string-in strip_id 5743640 | planet data search PSScene --filter - \
+planet data filter --string-in strip_id 5743640 | planet data search PSScene - \
 | planet collect - | gh gist create -f ps-search.geojson -w
 ```
 
@@ -125,9 +125,9 @@ TODO: get a command that gets the latest strip id and uses that as input in one
 This current command doesn't quite work.
 
 ```console
-strip-id=`planet data search PSScene --limit 1 \
+strip-id=`planet data filter | planet data search PSScene - --limit 1 \
 | jq -r '.properties.strip_id' | sed 's/\\[tn]//g'`
-planet data filter --string-in strip_id $stripid | planet data search PSScene --filter -
+planet data filter --string-in strip_id $stripid | planet data search PSScene -
 ```
 
 
@@ -146,20 +146,23 @@ Once it's set up you can just pipe any search command directly to `kepler` (it u
 
 ```console
 curl -s https://storage.googleapis.com/open-geodata/ch/vermont.json \
-| planet data filter --geom -  \
-| planet data search PSScene --filter - \
+| planet data filter --permission false --geom -  \
+| planet data search PSScene - \
 | kepler
 ```
 
+Note `--permission false` is added to these examples so that anyone with Planet access can try them, even if you don't
+have download permission for the area shown.
+
 (TODO: Add animated gif, showing some options)
 
 Kepler really excels at larger amounts of data, so try it out with larger limits:
 
 ```console
 curl -s https://storage.googleapis.com/open-geodata/ch/vermont.json \
-| planet data filter --geom - \
+| planet data filter --permission false --geom - \
 | planet data search PSScene,Sentinel2L1C,Landsat8L1G,SkySatCollect,Sentinel1 \
---sort 'acquired desc' --limit 1500 --filter - \
+--sort 'acquired desc' --limit 1500 - \
 | kepler
 ```
 
@@ -173,8 +176,8 @@ And you can bring it all together using Placemark for input and Kepler for outpu
 
 ```console
 curl -s https://api.placemark.io/api/v1/map/a0BWUEErqU9A1EDHZWHez/feature/91a07390-0652-11ed-8fdd-15633e4f8f01 \
-| planet data filter --geom - \
-| planet data search PSScene,Landsat8L1G,SkySatCollect,Sentinel1 --filter - | kepler
+| planet data filter --permission false --geom - \
+| planet data search PSScene,Landsat8L1G,SkySatCollect,Sentinel1 - | kepler
 ```
 
 #### Large Dataset Visualization
@@ -185,7 +188,7 @@ to disk. Getting 20,000 skysat collects will take at least a couple of minutes,
 100 megabytes of GeoJSON on disk.
 
 ```console
-planet data search SkySatCollect --limit 20000 > skysat-large.geojson
+planet data filter | planet data search SkySatCollect --limit 20000 > skysat-large.geojson
 ```
 
 Kepler can fairly easily handle 20,000 skysat footprints, try:
@@ -271,7 +274,7 @@ mapshaper -i footprints.geojson -simplify 15% -o simplified.geojson
 Once you find a simplification amount you're happy with you can use it as a piped output. 
 
 ```console
-planet data search --limit 20 SkySatCollect - | planet collect - | mapshaper -i - -simplify 15% -o skysat-ms2.geojson
+planet data filter | planet data search --limit 20 SkySatCollect - | planet collect - | mapshaper -i - -simplify 15% -o skysat-ms2.geojson
 ```
 
 Mapshaper also has more simplification algorithms to try out, so we recommend diving into the 
diff --git a/docs/get-started/quick-start-guide.md b/docs/get-started/quick-start-guide.md
index 5de63f3..a1f10eb 100644
--- a/docs/get-started/quick-start-guide.md
+++ b/docs/get-started/quick-start-guide.md
@@ -100,10 +100,10 @@ In this step, you search for the most recent PSScene images available to downloa
 One of the commands you’ll use most frequently is `planet data filter`. This “convenience method” creates the JSON you need to run other commands. Run it with no arguments to see how it works by default:
 
 ```console
-planet data filter --permission --std-quality
+planet data filter
 ```
 
-Look at the console output to see some default filters. `PermissionFilter` filters the output to only contain imagery that you have permission to download. You’ll also see `quality_category`, which means the output lists only images in the [`standard quality` category](https://developers.planet.com/docs/data/planetscope/#image-quality-standard-vs-test-imagery). Without these options, an empty filter is generated which would be used to disable filtering and simply return all results.
+Look at the console output to see some default filters. `PermissionFilter` filters the output to only contain imagery that you have permission to download. You’ll also see `quality_category`, which means the output lists only images in the [`standard quality` category](https://developers.planet.com/docs/data/planetscope/#image-quality-standard-vs-test-imagery). 
 
 !!!note "The --help switch is your friend"
     You can do a lot with this `filter` command. We recommend running `planet data filter --help` often to get a reference of how the commands work.
@@ -113,13 +113,13 @@ Look at the console output to see some default filters. `PermissionFilter` filte
 Run the filter command and save it to a file named `filter.json`:
 
 ```console
-planet data filter --permission --std-quality > filter.json
+planet data filter > filter.json
 ```
 
 Then use that file with the search command and save the results to another file named `recent-psscene.json`.
 
 ```console
-planet data search PSScene --filter filter.json > recent-psscene.json
+planet data search PSScene filter.json > recent-psscene.json
 ```
 
 Open `recent-psscene.json` to see the 100 most recent PSScene images you have permissions to actually download.
diff --git a/planet/cli/data.py b/planet/cli/data.py
index a232ede..b730609 100644
--- a/planet/cli/data.py
+++ b/planet/cli/data.py
@@ -17,12 +17,8 @@ from contextlib import asynccontextmanager
 
 import click
 
-from planet import data_filter, DataClient, exceptions
-from planet.clients.data import (LIST_SEARCH_TYPE,
-                                 LIST_SEARCH_TYPE_DEFAULT,
-                                 LIST_SORT_ORDER,
-                                 LIST_SORT_DEFAULT,
-                                 SEARCH_SORT,
+from planet import data_filter, DataClient
+from planet.clients.data import (SEARCH_SORT,
                                  SEARCH_SORT_DEFAULT,
                                  STATS_INTERVAL)
 from planet.specs import (get_item_types,
@@ -78,15 +74,6 @@ def check_item_types(ctx, param, item_types) -> Optional[List[dict]]:
         raise click.BadParameter(str(e))
 
 
-def check_search_id(ctx, param, search_id) -> str:
-    '''Ensure search id is a valix hex string'''
-    try:
-        _ = DataClient._check_search_id(search_id)
-    except exceptions.ClientError as e:
-        raise click.BadParameter(str(e))
-    return search_id
-
-
 def date_range_to_filter(ctx, param, values) -> Optional[List[dict]]:
 
     def _func(obj):
@@ -168,8 +155,8 @@ def string_in_to_filter(ctx, param, values) -> Optional[List[dict]]:
     VALUE is a comma-separated list of entries.
     When multiple entries are specified, an implicit 'or' logic is applied.""")
 @click.option('--permission',
-              is_flag=True,
-              default=False,
+              type=bool,
+              default=True,
               show_default=True,
               help='Filter to assets with download permissions.')
 @click.option('--range',
@@ -181,8 +168,8 @@ def string_in_to_filter(ctx, param, values) -> Optional[List[dict]]:
     FIELD is the name of the field to filter on.
     COMP can be lt, lte, gt, or gte.""")
 @click.option('--std-quality',
-              is_flag=True,
-              default=False,
+              type=bool,
+              default=True,
               show_default=True,
               help='Filter to standard quality.')
 @click.option('--string-in',
@@ -222,9 +209,6 @@ def filter(ctx,
     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.
-
-    If no options are specified, an empty filter is returned which, when used
-    in a search, bypasses all search filtering.
     """
     permission = data_filter.permission_filter() if permission else None
     std_quality = data_filter.std_quality_filter() if std_quality else None
@@ -250,13 +234,7 @@ def filter(ctx,
             else:
                 filters.append(f)
 
-    if filters:
-        filt = data_filter.and_filter(filters)
-    else:
-        # make it explicit that we return an empty filter
-        # when no filters are specified
-        filt = data_filter.empty_filter()
-
+    filt = data_filter.and_filter(filters)
     echo_json(filt, pretty)
 
 
@@ -267,9 +245,7 @@ def filter(ctx,
 @click.argument("item_types",
                 type=types.CommaSeparatedString(),
                 callback=check_item_types)
-@click.option('--filter',
-              type=types.JSON(),
-              help='Apply specified filter to search.')
+@click.argument("filter", type=types.JSON())
 @limit
 @click.option('--name', type=str, help='Name of the saved search.')
 @click.option('--sort',
@@ -286,9 +262,8 @@ async def search(ctx, item_types, filter, limit, name, sort, pretty):
 
     ITEM_TYPES is a comma-separated list of item-types to search.
 
-    If --filter is specified, the filter must be JSON and can be a json string,
-    filename, or '-' for stdin. If not specified, search results are not
-    filtered.
+    FILTER must be JSON and can be specified a json string, filename, or '-'
+    for stdin.
 
     Quick searches are stored for approximately 30 days and the --name
     parameter will be applied to the stored quick search.
@@ -296,7 +271,7 @@ async def search(ctx, item_types, filter, limit, name, sort, pretty):
     async with data_client(ctx) as cl:
 
         async for item in cl.search(item_types,
-                                    search_filter=filter,
+                                    filter,
                                     name=name,
                                     sort=sort,
                                     limit=limit):
@@ -337,62 +312,6 @@ async def search_create(ctx, name, item_types, filter, daily_email, pretty):
         echo_json(items, pretty)
 
 
-@data.command()
-@click.pass_context
-@translate_exceptions
-@coro
-@click.option('--sort',
-              type=click.Choice(LIST_SORT_ORDER),
-              default=LIST_SORT_DEFAULT,
-              show_default=True,
-              help='Field and direction to order results by.')
-@click.option('--search-type',
-              type=click.Choice(LIST_SEARCH_TYPE),
-              default=LIST_SEARCH_TYPE_DEFAULT,
-              show_default=True,
-              help='Search type filter.')
-@limit
-@pretty
-async def search_list(ctx, sort, search_type, limit, pretty):
-    """List saved searches.
-
-    This function outputs a full JSON description of the saved searches,
-    optionally pretty-printed.
-    """
-    async with data_client(ctx) as cl:
-        async for item in cl.list_searches(sort=sort,
-                                           search_type=search_type,
-                                           limit=limit):
-            echo_json(item, pretty)
-
-
-@data.command()
-@click.pass_context
-@translate_exceptions
-@coro
-@click.argument('search_id', callback=check_search_id)
-@click.option('--sort',
-              type=click.Choice(SEARCH_SORT),
-              default=SEARCH_SORT_DEFAULT,
-              show_default=True,
-              help='Field and direction to order results by.')
-@limit
-@pretty
-async def search_run(ctx, search_id, sort, limit, pretty):
-    """Execute a saved structured item search.
-
-    This function outputs a series of GeoJSON descriptions, one for each of the
-    returned items, optionally pretty-printed.
-    """
-    async with data_client(ctx) as cl:
-        async for item in cl.run_search(search_id, sort=sort, limit=limit):
-            echo_json(item, pretty)
-
-
-# TODO: search-update
-# TODO: search-delete
-
-
 @data.command(epilog=valid_item_string)
 @click.pass_context
 @translate_exceptions
@@ -433,53 +352,8 @@ async def search_get(ctx, search_id, pretty):
         echo_json(items, pretty)
 
 
-@data.command()
-@click.pass_context
-@translate_exceptions
-@coro
-@click.argument('search_id')
-async def search_delete(ctx, search_id):
-    """Delete an existing saved search.
-    """
-    async with data_client(ctx) as cl:
-        await cl.delete_search(search_id)
-
-
-@data.command()
-@click.pass_context
-@translate_exceptions
-@coro
-@click.argument('search_id')
-@click.argument('name')
-@click.argument("item_types",
-                type=types.CommaSeparatedString(),
-                callback=check_item_types)
-@click.argument('filter', type=types.JSON())
-@click.option('--daily-email',
-              is_flag=True,
-              help='Send a daily email when new results are added.')
-@pretty
-async def search_update(ctx,
-                        search_id,
-                        name,
-                        item_types,
-                        filter,
-                        daily_email,
-                        pretty):
-    """Update a saved search with the given search request.
-
-    This function outputs a full JSON description of the updated search,
-    optionally pretty-printed.
-    """
-    async with data_client(ctx) as cl:
-        items = await cl.update_search(search_id,
-                                       name,
-                                       item_types,
-                                       filter,
-                                       daily_email)
-        echo_json(items, pretty)
-
-
+# TODO: search_update()".
+# TODO: search_delete()".
 # TODO: search_run()".
 # TODO: item_get()".
 # TODO: asset_activate()".
diff --git a/planet/cli/subscriptions.py b/planet/cli/subscriptions.py
index 8cc508a..7e0b21d 100644
--- a/planet/cli/subscriptions.py
+++ b/planet/cli/subscriptions.py
@@ -9,6 +9,7 @@ from .io import echo_json
 from .options import limit, pretty
 from .session import CliSession
 from planet.clients.subscriptions import SubscriptionsClient
+from .. import subscription_request
 
 
 @asynccontextmanager
@@ -157,3 +158,84 @@ async def list_subscription_results_cmd(ctx,
                                                status=status,
                                                limit=limit):
             echo_json(result, pretty)
+
+
+@subscriptions.command()
+@click.option('--name',
+              required=True,
+              type=str,
+              help='Subscription name. Does not need to be unique.')
+@click.option('--source',
+              required=True,
+              type=types.JSON(),
+              help='Source JSON. Can be a string, filename, or - for stdin.')
+@click.option('--delivery',
+              required=True,
+              type=types.JSON(),
+              help='Delivery JSON. Can be a string, filename, or - for stdin.')
+@click.option(
+    '--notifications',
+    type=types.JSON(),
+    help='Notifications JSON. Can be a string, filename, or - for stdin.')
+@click.option('--tools',
+              type=types.JSON(),
+              help='Toolchain JSON. Can be a string, filename, or - for stdin.'
+              )
+@pretty
+def request(name, source, delivery, notifications, tools, pretty):
+    """Generate a subscriptions request."""
+    res = subscription_request.build_request(name,
+                                             source,
+                                             delivery,
+                                             notifications=notifications,
+                                             tools=tools)
+    echo_json(res, pretty)
+
+
+@subscriptions.command()
+@click.option('--item-types',
+              required=True,
+              type=types.CommaSeparatedString(),
+              help='One or more comma-separated item types.')
+@click.option('--asset-types',
+              required=True,
+              type=types.CommaSeparatedString(),
+              help='One or more comma-separated asset types.')
+@click.option(
+    '--geometry',
+    required=True,
+    type=types.JSON(),
+    help="""Geometry of the area of interest of the subscription that will be
+    used to determine matches. Can be a string, filename, or - for stdin.""")
+@click.option('--start-time',
+              required=True,
+              type=types.DateTime(),
+              help='Date and time to begin subscription.')
+@click.option('--end-time',
+              type=types.DateTime(),
+              help='Date and time to end subscription.')
+@click.option('--rrule',
+              type=str,
+              help='iCalendar recurrance rule to specify recurrances.')
+@click.option('--filter',
+              type=types.JSON(),
+              help='Search filter.  Can be a string, filename, or - for stdin.'
+              )
+@pretty
+def request_catalog(item_types,
+                    asset_types,
+                    geometry,
+                    start_time,
+                    end_time,
+                    rrule,
+                    filter,
+                    pretty):
+    """Generate a subscriptions request catalog source description."""
+    res = subscription_request.catalog_source(item_types,
+                                              asset_types,
+                                              geometry,
+                                              start_time,
+                                              end_time=end_time,
+                                              rrule=rrule,
+                                              filter=filter)
+    echo_json(res, pretty)
diff --git a/planet/clients/data.py b/planet/clients/data.py
index 82c22c2..2acd649 100644
--- a/planet/clients/data.py
+++ b/planet/clients/data.py
@@ -18,9 +18,7 @@ import logging
 from pathlib import Path
 import time
 from typing import Any, AsyncIterator, Callable, Dict, List, Optional
-import uuid
 
-from ..data_filter import empty_filter
 from .. import exceptions
 from ..constants import PLANET_BASE_URL
 from ..http import Session
@@ -31,13 +29,8 @@ SEARCHES_PATH = '/searches'
 STATS_PATH = '/stats'
 
 # TODO: get these values from the spec directly gh-619
-# NOTE: these values are mached against a lower-case value so must also be
-# lower-case
 LIST_SORT_ORDER = ('created desc', 'created asc')
 LIST_SEARCH_TYPE = ('any', 'saved', 'quick')
-LIST_SORT_DEFAULT = 'created desc'
-LIST_SEARCH_TYPE_DEFAULT = 'any'
-
 SEARCH_SORT = ('published desc',
                'published asc',
                'acquired desc',
@@ -94,15 +87,6 @@ class DataClient:
         if self._base_url.endswith('/'):
             self._base_url = self._base_url[:-1]
 
-    @staticmethod
-    def _check_search_id(sid):
-        """Raises planet.exceptions.ClientError if sid is not a valid UUID"""
-        try:
-            uuid.UUID(hex=sid)
-        except (ValueError, AttributeError):
-            msg = f'Search id ({sid}) is not a valid UUID hexadecimal string.'
-            raise exceptions.ClientError(msg)
-
     def _searches_url(self):
         return f'{self._base_url}{SEARCHES_PATH}'
 
@@ -111,7 +95,7 @@ class DataClient:
 
     async def search(self,
                      item_types: List[str],
-                     search_filter: Optional[dict] = None,
+                     search_filter: dict,
                      name: Optional[str] = None,
                      sort: Optional[str] = None,
                      limit: int = 100) -> AsyncIterator[dict]:
@@ -128,8 +112,7 @@ class DataClient:
 
         Parameters:
             item_types: The item types to include in the search.
-            search_filter: Structured search criteria to apply. If None,
-                no search criteria is applied.
+            search_filter: Structured search criteria.
             sort: Field and direction to order results by. Valid options are
             given in SEARCH_SORT.
             name: The name of the saved search.
@@ -144,8 +127,6 @@ class DataClient:
         """
         url = f'{self._base_url}/quick-search'
 
-        search_filter = search_filter or empty_filter()
-
         # TODO: validate item_types
         request_json = {'filter': search_filter, 'item_types': item_types}
         if name:
@@ -249,8 +230,8 @@ class DataClient:
         return response.json()
 
     async def list_searches(self,
-                            sort: str = LIST_SORT_DEFAULT,
-                            search_type: str = LIST_SEARCH_TYPE_DEFAULT,
+                            sort: str = 'created desc',
+                            search_type: str = 'any',
                             limit: int = 100) -> AsyncIterator[dict]:
         """Iterate through list of searches available to the user.
 
@@ -261,7 +242,7 @@ class DataClient:
 
         Parameters:
             sort: Field and direction to order results by.
-            search_type: Filter to specified search type.
+            search_type: Search type filter.
             limit: Maximum number of results to return. When set to 0, no
                 maximum is applied.
 
@@ -273,26 +254,19 @@ class DataClient:
             planet.exceptions.ClientError: If sort or search_type are not
                 valid.
         """
-        params = {}
-
         sort = sort.lower()
         if sort not in LIST_SORT_ORDER:
             raise exceptions.ClientError(
                 f'{sort} must be one of {LIST_SORT_ORDER}')
-        elif sort != LIST_SORT_DEFAULT:
-            params['_sort'] = sort
 
         search_type = search_type.lower()
         if search_type not in LIST_SEARCH_TYPE:
             raise exceptions.ClientError(
                 f'{search_type} must be one of {LIST_SEARCH_TYPE}')
-        elif search_type != LIST_SEARCH_TYPE_DEFAULT:
-            params['search_type'] = search_type
 
-        url = self._searches_url()
-        response = await self._session.request(method='GET',
-                                               url=url,
-                                               params=params)
+        url = f'{self._searches_url()}'
+
+        response = await self._session.request(method='GET', url=url)
         async for s in Searches(response, self._session.request, limit=limit):
             yield s
 
@@ -328,7 +302,6 @@ class DataClient:
 
     async def run_search(self,
                          search_id: str,
-                         sort: Optional[str] = None,
                          limit: int = 100) -> AsyncIterator[dict]:
         """Iterate over results from a saved search.
 
@@ -339,8 +312,6 @@ class DataClient:
 
         Parameters:
             search_id: Stored search identifier.
-            sort: Field and direction to order results by. Valid options are
-            given in SEARCH_SORT.
             limit: Maximum number of results to return. When set to 0, no
                 maximum is applied.
 
@@ -349,22 +320,10 @@ class DataClient:
 
         Raises:
             planet.exceptions.APIError: On API error.
-            planet.exceptions.ClientError: If search_id or sort is not valid.
         """
-        self._check_search_id(search_id)
         url = f'{self._searches_url()}/{search_id}/results'
 
-        params = {}
-        if sort and sort != SEARCH_SORT_DEFAULT:
-            sort = sort.lower()
-            if sort not in SEARCH_SORT:
-                raise exceptions.ClientError(
-                    f'{sort} must be one of {SEARCH_SORT}')
-            params['_sort'] = sort
-
-        response = await self._session.request(method='GET',
-                                               url=url,
-                                               params=params)
+        response = await self._session.request(method='GET', url=url)
         async for i in Items(response, self._session.request, limit=limit):
             yield i
 
diff --git a/planet/data_filter.py b/planet/data_filter.py
index 86db69a..e92fb65 100644
--- a/planet/data_filter.py
+++ b/planet/data_filter.py
@@ -21,11 +21,6 @@ from planet import exceptions, geojson
 LOGGER = logging.getLogger(__name__)
 
 
-def empty_filter() -> dict:
-    """Create an Empty filter for bypassing search filtering."""
-    return {'type': 'AndFilter', 'config': []}
-
-
 def and_filter(nested_filters: List[dict]) -> dict:
     """Create an AndFilter
 
diff --git a/planet/subscription_request.py b/planet/subscription_request.py
index 26b761e..1a01e50 100644
--- a/planet/subscription_request.py
+++ b/planet/subscription_request.py
@@ -84,7 +84,7 @@ def build_request(name: str,
         name: Name of the subscription.
         source: A source for the subscription, i.e. catalog.
         delivery: A delivery mechanism e.g. GCS, AWS, Azure, or OCS.
-        notifications: Specify notifications via email/webhook.
+        notifications: Specify notifications via email/webhook
         tools: Tools to apply to the products. Order defines
             the toolchain order of operatations.
     """
@@ -258,16 +258,6 @@ def oracle_cloud_storage(customer_access_key_id: str,
 
 
 def notifications(url: str, topics: List[str]) -> dict:
-    '''Specify a subscriptions API notification.
-
-    Webhook notifications proactively notify you when a subscription matches
-    and delivers an item so you have confidence that you have all the expected
-    imagery.
-
-    Parameters:
-        url: location of webhook/callback where you expect to receive updates.
-        topics: Event types that you can choose to be notified about.
-    '''
     for i, t in enumerate(topics):
         try:
             topics[i] = specs.get_match(t, NOTIFICATIONS_TOPICS, 'topic')


Test patch:
diff --git a/tests/integration/test_data_api.py b/tests/integration/test_data_api.py
index 736d751..8283d8b 100644
--- a/tests/integration/test_data_api.py
+++ b/tests/integration/test_data_api.py
@@ -24,17 +24,12 @@ import httpx
 import pytest
 import respx
 
-from planet import exceptions, DataClient, data_filter
-from planet.clients.data import (LIST_SORT_DEFAULT,
-                                 LIST_SEARCH_TYPE_DEFAULT,
-                                 SEARCH_SORT_DEFAULT)
+from planet import exceptions, DataClient
 
-TEST_URL = 'http://www.mocknotrealurl.com/api/path'
+TEST_URL = 'http://www.MockNotRealURL.com/api/path'
 TEST_SEARCHES_URL = f'{TEST_URL}/searches'
 TEST_STATS_URL = f'{TEST_URL}/stats'
 
-VALID_SEARCH_ID = '286469f0b27c476e96c3c4e561f59664'
-
 LOGGER = logging.getLogger(__name__)
 
 
@@ -72,41 +67,10 @@ def search_response(item_descriptions):
 
 @respx.mock
 @pytest.mark.asyncio
-async def test_search_basic(item_descriptions, search_response, session):
-
-    quick_search_url = f'{TEST_URL}/quick-search'
-    next_page_url = f'{TEST_URL}/blob/?page_marker=IAmATest'
-
-    item1, item2, item3 = item_descriptions
-    page1_response = {
-        "_links": {
-            "_next": next_page_url
-        }, "features": [item1, item2]
-    }
-    mock_resp1 = httpx.Response(HTTPStatus.OK, json=page1_response)
-    respx.post(quick_search_url).return_value = mock_resp1
-
-    page2_response = {"_links": {"_self": next_page_url}, "features": [item3]}
-    mock_resp2 = httpx.Response(HTTPStatus.OK, json=page2_response)
-    respx.get(next_page_url).return_value = mock_resp2
-
-    cl = DataClient(session, base_url=TEST_URL)
-    items_list = [i async for i in cl.search(['PSScene'])]
-
-    # check that request is correct
-    expected_request = {
-        "item_types": ["PSScene"], "filter": data_filter.empty_filter()
-    }
-    actual_body = json.loads(respx.calls[0].request.content)
-    assert actual_body == expected_request
-
-    # check that all of the items were returned unchanged
-    assert items_list == item_descriptions
-
-
-@respx.mock
-@pytest.mark.asyncio
-async def test_search_name(item_descriptions, search_response, session):
+async def test_search_basic(item_descriptions,
+                            search_filter,
+                            search_response,
+                            session):
 
     quick_search_url = f'{TEST_URL}/quick-search'
     next_page_url = f'{TEST_URL}/blob/?page_marker=IAmATest'
@@ -125,12 +89,15 @@ async def test_search_name(item_descriptions, search_response, session):
     respx.get(next_page_url).return_value = mock_resp2
 
     cl = DataClient(session, base_url=TEST_URL)
-    items_list = [i async for i in cl.search(['PSScene'], name='quick_search')]
+    items_list = [
+        i async for i in cl.search(
+            ['PSScene'], search_filter, name='quick_search')
+    ]
 
     # check that request is correct
     expected_request = {
         "item_types": ["PSScene"],
-        "filter": data_filter.empty_filter(),
+        "filter": search_filter,
         "name": "quick_search"
     }
     actual_body = json.loads(respx.calls[0].request.content)
@@ -140,43 +107,6 @@ async def test_search_name(item_descriptions, search_response, session):
     assert items_list == item_descriptions
 
 
-@respx.mock
-@pytest.mark.asyncio
-async def test_search_filter(item_descriptions,
-                             search_filter,
-                             search_response,
-                             session):
-
-    quick_search_url = f'{TEST_URL}/quick-search'
-    next_page_url = f'{TEST_URL}/blob/?page_marker=IAmATest'
-
-    item1, item2, item3 = item_descriptions
-    page1_response = {
-        "_links": {
-            "_next": next_page_url
-        }, "features": [item1, item2]
-    }
-    mock_resp1 = httpx.Response(HTTPStatus.OK, json=page1_response)
-    respx.post(quick_search_url).return_value = mock_resp1
-
-    page2_response = {"_links": {"_self": next_page_url}, "features": [item3]}
-    mock_resp2 = httpx.Response(HTTPStatus.OK, json=page2_response)
-    respx.get(next_page_url).return_value = mock_resp2
-
-    cl = DataClient(session, base_url=TEST_URL)
-    items_list = [
-        i async for i in cl.search(['PSScene'], search_filter=search_filter)
-    ]
-
-    # check that request is correct
-    expected_request = {"item_types": ["PSScene"], "filter": search_filter}
-    actual_body = json.loads(respx.calls[0].request.content)
-    assert actual_body == expected_request
-
-    # check that all of the items were returned unchanged
-    assert items_list == item_descriptions
-
-
 @respx.mock
 @pytest.mark.asyncio
 async def test_search_sort(item_descriptions,
@@ -330,6 +260,7 @@ async def test_get_search_id_doesnt_exist(search_id, session):
 @respx.mock
 @pytest.mark.asyncio
 async def test_update_search_basic(search_filter, session):
+    sid = 'search_id'
 
     page_response = {
         "__daily_email_enabled": False,
@@ -338,19 +269,16 @@ async def test_update_search_basic(search_filter, session):
         },
         "created": "2019-08-24T14:15:22Z",
         "filter": search_filter,
-        "id": VALID_SEARCH_ID,
+        "id": sid,
         "last_executed": "2019-08-24T14:15:22Z",
         "name": "test",
         "updated": "2019-08-24T14:15:22Z"
     }
     mock_resp = httpx.Response(HTTPStatus.OK, json=page_response)
-    respx.put(
-        f'{TEST_SEARCHES_URL}/{VALID_SEARCH_ID}').return_value = mock_resp
+    respx.put(f'{TEST_SEARCHES_URL}/{sid}').return_value = mock_resp
 
     cl = DataClient(session, base_url=TEST_URL)
-    search = await cl.update_search(VALID_SEARCH_ID,
-                                    'test', ['PSScene'],
-                                    search_filter)
+    search = await cl.update_search(sid, 'test', ['PSScene'], search_filter)
 
     # check that request is correct
     expected_request = {
@@ -385,41 +313,6 @@ async def test_list_searches_success(limit,
     assert route.called
 
 
-@respx.mock
-@pytest.mark.asyncio
-@pytest.mark.parametrize("sort, rel_url",
-                         [(LIST_SORT_DEFAULT, ''),
-                          ('created asc', '?_sort=created+asc')])
-async def test_list_searches_sort(sort, rel_url, search_result, session):
-    page1_response = {"_links": {}, "searches": [search_result] * 4}
-    route = respx.get(f'{TEST_SEARCHES_URL}{rel_url}')
-    route.return_value = httpx.Response(200, json=page1_response)
-
-    cl = DataClient(session, base_url=TEST_URL)
-    _ = [s async for s in cl.list_searches(sort=sort)]
-
-    assert route.called
-
-
-@respx.mock
-@pytest.mark.asyncio
-@pytest.mark.parametrize("search_type, rel_url",
-                         [(LIST_SEARCH_TYPE_DEFAULT, ''),
-                          ('saved', '?search_type=saved')])
-async def test_list_searches_searchtype(search_type,
-                                        rel_url,
-                                        search_result,
-                                        session):
-    page1_response = {"_links": {}, "searches": [search_result] * 4}
-    route = respx.get(f'{TEST_SEARCHES_URL}{rel_url}')
-    route.return_value = httpx.Response(200, json=page1_response)
-
-    cl = DataClient(session, base_url=TEST_URL)
-    _ = [s async for s in cl.list_searches(search_type=search_type)]
-
-    assert route.called
-
-
 @respx.mock
 @pytest.mark.asyncio
 @pytest.mark.parametrize(
@@ -447,69 +340,24 @@ async def test_list_searches_args_do_not_match(sort,
                          [(204, does_not_raise()),
                           (404, pytest.raises(exceptions.APIError))])
 async def test_delete_search(retcode, expectation, session):
+    sid = 'search_id'
     mock_resp = httpx.Response(retcode)
-    route = respx.delete(f'{TEST_SEARCHES_URL}/{VALID_SEARCH_ID}')
+    route = respx.delete(f'{TEST_SEARCHES_URL}/{sid}')
     route.return_value = mock_resp
     cl = DataClient(session, base_url=TEST_URL)
 
     with expectation:
-        await cl.delete_search(VALID_SEARCH_ID)
+        await cl.delete_search(sid)
 
     assert route.called
 
 
 @respx.mock
 @pytest.mark.asyncio
-@pytest.mark.parametrize("search_id, valid", [(VALID_SEARCH_ID, True),
-                                              ('invalid', False)])
-@pytest.mark.parametrize("limit, expected_count", [(None, 3), (2, 2)])
-async def test_run_search_basic(item_descriptions,
-                                session,
-                                search_id,
-                                valid,
-                                limit,
-                                expected_count):
-    """Ensure run_search is successful and handles search_id and limit"""
-    next_page_url = f'{TEST_URL}/blob/?page_marker=IAmATest'
-    item1, item2, item3 = item_descriptions
-    page1_response = {
-        "_links": {
-            "_next": next_page_url
-        }, "features": [item1, item2]
-    }
-
-    route = respx.get(f'{TEST_SEARCHES_URL}/{search_id}/results')
-    route.return_value = httpx.Response(204, json=page1_response)
-
-    page2_response = {"_links": {"_self": next_page_url}, "features": [item3]}
-    mock_resp2 = httpx.Response(HTTPStatus.OK, json=page2_response)
-    respx.get(next_page_url).return_value = mock_resp2
-
-    cl = DataClient(session, base_url=TEST_URL)
-
-    if valid:
-        items_list = [i async for i in cl.run_search(search_id, limit=limit)]
-
-        assert route.called
-
-        # check that all of the items were returned unchanged
-        assert items_list == item_descriptions[:expected_count]
-    else:
-        with pytest.raises(exceptions.ClientError):
-            [i async for i in cl.run_search(search_id)]
+async def test_run_search_success(item_descriptions, session):
+    sid = 'search_id'
+    route = respx.get(f'{TEST_SEARCHES_URL}/{sid}/results')
 
-
-@respx.mock
-@pytest.mark.asyncio
-@pytest.mark.parametrize("sort, rel_url, valid",
-                         [(SEARCH_SORT_DEFAULT, '', True),
-                          ('acquired asc', '?_sort=acquired+asc', True),
-                          ('invalid', '', False)])
-async def test_run_search_sort(item_descriptions,
-                               session,
-                               sort,
-                               rel_url,
-                               valid):
     next_page_url = f'{TEST_URL}/blob/?page_marker=IAmATest'
     item1, item2, item3 = item_descriptions
     page1_response = {
@@ -518,8 +366,6 @@ async def test_run_search_sort(item_descriptions,
         }, "features": [item1, item2]
     }
 
-    route = respx.get(
-        f'{TEST_SEARCHES_URL}/{VALID_SEARCH_ID}/results{rel_url}')
     route.return_value = httpx.Response(204, json=page1_response)
 
     page2_response = {"_links": {"_self": next_page_url}, "features": [item3]}
@@ -527,24 +373,24 @@ async def test_run_search_sort(item_descriptions,
     respx.get(next_page_url).return_value = mock_resp2
 
     cl = DataClient(session, base_url=TEST_URL)
+    items_list = [i async for i in cl.run_search(sid)]
 
-    expectation = pytest.raises(
-        exceptions.ClientError) if not valid else does_not_raise()
+    assert route.called
 
-    with expectation:
-        [s async for s in cl.run_search(VALID_SEARCH_ID, sort=sort)]
-        assert route.called
+    # check that all of the items were returned unchanged
+    assert items_list == item_descriptions
 
 
 @respx.mock
 @pytest.mark.asyncio
 async def test_run_search_doesnotexist(session):
-    route = respx.get(f'{TEST_SEARCHES_URL}/{VALID_SEARCH_ID}/results')
+    sid = 'search_id'
+    route = respx.get(f'{TEST_SEARCHES_URL}/{sid}/results')
     route.return_value = httpx.Response(404)
 
     cl = DataClient(session, base_url=TEST_URL)
     with pytest.raises(exceptions.APIError):
-        [i async for i in cl.run_search(VALID_SEARCH_ID)]
+        [i async for i in cl.run_search(sid)]
 
     assert route.called
 
diff --git a/tests/integration/test_data_cli.py b/tests/integration/test_data_cli.py
index c8f37b8..109497c 100644
--- a/tests/integration/test_data_cli.py
+++ b/tests/integration/test_data_cli.py
@@ -23,9 +23,6 @@ from click.testing import CliRunner
 import pytest
 
 from planet.cli import cli
-from planet.clients.data import (LIST_SEARCH_TYPE_DEFAULT,
-                                 LIST_SORT_DEFAULT,
-                                 SEARCH_SORT_DEFAULT)
 from planet.specs import get_item_types
 
 LOGGER = logging.getLogger(__name__)
@@ -35,31 +32,18 @@ TEST_QUICKSEARCH_URL = f'{TEST_URL}/quick-search'
 TEST_SEARCHES_URL = f'{TEST_URL}/searches'
 TEST_STATS_URL = f'{TEST_URL}/stats'
 
-VALID_SEARCH_ID = '286469f0b27c476e96c3c4e561f59664'
-
 
 @pytest.fixture
 def invoke():
 
-    def _invoke(extra_args, runner=None, **kwargs):
+    def _invoke(extra_args, runner=None):
         runner = runner or CliRunner()
         args = ['data'] + extra_args
-        return runner.invoke(cli.main, args=args, **kwargs)
+        return runner.invoke(cli.main, args=args)
 
     return _invoke
 
 
-@pytest.fixture
-def item_descriptions(get_test_file_json):
-    item_ids = [
-        '20220125_075509_67_1061',
-        '20220125_075511_17_1061',
-        '20220125_075650_17_1061'
-    ]
-    items = [get_test_file_json(f'data_item_{id}.json') for id in item_ids]
-    return items
-
-
 def test_data_command_registered(invoke):
     """planet-data command prints help and usage message."""
     runner = CliRunner()
@@ -69,8 +53,6 @@ def test_data_command_registered(invoke):
     assert "search" in result.output
     assert "search-create" in result.output
     assert "search-get" in result.output
-    assert "search-delete" in result.output
-    assert "search-update" in result.output
     # Add other sub-commands here.
 
 
@@ -91,10 +73,17 @@ def test_data_search_command_registered(invoke):
     # Add other sub-commands here.
 
 
-@pytest.fixture
-def search_filter(get_test_file_json):
-    filename = 'data_search_filter_2022-01.json'
-    return get_test_file_json(filename)
+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
@@ -114,45 +103,27 @@ def assert_and_filters_equal():
 
 @respx.mock
 @pytest.mark.asyncio
-def test_data_filter_defaults(invoke, assert_and_filters_equal):
-
-    result = invoke(["filter"])
-    assert result.exit_code == 0
-
-    empty_filter = {"type": "AndFilter", "config": []}
-    assert_and_filters_equal(json.loads(result.output), empty_filter)
-
-
-@respx.mock
-@pytest.mark.asyncio
-def test_data_filter_permission(invoke, assert_and_filters_equal):
-    result = invoke(["filter", "--permission"])
-    assert result.exit_code == 0
-
-    expected_filt = {
-        "type": "AndFilter",
-        "config": [{
-            "type": "PermissionFilter", "config": ["assets:download"]
-        }]
-    }
-    assert_and_filters_equal(json.loads(result.output), expected_filt)
-
+@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()
 
-@respx.mock
-@pytest.mark.asyncio
-def test_data_filter_std_quality(invoke, assert_and_filters_equal):
-    result = invoke(["filter", '--std-quality'])
+    args = [arg for arg in [permission, std_quality] if arg]
+    result = invoke(["filter", *args], runner=runner)
     assert result.exit_code == 0
 
-    expected_filt = {
-        "type":
-        "AndFilter",
-        "config": [{
-            "type": "StringInFilter",
-            "field_name": "quality_category",
-            "config": ["standard"]
-        }]
-    }
+    [default_filters.remove(rem) for rem in [p_remove, s_remove] if rem]
+    expected_filt = {"type": "AndFilter", "config": default_filters}
     assert_and_filters_equal(json.loads(result.output), expected_filt)
 
 
@@ -164,24 +135,35 @@ def test_data_filter_std_quality(invoke, assert_and_filters_equal):
                            ['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, assert_and_filters_equal):
+def test_data_filter_asset(asset,
+                           expected,
+                           invoke,
+                           default_filters,
+                           assert_and_filters_equal):
+    runner = CliRunner()
 
-    result = invoke(["filter", f'--asset={asset}'])
+    result = invoke(["filter", f'--asset={asset}'], runner=runner)
     assert result.exit_code == 0
 
     asset_filter = {"type": "AssetFilter", "config": expected}
-    expected_filt = {"type": "AndFilter", "config": [asset_filter]}
+    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_success(invoke, assert_and_filters_equal):
+def test_data_filter_date_range_success(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())
+                    '--date-range field2 lt 2022-01-01'.split(),
+                    runner=runner)
     assert result.exit_code == 0
 
     date_range_filter1 = {
@@ -201,7 +183,7 @@ def test_data_filter_date_range_success(invoke, assert_and_filters_equal):
 
     expected_filt = {
         "type": "AndFilter",
-        "config": [date_range_filter1, date_range_filter2]
+        "config": default_filters + [date_range_filter1, date_range_filter2]
     }
 
     assert_and_filters_equal(json.loads(result.output), expected_filt)
@@ -210,7 +192,10 @@ def test_data_filter_date_range_success(invoke, assert_and_filters_equal):
 @respx.mock
 @pytest.mark.asyncio
 def test_data_filter_date_range_invalid(invoke):
-    result = invoke(["filter"] + '--date-range field gt 2021'.split())
+    runner = CliRunner()
+
+    result = invoke(["filter"] + '--date-range field gt 2021'.split(),
+                    runner=runner)
     assert result.exit_code == 2
 
 
@@ -223,13 +208,15 @@ def test_data_filter_geom(geom_fixture,
                           request,
                           invoke,
                           geom_geojson,
-                          assert_and_filters_equal):
+                          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}'])
+    result = invoke(["filter", f'--geom={geom_str}'], runner=runner)
     assert result.exit_code == 0
 
     geom_filter = {
@@ -238,17 +225,23 @@ def test_data_filter_geom(geom_fixture,
         "config": geom_geojson
     }
 
-    expected_filt = {"type": "AndFilter", "config": [geom_filter]}
+    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):
+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())
+                    '--number-in field2 2,3.5'.split(),
+                    runner=runner)
     assert result.exit_code == 0
 
     number_in_filter1 = {
@@ -259,7 +252,8 @@ def test_data_filter_number_in_success(invoke, assert_and_filters_equal):
     }
 
     expected_filt = {
-        "type": "AndFilter", "config": [number_in_filter1, number_in_filter2]
+        "type": "AndFilter",
+        "config": default_filters + [number_in_filter1, number_in_filter2]
     }
 
     assert_and_filters_equal(json.loads(result.output), expected_filt)
@@ -267,19 +261,26 @@ def test_data_filter_number_in_success(invoke, assert_and_filters_equal):
 
 @respx.mock
 @pytest.mark.asyncio
-def test_data_filter_number_in_badparam(invoke, assert_and_filters_equal):
+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())
+    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):
+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())
+                    '--range cloud_cover lt 0.5'.split(),
+                    runner=runner)
     assert result.exit_code == 0
 
     range_filter1 = {
@@ -296,7 +297,8 @@ def test_data_filter_range(invoke, assert_and_filters_equal):
     }
 
     expected_filt = {
-        "type": "AndFilter", "config": [range_filter1, range_filter2]
+        "type": "AndFilter",
+        "config": default_filters + [range_filter1, range_filter2]
     }
 
     assert_and_filters_equal(json.loads(result.output), expected_filt)
@@ -304,10 +306,14 @@ def test_data_filter_range(invoke, assert_and_filters_equal):
 
 @respx.mock
 @pytest.mark.asyncio
-def test_data_filter_string_in(invoke, assert_and_filters_equal):
+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())
+                    '--string-in field2 foo,bar'.split(),
+                    runner=runner)
     assert result.exit_code == 0
 
     string_in_filter1 = {
@@ -320,7 +326,8 @@ def test_data_filter_string_in(invoke, assert_and_filters_equal):
     }
 
     expected_filt = {
-        "type": "AndFilter", "config": [string_in_filter1, string_in_filter2]
+        "type": "AndFilter",
+        "config": default_filters + [string_in_filter1, string_in_filter2]
     }
 
     assert_and_filters_equal(json.loads(result.output), expected_filt)
@@ -328,12 +335,14 @@ def test_data_filter_string_in(invoke, assert_and_filters_equal):
 
 @respx.mock
 @pytest.mark.asyncio
-def test_data_filter_update(invoke, assert_and_filters_equal):
+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())
+                    '--update field2 gte 2022-01-01'.split(),
+                    runner=runner)
     assert result.exit_code == 0
 
     update_filter1 = {
@@ -352,38 +361,20 @@ def test_data_filter_update(invoke, assert_and_filters_equal):
     }
 
     expected_filt = {
-        "type": "AndFilter", "config": [update_filter1, update_filter2]
+        "type": "AndFilter",
+        "config": default_filters + [update_filter1, update_filter2]
     }
 
     assert_and_filters_equal(json.loads(result.output), expected_filt)
 
 
-@respx.mock
-@pytest.mark.parametrize("item_types, expect_success",
-                         [('PSScene', True), ('SkySatScene', True),
-                          ('PSScene, SkySatScene', True), ('INVALID', False)])
-def test_data_search_cmd_item_types(item_types, expect_success, invoke):
-    """Test for planet data search_quick item types, valid and invalid."""
-    mock_resp = httpx.Response(HTTPStatus.OK,
-                               json={'features': [{
-                                   "key": "value"
-                               }]})
-    respx.post(TEST_QUICKSEARCH_URL).return_value = mock_resp
-
-    result = invoke(['search', item_types])
-
-    if expect_success:
-        assert result.exit_code == 0
-        assert len(result.output.strip().split('\n')) == 1  # we have 1 feature
-    else:
-        assert result.exit_code == 2
-
-
 @respx.mock
 @pytest.mark.asyncio
 @pytest.mark.parametrize("filter", ['{1:1}', '{"foo"}'])
-def test_data_search_cmd_filter_invalid_json(invoke, filter):
-    """Test for planet data search_quick.
+@pytest.mark.parametrize("item_types",
+                         ['PSScene', 'SkySatScene', 'PSScene, SkySatScene'])
+def test_data_search_cmd_filter_invalid_json(invoke, item_types, filter):
+    """Test for planet data search_quick. Test with multiple item_types.
     Test should fail as filter does not contain valid JSON."""
     mock_resp = httpx.Response(HTTPStatus.OK,
                                json={'features': [{
@@ -391,12 +382,15 @@ def test_data_search_cmd_filter_invalid_json(invoke, filter):
                                }]})
     respx.post(TEST_QUICKSEARCH_URL).return_value = mock_resp
 
-    result = invoke(['search', 'PSScene', f'--filter={filter}'])
+    runner = CliRunner()
+    result = invoke(["search", item_types, filter], runner=runner)
     assert result.exit_code == 2
 
 
 @respx.mock
-def test_data_search_cmd_filter_success(invoke):
+@pytest.mark.parametrize("item_types",
+                         ['PSScene', 'SkySatScene', 'PSScene, SkySatScene'])
+def test_data_search_cmd_filter_success(invoke, item_types):
     """Test for planet data search_quick. Test with multiple item_types.
     Test should succeed as filter contains valid JSON."""
     filter = {
@@ -413,17 +407,12 @@ def test_data_search_cmd_filter_success(invoke):
                                }]})
     respx.post(TEST_QUICKSEARCH_URL).return_value = mock_resp
 
-    result = invoke(['search', 'PSScene', f'--filter={json.dumps(filter)}'])
+    runner = CliRunner()
+    result = invoke(["search", item_types, json.dumps(filter)], runner=runner)
 
     assert result.exit_code == 0
     assert len(result.output.strip().split('\n')) == 1  # we have 1 feature
 
-    # check that filter was sent
-    expected_request = {"item_types": ['PSScene'], "filter": filter}
-    actual_body = json.loads(respx.calls[0].request.content)
-
-    assert actual_body == expected_request
-
 
 @respx.mock
 def test_data_search_cmd_sort_success(invoke):
@@ -432,18 +421,41 @@ def test_data_search_cmd_sort_success(invoke):
     sort = 'published asc'
     search_url = f'{TEST_QUICKSEARCH_URL}?_sort={sort}'
 
+    filter = {
+        "type": "DateRangeFilter",
+        "field_name": "acquired",
+        "config": {
+            "gt": "2019-12-31T00:00:00Z"
+        }
+    }
+
     feature = {"key": "value"}
     mock_resp = httpx.Response(HTTPStatus.OK, json={'features': [feature]})
     respx.post(search_url).return_value = mock_resp
 
-    result = invoke(['search', 'PSScene', f'--sort={sort}'])
+    runner = CliRunner()
+    result = invoke(
+        ['search', 'PSScene', json.dumps(filter), f'--sort={sort}'],
+        runner=runner)
     assert result.exit_code == 0
     assert json.loads(result.output) == feature
 
 
 @respx.mock
 def test_data_search_cmd_sort_invalid(invoke):
-    result = invoke(['search', 'PSScene', '--sort=invalid'])
+    filter = {
+        "type": "DateRangeFilter",
+        "field_name": "acquired",
+        "config": {
+            "gt": "2019-12-31T00:00:00Z"
+        }
+    }
+
+    runner = CliRunner()
+    result = invoke(
+        ['search', 'PSScene', json.dumps(filter), '--sort=invalid'],
+        runner=runner)
+
     assert result.exit_code == 2
 
 
@@ -459,6 +471,13 @@ def test_data_search_cmd_limit(invoke,
     If no value is specified, make sure the result contains at most 100
     entries.
     """
+    filter = {
+        "type": "DateRangeFilter",
+        "field_name": "acquired",
+        "config": {
+            "gt": "2019-12-31T00:00:00Z", "lte": "2020-01-31T00:00:00Z"
+        }
+    }
     item_types = 'SkySatCollect'
 
     # Creating 102 (3x34) search results
@@ -482,7 +501,9 @@ def test_data_search_cmd_limit(invoke,
     respx.post(TEST_QUICKSEARCH_URL).return_value = mock_resp
 
     runner = CliRunner()
-    result = invoke(["search", item_types, "--limit", limit], runner=runner)
+    result = invoke(
+        ["search", item_types, json.dumps(filter), "--limit", limit],
+        runner=runner)
     assert result.exit_code == 0
     assert result.output.count('"id"') == limited_list_length
 
@@ -493,7 +514,7 @@ def test_data_search_cmd_limit(invoke,
 @pytest.mark.parametrize("item_types",
                          ['PSScene', 'SkySatScene', 'PSScene, SkySatScene'])
 def test_data_search_create_filter_invalid_json(invoke, item_types, filter):
-    """Test for planet data search_create.
+    """Test for planet data search_create. Test with multiple item_types.
     Test should fail as filter does not contain valid JSON."""
     mock_resp = httpx.Response(HTTPStatus.OK,
                                json={'features': [{
@@ -539,7 +560,7 @@ def test_data_search_create_filter_success(invoke, item_types):
 
 
 @respx.mock
-def test_data_search_create_daily_email(invoke, search_result):
+def test_search_create_daily_email(invoke, search_result):
     mock_resp = httpx.Response(HTTPStatus.OK, json=search_result)
     respx.post(TEST_SEARCHES_URL).return_value = mock_resp
 
@@ -577,127 +598,6 @@ def test_data_search_create_daily_email(invoke, search_result):
     assert json.loads(result.output) == search_result
 
 
-@respx.mock
-@pytest.mark.asyncio
-@pytest.mark.parametrize("limit, expected_list_length", [(0, 4), (3, 3)])
-def test_data_search_list_basic(invoke,
-                                search_result,
-                                limit,
-                                expected_list_length):
-    """Ensure planet data search-list runs successfully and respects limit."""
-    page1_response = {"_links": {}, "searches": [search_result] * 4}
-    route = respx.get(TEST_SEARCHES_URL)
-    route.return_value = httpx.Response(200, json=page1_response)
-
-    result = invoke(['search-list', f'--limit={limit}'])
-    assert result.exit_code == 0
-    assert len(result.output.strip().split('\n')) == expected_list_length
-
-
-@respx.mock
-@pytest.mark.asyncio
-@pytest.mark.parametrize("sort, rel_url, valid",
-                         [(LIST_SORT_DEFAULT, '', True),
-                          ('created asc', '?_sort=created+asc', True),
-                          ('notvalid', '', False)])
-def test_data_search_list_sort(invoke, search_result, sort, rel_url, valid):
-    """Ensure planet data search-list handles sort."""
-    page1_response = {"_links": {}, "searches": [search_result] * 4}
-    route = respx.get(f'{TEST_SEARCHES_URL}{rel_url}')
-    route.return_value = httpx.Response(200, json=page1_response)
-
-    result = invoke(['search-list', f'--sort={sort}'])
-    expected_code = 0 if valid else 2
-    assert result.exit_code == expected_code
-
-
-@respx.mock
-@pytest.mark.asyncio
-@pytest.mark.parametrize("search_type, rel_url, valid",
-                         [(LIST_SEARCH_TYPE_DEFAULT, '', True),
-                          ('saved', '?search_type=saved', True),
-                          ('notvalid', '', False)])
-def test_data_search_list_searchtype(invoke,
-                                     search_result,
-                                     search_type,
-                                     rel_url,
-                                     valid):
-    """Ensure planet data search-list handles search-type."""
-    page1_response = {"_links": {}, "searches": [search_result] * 4}
-    route = respx.get(f'{TEST_SEARCHES_URL}{rel_url}')
-    route.return_value = httpx.Response(200, json=page1_response)
-
-    result = invoke(['search-list', f'--search-type={search_type}'])
-    expected_code = 0 if valid else 2
-    assert result.exit_code == expected_code
-
-
-@respx.mock
-@pytest.mark.asyncio
-@pytest.mark.parametrize("search_id, valid", [(VALID_SEARCH_ID, True),
-                                              ('invalid', False)])
-@pytest.mark.parametrize("limit, expected_count", [(0, 3), (2, 2)])
-def test_data_search_run_basic(invoke,
-                               item_descriptions,
-                               search_id,
-                               valid,
-                               limit,
-                               expected_count):
-    """Ensure planet data search-run runs successfully and handles search id
-    and limit."""
-    next_page_url = f'{TEST_URL}/blob/?page_marker=IAmATest'
-    item1, item2, item3 = item_descriptions
-    page1_response = {
-        "_links": {
-            "_next": next_page_url
-        }, "features": [item1, item2]
-    }
-
-    route = respx.get(f'{TEST_SEARCHES_URL}/{search_id}/results')
-    route.return_value = httpx.Response(204, json=page1_response)
-
-    page2_response = {"_links": {"_self": next_page_url}, "features": [item3]}
-    mock_resp2 = httpx.Response(HTTPStatus.OK, json=page2_response)
-    respx.get(next_page_url).return_value = mock_resp2
-
-    result = invoke(['search-run', f'--limit={limit}', search_id])
-
-    if valid:
-        assert result.exit_code == 0
-        assert len(result.output.strip().split('\n')) == expected_count
-    else:
-        assert result.exit_code == 2
-
-
-@respx.mock
-@pytest.mark.asyncio
-@pytest.mark.parametrize("sort, rel_url, valid",
-                         [(SEARCH_SORT_DEFAULT, '', True),
-                          ('acquired asc', '?_sort=acquired+asc', True),
-                          ('invalid', '', False)])
-def test_data_search_run_sort(invoke, item_descriptions, sort, rel_url, valid):
-    """Ensure planet data search-run handles sort."""
-    next_page_url = f'{TEST_URL}/blob/?page_marker=IAmATest'
-    item1, item2, item3 = item_descriptions
-    page1_response = {
-        "_links": {
-            "_next": next_page_url
-        }, "features": [item1, item2]
-    }
-
-    route = respx.get(
-        f'{TEST_SEARCHES_URL}/{VALID_SEARCH_ID}/results{rel_url}')
-    route.return_value = httpx.Response(204, json=page1_response)
-
-    page2_response = {"_links": {"_self": next_page_url}, "features": [item3]}
-    mock_resp2 = httpx.Response(HTTPStatus.OK, json=page2_response)
-    respx.get(next_page_url).return_value = mock_resp2
-
-    result = invoke(['search-run', f'--sort={sort}', VALID_SEARCH_ID])
-    expected_code = 0 if valid else 2
-    assert result.exit_code == expected_code
-
-
 @respx.mock
 @pytest.mark.asyncio
 @pytest.mark.parametrize("filter", ['{1:1}', '{"foo"}'])
@@ -801,77 +701,9 @@ def test_search_get_id_not_found(invoke, search_id):
     assert 'Error: {"message": "Error message"}\n' == result.output
 
 
-@respx.mock
-def test_search_delete_success(invoke, search_id, search_result):
-    delete_url = f'{TEST_SEARCHES_URL}/{search_id}'
-    mock_resp = httpx.Response(HTTPStatus.NO_CONTENT, json=search_result)
-    respx.delete(delete_url).return_value = mock_resp
-
-    result = invoke(['search-delete', search_id])
-
-    assert not result.exception
-
-
-@respx.mock
-def test_search_delete_nonexistant_search_id(invoke, search_id, search_result):
-    delete_url = f'{TEST_SEARCHES_URL}/{search_id}'
-    mock_resp = httpx.Response(404, json=search_result)
-    respx.delete(delete_url).return_value = mock_resp
-
-    result = invoke(['search-delete', search_id])
-
-    assert result.exception
-    assert result.exit_code == 1
-
-
-@pytest.mark.parametrize("item_types",
-                         ['PSScene', 'SkySatScene', 'PSScene, SkySatScene'])
-@respx.mock
-def test_search_update_success(invoke,
-                               search_id,
-                               search_result,
-                               item_types,
-                               search_filter):
-    update_url = f'{TEST_SEARCHES_URL}/{search_id}'
-    mock_resp = httpx.Response(HTTPStatus.OK, json=search_result)
-    respx.put(update_url).return_value = mock_resp
-
-    name = "search_name"
-
-    result = invoke([
-        'search-update',
-        search_id,
-        name,
-        item_types,
-        json.dumps(search_filter)
-    ])
-
-    assert not result.exception
-
-
-@respx.mock
-def test_search_update_fail(invoke, search_id, search_filter):
-    update_url = f'{TEST_SEARCHES_URL}/{search_id}'
-    error_json = {"message": "Error message"}
-    mock_resp = httpx.Response(404, json=error_json)
-    respx.put(update_url).return_value = mock_resp
-
-    name = "search_name"
-    item_types = "PSScene"
-
-    result = invoke([
-        'search-update',
-        search_id,
-        name,
-        item_types,
-        json.dumps(search_filter)
-    ])
-
-    assert result.output.startswith("Error")
-    assert result.exception
-
-
 # TODO: basic test for "planet data search-create".
+# TODO: basic test for "planet data search-update".
+# TODO: basic test for "planet data search-delete".
 # TODO: basic test for "planet data search-get".
 # TODO: basic test for "planet data search-list".
 # TODO: basic test for "planet data search-run".
diff --git a/tests/integration/test_subscriptions_cli.py b/tests/integration/test_subscriptions_cli.py
index 6070d21..edaa3c2 100644
--- a/tests/integration/test_subscriptions_cli.py
+++ b/tests/integration/test_subscriptions_cli.py
@@ -12,7 +12,6 @@ There are 6 subscriptions commands:
 TODO: tests for 3 options of the planet-subscriptions-results command.
 
 """
-
 import json
 
 from click.testing import CliRunner
@@ -250,3 +249,60 @@ def test_subscriptions_results_success(invoke, options, expected_count):
 
     assert result.exit_code == 0  # success.
     assert result.output.count('"id"') == expected_count
+
+
+def test_request_base_success(invoke, geom_geojson):
+    """Request command succeeds"""
+    source = json.dumps({
+        "type": "catalog",
+        "parameters": {
+            "geometry": geom_geojson,
+            "start_time": "2021-03-01T00:00:00Z",
+            "end_time": "2023-11-01T00:00:00Z",
+            "rrule": "FREQ=MONTHLY;BYMONTH=3,4,5,6,7,8,9,10",
+            "item_types": ["PSScene"],
+            "asset_types": ["ortho_analytic_4b"]
+        }
+    })
+    delivery = json.dumps({
+        "type": "amazon_s3",
+        "parameters": {
+            "aws_access_key_id": "keyid",
+            "aws_secret_access_key": "accesskey",
+            "bucket": "bucket",
+            "aws_region": "region"
+        }
+    })
+
+    result = invoke([
+        'request',
+        '--name=test',
+        f'--source={source}',
+        f'--delivery={delivery}'
+    ])
+
+    assert source in result.output
+    assert result.exit_code == 0  # success.
+
+
+def test_request_catalog_success(invoke, geom_geojson):
+    """Request-catalog command succeeds"""
+    source = {
+        "type": "catalog",
+        "parameters": {
+            "geometry": geom_geojson,
+            "start_time": "2021-03-01T00:00:00Z",
+            "item_types": ["PSScene"],
+            "asset_types": ["ortho_analytic_4b"]
+        }
+    }
+
+    result = invoke([
+        'request-catalog',
+        '--item-types=PSScene',
+        '--asset-types=ortho_analytic_4b',
+        f"--geometry={json.dumps(geom_geojson)}",
+        '--start-time=2021-03-01T00:00:00'
+    ])
+    assert json.loads(result.output) == source
+    assert result.exit_code == 0  # success.