diff --git a/docs/cli/cli-subscriptions.md b/docs/cli/cli-subscriptions.md index 2269775b..7554e2e6 100644 --- a/docs/cli/cli-subscriptions.md +++ b/docs/cli/cli-subscriptions.md @@ -228,12 +228,51 @@ planet subscriptions results SUBSCRIPTION_ID By default this displays the first 100 results. As with other commands, you can use the `--limit` param to set a higher limit, or set it to 0 to see all results (this can be quite large with subscriptions results). -You can also filter by status: +#### Filtering Results + +The `results` command supports filtering on several fields: + +* `--status`: Filter on the status of results. Status options include `created`, `queued`, `processing`, `failed`, and `success`. Multiple status args are allowed. +* `--created`: Filter results by creation time or an interval of creation times. +* `--updated`: Filter results by update time or an interval of update times. +* `--completed`: Filter results by completion time or an interval of completion times. +* `--item-datetime`: Filter results by item datetime or an interval of item datetimes. + +Datetime args (`--created`, `--updated`, `--completed`, and `--item-datetime`) can either be a date-time or an interval, open or closed. Date and time expressions adhere to RFC 3339. Open intervals are expressed using double-dots. + +* A date-time: `2018-02-12T23:20:50Z` +* A closed interval: `2018-02-12T00:00:00Z/2018-03-18T12:31:12Z` +* Open intervals: `2018-02-12T00:00:00Z/..` or `../2018-03-18T12:31:12Z` + +Examples: + +To see results that are currently processing: ```sh planet subscriptions results SUBSCRIPTION_ID --status processing ``` +To see successful results completed in the last week: + +```sh +planet subscriptions results SUBSCRIPTION_ID --status success \ + --completed 2026-03-17T00:00:00Z/.. +``` + +To see results created in a specific time range: + +```sh +planet subscriptions results SUBSCRIPTION_ID \ + --created 2024-01-01T00:00:00Z/2024-02-01T00:00:00Z +``` + +To see results for imagery captured after a specific date: + +```sh +planet subscriptions results SUBSCRIPTION_ID \ + --item-datetime 2024-01-01T00:00:00Z/.. +``` + See the Subscriptions API documentation for the [official list of available statuses](https://docs.planet.com/develop/apis/subscriptions/#states--status-descriptions). #### Results as comma-separated values (CSV) diff --git a/planet/cli/subscriptions.py b/planet/cli/subscriptions.py index 2cd15dfe..2ad1072c 100644 --- a/planet/cli/subscriptions.py +++ b/planet/cli/subscriptions.py @@ -54,12 +54,11 @@ def subscriptions(ctx, base_url): @pretty @click.option( '--created', - help="""Filter subscriptions by creation time or interval. See documentation - for examples.""") -@click.option( - '--end-time', - help="""Filter subscriptions by end time or interval. See documentation - for examples.""") + help="""Filter subscriptions by creation time or interval (RFC 3339). + See documentation for examples.""") +@click.option('--end-time', + help="""Filter subscriptions by end time or interval (RFC 3339). + See documentation for examples.""") @click.option( '--hosting', type=click.BOOL, @@ -76,8 +75,8 @@ def subscriptions(ctx, base_url): available types. Default is all.""") @click.option( '--start-time', - help="""Filter subscriptions by start time or interval. See documentation - for examples.""") + help="""Filter subscriptions by start time or interval (RFC 3339). + See documentation for examples.""") @click.option( '--status', type=click.Choice([ @@ -102,10 +101,10 @@ def subscriptions(ctx, base_url): Supported fields: [name, created, updated, start_time, end_time]. Example: 'name ASC,created DESC'""") -@click.option('--updated', - help="""Filter subscriptions by update time or interval. See - documentation - for examples.""") +@click.option( + '--updated', + help="""Filter subscriptions by update time or interval (RFC 3339). See + documentation for examples.""") @click.option( '--destination-ref', help="Filter subscriptions created with the provided destination reference." @@ -437,11 +436,19 @@ async def get_subscription_cmd(ctx, subscription_id, pretty): default=False, help="Get subscription results as comma-separated fields. When " "this flag is included, --limit is ignored") +@click.option('--created', + help="""Filter results by creation time or interval (RFC 3339). + See documentation for examples.""") +@click.option('--updated', + help="""Filter results by update time or interval (RFC 3339). + See documentation for examples.""") +@click.option('--completed', + help="""Filter results by completion time or interval (RFC 3339). + See documentation for examples.""") +@click.option('--item-datetime', + help="""Filter results by item datetime or interval (RFC 3339). + See documentation for examples.""") @limit -# TODO: the following 3 options. -# –created: timestamp instant or range. -# –updated: timestamp instant or range. -# –completed: timestamp instant or range. @click.pass_context @translate_exceptions @coro @@ -450,6 +457,10 @@ async def list_subscription_results_cmd(ctx, pretty, status, csv_flag, + created, + updated, + completed, + item_datetime, limit): """Print the results of a subscription to stdout. @@ -473,13 +484,23 @@ async def list_subscription_results_cmd(ctx, """ async with subscriptions_client(ctx) as client: if csv_flag: - async for result in client.get_results_csv(subscription_id, - status=status): + async for result in client.get_results_csv( + subscription_id, + status=status, + created=created, + updated=updated, + completed=completed, + item_datetime=item_datetime): click.echo(result) else: - async for result in client.get_results(subscription_id, - status=status, - limit=limit): + async for result in client.get_results( + subscription_id, + status=status, + limit=limit, + created=created, + updated=updated, + completed=completed, + item_datetime=item_datetime): echo_json(result, pretty) diff --git a/planet/clients/subscriptions.py b/planet/clients/subscriptions.py index 372b6eb6..85c23289 100644 --- a/planet/clients/subscriptions.py +++ b/planet/clients/subscriptions.py @@ -514,15 +514,19 @@ async def get_subscription(self, subscription_id: str) -> dict: sub = resp.json() return sub - async def get_results(self, - subscription_id: str, - status: Optional[Sequence[Literal[ - "created", - "queued", - "processing", - "failed", - "success"]]] = None, - limit: int = 100) -> AsyncIterator[dict]: + async def get_results( + self, + subscription_id: str, + status: Optional[Sequence[Literal["created", + "queued", + "processing", + "failed", + "success"]]] = None, + limit: int = 100, + created: Optional[str] = None, + updated: Optional[str] = None, + completed: Optional[str] = None, + item_datetime: Optional[str] = None) -> AsyncIterator[dict]: """Iterate over results of a Subscription. Notes: @@ -536,6 +540,19 @@ async def get_results(self, filter out results with status not in this set. limit (int): limit the number of subscriptions in the results. When set to 0, no maximum is applied. + created (str): filter by created time or interval. + updated (str): filter by updated time or interval. + completed (str): filter by completed time or interval. + item_datetime (str): filter by item datetime or interval. + + Datetime args (created, updated, completed, item_datetime) can either be a + date-time or an interval, open or closed. Date and time expressions adhere + to RFC 3339. Open intervals are expressed using double-dots. + + Examples: + * A date-time: "2018-02-12T23:20:50Z" + * A closed interval: "2018-02-12T00:00:00Z/2018-03-18T12:31:12Z" + * Open intervals: "2018-02-12T00:00:00Z/.." or "../2018-03-18T12:31:12Z" Yields: dict: description of a subscription results. @@ -545,13 +562,22 @@ async def get_results(self, ClientError: on a client error. """ - # TODO from old doc string, which breaks strict document checking: - # Add Parameters created, updated, completed, user_id class _ResultsPager(Paged): """Navigates pages of messages about subscription results.""" ITEMS_KEY = 'results' - params = {'status': [val for val in status or {}]} + params: Dict[str, Any] = {} + if status is not None: + params['status'] = [val for val in status] + if created is not None: + params['created'] = created + if updated is not None: + params['updated'] = updated + if completed is not None: + params['completed'] = completed + if item_datetime is not None: + params['item_datetime'] = item_datetime + url = f'{self._base_url}/{subscription_id}/results' try: @@ -570,20 +596,36 @@ class _ResultsPager(Paged): raise async def get_results_csv( - self, - subscription_id: str, - status: Optional[Sequence[Literal["created", - "queued", - "processing", - "failed", - "success"]]] = None - ) -> AsyncIterator[str]: + self, + subscription_id: str, + status: Optional[Sequence[Literal["created", + "queued", + "processing", + "failed", + "success"]]] = None, + created: Optional[str] = None, + updated: Optional[str] = None, + completed: Optional[str] = None, + item_datetime: Optional[str] = None) -> AsyncIterator[str]: """Iterate over rows of results CSV for a Subscription. Parameters: subscription_id (str): id of a subscription. status (Set[str]): pass result with status in this set, filter out results with status not in this set. + created (str): filter by created time or interval. + updated (str): filter by updated time or interval. + completed (str): filter by completed time or interval. + item_datetime (str): filter by item datetime or interval. + + Datetime args (created, updated, completed, item_datetime) can either be a + date-time or an interval, open or closed. Date and time expressions adhere + to RFC 3339. Open intervals are expressed using double-dots. + + Examples: + * A date-time: "2018-02-12T23:20:50Z" + * A closed interval: "2018-02-12T00:00:00Z/2018-03-18T12:31:12Z" + * Open intervals: "2018-02-12T00:00:00Z/.." or "../2018-03-18T12:31:12Z" Yields: str: a row from a CSV file. @@ -592,10 +634,18 @@ async def get_results_csv( APIError: on an API server error. ClientError: on a client error. """ - # TODO from old doc string, which breaks strict document checking: - # Add Parameters created, updated, completed, user_id url = f'{self._base_url}/{subscription_id}/results' - params = {'status': [val for val in status or {}], 'format': 'csv'} + params: Dict[str, Any] = {'format': 'csv'} + if status is not None: + params['status'] = [val for val in status] + if created is not None: + params['created'] = created + if updated is not None: + params['updated'] = updated + if completed is not None: + params['completed'] = completed + if item_datetime is not None: + params['item_datetime'] = item_datetime # Note: retries are not implemented yet. This project has # retry logic for HTTP requests, but does not handle errors diff --git a/planet/sync/subscriptions.py b/planet/sync/subscriptions.py index 9ec656c2..cdbc6827 100644 --- a/planet/sync/subscriptions.py +++ b/planet/sync/subscriptions.py @@ -338,6 +338,10 @@ def get_results( "failed", "success"]]] = None, limit: int = 100, + created: Optional[str] = None, + updated: Optional[str] = None, + completed: Optional[str] = None, + item_datetime: Optional[str] = None ) -> Iterator[Union[Dict[str, Any], str]]: """Iterate over results of a Subscription. @@ -352,7 +356,19 @@ def get_results( filter out results with status not in this set. limit (int): limit the number of subscriptions in the results. When set to 0, no maximum is applied. - TODO: created, updated, completed, user_id + created (str): filter by created time or interval. + updated (str): filter by updated time or interval. + completed (str): filter by completed time or interval. + item_datetime (str): filter by item datetime or interval. + + Datetime args (created, updated, completed, item_datetime) can either be a + date-time or an interval, open or closed. Date and time expressions adhere + to RFC 3339. Open intervals are expressed using double-dots. + + Examples: + * A date-time: "2018-02-12T23:20:50Z" + * A closed interval: "2018-02-12T00:00:00Z/2018-03-18T12:31:12Z" + * Open intervals: "2018-02-12T00:00:00Z/.." or "../2018-03-18T12:31:12Z" Yields: dict: description of a subscription results. @@ -362,24 +378,44 @@ def get_results( ClientError: on a client error. """ return self._client._aiter_to_iter( - self._client.get_results(subscription_id, status, limit)) - - def get_results_csv( - self, - subscription_id: str, - status: Optional[Sequence[Literal["created", - "queued", - "processing", - "failed", - "success"]]] = None - ) -> Iterator[str]: + self._client.get_results(subscription_id, + status, + limit, + created, + updated, + completed, + item_datetime)) + + def get_results_csv(self, + subscription_id: str, + status: Optional[Sequence[Literal["created", + "queued", + "processing", + "failed", + "success"]]] = None, + created: Optional[str] = None, + updated: Optional[str] = None, + completed: Optional[str] = None, + item_datetime: Optional[str] = None) -> Iterator[str]: """Iterate over rows of results CSV for a Subscription. Parameters: subscription_id (str): id of a subscription. status (Set[str]): pass result with status in this set, filter out results with status not in this set. - TODO: created, updated, completed, user_id + created (str): filter by created time or interval. + updated (str): filter by updated time or interval. + completed (str): filter by completed time or interval. + item_datetime (str): filter by item datetime or interval. + + Datetime args (created, updated, completed, item_datetime) can either be a + date-time or an interval, open or closed. Date and time expressions adhere + to RFC 3339. Open intervals are expressed using double-dots. + + Examples: + * A date-time: "2018-02-12T23:20:50Z" + * A closed interval: "2018-02-12T00:00:00Z/2018-03-18T12:31:12Z" + * Open intervals: "2018-02-12T00:00:00Z/.." or "../2018-03-18T12:31:12Z" Yields: str: a row from a CSV file. @@ -394,7 +430,12 @@ def get_results_csv( # for this entire method a la stamina: # https://github.com/hynek/stamina. return self._client._aiter_to_iter( - self._client.get_results_csv(subscription_id, status)) + self._client.get_results_csv(subscription_id, + status, + created, + updated, + completed, + item_datetime)) def get_summary(self) -> Dict[str, Any]: """Summarize the status of all subscriptions via GET. diff --git a/tests/integration/test_subscriptions_api.py b/tests/integration/test_subscriptions_api.py index 72c0e7a2..10d0b9aa 100644 --- a/tests/integration/test_subscriptions_api.py +++ b/tests/integration/test_subscriptions_api.py @@ -830,6 +830,145 @@ def test_get_results_csv_sync(): assert rows == [['id', 'status'], ['1234-abcd', 'SUCCESS']] +# Mock router for testing query parameter filtering +results_filter_mock = respx.mock(assert_all_called=False) + +# More specific routes (multiple params) must come first +results_filter_mock.route( + M(url__startswith=TEST_URL), + M( + params__contains={ + 'created': '2024-01-01T00:00:00Z/..', + 'item_datetime': '2024-06-01T00:00:00Z/..' + }) +).mock( + side_effect=[Response(200, json={ + 'results': [{ + 'id': '5' + }], '_links': {} + })]) + +results_filter_mock.route( + M(url__startswith=TEST_URL), + M(params__contains={ + 'format': 'csv', 'completed': '2024-01-01T00:00:00Z/..' + })).mock(side_effect=[Response(200, text="id,status\n6,SUCCESS\n")]) + +# Single parameter routes +results_filter_mock.route( + M(url__startswith=TEST_URL), + M(params__contains={'created': '2024-01-01T00:00:00Z/..'}) +).mock( + side_effect=[Response(200, json={ + 'results': [{ + 'id': '1' + }], '_links': {} + })]) + +results_filter_mock.route( + M(url__startswith=TEST_URL), + M(params__contains={'updated': '../2024-12-31T23:59:59Z'}) +).mock( + side_effect=[Response(200, json={ + 'results': [{ + 'id': '2' + }], '_links': {} + })]) + +results_filter_mock.route( + M(url__startswith=TEST_URL), + M(params__contains={ + 'completed': '2024-01-01T00:00:00Z/2024-02-01T00:00:00Z' + }) +).mock( + side_effect=[Response(200, json={ + 'results': [{ + 'id': '3' + }], '_links': {} + })]) + +results_filter_mock.route( + M(url__startswith=TEST_URL), + M(params__contains={'item_datetime': '2024-06-01T00:00:00Z/..'}) +).mock( + side_effect=[Response(200, json={ + 'results': [{ + 'id': '4' + }], '_links': {} + })]) + + +@pytest.mark.parametrize( + "filter_param,expected_id", + [ + ({ + "created": "2024-01-01T00:00:00Z/.." + }, '1'), + ({ + "updated": "../2024-12-31T23:59:59Z" + }, '2'), + ({ + "completed": "2024-01-01T00:00:00Z/2024-02-01T00:00:00Z" + }, '3'), + ({ + "item_datetime": "2024-06-01T00:00:00Z/.." + }, '4'), + ]) +@pytest.mark.anyio +@results_filter_mock +async def test_get_results_with_filter_params(filter_param, expected_id): + """get_results passes filter parameters to API.""" + async with Session() as session: + client = SubscriptionsClient(session, base_url=TEST_URL) + results = [ + res async for res in client.get_results("42", **filter_param) + ] + assert len(results) == 1 + assert results[0]['id'] == expected_id + + +@pytest.mark.anyio +@results_filter_mock +async def test_get_results_with_multiple_filters(): + """get_results passes multiple filter parameters to API.""" + async with Session() as session: + client = SubscriptionsClient(session, base_url=TEST_URL) + results = [ + res async for res in + client.get_results("42", created="2024-01-01T00:00:00Z/..", + item_datetime="2024-06-01T00:00:00Z/..") + ] + assert len(results) == 1 + assert results[0]['id'] == '5' + + +@pytest.mark.anyio +@results_filter_mock +async def test_get_results_csv_with_filter(): + """get_results_csv passes filter parameters to API.""" + async with Session() as session: + client = SubscriptionsClient(session, base_url=TEST_URL) + results = [ + res async for res in client.get_results_csv( + "42", completed="2024-01-01T00:00:00Z/..") + ] + rows = list(csv.reader(results)) + assert rows == [['id', 'status'], ['6', 'SUCCESS']] + + +@results_filter_mock +def test_get_results_with_filters_sync(): + """Sync get_results passes filter parameters to API.""" + pl = Planet() + pl.subscriptions._client._base_url = TEST_URL + results = list( + pl.subscriptions.get_results("42", + created="2024-01-01T00:00:00Z/..", + item_datetime="2024-06-01T00:00:00Z/..")) + assert len(results) == 1 + assert results[0]['id'] == '5' + + paging_cycle_api_mock = respx.mock() # Identical next links is a hangup we want to avoid.