From e7ae96474b45dcad850effa1ae14d9bd902f05a1 Mon Sep 17 00:00:00 2001 From: Jaap Vermeulen Date: Wed, 1 Apr 2020 13:36:19 -0600 Subject: [PATCH] [ES-53] Allow deriving from Session and JsonApiSession (#6631) GitOrigin-RevId: 0fe426a00fe8eaa86d8ce8d4ba543805fb16610a --- descarteslabs/catalog/catalog_client.py | 58 +- descarteslabs/client/exceptions.py | 6 + .../client/services/catalog/catalog.py | 14 +- .../client/services/metadata/readme.rst | 2 +- .../client/services/service/__init__.py | 18 +- .../client/services/service/readme.rst | 38 + .../client/services/service/service.py | 738 ++++++++++++++++-- .../services/service/tests/test_service.py | 476 ++++++++++- descarteslabs/scenes/scene.py | 10 +- descarteslabs/scenes/scenecollection.py | 16 +- 10 files changed, 1233 insertions(+), 143 deletions(-) create mode 100644 descarteslabs/client/services/service/readme.rst diff --git a/descarteslabs/catalog/catalog_client.py b/descarteslabs/catalog/catalog_client.py index 6077b48d..4731706c 100644 --- a/descarteslabs/catalog/catalog_client.py +++ b/descarteslabs/catalog/catalog_client.py @@ -1,14 +1,11 @@ # jsonapi_document -import json import os from descarteslabs.client.auth import Auth -from descarteslabs.client.exceptions import ClientError from descarteslabs.client.services.service.service import ( JsonApiService, - JsonApiSession, HttpRequestMethod, ) @@ -16,59 +13,6 @@ HttpRequestMethod = HttpRequestMethod -class _RewriteErrorSession(JsonApiSession): - """Rewrite JSON ClientErrors that are returned to make them easier to read""" - - def request(self, *args, **kwargs): - try: - return super(_RewriteErrorSession, self).request(*args, **kwargs) - except ClientError as client_error: - self._rewrite_error(client_error) - raise - - def _rewrite_error(self, client_error): - KEY_ERRORS = "errors" - KEY_TITLE = "title" - KEY_STATUS = "status" - KEY_DETAIL = "detail" - KEY_SOURCE = "source" - KEY_POINTER = "pointer" - message = "" - - for arg in client_error.args: - try: - errors = json.loads(arg)[KEY_ERRORS] - - for error in errors: - line = "" - seperator = "" - - if KEY_TITLE in error: - line += error[KEY_TITLE] - seperator = ": " - elif KEY_STATUS in error: - line += error[KEY_STATUS] - seperator = ": " - - if KEY_DETAIL in error: - line += seperator + error[KEY_DETAIL].strip(".") - seperator = ": " - - if KEY_SOURCE in error: - source = error[KEY_SOURCE] - if KEY_POINTER in source: - source = source[KEY_POINTER].split("/")[-1] - line += seperator + source - - if line: - message += "\n " + line - except Exception: - return - - if message: - client_error.args = (message,) - - class CatalogClient(JsonApiService): """ The CatalogClient handles the HTTP communication with the Descartes Labs catalog. @@ -106,7 +50,7 @@ def __init__(self, url=None, auth=None, retries=None): ) super(CatalogClient, self).__init__( - url, auth=auth, retries=retries, session_class=_RewriteErrorSession + url, auth=auth, retries=retries, rewrite_errors=True ) @staticmethod diff --git a/descarteslabs/client/exceptions.py b/descarteslabs/client/exceptions.py index 014ca340..fdb83e5a 100644 --- a/descarteslabs/client/exceptions.py +++ b/descarteslabs/client/exceptions.py @@ -49,6 +49,12 @@ class NotFoundError(ClientError): status = 404 +class ProxyAuthenticationRequiredError(ClientError): + """Client request needs proxy authentication.""" + + status = 407 + + class ConflictError(ClientError): """Client request conflicts with existing state.""" diff --git a/descarteslabs/client/services/catalog/catalog.py b/descarteslabs/client/services/catalog/catalog.py index 98b7e450..c0cdc751 100644 --- a/descarteslabs/client/services/catalog/catalog.py +++ b/descarteslabs/client/services/catalog/catalog.py @@ -1455,7 +1455,11 @@ def upload_image( ) else: failed, upload_id, error = self._do_upload( - files, product_id, image_id, metadata=metadata, add_namespace=add_namespace + files, + product_id, + image_id, + metadata=metadata, + add_namespace=add_namespace, ) if failed: @@ -1770,7 +1774,9 @@ def _do_multi_file_upload( return failed, upload_id, error - def _do_upload(self, file_ish, product_id, image_id=None, metadata=None, add_namespace=False): + def _do_upload( + self, file_ish, product_id, image_id=None, metadata=None, add_namespace=False + ): # kwargs are treated as metadata fields and restricted to primitives # for the key val pairs. fd = None @@ -1808,7 +1814,9 @@ def _do_upload(self, file_ish, product_id, image_id=None, metadata=None, add_nam return True, upload_id, e try: - upload_id = image_id or metadata.pop("image_id", None) or os.path.basename(fd.name) + upload_id = ( + image_id or metadata.pop("image_id", None) or os.path.basename(fd.name) + ) r = self.session.post( "/products/{}/images/upload/{}".format(product_id, upload_id), diff --git a/descarteslabs/client/services/metadata/readme.rst b/descarteslabs/client/services/metadata/readme.rst index 6588e68d..f96a8f32 100644 --- a/descarteslabs/client/services/metadata/readme.rst +++ b/descarteslabs/client/services/metadata/readme.rst @@ -6,7 +6,7 @@ Metadata (deprecated) Filtering ---------- +~~~~~~~~~ .. py:attribute:: properties diff --git a/descarteslabs/client/services/service/__init__.py b/descarteslabs/client/services/service/__init__.py index 184da142..06f3c55a 100644 --- a/descarteslabs/client/services/service/__init__.py +++ b/descarteslabs/client/services/service/__init__.py @@ -12,6 +12,20 @@ # See the License for the specific language governing permissions and # limitations under the License. -from .service import Service, JsonApiService, ThirdPartyService, NotFoundError +from .service import ( + Service, + Session, + JsonApiService, + JsonApiSession, + ThirdPartyService, + NotFoundError, +) -__all__ = ["Service", "JsonApiService", "ThirdPartyService", "NotFoundError"] +__all__ = [ + "Service", + "Session", + "JsonApiService", + "JsonApiSession", + "ThirdPartyService", + "NotFoundError", +] diff --git a/descarteslabs/client/services/service/readme.rst b/descarteslabs/client/services/service/readme.rst new file mode 100644 index 00000000..8c7d6833 --- /dev/null +++ b/descarteslabs/client/services/service/readme.rst @@ -0,0 +1,38 @@ +.. default-role:: py:obj + +Service +------- + +.. autosummary:: + :nosignatures: + + ~descarteslabs.client.services.service.Service + ~descarteslabs.client.services.service.JsonApiService + ~descarteslabs.client.services.service.ThirdPartyService + ~descarteslabs.client.services.service.Session + ~descarteslabs.client.services.service.JsonApiSession + +----- + +.. autoclass:: descarteslabs.client.services.service.Service + :autosummary: + :members: + +.. autoclass:: descarteslabs.client.services.service.JsonApiService + :autosummary: + :members: + :inherited-members: + :show-inheritance: + +.. autoclass:: descarteslabs.client.services.service.ThirdPartyService + :autosummary: + :members: + +.. autoclass:: descarteslabs.client.services.service.Session + :autosummary: + :members: + +.. autoclass:: descarteslabs.client.services.service.JsonApiSession + :autosummary: + :members: + :show-inheritance: diff --git a/descarteslabs/client/services/service/service.py b/descarteslabs/client/services/service/service.py index 56e7421c..2532df5f 100644 --- a/descarteslabs/client/services/service/service.py +++ b/descarteslabs/client/services/service/service.py @@ -25,6 +25,7 @@ import requests import sys import uuid +import json from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry @@ -32,10 +33,12 @@ from descarteslabs.client.auth import Auth from descarteslabs.client.exceptions import ( + ClientError, ServerError, BadRequestError, NotFoundError, RateLimitError, + ProxyAuthenticationRequiredError, GatewayTimeoutError, ConflictError, ) @@ -61,6 +64,12 @@ class HttpRequestMethod(object): class HttpStatusCode(object): + Ok = 200 + BadRequest = 400 + NotFound = 404 + ProxyAuthenticationRequired = 407 + Conflict = 409 + UnprocessableEntity = 422 TooManyRequests = 429 InternalServerError = 500 BadGateway = 502 @@ -89,7 +98,27 @@ class HttpHeaderValues(object): DlPython = "dl-python" -class WrappedSession(requests.Session): +class Session(requests.Session): + """The HTTP Session that performs the actual HTTP request. + + This is the base session that is used for all Descartes Labs HTTP calls which + itself is derived from `requests.Session + `_. + + You cannot control its instantiation, but you can derive from this class + and pass it as the class to use when you instantiate a :py:class:`Service` + or register it as the default session class using + :py:meth:`Service.set_default_session_class`. + + Parameters + ---------- + base_url: str + The URL prefix to use for communication with the Descartes Labs servers. + timeout: int or tuple(int, int) + See `requests timeouts + `_. + """ + ATTR_BASE_URL = "base_url" ATTR_HEADERS = "headers" ATTR_TIMEOUT = "timeout" @@ -100,9 +129,57 @@ class WrappedSession(requests.Session): def __init__(self, base_url, timeout=None): self.base_url = base_url self.timeout = timeout - super(WrappedSession, self).__init__() + super(Session, self).__init__() + + def initialize(self): + """Initialize the :py:class:`Session` instance + + You can override this method in a derived class to add your own initialization. + This method does nothing in the base class. + """ + + pass def request(self, method, url, **kwargs): + """Sends an HTTP request and emits Descartes Labs specific errors. + + Parameters + ---------- + method: str + The HTTP method to use. + url: str + The URL to send the request to. + kwargs: dict + Additional arguments. See `requests.request + `_. + + Returns + ------- + Response + A :py:class:`request.Response` object. + + Raises + ------ + BadRequestError + Either a 400 or 422 HTTP response status code was encountered. + NotFoundError + A 404 HTTP response status code was encountered. + ProxyAuthenticationRequiredError + A 407 HTTP response status code was encountered and the resulting + :py:meth:`handle_proxy_authentication` did not indicate that the + proxy authentication was handled. + ConflictError + A 409 HTTP response status code was encountered. + RateLimitError + A 429 HTTP response status code was encountered. + GatewayTimeoutError + A 504 HTTP response status code was encountered. + ServerError + Any HTTP response status code larger than 400 that was not covered above + is returned as a ServerError. The original HTTP response status code + can be found in the attribute :py:attr:`original_status`. + """ + if self.timeout and self.ATTR_TIMEOUT not in kwargs: kwargs[self.ATTR_TIMEOUT] = self.timeout @@ -111,37 +188,116 @@ def request(self, method, url, **kwargs): kwargs[self.ATTR_HEADERS][HttpHeaderKeys.RequestGroup] = uuid.uuid4().hex - resp = super(WrappedSession, self).request( - method, self.base_url + url, **kwargs - ) + resp = super(Session, self).request(method, self.base_url + url, **kwargs) - if resp.status_code >= 200 and resp.status_code < 400: + if ( + resp.status_code >= HttpStatusCode.Ok + and resp.status_code < HttpStatusCode.BadRequest + ): return resp - elif resp.status_code == 400: + elif resp.status_code == HttpStatusCode.BadRequest: raise BadRequestError(resp.text) - elif resp.status_code == 404: + elif resp.status_code == HttpStatusCode.NotFound: text = resp.text if not text: - text = "404 {} {}".format(method, url) + text = "{} {} {}".format(HttpStatusCode.NotFound, method, url) raise NotFoundError(text) - elif resp.status_code == 409: + elif resp.status_code == HttpStatusCode.ProxyAuthenticationRequired: + if not self.handle_proxy_authentication(method, url, **kwargs): + raise ProxyAuthenticationRequiredError() + elif resp.status_code == HttpStatusCode.Conflict: raise ConflictError(resp.text) - elif resp.status_code == 422: + elif resp.status_code == HttpStatusCode.UnprocessableEntity: raise BadRequestError(resp.text) - elif resp.status_code == 429: + elif resp.status_code == HttpStatusCode.TooManyRequests: raise RateLimitError( resp.text, retry_after=resp.headers.get(HttpHeaderKeys.RetryAfter) ) - elif resp.status_code == 504: + elif resp.status_code == HttpStatusCode.GatewayTimeout: raise GatewayTimeoutError( "Your request timed out on the server. " "Consider reducing the complexity of your request." ) else: - raise ServerError(resp.text) + # The whole error hierarchy has some problems. Originally a ClientError + # could be thrown by our client libraries, but any HTTP error was a + # ServerError. That changed and HTTP errors below 500 became ClientErrors. + # That means that this actually should be split in ClientError for + # status < 500 and ServerError for status >= 500, but that might break + # things. So instead, we'll add the original status. + server_error = ServerError(resp.text) + server_error.original_status = resp.status_code + raise server_error + + def handle_proxy_authentication(self, method, url, **kwargs): + """Handle proxy authentication when the HTTP request was denied. + + This method can be overridden in a derived class. By default a + :py:class:`~descarteslabs.client.exceptions.ProxyAuthenticationRequiredError` + will be raised. + + Returns + ------- + bool + Return True if the proxy authentication has been handled and no further + exception should be raised. Return False if a + :py:class:`~descarteslabs.client.exceptions.ProxyAuthenticationRequiredError` + should be raised. + """ + + return False + + +# For backward compatibility +WrappedSession = Session class Service(object): + """The default Descartes Labs HTTP Service used to communicate with its servers. + + This service has a default timeout and retry policy that retries HTTP requests + depending on the timeout and HTTP status code that was returned. This is based + on the `requests timeouts + `_ + and the `urllib3 retry object + `_. + + The default timeouts are set to 9.5 seconds for establishing a connection (slightly + larger than a multiple of 3, which is the TCP default packet retransmission window), + and 30 seconds for reading a response. + + The default retry logic retries up to 3 times total, a maximum of 2 for establishing + a connection, 2 for reading a response, and 2 for unexpected HTTP status codes. + The backoff_factor is a random number between 1 and 3, but will never be more + than 2 minutes. The unexpected HTTP status codes that will be retried are ``500``, + ``502``, ``503``, and ``504`` for any of the HTTP requests. + + Parameters + ---------- + url: str + The URL prefix to use for communication with the Descartes Labs server. + token: str, optional + Deprecated. + auth: Auth, optional + A Descartes Labs :py:class:`~descarteslabs.client.auth.Auth` instance. If not + provided, a default one will be instantiated. + retries: int or urllib3.util.retry.Retry + If a number, it's the number of retries that will be attempled. If a + :py:class:`urllib3.util.retry.Retry` instance, it will determine the retry + behavior. If not provided, the default retry policy as described above will + be used. + session_class: class + The session class to use when instantiating the session. This must be a derived + class from :py:class:`Session`. If not provided, the default session class + is used. You can register a default session class with + :py:meth:`Service.set_default_session_class`. + + Raises + ------ + TypeError + If you try to use a session class that is not derived from :py:class:`Session`. + """ + # https://requests.readthedocs.io/en/master/user/advanced/#timeouts CONNECT_TIMEOUT = 9.5 READ_TIMEOUT = 30 @@ -178,6 +334,43 @@ class Service(object): # of the single underlying connection pool. ADAPTER = ThreadLocalWrapper(lambda: HTTPAdapter(max_retries=Service.RETRY_CONFIG)) + _session_class = Session + + @classmethod + def set_default_session_class(cls, session_class): + """Set the default session class for :py:class:`Service`. + + The default session is used for any :py:class:`Service` that is instantiated + without specifying the session class. + + Parameters + ---------- + session_class: class + The session class to use when instantiating the session. This must be the + class :py:class:`Session` itself or a derived class from + :py:class:`Session`. + """ + + if not issubclass(session_class, Session): + raise TypeError( + "The session class must be a subclass of {}.".format(Session) + ) + + cls._session_class = session_class + + @classmethod + def get_default_session_class(cls): + """Get the default session class for :py:class:`Service`. + + Returns + ------- + Session + The default session class, which is :py:class:`Session` itself or a derived + class from :py:class:`Session`. + """ + + return cls._session_class + def __init__(self, url, token=None, auth=None, retries=None, session_class=None): if auth is None: auth = Auth() @@ -197,27 +390,34 @@ def __init__(self, url, token=None, auth=None, retries=None, session_class=None) else: self._adapter = ThreadLocalWrapper(lambda: HTTPAdapter(max_retries=retries)) - if session_class is None: - self._session_class = WrappedSession - else: + if session_class is not None: + # Overwrite the default session class + if not issubclass(session_class, Session): + raise TypeError( + "The session class must be a subclass of {}.".format(Session) + ) + self._session_class = session_class # Sessions can't be shared across threads or processes because the underlying # SSL connection pool can't be shared. We create them thread-local to avoid # intractable exceptions when users naively share clients e.g. when using # multiprocessing. - self._session = ThreadLocalWrapper(self.build_session) + self._session = ThreadLocalWrapper(self._build_session) @property def token(self): + """str: The bearer token used in the requests.""" return self.auth.token @token.setter def token(self, token): + """str: Deprecated""" self.auth._token = token @property def session(self): + """Session: The session instance used by this service.""" session = self._session.get() auth = add_bearer(self.token) if session.headers.get(HttpHeaderKeys.Authorization) != auth: @@ -225,13 +425,15 @@ def session(self): return session - def build_session(self): - s = self._session_class(self.base_url, timeout=self.TIMEOUT) + def _build_session(self): + session = self._session_class(self.base_url, timeout=self.TIMEOUT) + session.initialize() + adapter = self._adapter.get() - s.mount(HttpMountProtocol.HTTPS, adapter) - s.mount(HttpMountProtocol.HTTP, adapter) + session.mount(HttpMountProtocol.HTTPS, adapter) + session.mount(HttpMountProtocol.HTTP, adapter) - s.headers.update( + session.headers.update( { HttpHeaderKeys.ContentType: HttpHeaderValues.ApplicationJson, HttpHeaderKeys.UserAgent: "{}/{}".format( @@ -241,7 +443,7 @@ def build_session(self): ) try: - s.headers.update( + session.headers.update( { # https://github.com/easybuilders/easybuild/wiki/OS_flavor_name_version HttpHeaderKeys.Platform: platform.platform(), @@ -260,72 +462,351 @@ def build_session(self): except Exception: pass - return s + return session + +class JsonApiSession(Session): + """The HTTP Session that performs the actual JSONAPI HTTP request. -class JsonApiSession(WrappedSession): + You cannot control its instantiation, but you can derive from this class + and pass it as the class to use when you instantiate a :py:class:`JsonApiService` + or register it as the default session class using + :py:meth:`JsonApiService.set_default_session_class`. + + Parameters + ---------- + base_url: str + The URL prefix to use for communication with the Descartes Labs servers. + timeout: int or tuple(int, int) + See `requests timeouts + `_. + """ + + # Warning keys KEY_CATEGORY = "category" KEY_MESSAGE = "message" KEY_META = "meta" KEY_WARNINGS = "warnings" + # Error keys + KEY_ABOUT = "about" + KEY_DETAIL = "detail" + KEY_ERRORS = "errors" + KEY_HREF = "href" + KEY_ID = "id" + KEY_LINKS = "links" + KEY_PARAMETER = "parameter" + KEY_POINTER = "pointer" + KEY_SOURCE = "source" + KEY_STATUS = "status" + KEY_TITLE = "title" + + def __init__(self, *args, **kwargs): + self.rewrite_errors = False # This may be changed by the JsonApiService + super(JsonApiSession, self).__init__(*args, **kwargs) + + def initialize(self): + """Initialize the :py:class:`Session` instance + + You can override this method in a derived class to add your own initialization. + This method does nothing in the base class. + """ + + pass + def request(self, *args, **kwargs): - resp = super(JsonApiSession, self).request(*args, **kwargs) + """Sends an HTTP request and emits Descartes Labs specific errors. + + Parameters + ---------- + method: str + The HTTP method to use. + url: str + The URL to send the request to. + kwargs: dict + Additional arguments. See `requests.request + `_. + + Returns + ------- + Response + A :py:class:`request.Response` object. + + Raises + ------ + BadRequestError + Either a 400 or 422 HTTP response status code was encountered. + ~descarteslabs.client.exceptions.NotFoundError + A 404 HTTP response status code was encountered. + ProxyAuthenticationRequiredError + A 407 HTTP response status code was encountered and the resulting + :py:meth:`~JsonApiSession.handle_proxy_authentication` did not indicate + that the proxy authentication was handled. + ConflictError + A 409 HTTP response status code was encountered. + RateLimitError + A 429 HTTP response status code was encountered. + GatewayTimeoutError + A 504 HTTP response status code was encountered. + ServerError + Any HTTP response status code larger than 400 that was not covered above + is returned as a ServerError. The original HTTP response status code + can be found in the attribute :py:attr:`original_status`. + + Note + ---- + If :py:attr:`rewrite_errors` was set to ``True`` in the corresponding + :py:class:`JsonApiService`, the JSONAPI errors will be rewritten in a more + human readable format. + """ + + try: + resp = super(JsonApiSession, self).request(*args, **kwargs) + except (ClientError, ServerError) as error: + if self.rewrite_errors: + self._rewrite_error(error) + raise try: - json_response = resp.json() - except ValueError: + self._emit_warnings(resp.json()) + except Exception: + # Really don't want to raise anything here pass - else: - if ( - self.KEY_META not in json_response - or self.KEY_WARNINGS not in json_response[self.KEY_META] - ): - return # This activates the `finally` clause... - for warning in json_response[self.KEY_META][self.KEY_WARNINGS]: - if self.KEY_MESSAGE not in warning: # Mandatory - continue + return resp - message = warning[self.KEY_MESSAGE] - category = UserWarning + def handle_proxy_authentication(self, method, url, **kwargs): + """Handle proxy authentication when the HTTP request was denied. - if self.KEY_CATEGORY in warning: - category = getattr(builtins, warning[self.KEY_CATEGORY], None) + This method can be overridden in a derived class. By default a + :py:class:`~descarteslabs.client.exceptions.ProxyAuthenticationRequiredError` + will be raised. - if category is None: - category = UserWarning - message = "{}: {}".format(warning[self.KEY_CATEGORY], message) + Returns + ------- + bool + Return True if the proxy authentication has been handled and no further + exception should be raised. Return False if a + :py:class:`~descarteslabs.client.exceptions.ProxyAuthenticationRequiredError` + should be raised. + """ - warn(message, category) - finally: - return resp + return False + + def _emit_warnings(self, json_response): + if ( + self.KEY_META not in json_response + or self.KEY_WARNINGS not in json_response[self.KEY_META] + ): + return + + for warning in json_response[self.KEY_META][self.KEY_WARNINGS]: + if self.KEY_MESSAGE not in warning: # Mandatory + continue + + message = warning[self.KEY_MESSAGE] + category = UserWarning + + if self.KEY_CATEGORY in warning: + category = getattr(builtins, warning[self.KEY_CATEGORY], None) + + if category is None: + # Couldn't find this category; add it to the message instead + category = UserWarning + message = "{}: {}".format(warning[self.KEY_CATEGORY], message) + + warn(message, category) + + def _rewrite_error(self, client_error): + """Rewrite JSON ClientErrors that are returned to make them easier to read""" + message = "" + + for arg in client_error.args: + try: + errors = json.loads(arg)[self.KEY_ERRORS] + + for error in errors: + line = "" + seperator = "" + + if self.KEY_TITLE in error: + line += error[self.KEY_TITLE] + seperator = ": " + elif self.KEY_STATUS in error: + line += error[self.KEY_STATUS] + seperator = ": " + + if self.KEY_DETAIL in error: + line += seperator + error[self.KEY_DETAIL].strip(".") + seperator = ": " + + if self.KEY_SOURCE in error: + source = error[self.KEY_SOURCE] + if self.KEY_POINTER in source: + source = source[self.KEY_POINTER].split("/")[-1] + elif self.KEY_PARAMETER in source: + source = source[self.KEY_PARAMETER] + line += seperator + source + + if self.KEY_ID in error: + line += " ({})".format(error[self.KEY_ID]) + + if line: + message += "\n " + line + + if self.KEY_LINKS in error: + links = error[self.KEY_LINKS] + + if self.KEY_ABOUT in links: + link = links[self.KEY_ABOUT] + + if isinstance(link, str): + message += "\n {}".format(link) + elif isinstance(link, dict) and self.KEY_HREF in link: + message += "\n {}".format(link[self.KEY_HREF]) + except Exception: + return + + if message: + client_error.args = (message,) class JsonApiService(Service): + """A JsonApi oriented default Descateslabs Labs HTTP Service. + + For details see the :py:class:`Service`. This service adheres to the `JsonApi + standard `_ and interprets responses as needed. + + This service uses the :py:class:`JsonApiSession` which provides some optional + functionality. + + Parameters + ---------- + url: str + The URL prefix to use for communication with the Descartes Labs servers. + session_class: class + The session class to use when instantiating the session. This must be a derived + class from :py:class:`JsonApiSession`. If not provided, the default session + class is used. You can register a default session class with + :py:meth:`JsonApiService.set_default_session_class`. + rewrite_errors: bool + When set to ``True``, errors are rewritten to be more readable. Each JsonApi + error becomes a single line of error information without tags. + auth: Auth, optional + A Descartes Labs :py:class:`~descarteslabs.client.auth.Auth` instance. If not + provided, a default one will be instantiated. + retries: int or urllib3.util.retry.Retry If a number, it's the number of retries + that will be attempled. If a :py:class:`urllib3.util.retry.Retry` instance, + it will determine the retry behavior. If not provided, the default retry + policy as described above will be used. + + Raises + ------ + TypeError + If you try to use a session class that is not derived from + :py:class:`JsonApiSession`. + """ + KEY_ATTRIBUTES = "attributes" KEY_DATA = "data" KEY_ID = "id" KEY_TYPE = "type" - def __init__(self, url, session_class=None, **kwargs): - if session_class is None: - session_class = JsonApiSession + _session_class = JsonApiSession + + @classmethod + def set_default_session_class(cls, session_class): + """Set the default session class for :py:class:`JsonApiService`. + + The default session is used for any :py:class:`JsonApiService` that is + instantiated without specifying the session class. + + Parameters + ---------- + session_class: class + The session class to use when instantiating the session. This must be the + class :py:class:`JsonApiSession` itself or a derived class from + :py:class:`JsonApiSession`. + """ + + if not issubclass(session_class, JsonApiSession): + raise TypeError( + "The session class must be a subclass of {}.".format(JsonApiSession) + ) + + cls._session_class = session_class + + @classmethod + def get_default_session_class(cls): + """Get the default session class for :py:class:`JsonApiService`. + + Returns + ------- + JsonApiService + The default session class, which is :py:class:`JsonApiService` itself or + a derived class from :py:class:`JsonApiService`. + """ + + return cls._session_class + def __init__(self, url, session_class=None, rewrite_errors=False, **kwargs): + if not (session_class is None or issubclass(session_class, JsonApiSession)): + raise TypeError( + "The session class must be a subclass of {}.".format(JsonApiSession) + ) + + self.rewrite_errors = rewrite_errors super(JsonApiService, self).__init__(url, session_class=session_class, **kwargs) - def build_session(self): - s = super(JsonApiService, self).build_session() - s.headers.update( + def _build_session(self): + session = super(JsonApiService, self)._build_session() + + session.rewrite_errors = self.rewrite_errors + session.headers.update( { HttpHeaderKeys.ContentType: HttpHeaderValues.ApplicationVndApiJson, HttpHeaderKeys.Accept: HttpHeaderValues.ApplicationVndApiJson, } ) - return s + return session @staticmethod def jsonapi_document(type, attributes, id=None): + """Return a JsonApi document with a single resource. + + A JsonApi document has the following structure: + + .. code:: + + { + "data": { + "type": "...", + "id": "...", // Optional + "attributes": { + "...": "...", + ... + } + } + } + + Parameters + ---------- + type: str + The type of resource; this becomes the ``type`` key in the ``data`` element. + attributes: dict + The attributes for this resource; this becomes the ``attributes`` key in + the ``data`` element. + id: str, optional + The optional id for the resource; if provided this becomes the ``id`` key + in the ``data`` element. + + Returns + ------- + dict + A dictionary representing the JsonApi document with ``data`` as the + top-level key, which itself contains a single resource. + """ + resource = { JsonApiService.KEY_DATA: { JsonApiService.KEY_TYPE: type, @@ -338,6 +819,57 @@ def jsonapi_document(type, attributes, id=None): @staticmethod def jsonapi_collection(type, attributes_list, ids_list=None): + """Return a JsonApi document with a collection of resources. + + The number of elements in the ``attributes_list`` must be identical to the + number of elements in the ``ids_list``. + + A JsonApi collection has the following structure: + + .. code:: + + { + "data": [ + { + "type": "...", + "id": "...", // Optional + "attributes": { + "...": "...", + ... + } + }, { + ... + }, { + ... + ] + } + + Parameters + ---------- + type: str + The type of resource; this becomes the ``type`` key for each resource in + the collection. The JsonApi collection contains resources of the same + type. + attributes: list(dict) + A list of attributes for each resource; this becomes the ``attributes`` + key for each resource in the collection. + id: list(str), optional + The optional id for the resource; if provided this becomes the ``id`` key + for each resource in the collection. + + Returns + ------- + dict + A dictionary representing the JsonApi document with ``data`` as the + top-level key, which itself contains a list of resources. + + Raises + ------ + ValueError + If the number of elements in ``attributes_list`` differs from the number + of elements in ``ids_list``. + """ + if ids_list is None: ids_list = itertools.repeat(None) else: @@ -360,6 +892,41 @@ def jsonapi_collection(type, attributes_list, ids_list=None): class ThirdPartyService(object): + """The default Descartes Labs HTTP Service used for 3rd party servers. + + This service has a default timeout and retry policy that retries HTTP requests + depending on the timeout and HTTP status code that was returned. This is based + on the `requests timeouts + `_ + and the `urllib3 retry object + `_. + + The default timeouts are set to 9.5 seconds for establishing a connection (slightly + larger than a multiple of 3, which is the TCP default packet retransmission window), + and 30 seconds for reading a response. + + The default retry logic retries up to 10 times total, a maximum of 2 for + establishing a connection. The backoff_factor is a random number between 1 and + 3, but will never be more than 2 minutes. The unexpected HTTP status codes that + will be retried are ``429``, ``500``, ``502``, ``503``, and ``504`` for any of the + HTTP requests. + + Parameters + ---------- + url: str + The URL prefix to use for communication with the 3rd party server. + session_class: class + The session class to use when instantiating the session. This must be a derived + class from :py:class:`Session`. If not provided, the default session class + is used. You can register a default session class with + :py:meth:`ThirdPartyService.set_default_session_class`. + + Raises + ------ + TypeError + If you try to use a session class that is not derived from :py:class:`Session`. + """ + CONNECT_TIMEOUT = 9.5 READ_TIMEOUT = 30 TIMEOUT = (CONNECT_TIMEOUT, READ_TIMEOUT) @@ -392,19 +959,66 @@ class ThirdPartyService(object): lambda: HTTPAdapter(max_retries=ThirdPartyService.RETRY_CONFIG) ) - def __init__(self, url=""): + _session_class = Session + + @classmethod + def set_default_session_class(cls, session_class=None): + """Set the default session class for :py:class:`ThirdPartyService`. + + The default session is used for any :py:meth:`ThirdPartyService` that is + instantiated without specifying the session class. + + Parameters + ---------- + session_class: class + The session class to use when instantiating the session. This must be the + class :py:class:`Session` itself or a derived class from + :py:class:`Session`. + """ + + if not issubclass(session_class, Session): + raise TypeError( + "The session class must be a subclass of {}.".format(Session) + ) + + cls._session_class = session_class + + @classmethod + def get_default_session_class(cls): + """Get the default session class for the :py:class:`ThirdPartyService`. + + Returns + ------- + Session + The default session class, which is :py:class:`Session` itself or a derived + class from :py:class:`Session`. + """ + + return cls._session_class + + def __init__(self, url="", session_class=None): self.base_url = url - self._session = ThreadLocalWrapper(self.build_session) + + if session_class is not None: + if not issubclass(session_class, Session): + raise TypeError( + "The session class must be a subclass of {}.".format(Session) + ) + + self._session_class = session_class + + self._session = ThreadLocalWrapper(self._build_session) @property def session(self): return self._session.get() - def build_session(self): - s = WrappedSession(self.base_url, timeout=self.TIMEOUT) - s.mount(HttpMountProtocol.HTTPS, self.ADAPTER.get()) + def _build_session(self): + session = self._session_class(self.base_url, timeout=self.TIMEOUT) + session.initialize() - s.headers.update( + session.mount(HttpMountProtocol.HTTPS, self.ADAPTER.get()) + session.headers.update( { HttpHeaderKeys.ContentType: HttpHeaderValues.ApplicationOctetStream, HttpHeaderKeys.UserAgent: "{}/{}".format( @@ -413,4 +1027,4 @@ def build_session(self): } ) - return s + return session diff --git a/descarteslabs/client/services/service/tests/test_service.py b/descarteslabs/client/services/service/tests/test_service.py index db612086..d391b8cd 100644 --- a/descarteslabs/client/services/service/tests/test_service.py +++ b/descarteslabs/client/services/service/tests/test_service.py @@ -16,9 +16,27 @@ import unittest import mock - -from descarteslabs.client.services.service import JsonApiService, Service -from descarteslabs.client.services.service.service import WrappedSession, requests +import descarteslabs +from descarteslabs.client.exceptions import ( + ProxyAuthenticationRequiredError, + BadRequestError, +) +from descarteslabs.client.services.service import ( + JsonApiService, + JsonApiSession, + Service, + Session, + ThirdPartyService, +) +from descarteslabs.client.services.service.service import ( + HttpHeaderKeys, + HttpHeaderValues, + HttpRequestMethod, + HttpStatusCode, + WrappedSession, + requests, +) +from descarteslabs.client.version import __version__ from descarteslabs.common.http.authorization import add_bearer FAKE_URL = "http://localhost" @@ -33,6 +51,13 @@ def test_session_token(self): def test_client_session_header(self): service = Service("foo", auth=mock.MagicMock(token=FAKE_TOKEN)) assert "X-Client-Session" in service.session.headers + assert ( + service.session.headers[HttpHeaderKeys.ContentType] + == HttpHeaderValues.ApplicationJson + ) + assert service.session.headers[HttpHeaderKeys.UserAgent] == "{}/{}".format( + HttpHeaderValues.DlPython, __version__ + ) class TestJsonApiService(unittest.TestCase): @@ -43,6 +68,19 @@ def test_session_token(self): def test_client_session_header(self): service = JsonApiService("foo", auth=mock.MagicMock(token=FAKE_TOKEN)) assert "X-Client-Session" in service.session.headers + assert ( + service.session.headers[HttpHeaderKeys.ContentType] + == HttpHeaderValues.ApplicationVndApiJson + ) + assert service.session.headers[HttpHeaderKeys.UserAgent] == "{}/{}".format( + HttpHeaderValues.DlPython, __version__ + ) + + +class TestThirdParyService(unittest.TestCase): + def test_client_session_header(self): + service = ThirdPartyService() + assert "User-Agent" in service.session.headers class TestWrappedSession(unittest.TestCase): @@ -64,7 +102,7 @@ def test_request_group_header_none(self, request): @mock.patch.object(requests.Session, "request") def test_request_group_header_conflict(self, request): - request.return_value.status_code = 200 + request.return_value.status_code = HttpStatusCode.Ok args = "POST", FAKE_URL kwargs = dict(headers={"X-Request-Group": "f00"}) @@ -75,10 +113,438 @@ def test_request_group_header_conflict(self, request): @mock.patch.object(requests.Session, "request") def test_request_group_header_no_conflict(self, request): - request.return_value.status_code = 200 + request.return_value.status_code = HttpStatusCode.Ok session = WrappedSession("") session.request("POST", FAKE_URL, headers={"foo": "bar"}) request.assert_called_once() assert "X-Request-Group" in request.call_args[1]["headers"] + + +class TestSessionClass(unittest.TestCase): + def test_bad_session(self): + class MySession: + pass + + with self.assertRaises(TypeError): + Service( + "foo", auth=mock.MagicMock(token=FAKE_TOKEN), session_class=MySession + ) + + @mock.patch.object(requests.Session, "request") + def test_good_session(self, request): + request.return_value.status_code = HttpStatusCode.Ok + + class MySession(Session): + pass + + service = Service( + "foo", auth=mock.MagicMock(token=FAKE_TOKEN), session_class=MySession + ) + service.session.get("bar") + + request.assert_called() + + @mock.patch.object(requests.Session, "request") + def test_bad_json_session(self, request): + request.return_value.status_code = HttpStatusCode.Ok + + class MySession(Session): + pass + + with self.assertRaises(TypeError): + JsonApiService( + "foo", auth=mock.MagicMock(token=FAKE_TOKEN), session_class=MySession + ) + + @mock.patch.object(requests.Session, "request") + def test_good_json_session(self, request): + request.return_value.status_code = HttpStatusCode.Ok + + class MySession(JsonApiSession): + pass + + service = JsonApiService( + "foo", auth=mock.MagicMock(token=FAKE_TOKEN), session_class=MySession + ) + service.session.get("bar") + + request.assert_called() + + @mock.patch.object(requests.Session, "request") + def test_proxy_called(self, request): + request.return_value.status_code = HttpStatusCode.ProxyAuthenticationRequired + + class MySession(Session): + handle_proxy_authentication_called = 0 + handled = True + + def handle_proxy_authentication(self, method, url, **kwargs): + MySession.handle_proxy_authentication_called += 1 + assert method == HttpRequestMethod.GET + assert url == "bar" + return MySession.handled + + service = Service( + "foo", auth=mock.MagicMock(token=FAKE_TOKEN), session_class=MySession + ) + service.session.get("bar") + + assert MySession.handle_proxy_authentication_called == 1 + + MySession.handled = False + with self.assertRaises(ProxyAuthenticationRequiredError): + service.session.get("bar") + + assert MySession.handle_proxy_authentication_called == 2 + + @mock.patch.object(requests.Session, "request") + def test_proxy_called_jsonapi(self, request): + request.return_value.status_code = HttpStatusCode.ProxyAuthenticationRequired + + class MySession(JsonApiSession): + handle_proxy_authentication_called = 0 + handled = True + + def handle_proxy_authentication(self, method, url, **kwargs): + MySession.handle_proxy_authentication_called += 1 + assert method == HttpRequestMethod.GET + assert url == "bar" + return MySession.handled + + service = JsonApiService( + "foo", auth=mock.MagicMock(token=FAKE_TOKEN), session_class=MySession + ) + service.session.get("bar") + + assert MySession.handle_proxy_authentication_called == 1 + + MySession.handled = False + with self.assertRaises(ProxyAuthenticationRequiredError): + service.session.get("bar") + + assert MySession.handle_proxy_authentication_called == 2 + + @mock.patch.object(requests.Session, "request") + def test_proxy_called_thirdpary(self, request): + request.return_value.status_code = HttpStatusCode.ProxyAuthenticationRequired + + class MySession(Session): + handle_proxy_authentication_called = 0 + handled = True + + def handle_proxy_authentication(self, method, url, **kwargs): + MySession.handle_proxy_authentication_called += 1 + assert method == HttpRequestMethod.GET + assert url == "bar" + return MySession.handled + + service = ThirdPartyService(session_class=MySession) + service.session.get("bar") + + assert MySession.handle_proxy_authentication_called == 1 + + MySession.handled = False + with self.assertRaises(ProxyAuthenticationRequiredError): + service.session.get("bar") + + assert MySession.handle_proxy_authentication_called == 2 + + +class TestJsonApiSession(unittest.TestCase): + # A JSONAPI error can contain, amongst others, the following fields: + # status, title, detail, source + # The source field can contain: + # pointer, parameter + # When rewriting the error, it looks like + # [title or status: ][description: ][source or parameter][ (id)][ + # link] + + @mock.patch.object(requests.Session, "request") + def test_jsonapi_error(self, request): + error_title = "Title" + error_status = "Status" # Should be ignored + + request.return_value.status_code = HttpStatusCode.BadRequest + request.return_value.text = ( + '{{"errors": [{{"title": "{}", "status": "{}"}}]}}' + ).format(error_title, error_status) + service = JsonApiService( + "foo", auth=mock.MagicMock(token=FAKE_TOKEN), rewrite_errors=True + ) + + try: + service.session.get("bar") + except BadRequestError as e: + assert e.args == ("\n {}".format(error_title),) + + @mock.patch.object(requests.Session, "request") + def test_jsonapi_error_with_detail(self, request): + error_title = "Title" + error_detail = "Description" + + request.return_value.status_code = HttpStatusCode.BadRequest + request.return_value.text = ( + '{{"errors": [{{"title": "{}", "detail": "{}"}}]}}' + ).format(error_title, error_detail) + service = JsonApiService( + "foo", auth=mock.MagicMock(token=FAKE_TOKEN), rewrite_errors=True + ) + + try: + service.session.get("bar") + except BadRequestError as e: + assert e.args == ("\n {}: {}".format(error_title, error_detail),) + + @mock.patch.object(requests.Session, "request") + def test_jsonapi_error_no_title(self, request): + error_status = "Status" # Should be used instead of the title + error_detail = "Description" + + request.return_value.status_code = HttpStatusCode.BadRequest + request.return_value.text = ( + '{{"errors": [{{"status": "{}", "detail": "{}"}}]}}' + ).format(error_status, error_detail) + service = JsonApiService( + "foo", auth=mock.MagicMock(token=FAKE_TOKEN), rewrite_errors=True + ) + + try: + service.session.get("bar") + except BadRequestError as e: + assert e.args == ("\n {}: {}".format(error_status, error_detail),) + + @mock.patch.object(requests.Session, "request") + def test_jsonapi_error_with_source(self, request): + error_title = "Title" + error_detail = "Detail" + error_field = "Field" + + request.return_value.status_code = HttpStatusCode.BadRequest + request.return_value.text = ( + '{{"errors": [{{"title": "{}", "detail": "{}", "source": ' + '{{"pointer": "/path/to/{}"}}}}]}}' + ).format(error_title, error_detail, error_field) + service = JsonApiService( + "foo", auth=mock.MagicMock(token=FAKE_TOKEN), rewrite_errors=True + ) + + try: + service.session.get("bar") + except BadRequestError as e: + assert e.args == ( + "\n {}: {}: {}".format(error_title, error_detail, error_field), + ) + + @mock.patch.object(requests.Session, "request") + def test_jsonapi_error_with_id(self, request): + error_title = "Title" + error_detail = "Detail" + error_id = "123" + + request.return_value.status_code = HttpStatusCode.BadRequest + request.return_value.text = ( + '{{"errors": [{{"title": "{}", "detail": "{}", "id": {}}}]}}' + ).format(error_title, error_detail, error_id) + service = JsonApiService( + "foo", auth=mock.MagicMock(token=FAKE_TOKEN), rewrite_errors=True + ) + + try: + service.session.get("bar") + except BadRequestError as e: + assert e.args == ( + "\n {}: {} ({})".format(error_title, error_detail, error_id), + ) + + @mock.patch.object(requests.Session, "request") + def test_jsonapi_error_with_link(self, request): + error_title = "Title" + error_detail = "Detail" + error_href = "Href" + + request.return_value.status_code = HttpStatusCode.BadRequest + request.return_value.text = ( + '{{"errors": [{{"title": "{}", "detail": "{}", "links": ' + '{{"about": "{}"}}}}]}}' + ).format(error_title, error_detail, error_href) + service = JsonApiService( + "foo", auth=mock.MagicMock(token=FAKE_TOKEN), rewrite_errors=True + ) + + try: + service.session.get("bar") + except BadRequestError as e: + assert e.args == ( + "\n {}: {}\n {}".format( + error_title, error_detail, error_href + ), + ) + + request.return_value.text = ( + '{{"errors": [{{"title": "{}", "detail": "{}", "links": ' + '{{"about": {{"href": "{}"}}}}}}]}}' + ).format(error_title, error_detail, error_href) + service = JsonApiService( + "foo", auth=mock.MagicMock(token=FAKE_TOKEN), rewrite_errors=True + ) + + try: + service.session.get("bar") + except BadRequestError as e: + assert e.args == ( + "\n {}: {}\n {}".format( + error_title, error_detail, error_href + ), + ) + + +class TestDefaultProxyClass(unittest.TestCase): + @mock.patch.object(requests.Session, "request") + def test_session_default_proxy(self, request): + request.return_value.status_code = HttpStatusCode.ProxyAuthenticationRequired + + class MySession(Session): + handle_proxy_authentication_called = 0 + handled = True + + def handle_proxy_authentication(self, method, url, **kwargs): + MySession.handle_proxy_authentication_called += 1 + assert method == HttpRequestMethod.GET + assert url == "bar" + return MySession.handled + + Service.set_default_session_class(MySession) + service = Service("foo", auth=mock.MagicMock(token=FAKE_TOKEN)) + service.session.get("bar") + + assert MySession.handle_proxy_authentication_called == 1 + + MySession.handled = False + with self.assertRaises(ProxyAuthenticationRequiredError): + service.session.get("bar") + + assert MySession.handle_proxy_authentication_called == 2 + + MySession.handled = True + ThirdPartyService.set_default_session_class(MySession) + service = ThirdPartyService() + service.session.get("bar") + + assert MySession.handle_proxy_authentication_called == 3 + + MySession.handled = False + with self.assertRaises(ProxyAuthenticationRequiredError): + service.session.get("bar") + + assert MySession.handle_proxy_authentication_called == 4 + + +class TestWarningsClass(unittest.TestCase): + @mock.patch.object(descarteslabs.client.services.service.service, "warn") + @mock.patch.object(requests.Session, "request") + def test_session_deprecation_warning(self, request, warn): + message = "Warning" + cls = DeprecationWarning + + class result: + status_code = HttpStatusCode.Ok + + def json(self): + return { + "meta": { + "warnings": [{"message": message, "category": cls.__name__}] + } + } + + request.side_effect = lambda *args, **kw: result() + service = JsonApiService("foo", auth=mock.MagicMock(token=FAKE_TOKEN)) + service.session.get("bar") + warn.assert_called_once_with(message, cls) + + @mock.patch.object(descarteslabs.client.services.service.service, "warn") + @mock.patch.object(requests.Session, "request") + def test_session_my_warning(self, request, warn): + message = "Warning" + category = "MyCategory" + + class result: + status_code = HttpStatusCode.Ok + + def json(self): + return { + "meta": {"warnings": [{"message": message, "category": category}]} + } + + request.side_effect = lambda *args, **kw: result() + service = JsonApiService("foo", auth=mock.MagicMock(token=FAKE_TOKEN)) + service.session.get("bar") + warn.assert_called_once_with("{}: {}".format(category, message), UserWarning) + + @mock.patch.object(descarteslabs.client.services.service.service, "warn") + @mock.patch.object(requests.Session, "request") + def test_session_warning(self, request, warn): + message = "Warning" + + class result: + status_code = HttpStatusCode.Ok + + def json(self): + return {"meta": {"warnings": [{"message": message}]}} + + request.side_effect = lambda *args, **kw: result() + service = JsonApiService("foo", auth=mock.MagicMock(token=FAKE_TOKEN)) + service.session.get("bar") + warn.assert_called_once_with(message, UserWarning) + + +class TestInitialize(unittest.TestCase): + @mock.patch.object(requests.Session, "request") + def test_initialize_session(self, request): + request.return_value.status_code = HttpStatusCode.Ok + + class MySession(Session): + initialize_called = 0 + + def initialize(self): + MySession.initialize_called += 1 + + service = Service( + "foo", auth=mock.MagicMock(token=FAKE_TOKEN), session_class=MySession + ) + service.session.get("bar") + + assert MySession.initialize_called == 1 + + @mock.patch.object(requests.Session, "request") + def test_initialize_json_api_session(self, request): + request.return_value.status_code = HttpStatusCode.Ok + + class MySession(JsonApiSession): + initialize_called = 0 + + def initialize(self): + MySession.initialize_called += 1 + + service = JsonApiService( + "foo", auth=mock.MagicMock(token=FAKE_TOKEN), session_class=MySession + ) + service.session.get("bar") + + assert MySession.initialize_called == 1 + + @mock.patch.object(requests.Session, "request") + def test_initialize_third_party_session(self, request): + request.return_value.status_code = HttpStatusCode.Ok + + class MySession(Session): + initialize_called = 0 + + def initialize(self): + MySession.initialize_called += 1 + + service = ThirdPartyService(session_class=MySession) + service.session.get("bar") + + assert MySession.initialize_called == 1 diff --git a/descarteslabs/scenes/scene.py b/descarteslabs/scenes/scene.py index 766c426c..74ce8e88 100644 --- a/descarteslabs/scenes/scene.py +++ b/descarteslabs/scenes/scene.py @@ -216,7 +216,7 @@ def from_id(cls, scene_id, metadata_client=None): Raises ------ - `NotFoundError` + NotFoundError If the ``scene_id`` cannot be found in the Descartes Labs catalog """ @@ -454,9 +454,9 @@ def ndarray( If requested bands are unavailable. If band names are not given or are invalid. If the requested bands have incompatible dtypes. - `NotFoundError` + NotFoundError If a Scene's ID cannot be found in the Descartes Labs catalog - `BadRequestError` + BadRequestError If the Descartes Labs platform is given invalid parameters """ if raster_client is None: @@ -666,9 +666,9 @@ def download( If band names are not given or are invalid. If the requested bands have incompatible dtypes. If ``format`` is invalid, or the path has an invalid extension. - `NotFoundError` + NotFoundError If a Scene's ID cannot be found in the Descartes Labs catalog - `BadRequestError` + BadRequestError If the Descartes Labs platform is given invalid parameters """ bands = self._bands_to_list(bands) diff --git a/descarteslabs/scenes/scenecollection.py b/descarteslabs/scenes/scenecollection.py index e2e1df8f..5cdb1c26 100644 --- a/descarteslabs/scenes/scenecollection.py +++ b/descarteslabs/scenes/scenecollection.py @@ -207,9 +207,9 @@ def stack( or are invalid. If not all required parameters are specified in the :class:`~descarteslabs.scenes.geocontext.GeoContext`. If the SceneCollection is empty. - `NotFoundError` + NotFoundError If a Scene's ID cannot be found in the Descartes Labs catalog - `BadRequestError` + BadRequestError If the Descartes Labs platform is given unrecognized parameters """ if len(self) == 0: @@ -416,9 +416,9 @@ def mosaic( or are invalid. If not all required parameters are specified in the :class:`~descarteslabs.scenes.geocontext.GeoContext`. If the SceneCollection is empty. - `NotFoundError` + NotFoundError If a Scene's ID cannot be found in the Descartes Labs catalog - `BadRequestError` + BadRequestError If the Descartes Labs platform is given unrecognized parameters """ if len(self) == 0: @@ -618,9 +618,9 @@ def download( If ``format`` is invalid, or a path has an invalid extension. TypeError If ``dest`` is not a string or a sequence type. - `NotFoundError` + NotFoundError If a Scene's ID cannot be found in the Descartes Labs catalog - `BadRequestError` + BadRequestError If the Descartes Labs platform is given unrecognized parameters """ if len(self) == 0: @@ -811,9 +811,9 @@ def download_mosaic( If not all required parameters are specified in the :class:`~descarteslabs.scenes.geocontext.GeoContext`. If the SceneCollection is empty. If ``format`` is invalid, or the path has an invalid extension. - `NotFoundError` + NotFoundError If a Scene's ID cannot be found in the Descartes Labs catalog - `BadRequestError` + BadRequestError If the Descartes Labs platform is given unrecognized parameters """ if len(self) == 0: