Repository: planetlabs/planet-client-python
Pull request #680 (2022-08-24 15:44:03)

Get supported bundles for a given item_type
PR #677 did not fully address this issue, however this PR should cover all the bases!

Validating `item_type` before `product_bundle`, so that the item type compatibility exception will propagate before the bundle validation exception.
1. `sepcs.validate_item_type()` now just checks if input `item_type` is a valid input (i.e., if part of all item type)
2. `specs. validate_bundle()`  now checks if the input product bundle is a valid product bundle (i.e., if part of all product bundles), then checks if item type is compatible with the given product bundle
    - Important part! If the item type and product bundle are not compatible, it returns all valid product bundles for given item type with the new function `specs.validate_supported_bundles()`
3. Created tests for new function, `specs.validate_supported_bundles()`
4. Reformatted tests for `specs.validate_bundle()` and `specs.validate_item_type()`
5. As per @jreiberkyle's new PR, #616, `item-type` and `bundle` are now both arguments, instead of options
    - Changed all associated tests
6. @cholmes We could not accomplish adding the feature that presents the user with a subset of valid bundles for a given item type when only providing the item type, as to do this we'd have to add the `is_eagar` flag to `item_type`, which 1. requires `item_type` to be an option and 2. requires an item type to be given when entering `planet orders request --help`.
    - So, now the user is presented with all compatible bundles only when the API is presented with an incompatible bundle. See **New behaviour (1)**.
    
**Previous behaviour (1):** Incompatible item type with given bundle would return all compatible item types with given bundle.
```
❯ planet orders request --item-type PSScene --bundle analytic --id 20220919_183509_93_248f --name hood-stac 

Usage: planet orders request [OPTIONS]
Try 'planet orders request --help' for help.

Error: Invalid value: item_type - 'PSScene' is not one of 'REOrthoTile', 'PSOrthoTile', 'PSScene4Band', 'Sentinel1', 'SkySatScene', 'Landsat8L1G', 'Sentinel2L1C', 'PSScene3Band', 'SkySatCollect'.
```

**New behaviour (1):** Incompatible item type with given bundle now returns all compatible bundles with given item type.
```
❯ planet orders request PSScene analytic --id 20220919_183509_93_248f --name hood-stac

Usage: planet orders request [OPTIONS]
Try 'planet orders request --help' for help.

Error: Invalid value: bundle - 'analytic' is not one of 'analytic_udm2', 'analytic_3b_udm2', 'analytic_8b_udm2', 'visual', 'basic_analytic_udm2', 'basic_analytic_8b_udm2', 'analytic_sr_udm2', 'analytic_8b_sr_udm2'.
```

**Previous behaviour (2):** Only `--name` provided prompted user to enter a bundle.
```
> planet orders request --name "dude"                   
Usage: planet orders request [OPTIONS]
Try 'planet orders request --help' for help.

Error: Missing option '--bundle'. Choose from:
	analytic,
	analytic_udm2,
	analytic_3b_udm2,
	analytic_5b,
	analytic_5b_udm2,
	analytic_8b_udm2,
	visual,
	uncalibrated_dn,
	uncalibrated_dn_udm2,
	basic_analytic,
	basic_analytic_udm2,
	basic_analytic_8b_udm2,
	basic_uncalibrated_dn,
	basic_uncalibrated_dn_udm2,
	analytic_sr,
	analytic_sr_udm2,
	analytic_8b_sr_udm2,
	basic_uncalibrated_dn_nitf,
	basic_uncalibrated_dn_nitf_udm2,
	basic_analytic_nitf,
	basic_analytic_nitf_udm2,
	basic_panchromatic,
	basic_panchromatic_dn,
	panchromatic,
	panchromatic_dn,
	panchromatic_dn_udm2,
	pansharpened,
	pansharpened_udm2,
	basic_l1a_dn
```
**New behaviour (2):** Only `--name` provided now prompts the user to enter an item type **and** now provides all choices for item types.
```
❯ planet orders request --name "dude"        
Usage: planet orders request [OPTIONS] ITEM_TYPE BUNDLE
Try 'planet orders request --help' for help.

Error: Missing argument 'ITEM_TYPE'. Choose from:
        PSScene3Band,
        SkySatScene,
        PSScene,
        MYD09GA,
        PSOrthoTile,
        MYD09GQ,
        MOD09GA,
        Sentinel1,
        REOrthoTile,
        REScene,
        SkySatCollect,
        Sentinel2L1C,
        MOD09GQ,
        Landsat8L1G,
        PSScene4Band
```
Closing #618, #698 


Patch:
diff --git a/planet/cli/orders.py b/planet/cli/orders.py
index fc03163..60d52ae 100644
--- a/planet/cli/orders.py
+++ b/planet/cli/orders.py
@@ -225,26 +225,22 @@ async def create(ctx, request: str, pretty):
 @click.pass_context
 @translate_exceptions
 @coro
+@click.argument('item_type',
+                metavar='ITEM_TYPE',
+                type=click.Choice(planet.specs.get_item_types(),
+                                  case_sensitive=False))
+@click.argument('bundle',
+                metavar='BUNDLE',
+                type=click.Choice(planet.specs.get_product_bundles(),
+                                  case_sensitive=False))
 @click.option('--name',
               required=True,
               help='Order name. Does not need to be unique.',
               type=click.STRING)
-@click.option(
-    '--bundle',
-    multiple=False,
-    required=True,
-    help='Product bundle.',
-    type=click.Choice(planet.specs.get_product_bundles(),
-                      case_sensitive=False),
-)
 @click.option('--id',
               help='One or more comma-separated item IDs.',
               type=types.CommaSeparatedString(),
               required=True)
-@click.option('--item-type',
-              required=True,
-              help='Specify an item type',
-              type=click.STRING)
 @click.option('--clip',
               type=types.JSON(),
               help="""Clip feature GeoJSON. Can be a json string, filename,
@@ -271,12 +267,12 @@ async def create(ctx, request: str, pretty):
     format. Not specifying either defaults to including it (--stac).""")
 @pretty
 async def request(ctx,
-                  name,
+                  item_type,
                   bundle,
+                  name,
                   id,
                   clip,
                   tools,
-                  item_type,
                   email,
                   cloudconfig,
                   stac,
diff --git a/planet/order_request.py b/planet/order_request.py
index 911927b..b24a1ec 100644
--- a/planet/order_request.py
+++ b/planet/order_request.py
@@ -113,12 +113,13 @@ def product(item_ids: List[str],
             are not valid bundles or if item_type is not valid for the given
             bundle or fallback bundle.
     '''
-    validated_product_bundle = specs.validate_bundle(product_bundle)
-    item_type = specs.validate_item_type(item_type, validated_product_bundle)
+    item_type = specs.validate_item_type(item_type)
+    validated_product_bundle = specs.validate_bundle(item_type, product_bundle)
 
     if fallback_bundle is not None:
-        validated_fallback_bundle = specs.validate_bundle(fallback_bundle)
-        specs.validate_item_type(item_type, validated_fallback_bundle)
+        item_type = specs.validate_item_type(item_type)
+        validated_fallback_bundle = specs.validate_bundle(
+            item_type, fallback_bundle)
         validated_product_bundle = ','.join(
             [validated_product_bundle, validated_fallback_bundle])
 
diff --git a/planet/specs.py b/planet/specs.py
index 9c08a0b..e24312f 100644
--- a/planet/specs.py
+++ b/planet/specs.py
@@ -55,15 +55,15 @@ class SpecificationException(Exception):
         return f'{self.field_name} - \'{self.value}\' is not one of {self.opts}.'
 
 
-def validate_bundle(bundle):
-    supported = get_product_bundles()
-    return _validate_field(bundle, supported, 'product_bundle')
+def validate_bundle(item_type, bundle):
+    all_product_bundles = get_product_bundles()
+    validate_supported_bundles(item_type, bundle, all_product_bundles)
+    return _validate_field(bundle, all_product_bundles, 'product_bundle')
 
 
-def validate_item_type(item_type, bundle):
-    validated_bundle = validate_bundle(bundle)
-    supported = get_item_types(validated_bundle)
-    return _validate_field(item_type, supported, 'item_type')
+def validate_item_type(item_type):
+    supported_item_types = get_item_types()
+    return _validate_field(item_type, supported_item_types, 'item_type')
 
 
 def validate_order_type(order_type):
@@ -92,6 +92,25 @@ def _validate_field(value, supported, field_name):
     return value
 
 
+def validate_supported_bundles(item_type, bundle, all_product_bundles):
+    spec = _get_product_bundle_spec()
+
+    supported_bundles = []
+    for product_bundle in all_product_bundles:
+        availible_item_types = set(
+            spec['bundles'][product_bundle]['assets'].keys())
+        if item_type.lower() in [x.lower() for x in availible_item_types]:
+            supported_bundles.append(product_bundle)
+
+    return _validate_field(bundle, supported_bundles, 'bundle')
+
+
+def _get_product_bundle_spec():
+    with open(DATA_DIR / PRODUCT_BUNDLE_SPEC_NAME) as f:
+        data = json.load(f)
+    return data
+
+
 def get_match(test_entry, spec_entries, field_name):
     '''Find and return matching spec entry regardless of capitalization.
 
@@ -107,10 +126,22 @@ def get_match(test_entry, spec_entries, field_name):
     return match
 
 
-def get_product_bundles():
+def get_product_bundles(item_type=None):
     '''Get product bundles supported by Orders API.'''
     spec = _get_product_bundle_spec()
-    return spec['bundles'].keys()
+
+    if item_type:
+        all_product_bundles = get_product_bundles()
+
+        supported_bundles = []
+        for product_bundle in all_product_bundles:
+            availible_item_types = set(
+                spec['bundles'][product_bundle]['assets'].keys())
+            if item_type.lower() in [x.lower() for x in availible_item_types]:
+                supported_bundles.append(product_bundle)
+    else:
+        supported_bundles = spec['bundles'].keys()
+    return supported_bundles
 
 
 def get_item_types(product_bundle=None):
@@ -127,9 +158,3 @@ def get_item_types(product_bundle=None):
                 for bundle in get_product_bundles()))
 
     return item_types
-
-
-def _get_product_bundle_spec():
-    with open(DATA_DIR / PRODUCT_BUNDLE_SPEC_NAME) as f:
-        data = json.load(f)
-    return data


Test patch:
diff --git a/tests/integration/test_orders_cli.py b/tests/integration/test_orders_cli.py
index f47c6bb..3610ad9 100644
--- a/tests/integration/test_orders_cli.py
+++ b/tests/integration/test_orders_cli.py
@@ -53,7 +53,6 @@ def stac_json():
 def test_cli_orders_list_basic(invoke, order_descriptions):
     next_page_url = TEST_ORDERS_URL + '/blob/?page_marker=IAmATest'
     order1, order2, order3 = order_descriptions
-
     page1_response = {
         "_links": {
             "_self": "string", "next": next_page_url
@@ -462,10 +461,10 @@ def test_cli_orders_request_basic_success(expected_ids,
                                           stac_json):
     result = invoke([
         'request',
+        'PSOrthoTile',
+        'analytic',
         '--name=test',
         f'--id={id_string}',
-        '--bundle=analytic',
-        '--item-type=PSOrthoTile'
     ])
     assert not result.exception
 
@@ -486,24 +485,43 @@ def test_cli_orders_request_basic_success(expected_ids,
 def test_cli_orders_request_item_type_invalid(invoke):
     result = invoke([
         'request',
+        'invalid'
+        'analytic',
         '--name=test',
         '--id=4500474_2133707_2021-05-20_2419',
-        '--bundle=analytic',
-        '--item-type=invalid'
     ])
     assert result.exit_code == 2
-    assert 'Error: Invalid value: item_type' in result.output
+    assert "Usage: main orders request [OPTIONS] ITEM_TYPE BUNDLE" in result.output
 
 
-def test_cli_orders_request_id_empty(invoke):
+def test_cli_orders_request_product_bundle_invalid(invoke):
     result = invoke([
         'request',
+        'PSScene'
+        'invalid',
         '--name=test',
-        '--id=',
-        '--bundle=analytic',
-        '--item-type=PSOrthoTile',
+        '--id=4500474_2133707_2021-05-20_2419',
     ])
     assert result.exit_code == 2
+    assert "Usage: main orders request [OPTIONS] ITEM_TYPE BUNDLE" in result.output
+
+
+def test_cli_orders_request_product_bundle_incompatible(invoke):
+    result = invoke([
+        'request',
+        'PSScene',
+        'analytic',
+        '--name=test',
+        '--id=4500474_2133707_2021-05-20_2419',
+    ])
+    assert result.exit_code == 2
+    assert "Usage: main orders request [OPTIONS] ITEM_TYPE BUNDLE" in result.output
+
+
+def test_cli_orders_request_id_empty(invoke):
+    result = invoke(
+        ['request', 'PSOrthoTile', 'analytic', '--name=test', '--id='])
+    assert result.exit_code == 2
     assert 'Entry cannot be an empty string.' in result.output
 
 
@@ -520,10 +538,10 @@ def test_cli_orders_request_clip_success(geom_fixture,
 
     result = invoke([
         'request',
+        'PSOrthoTile',
+        'analytic',
         '--name=test',
         '--id=4500474_2133707_2021-05-20_2419',
-        '--bundle=analytic',
-        '--item-type=PSOrthoTile',
         f'--clip={json.dumps(geom)}',
     ])
     assert result.exit_code == 0
@@ -550,10 +568,10 @@ def test_cli_orders_request_clip_success(geom_fixture,
 def test_cli_orders_request_clip_invalid_geometry(invoke, point_geom_geojson):
     result = invoke([
         'request',
+        'PSOrthoTile',
+        'analytic',
         '--name=test',
         '--id=4500474_2133707_2021-05-20_2419',
-        '--bundle=analytic',
-        '--item-type=PSOrthoTile',
         f'--clip={json.dumps(point_geom_geojson)}'
     ])
     assert result.exit_code == 2
@@ -567,10 +585,10 @@ def test_cli_orders_request_both_clip_and_tools(invoke, geom_geojson):
     # option values are valid json
     result = invoke([
         'request',
+        'PSOrthoTile',
+        'analytic',
         '--name=test',
         '--id=4500474_2133707_2021-05-20_2419',
-        '--bundle=analytic',
-        '--item-type=PSOrthoTile',
         f'--clip={json.dumps(geom_geojson)}',
         f'--tools={json.dumps(geom_geojson)}'
     ])
@@ -592,10 +610,10 @@ def test_cli_orders_request_cloudconfig(invoke, stac_json):
 
     result = invoke([
         'request',
+        'PSOrthoTile',
+        'analytic',
         '--name=test',
         '--id=4500474_2133707_2021-05-20_2419',
-        '--bundle=analytic',
-        '--item-type=PSOrthoTile',
         f'--cloudconfig={json.dumps(config_json)}',
     ])
     assert result.exit_code == 0
@@ -619,10 +637,10 @@ def test_cli_orders_request_cloudconfig(invoke, stac_json):
 def test_cli_orders_request_email(invoke, stac_json):
     result = invoke([
         'request',
+        'PSOrthoTile',
+        'analytic',
         '--name=test',
         '--id=4500474_2133707_2021-05-20_2419',
-        '--bundle=analytic',
-        '--item-type=PSOrthoTile',
         '--email'
     ])
     assert result.exit_code == 0
@@ -650,10 +668,10 @@ def test_cli_orders_request_tools(invoke, geom_geojson, stac_json):
 
     result = invoke([
         'request',
+        'PSOrthoTile',
+        'analytic',
         '--name=test',
         '--id=4500474_2133707_2021-05-20_2419',
-        '--bundle=analytic',
-        '--item-type=PSOrthoTile',
         f'--tools={json.dumps(tools_json)}'
     ])
 
@@ -678,10 +696,10 @@ def test_cli_orders_request_no_stac(invoke):
 
     result = invoke([
         'request',
+        'PSOrthoTile',
+        'analytic',
         '--name=test',
         '--id=4500474_2133707_2021-05-20_2419',
-        '--bundle=analytic',
-        '--item-type=PSOrthoTile',
         '--no-stac'
     ])
 
diff --git a/tests/unit/test_data_item_type.py b/tests/unit/test_data_callbacks.py
similarity index 100%
rename from tests/unit/test_data_item_type.py
rename to tests/unit/test_data_callbacks.py
diff --git a/tests/unit/test_specs.py b/tests/unit/test_specs.py
index e7c17bf..f326f76 100644
--- a/tests/unit/test_specs.py
+++ b/tests/unit/test_specs.py
@@ -21,6 +21,37 @@ from planet import specs
 LOGGER = logging.getLogger(__name__)
 
 TEST_PRODUCT_BUNDLE = 'visual'
+ALL_PRODUCT_BUNDLES = [
+    'analytic',
+    'analytic_udm2',
+    'analytic_3b_udm2',
+    'analytic_5b',
+    'analytic_5b_udm2',
+    'analytic_8b_udm2',
+    'visual',
+    'uncalibrated_dn',
+    'uncalibrated_dn_udm2',
+    'basic_analytic',
+    'basic_analytic_udm2',
+    'basic_analytic_8b_udm2',
+    'basic_uncalibrated_dn',
+    'basic_uncalibrated_dn_udm2',
+    'analytic_sr',
+    'analytic_sr_udm2',
+    'analytic_8b_sr_udm2',
+    'basic_uncalibrated_dn_nitf',
+    'basic_uncalibrated_dn_nitf_udm2',
+    'basic_analytic_nitf',
+    'basic_analytic_nitf_udm2',
+    'basic_panchromatic',
+    'basic_panchromatic_dn',
+    'panchromatic',
+    'panchromatic_dn',
+    'panchromatic_dn_udm2',
+    'pansharpened',
+    'pansharpened_udm2',
+    'basic_l1a_dn'
+]
 # must be a valid item type for TEST_PRODUCT_BUNDLE
 TEST_ITEM_TYPE = 'PSScene'
 ALL_ITEM_TYPES = [
@@ -54,26 +85,26 @@ def test_get_type_match():
 
 
 def test_validate_bundle_supported():
-    assert 'analytic' == specs.validate_bundle('ANALYTIC')
+    assert 'visual' == specs.validate_bundle(TEST_ITEM_TYPE, 'VISUAL')
 
 
 def test_validate_bundle_notsupported():
     with pytest.raises(specs.SpecificationException):
-        specs.validate_bundle('notsupported')
+        specs.validate_bundle(TEST_ITEM_TYPE, 'notsupported')
 
 
-def test_validate_item_type_supported():
-    assert 'PSOrthoTile' == specs.validate_item_type('psorthotile', 'analytic')
+def test_validate_bundle_notsupported_item_type():
+    with pytest.raises(specs.SpecificationException):
+        specs.validate_item_type('wha')
 
 
-def test_validate_item_type_notsupported_bundle():
-    with pytest.raises(specs.SpecificationException):
-        specs.validate_item_type('psorthotile', 'wha')
+def test_validate_item_type_supported():
+    assert 'PSOrthoTile' == specs.validate_item_type('psorthotile')
 
 
 def test_validate_item_type_notsupported_itemtype():
     with pytest.raises(specs.SpecificationException):
-        specs.validate_item_type('notsupported', 'analytic')
+        specs.validate_item_type('notsupported')
 
 
 def test_validate_order_type_supported():
@@ -103,11 +134,17 @@ def test_validate_file_format_notsupported():
         specs.validate_archive_type('notsupported')
 
 
-def test_get_product_bundles():
-    bundles = specs.get_product_bundles()
+def test_get_product_bundles_with_item_type():
+    bundles = specs.get_product_bundles(item_type=TEST_ITEM_TYPE)
     assert TEST_PRODUCT_BUNDLE in bundles
 
 
+def test_get_product_bundles_without_item_type():
+    bundles = specs.get_product_bundles()
+    for bundle in bundles:
+        assert bundle in ALL_PRODUCT_BUNDLES
+
+
 def test_get_item_types_with_bundle():
     item_types = specs.get_item_types(product_bundle=TEST_PRODUCT_BUNDLE)
     assert TEST_ITEM_TYPE in item_types
@@ -117,3 +154,16 @@ def test_get_item_types_without_bundle():
     item_types = specs.get_item_types()
     for item in item_types:
         assert item in ALL_ITEM_TYPES
+
+
+def test_validate_supported_bundles_success():
+    validated_bundle = specs.validate_supported_bundles(
+        TEST_ITEM_TYPE, TEST_PRODUCT_BUNDLE, ALL_PRODUCT_BUNDLES)
+    assert validated_bundle in ALL_PRODUCT_BUNDLES
+
+
+def test_validate_supported_bundles_fail():
+    with pytest.raises(specs.SpecificationException):
+        specs.validate_supported_bundles(TEST_ITEM_TYPE,
+                                         'analytic',
+                                         ALL_PRODUCT_BUNDLES)