From 78a1768fc1c72dbc056434f8d493fd30ff7f006a Mon Sep 17 00:00:00 2001 From: asonnenschein Date: Mon, 10 Nov 2025 20:20:09 -0500 Subject: [PATCH 1/8] default destinations sdk and cli support --- planet/cli/destinations.py | 81 ++++++++++++++++++++++++++++++++++ planet/clients/destinations.py | 76 +++++++++++++++++++++++++++++++ planet/order_request.py | 29 ++++++++++++ planet/subscription_request.py | 29 ++++++++++++ 4 files changed, 215 insertions(+) diff --git a/planet/cli/destinations.py b/planet/cli/destinations.py index 139378a9a..5bebdb7a0 100644 --- a/planet/cli/destinations.py +++ b/planet/cli/destinations.py @@ -65,6 +65,33 @@ async def _create_destination(ctx, data, pretty): raise ClickException(f"Failed to create destination: {e}") +async def _set_default_destination(ctx, destination_id, pretty): + async with destinations_client(ctx) as cl: + try: + response = await cl.set_default_destination(destination_id) + echo_json(response, pretty) + except Exception as e: + raise ClickException(f"Failed to set default destination: {e}") + + +async def _unset_default_destination(ctx, pretty): + async with destinations_client(ctx) as cl: + try: + response = await cl.unset_default_destination() + echo_json(response, pretty) + except Exception as e: + raise ClickException(f"Failed to unset default destination: {e}") + + +async def _get_default_destination(ctx, pretty): + async with destinations_client(ctx) as cl: + try: + response = await cl.get_default_destination() + echo_json(response, pretty) + except Exception as e: + raise ClickException(f"Failed to get default destination: {e}") + + @command(destinations, name="list") @click.option('--archived', type=bool, @@ -590,3 +617,57 @@ async def update_s3_compatible(ctx, data["parameters"]["use_path_style"] = True await _patch_destination(ctx, destination_id, data, pretty) + + +@destinations.group() +def default(): + """Commands for interacting with default destinations.""" + pass + + +@command(default, name="set") +@click.argument("destination_id") +async def set_default_destination(ctx, destination_id, pretty): + """ + Set a default destination. + + This command sets a specified destination as the default for the organization. Default destinations + are globally available to all members of an organization. An organization can have zero or one default + destination at any time. Ability to set a default destination is restricted to organization + administrators and destination owners. + + Example: + + planet destinations default set my-destination-id + """ + await _set_default_destination(ctx, destination_id, pretty) + + +@command(default, name="unset") +async def unset_default_destination(ctx, pretty): + """ + Unset the current default destination. + + This command unsets the current default destination. Ability to unset a default destination is restricted + to organization administrators and destination owners. Returns None (HTTP 204, No Content) on success. + + Example: + + planet destinations default unset + """ + await _unset_default_destination(ctx, pretty) + + +@command(default, name="get") +async def get_default_destination(ctx, pretty): + """ + Get the current default destination. + + This command gets the current default destination for the organization, if one is set. The default + destination is globally available to all members of an organization. + + Example: + + planet destinations default get + """ + await _get_default_destination(ctx, pretty) \ No newline at end of file diff --git a/planet/clients/destinations.py b/planet/clients/destinations.py index 92d2a37be..1a97d859b 100644 --- a/planet/clients/destinations.py +++ b/planet/clients/destinations.py @@ -26,6 +26,8 @@ T = TypeVar("T") +DEFAULT_DESTINATION_REF = "pl:destinations/default" + class DestinationsClient(_BaseClient): """Asynchronous Destinations API client. @@ -174,3 +176,77 @@ async def create_destination(self, request: Dict[str, Any]) -> Dict: else: dest = response.json() return dest + + async def set_default_destination(self, destination_id: str) -> None: + """ + Set an existing destination as the default destination. Default destinations are globally available + to all members of an organization. An organization can have zero or one default destination at any time. + Ability to set a default destination is restricted to organization administrators and destination owners. + + Args: + destination_id (str): The ID of the destination to set as default. + + Returns: + dict: A dictionary containing the default destination details. + + Raises: + APIError: If the API returns an error response. + ClientError: If there is an issue with the client request. + """ + url = f'{self._base_url}/default' + request = { + "destination_id": destination_id + } + try: + response = await self._session.request(method='PUT', + url=url, + json=request) + except APIError: + raise + except ClientError: # pragma: no cover + raise + else: + return response.json() + + async def unset_default_destination(self) -> None: + """ + Unset the current default destination. Ability to unset a default destination is restricted to + organization administrators and destination owners. Returns None (HTTP 204, No Content) on success. + + Returns: + None + + Raises: + APIError: If the API returns an error response. + ClientError: If there is an issue with the client request. + """ + url = f'{self._base_url}/default' + try: + await self._session.request(method='DELETE', url=url) + except APIError: + raise + except ClientError: # pragma: no cover + raise + + async def get_default_destination(self) -> Dict: + """ + Get the current default destination. The default destination is globally available to all members of an + organization. + + Returns: + dict: A dictionary containing the default destination details. + + Raises: + APIError: If the API returns an error response. + ClientError: If there is an issue with the client request. + """ + url = f'{self._base_url}/default' + try: + response = await self._session.request(method='GET', url=url) + except APIError: + raise + except ClientError: # pragma: no cover + raise + else: + dest = response.json() + return dest \ No newline at end of file diff --git a/planet/order_request.py b/planet/order_request.py index 31a600c8d..5070f61ef 100644 --- a/planet/order_request.py +++ b/planet/order_request.py @@ -392,6 +392,35 @@ def s3_compatible(endpoint: str, return {'s3_compatible': parameters} +def destination(destination_ref: str, path_prefix: Optional[str] = None) -> dict: + """Destinations API configuration. + Parameters: + destination_ref: Reference to an existing Destinations API + destination. + path_prefix: Path prefix for deliveries. + """ + cloud_details: Dict[str, Any] = {'ref': destination_ref} + + if path_prefix: + cloud_details['path_prefix'] = path_prefix + + return {'destination': cloud_details} + + +def default_destination(path_prefix: Optional[str] = None) -> dict: + """Default Destinations API configuration. + + Parameters: + path_prefix: Path prefix for deliveries. + """ + parameters: Dict[str, Any] = {'ref': 'pl:destinations/default'} + + if path_prefix: + parameters['path_prefix'] = path_prefix + + return {'destination': parameters} + + def _tool(name: str, parameters: dict) -> dict: """Create the API spec representation of a tool. diff --git a/planet/subscription_request.py b/planet/subscription_request.py index 72b36e80e..a38776c4b 100644 --- a/planet/subscription_request.py +++ b/planet/subscription_request.py @@ -19,6 +19,7 @@ from typing_extensions import Literal from . import geojson, specs +from .clients.destinations import DEFAULT_DESTINATION_REF from .exceptions import ClientError NOTIFICATIONS_TOPICS = ('delivery.success', @@ -528,6 +529,34 @@ def s3_compatible(endpoint: str, return _delivery('s3_compatible', parameters) +def destination(destination_ref: str, path_prefix: Optional[str] = None) -> dict: + """Specify a Destinations API destination by its ref. + + Parameters: + destination_id: The ID of the destination to deliver to. + """ + parameters: Dict[str, Any] = {"ref": destination_ref} + + if path_prefix: + parameters['path_prefix'] = path_prefix + + return _delivery('destination', parameters) + + +def default_destination(path_prefix: Optional[str] = None) -> dict: + """Specify the organization's default Destinations API destination. + + Parameters: + path_prefix: Path prefix for deliveries. + """ + parameters: Dict[str, Any] = {"ref": DEFAULT_DESTINATION_REF} + + if path_prefix: + parameters['path_prefix'] = path_prefix + + return _delivery('destination', parameters) + + def notifications(url: str, topics: List[str]) -> dict: """Specify a subscriptions API notification. From c3c497d77ca73e0a199bbb4518b3ca5c5f2461a6 Mon Sep 17 00:00:00 2001 From: asonnenschein Date: Mon, 10 Nov 2025 20:58:54 -0500 Subject: [PATCH 2/8] add tests --- planet/cli/destinations.py | 15 ++-- planet/clients/destinations.py | 6 +- planet/sync/destinations.py | 55 ++++++++++++++- tests/integration/test_destinations_api.py | 80 ++++++++++++++++++++++ tests/integration/test_destinations_cli.py | 69 +++++++++++++++++++ tests/unit/test_order_request.py | 47 +++++++++++++ tests/unit/test_subscription_request.py | 47 +++++++++++++ 7 files changed, 312 insertions(+), 7 deletions(-) diff --git a/planet/cli/destinations.py b/planet/cli/destinations.py index 5bebdb7a0..63ef003e7 100644 --- a/planet/cli/destinations.py +++ b/planet/cli/destinations.py @@ -36,12 +36,13 @@ async def _patch_destination(ctx, destination_id, data, pretty): raise ClickException(f"Failed to patch destination: {e}") -async def _list_destinations(ctx, archived, is_owner, can_write, pretty): +async def _list_destinations(ctx, archived, is_owner, can_write, is_default, pretty): async with destinations_client(ctx) as cl: try: response = await cl.list_destinations(archived, is_owner, - can_write) + can_write, + is_default) echo_json(response, pretty) except Exception as e: raise ClickException(f"Failed to list destinations: {e}") @@ -109,7 +110,13 @@ async def _get_default_destination(ctx, pretty): default=None, help="""Set to true to include only destinations the user can modify, false to exclude them.""") -async def list_destinations(ctx, archived, is_owner, can_write, pretty): +@click.option( + '--is-default', + type=bool, + default=None, + help="""Set to true to include only the default destination, + false to exclude it.""") +async def list_destinations(ctx, archived, is_owner, can_write, is_default, pretty): """ List destinations with optional filters @@ -120,7 +127,7 @@ async def list_destinations(ctx, archived, is_owner, can_write, pretty): planet destinations list --archived false --is-owner true --can-write true """ - await _list_destinations(ctx, archived, is_owner, can_write, pretty) + await _list_destinations(ctx, archived, is_owner, can_write, is_default, pretty) @command(destinations, name="get") diff --git a/planet/clients/destinations.py b/planet/clients/destinations.py index 1a97d859b..dd8177bd6 100644 --- a/planet/clients/destinations.py +++ b/planet/clients/destinations.py @@ -60,7 +60,8 @@ def __init__(self, async def list_destinations(self, archived: Optional[bool] = None, is_owner: Optional[bool] = None, - can_write: Optional[bool] = None) -> Dict: + can_write: Optional[bool] = None, + is_default: Optional[bool] = None) -> Dict: """ List all destinations. By default, all non-archived destinations in the requesting user's org are returned. @@ -68,6 +69,7 @@ async def list_destinations(self, archived (bool): If True, include archived destinations. is_owner (bool): If True, include only destinations owned by the requesting user. can_write (bool): If True, include only destinations the requesting user can modify. + is_default (bool): If True, include only the default destination. Returns: dict: A dictionary containing the list of destinations inside the 'destinations' key. @@ -83,6 +85,8 @@ async def list_destinations(self, params["is_owner"] = is_owner if can_write is not None: params["can_write"] = can_write + if is_default is not None: + params["is_default"] = is_default try: response = await self._session.request(method='GET', diff --git a/planet/sync/destinations.py b/planet/sync/destinations.py index 6024dd715..f8466c5eb 100644 --- a/planet/sync/destinations.py +++ b/planet/sync/destinations.py @@ -34,7 +34,8 @@ def __init__(self, session: Session, base_url: Optional[str] = None): def list_destinations(self, archived: Optional[bool] = None, is_owner: Optional[bool] = None, - can_write: Optional[bool] = None) -> Dict: + can_write: Optional[bool] = None, + is_default: Optional[bool] = None) -> Dict: """ List all destinations. By default, all non-archived destinations in the requesting user's org are returned. @@ -42,6 +43,7 @@ def list_destinations(self, archived (bool): If True, include archived destinations. is_owner (bool): If True, include only destinations owned by the requesting user. can_write (bool): If True, include only destinations the requesting user can modify. + is_default (bool): If True, include only the default destination. Returns: dict: A dictionary containing the list of destinations inside the 'destinations' key. @@ -51,7 +53,7 @@ def list_destinations(self, ClientError: If there is an issue with the client request. """ return self._client._call_sync( - self._client.list_destinations(archived, is_owner, can_write)) + self._client.list_destinations(archived, is_owner, can_write, is_default)) def get_destination(self, destination_id: str) -> Dict: """ @@ -105,3 +107,52 @@ def create_destination(self, request: Dict[str, Any]) -> Dict: """ return self._client._call_sync( self._client.create_destination(request)) + + def set_default_destination(self, destination_id: str) -> Dict: + """ + Set an existing destination as the default destination. Default destinations are globally available + to all members of an organization. An organization can have zero or one default destination at any time. + Ability to set a default destination is restricted to organization administrators and destination owners. + + Args: + destination_id (str): The ID of the destination to set as default. + + Returns: + dict: A dictionary containing the default destination details. + + Raises: + APIError: If the API returns an error response. + ClientError: If there is an issue with the client request. + """ + return self._client._call_sync( + self._client.set_default_destination(destination_id)) + + def unset_default_destination(self) -> None: + """ + Unset the current default destination. Ability to unset a default destination is restricted to + organization administrators and destination owners. Returns None (HTTP 204, No Content) on success. + + Returns: + None + + Raises: + APIError: If the API returns an error response. + ClientError: If there is an issue with the client request. + """ + return self._client._call_sync( + self._client.unset_default_destination()) + + def get_default_destination(self) -> Dict: + """ + Get the current default destination. The default destination is globally available to all members of an + organization. + + Returns: + dict: A dictionary containing the default destination details. + + Raises: + APIError: If the API returns an error response. + ClientError: If there is an issue with the client request. + """ + return self._client._call_sync( + self._client.get_default_destination()) diff --git a/tests/integration/test_destinations_api.py b/tests/integration/test_destinations_api.py index e480bc539..205901c76 100644 --- a/tests/integration/test_destinations_api.py +++ b/tests/integration/test_destinations_api.py @@ -179,3 +179,83 @@ async def test_get_destination_not_found(): await cl_async.get_destination(id) with pytest.raises(Exception): cl_sync.get_destination(id) + + +@respx.mock +async def test_set_default_destination(): + id = DEST_1["id"] + url = f"{TEST_URL}/default" + mock_response(url, DEST_1, method="put") + + def assertf(resp): + assert resp == DEST_1 + + assertf(await cl_async.set_default_destination(id)) + assertf(cl_sync.set_default_destination(id)) + + +@respx.mock +async def test_set_default_destination_bad_request(): + id = "invalid_dest_id" + url = f"{TEST_URL}/default" + mock_response(url, { + "code": 400, "message": "Bad Request: Invalid destination ID" + }, + method="put", + status_code=HTTPStatus.BAD_REQUEST) + + with pytest.raises(Exception): + await cl_async.set_default_destination(id) + with pytest.raises(Exception): + cl_sync.set_default_destination(id) + + +@respx.mock +async def test_get_default_destination(): + url = f"{TEST_URL}/default" + mock_response(url, DEST_1) + + def assertf(resp): + assert resp == DEST_1 + + assertf(await cl_async.get_default_destination()) + assertf(cl_sync.get_default_destination()) + + +@respx.mock +async def test_get_default_destination_not_found(): + url = f"{TEST_URL}/default" + mock_response(url, { + "code": 404, "message": "No default destination configured" + }, + status_code=HTTPStatus.NOT_FOUND) + + with pytest.raises(Exception): + await cl_async.get_default_destination() + with pytest.raises(Exception): + cl_sync.get_default_destination() + + +@respx.mock +async def test_unset_default_destination(): + url = f"{TEST_URL}/default" + mock_response(url, None, method="delete", status_code=HTTPStatus.NO_CONTENT) + + # unset_default_destination returns None + assert await cl_async.unset_default_destination() is None + assert cl_sync.unset_default_destination() is None + + +@respx.mock +async def test_unset_default_destination_unauthorized(): + url = f"{TEST_URL}/default" + mock_response(url, { + "code": 401, "message": "Unauthorized: Insufficient permissions" + }, + method="delete", + status_code=HTTPStatus.UNAUTHORIZED) + + with pytest.raises(Exception): + await cl_async.unset_default_destination() + with pytest.raises(Exception): + cl_sync.unset_default_destination() diff --git a/tests/integration/test_destinations_cli.py b/tests/integration/test_destinations_cli.py index 50f806234..68a00d265 100644 --- a/tests/integration/test_destinations_cli.py +++ b/tests/integration/test_destinations_cli.py @@ -180,6 +180,9 @@ def test_destinations_cli_list(invoke): result = invoke(['list', '--can-write', 'true']) assert result.exit_code == 0 + result = invoke(['list', '--is-default', 'true']) + assert result.exit_code == 0 + result = invoke(['list', '--archived', 'false']) assert result.exit_code == 0 @@ -189,6 +192,9 @@ def test_destinations_cli_list(invoke): result = invoke(['list', '--can-write', 'false']) assert result.exit_code == 0 + result = invoke(['list', '--is-default', 'false']) + assert result.exit_code == 0 + result = invoke(['list', '--archived', 'false', '--is-owner', 'true']) assert result.exit_code == 0 @@ -251,3 +257,66 @@ def test_destinations_cli_update(invoke): '--use-path-style' ]) assert result.exit_code == 0 + + +@respx.mock +def test_destinations_cli_default_set(invoke): + url = f"{TEST_DESTINATIONS_URL}/default" + respx.put(url).return_value = httpx.Response(HTTPStatus.OK, json={}) + + result = invoke(['default', 'set', 'fake-dest-id']) + assert result.exit_code == 0 + + +@respx.mock +def test_destinations_cli_default_set_bad_request(invoke): + url = f"{TEST_DESTINATIONS_URL}/default" + respx.put(url).return_value = httpx.Response( + HTTPStatus.BAD_REQUEST, + json={"code": 400, "message": "Bad Request: Invalid destination ID"}) + + result = invoke(['default', 'set', 'invalid-dest-id']) + assert result.exit_code != 0 + assert "Failed to set default destination" in result.output + + +@respx.mock +def test_destinations_cli_default_get(invoke): + url = f"{TEST_DESTINATIONS_URL}/default" + respx.get(url).return_value = httpx.Response(HTTPStatus.OK, json={}) + + result = invoke(['default', 'get']) + assert result.exit_code == 0 + + +@respx.mock +def test_destinations_cli_default_get_not_found(invoke): + url = f"{TEST_DESTINATIONS_URL}/default" + respx.get(url).return_value = httpx.Response( + HTTPStatus.NOT_FOUND, + json={"code": 404, "message": "No default destination configured"}) + + result = invoke(['default', 'get']) + assert result.exit_code != 0 + assert "Failed to get default destination" in result.output + + +@respx.mock +def test_destinations_cli_default_unset(invoke): + url = f"{TEST_DESTINATIONS_URL}/default" + respx.delete(url).return_value = httpx.Response(HTTPStatus.NO_CONTENT) + + result = invoke(['default', 'unset']) + assert result.exit_code == 0 + + +@respx.mock +def test_destinations_cli_default_unset_unauthorized(invoke): + url = f"{TEST_DESTINATIONS_URL}/default" + respx.delete(url).return_value = httpx.Response( + HTTPStatus.UNAUTHORIZED, + json={"code": 401, "message": "Unauthorized: Insufficient permissions"}) + + result = invoke(['default', 'unset']) + assert result.exit_code != 0 + assert "Failed to unset default destination" in result.output diff --git a/tests/unit/test_order_request.py b/tests/unit/test_order_request.py index b602fe983..5211c1440 100644 --- a/tests/unit/test_order_request.py +++ b/tests/unit/test_order_request.py @@ -469,3 +469,50 @@ def test_fallback_bundle_invalid(bundle, fallback_bundle): bundle, "PSScene", fallback_bundle=fallback_bundle) + + +def test_destination(): + dest_config = order_request.destination('my-dest-ref') + + expected = { + 'destination': { + 'ref': 'my-dest-ref' + } + } + assert dest_config == expected + + +def test_destination_path_prefix(): + dest_config = order_request.destination('my-dest-ref', + path_prefix='my/prefix') + + expected = { + 'destination': { + 'ref': 'my-dest-ref', + 'path_prefix': 'my/prefix' + } + } + assert dest_config == expected + + +def test_default_destination(): + dest_config = order_request.default_destination() + + expected = { + 'destination': { + 'ref': 'pl:destinations/default' + } + } + assert dest_config == expected + + +def test_default_destination_path_prefix(): + dest_config = order_request.default_destination(path_prefix='my/prefix') + + expected = { + 'destination': { + 'ref': 'pl:destinations/default', + 'path_prefix': 'my/prefix' + } + } + assert dest_config == expected diff --git a/tests/unit/test_subscription_request.py b/tests/unit/test_subscription_request.py index 858cc6c26..a48e0defc 100644 --- a/tests/unit/test_subscription_request.py +++ b/tests/unit/test_subscription_request.py @@ -634,3 +634,50 @@ def test_cloud_filter_tool_success(): } assert res == expected + + +def test_destination_success(): + res = subscription_request.destination(destination_ref='my-dest-ref') + + assert res == { + "type": "destination", + "parameters": { + "ref": "my-dest-ref" + } + } + + +def test_destination_path_prefix_success(): + res = subscription_request.destination(destination_ref='my-dest-ref', + path_prefix='my/prefix') + + assert res == { + "type": "destination", + "parameters": { + "ref": "my-dest-ref", + "path_prefix": "my/prefix" + } + } + + +def test_default_destination_success(): + res = subscription_request.default_destination() + + assert res == { + "type": "destination", + "parameters": { + "ref": "pl:destinations/default" + } + } + + +def test_default_destination_path_prefix_success(): + res = subscription_request.default_destination(path_prefix='my/prefix') + + assert res == { + "type": "destination", + "parameters": { + "ref": "pl:destinations/default", + "path_prefix": "my/prefix" + } + } From a6d81db11957cd492972949ae8197adff293d220 Mon Sep 17 00:00:00 2001 From: asonnenschein Date: Wed, 12 Nov 2025 10:40:40 -0500 Subject: [PATCH 3/8] yapf --- planet/cli/destinations.py | 32 ++++++++++++++++------ planet/clients/destinations.py | 10 +++---- planet/order_request.py | 3 +- planet/subscription_request.py | 3 +- planet/sync/destinations.py | 8 ++++-- tests/integration/test_destinations_api.py | 27 ++++++++++-------- tests/integration/test_destinations_cli.py | 12 ++++++-- tests/unit/test_order_request.py | 18 +++--------- tests/unit/test_subscription_request.py | 15 +++------- 9 files changed, 69 insertions(+), 59 deletions(-) diff --git a/planet/cli/destinations.py b/planet/cli/destinations.py index 63ef003e7..ed3a25131 100644 --- a/planet/cli/destinations.py +++ b/planet/cli/destinations.py @@ -36,7 +36,12 @@ async def _patch_destination(ctx, destination_id, data, pretty): raise ClickException(f"Failed to patch destination: {e}") -async def _list_destinations(ctx, archived, is_owner, can_write, is_default, pretty): +async def _list_destinations(ctx, + archived, + is_owner, + can_write, + is_default, + pretty): async with destinations_client(ctx) as cl: try: response = await cl.list_destinations(archived, @@ -110,13 +115,17 @@ async def _get_default_destination(ctx, pretty): default=None, help="""Set to true to include only destinations the user can modify, false to exclude them.""") -@click.option( - '--is-default', - type=bool, - default=None, - help="""Set to true to include only the default destination, +@click.option('--is-default', + type=bool, + default=None, + help="""Set to true to include only the default destination, false to exclude it.""") -async def list_destinations(ctx, archived, is_owner, can_write, is_default, pretty): +async def list_destinations(ctx, + archived, + is_owner, + can_write, + is_default, + pretty): """ List destinations with optional filters @@ -127,7 +136,12 @@ async def list_destinations(ctx, archived, is_owner, can_write, is_default, pret planet destinations list --archived false --is-owner true --can-write true """ - await _list_destinations(ctx, archived, is_owner, can_write, is_default, pretty) + await _list_destinations(ctx, + archived, + is_owner, + can_write, + is_default, + pretty) @command(destinations, name="get") @@ -677,4 +691,4 @@ async def get_default_destination(ctx, pretty): planet destinations default get """ - await _get_default_destination(ctx, pretty) \ No newline at end of file + await _get_default_destination(ctx, pretty) diff --git a/planet/clients/destinations.py b/planet/clients/destinations.py index dd8177bd6..fb0e7ed90 100644 --- a/planet/clients/destinations.py +++ b/planet/clients/destinations.py @@ -198,13 +198,11 @@ async def set_default_destination(self, destination_id: str) -> None: ClientError: If there is an issue with the client request. """ url = f'{self._base_url}/default' - request = { - "destination_id": destination_id - } + request = {"destination_id": destination_id} try: response = await self._session.request(method='PUT', - url=url, - json=request) + url=url, + json=request) except APIError: raise except ClientError: # pragma: no cover @@ -253,4 +251,4 @@ async def get_default_destination(self) -> Dict: raise else: dest = response.json() - return dest \ No newline at end of file + return dest diff --git a/planet/order_request.py b/planet/order_request.py index 5070f61ef..02780b252 100644 --- a/planet/order_request.py +++ b/planet/order_request.py @@ -392,7 +392,8 @@ def s3_compatible(endpoint: str, return {'s3_compatible': parameters} -def destination(destination_ref: str, path_prefix: Optional[str] = None) -> dict: +def destination(destination_ref: str, + path_prefix: Optional[str] = None) -> dict: """Destinations API configuration. Parameters: destination_ref: Reference to an existing Destinations API diff --git a/planet/subscription_request.py b/planet/subscription_request.py index a38776c4b..3ee820faa 100644 --- a/planet/subscription_request.py +++ b/planet/subscription_request.py @@ -529,7 +529,8 @@ def s3_compatible(endpoint: str, return _delivery('s3_compatible', parameters) -def destination(destination_ref: str, path_prefix: Optional[str] = None) -> dict: +def destination(destination_ref: str, + path_prefix: Optional[str] = None) -> dict: """Specify a Destinations API destination by its ref. Parameters: diff --git a/planet/sync/destinations.py b/planet/sync/destinations.py index f8466c5eb..a95b2f977 100644 --- a/planet/sync/destinations.py +++ b/planet/sync/destinations.py @@ -53,7 +53,10 @@ def list_destinations(self, ClientError: If there is an issue with the client request. """ return self._client._call_sync( - self._client.list_destinations(archived, is_owner, can_write, is_default)) + self._client.list_destinations(archived, + is_owner, + can_write, + is_default)) def get_destination(self, destination_id: str) -> Dict: """ @@ -154,5 +157,4 @@ def get_default_destination(self) -> Dict: APIError: If the API returns an error response. ClientError: If there is an issue with the client request. """ - return self._client._call_sync( - self._client.get_default_destination()) + return self._client._call_sync(self._client.get_default_destination()) diff --git a/tests/integration/test_destinations_api.py b/tests/integration/test_destinations_api.py index 205901c76..af702b223 100644 --- a/tests/integration/test_destinations_api.py +++ b/tests/integration/test_destinations_api.py @@ -198,11 +198,12 @@ def assertf(resp): async def test_set_default_destination_bad_request(): id = "invalid_dest_id" url = f"{TEST_URL}/default" - mock_response(url, { - "code": 400, "message": "Bad Request: Invalid destination ID" - }, - method="put", - status_code=HTTPStatus.BAD_REQUEST) + mock_response( + url, { + "code": 400, "message": "Bad Request: Invalid destination ID" + }, + method="put", + status_code=HTTPStatus.BAD_REQUEST) with pytest.raises(Exception): await cl_async.set_default_destination(id) @@ -239,7 +240,10 @@ async def test_get_default_destination_not_found(): @respx.mock async def test_unset_default_destination(): url = f"{TEST_URL}/default" - mock_response(url, None, method="delete", status_code=HTTPStatus.NO_CONTENT) + mock_response(url, + None, + method="delete", + status_code=HTTPStatus.NO_CONTENT) # unset_default_destination returns None assert await cl_async.unset_default_destination() is None @@ -249,11 +253,12 @@ async def test_unset_default_destination(): @respx.mock async def test_unset_default_destination_unauthorized(): url = f"{TEST_URL}/default" - mock_response(url, { - "code": 401, "message": "Unauthorized: Insufficient permissions" - }, - method="delete", - status_code=HTTPStatus.UNAUTHORIZED) + mock_response( + url, { + "code": 401, "message": "Unauthorized: Insufficient permissions" + }, + method="delete", + status_code=HTTPStatus.UNAUTHORIZED) with pytest.raises(Exception): await cl_async.unset_default_destination() diff --git a/tests/integration/test_destinations_cli.py b/tests/integration/test_destinations_cli.py index 68a00d265..f975989b8 100644 --- a/tests/integration/test_destinations_cli.py +++ b/tests/integration/test_destinations_cli.py @@ -273,7 +273,9 @@ def test_destinations_cli_default_set_bad_request(invoke): url = f"{TEST_DESTINATIONS_URL}/default" respx.put(url).return_value = httpx.Response( HTTPStatus.BAD_REQUEST, - json={"code": 400, "message": "Bad Request: Invalid destination ID"}) + json={ + "code": 400, "message": "Bad Request: Invalid destination ID" + }) result = invoke(['default', 'set', 'invalid-dest-id']) assert result.exit_code != 0 @@ -294,7 +296,9 @@ def test_destinations_cli_default_get_not_found(invoke): url = f"{TEST_DESTINATIONS_URL}/default" respx.get(url).return_value = httpx.Response( HTTPStatus.NOT_FOUND, - json={"code": 404, "message": "No default destination configured"}) + json={ + "code": 404, "message": "No default destination configured" + }) result = invoke(['default', 'get']) assert result.exit_code != 0 @@ -315,7 +319,9 @@ def test_destinations_cli_default_unset_unauthorized(invoke): url = f"{TEST_DESTINATIONS_URL}/default" respx.delete(url).return_value = httpx.Response( HTTPStatus.UNAUTHORIZED, - json={"code": 401, "message": "Unauthorized: Insufficient permissions"}) + json={ + "code": 401, "message": "Unauthorized: Insufficient permissions" + }) result = invoke(['default', 'unset']) assert result.exit_code != 0 diff --git a/tests/unit/test_order_request.py b/tests/unit/test_order_request.py index 5211c1440..c963aa170 100644 --- a/tests/unit/test_order_request.py +++ b/tests/unit/test_order_request.py @@ -474,11 +474,7 @@ def test_fallback_bundle_invalid(bundle, fallback_bundle): def test_destination(): dest_config = order_request.destination('my-dest-ref') - expected = { - 'destination': { - 'ref': 'my-dest-ref' - } - } + expected = {'destination': {'ref': 'my-dest-ref'}} assert dest_config == expected @@ -488,8 +484,7 @@ def test_destination_path_prefix(): expected = { 'destination': { - 'ref': 'my-dest-ref', - 'path_prefix': 'my/prefix' + 'ref': 'my-dest-ref', 'path_prefix': 'my/prefix' } } assert dest_config == expected @@ -498,11 +493,7 @@ def test_destination_path_prefix(): def test_default_destination(): dest_config = order_request.default_destination() - expected = { - 'destination': { - 'ref': 'pl:destinations/default' - } - } + expected = {'destination': {'ref': 'pl:destinations/default'}} assert dest_config == expected @@ -511,8 +502,7 @@ def test_default_destination_path_prefix(): expected = { 'destination': { - 'ref': 'pl:destinations/default', - 'path_prefix': 'my/prefix' + 'ref': 'pl:destinations/default', 'path_prefix': 'my/prefix' } } assert dest_config == expected diff --git a/tests/unit/test_subscription_request.py b/tests/unit/test_subscription_request.py index a48e0defc..d7661e115 100644 --- a/tests/unit/test_subscription_request.py +++ b/tests/unit/test_subscription_request.py @@ -639,23 +639,17 @@ def test_cloud_filter_tool_success(): def test_destination_success(): res = subscription_request.destination(destination_ref='my-dest-ref') - assert res == { - "type": "destination", - "parameters": { - "ref": "my-dest-ref" - } - } + assert res == {"type": "destination", "parameters": {"ref": "my-dest-ref"}} def test_destination_path_prefix_success(): res = subscription_request.destination(destination_ref='my-dest-ref', - path_prefix='my/prefix') + path_prefix='my/prefix') assert res == { "type": "destination", "parameters": { - "ref": "my-dest-ref", - "path_prefix": "my/prefix" + "ref": "my-dest-ref", "path_prefix": "my/prefix" } } @@ -677,7 +671,6 @@ def test_default_destination_path_prefix_success(): assert res == { "type": "destination", "parameters": { - "ref": "pl:destinations/default", - "path_prefix": "my/prefix" + "ref": "pl:destinations/default", "path_prefix": "my/prefix" } } From ecbc838a94d3d96fe3738f9a53c37f1747b104e6 Mon Sep 17 00:00:00 2001 From: asonnenschein Date: Wed, 12 Nov 2025 10:47:41 -0500 Subject: [PATCH 4/8] fix type issues --- planet/clients/destinations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/planet/clients/destinations.py b/planet/clients/destinations.py index fb0e7ed90..1d1f0dad0 100644 --- a/planet/clients/destinations.py +++ b/planet/clients/destinations.py @@ -181,7 +181,7 @@ async def create_destination(self, request: Dict[str, Any]) -> Dict: dest = response.json() return dest - async def set_default_destination(self, destination_id: str) -> None: + async def set_default_destination(self, destination_id: str) -> Dict: """ Set an existing destination as the default destination. Default destinations are globally available to all members of an organization. An organization can have zero or one default destination at any time. From e57b6e2a7a3b69d8b9b425dba5c3ec9a8c087206 Mon Sep 17 00:00:00 2001 From: asonnenschein Date: Wed, 12 Nov 2025 11:11:03 -0500 Subject: [PATCH 5/8] fix parameter name --- planet/subscription_request.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/planet/subscription_request.py b/planet/subscription_request.py index 3ee820faa..d1ebadfad 100644 --- a/planet/subscription_request.py +++ b/planet/subscription_request.py @@ -534,7 +534,7 @@ def destination(destination_ref: str, """Specify a Destinations API destination by its ref. Parameters: - destination_id: The ID of the destination to deliver to. + destination_ref: The ID of the destination to deliver to. """ parameters: Dict[str, Any] = {"ref": destination_ref} From a3bb4593709557fd056166ad6b40b55e57626c22 Mon Sep 17 00:00:00 2001 From: asonnenschein Date: Wed, 12 Nov 2025 11:34:28 -0500 Subject: [PATCH 6/8] update cli docs --- docs/cli/cli-destinations.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/docs/cli/cli-destinations.md b/docs/cli/cli-destinations.md index 299e4a554..d990ae843 100644 --- a/docs/cli/cli-destinations.md +++ b/docs/cli/cli-destinations.md @@ -31,12 +31,19 @@ The `list` command supports filtering on a variety of fields. You can discover a requesting user, false to exclude them. * `--can-write`: Set to true to include only destinations the user can modify, false to exclude them. +* `--is-default`: Set to true to include only the default destination, + false to exclude it. For example, issue the following command to list destinations that are not archived and you as the user have permission to modify: ```sh planet destinations list --archived false --can-write true ``` +To list only the default destination (if one is set): +```sh +planet destinations list --is-default true +``` + ### Modify Destinations The CLI conveniently moves all modify actions to first class commands on the destination. The supported actions are archive, unarchive, rename, and update credentials. To discover all update actions run `planet destinations --help`. @@ -47,6 +54,31 @@ Credential updating might be done if credentials expire or need to be rotated. F planet destinations update s3 my-destination-id --access-key-id NEW_ACCESS_KEY --secret-access-key NEW_SECRET_KEY ``` +### Manage Default Destinations +Default destinations are globally available to all members of an organization. An organization can have zero or one default destination at any time. Managing default destinations (setting, unsetting) is restricted to organization administrators and destination owners. + +#### Set a Default Destination +To set a destination as the default for your organization: +```sh +planet destinations default set my-destination-id +``` + +#### Get the Current Default Destination +To retrieve the current default destination (if one is set): +```sh +planet destinations default get +``` + +If no default destination is set, this command will return an error indicating that no default destination is configured. + +#### Unset the Default Destination +To remove the current default destination: +```sh +planet destinations default unset +``` + +This command returns no content on success (HTTP 204). Only organization administrators and destination owners can unset the default destination. + ## Using destinations in Subscriptions API After creating a destination, it can be used as the delivery location for subscriptions. Use the destination reference in the delivery block instead of credentials. From 48a8d62cf729ce0bc019c628d1f482c709a5c815 Mon Sep 17 00:00:00 2001 From: Adrian Sonnenschein Date: Wed, 12 Nov 2025 11:45:29 -0500 Subject: [PATCH 7/8] Update planet/subscription_request.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- planet/subscription_request.py | 1 + 1 file changed, 1 insertion(+) diff --git a/planet/subscription_request.py b/planet/subscription_request.py index d1ebadfad..ae5dbf19f 100644 --- a/planet/subscription_request.py +++ b/planet/subscription_request.py @@ -535,6 +535,7 @@ def destination(destination_ref: str, Parameters: destination_ref: The ID of the destination to deliver to. + path_prefix: Path prefix for deliveries. """ parameters: Dict[str, Any] = {"ref": destination_ref} From 2482597bf22b8241a3e14e409bfd2622f2747029 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Nov 2025 11:59:52 -0500 Subject: [PATCH 8/8] Use DEFAULT_DESTINATION_REF constant in order_request.py (#1202) * Initial plan * Use DEFAULT_DESTINATION_REF constant in order_request.py Co-authored-by: asonnenschein <3228909+asonnenschein@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: asonnenschein <3228909+asonnenschein@users.noreply.github.com> --- planet/order_request.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/planet/order_request.py b/planet/order_request.py index 02780b252..512fb0c76 100644 --- a/planet/order_request.py +++ b/planet/order_request.py @@ -18,6 +18,7 @@ from typing import Any, Dict, List, Mapping, Optional, Union from . import geojson, specs +from .clients.destinations import DEFAULT_DESTINATION_REF from .exceptions import ClientError LOGGER = logging.getLogger(__name__) @@ -414,7 +415,7 @@ def default_destination(path_prefix: Optional[str] = None) -> dict: Parameters: path_prefix: Path prefix for deliveries. """ - parameters: Dict[str, Any] = {'ref': 'pl:destinations/default'} + parameters: Dict[str, Any] = {'ref': DEFAULT_DESTINATION_REF} if path_prefix: parameters['path_prefix'] = path_prefix