Skip to content

Commit

Permalink
Add construct_error to _testing subpackage (#770)
Browse files Browse the repository at this point in the history
* Add `construct_error` to `_testing` subpackage

This helper replaces several "make_response" fixtures in error
testing, not only making things more uniform and explicit within the
SDK tests, but also making it possible for `_testing` users to
leverage this art.

`construct_error` replaces an entire error-testing `conftest.py` file
and is generally a simplification. Several tests become more verbose
as they now contain more of the test data.

The type of `construct_error` needs to be declared with overrides to
allow for the `error_class` to indicate the return type.

* Apply suggestions from code review

Co-authored-by: Kurt McKee <contactme@kurtmckee.org>

* Add dedicated unit tests for construct_error

Rather than "piggybacking" the testing of construct_error on the tests
for errors themselves, the construct_error now has its own dedicated
test module.

Although there may be (significant) overlap between the two suites of
tests, their differing targets simplify some decisions about how to
test binary content handling and other details. It also makes it
easier to assess whether or not there is a testing gap which isn't
easily measurable via code coverage.

* Update test_common_functionality.py

Minor typo fix after rebase

* Fix incorrect test fixture usage

Introduced by the rebase.

---------

Co-authored-by: Kurt McKee <contactme@kurtmckee.org>
  • Loading branch information
sirosen and kurtmckee authored Jul 1, 2023
1 parent baa4604 commit 3ded9a5
Show file tree
Hide file tree
Showing 10 changed files with 453 additions and 421 deletions.
7 changes: 7 additions & 0 deletions changelog.d/20230630_004448_sirosen_test_construct_error.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Added
~~~~~

- ``globus_sdk._testing`` now exposes a method, ``construct_error`` which makes
it simpler to explicitly construct and return a Globus SDK error object for
testing. This is used in the SDK's own testsuite and is available for
``_testing`` users. (:pr:`NUMBER`)
2 changes: 2 additions & 0 deletions docs/testing/reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ Functions

.. autofunction:: load_response

.. autofunction:: construct_error

Classes
-------

Expand Down
3 changes: 2 additions & 1 deletion src/globus_sdk/_testing/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from .helpers import get_last_request
from .helpers import construct_error, get_last_request
from .models import RegisteredResponse, ResponseList, ResponseSet
from .registry import (
get_response_set,
Expand All @@ -9,6 +9,7 @@

__all__ = (
"get_last_request",
"construct_error",
"ResponseSet",
"ResponseList",
"RegisteredResponse",
Expand Down
93 changes: 93 additions & 0 deletions src/globus_sdk/_testing/helpers.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
from __future__ import annotations

import http.client
import json
import typing as t

import requests
import responses

from globus_sdk.exc import GlobusAPIError

E = t.TypeVar("E", bound=GlobusAPIError)


def get_last_request(
*, requests_mock: responses.RequestsMock | None = None
Expand All @@ -21,3 +27,90 @@ def get_last_request(
except IndexError:
return None
return t.cast(requests.PreparedRequest, last_call.request)


@t.overload
def construct_error(
*,
http_status: int,
body: bytes | str | t.Dict[str, t.Any],
method: str = "GET",
response_headers: t.Dict[str, str] | None = None,
request_headers: t.Dict[str, str] | None = None,
response_encoding: str = "utf-8",
url: str = "https://bogus-url/",
) -> GlobusAPIError:
...


@t.overload
def construct_error(
*,
http_status: int,
error_class: type[E],
body: bytes | str | t.Dict[str, t.Any],
method: str = "GET",
response_headers: t.Dict[str, str] | None = None,
request_headers: t.Dict[str, str] | None = None,
response_encoding: str = "utf-8",
url: str = "https://bogus-url/",
) -> E:
...


def construct_error(
*,
http_status: int,
body: bytes | str | t.Dict[str, t.Any],
error_class: type[E] | type[GlobusAPIError] = GlobusAPIError,
method: str = "GET",
response_headers: t.Dict[str, str] | None = None,
request_headers: t.Dict[str, str] | None = None,
response_encoding: str = "utf-8",
url: str = "https://bogus-url/",
) -> E | GlobusAPIError:
"""
Given parameters for an HTTP response, construct a GlobusAPIError and return it.
:param error_class: The class of the error to construct. Defaults to
GlobusAPIError.
:type error_class: type[GlobusAPIError]
:param http_status: The HTTP status code to use in the response.
:type http_status: int
:param body: The body of the response. If a dict, will be JSON-encoded.
:type body: bytes | str | dict
:param method: The HTTP method to set on the underlying request.
:type method: str, optional
:param response_headers: The headers of the response.
:type response_headers: dict, optional
:param request_headers: The headers of the request.
:type request_headers: dict, optional
:param response_encoding: The encoding to use for the response body.
:type response_encoding: str, optional
:param url: The URL to set on the underlying request.
:type url: str, optional
"""
raw_response = requests.Response()
raw_response.status_code = http_status
raw_response.reason = http.client.responses.get(http_status, "Unknown")
raw_response.url = url
raw_response.encoding = response_encoding
raw_response.request = requests.Request(
method=method, url=url, headers=request_headers or {}
).prepare()
raw_response.headers.update(response_headers or {})
if isinstance(body, dict) and "Content-Type" not in raw_response.headers:
raw_response.headers["Content-Type"] = "application/json"

raw_response._content = _encode_body(body, response_encoding)

return error_class(raw_response)


def _encode_body(body: bytes | str | t.Dict[str, t.Any], encoding: str) -> bytes:
if isinstance(body, bytes):
return body
elif isinstance(body, str):
return body.encode(encoding)
else:
return json.dumps(body).encode(encoding)
50 changes: 50 additions & 0 deletions tests/unit/_testing/test_construct_error.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import pytest

import globus_sdk
from globus_sdk._testing import construct_error


def test_construct_error_defaults_to_base_error_class():
err = construct_error(body="foo", http_status=400)
assert isinstance(err, globus_sdk.GlobusAPIError)


@pytest.mark.parametrize(
"error_class",
(
globus_sdk.SearchAPIError,
globus_sdk.AuthAPIError,
globus_sdk.TransferAPIError,
globus_sdk.FlowsAPIError,
globus_sdk.GCSAPIError,
),
)
def test_construct_error_can_be_customized_to_service_error_classes(error_class):
err = construct_error(body="foo", http_status=400, error_class=error_class)
assert isinstance(err, error_class)


def test_construct_error_defaults_to_json_for_dict_body():
err = construct_error(body={"foo": "bar"}, http_status=400)
assert err.text == '{"foo": "bar"}'
assert err.headers == {"Content-Type": "application/json"}


@pytest.mark.parametrize(
"body, add_params, expect_encoding, expect_text",
(
(b"foo-bar", {}, "utf-8", "foo-bar"),
(b"foo-bar", {"response_encoding": "utf-8"}, "utf-8", "foo-bar"),
(b"foo-bar", {"response_encoding": "latin-1"}, "latin-1", "foo-bar"),
# this is invalid utf-8 (continuation byte),
# but valid in latin-1 (e with acute accent)
(b"\xe9", {"response_encoding": "latin-1"}, "latin-1", "é"),
),
)
def test_construct_error_allows_binary_content(
body, add_params, expect_encoding, expect_text
):
err = construct_error(body=body, http_status=400, **add_params)
assert err.binary_content == body
assert err.text == expect_text
assert err._underlying_response.encoding == expect_encoding
84 changes: 0 additions & 84 deletions tests/unit/errors/conftest.py

This file was deleted.

Loading

0 comments on commit 3ded9a5

Please sign in to comment.