From 3053261cabbd00df863bebb6060faebea94adbe4 Mon Sep 17 00:00:00 2001 From: James Harrison Date: Thu, 18 Jul 2024 10:07:07 +0100 Subject: [PATCH] feat: add `OtmV2.download_order()` method --- satellitevu/apis/helpers.py | 39 +++++++++++++++ satellitevu/apis/orders.py | 41 +--------------- satellitevu/apis/otm.py | 95 +++++++++++++++++++++++++++++++++++- satellitevu/apis/otm_test.py | 45 ++++++++++++++++- 4 files changed, 179 insertions(+), 41 deletions(-) create mode 100644 satellitevu/apis/helpers.py diff --git a/satellitevu/apis/helpers.py b/satellitevu/apis/helpers.py new file mode 100644 index 0000000..16fee6d --- /dev/null +++ b/satellitevu/apis/helpers.py @@ -0,0 +1,39 @@ +from io import BytesIO + +from satellitevu.http.base import ResponseWrapper + + +def raw_response_to_bytes(response: ResponseWrapper) -> BytesIO: + """ + Converts the raw response data from a request into a bytes object. + """ + raw_response = response.raw + + if isinstance(raw_response, bytes): + data = BytesIO(raw_response) + elif hasattr(raw_response, "read"): + data = BytesIO(raw_response.read()) + elif hasattr(raw_response, "iter_content"): + data = BytesIO() + for chunk in raw_response.iter_content(): + data.write(chunk) + data.seek(0) + else: + raise Exception( + ( + "Cannot convert response object with raw type" + f"{type(raw_response)} into byte stream." + ) + ) + + return data + + +def bytes_to_file(data: BytesIO, destfile: str) -> str: + """ + Converts bytes into a file object at the specified location. + """ + with open(destfile, "wb+") as f: + f.write(data.getbuffer()) + + return destfile diff --git a/satellitevu/apis/orders.py b/satellitevu/apis/orders.py index 1e896e0..26b2d54 100644 --- a/satellitevu/apis/orders.py +++ b/satellitevu/apis/orders.py @@ -1,49 +1,12 @@ import os -from io import BytesIO from time import sleep from typing import Dict, List, Union from uuid import UUID -from satellitevu.http.base import ResponseWrapper from .base import AbstractApi from .exceptions import OrdersAPIError - - -def raw_response_to_bytes(response: ResponseWrapper) -> BytesIO: - """ - Converts the raw response data from a request into a bytes object. - """ - raw_response = response.raw - - if isinstance(raw_response, bytes): - data = BytesIO(raw_response) - elif hasattr(raw_response, "read"): - data = BytesIO(raw_response.read()) - elif hasattr(raw_response, "iter_content"): - data = BytesIO() - for chunk in raw_response.iter_content(): - data.write(chunk) - data.seek(0) - else: - raise Exception( - ( - "Cannot convert response object with raw type" - f"{type(raw_response)} into byte stream." - ) - ) - - return data - - -def bytes_to_file(data: BytesIO, destfile: str) -> str: - """ - Converts bytes into a file object at the specified location. - """ - with open(destfile, "wb+") as f: - f.write(data.getbuffer()) - - return destfile +from .helpers import raw_response_to_bytes, bytes_to_file class OrdersV2(AbstractApi): @@ -186,7 +149,7 @@ def order_download_url( Args: contract_id: String or UUID representing the ID of the Contract - which an item in the order is associated with. + which the order is associated with. order_id: String or UUID representing the order id e.g. "2009466e-cccc-4712-a489-b09aeb772296". diff --git a/satellitevu/apis/otm.py b/satellitevu/apis/otm.py index e4701a1..3293d12 100644 --- a/satellitevu/apis/otm.py +++ b/satellitevu/apis/otm.py @@ -1,5 +1,7 @@ +import os from datetime import datetime -from typing import Any, List, Literal, Optional, Tuple, Union +from time import sleep +from typing import Any, List, Literal, Optional, Tuple, Union, Dict from uuid import UUID from .base import AbstractApi @@ -9,6 +11,7 @@ OTMOrderError, OTMParametersError, ) +from .helpers import raw_response_to_bytes, bytes_to_file MAX_CLOUD_COVER_DEFAULT = 15 MIN_OFF_NADIR_RANGE = [0, 45] @@ -646,3 +649,93 @@ def search( method="POST", url=url, json={k: v for k, v in payload.items() if v} ) return response.json() + + def _download_request( + self, + url: str, + retry_factor: float, + ): + """ + Request download, handling retries. + """ + while True: + response = self.make_request(method="GET", url=url) + + if response.status == 202: + sleep(retry_factor * int(response.headers["Retry-After"])) + elif response.status == 200: + break + + return response.json() + + def order_download_url( + self, + *, + contract_id: Union[UUID, str], + order_id: Union[UUID, str], + retry_factor: float = 1.0, + ) -> Dict: + """ + Finds the download url for a submitted tasking order. + + Args: + contract_id: String or UUID representing the ID of the Contract + which the order is associated with. + + order_id: String or UUID representing the order id e.g. + "2009466e-cccc-4712-a489-b09aeb772296". + + retry_factor: A float that determines how retries will be handled. + A factor of 0.5 means that only half the time specified by the + "Retry-After" header will be observed before the download request + is retried again. Defaults to 1.0. + + Returns: + A dictionary containing the url which the image can be downloaded from. + """ + url = self.url( + f"/{contract_id}/tasking/orders/{order_id}/download?redirect=False" + ) + + return self._download_request(url, retry_factor=retry_factor) + + def download_order( + self, + *, + contract_id: Union[UUID, str], + order_id: UUID, + destdir: str, + retry_factor: float = 1.0, + ): + """ + Downloads tasking order. + + Args: + contract_id: String or UUID representing the ID of the Contract + which an order is associated with. + + order_id: String or UUID representing the order id e.g. + "2009466e-cccc-4712-a489-b09aeb772296". + + destdir: A string (file path) representing the directory to which + the imagery will be downloaded. + + retry_factor: A float that determines how retries will be handled. + A factor of 0.5 means that only half the time specified by the + "Retry-After" header will be observed before the download request + is retried again. Defaults to 1.0. + + Returns: + A string specifying the path the imagery has been downloaded to. + All items will be downloaded into one ZIP file. + """ + order_url = self.order_download_url( + contract_id=contract_id, order_id=order_id, retry_factor=retry_factor + )["url"] + + response = self.make_request(method="GET", url=order_url) + + destfile = os.path.join(destdir, f"{order_id}.zip") + data = raw_response_to_bytes(response) + + return bytes_to_file(data, destfile) diff --git a/satellitevu/apis/otm_test.py b/satellitevu/apis/otm_test.py index 264ce06..e21f3ea 100644 --- a/satellitevu/apis/otm_test.py +++ b/satellitevu/apis/otm_test.py @@ -1,10 +1,11 @@ from json import dumps, loads from secrets import token_urlsafe +from unittest.mock import patch from urllib.parse import urlparse from uuid import uuid4 from mocket import Mocket, mocketize -from mocket.mockhttp import Entry +from mocket.mockhttp import Entry, Response from pytest import mark, raises from satellitevu.apis.exceptions import OTMOrderCancellationError, OTMParametersError @@ -456,3 +457,45 @@ def test_post_search( assert api_request.headers["Host"] == urlparse(client._gateway_url).hostname assert api_request.path == "/" + api_path assert api_request.headers["Authorization"] == oauth_token_entry + + +@mocketize(strict_mode=True) +def test_download_order( + client, + oauth_token_entry, + redirect_response, +): + contract_id = str(uuid4()) + api_path = API_PATH_ORDERS.replace("contract-id", str(contract_id)) + order_id = "528b0f77-5df1-4ed7-9224-502817170613" + download_dir = "downloads" + + Entry.register( + "GET", + client._gateway_url + f"{api_path}{order_id}/download?redirect=False", + Response(headers={"Retry-After": "1"}, status=202), + Response(body=dumps(redirect_response), status=200), + ) + Entry.single_register("GET", uri=redirect_response["url"]) + + with patch("satellitevu.apis.otm.bytes_to_file") as mock_file_dl: + mock_file_dl.return_value = f"{download_dir}/{order_id}.zip" + + response = client.otm_v2.download_order( + contract_id=contract_id, order_id=order_id, destdir=download_dir + ) + + requests = Mocket.request_list() + + assert len(requests) == 4 + + api_request = requests[1] + assert api_request.headers["Host"] == urlparse(client._gateway_url).hostname + assert api_request.path == f"/{api_path}{order_id}/download?redirect=False" + assert api_request.headers["Authorization"] == oauth_token_entry + + mock_file_dl.assert_called_once() + assert response == mock_file_dl() + assert isinstance(response, str) + + Mocket.assert_fail_if_entries_not_served()