diff --git a/planet/cli/cli.py b/planet/cli/cli.py index 467b1e5b..2100db95 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, reports, subscriptions, features LOGGER = logging.getLogger(__name__) @@ -126,6 +126,7 @@ def _configure_logging(verbosity): main.add_command(auth.cmd_auth) # type: ignore main.add_command(data.data) # type: ignore main.add_command(orders.orders) # type: ignore +main.add_command(reports.reports) # type: ignore main.add_command(subscriptions.subscriptions) # type: ignore main.add_command(collect.collect) # type: ignore main.add_command(features.features) # type: ignore diff --git a/planet/cli/reports.py b/planet/cli/reports.py new file mode 100644 index 00000000..a161bd0e --- /dev/null +++ b/planet/cli/reports.py @@ -0,0 +1,250 @@ +from contextlib import asynccontextmanager +import click +from click.exceptions import ClickException +import json +from pathlib import Path + +from planet.cli.io import echo_json +from planet.clients.reports import ReportsClient + +from .cmds import command +from .session import CliSession + + +@asynccontextmanager +async def reports_client(ctx): + async with CliSession() as sess: + cl = ReportsClient(sess, base_url=ctx.obj['BASE_URL']) + yield cl + + +@click.group() # type: ignore +@click.pass_context +@click.option('-u', + '--base-url', + default=None, + help='Assign custom base Reports API URL.') +def reports(ctx, base_url): + """Commands for interacting with the Reports API""" + ctx.obj['BASE_URL'] = base_url + + +async def _list_reports(ctx, + report_type, + start_date, + end_date, + limit, + offset, + pretty): + async with reports_client(ctx) as cl: + try: + response = await cl.list_reports(report_type=report_type, + start_date=start_date, + end_date=end_date, + limit=limit, + offset=offset) + echo_json(response, pretty) + except Exception as e: + raise ClickException(f"Failed to list reports: {e}") + + +async def _get_report(ctx, report_id, pretty): + async with reports_client(ctx) as cl: + try: + response = await cl.get_report(report_id) + echo_json(response, pretty) + except Exception as e: + raise ClickException(f"Failed to get report: {e}") + + +async def _create_report(ctx, request_file, pretty): + async with reports_client(ctx) as cl: + try: + if request_file: + with open(request_file, 'r') as f: + request_data = json.load(f) + else: + raise ClickException("Report configuration file is required") + + response = await cl.create_report(request_data) + echo_json(response, pretty) + except Exception as e: + raise ClickException(f"Failed to create report: {e}") + + +async def _download_report(ctx, report_id, output_file): + async with reports_client(ctx) as cl: + try: + content = await cl.download_report(report_id) + + if output_file: + output_path = Path(output_file) + output_path.write_bytes(content) + click.echo(f"Report downloaded to {output_path}") + else: + click.echo(content.decode('utf-8')) + except Exception as e: + raise ClickException(f"Failed to download report: {e}") + + +async def _get_report_status(ctx, report_id, pretty): + async with reports_client(ctx) as cl: + try: + response = await cl.get_report_status(report_id) + echo_json(response, pretty) + except Exception as e: + raise ClickException(f"Failed to get report status: {e}") + + +async def _delete_report(ctx, report_id, pretty): + async with reports_client(ctx) as cl: + try: + response = await cl.delete_report(report_id) + echo_json(response, pretty) + except Exception as e: + raise ClickException(f"Failed to delete report: {e}") + + +async def _list_report_types(ctx, pretty): + async with reports_client(ctx) as cl: + try: + response = await cl.list_report_types() + echo_json(response, pretty) + except Exception as e: + raise ClickException(f"Failed to list report types: {e}") + + +async def _get_report_export_formats(ctx, pretty): + async with reports_client(ctx) as cl: + try: + response = await cl.get_report_export_formats() + echo_json(response, pretty) + except Exception as e: + raise ClickException(f"Failed to get export formats: {e}") + + +@reports.command('list') # type: ignore +@click.pass_context +@click.option('--type', 'report_type', help='Filter by report type') +@click.option('--start-date', + help='Filter reports from this date (ISO 8601 format)') +@click.option('--end-date', + help='Filter reports to this date (ISO 8601 format)') +@click.option('--limit', type=int, help='Maximum number of reports to return') +@click.option('--offset', + type=int, + help='Number of reports to skip for pagination') +@click.option('--pretty', + is_flag=True, + default=False, + help='Format JSON output') +@command +async def list_reports_cmd(ctx, + report_type, + start_date, + end_date, + limit, + offset, + pretty): + """List available reports""" + await _list_reports(ctx, + report_type, + start_date, + end_date, + limit, + offset, + pretty) + + +@reports.command('get') # type: ignore +@click.pass_context +@click.argument('report_id') +@click.option('--pretty', + is_flag=True, + default=False, + help='Format JSON output') +@command +async def get_report_cmd(ctx, report_id, pretty): + """Get a specific report by ID""" + await _get_report(ctx, report_id, pretty) + + +@reports.command('create') # type: ignore +@click.pass_context +@click.option('--config', + 'request_file', + required=True, + type=click.Path(exists=True), + help='JSON file containing report configuration') +@click.option('--pretty', + is_flag=True, + default=False, + help='Format JSON output') +@command +async def create_report_cmd(ctx, request_file, pretty): + """Create a new report from configuration file""" + await _create_report(ctx, request_file, pretty) + + +@reports.command('download') # type: ignore +@click.pass_context +@click.argument('report_id') +@click.option( + '--output', + 'output_file', + type=click.Path(), + help='Output file path. If not specified, content is printed to stdout') +@command +async def download_report_cmd(ctx, report_id, output_file): + """Download a completed report""" + await _download_report(ctx, report_id, output_file) + + +@reports.command('status') # type: ignore +@click.pass_context +@click.argument('report_id') +@click.option('--pretty', + is_flag=True, + default=False, + help='Format JSON output') +@command +async def get_report_status_cmd(ctx, report_id, pretty): + """Get the status of a report""" + await _get_report_status(ctx, report_id, pretty) + + +@reports.command('delete') # type: ignore +@click.pass_context +@click.argument('report_id') +@click.option('--pretty', + is_flag=True, + default=False, + help='Format JSON output') +@command +async def delete_report_cmd(ctx, report_id, pretty): + """Delete a report""" + await _delete_report(ctx, report_id, pretty) + + +@reports.command('types') # type: ignore +@click.pass_context +@click.option('--pretty', + is_flag=True, + default=False, + help='Format JSON output') +@command +async def list_report_types_cmd(ctx, pretty): + """List available report types""" + await _list_report_types(ctx, pretty) + + +@reports.command('formats') # type: ignore +@click.pass_context +@click.option('--pretty', + is_flag=True, + default=False, + help='Format JSON output') +@command +async def get_report_export_formats_cmd(ctx, pretty): + """Get available export formats""" + await _get_report_export_formats(ctx, pretty) diff --git a/planet/clients/__init__.py b/planet/clients/__init__.py index 6aae646f..56ab3d22 100644 --- a/planet/clients/__init__.py +++ b/planet/clients/__init__.py @@ -17,6 +17,7 @@ from .features import FeaturesClient from .mosaics import MosaicsClient from .orders import OrdersClient +from .reports import ReportsClient from .subscriptions import SubscriptionsClient __all__ = [ @@ -25,6 +26,7 @@ 'FeaturesClient', 'MosaicsClient', 'OrdersClient', + 'ReportsClient', 'SubscriptionsClient' ] @@ -35,5 +37,6 @@ 'features': FeaturesClient, 'mosaics': MosaicsClient, 'orders': OrdersClient, + 'reports': ReportsClient, 'subscriptions': SubscriptionsClient } diff --git a/planet/clients/reports.py b/planet/clients/reports.py new file mode 100644 index 00000000..b1956405 --- /dev/null +++ b/planet/clients/reports.py @@ -0,0 +1,277 @@ +# 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. + +import logging +from typing import Any, Dict, List, Optional, TypeVar + +from planet.clients.base import _BaseClient +from planet.exceptions import APIError, ClientError +from planet.http import Session +from ..constants import PLANET_BASE_URL + +BASE_URL = f'{PLANET_BASE_URL}/reports/v1/' + +LOGGER = logging.getLogger() + +T = TypeVar("T") + + +class ReportsClient(_BaseClient): + """Asynchronous Reports API client. + + The Reports API allows Organization Administrators to download usage reports + systematically for internal processing and analysis. Reports downloaded from + the API are the exact same reports available from the user interface + accessible from your Account at www.planet.com/account. + + Example: + ```python + >>> import asyncio + >>> from planet import Session + >>> + >>> async def main(): + ... async with Session() as sess: + ... cl = sess.client('reports') + ... # use client here + ... + >>> asyncio.run(main()) + ``` + """ + + def __init__(self, + session: Session, + base_url: Optional[str] = None) -> None: + """ + Parameters: + session: Open session connected to server. + base_url: The base URL to use. Defaults to production reports + API base url. + """ + super().__init__(session, base_url or BASE_URL) + + async def list_reports(self, + report_type: Optional[str] = None, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + limit: Optional[int] = None, + offset: Optional[int] = None) -> Dict: + """ + List available reports. By default, all reports for the requesting user's org are returned. + + Args: + report_type (str, optional): Filter by report type (e.g., 'usage', 'billing', 'downloads'). + start_date (str, optional): Filter reports from this date (ISO 8601 format). + end_date (str, optional): Filter reports to this date (ISO 8601 format). + limit (int, optional): Maximum number of reports to return. + offset (int, optional): Number of reports to skip for pagination. + + Returns: + dict: A dictionary containing the list of reports and pagination info. + + Raises: + APIError: If the API returns an error response. + ClientError: If there is an issue with the client request. + """ + params: Dict[str, Any] = {} + if report_type is not None: + params["type"] = report_type + if start_date is not None: + params["start_date"] = start_date + if end_date is not None: + params["end_date"] = end_date + if limit is not None: + params["limit"] = limit + if offset is not None: + params["offset"] = offset + + try: + response = await self._session.request(method='GET', + url=self._base_url, + params=params) + except APIError: + raise + except ClientError: + raise + else: + reports_response = response.json() + return reports_response + + async def get_report(self, report_id: str) -> Dict: + """ + Get a specific report by its ID. + + Args: + report_id (str): The ID of the report to retrieve. + + Returns: + dict: A dictionary containing the report 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}/{report_id}' + try: + response = await self._session.request(method='GET', url=url) + except APIError: + raise + except ClientError: + raise + else: + report = response.json() + return report + + async def create_report(self, request: Dict[str, Any]) -> Dict: + """ + Create a new report. + + Args: + request (dict): Report configuration including type, date range, and other parameters. + + Returns: + dict: A dictionary containing the created report details including report ID. + + Raises: + APIError: If the API returns an error response. + ClientError: If there is an issue with the client request. + """ + try: + response = await self._session.request(method='POST', + url=self._base_url, + json=request) + except APIError: + raise + except ClientError: + raise + else: + report = response.json() + return report + + async def download_report(self, report_id: str) -> bytes: + """ + Download the content of a completed report. + + Args: + report_id (str): The ID of the report to download. + + Returns: + bytes: The report content as bytes. + + Raises: + APIError: If the API returns an error response. + ClientError: If there is an issue with the client request. + """ + url = f'{self._base_url}/{report_id}/download' + try: + response = await self._session.request(method='GET', url=url) + except APIError: + raise + except ClientError: + raise + else: + return response.content + + async def get_report_status(self, report_id: str) -> Dict: + """ + Get the status of a report. + + Args: + report_id (str): The ID of the report to check. + + Returns: + dict: A dictionary containing the report status information. + + Raises: + APIError: If the API returns an error response. + ClientError: If there is an issue with the client request. + """ + url = f'{self._base_url}/{report_id}/status' + try: + response = await self._session.request(method='GET', url=url) + except APIError: + raise + except ClientError: + raise + else: + status = response.json() + return status + + async def delete_report(self, report_id: str) -> Dict: + """ + Delete a report. + + Args: + report_id (str): The ID of the report to delete. + + Returns: + dict: A dictionary confirming the deletion. + + Raises: + APIError: If the API returns an error response. + ClientError: If there is an issue with the client request. + """ + url = f'{self._base_url}/{report_id}' + try: + response = await self._session.request(method='DELETE', url=url) + except APIError: + raise + except ClientError: + raise + else: + result = response.json() + return result + + async def list_report_types(self) -> List[Dict]: + """ + List available report types that can be generated. + + Returns: + list: A list of available report types with their descriptions and parameters. + + Raises: + APIError: If the API returns an error response. + ClientError: If there is an issue with the client request. + """ + url = f'{self._base_url}/types' + try: + response = await self._session.request(method='GET', url=url) + except APIError: + raise + except ClientError: + raise + else: + types = response.json() + return types + + async def get_report_export_formats(self) -> List[Dict]: + """ + List available export formats for reports. + + Returns: + list: A list of supported export formats (e.g., CSV, JSON, PDF). + + Raises: + APIError: If the API returns an error response. + ClientError: If there is an issue with the client request. + """ + url = f'{self._base_url}/formats' + try: + response = await self._session.request(method='GET', url=url) + except APIError: + raise + except ClientError: + raise + else: + formats = response.json() + return formats diff --git a/planet/sync/client.py b/planet/sync/client.py index 993b3527..698b7807 100644 --- a/planet/sync/client.py +++ b/planet/sync/client.py @@ -4,6 +4,7 @@ from .data import DataAPI from .destinations import DestinationsAPI from .orders import OrdersAPI +from .reports import ReportsAPI from .subscriptions import SubscriptionsAPI from planet.http import Session from planet.__version__ import __version__ @@ -22,6 +23,7 @@ class Planet: - `data`: for interacting with the Planet Data API. - `destinations`: Destinations API. - `orders`: Orders API. + - `reports`: Reports API. - `subscriptions`: Subscriptions API. - `features`: Features API @@ -62,6 +64,7 @@ def __init__(self, self.destinations = DestinationsAPI(self._session, f"{planet_base}/destinations/v1") self.orders = OrdersAPI(self._session, f"{planet_base}/compute/ops") + self.reports = ReportsAPI(self._session, f"{planet_base}/reports/v1/") self.subscriptions = SubscriptionsAPI( self._session, f"{planet_base}/subscriptions/v1/") self.features = FeaturesAPI(self._session, diff --git a/planet/sync/reports.py b/planet/sync/reports.py new file mode 100644 index 00000000..6368aa70 --- /dev/null +++ b/planet/sync/reports.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. + +from typing import Any, Dict, List, Optional +from planet.clients.reports import ReportsClient +from planet.http import Session + + +class ReportsAPI: + + _client: ReportsClient + + 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 Reports API + base url. + """ + + self._client = ReportsClient(session, base_url) + + def list_reports(self, + report_type: Optional[str] = None, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + limit: Optional[int] = None, + offset: Optional[int] = None) -> Dict: + """ + List available reports. By default, all reports for the requesting user's org are returned. + + Args: + report_type (str, optional): Filter by report type (e.g., 'usage', 'billing', 'downloads'). + start_date (str, optional): Filter reports from this date (ISO 8601 format). + end_date (str, optional): Filter reports to this date (ISO 8601 format). + limit (int, optional): Maximum number of reports to return. + offset (int, optional): Number of reports to skip for pagination. + + Returns: + dict: A dictionary containing the list of reports and pagination info. + + 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.list_reports(report_type, + start_date, + end_date, + limit, + offset)) + + def get_report(self, report_id: str) -> Dict: + """ + Get a specific report by its ID. + + Args: + report_id (str): The ID of the report to retrieve. + + Returns: + dict: A dictionary containing the report 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_report(report_id)) + + def create_report(self, request: Dict[str, Any]) -> Dict: + """ + Create a new report. + + Args: + request (dict): Report configuration including type, date range, and other parameters. + + Returns: + dict: A dictionary containing the created report details including report ID. + + 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.create_report(request)) + + def download_report(self, report_id: str) -> bytes: + """ + Download the content of a completed report. + + Args: + report_id (str): The ID of the report to download. + + Returns: + bytes: The report content as bytes. + + 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.download_report(report_id)) + + def get_report_status(self, report_id: str) -> Dict: + """ + Get the status of a report. + + Args: + report_id (str): The ID of the report to check. + + Returns: + dict: A dictionary containing the report status information. + + 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_report_status(report_id)) + + def delete_report(self, report_id: str) -> Dict: + """ + Delete a report. + + Args: + report_id (str): The ID of the report to delete. + + Returns: + dict: A dictionary confirming the deletion. + + 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.delete_report(report_id)) + + def list_report_types(self) -> List[Dict]: + """ + List available report types that can be generated. + + Returns: + list: A list of available report types with their descriptions and parameters. + + 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.list_report_types()) + + def get_report_export_formats(self) -> List[Dict]: + """ + List available export formats for reports. + + Returns: + list: A list of supported export formats (e.g., CSV, JSON, PDF). + + 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_report_export_formats()) diff --git a/tests/integration/test_reports_api.py b/tests/integration/test_reports_api.py new file mode 100644 index 00000000..16f13538 --- /dev/null +++ b/tests/integration/test_reports_api.py @@ -0,0 +1,183 @@ +# 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. + +import pytest +from planet import Session +from planet.sync import Planet + + +class TestReportsAPIIntegration: + """Integration tests for Reports API. + + These tests require valid Planet API credentials and should be run against + a test environment or with caution in production. + """ + + async def test_async_reports_client_list_reports(self): + """Test async ReportsClient list_reports method.""" + async with Session() as session: + client = session.client('reports') + + # Test basic list operation + response = await client.list_reports() + + assert isinstance(response, dict) + assert 'reports' in response or 'items' in response # API might use either key + + # Test with parameters + response_filtered = await client.list_reports(limit=5, offset=0) + + assert isinstance(response_filtered, dict) + + async def test_async_reports_client_list_report_types(self): + """Test async ReportsClient list_report_types method.""" + async with Session() as session: + client = session.client('reports') + + response = await client.list_report_types() + + assert isinstance(response, list) + if response: # If there are report types available + assert isinstance(response[0], dict) + + async def test_async_reports_client_get_export_formats(self): + """Test async ReportsClient get_report_export_formats method.""" + async with Session() as session: + client = session.client('reports') + + response = await client.get_report_export_formats() + + assert isinstance(response, list) + if response: # If there are export formats available + assert isinstance(response[0], dict) + + def test_sync_reports_api_list_reports(self): + """Test sync ReportsAPI list_reports method.""" + with Planet() as planet: + reports_api = planet.reports + + # Test basic list operation + response = reports_api.list_reports() + + assert isinstance(response, dict) + assert 'reports' in response or 'items' in response # API might use either key + + # Test with parameters + response_filtered = reports_api.list_reports(limit=5, offset=0) + + assert isinstance(response_filtered, dict) + + def test_sync_reports_api_list_report_types(self): + """Test sync ReportsAPI list_report_types method.""" + with Planet() as planet: + reports_api = planet.reports + + response = reports_api.list_report_types() + + assert isinstance(response, list) + if response: # If there are report types available + assert isinstance(response[0], dict) + + def test_sync_reports_api_get_export_formats(self): + """Test sync ReportsAPI get_report_export_formats method.""" + with Planet() as planet: + reports_api = planet.reports + + response = reports_api.get_report_export_formats() + + assert isinstance(response, list) + if response: # If there are export formats available + assert isinstance(response[0], dict) + + @pytest.mark.slow + async def test_async_reports_client_create_and_manage_report(self): + """Test async ReportsClient full workflow: create, check status, get, delete.""" + async with Session() as session: + client = session.client('reports') + + # Create a test report (this might fail if report type is not available) + try: + create_request = { + "type": "usage", + "start_date": "2024-01-01", + "end_date": "2024-01-31", + "format": "csv" + } + + create_response = await client.create_report(create_request) + assert isinstance(create_response, dict) + assert 'id' in create_response + + report_id = create_response['id'] + + # Check status + status_response = await client.get_report_status(report_id) + assert isinstance(status_response, dict) + assert 'status' in status_response + + # Get report details + report_response = await client.get_report(report_id) + assert isinstance(report_response, dict) + assert report_response['id'] == report_id + + # Clean up - delete the report + delete_response = await client.delete_report(report_id) + assert isinstance(delete_response, dict) + + except Exception as e: + # Some test environments might not support all report types + # or might have different permission requirements + pytest.skip( + f"Report creation not supported in test environment: {e}") + + @pytest.mark.slow + def test_sync_reports_api_create_and_manage_report(self): + """Test sync ReportsAPI full workflow: create, check status, get, delete.""" + with Planet() as planet: + reports_api = planet.reports + + # Create a test report (this might fail if report type is not available) + try: + create_request = { + "type": "usage", + "start_date": "2024-01-01", + "end_date": "2024-01-31", + "format": "csv" + } + + create_response = reports_api.create_report(create_request) + assert isinstance(create_response, dict) + assert 'id' in create_response + + report_id = create_response['id'] + + # Check status + status_response = reports_api.get_report_status(report_id) + assert isinstance(status_response, dict) + assert 'status' in status_response + + # Get report details + report_response = reports_api.get_report(report_id) + assert isinstance(report_response, dict) + assert report_response['id'] == report_id + + # Clean up - delete the report + delete_response = reports_api.delete_report(report_id) + assert isinstance(delete_response, dict) + + except Exception as e: + # Some test environments might not support all report types + # or might have different permission requirements + pytest.skip( + f"Report creation not supported in test environment: {e}") diff --git a/tests/integration/test_reports_cli.py b/tests/integration/test_reports_cli.py new file mode 100644 index 00000000..451a85fd --- /dev/null +++ b/tests/integration/test_reports_cli.py @@ -0,0 +1,210 @@ +# 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. + +import json +import pytest +import tempfile +from pathlib import Path +from click.testing import CliRunner + +from planet.cli.cli import cli + + +class TestReportsCLIIntegration: + """Integration tests for Reports CLI commands. + + These tests require valid Planet API credentials and should be run against + a test environment or with caution in production. + """ + + @pytest.fixture + def runner(self): + return CliRunner() + + def test_reports_list_command(self, runner): + """Test 'planet reports list' command.""" + result = runner.invoke(cli, ['reports', 'list']) + + # Should succeed or fail gracefully with proper error message + if result.exit_code == 0: + # If successful, output should be valid JSON + try: + output = json.loads(result.output) + assert isinstance(output, dict) + assert 'reports' in output or 'items' in output + except json.JSONDecodeError: + pytest.fail("Command succeeded but output is not valid JSON") + else: + # If failed, should have meaningful error message + assert result.output, "Command failed but provided no error message" + + def test_reports_list_with_parameters(self, runner): + """Test 'planet reports list' command with parameters.""" + result = runner.invoke( + cli, + ['reports', 'list', '--limit', '5', '--offset', '0', '--pretty']) + + # Should succeed or fail gracefully + if result.exit_code == 0: + try: + output = json.loads(result.output) + assert isinstance(output, dict) + except json.JSONDecodeError: + pytest.fail("Command succeeded but output is not valid JSON") + + def test_reports_types_command(self, runner): + """Test 'planet reports types' command.""" + result = runner.invoke(cli, ['reports', 'types']) + + if result.exit_code == 0: + try: + output = json.loads(result.output) + assert isinstance(output, list) + except json.JSONDecodeError: + pytest.fail("Command succeeded but output is not valid JSON") + + def test_reports_formats_command(self, runner): + """Test 'planet reports formats' command.""" + result = runner.invoke(cli, ['reports', 'formats']) + + if result.exit_code == 0: + try: + output = json.loads(result.output) + assert isinstance(output, list) + except json.JSONDecodeError: + pytest.fail("Command succeeded but output is not valid JSON") + + def test_reports_get_nonexistent(self, runner): + """Test 'planet reports get' command with non-existent report ID.""" + result = runner.invoke(cli, + ['reports', 'get', 'nonexistent-report-id']) + + # Should fail with appropriate error + assert result.exit_code != 0 + assert result.output, "Command failed but provided no error message" + + def test_reports_status_nonexistent(self, runner): + """Test 'planet reports status' command with non-existent report ID.""" + result = runner.invoke(cli, + ['reports', 'status', 'nonexistent-report-id']) + + # Should fail with appropriate error + assert result.exit_code != 0 + assert result.output, "Command failed but provided no error message" + + def test_reports_create_invalid_config(self, runner): + """Test 'planet reports create' command with invalid configuration.""" + invalid_config = {"invalid": "config"} + + with tempfile.NamedTemporaryFile(mode='w', + suffix='.json', + delete=False) as f: + json.dump(invalid_config, f) + config_file = f.name + + try: + result = runner.invoke( + cli, ['reports', 'create', '--config', config_file]) + + # Should fail with appropriate error (either validation or API error) + assert result.exit_code != 0 + assert result.output, "Command failed but provided no error message" + finally: + Path(config_file).unlink() + + def test_reports_create_missing_config_file(self, runner): + """Test 'planet reports create' command with missing configuration file.""" + result = runner.invoke( + cli, ['reports', 'create', '--config', 'nonexistent.json']) + + # Should fail with file not found error + assert result.exit_code != 0 + assert "nonexistent.json" in result.output or "does not exist" in result.output + + @pytest.mark.slow + def test_reports_full_workflow(self, runner): + """Test full workflow: create -> status -> get -> delete report.""" + # This test is marked as slow and might be skipped in environments + # where report creation is not supported + + config_data = { + "type": "usage", + "start_date": "2024-01-01", + "end_date": "2024-01-31", + "format": "csv" + } + + with tempfile.NamedTemporaryFile(mode='w', + suffix='.json', + delete=False) as f: + json.dump(config_data, f) + config_file = f.name + + try: + # Try to create a report + create_result = runner.invoke( + cli, ['reports', 'create', '--config', config_file]) + + if create_result.exit_code == 0: + # If creation succeeded, extract report ID and test other commands + try: + create_output = json.loads(create_result.output) + report_id = create_output.get('id') + + if report_id: + # Test status command + status_result = runner.invoke( + cli, ['reports', 'status', report_id]) + assert status_result.exit_code == 0 + + # Test get command + get_result = runner.invoke( + cli, ['reports', 'get', report_id]) + assert get_result.exit_code == 0 + + # Test delete command (cleanup) + delete_result = runner.invoke( + cli, ['reports', 'delete', report_id]) + assert delete_result.exit_code == 0 + + except (json.JSONDecodeError, KeyError): + pytest.fail( + "Report creation succeeded but response format is unexpected" + ) + else: + # If creation failed, skip the rest of the workflow test + pytest.skip( + f"Report creation not supported in test environment: {create_result.output}" + ) + + finally: + Path(config_file).unlink() + + def test_reports_help_commands(self, runner): + """Test that all reports subcommands have help text.""" + commands = [ + 'list', + 'get', + 'create', + 'download', + 'status', + 'delete', + 'types', + 'formats' + ] + + for command in commands: + result = runner.invoke(cli, ['reports', command, '--help']) + assert result.exit_code == 0 + assert '--help' in result.output or 'Usage:' in result.output diff --git a/tests/unit/test_reports_cli.py b/tests/unit/test_reports_cli.py new file mode 100644 index 00000000..b58a8226 --- /dev/null +++ b/tests/unit/test_reports_cli.py @@ -0,0 +1,307 @@ +# 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. + +import json +import pytest +import tempfile +from pathlib import Path +from unittest.mock import AsyncMock, Mock, patch +from click.testing import CliRunner + +from planet.cli.reports import (reports, + list_reports_cmd, + get_report_cmd, + create_report_cmd, + download_report_cmd, + get_report_status_cmd, + delete_report_cmd, + list_report_types_cmd, + get_report_export_formats_cmd) + + +@pytest.fixture +def runner(): + return CliRunner() + + +@pytest.fixture +def mock_ctx(): + ctx = Mock() + ctx.obj = {'BASE_URL': None} + return ctx + + +class TestReportsCLI: + + def test_reports_group_default_base_url(self, runner): + result = runner.invoke(reports, ['--help']) + assert result.exit_code == 0 + assert 'Commands for interacting with the Reports API' in result.output + + def test_reports_group_custom_base_url(self, runner): + custom_url = 'https://custom.api.com/reports/v1/' + result = runner.invoke(reports, ['-u', custom_url, '--help']) + assert result.exit_code == 0 + + @patch('planet.cli.reports.reports_client') + @patch('planet.cli.reports.echo_json') + def test_list_reports_cmd_no_params(self, + mock_echo_json, + mock_reports_client, + runner, + mock_ctx): + mock_client = AsyncMock() + mock_client.list_reports.return_value = {"reports": [], "total": 0} + mock_reports_client.return_value.__aenter__.return_value = mock_client + + result = runner.invoke(list_reports_cmd, [], obj=mock_ctx.obj) + + assert result.exit_code == 0 + mock_client.list_reports.assert_called_once_with(report_type=None, + start_date=None, + end_date=None, + limit=None, + offset=None) + + @patch('planet.cli.reports.reports_client') + @patch('planet.cli.reports.echo_json') + def test_list_reports_cmd_with_params(self, + mock_echo_json, + mock_reports_client, + runner, + mock_ctx): + mock_client = AsyncMock() + mock_client.list_reports.return_value = { + "reports": [{ + "id": "report1" + }], "total": 1 + } + mock_reports_client.return_value.__aenter__.return_value = mock_client + + result = runner.invoke(list_reports_cmd, + [ + '--type', + 'usage', + '--start-date', + '2024-01-01', + '--end-date', + '2024-01-31', + '--limit', + '10', + '--offset', + '0', + '--pretty' + ], + obj=mock_ctx.obj) + + assert result.exit_code == 0 + mock_client.list_reports.assert_called_once_with( + report_type='usage', + start_date='2024-01-01', + end_date='2024-01-31', + limit=10, + offset=0) + + @patch('planet.cli.reports.reports_client') + @patch('planet.cli.reports.echo_json') + def test_get_report_cmd(self, + mock_echo_json, + mock_reports_client, + runner, + mock_ctx): + mock_client = AsyncMock() + mock_client.get_report.return_value = { + "id": "report123", "type": "usage" + } + mock_reports_client.return_value.__aenter__.return_value = mock_client + + result = runner.invoke(get_report_cmd, ['report123'], obj=mock_ctx.obj) + + assert result.exit_code == 0 + mock_client.get_report.assert_called_once_with('report123') + + @patch('planet.cli.reports.reports_client') + @patch('planet.cli.reports.echo_json') + def test_create_report_cmd(self, + mock_echo_json, + mock_reports_client, + runner, + mock_ctx): + mock_client = AsyncMock() + mock_client.create_report.return_value = { + "id": "report123", "status": "pending" + } + mock_reports_client.return_value.__aenter__.return_value = mock_client + + config_data = { + "type": "usage", + "start_date": "2024-01-01", + "end_date": "2024-01-31" + } + + with tempfile.NamedTemporaryFile(mode='w', + suffix='.json', + delete=False) as f: + json.dump(config_data, f) + config_file = f.name + + try: + result = runner.invoke(create_report_cmd, + ['--config', config_file], + obj=mock_ctx.obj) + assert result.exit_code == 0 + mock_client.create_report.assert_called_once_with(config_data) + finally: + Path(config_file).unlink() + + def test_create_report_cmd_missing_config(self, runner, mock_ctx): + result = runner.invoke(create_report_cmd, [], obj=mock_ctx.obj) + assert result.exit_code != 0 + assert 'Missing option "--config"' in result.output + + @patch('planet.cli.reports.reports_client') + @patch('planet.cli.reports.click.echo') + def test_download_report_cmd_to_file(self, + mock_echo, + mock_reports_client, + runner, + mock_ctx): + mock_client = AsyncMock() + mock_client.download_report.return_value = b"report,data\nvalue1,value2\n" + mock_reports_client.return_value.__aenter__.return_value = mock_client + + with tempfile.NamedTemporaryFile(delete=False) as f: + output_file = f.name + + try: + result = runner.invoke(download_report_cmd, + ['report123', '--output', output_file], + obj=mock_ctx.obj) + + assert result.exit_code == 0 + mock_client.download_report.assert_called_once_with('report123') + + # Verify file was written + content = Path(output_file).read_bytes() + assert content == b"report,data\nvalue1,value2\n" + finally: + Path(output_file).unlink() + + @patch('planet.cli.reports.reports_client') + @patch('planet.cli.reports.click.echo') + def test_download_report_cmd_to_stdout(self, + mock_echo, + mock_reports_client, + runner, + mock_ctx): + mock_client = AsyncMock() + mock_client.download_report.return_value = b"report,data\nvalue1,value2\n" + mock_reports_client.return_value.__aenter__.return_value = mock_client + + result = runner.invoke(download_report_cmd, ['report123'], + obj=mock_ctx.obj) + + assert result.exit_code == 0 + mock_client.download_report.assert_called_once_with('report123') + mock_echo.assert_called_with("report,data\nvalue1,value2\n") + + @patch('planet.cli.reports.reports_client') + @patch('planet.cli.reports.echo_json') + def test_get_report_status_cmd(self, + mock_echo_json, + mock_reports_client, + runner, + mock_ctx): + mock_client = AsyncMock() + mock_client.get_report_status.return_value = { + "id": "report123", "status": "processing" + } + mock_reports_client.return_value.__aenter__.return_value = mock_client + + result = runner.invoke(get_report_status_cmd, ['report123'], + obj=mock_ctx.obj) + + assert result.exit_code == 0 + mock_client.get_report_status.assert_called_once_with('report123') + + @patch('planet.cli.reports.reports_client') + @patch('planet.cli.reports.echo_json') + def test_delete_report_cmd(self, + mock_echo_json, + mock_reports_client, + runner, + mock_ctx): + mock_client = AsyncMock() + mock_client.delete_report.return_value = { + "message": "Report deleted successfully" + } + mock_reports_client.return_value.__aenter__.return_value = mock_client + + result = runner.invoke(delete_report_cmd, ['report123'], + obj=mock_ctx.obj) + + assert result.exit_code == 0 + mock_client.delete_report.assert_called_once_with('report123') + + @patch('planet.cli.reports.reports_client') + @patch('planet.cli.reports.echo_json') + def test_list_report_types_cmd(self, + mock_echo_json, + mock_reports_client, + runner, + mock_ctx): + mock_client = AsyncMock() + mock_client.list_report_types.return_value = [{ + "type": "usage" + }, { + "type": "billing" + }] + mock_reports_client.return_value.__aenter__.return_value = mock_client + + result = runner.invoke(list_report_types_cmd, [], obj=mock_ctx.obj) + + assert result.exit_code == 0 + mock_client.list_report_types.assert_called_once() + + @patch('planet.cli.reports.reports_client') + @patch('planet.cli.reports.echo_json') + def test_get_report_export_formats_cmd(self, + mock_echo_json, + mock_reports_client, + runner, + mock_ctx): + mock_client = AsyncMock() + mock_client.get_report_export_formats.return_value = [{ + "format": "csv" + }, { + "format": "json" + }] + mock_reports_client.return_value.__aenter__.return_value = mock_client + + result = runner.invoke(get_report_export_formats_cmd, [], + obj=mock_ctx.obj) + + assert result.exit_code == 0 + mock_client.get_report_export_formats.assert_called_once() + + @patch('planet.cli.reports.reports_client') + def test_error_handling(self, mock_reports_client, runner, mock_ctx): + mock_client = AsyncMock() + mock_client.list_reports.side_effect = Exception("API Error") + mock_reports_client.return_value.__aenter__.return_value = mock_client + + result = runner.invoke(list_reports_cmd, [], obj=mock_ctx.obj) + + assert result.exit_code != 0 + assert "Failed to list reports: API Error" in result.output diff --git a/tests/unit/test_reports_client.py b/tests/unit/test_reports_client.py new file mode 100644 index 00000000..3aa45f55 --- /dev/null +++ b/tests/unit/test_reports_client.py @@ -0,0 +1,225 @@ +# 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. + +import pytest +from unittest.mock import AsyncMock, Mock +from planet.clients.reports import ReportsClient +from planet.exceptions import APIError, ClientError + + +@pytest.fixture +def mock_session(): + session = Mock() + session.request = AsyncMock() + return session + + +@pytest.fixture +def reports_client(mock_session): + return ReportsClient(mock_session) + + +class TestReportsClient: + + async def test_list_reports_no_params(self, reports_client, mock_session): + expected_response = { + "reports": [], "total": 0, "limit": 50, "offset": 0 + } + mock_response = Mock() + mock_response.json.return_value = expected_response + mock_session.request.return_value = mock_response + + result = await reports_client.list_reports() + + mock_session.request.assert_called_once_with( + method='GET', url='https://api.planet.com/reports/v1/', params={}) + assert result == expected_response + + async def test_list_reports_with_params(self, reports_client, + mock_session): + expected_response = { + "reports": [{ + "id": "report1", "type": "usage" + }], + "total": 1, + "limit": 10, + "offset": 0 + } + mock_response = Mock() + mock_response.json.return_value = expected_response + mock_session.request.return_value = mock_response + + result = await reports_client.list_reports(report_type="usage", + start_date="2024-01-01", + end_date="2024-01-31", + limit=10, + offset=0) + + mock_session.request.assert_called_once_with( + method='GET', + url='https://api.planet.com/reports/v1/', + params={ + "type": "usage", + "start_date": "2024-01-01", + "end_date": "2024-01-31", + "limit": 10, + "offset": 0 + }) + assert result == expected_response + + async def test_get_report(self, reports_client, mock_session): + report_id = "report123" + expected_response = { + "id": report_id, + "type": "usage", + "status": "completed", + "created_at": "2024-01-01T00:00:00Z" + } + mock_response = Mock() + mock_response.json.return_value = expected_response + mock_session.request.return_value = mock_response + + result = await reports_client.get_report(report_id) + + mock_session.request.assert_called_once_with( + method='GET', url=f'https://api.planet.com/reports/v1/{report_id}') + assert result == expected_response + + async def test_create_report(self, reports_client, mock_session): + request_data = { + "type": "usage", + "start_date": "2024-01-01", + "end_date": "2024-01-31", + "format": "csv" + } + expected_response = { + "id": "report123", + "type": "usage", + "status": "pending", + "created_at": "2024-01-01T00:00:00Z" + } + mock_response = Mock() + mock_response.json.return_value = expected_response + mock_session.request.return_value = mock_response + + result = await reports_client.create_report(request_data) + + mock_session.request.assert_called_once_with( + method='POST', + url='https://api.planet.com/reports/v1/', + json=request_data) + assert result == expected_response + + async def test_download_report(self, reports_client, mock_session): + report_id = "report123" + expected_content = b"report,data\nvalue1,value2\n" + mock_response = Mock() + mock_response.content = expected_content + mock_session.request.return_value = mock_response + + result = await reports_client.download_report(report_id) + + mock_session.request.assert_called_once_with( + method='GET', + url=f'https://api.planet.com/reports/v1/{report_id}/download') + assert result == expected_content + + async def test_get_report_status(self, reports_client, mock_session): + report_id = "report123" + expected_response = { + "id": report_id, "status": "processing", "progress": 50 + } + mock_response = Mock() + mock_response.json.return_value = expected_response + mock_session.request.return_value = mock_response + + result = await reports_client.get_report_status(report_id) + + mock_session.request.assert_called_once_with( + method='GET', + url=f'https://api.planet.com/reports/v1/{report_id}/status') + assert result == expected_response + + async def test_delete_report(self, reports_client, mock_session): + report_id = "report123" + expected_response = {"message": "Report deleted successfully"} + mock_response = Mock() + mock_response.json.return_value = expected_response + mock_session.request.return_value = mock_response + + result = await reports_client.delete_report(report_id) + + mock_session.request.assert_called_once_with( + method='DELETE', + url=f'https://api.planet.com/reports/v1/{report_id}') + assert result == expected_response + + async def test_list_report_types(self, reports_client, mock_session): + expected_response = [{ + "type": "usage", "description": "Usage report" + }, + { + "type": "billing", + "description": "Billing report" + }] + mock_response = Mock() + mock_response.json.return_value = expected_response + mock_session.request.return_value = mock_response + + result = await reports_client.list_report_types() + + mock_session.request.assert_called_once_with( + method='GET', url='https://api.planet.com/reports/v1/types') + assert result == expected_response + + async def test_get_report_export_formats(self, + reports_client, + mock_session): + expected_response = [{ + "format": "csv", "description": "Comma-separated values" + }, + { + "format": "json", + "description": "JavaScript Object Notation" + }] + mock_response = Mock() + mock_response.json.return_value = expected_response + mock_session.request.return_value = mock_response + + result = await reports_client.get_report_export_formats() + + mock_session.request.assert_called_once_with( + method='GET', url='https://api.planet.com/reports/v1/formats') + assert result == expected_response + + async def test_api_error_handling(self, reports_client, mock_session): + mock_session.request.side_effect = APIError("API Error") + + with pytest.raises(APIError): + await reports_client.list_reports() + + async def test_client_error_handling(self, reports_client, mock_session): + mock_session.request.side_effect = ClientError("Client Error") + + with pytest.raises(ClientError): + await reports_client.get_report("report123") + + def test_init_default_base_url(self, mock_session): + client = ReportsClient(mock_session) + assert client._base_url == 'https://api.planet.com/reports/v1' + + def test_init_custom_base_url(self, mock_session): + custom_url = 'https://custom.api.com/reports/v1/' + client = ReportsClient(mock_session, custom_url) + assert client._base_url == 'https://custom.api.com/reports/v1' diff --git a/tests/unit/test_reports_sync.py b/tests/unit/test_reports_sync.py new file mode 100644 index 00000000..8ceb2784 --- /dev/null +++ b/tests/unit/test_reports_sync.py @@ -0,0 +1,138 @@ +# 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. + +import pytest +from unittest.mock import Mock, patch +from planet.sync.reports import ReportsAPI + + +@pytest.fixture +def mock_session(): + return Mock() + + +@pytest.fixture +def reports_api(mock_session): + return ReportsAPI(mock_session) + + +class TestReportsAPI: + + @patch('planet.sync.reports.ReportsClient') + def test_init(self, mock_reports_client, mock_session): + ReportsAPI(mock_session) + mock_reports_client.assert_called_once_with(mock_session, None) + + @patch('planet.sync.reports.ReportsClient') + def test_init_with_base_url(self, mock_reports_client, mock_session): + custom_url = 'https://custom.api.com/reports/v1/' + ReportsAPI(mock_session, custom_url) + mock_reports_client.assert_called_once_with(mock_session, custom_url) + + def test_list_reports(self, reports_api): + expected_result = {"reports": [], "total": 0} + reports_api._client.list_reports.return_value = "async_coroutine" + reports_api._client._call_sync.return_value = expected_result + + result = reports_api.list_reports(report_type="usage", + start_date="2024-01-01", + end_date="2024-01-31", + limit=10, + offset=0) + + reports_api._client._call_sync.assert_called_once_with( + "async_coroutine") + assert result == expected_result + + def test_get_report(self, reports_api): + report_id = "report123" + expected_result = {"id": report_id, "type": "usage"} + reports_api._client.get_report.return_value = "async_coroutine" + reports_api._client._call_sync.return_value = expected_result + + result = reports_api.get_report(report_id) + + reports_api._client._call_sync.assert_called_once_with( + "async_coroutine") + assert result == expected_result + + def test_create_report(self, reports_api): + request_data = {"type": "usage", "start_date": "2024-01-01"} + expected_result = {"id": "report123", "status": "pending"} + reports_api._client.create_report.return_value = "async_coroutine" + reports_api._client._call_sync.return_value = expected_result + + result = reports_api.create_report(request_data) + + reports_api._client._call_sync.assert_called_once_with( + "async_coroutine") + assert result == expected_result + + def test_download_report(self, reports_api): + report_id = "report123" + expected_result = b"report,data\nvalue1,value2\n" + reports_api._client.download_report.return_value = "async_coroutine" + reports_api._client._call_sync.return_value = expected_result + + result = reports_api.download_report(report_id) + + reports_api._client._call_sync.assert_called_once_with( + "async_coroutine") + assert result == expected_result + + def test_get_report_status(self, reports_api): + report_id = "report123" + expected_result = {"id": report_id, "status": "processing"} + reports_api._client.get_report_status.return_value = "async_coroutine" + reports_api._client._call_sync.return_value = expected_result + + result = reports_api.get_report_status(report_id) + + reports_api._client._call_sync.assert_called_once_with( + "async_coroutine") + assert result == expected_result + + def test_delete_report(self, reports_api): + report_id = "report123" + expected_result = {"message": "Report deleted successfully"} + reports_api._client.delete_report.return_value = "async_coroutine" + reports_api._client._call_sync.return_value = expected_result + + result = reports_api.delete_report(report_id) + + reports_api._client._call_sync.assert_called_once_with( + "async_coroutine") + assert result == expected_result + + def test_list_report_types(self, reports_api): + expected_result = [{"type": "usage"}, {"type": "billing"}] + reports_api._client.list_report_types.return_value = "async_coroutine" + reports_api._client._call_sync.return_value = expected_result + + result = reports_api.list_report_types() + + reports_api._client._call_sync.assert_called_once_with( + "async_coroutine") + assert result == expected_result + + def test_get_report_export_formats(self, reports_api): + expected_result = [{"format": "csv"}, {"format": "json"}] + reports_api._client.get_report_export_formats.return_value = "async_coroutine" + reports_api._client._call_sync.return_value = expected_result + + result = reports_api.get_report_export_formats() + + reports_api._client._call_sync.assert_called_once_with( + "async_coroutine") + assert result == expected_result