From c949b9032203271f59751ded0cff6b6d17cce7d6 Mon Sep 17 00:00:00 2001 From: asonnenschein Date: Tue, 12 Aug 2025 20:03:36 -0400 Subject: [PATCH] initial commit --- planet/__init__.py | 5 +- planet/cli/cli.py | 3 +- planet/cli/tasking.py | 177 +++++++++++++++ planet/clients/__init__.py | 7 +- planet/clients/tasking.py | 305 ++++++++++++++++++++++++++ planet/sync/client.py | 3 + planet/sync/tasking.py | 170 ++++++++++++++ tests/integration/test_tasking_api.py | 279 +++++++++++++++++++++++ tests/integration/test_tasking_cli.py | 285 ++++++++++++++++++++++++ tests/unit/test_client.py | 6 + tests/unit/test_tasking_client.py | 98 +++++++++ 11 files changed, 1333 insertions(+), 5 deletions(-) create mode 100644 planet/cli/tasking.py create mode 100644 planet/clients/tasking.py create mode 100644 planet/sync/tasking.py create mode 100644 tests/integration/test_tasking_api.py create mode 100644 tests/integration/test_tasking_cli.py create mode 100644 tests/unit/test_tasking_client.py diff --git a/planet/__init__.py b/planet/__init__.py index 41a9e62b6..4bb349e27 100644 --- a/planet/__init__.py +++ b/planet/__init__.py @@ -17,7 +17,7 @@ from .__version__ import __version__ # NOQA from .auth import Auth from .auth_builtins import PlanetOAuthScopes -from .clients import DataClient, DestinationsClient, FeaturesClient, MosaicsClient, OrdersClient, SubscriptionsClient # NOQA +from .clients import DataClient, DestinationsClient, FeaturesClient, MosaicsClient, OrdersClient, SubscriptionsClient, TaskingClient # NOQA from .io import collect from .sync import Planet @@ -36,5 +36,6 @@ 'reporting', 'Session', 'SubscriptionsClient', - 'subscription_request' + 'subscription_request', + 'TaskingClient' ] diff --git a/planet/cli/cli.py b/planet/cli/cli.py index 467b1e5b1..52665faf9 100644 --- a/planet/cli/cli.py +++ b/planet/cli/cli.py @@ -22,7 +22,7 @@ import planet from planet.cli import mosaics -from . import auth, cmds, collect, data, destinations, orders, subscriptions, features +from . import auth, cmds, collect, data, destinations, orders, subscriptions, features, tasking LOGGER = logging.getLogger(__name__) @@ -131,6 +131,7 @@ def _configure_logging(verbosity): main.add_command(features.features) # type: ignore main.add_command(destinations.destinations) # type: ignore main.add_command(mosaics.mosaics) # type: ignore +main.add_command(tasking.tasking) # type: ignore if __name__ == "__main__": main() # pylint: disable=E1120 diff --git a/planet/cli/tasking.py b/planet/cli/tasking.py new file mode 100644 index 000000000..886d1faa6 --- /dev/null +++ b/planet/cli/tasking.py @@ -0,0 +1,177 @@ +# Copyright 2025 Planet Labs PBC. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Tasking API CLI""" +from contextlib import asynccontextmanager +import json +import logging + +import click + +from planet import TaskingClient +from .cmds import coro, translate_exceptions +from .io import echo_json +from .options import limit, pretty +from .session import CliSession +from .validators import check_geom + +LOGGER = logging.getLogger(__name__) + + +@asynccontextmanager +async def tasking_client(ctx): + base_url = ctx.obj['BASE_URL'] + async with CliSession(ctx) as sess: + cl = TaskingClient(sess, base_url=base_url) + yield cl + + +@click.group() # type: ignore +@click.pass_context +@click.option('-u', + '--base-url', + default=None, + help='Assign custom base Tasking API URL.') +def tasking(ctx, base_url): + """Commands for interacting with the Tasking API""" + ctx.obj['BASE_URL'] = base_url + + +@tasking.command('create-order') # type: ignore +@click.pass_context +@translate_exceptions +@coro +@click.option('--request', + type=click.File(), + help='Request specification as a JSON file.') +@click.option('--name', help='Name for the tasking order.') +@click.option('--geometry', + callback=check_geom, + help='Geometry as GeoJSON string or @file.geojson.') +@pretty +async def create_order(ctx, request, name, geometry, pretty): + """Create a tasking order. + + Example: + planet tasking create-order --request order.json + planet tasking create-order --name "my order" --geometry '{"type":"Point","coordinates":[-122,37]}' + """ + if request: + request_data = json.load(request) + elif name and geometry: + request_data = { + 'name': name, + 'geometry': geometry, + 'products': [{ + 'item_type': 'skysat_collect', 'asset_type': 'ortho_analytic' + }] + } + else: + raise click.UsageError( + 'Either --request file or --name and --geometry must be provided') + + async with tasking_client(ctx) as client: + order = await client.create_order(request_data) + echo_json(order, pretty) + + +@tasking.command('get-order') # type: ignore +@click.pass_context +@translate_exceptions +@coro +@click.argument('order_id') +@pretty +async def get_order(ctx, order_id, pretty): + """Get a tasking order by ID.""" + async with tasking_client(ctx) as client: + order = await client.get_order(order_id) + echo_json(order, pretty) + + +@tasking.command('cancel-order') # type: ignore +@click.pass_context +@translate_exceptions +@coro +@click.argument('order_id') +@pretty +async def cancel_order(ctx, order_id, pretty): + """Cancel a tasking order.""" + async with tasking_client(ctx) as client: + order = await client.cancel_order(order_id) + echo_json(order, pretty) + + +@tasking.command('list-orders') # type: ignore +@click.pass_context +@translate_exceptions +@coro +@click.option( + '--state', + help='Filter by order state (queued, running, success, failed, cancelled).' +) +@limit +@pretty +async def list_orders(ctx, state, limit, pretty): + """List tasking orders.""" + async with tasking_client(ctx) as client: + async for order in client.list_orders(state=state, limit=limit): + echo_json(order, pretty) + + +@tasking.command('wait-order') # type: ignore +@click.pass_context +@translate_exceptions +@coro +@click.argument('order_id') +@click.option('--state', + default='success', + help='State to wait for (default: success).') +@click.option('--delay', + type=int, + default=5, + help='Delay between polling attempts in seconds (default: 5).') +@click.option('--max-attempts', + type=int, + default=200, + help='Maximum number of polling attempts (default: 200).') +@pretty +async def wait_order(ctx, order_id, state, delay, max_attempts, pretty): + """Wait for a tasking order to reach a specified state.""" + + def callback(order): + """Print order status during polling.""" + echo_json({'order_id': order['id'], 'state': order['state']}, pretty) + + async with tasking_client(ctx) as client: + await client.wait_order(order_id=order_id, + state=state, + delay=delay, + max_attempts=max_attempts, + callback=callback) + + # Get final order details + final_order = await client.get_order(order_id) + echo_json(final_order, pretty) + + +@tasking.command('get-results') # type: ignore +@click.pass_context +@translate_exceptions +@coro +@click.argument('order_id') +@pretty +async def get_results(ctx, order_id, pretty): + """Get results for a completed tasking order.""" + async with tasking_client(ctx) as client: + results = await client.get_order_results(order_id) + echo_json({'results': results}, pretty) diff --git a/planet/clients/__init__.py b/planet/clients/__init__.py index 6aae646f6..181d1c1d7 100644 --- a/planet/clients/__init__.py +++ b/planet/clients/__init__.py @@ -18,6 +18,7 @@ from .mosaics import MosaicsClient from .orders import OrdersClient from .subscriptions import SubscriptionsClient +from .tasking import TaskingClient __all__ = [ 'DataClient', @@ -25,7 +26,8 @@ 'FeaturesClient', 'MosaicsClient', 'OrdersClient', - 'SubscriptionsClient' + 'SubscriptionsClient', + 'TaskingClient' ] # Organize client classes by their module name to allow lookup. @@ -35,5 +37,6 @@ 'features': FeaturesClient, 'mosaics': MosaicsClient, 'orders': OrdersClient, - 'subscriptions': SubscriptionsClient + 'subscriptions': SubscriptionsClient, + 'tasking': TaskingClient } diff --git a/planet/clients/tasking.py b/planet/clients/tasking.py new file mode 100644 index 000000000..b49071cbf --- /dev/null +++ b/planet/clients/tasking.py @@ -0,0 +1,305 @@ +# Copyright 2025 Planet Labs PBC. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. +"""Functionality for interacting with the tasking api""" +import asyncio +import logging +from typing import AsyncIterator, Callable, List, Optional, TypeVar +import uuid + +from planet.clients.base import _BaseClient +from .. import exceptions +from ..constants import PLANET_BASE_URL +from ..http import Session +from ..models import Paged + +BASE_URL = f'{PLANET_BASE_URL}/tasking/v2/' +ORDERS_PATH = 'orders' + +# Tasking order states - similar to orders API but for tasking +TASKING_ORDER_STATE_SEQUENCE = \ + ('queued', 'running', 'success', 'failed', 'cancelled') + +WAIT_DELAY = 5 +WAIT_MAX_ATTEMPTS = 200 + +LOGGER = logging.getLogger(__name__) + +T = TypeVar("T") + + +class TaskingOrders(Paged): + """Asynchronous iterator over tasking orders from a paged response.""" + ITEMS_KEY = 'orders' + + +class TaskingOrderStates: + """Helper class for working with tasking order states.""" + SEQUENCE = TASKING_ORDER_STATE_SEQUENCE + + @classmethod + def _get_position(cls, state): + return cls.SEQUENCE.index(state) + + @classmethod + def reached(cls, state, test): + return cls._get_position(test) >= cls._get_position(state) + + @classmethod + def passed(cls, state, test): + return cls._get_position(test) > cls._get_position(state) + + @classmethod + def is_final(cls, test): + return cls.passed('running', test) + + +class TaskingClient(_BaseClient): + """High-level asynchronous access to Planet's Tasking API. + + The Planet Tasking API is a programmatic interface that enables customers + to manage and request high-resolution imagery collection in an efficient + and automated way. + + Example: + ```python + >>> import asyncio + >>> from planet import Session + >>> + >>> async def main(): + ... async with Session() as sess: + ... cl = sess.client('tasking') + ... # use client here + ... + >>> asyncio.run(main()) + + ``` + """ + + def __init__(self, session: Session, base_url: Optional[str] = None): + """ + Parameters: + session: Open session connected to server. + base_url: The base URL to use. Defaults to production tasking API + base url. + """ + super().__init__(session, base_url or BASE_URL) + + @staticmethod + def _check_order_id(oid): + """Raises planet.exceptions.ClientError if oid is not a valid UUID""" + try: + uuid.UUID(oid) + except (ValueError, AttributeError, TypeError): + msg = f'Tasking order id ({oid}) is not a valid UUID hexadecimal string.' + raise exceptions.ClientError(msg) + + def _orders_url(self, order_id: Optional[str] = None): + """Build orders URL with optional order ID.""" + url = f'{self._base_url}{ORDERS_PATH}' + if order_id: + url = f'{url}/{order_id}' + return url + + async def create_order(self, request: dict) -> dict: + """Create a tasking order request. + + Example: + + ```python + >>> import asyncio + >>> from planet import Session + >>> + >>> async def main(): + ... request = { + ... "name": "my_tasking_order", + ... "geometry": { + ... "type": "Point", + ... "coordinates": [-122.0, 37.0] + ... }, + ... "products": [{ + ... "item_type": "skysat_collect", + ... "asset_type": "ortho_analytic" + ... }] + ... } + ... async with Session() as sess: + ... cl = sess.client('tasking') + ... order = await cl.create_order(request) + ... + >>> asyncio.run(main()) + ``` + + Parameters: + request: tasking order request definition + + Returns: + JSON description of the created tasking order + + Raises: + planet.exceptions.APIError: On API error. + """ + url = self._orders_url() + response = await self._session.request(method='POST', + url=url, + json=request) + return response.json() + + async def get_order(self, order_id: str) -> dict: + """Get a tasking order. + + Parameters: + order_id: Order identifier. + + Returns: + JSON description of the tasking order. + + Raises: + planet.exceptions.ClientError: If order_id is not a valid UUID. + planet.exceptions.APIError: On API error. + """ + self._check_order_id(order_id) + url = self._orders_url(order_id) + response = await self._session.request(method='GET', url=url) + return response.json() + + async def cancel_order(self, order_id: str) -> dict: + """Cancel a tasking order. + + Parameters: + order_id: Order identifier. + + Returns: + JSON description of the cancelled tasking order. + + Raises: + planet.exceptions.ClientError: If order_id is not a valid UUID. + planet.exceptions.APIError: On API error. + """ + self._check_order_id(order_id) + url = self._orders_url(order_id) + # Cancel is typically done via PATCH or PUT with state change + response = await self._session.request(method='PATCH', + url=url, + json={'state': 'cancelled'}) + return response.json() + + async def list_orders(self, + state: Optional[str] = None, + limit: Optional[int] = None, + **filters) -> AsyncIterator[dict]: + """Iterate over tasking orders. + + Parameters: + state: Filter by order state + limit: Maximum number of orders to return + **filters: Additional query parameters + + Yields: + Tasking order description + + Raises: + planet.exceptions.APIError: On API error. + """ + params = {} + if state: + params['state'] = state + params.update(filters) + + url = self._orders_url() + response = await self._session.request(method='GET', + url=url, + params=params) + async for order in TaskingOrders(response, self._session.request, limit=limit or 0): + yield order + + async def wait_order( + self, + order_id: str, + state: str = 'success', + delay: int = WAIT_DELAY, + max_attempts: int = WAIT_MAX_ATTEMPTS, + callback: Optional[Callable[[dict], None]] = None) -> str: + """Wait for a tasking order to reach a specified state. + + Parameters: + order_id: Order identifier. + state: Desired order state. Default is 'success'. + delay: Time (in seconds) between polls. Default is 5. + max_attempts: Maximum number of attempts. Default is 200. + callback: Function that handles order description. + + Returns: + Final state of order. + + Raises: + planet.exceptions.ClientError: If order_id is not a valid UUID + or state is not in the state sequence. + planet.exceptions.APIError: On API error. + """ + self._check_order_id(order_id) + + if state not in TaskingOrderStates.SEQUENCE: + raise exceptions.ClientError(f'{state} not a valid order state') + + # Check if we have passed the state we are waiting for + order = await self.get_order(order_id) + current_state = order['state'] + + if callback: + callback(order) + + if TaskingOrderStates.passed(state, current_state): + return current_state + + # Poll until state reached + attempt = 0 + while not TaskingOrderStates.reached(state, current_state): + + LOGGER.debug(f'Order {order_id} state: {current_state}. ' + f'Waiting for {state}.') + + if attempt >= max_attempts: + raise exceptions.APIError( + f'Maximum attempts reached waiting for order {order_id}') + + if TaskingOrderStates.is_final(current_state): + return current_state + + attempt += 1 + await asyncio.sleep(delay) + + order = await self.get_order(order_id) + current_state = order['state'] + + if callback: + callback(order) + + return current_state + + async def get_order_results(self, order_id: str) -> List[dict]: + """Get results for a completed tasking order. + + Parameters: + order_id: Order identifier. + + Returns: + List of result assets. + + Raises: + planet.exceptions.ClientError: If order_id is not a valid UUID. + planet.exceptions.APIError: On API error. + """ + self._check_order_id(order_id) + url = f'{self._orders_url(order_id)}/results' + response = await self._session.request(method='GET', url=url) + return response.json().get('results', []) diff --git a/planet/sync/client.py b/planet/sync/client.py index 993b35271..1dda7a9db 100644 --- a/planet/sync/client.py +++ b/planet/sync/client.py @@ -5,6 +5,7 @@ from .destinations import DestinationsAPI from .orders import OrdersAPI from .subscriptions import SubscriptionsAPI +from .tasking import TaskingAPI from planet.http import Session from planet.__version__ import __version__ from planet.constants import PLANET_BASE_URL @@ -24,6 +25,7 @@ class Planet: - `orders`: Orders API. - `subscriptions`: Subscriptions API. - `features`: Features API + - `tasking`: Tasking API Quick start example: ```python @@ -66,3 +68,4 @@ def __init__(self, self._session, f"{planet_base}/subscriptions/v1/") self.features = FeaturesAPI(self._session, f"{planet_base}/features/v1/ogc/my/") + self.tasking = TaskingAPI(self._session, f"{planet_base}/tasking/v2/") diff --git a/planet/sync/tasking.py b/planet/sync/tasking.py new file mode 100644 index 000000000..a4c92dbd8 --- /dev/null +++ b/planet/sync/tasking.py @@ -0,0 +1,170 @@ +# Copyright 2025 Planet Labs PBC. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. +"""Functionality for interacting with the tasking api""" +from typing import Any, Callable, Dict, Iterator, List, Optional + +from ..http import Session +from planet.clients import TaskingClient + + +class TaskingAPI: + """Tasking API client + + The Planet Tasking API is a programmatic interface that enables customers + to manage and request high-resolution imagery collection in an efficient + and automated way. + """ + + _client: TaskingClient + + def __init__(self, session: Session, base_url: Optional[str] = None): + """ + Parameters: + session: Open session connected to server. + base_url: The base URL to use. Defaults to production tasking API + base url. + """ + self._client = TaskingClient(session, base_url) + + def create_order(self, request: Dict) -> Dict: + """Create a tasking order. + + Example: + + ```python + + from planet import Planet + + def main(): + pl = Planet() + request = { + "name": "my_tasking_order", + "geometry": { + "type": "Point", + "coordinates": [-122.0, 37.0] + }, + "products": [{ + "item_type": "skysat_collect", + "asset_type": "ortho_analytic" + }] + } + order = pl.tasking.create_order(request) + ``` + + Parameters: + request: tasking order request definition + + Returns: + JSON description of the created tasking order + + Raises: + planet.exceptions.APIError: On API error. + """ + return self._client._call_sync(self._client.create_order(request)) + + def get_order(self, order_id: str) -> Dict: + """Get tasking order details by Order ID. + + Parameters: + order_id: The ID of the tasking order + + Returns: + JSON description of the tasking order + + Raises: + planet.exceptions.ClientError: If order_id is not a valid UUID. + planet.exceptions.APIError: On API error. + """ + return self._client._call_sync(self._client.get_order(order_id)) + + def cancel_order(self, order_id: str) -> Dict[str, Any]: + """Cancel a tasking order. + + Parameters: + order_id: The ID of the tasking order + + Returns: + Results of the cancel request + + Raises: + planet.exceptions.ClientError: If order_id is not a valid UUID. + planet.exceptions.APIError: On API error. + """ + return self._client._call_sync(self._client.cancel_order(order_id)) + + def list_orders(self, + state: Optional[str] = None, + limit: Optional[int] = None, + **filters) -> Iterator[Dict]: + """Iterate over tasking orders. + + Parameters: + state: Filter by order state + limit: Maximum number of orders to return + **filters: Additional query parameters + + Yields: + Tasking order description + + Raises: + planet.exceptions.APIError: On API error. + """ + return self._client._aiter_to_iter( + self._client.list_orders(state=state, limit=limit, **filters)) + + def wait_order(self, + order_id: str, + state: str = 'success', + delay: int = 5, + max_attempts: int = 200, + callback: Optional[Callable[[Dict], None]] = None) -> str: + """Wait for a tasking order to reach a specified state. + + Parameters: + order_id: Order identifier. + state: Desired order state. Default is 'success'. + delay: Time (in seconds) between polls. Default is 5. + max_attempts: Maximum number of attempts. Default is 200. + callback: Function that handles order description. + + Returns: + Final state of order. + + Raises: + planet.exceptions.ClientError: If order_id is not a valid UUID + or state is not in the state sequence. + planet.exceptions.APIError: On API error. + """ + return self._client._call_sync( + self._client.wait_order(order_id=order_id, + state=state, + delay=delay, + max_attempts=max_attempts, + callback=callback)) + + def get_order_results(self, order_id: str) -> List[Dict]: + """Get results for a completed tasking order. + + Parameters: + order_id: Order identifier. + + Returns: + List of result assets. + + Raises: + planet.exceptions.ClientError: If order_id is not a valid UUID. + planet.exceptions.APIError: On API error. + """ + return self._client._call_sync( + self._client.get_order_results(order_id)) diff --git a/tests/integration/test_tasking_api.py b/tests/integration/test_tasking_api.py new file mode 100644 index 000000000..fc0b335a7 --- /dev/null +++ b/tests/integration/test_tasking_api.py @@ -0,0 +1,279 @@ +# Copyright 2025 Planet Labs PBC. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. +"""Integration tests for the Tasking API.""" + +from http import HTTPStatus +import logging + +import httpx +import pytest +import respx + +from planet import TaskingClient, exceptions +from planet.clients.tasking import TaskingOrderStates +from planet.sync import Planet + +TEST_URL = 'http://www.MockNotRealURL.com/tasking/v2' +TEST_ORDERS_URL = f'{TEST_URL}/orders' + +LOGGER = logging.getLogger(__name__) + + +@pytest.fixture +def tasking_order_description(): + """Mock tasking order description.""" + return { + 'id': '550e8400-e29b-41d4-a716-446655440000', + 'name': 'test_tasking_order', + 'state': 'queued', + 'geometry': { + 'type': 'Point', 'coordinates': [-122.0, 37.0] + }, + 'products': [{ + 'item_type': 'skysat_collect', 'asset_type': 'ortho_analytic' + }], + 'created_on': '2023-01-01T00:00:00Z', + 'last_modified': '2023-01-01T00:00:00Z', + '_links': { + 'self': f'{TEST_ORDERS_URL}/550e8400-e29b-41d4-a716-446655440000' + } + } + + +@pytest.fixture +def tasking_order_request(): + """Mock tasking order request.""" + return { + 'name': 'test_tasking_order', + 'geometry': { + 'type': 'Point', 'coordinates': [-122.0, 37.0] + }, + 'products': [{ + 'item_type': 'skysat_collect', 'asset_type': 'ortho_analytic' + }] + } + + +@pytest.fixture +def tasking_orders_list(): + """Mock list of tasking orders.""" + return { + 'orders': [{ + 'id': '550e8400-e29b-41d4-a716-446655440000', + 'name': 'order1', + 'state': 'success' + }, + { + 'id': '550e8400-e29b-41d4-a716-446655440001', + 'name': 'order2', + 'state': 'running' + }] + } + + +def test_TaskingOrderStates_reached(): + """Test TaskingOrderStates.reached method.""" + assert not TaskingOrderStates.reached('running', 'queued') + assert TaskingOrderStates.reached('running', 'running') + assert TaskingOrderStates.reached('running', 'success') + + +def test_TaskingOrderStates_passed(): + """Test TaskingOrderStates.passed method.""" + assert not TaskingOrderStates.passed('running', 'queued') + assert not TaskingOrderStates.passed('running', 'running') + assert TaskingOrderStates.passed('running', 'success') + + +@respx.mock +@pytest.mark.anyio +async def test_create_order(tasking_order_request, + tasking_order_description, + session): + """Test creating a tasking order.""" + mock_resp = httpx.Response(HTTPStatus.CREATED, + json=tasking_order_description) + respx.post(TEST_ORDERS_URL).return_value = mock_resp + + client = TaskingClient(session, base_url=TEST_URL) + order = await client.create_order(tasking_order_request) + + assert order['id'] == '550e8400-e29b-41d4-a716-446655440000' + assert order['name'] == 'test_tasking_order' + assert order['state'] == 'queued' + + +@respx.mock +@pytest.mark.anyio +async def test_get_order(tasking_order_description, session): + """Test getting a tasking order.""" + order_id = '550e8400-e29b-41d4-a716-446655440000' + get_url = f'{TEST_ORDERS_URL}/{order_id}' + + mock_resp = httpx.Response(HTTPStatus.OK, json=tasking_order_description) + respx.get(get_url).return_value = mock_resp + + client = TaskingClient(session, base_url=TEST_URL) + order = await client.get_order(order_id) + + assert order['id'] == order_id + assert order['name'] == 'test_tasking_order' + + +@respx.mock +@pytest.mark.anyio +async def test_get_order_invalid_id(session): + """Test getting a tasking order with invalid ID.""" + client = TaskingClient(session, base_url=TEST_URL) + + with pytest.raises(exceptions.ClientError): + await client.get_order('invalid-id') + + +@respx.mock +@pytest.mark.anyio +async def test_cancel_order(tasking_order_description, session): + """Test cancelling a tasking order.""" + order_id = '550e8400-e29b-41d4-a716-446655440000' + cancel_url = f'{TEST_ORDERS_URL}/{order_id}' + + cancelled_order = tasking_order_description.copy() + cancelled_order['state'] = 'cancelled' + + mock_resp = httpx.Response(HTTPStatus.OK, json=cancelled_order) + respx.patch(cancel_url).return_value = mock_resp + + client = TaskingClient(session, base_url=TEST_URL) + order = await client.cancel_order(order_id) + + assert order['state'] == 'cancelled' + + +@respx.mock +@pytest.mark.anyio +async def test_list_orders(tasking_orders_list, session): + """Test listing tasking orders.""" + mock_resp = httpx.Response(HTTPStatus.OK, json=tasking_orders_list) + respx.get(TEST_ORDERS_URL).return_value = mock_resp + + client = TaskingClient(session, base_url=TEST_URL) + orders = [] + async for order in client.list_orders(): + orders.append(order) + + assert len(orders) == 2 + assert orders[0]['id'] == '550e8400-e29b-41d4-a716-446655440000' + assert orders[1]['id'] == '550e8400-e29b-41d4-a716-446655440001' + + +@respx.mock +@pytest.mark.anyio +async def test_list_orders_with_state_filter(tasking_orders_list, session): + """Test listing tasking orders with state filter.""" + mock_resp = httpx.Response(HTTPStatus.OK, json=tasking_orders_list) + respx.get(TEST_ORDERS_URL).return_value = mock_resp + + client = TaskingClient(session, base_url=TEST_URL) + orders = [] + async for order in client.list_orders(state='success'): + orders.append(order) + + assert len(orders) == 2 # Mock returns all, filtering would be server-side + + +@respx.mock +@pytest.mark.anyio +async def test_wait_order_success(tasking_order_description, session): + """Test waiting for order to reach success state.""" + order_id = '550e8400-e29b-41d4-a716-446655440000' + get_url = f'{TEST_ORDERS_URL}/{order_id}' + + # First call returns running state + running_order = tasking_order_description.copy() + running_order['state'] = 'running' + + # Second call returns success state + success_order = tasking_order_description.copy() + success_order['state'] = 'success' + + respx.get(get_url).side_effect = [ + httpx.Response(HTTPStatus.OK, json=running_order), + httpx.Response(HTTPStatus.OK, json=success_order) + ] + + client = TaskingClient(session, base_url=TEST_URL) + final_state = await client.wait_order(order_id, delay=0.1, max_attempts=5) + + assert final_state == 'success' + + +@respx.mock +@pytest.mark.anyio +async def test_wait_order_invalid_state(session): + """Test waiting for order with invalid state.""" + order_id = '550e8400-e29b-41d4-a716-446655440000' + + client = TaskingClient(session, base_url=TEST_URL) + + with pytest.raises(exceptions.ClientError): + await client.wait_order(order_id, state='invalid_state') + + +@respx.mock +@pytest.mark.anyio +async def test_get_order_results(session): + """Test getting tasking order results.""" + order_id = '550e8400-e29b-41d4-a716-446655440000' + results_url = f'{TEST_ORDERS_URL}/{order_id}/results' + + results_data = { + 'results': [{ + 'asset_type': 'ortho_analytic', + 'location': 'https://download.url/1' + }, + { + 'asset_type': 'ortho_visual', + 'location': 'https://download.url/2' + }] + } + + mock_resp = httpx.Response(HTTPStatus.OK, json=results_data) + respx.get(results_url).return_value = mock_resp + + client = TaskingClient(session, base_url=TEST_URL) + results = await client.get_order_results(order_id) + + assert len(results) == 2 + assert results[0]['asset_type'] == 'ortho_analytic' + + +# Sync client tests +def test_sync_tasking_create_order(): + """Test sync client tasking order creation.""" + pl = Planet() + assert hasattr(pl, 'tasking') + assert pl.tasking is not None + + +def test_sync_tasking_api_methods(): + """Test that sync tasking API has all expected methods.""" + pl = Planet() + + # Check that all expected methods exist + assert hasattr(pl.tasking, 'create_order') + assert hasattr(pl.tasking, 'get_order') + assert hasattr(pl.tasking, 'cancel_order') + assert hasattr(pl.tasking, 'list_orders') + assert hasattr(pl.tasking, 'wait_order') + assert hasattr(pl.tasking, 'get_order_results') diff --git a/tests/integration/test_tasking_cli.py b/tests/integration/test_tasking_cli.py new file mode 100644 index 000000000..4747a16f1 --- /dev/null +++ b/tests/integration/test_tasking_cli.py @@ -0,0 +1,285 @@ +# Copyright 2025 Planet Labs PBC. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. +"""Test Tasking CLI""" + +import json +from http import HTTPStatus + +from click.testing import CliRunner +import httpx +import pytest +import respx + +from planet.cli import cli + +TEST_URL = 'https://api.planet.com/tasking/v2' +TEST_ORDERS_URL = f'{TEST_URL}/orders' + + +@pytest.fixture +def invoke(): + """Helper to invoke CLI commands.""" + + def _invoke(extra_args, runner=None): + runner = runner or CliRunner() + args = ['tasking'] + extra_args + return runner.invoke(cli.main, args=args) + + return _invoke + + +@pytest.fixture +def tasking_order_json(): + """Sample tasking order JSON.""" + return { + 'id': '550e8400-e29b-41d4-a716-446655440000', + 'name': 'test_tasking_order', + 'state': 'queued', + 'geometry': { + 'type': 'Point', 'coordinates': [-122.0, 37.0] + }, + 'products': [{ + 'item_type': 'skysat_collect', 'asset_type': 'ortho_analytic' + }], + 'created_on': '2023-01-01T00:00:00Z', + 'last_modified': '2023-01-01T00:00:00Z' + } + + +@pytest.fixture +def tasking_orders_list_json(): + """Sample tasking orders list JSON.""" + return { + 'orders': [{ + 'id': '550e8400-e29b-41d4-a716-446655440000', + 'name': 'order1', + 'state': 'success' + }, + { + 'id': '550e8400-e29b-41d4-a716-446655440001', + 'name': 'order2', + 'state': 'running' + }] + } + + +@respx.mock +def test_create_order_with_name_and_geometry(invoke, tasking_order_json): + """Test creating a tasking order with name and geometry.""" + mock_resp = httpx.Response(HTTPStatus.CREATED, json=tasking_order_json) + respx.post(TEST_ORDERS_URL).return_value = mock_resp + + result = invoke([ + 'create-order', + '--name', + 'test_order', + '--geometry', + '{"type":"Point","coordinates":[-122,37]}' + ]) + + assert result.exit_code == 0 + assert 'test_tasking_order' in result.output + + +@respx.mock +def test_create_order_with_request_file(invoke, tasking_order_json, tmp_path): + """Test creating a tasking order with request file.""" + request_data = { + 'name': 'test_tasking_order', + 'geometry': { + 'type': 'Point', 'coordinates': [-122.0, 37.0] + }, + 'products': [{ + 'item_type': 'skysat_collect', 'asset_type': 'ortho_analytic' + }] + } + + request_file = tmp_path / 'request.json' + request_file.write_text(json.dumps(request_data)) + + mock_resp = httpx.Response(HTTPStatus.CREATED, json=tasking_order_json) + respx.post(TEST_ORDERS_URL).return_value = mock_resp + + result = invoke(['create-order', '--request', str(request_file)]) + + assert result.exit_code == 0 + assert 'test_tasking_order' in result.output + + +def test_create_order_missing_args(invoke): + """Test creating a tasking order with missing arguments.""" + result = invoke(['create-order']) + + assert result.exit_code != 0 + assert 'Either --request file or --name and --geometry must be provided' in result.output + + +@respx.mock +def test_get_order(invoke, tasking_order_json): + """Test getting a tasking order.""" + order_id = '550e8400-e29b-41d4-a716-446655440000' + get_url = f'{TEST_ORDERS_URL}/{order_id}' + + mock_resp = httpx.Response(HTTPStatus.OK, json=tasking_order_json) + respx.get(get_url).return_value = mock_resp + + result = invoke(['get-order', order_id]) + + assert result.exit_code == 0 + assert order_id in result.output + assert 'test_tasking_order' in result.output + + +@respx.mock +def test_cancel_order(invoke, tasking_order_json): + """Test cancelling a tasking order.""" + order_id = '550e8400-e29b-41d4-a716-446655440000' + cancel_url = f'{TEST_ORDERS_URL}/{order_id}' + + cancelled_order = tasking_order_json.copy() + cancelled_order['state'] = 'cancelled' + + mock_resp = httpx.Response(HTTPStatus.OK, json=cancelled_order) + respx.patch(cancel_url).return_value = mock_resp + + result = invoke(['cancel-order', order_id]) + + assert result.exit_code == 0 + assert 'cancelled' in result.output + + +@respx.mock +def test_list_orders(invoke, tasking_orders_list_json): + """Test listing tasking orders.""" + mock_resp = httpx.Response(HTTPStatus.OK, json=tasking_orders_list_json) + respx.get(TEST_ORDERS_URL).return_value = mock_resp + + result = invoke(['list-orders']) + + assert result.exit_code == 0 + assert '550e8400-e29b-41d4-a716-446655440000' in result.output + assert '550e8400-e29b-41d4-a716-446655440001' in result.output + + +@respx.mock +def test_list_orders_with_state_filter(invoke, tasking_orders_list_json): + """Test listing tasking orders with state filter.""" + mock_resp = httpx.Response(HTTPStatus.OK, json=tasking_orders_list_json) + respx.get(TEST_ORDERS_URL).return_value = mock_resp + + result = invoke(['list-orders', '--state', 'success']) + + assert result.exit_code == 0 + + +@respx.mock +def test_list_orders_with_limit(invoke, tasking_orders_list_json): + """Test listing tasking orders with limit.""" + mock_resp = httpx.Response(HTTPStatus.OK, json=tasking_orders_list_json) + respx.get(TEST_ORDERS_URL).return_value = mock_resp + + result = invoke(['list-orders', '--limit', '1']) + + assert result.exit_code == 0 + + +@respx.mock +def test_wait_order(invoke, tasking_order_json): + """Test waiting for a tasking order.""" + order_id = '550e8400-e29b-41d4-a716-446655440000' + get_url = f'{TEST_ORDERS_URL}/{order_id}' + + # Return success state immediately + success_order = tasking_order_json.copy() + success_order['state'] = 'success' + + mock_resp = httpx.Response(HTTPStatus.OK, json=success_order) + respx.get(get_url).return_value = mock_resp + + result = invoke( + ['wait-order', order_id, '--delay', '0.1', '--max-attempts', '1']) + + assert result.exit_code == 0 + assert 'success' in result.output + + +@respx.mock +def test_wait_order_with_custom_state(invoke, tasking_order_json): + """Test waiting for a tasking order with custom state.""" + order_id = '550e8400-e29b-41d4-a716-446655440000' + get_url = f'{TEST_ORDERS_URL}/{order_id}' + + # Return running state + running_order = tasking_order_json.copy() + running_order['state'] = 'running' + + mock_resp = httpx.Response(HTTPStatus.OK, json=running_order) + respx.get(get_url).return_value = mock_resp + + result = invoke([ + 'wait-order', + order_id, + '--state', + 'running', + '--delay', + '0.1', + '--max-attempts', + '1' + ]) + + assert result.exit_code == 0 + assert 'running' in result.output + + +@respx.mock +def test_get_results(invoke): + """Test getting tasking order results.""" + order_id = '550e8400-e29b-41d4-a716-446655440000' + results_url = f'{TEST_ORDERS_URL}/{order_id}/results' + + results_data = { + 'results': [{ + 'asset_type': 'ortho_analytic', + 'location': 'https://download.url/1' + }, + { + 'asset_type': 'ortho_visual', + 'location': 'https://download.url/2' + }] + } + + mock_resp = httpx.Response(HTTPStatus.OK, json=results_data) + respx.get(results_url).return_value = mock_resp + + result = invoke(['get-results', order_id]) + + assert result.exit_code == 0 + assert 'ortho_analytic' in result.output + assert 'ortho_visual' in result.output + + +def test_tasking_help(invoke): + """Test tasking command help.""" + result = invoke(['--help']) + + assert result.exit_code == 0 + assert 'tasking' in result.output + + +def test_create_order_help(invoke): + """Test create-order command help.""" + result = invoke(['create-order', '--help']) + + assert result.exit_code == 0 + assert 'Create a tasking order' in result.output diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index 27c4376ae..f036eaa21 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -35,6 +35,9 @@ def test_planet_default_initialization(self): assert pl.features is not None assert pl.features._client._base_url == "https://api.planet.com/features/v1/ogc/my" + assert pl.tasking is not None + assert pl.tasking._client._base_url == "https://api.planet.com/tasking/v2" + def test_planet_custom_base_url_initialization(self): """Test that Planet client accepts custom base URL.""" pl = Planet(base_url="https://custom.planet.com") @@ -48,5 +51,8 @@ def test_planet_custom_base_url_initialization(self): assert pl.subscriptions is not None assert pl.subscriptions._client._base_url == "https://custom.planet.com/subscriptions/v1" + assert pl.tasking is not None + assert pl.tasking._client._base_url == "https://custom.planet.com/tasking/v2" + assert pl.features is not None assert pl.features._client._base_url == "https://custom.planet.com/features/v1/ogc/my" diff --git a/tests/unit/test_tasking_client.py b/tests/unit/test_tasking_client.py new file mode 100644 index 000000000..bf3b7ea68 --- /dev/null +++ b/tests/unit/test_tasking_client.py @@ -0,0 +1,98 @@ +# Copyright 2025 Planet Labs PBC. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. +"""Tests for the TaskingClient.""" + +import pytest +from unittest.mock import Mock + +from planet.clients.tasking import TaskingClient, TaskingOrderStates +from planet import exceptions + + +class TestTaskingOrderStates: + """Test TaskingOrderStates helper class.""" + + def test_reached(self): + """Test that reached correctly identifies when a state has been reached.""" + assert not TaskingOrderStates.reached('running', 'queued') + assert TaskingOrderStates.reached('running', 'running') + assert TaskingOrderStates.reached('running', 'success') + assert TaskingOrderStates.reached('running', 'failed') + + def test_passed(self): + """Test that passed correctly identifies when a state has been passed.""" + assert not TaskingOrderStates.passed('running', 'queued') + assert not TaskingOrderStates.passed('running', 'running') + assert TaskingOrderStates.passed('running', 'success') + assert TaskingOrderStates.passed('running', 'failed') + + def test_is_final(self): + """Test that is_final correctly identifies final states.""" + assert not TaskingOrderStates.is_final('queued') + assert not TaskingOrderStates.is_final('running') + assert TaskingOrderStates.is_final('success') + assert TaskingOrderStates.is_final('failed') + assert TaskingOrderStates.is_final('cancelled') + + +class TestTaskingClient: + """Test TaskingClient functionality.""" + + def test_check_order_id_valid(self): + """Test that valid UUID order IDs pass validation.""" + valid_id = "550e8400-e29b-41d4-a716-446655440000" + # Should not raise exception + TaskingClient._check_order_id(valid_id) + + def test_check_order_id_invalid(self): + """Test that invalid order IDs raise ClientError.""" + invalid_ids = [ + "not-a-uuid", + "123", + "", + None, + "550e8400-e29b-41d4-a716-44665544000g", # invalid character + ] + + for invalid_id in invalid_ids: + with pytest.raises(exceptions.ClientError): + TaskingClient._check_order_id(invalid_id) + + def test_init_default_base_url(self): + """Test TaskingClient initialization with default base URL.""" + session = Mock() + client = TaskingClient(session) + assert client._base_url == "https://api.planet.com/tasking/v2" + + def test_init_custom_base_url(self): + """Test TaskingClient initialization with custom base URL.""" + session = Mock() + custom_url = "https://custom.planet.com/tasking/v2/" + client = TaskingClient(session, base_url=custom_url) + assert client._base_url == "https://custom.planet.com/tasking/v2" + + def test_orders_url_no_order_id(self): + """Test _orders_url method without order ID.""" + session = Mock() + client = TaskingClient(session) + url = client._orders_url() + assert url == "https://api.planet.com/tasking/v2/orders" + + def test_orders_url_with_order_id(self): + """Test _orders_url method with order ID.""" + session = Mock() + client = TaskingClient(session) + order_id = "550e8400-e29b-41d4-a716-446655440000" + url = client._orders_url(order_id) + assert url == f"https://api.planet.com/tasking/v2/orders/{order_id}"