from __future__ import annotations
import collections.abc
import json
import logging
import typing as t
from globus_sdk import _guards
log = logging.getLogger(__name__)
if t.TYPE_CHECKING:
from requests import Response
import globus_sdk
__all__ = (
"GlobusHTTPResponse",
"IterableResponse",
"ArrayResponse",
)
[docs]
class GlobusHTTPResponse:
"""
Response object that wraps an HTTP response from the underlying HTTP
library. If the response is JSON, the parsed data will be available in
``data``, otherwise ``data`` will be ``None`` and ``text`` should
be used instead.
The most common response data is a JSON dictionary. To make
handling this type of response as seamless as possible, the
``GlobusHTTPResponse`` object implements the immutable mapping protocol for
dict-style access. This is just an alias for access to the underlying data.
If the response data is not a dictionary or list, item access will raise
``TypeError``.
>>> print("Response ID": r["id"]) # alias for r.data["id"]
:ivar client: The client instance which made the request
"""
def __init__(
self,
response: Response | GlobusHTTPResponse,
client: globus_sdk.BaseClient | None = None,
) -> None:
# init on a GlobusHTTPResponse: we are wrapping this data
# the _response is None
if isinstance(response, GlobusHTTPResponse):
if client is not None:
raise ValueError("Redundant client with wrapped response")
self._wrapped: GlobusHTTPResponse | None = response
self._response: Response | None = None
self.client: globus_sdk.BaseClient = self._wrapped.client
# copy parsed JSON data off of '_wrapped'
self._parsed_json: t.Any = self._wrapped._parsed_json
# init on a Response object, this is the "normal" case
# _wrapped is None
else:
if client is None:
raise ValueError("Missing client with normal response")
self._wrapped = None
self._response = response
self.client = client
# JSON decoding may raise a ValueError due to an invalid JSON
# document. In the case of trying to fetch the "data" on an HTTP
# response, this means we didn't get a JSON response.
# store this as None, as in "no data"
#
# if the caller *really* wants the raw body of the response, they can
# always use `text`
try:
self._parsed_json = self._response.json()
except ValueError:
log.warning("response data did not parse as JSON, data=None")
self._parsed_json = None
@property
def _raw_response(self) -> Response:
# this is an internal property which traverses any series of wrapped responses
# until reaching a requests response object
if self._response is not None:
return self._response
elif self._wrapped is not None:
return self._wrapped._raw_response
else: # unreachable # pragma: no cover
raise ValueError("could not find an inner response object")
@property
def http_status(self) -> int:
"""The HTTP response status, as an integer."""
return self._raw_response.status_code
@property
def http_reason(self) -> str:
"""
The HTTP reason string from the response.
This is the part of the status line after the status code, and typically is a
string description of the status. If the status line is
``HTTP/1.1 200 OK``, then this is the string ``"OK"``.
"""
return self._raw_response.reason
@property
def headers(self) -> t.Mapping[str, str]:
"""
The HTTP response headers as a case-insensitive mapping.
For example, ``headers["Content-Length"]`` and ``headers["content-length"]`` are
treated as equivalent.
"""
return self._raw_response.headers
@property
def content_type(self) -> str | None:
return self.headers.get("Content-Type")
@property
def text(self) -> str:
"""The raw response data as a string."""
return self._raw_response.text
@property
def binary_content(self) -> bytes:
"""
The raw response data in bytes.
"""
return self._raw_response.content
@property
def data(self) -> t.Any:
return self._parsed_json
[docs]
def get(self, key: str, default: t.Any = None) -> t.Any:
"""
``get`` is just an alias for ``data.get(key, default)``, but with the added
checks that if ``data`` is ``None`` or a list, it returns the default.
:param key: The string key to lookup in the response if it is a dict
:param default: The default value to be used if the data is null or a list
"""
if _guards.is_optional(self.data, list):
return default
# NB: `default` is provided as a positional because the native dict type
# doesn't recognize a keyword argument `default`
return self.data.get(key, default)
def __str__(self) -> str:
"""The default __str__ for a response assumes that the data is valid
JSON-dump-able."""
if self.data is not None:
return json.dumps(self.data, indent=2, separators=(",", ": "))
return self.text
def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.text})"
def __getitem__(self, key: str | int | slice) -> t.Any:
# force evaluation of the data property outside of the upcoming
# try-catch so that we don't accidentally catch TypeErrors thrown
# during the getter function itself
data = self.data
try:
return data[key]
except TypeError as err:
log.error(
f"Can't index into responses with underlying data of type {type(data)}"
)
# re-raise with an altered message and error type -- the issue is that
# whatever data is in the response doesn't support indexing (e.g. a response
# that is just an integer, parsed as json)
#
# "type" is ambiguous, but we don't know if it's the fault of the
# class at large, or just a particular call's `data` property
raise ValueError(
"This type of response data does not support indexing."
) from err
def __contains__(self, item: t.Any) -> bool:
"""
``x in response`` is an alias for ``x in response.data``
"""
if self.data is None:
return False
return item in self.data
def __bool__(self) -> bool:
"""
``bool(response)`` is an alias for ``bool(response.data)``
"""
return bool(self.data)
[docs]
class IterableResponse(GlobusHTTPResponse):
"""This response class adds an __iter__ method on an 'iter_key' variable.
The assumption is that iter produces dicts or dict-like mappings."""
default_iter_key: t.ClassVar[str]
iter_key: str
def __init__(
self,
response: Response | GlobusHTTPResponse,
client: globus_sdk.BaseClient | None = None,
*,
iter_key: str | None = None,
) -> None:
if not hasattr(self, "default_iter_key"):
raise TypeError(
"Cannot instantiate an iterable response from a class "
"which does not define a default iteration key."
)
iter_key = iter_key if iter_key is not None else self.default_iter_key
self.iter_key = iter_key
super().__init__(response, client)
def __iter__(self) -> t.Iterator[t.Mapping[t.Any, t.Any]]:
if not isinstance(self.data, dict):
raise TypeError(
"Cannot iterate on IterableResponse data when "
f"type is '{type(self.data).__name__}'"
)
return iter(self.data[self.iter_key])
[docs]
class ArrayResponse(GlobusHTTPResponse):
"""This response class adds an ``__iter__`` method which assumes that the top-level
data of the response is a JSON array."""
def __iter__(self) -> t.Iterator[t.Any]:
if not isinstance(self.data, list):
raise TypeError(
"Cannot iterate on ArrayResponse data when "
f"type is '{type(self.data).__name__}'"
)
return iter(self.data)
def __len__(self) -> int:
"""
``len(response)`` is an alias for ``len(response.data)``
"""
if not isinstance(self.data, collections.abc.Sequence):
raise TypeError(
"Cannot take len() on ArrayResponse data when "
f"type is '{type(self.data).__name__}'"
)
return len(self.data)