From 4baa5ebf62a3c1f8089b500a6cbfe366d46cfe84 Mon Sep 17 00:00:00 2001 From: Stephen Rosen Date: Fri, 1 May 2026 18:07:29 -0500 Subject: [PATCH 1/2] Add support for `orjson`, add `ResponseDecoder`s In order to support `orjson` for serialization, add a new `OrjsonRequestEncoder`, which mirrors the `JSONRequestEncoder` type. The transport can select which encoder to use based on an init-time flag. This setting highlights the awkwardness of the class-level (read: global) mapping of strings to encoders. Therefore, it is now copied into an instance attribute, which is then post-hoc modified if `use_orjson=True`. On the decoding side, the story is more complex, as the objects doing decoding are various: responses, errors, and retry hooks. Only one of those three (retry hooks) operates at the abstraction layer of the transport. Therefore, the changes to support a defined decoder type, and provide it in these contexts, are as follows: - RetryContext objects now include the response decoder - response objects use their client object's decoder (specifically, `self.client.transport.decoder`) - API errors have their own decoder setting, which can be _injected_ via a contextvar API, but which is not publicly controllable via init -- this avoids compatibility issues around changing the init signature of our error types Selection of orjson is provided via an init arg to the transport and via an env var, `GLOBUS_SDK_USE_ORJSON=1`. If the setting is enabled but `orjson` is not installed, instantiating encoders and decoders (of the `Orjson*` types) will emit errors, to provide an early-error experience. Because API errors now need access to `globus_sdk.transport`, these import paths are more heavily implicated in other parts of the SDK. This breaks tests which help enforce deferred imports of `requests`, due to its very slow import time. To rectify, a number of `requests` imports, specifically, are now `TYPE_CHECKING` flagged and deferred. Tests are extended to cover `orjson` testing, including several new tests, an `orjson` dependency group, new frozen requirements, tox config, and CI config. Because responses read the client's decoder, a large number of test tweaks are needed to provide a "better" client mock which satisfies this requirement. In CI and the default tox env list, we only test `orjson` on a small selection of Python versions, to moderate the expansion of our test matrix. --- .github/workflows/test.yaml | 4 + ...1_170758_sirosen_define_orjson_encoder.rst | 24 ++++ pyproject.toml | 3 + requirements/py3.10/test-orjson.txt | 55 ++++++++ requirements/py3.10/typing.txt | 34 +++-- requirements/py3.11/test-orjson.txt | 47 +++++++ requirements/py3.11/typing.txt | 38 +++--- requirements/py3.12/test-orjson.txt | 47 +++++++ requirements/py3.12/typing.txt | 38 +++--- requirements/py3.13/test-orjson.txt | 47 +++++++ requirements/py3.13/typing.txt | 38 +++--- requirements/py3.14/test-orjson.txt | 47 +++++++ requirements/py3.14/typing.txt | 38 +++--- requirements/py3.9/test-orjson.txt | 59 ++++++++ requirements/py3.9/typing.txt | 34 +++-- src/globus_sdk/_internal/orjson_compat.py | 46 +++++++ src/globus_sdk/client.py | 4 +- src/globus_sdk/config/__init__.py | 8 +- src/globus_sdk/config/env_vars.py | 20 +++ src/globus_sdk/exc/api.py | 36 ++++- .../experimental/transfer_v2/transport.py | 4 +- src/globus_sdk/globus_app/app.py | 11 +- src/globus_sdk/response.py | 6 +- src/globus_sdk/services/transfer/transport.py | 4 +- src/globus_sdk/testing/registry.py | 1 + src/globus_sdk/transport/caller_info.py | 5 +- src/globus_sdk/transport/decoders.py | 30 ++++ .../transport/default_retry_checks.py | 7 +- src/globus_sdk/transport/encoders.py | 46 ++++++- src/globus_sdk/transport/requests.py | 77 +++++++++-- src/globus_sdk/transport/retry.py | 11 +- tests/common/response_mock.py | 4 + tests/conftest.py | 18 ++- .../functional/base_client/test_encodings.py | 93 +++++++++++++ .../globus_app/test_authorizer_factory.py | 8 +- tests/unit/responses/conftest.py | 8 +- tests/unit/responses/test_response.py | 129 +++++++++++------- .../services/auth/test_id_token_decoder.py | 14 +- tests/unit/test_config.py | 16 +++ tests/unit/test_paging.py | 19 +-- .../transport/test_default_retry_policy.py | 29 +++- .../unit/transport/test_retry_check_runner.py | 22 ++- .../unit/transport/test_transfer_transport.py | 22 ++- .../transport/test_transfer_v2_transport.py | 15 +- tests/unit/transport/test_transport.py | 26 ++++ tox.ini | 7 + 46 files changed, 1087 insertions(+), 212 deletions(-) create mode 100644 changelog.d/20260501_170758_sirosen_define_orjson_encoder.rst create mode 100644 requirements/py3.10/test-orjson.txt create mode 100644 requirements/py3.11/test-orjson.txt create mode 100644 requirements/py3.12/test-orjson.txt create mode 100644 requirements/py3.13/test-orjson.txt create mode 100644 requirements/py3.14/test-orjson.txt create mode 100644 requirements/py3.9/test-orjson.txt create mode 100644 src/globus_sdk/_internal/orjson_compat.py create mode 100644 src/globus_sdk/transport/decoders.py diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 88779cf26..591ebbf08 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -44,6 +44,8 @@ jobs: - "3.14" tox-post-environments: - "py3.9-mindeps" + - "py3.9-orjson" + - "py3.14-orjson" - "py3.11-sphinxext" - "coverage_report" @@ -53,6 +55,7 @@ jobs: - "3.11" tox-post-environments: - "py3.11-sphinxext" + - "py3.14-orjson" - "coverage_report" - name: "Windows" @@ -62,6 +65,7 @@ jobs: - "3.11" tox-post-environments: - "py3.9-mindeps" + - "py3.14-orjson" - "py3.11-sphinxext" - "coverage_report" diff --git a/changelog.d/20260501_170758_sirosen_define_orjson_encoder.rst b/changelog.d/20260501_170758_sirosen_define_orjson_encoder.rst new file mode 100644 index 000000000..faba1337a --- /dev/null +++ b/changelog.d/20260501_170758_sirosen_define_orjson_encoder.rst @@ -0,0 +1,24 @@ +Added +----- + +- The SDK now supports use of ``orjson`` as an alternative JSON encoder and decoder. + When ``GLOBUS_SDK_USE_ORJSON=1`` is set, request sending and response decoding + will use ``orjson``. (:pr:`NUMBER`) + + - Use of ``orjson`` is optional, but if the variable is set and ``orjson`` + is not installed, errors will be emitted. + + - The setting can also be configured on transport objects with the init + option, ``use_orjson=True``. + + - In a future major version of the SDK, use of ``orjson`` will default to + true when it is available. + +Deprecated +---------- + +- The ``RequestsTransport`` class supports configuration of request encoding + via a class-variable mapping, ``encoders``. This limits the ability of the + SDK to apply per-object customizations, as in the case of ``orjson`` + support. The class variable ``encoders`` is deprecated, and users should + leverage the new ``encoder_map`` instance variable instead. diff --git a/pyproject.toml b/pyproject.toml index 3b970ce38..204975b85 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,7 @@ docs = [ "scriv", ] coverage = ["coverage[toml]"] +orjson = ["orjson>=3"] test = [ {include-group = "coverage"}, "pytest", "pytest-xdist", "pytest-randomly", "flaky", @@ -83,6 +84,8 @@ typing = [ "responses", # similarly, sphinx is needed to type-check our sphinx extension "sphinx", + # include any optional test deps + {include-group = "orjson"}, ] typing-mindeps = [ {include-group = "typing"}, diff --git a/requirements/py3.10/test-orjson.txt b/requirements/py3.10/test-orjson.txt new file mode 100644 index 000000000..e87fc1734 --- /dev/null +++ b/requirements/py3.10/test-orjson.txt @@ -0,0 +1,55 @@ +# +# This file is autogenerated by pip-compile with Python 3.10 +# by the following command: +# +# tox p -m freezedeps +# +certifi==2026.4.22 + # via requests +charset-normalizer==3.4.7 + # via requests +coverage==7.13.5 + # via -r .test.in +exceptiongroup==1.3.1 + # via pytest +execnet==2.1.2 + # via pytest-xdist +flaky==3.8.1 + # via -r .test.in +idna==3.13 + # via requests +iniconfig==2.3.0 + # via pytest +orjson==3.11.8 + # via -r .orjson.in +packaging==26.2 + # via pytest +pluggy==1.6.0 + # via pytest +pygments==2.20.0 + # via pytest +pytest==9.0.3 + # via + # -r .test.in + # pytest-randomly + # pytest-xdist +pytest-randomly==4.1.0 + # via -r .test.in +pytest-xdist==3.8.0 + # via -r .test.in +pyyaml==6.0.3 + # via responses +requests==2.33.1 + # via responses +responses==0.26.0 + # via -r .test.in +tomli==2.4.1 + # via + # coverage + # pytest +typing-extensions==4.15.0 + # via exceptiongroup +urllib3==2.6.3 + # via + # requests + # responses diff --git a/requirements/py3.10/typing.txt b/requirements/py3.10/typing.txt index fb57ddade..51b712ae4 100644 --- a/requirements/py3.10/typing.txt +++ b/requirements/py3.10/typing.txt @@ -6,39 +6,43 @@ # alabaster==1.0.0 # via sphinx -babel==2.17.0 +babel==2.18.0 # via sphinx -certifi==2025.11.12 +certifi==2026.4.22 # via requests -charset-normalizer==3.4.4 +charset-normalizer==3.4.7 # via requests docutils==0.21.2 # via sphinx -idna==3.11 +idna==3.13 # via requests -imagesize==1.4.1 +imagesize==2.0.0 # via sphinx jinja2==3.1.6 # via sphinx +librt==0.9.0 + # via mypy markupsafe==3.0.3 # via jinja2 -mypy==1.18.2 +mypy==1.20.2 # via -r .typing.in mypy-extensions==1.1.0 # via mypy -packaging==25.0 +orjson==3.11.8 + # via -r .typing.in +packaging==26.2 # via sphinx -pathspec==0.12.1 +pathspec==1.1.1 # via mypy -pygments==2.19.2 +pygments==2.20.0 # via sphinx pyyaml==6.0.3 # via responses -requests==2.32.5 +requests==2.33.1 # via # responses # sphinx -responses==0.25.8 +responses==0.26.0 # via -r .typing.in snowballstemmer==3.0.1 # via sphinx @@ -56,23 +60,23 @@ sphinxcontrib-qthelp==2.0.0 # via sphinx sphinxcontrib-serializinghtml==2.0.0 # via sphinx -tomli==2.3.0 +tomli==2.4.1 # via # mypy # sphinx types-cryptography==3.3.23.2 # via types-jwt -types-docutils==0.22.3.20251115 +types-docutils==0.22.3.20260408 # via -r .typing.in types-jwt==0.1.3 # via -r .typing.in -types-requests==2.32.4.20250913 +types-requests==2.33.0.20260408 # via -r .typing.in typing-extensions==4.15.0 # via # -r .typing.in # mypy -urllib3==2.5.0 +urllib3==2.6.3 # via # requests # responses diff --git a/requirements/py3.11/test-orjson.txt b/requirements/py3.11/test-orjson.txt new file mode 100644 index 000000000..9f33bfdf8 --- /dev/null +++ b/requirements/py3.11/test-orjson.txt @@ -0,0 +1,47 @@ +# +# This file is autogenerated by pip-compile with Python 3.11 +# by the following command: +# +# tox p -m freezedeps +# +certifi==2026.4.22 + # via requests +charset-normalizer==3.4.7 + # via requests +coverage==7.13.5 + # via -r .test.in +execnet==2.1.2 + # via pytest-xdist +flaky==3.8.1 + # via -r .test.in +idna==3.13 + # via requests +iniconfig==2.3.0 + # via pytest +orjson==3.11.8 + # via -r .orjson.in +packaging==26.2 + # via pytest +pluggy==1.6.0 + # via pytest +pygments==2.20.0 + # via pytest +pytest==9.0.3 + # via + # -r .test.in + # pytest-randomly + # pytest-xdist +pytest-randomly==4.1.0 + # via -r .test.in +pytest-xdist==3.8.0 + # via -r .test.in +pyyaml==6.0.3 + # via responses +requests==2.33.1 + # via responses +responses==0.26.0 + # via -r .test.in +urllib3==2.6.3 + # via + # requests + # responses diff --git a/requirements/py3.11/typing.txt b/requirements/py3.11/typing.txt index 45bbb8070..6b278553b 100644 --- a/requirements/py3.11/typing.txt +++ b/requirements/py3.11/typing.txt @@ -6,45 +6,49 @@ # alabaster==1.0.0 # via sphinx -babel==2.17.0 +babel==2.18.0 # via sphinx -certifi==2025.11.12 +certifi==2026.4.22 # via requests -charset-normalizer==3.4.4 +charset-normalizer==3.4.7 # via requests -docutils==0.21.2 +docutils==0.22.4 # via sphinx -idna==3.11 +idna==3.13 # via requests -imagesize==1.4.1 +imagesize==2.0.0 # via sphinx jinja2==3.1.6 # via sphinx +librt==0.9.0 + # via mypy markupsafe==3.0.3 # via jinja2 -mypy==1.18.2 +mypy==1.20.2 # via -r .typing.in mypy-extensions==1.1.0 # via mypy -packaging==25.0 +orjson==3.11.8 + # via -r .typing.in +packaging==26.2 # via sphinx -pathspec==0.12.1 +pathspec==1.1.1 # via mypy -pygments==2.19.2 +pygments==2.20.0 # via sphinx pyyaml==6.0.3 # via responses -requests==2.32.5 +requests==2.33.1 # via # responses # sphinx -responses==0.25.8 +responses==0.26.0 # via -r .typing.in -roman-numerals-py==3.1.0 +roman-numerals==4.1.0 # via sphinx snowballstemmer==3.0.1 # via sphinx -sphinx==8.2.3 +sphinx==9.0.4 # via -r .typing.in sphinxcontrib-applehelp==2.0.0 # via sphinx @@ -60,17 +64,17 @@ sphinxcontrib-serializinghtml==2.0.0 # via sphinx types-cryptography==3.3.23.2 # via types-jwt -types-docutils==0.22.3.20251115 +types-docutils==0.22.3.20260408 # via -r .typing.in types-jwt==0.1.3 # via -r .typing.in -types-requests==2.32.4.20250913 +types-requests==2.33.0.20260408 # via -r .typing.in typing-extensions==4.15.0 # via # -r .typing.in # mypy -urllib3==2.5.0 +urllib3==2.6.3 # via # requests # responses diff --git a/requirements/py3.12/test-orjson.txt b/requirements/py3.12/test-orjson.txt new file mode 100644 index 000000000..6e172c587 --- /dev/null +++ b/requirements/py3.12/test-orjson.txt @@ -0,0 +1,47 @@ +# +# This file is autogenerated by pip-compile with Python 3.12 +# by the following command: +# +# tox p -m freezedeps +# +certifi==2026.4.22 + # via requests +charset-normalizer==3.4.7 + # via requests +coverage==7.13.5 + # via -r .test.in +execnet==2.1.2 + # via pytest-xdist +flaky==3.8.1 + # via -r .test.in +idna==3.13 + # via requests +iniconfig==2.3.0 + # via pytest +orjson==3.11.8 + # via -r .orjson.in +packaging==26.2 + # via pytest +pluggy==1.6.0 + # via pytest +pygments==2.20.0 + # via pytest +pytest==9.0.3 + # via + # -r .test.in + # pytest-randomly + # pytest-xdist +pytest-randomly==4.1.0 + # via -r .test.in +pytest-xdist==3.8.0 + # via -r .test.in +pyyaml==6.0.3 + # via responses +requests==2.33.1 + # via responses +responses==0.26.0 + # via -r .test.in +urllib3==2.6.3 + # via + # requests + # responses diff --git a/requirements/py3.12/typing.txt b/requirements/py3.12/typing.txt index 89ab34661..cb35ef7c6 100644 --- a/requirements/py3.12/typing.txt +++ b/requirements/py3.12/typing.txt @@ -6,45 +6,49 @@ # alabaster==1.0.0 # via sphinx -babel==2.17.0 +babel==2.18.0 # via sphinx -certifi==2025.11.12 +certifi==2026.4.22 # via requests -charset-normalizer==3.4.4 +charset-normalizer==3.4.7 # via requests -docutils==0.21.2 +docutils==0.22.4 # via sphinx -idna==3.11 +idna==3.13 # via requests -imagesize==1.4.1 +imagesize==2.0.0 # via sphinx jinja2==3.1.6 # via sphinx +librt==0.9.0 + # via mypy markupsafe==3.0.3 # via jinja2 -mypy==1.18.2 +mypy==1.20.2 # via -r .typing.in mypy-extensions==1.1.0 # via mypy -packaging==25.0 +orjson==3.11.8 + # via -r .typing.in +packaging==26.2 # via sphinx -pathspec==0.12.1 +pathspec==1.1.1 # via mypy -pygments==2.19.2 +pygments==2.20.0 # via sphinx pyyaml==6.0.3 # via responses -requests==2.32.5 +requests==2.33.1 # via # responses # sphinx -responses==0.25.8 +responses==0.26.0 # via -r .typing.in -roman-numerals-py==3.1.0 +roman-numerals==4.1.0 # via sphinx snowballstemmer==3.0.1 # via sphinx -sphinx==8.2.3 +sphinx==9.1.0 # via -r .typing.in sphinxcontrib-applehelp==2.0.0 # via sphinx @@ -60,17 +64,17 @@ sphinxcontrib-serializinghtml==2.0.0 # via sphinx types-cryptography==3.3.23.2 # via types-jwt -types-docutils==0.22.3.20251115 +types-docutils==0.22.3.20260408 # via -r .typing.in types-jwt==0.1.3 # via -r .typing.in -types-requests==2.32.4.20250913 +types-requests==2.33.0.20260408 # via -r .typing.in typing-extensions==4.15.0 # via # -r .typing.in # mypy -urllib3==2.5.0 +urllib3==2.6.3 # via # requests # responses diff --git a/requirements/py3.13/test-orjson.txt b/requirements/py3.13/test-orjson.txt new file mode 100644 index 000000000..69e5c8168 --- /dev/null +++ b/requirements/py3.13/test-orjson.txt @@ -0,0 +1,47 @@ +# +# This file is autogenerated by pip-compile with Python 3.13 +# by the following command: +# +# tox p -m freezedeps +# +certifi==2026.4.22 + # via requests +charset-normalizer==3.4.7 + # via requests +coverage==7.13.5 + # via -r .test.in +execnet==2.1.2 + # via pytest-xdist +flaky==3.8.1 + # via -r .test.in +idna==3.13 + # via requests +iniconfig==2.3.0 + # via pytest +orjson==3.11.8 + # via -r .orjson.in +packaging==26.2 + # via pytest +pluggy==1.6.0 + # via pytest +pygments==2.20.0 + # via pytest +pytest==9.0.3 + # via + # -r .test.in + # pytest-randomly + # pytest-xdist +pytest-randomly==4.1.0 + # via -r .test.in +pytest-xdist==3.8.0 + # via -r .test.in +pyyaml==6.0.3 + # via responses +requests==2.33.1 + # via responses +responses==0.26.0 + # via -r .test.in +urllib3==2.6.3 + # via + # requests + # responses diff --git a/requirements/py3.13/typing.txt b/requirements/py3.13/typing.txt index 06ba2a2c3..ddb345485 100644 --- a/requirements/py3.13/typing.txt +++ b/requirements/py3.13/typing.txt @@ -6,45 +6,49 @@ # alabaster==1.0.0 # via sphinx -babel==2.17.0 +babel==2.18.0 # via sphinx -certifi==2025.11.12 +certifi==2026.4.22 # via requests -charset-normalizer==3.4.4 +charset-normalizer==3.4.7 # via requests -docutils==0.21.2 +docutils==0.22.4 # via sphinx -idna==3.11 +idna==3.13 # via requests -imagesize==1.4.1 +imagesize==2.0.0 # via sphinx jinja2==3.1.6 # via sphinx +librt==0.9.0 + # via mypy markupsafe==3.0.3 # via jinja2 -mypy==1.18.2 +mypy==1.20.2 # via -r .typing.in mypy-extensions==1.1.0 # via mypy -packaging==25.0 +orjson==3.11.8 + # via -r .typing.in +packaging==26.2 # via sphinx -pathspec==0.12.1 +pathspec==1.1.1 # via mypy -pygments==2.19.2 +pygments==2.20.0 # via sphinx pyyaml==6.0.3 # via responses -requests==2.32.5 +requests==2.33.1 # via # responses # sphinx -responses==0.25.8 +responses==0.26.0 # via -r .typing.in -roman-numerals-py==3.1.0 +roman-numerals==4.1.0 # via sphinx snowballstemmer==3.0.1 # via sphinx -sphinx==8.2.3 +sphinx==9.1.0 # via -r .typing.in sphinxcontrib-applehelp==2.0.0 # via sphinx @@ -60,17 +64,17 @@ sphinxcontrib-serializinghtml==2.0.0 # via sphinx types-cryptography==3.3.23.2 # via types-jwt -types-docutils==0.22.3.20251115 +types-docutils==0.22.3.20260408 # via -r .typing.in types-jwt==0.1.3 # via -r .typing.in -types-requests==2.32.4.20250913 +types-requests==2.33.0.20260408 # via -r .typing.in typing-extensions==4.15.0 # via # -r .typing.in # mypy -urllib3==2.5.0 +urllib3==2.6.3 # via # requests # responses diff --git a/requirements/py3.14/test-orjson.txt b/requirements/py3.14/test-orjson.txt new file mode 100644 index 000000000..1ae899e61 --- /dev/null +++ b/requirements/py3.14/test-orjson.txt @@ -0,0 +1,47 @@ +# +# This file is autogenerated by pip-compile with Python 3.14 +# by the following command: +# +# tox p -m freezedeps +# +certifi==2026.4.22 + # via requests +charset-normalizer==3.4.7 + # via requests +coverage==7.13.5 + # via -r .test.in +execnet==2.1.2 + # via pytest-xdist +flaky==3.8.1 + # via -r .test.in +idna==3.13 + # via requests +iniconfig==2.3.0 + # via pytest +orjson==3.11.8 + # via -r .orjson.in +packaging==26.2 + # via pytest +pluggy==1.6.0 + # via pytest +pygments==2.20.0 + # via pytest +pytest==9.0.3 + # via + # -r .test.in + # pytest-randomly + # pytest-xdist +pytest-randomly==4.1.0 + # via -r .test.in +pytest-xdist==3.8.0 + # via -r .test.in +pyyaml==6.0.3 + # via responses +requests==2.33.1 + # via responses +responses==0.26.0 + # via -r .test.in +urllib3==2.6.3 + # via + # requests + # responses diff --git a/requirements/py3.14/typing.txt b/requirements/py3.14/typing.txt index ec731f1f7..b371ae76b 100644 --- a/requirements/py3.14/typing.txt +++ b/requirements/py3.14/typing.txt @@ -6,45 +6,49 @@ # alabaster==1.0.0 # via sphinx -babel==2.17.0 +babel==2.18.0 # via sphinx -certifi==2025.11.12 +certifi==2026.4.22 # via requests -charset-normalizer==3.4.4 +charset-normalizer==3.4.7 # via requests -docutils==0.21.2 +docutils==0.22.4 # via sphinx -idna==3.11 +idna==3.13 # via requests -imagesize==1.4.1 +imagesize==2.0.0 # via sphinx jinja2==3.1.6 # via sphinx +librt==0.9.0 + # via mypy markupsafe==3.0.3 # via jinja2 -mypy==1.18.2 +mypy==1.20.2 # via -r .typing.in mypy-extensions==1.1.0 # via mypy -packaging==25.0 +orjson==3.11.8 + # via -r .typing.in +packaging==26.2 # via sphinx -pathspec==0.12.1 +pathspec==1.1.1 # via mypy -pygments==2.19.2 +pygments==2.20.0 # via sphinx pyyaml==6.0.3 # via responses -requests==2.32.5 +requests==2.33.1 # via # responses # sphinx -responses==0.25.8 +responses==0.26.0 # via -r .typing.in -roman-numerals-py==3.1.0 +roman-numerals==4.1.0 # via sphinx snowballstemmer==3.0.1 # via sphinx -sphinx==8.2.3 +sphinx==9.1.0 # via -r .typing.in sphinxcontrib-applehelp==2.0.0 # via sphinx @@ -60,17 +64,17 @@ sphinxcontrib-serializinghtml==2.0.0 # via sphinx types-cryptography==3.3.23.2 # via types-jwt -types-docutils==0.22.3.20251115 +types-docutils==0.22.3.20260408 # via -r .typing.in types-jwt==0.1.3 # via -r .typing.in -types-requests==2.32.4.20250913 +types-requests==2.33.0.20260408 # via -r .typing.in typing-extensions==4.15.0 # via # -r .typing.in # mypy -urllib3==2.5.0 +urllib3==2.6.3 # via # requests # responses diff --git a/requirements/py3.9/test-orjson.txt b/requirements/py3.9/test-orjson.txt new file mode 100644 index 000000000..a1c033c68 --- /dev/null +++ b/requirements/py3.9/test-orjson.txt @@ -0,0 +1,59 @@ +# +# This file is autogenerated by pip-compile with Python 3.9 +# by the following command: +# +# tox p -m freezedeps +# +certifi==2026.4.22 + # via requests +charset-normalizer==3.4.7 + # via requests +coverage==7.10.7 + # via -r .test.in +exceptiongroup==1.3.1 + # via pytest +execnet==2.1.2 + # via pytest-xdist +flaky==3.8.1 + # via -r .test.in +idna==3.13 + # via requests +importlib-metadata==8.7.1 + # via pytest-randomly +iniconfig==2.1.0 + # via pytest +orjson==3.11.5 + # via -r .orjson.in +packaging==26.2 + # via pytest +pluggy==1.6.0 + # via pytest +pygments==2.20.0 + # via pytest +pytest==8.4.2 + # via + # -r .test.in + # pytest-randomly + # pytest-xdist +pytest-randomly==4.0.1 + # via -r .test.in +pytest-xdist==3.8.0 + # via -r .test.in +pyyaml==6.0.3 + # via responses +requests==2.32.5 + # via responses +responses==0.26.0 + # via -r .test.in +tomli==2.4.1 + # via + # coverage + # pytest +typing-extensions==4.15.0 + # via exceptiongroup +urllib3==2.6.3 + # via + # requests + # responses +zipp==3.23.1 + # via importlib-metadata diff --git a/requirements/py3.9/typing.txt b/requirements/py3.9/typing.txt index 1dc4046b1..6ed3d898a 100644 --- a/requirements/py3.9/typing.txt +++ b/requirements/py3.9/typing.txt @@ -6,33 +6,37 @@ # alabaster==0.7.16 # via sphinx -babel==2.17.0 +babel==2.18.0 # via sphinx -certifi==2025.11.12 +certifi==2026.4.22 # via requests -charset-normalizer==3.4.4 +charset-normalizer==3.4.7 # via requests docutils==0.21.2 # via sphinx -idna==3.11 +idna==3.13 # via requests -imagesize==1.4.1 +imagesize==1.5.0 # via sphinx -importlib-metadata==8.7.0 +importlib-metadata==8.7.1 # via sphinx jinja2==3.1.6 # via sphinx +librt==0.9.0 + # via mypy markupsafe==3.0.3 # via jinja2 -mypy==1.18.2 +mypy==1.19.1 # via -r .typing.in mypy-extensions==1.1.0 # via mypy -packaging==25.0 +orjson==3.11.5 + # via -r .typing.in +packaging==26.2 # via sphinx -pathspec==0.12.1 +pathspec==1.1.1 # via mypy -pygments==2.19.2 +pygments==2.20.0 # via sphinx pyyaml==6.0.3 # via responses @@ -40,7 +44,7 @@ requests==2.32.5 # via # responses # sphinx -responses==0.25.8 +responses==0.26.0 # via -r .typing.in snowballstemmer==3.0.1 # via sphinx @@ -58,7 +62,7 @@ sphinxcontrib-qthelp==2.0.0 # via sphinx sphinxcontrib-serializinghtml==2.0.0 # via sphinx -tomli==2.3.0 +tomli==2.4.1 # via # mypy # sphinx @@ -68,16 +72,16 @@ types-docutils==0.22.3.20251115 # via -r .typing.in types-jwt==0.1.3 # via -r .typing.in -types-requests==2.32.4.20250913 +types-requests==2.32.4.20260107 # via -r .typing.in typing-extensions==4.15.0 # via # -r .typing.in # mypy -urllib3==2.5.0 +urllib3==2.6.3 # via # requests # responses # types-requests -zipp==3.23.0 +zipp==3.23.1 # via importlib-metadata diff --git a/src/globus_sdk/_internal/orjson_compat.py b/src/globus_sdk/_internal/orjson_compat.py new file mode 100644 index 000000000..bb7eb07d1 --- /dev/null +++ b/src/globus_sdk/_internal/orjson_compat.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import sys +import typing as t + +__all__ = ( + "ORJSON_AVAILABLE", + "loads", + "dumps", +) + + +try: + import orjson + + ORJSON_AVAILABLE: bool = True +except ImportError: + ORJSON_AVAILABLE = False + + +def require() -> None: + """Raise an error if orjson is not available.""" + if not ORJSON_AVAILABLE: + raise RuntimeError( + "'orjson' is not available but globus-sdk was configured to use it. " + "This is not valid. Please ensure that 'orjson' is installed." + ) + + +if t.TYPE_CHECKING: + from orjson import dumps, loads +else: + + def __dir__() -> list[str]: + return ["__all__", "__file__", "__path__"] + list(__all__) + + def __getattr__(name: str) -> t.Any: + require() + + mod = sys.modules[__name__] + if name in ("loads", "dumps"): + value = getattr(orjson, name) + setattr(mod, name, value) + return value + + raise AttributeError(f"module {__name__} has no attribute {name}") diff --git a/src/globus_sdk/client.py b/src/globus_sdk/client.py index fec238f6c..04a434abe 100644 --- a/src/globus_sdk/client.py +++ b/src/globus_sdk/client.py @@ -574,4 +574,6 @@ def request( return GlobusHTTPResponse(r, self) log.debug(f"request completed with (error) response code: {r.status_code}") - raise self.error_class(r) + with exc.GlobusAPIError._inject_response_decoder(self.transport.decoder): + err = self.error_class(r) + raise err diff --git a/src/globus_sdk/config/__init__.py b/src/globus_sdk/config/__init__.py index da5268112..1948cbc60 100644 --- a/src/globus_sdk/config/__init__.py +++ b/src/globus_sdk/config/__init__.py @@ -1,4 +1,9 @@ -from .env_vars import get_environment_name, get_http_timeout, get_ssl_verify +from .env_vars import ( + get_environment_name, + get_http_timeout, + get_ssl_verify, + get_use_orjson, +) from .environments import EnvConfig, get_service_url, get_webapp_url __all__ = ( @@ -6,6 +11,7 @@ "get_environment_name", "get_ssl_verify", "get_http_timeout", + "get_use_orjson", "get_service_url", "get_webapp_url", ) diff --git a/src/globus_sdk/config/env_vars.py b/src/globus_sdk/config/env_vars.py index 8e34958fa..814d8dd8d 100644 --- a/src/globus_sdk/config/env_vars.py +++ b/src/globus_sdk/config/env_vars.py @@ -17,6 +17,7 @@ ENVNAME_VAR = "GLOBUS_SDK_ENVIRONMENT" HTTP_TIMEOUT_VAR = "GLOBUS_SDK_HTTP_TIMEOUT" SSL_VERIFY_VAR = "GLOBUS_SDK_VERIFY_SSL" +USE_ORJSON_VAR = "GLOBUS_SDK_USE_ORJSON" def get_environment_name(inputenv: str | None = None) -> str: @@ -56,6 +57,16 @@ def get_http_timeout(value: float | None = None) -> float | None: return result +def get_use_orjson(value: bool | None = None) -> bool: + if value is not None: + result: bool = value + else: + var = os.getenv(USE_ORJSON_VAR, "false") + result = _bool_cast(var) + log.debug(f"get_use_orjson() got value: {result}") + return result + + def _ssl_verify_cast(value: t.Any) -> bool | str: if isinstance(value, bool): return value @@ -83,3 +94,12 @@ def _float_cast(value: str) -> float: except ValueError as e: log.error(f'Value "{value}" can\'t cast to float') raise ValueError(f"Invalid config float: {value}") from e + + +def _bool_cast(value: str) -> bool: + if isinstance(value, str): + if value.lower() in {"y", "yes", "t", "true", "on", "1"}: + return True + if value.lower() in {"n", "no", "f", "false", "off", "0"}: + return False + raise ValueError(f"Invalid config bool: {value}") diff --git a/src/globus_sdk/exc/api.py b/src/globus_sdk/exc/api.py index 393293dc6..6580fa6a9 100644 --- a/src/globus_sdk/exc/api.py +++ b/src/globus_sdk/exc/api.py @@ -1,5 +1,7 @@ from __future__ import annotations +import contextlib +import contextvars import enum import logging import sys @@ -7,6 +9,7 @@ import typing as t from globus_sdk._internal import guards +from globus_sdk.transport.decoders import ResponseDecoder from .base import GlobusError from .err_info import ErrorInfoContainer @@ -18,6 +21,11 @@ _CACHE_SENTINEL = object() +_DEFAULT_DECODER = ResponseDecoder() +_RESPONSE_DECODER: contextvars.ContextVar[ResponseDecoder] = contextvars.ContextVar( + "_RESPONSE_DECODER", default=_DEFAULT_DECODER +) + class _ErrorFormat(enum.Enum): undefined = enum.auto() @@ -54,6 +62,8 @@ def __init__(self, r: requests.Response, *args: t.Any, **kwargs: t.Any) -> None: self._info: ErrorInfoContainer | None = None self._underlying_response = r + + self._decoder: ResponseDecoder = _RESPONSE_DECODER.get() self._parse_response() if sys.version_info >= (3, 11): @@ -66,6 +76,28 @@ def __init__(self, r: requests.Response, *args: t.Any, **kwargs: t.Any) -> None: ) super().__init__(*self._get_args()) + @staticmethod + @contextlib.contextmanager + def _inject_response_decoder(decoder: ResponseDecoder) -> t.Iterator[None]: + """ + This is a bypass method of passing a parameter into GlobusAPIError.__init__ + even though callers and subclasses of the error type are not aware of the + response decoder. + + A decoder can be set here, temporarily, and will be picked up during error init, + without requiring that subclasses properly pass args to super() or are even + aware of this interaction. + + This is purely for internal use, to pass the decoder used by the transport layer + which produced this error/response through to the errors which may be + subsequently raised. + """ + token = _RESPONSE_DECODER.set(decoder) + try: + yield + finally: + _RESPONSE_DECODER.reset(token) + @property def message(self) -> str | None: """ @@ -136,7 +168,9 @@ def raw_json(self) -> dict[str, t.Any] | None: # technically, this could be a non-dict JSON type, like a list or # string but in those cases the user can just cast -- the "normal" # case is a dict - self._cached_raw_json = self._underlying_response.json() + self._cached_raw_json = self._decoder.get_body_json( + self._underlying_response + ) except ValueError: log.error( "Error body could not be JSON decoded! " diff --git a/src/globus_sdk/experimental/transfer_v2/transport.py b/src/globus_sdk/experimental/transfer_v2/transport.py index c18609978..8be24dc26 100644 --- a/src/globus_sdk/experimental/transfer_v2/transport.py +++ b/src/globus_sdk/experimental/transfer_v2/transport.py @@ -28,10 +28,12 @@ def check_transfer_v2_transient_error(ctx: RetryContext) -> RetryCheckResult: if ctx.response is not None and ( ctx.response.status_code in retry_config.transient_error_status_codes ): + decoder = ctx.response_decoder + try: # if any of the error objects have a `code` of ExternalError or # EndpointError then the error likely isn't transient - errors = ctx.response.json()["errors"] + errors = decoder.get_body_json(ctx.response)["errors"] for error in errors: if error["code"] in ("ExternalError", "EndpointError"): return RetryCheckResult.no_decision diff --git a/src/globus_sdk/globus_app/app.py b/src/globus_sdk/globus_app/app.py index fc7348041..d8acd03bb 100644 --- a/src/globus_sdk/globus_app/app.py +++ b/src/globus_sdk/globus_app/app.py @@ -35,6 +35,7 @@ RetryContext, set_retry_check_flags, ) +from globus_sdk.transport.decoders import ResponseDecoder from .authorizer_factory import AuthorizerFactory from .config import DEFAULT_CONFIG, KNOWN_TOKEN_STORAGES, GlobusAppConfig @@ -578,7 +579,9 @@ def __call__(self, ctx: RetryContext) -> RetryCheckResult: if (resource_server := ctx.caller_info.resource_server) is None: return RetryCheckResult.no_decision - elif (gare := self._load_response_gare(ctx.response)) is None: + elif ( + gare := self._load_response_gare(ctx.response_decoder, ctx.response) + ) is None: return RetryCheckResult.no_decision log.debug("Intercepted re-drivable GARE; initiating app login.") @@ -590,12 +593,14 @@ def __call__(self, ctx: RetryContext) -> RetryCheckResult: return RetryCheckResult.do_retry @staticmethod - def _load_response_gare(response: Response | None) -> GARE | None: + def _load_response_gare( + response_decoder: ResponseDecoder, response: Response | None + ) -> GARE | None: """Return a parsed GARE from a 403 response or None if not possible.""" if response is None or response.status_code != 403: return None try: - decoded_body = response.json() + decoded_body = response_decoder.get_body_json(response) except JSONDecodeError: return None else: diff --git a/src/globus_sdk/response.py b/src/globus_sdk/response.py index 01d47f59b..2ba034c42 100644 --- a/src/globus_sdk/response.py +++ b/src/globus_sdk/response.py @@ -65,6 +65,10 @@ def __init__( self._response = response self.client = client + # lift the response decoder from the attached client; this will be used + # whenever response data decoding is needed + self._response_decoder = self.client.transport.decoder + @cached_property def _parsed_json(self) -> t.Any: # JSON decoding may raise a ValueError due to an invalid JSON @@ -79,7 +83,7 @@ def _parsed_json(self) -> t.Any: if self._response is not None: try: - return self._response.json() + return self._response_decoder.get_body_json(self._response) except ValueError: log.warning("response data did not parse as JSON, data=None") return None diff --git a/src/globus_sdk/services/transfer/transport.py b/src/globus_sdk/services/transfer/transport.py index 4ab2c6888..c35cdf3b8 100644 --- a/src/globus_sdk/services/transfer/transport.py +++ b/src/globus_sdk/services/transfer/transport.py @@ -25,8 +25,10 @@ def check_transfer_transient_error(ctx: RetryContext) -> RetryCheckResult: if ctx.response is not None and ( ctx.response.status_code in retry_config.transient_error_status_codes ): + decoder = ctx.response_decoder + try: - code = ctx.response.json()["code"] + code = decoder.get_body_json(ctx.response)["code"] except (ValueError, KeyError): code = "" diff --git a/src/globus_sdk/testing/registry.py b/src/globus_sdk/testing/registry.py index 54bd47af9..b6fca8c5a 100644 --- a/src/globus_sdk/testing/registry.py +++ b/src/globus_sdk/testing/registry.py @@ -7,6 +7,7 @@ import responses import globus_sdk +import globus_sdk.experimental from .models import RegisteredResponse, ResponseList, ResponseSet diff --git a/src/globus_sdk/transport/caller_info.py b/src/globus_sdk/transport/caller_info.py index 71e6a8913..34e040ec6 100644 --- a/src/globus_sdk/transport/caller_info.py +++ b/src/globus_sdk/transport/caller_info.py @@ -1,9 +1,12 @@ from __future__ import annotations -from globus_sdk.authorizers import GlobusAuthorizer +import typing as t from .retry_config import RetryConfig +if t.TYPE_CHECKING: + from globus_sdk.authorizers import GlobusAuthorizer + class RequestCallerInfo: """ diff --git a/src/globus_sdk/transport/decoders.py b/src/globus_sdk/transport/decoders.py new file mode 100644 index 000000000..22b972ae2 --- /dev/null +++ b/src/globus_sdk/transport/decoders.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import typing as t + +from globus_sdk._internal import orjson_compat + +if t.TYPE_CHECKING: + import requests + + +class ResponseDecoder: + """ + A response decoder takes a requests.Response object and decodes different parts of + it as necessary. + + The base decoder defines common behaviors, but subclasses may override. + """ + + def get_body_json(self, response: requests.Response) -> t.Any: + return response.json() + + +class OrjsonResponseDecoder(ResponseDecoder): + def __init__(self) -> None: + # eagerly error if one of these is ever constructed and 'orjson' is not + # installed + orjson_compat.require() + + def get_body_json(self, response: requests.Response) -> t.Any: + return orjson_compat.loads(response.content) diff --git a/src/globus_sdk/transport/default_retry_checks.py b/src/globus_sdk/transport/default_retry_checks.py index b9d1129f6..b87629aea 100644 --- a/src/globus_sdk/transport/default_retry_checks.py +++ b/src/globus_sdk/transport/default_retry_checks.py @@ -1,6 +1,6 @@ from __future__ import annotations -import requests +import typing as t from .retry import ( RetryCheck, @@ -10,6 +10,9 @@ set_retry_check_flags, ) +if t.TYPE_CHECKING: + import requests + def check_request_exception(ctx: RetryContext) -> RetryCheckResult: """ @@ -18,6 +21,8 @@ def check_request_exception(ctx: RetryContext) -> RetryCheckResult: :param ctx: The context object which describes the state of the request and the retries which may already have been attempted. """ + import requests + if ctx.exception and isinstance(ctx.exception, requests.RequestException): return RetryCheckResult.do_retry return RetryCheckResult.no_decision diff --git a/src/globus_sdk/transport/encoders.py b/src/globus_sdk/transport/encoders.py index 5052f47e9..b48aa7000 100644 --- a/src/globus_sdk/transport/encoders.py +++ b/src/globus_sdk/transport/encoders.py @@ -4,10 +4,12 @@ import typing as t import uuid -import requests - +from globus_sdk._internal import orjson_compat from globus_sdk._missing import MISSING, filter_missing +if t.TYPE_CHECKING: + import requests + class RequestEncoder: """ @@ -25,6 +27,8 @@ def encode( data: t.Any, headers: dict[str, str], ) -> requests.Request: + import requests + if not isinstance(data, (str, bytes)): raise TypeError( "Cannot encode non-text in a text request. " @@ -113,8 +117,11 @@ def encode( data: t.Any, headers: dict[str, str], ) -> requests.Request: + import requests + if data is not None: headers = {"Content-Type": "application/json", **headers} + return requests.Request( method, url, @@ -124,6 +131,39 @@ def encode( ) +class OrjsonRequestEncoder(RequestEncoder): + """ + This encoder prepares the data as JSON, just like the JSON encoder, but using the + `orjson` library. + """ + + def __init__(self) -> None: + # eagerly error if one of these encoders is ever constructed and 'orjson' is not + # installed + orjson_compat.require() + + def encode( + self, + method: str, + url: str, + params: dict[str, t.Any] | None, + data: t.Any, + headers: dict[str, str], + ) -> requests.Request: + import requests + + if data is not None: + headers = {"Content-Type": "application/json", **headers} + + return requests.Request( + method, + url, + data=orjson_compat.dumps(self._prepare_data(data)), + params=self._prepare_params(params), + headers=self._prepare_headers(headers), + ) + + class FormRequestEncoder(RequestEncoder): """ This encoder formats data as a form-encoded body. It requires that the input data is @@ -138,6 +178,8 @@ def encode( data: t.Any, headers: dict[str, str], ) -> requests.Request: + import requests + if not isinstance(data, dict): raise TypeError("FormRequestEncoder cannot encode non-dict data") return requests.Request( diff --git a/src/globus_sdk/transport/requests.py b/src/globus_sdk/transport/requests.py index 986bf9b3d..746295f03 100644 --- a/src/globus_sdk/transport/requests.py +++ b/src/globus_sdk/transport/requests.py @@ -1,18 +1,18 @@ from __future__ import annotations import contextlib +import functools import logging import pathlib import time import typing as t -import requests - from globus_sdk import __version__, config, exc -from globus_sdk.authorizers import GlobusAuthorizer +from globus_sdk.transport.decoders import OrjsonResponseDecoder, ResponseDecoder from globus_sdk.transport.encoders import ( FormRequestEncoder, JSONRequestEncoder, + OrjsonRequestEncoder, RequestEncoder, ) @@ -22,9 +22,17 @@ from .retry_check_runner import RetryCheckRunner from .retry_config import RetryConfig +if t.TYPE_CHECKING: + import requests + + from globus_sdk.authorizers import GlobusAuthorizer + log = logging.getLogger(__name__) +_DEFAULT_JSON_ENCODER = JSONRequestEncoder() + + class RequestsTransport: """ The RequestsTransport handles HTTP request sending and retries. @@ -44,6 +52,12 @@ class RequestsTransport: defaults to 60s but can be set via the ``GLOBUS_SDK_HTTP_TIMEOUT`` environment variable. Any value set via this parameter takes precedence over the environment variable. + :param use_orjson: Enable use of the 'orjson' library to encode requests and decode + responses. In future versions of the SDK, this will default to True when orjson + is installed. + Defaults to False but can be set via the ``GLOBUS_SDK_USE_ORJSON`` environment + variable. + Enabling this when 'orjson' is not installed results in errors. :ivar dict[str, str] headers: The headers which are sent on every request. These may be augmented by the transport when sending requests. @@ -52,10 +66,16 @@ class RequestsTransport: #: default maximum number of retries DEFAULT_MAX_RETRIES = 5 - #: the encoders are a mapping of encoding names to encoder objects - encoders: dict[str, RequestEncoder] = { + #: The encoders are a mapping of encoding names to encoder objects. + #: + #: .. warning:: + #: + #: This interface is deprecated, in favor of the instance-level + #: ``encoder_map``. This is used to seed that mapping per instance + #: and will be removed in a future release. + encoders: t.ClassVar[dict[str, RequestEncoder]] = { "text": RequestEncoder(), - "json": JSONRequestEncoder(), + "json": _DEFAULT_JSON_ENCODER, "form": FormRequestEncoder(), } @@ -65,10 +85,14 @@ def __init__( self, verify_ssl: bool | str | pathlib.Path | None = None, http_timeout: float | None = None, + use_orjson: bool | None = None, ) -> None: + import requests + self.session = requests.Session() self.verify_ssl = config.get_ssl_verify(verify_ssl) self.http_timeout = config.get_http_timeout(http_timeout) + self.use_orjson = config.get_use_orjson(use_orjson) self._user_agent = self.BASE_USER_AGENT self.globus_client_info: GlobusClientInfo = GlobusClientInfo( update_callback=self._handle_clientinfo_update @@ -79,6 +103,24 @@ def __init__( "X-Globus-Client-Info": self.globus_client_info.format(), } + self.encoder_map = self._initialize_encoder_map() + self.decoder = self._initialize_decoder() + + def _initialize_encoder_map(self) -> dict[str, RequestEncoder]: + # copy and return the class-level mapping + # replace the "json" element only if it *is* the default encoder + # meaning that the mapping was not modified by the user + mapping = self.encoders.copy() + if self.use_orjson and mapping["json"] is _DEFAULT_JSON_ENCODER: + mapping["json"] = OrjsonRequestEncoder() + return mapping + + def _initialize_decoder(self) -> ResponseDecoder: + if self.use_orjson: + return OrjsonResponseDecoder() + else: + return ResponseDecoder() + def close(self) -> None: """ Closes all resources owned by the transport, primarily the underlying @@ -162,6 +204,12 @@ def tune( self.http_timeout, ) = saved_settings + @functools.cached_property + def decoder(self) -> ResponseDecoder: + if self.use_orjson: + return OrjsonResponseDecoder() + return ResponseDecoder() + def _encode( self, method: str, @@ -180,14 +228,19 @@ def _encode( if isinstance(data, (bytes, str)): encoding = "text" else: - encoding = "json" + if self.use_orjson: + encoding = "orjson" + else: + encoding = "json" - if encoding not in self.encoders: + if encoding not in self.encoder_map: raise ValueError( f"Unknown encoding '{encoding}' is not supported by this transport." ) - return self.encoders[encoding].encode(method, url, query_params, data, headers) + return self.encoder_map[encoding].encode( + method, url, query_params, data, headers + ) def _set_authz_header( self, authorizer: GlobusAuthorizer | None, req: requests.Request @@ -250,6 +303,8 @@ def request( :return: ``requests.Response`` object """ + import requests + log.debug("starting request for %s", url) resp: requests.Response | None = None req = self._encode(method, url, query_params, data, headers, encoding) @@ -264,7 +319,9 @@ def request( # done fresh for each request, to handle potential for refreshed credentials self._set_authz_header(caller_info.authorizer, req) - ctx = RetryContext(attempt, caller_info=caller_info) + ctx = RetryContext( + attempt, response_decoder=self.decoder, caller_info=caller_info + ) try: log.debug("request about to send") resp = ctx.response = self.session.send( diff --git a/src/globus_sdk/transport/retry.py b/src/globus_sdk/transport/retry.py index 08d46e70d..50c1a111e 100644 --- a/src/globus_sdk/transport/retry.py +++ b/src/globus_sdk/transport/retry.py @@ -3,10 +3,11 @@ import enum import typing as t -import requests - if t.TYPE_CHECKING: + import requests + from .caller_info import RequestCallerInfo + from .decoders import ResponseDecoder C = t.TypeVar("C", bound=t.Callable[..., t.Any]) @@ -26,6 +27,9 @@ class RetryContext: or ``exception`` will be present. :param attempt: The request attempt number, starting at 0. + :param response_decoder: The response decoder which the transport layer is using to + read response data. Checks should prefer to use this decoder when reading + responses. :param caller_info: Contextual information about the caller, including authorizer :param response: The response on a successful request :param exception: The error raised when trying to send the request @@ -35,12 +39,15 @@ def __init__( self, attempt: int, *, + response_decoder: ResponseDecoder, caller_info: RequestCallerInfo, response: requests.Response | None = None, exception: Exception | None = None, ) -> None: # retry attempt number self.attempt = attempt + # the response decoder shares a choice of implementation for decoding + self.response_decoder = response_decoder # caller info provides contextual information about the request self.caller_info = caller_info # the response or exception from a request diff --git a/tests/common/response_mock.py b/tests/common/response_mock.py index 7008f94eb..b9602bbb6 100644 --- a/tests/common/response_mock.py +++ b/tests/common/response_mock.py @@ -48,6 +48,10 @@ def json(self): else: raise ValueError("globus sdk mock value error") + @property + def content(self): + return json.dumps(self.json()).encode() + def __getstate__(self): """Custom getstate discards most of the magical mock stuff""" keys = ["headers", "text", "_json_body", "status_code"] diff --git a/tests/conftest.py b/tests/conftest.py index 33c0a4be1..406ea8fb3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,6 +4,7 @@ import responses import globus_sdk +from globus_sdk.transport.decoders import ResponseDecoder @pytest.fixture(autouse=True) @@ -27,7 +28,20 @@ def mocked_responses(): @pytest.fixture -def make_response(): +def mock_client_factory(): + def build(): + client = mock.Mock() + # because responses are deserialized using the decoder attached to the client + # we need to ensure it is populated if we want to be able to pass the mock + # clients to response classes and get good behavior + client.transport.decoder = ResponseDecoder() + return client + + return build + + +@pytest.fixture +def make_response(mock_client_factory): def _make_response( response_class=None, status=200, @@ -48,7 +62,7 @@ def _make_response( status, headers=headers, json_body=json_body, text=text ) http_res = globus_sdk.GlobusHTTPResponse( - r, client=client if client is not None else mock.Mock() + r, client=client if client is not None else mock_client_factory() ) if response_class is not None: return response_class(http_res) diff --git a/tests/functional/base_client/test_encodings.py b/tests/functional/base_client/test_encodings.py index 81aa7fb26..2f57bbab7 100644 --- a/tests/functional/base_client/test_encodings.py +++ b/tests/functional/base_client/test_encodings.py @@ -1,6 +1,16 @@ +from __future__ import annotations + +import json +import typing as t + import pytest +import requests import responses +import globus_sdk +from globus_sdk.transport import RequestEncoder, RequestsTransport +from globus_sdk.transport.decoders import ResponseDecoder + def test_cannot_encode_dict_as_text(client): with pytest.raises(TypeError): @@ -45,3 +55,86 @@ def test_text_encoding_can_send_non_ascii_utf8_bytes(client): last_req = responses.calls[-1].request assert last_req.body == '{"field“: "value“}'.encode() + + +def test_can_configure_custom_encoding(client_class): + class MyRequestEncoder(RequestEncoder): + def encode( + self, + method: str, + url: str, + params: dict[str, t.Any] | None, + data: t.Any, + headers: dict[str, str], + ) -> requests.Request: + if data is not None: + headers = {"Content-Type": "application/json", **headers} + + return requests.Request( + method, + url, + json={"foo": self._prepare_data(data)}, + params=self._prepare_params(params), + headers=self._prepare_headers(headers), + ) + + my_transport = RequestsTransport() + my_transport.encoder_map["myjson"] = MyRequestEncoder() + client = client_class(transport=my_transport) + + responses.add(responses.POST, "https://foo.api.globus.org/bar", body="hi") + client.post("/bar", data={"baz": 1}, encoding="myjson") + + my_transport.close() + + last_req = responses.calls[-1].request + assert json.loads(last_req.body) == {"foo": {"baz": 1}} + + +def test_can_configure_custom_decoding(client_class): + class MyDecoder(ResponseDecoder): + def get_body_json(self, response: requests.Response) -> t.Any: + return {"a": "clever-cultural-reference-goes-here"} + + my_transport = RequestsTransport() + my_transport.decoder = MyDecoder() + client = client_class(transport=my_transport) + + responses.add(responses.POST, "https://foo.api.globus.org/bar", body="hi") + response = client.post("/bar", data={"baz": 1}) + + my_transport.close() + + # the raw text is available + assert response.text == "hi" + # but the decoded data is whatever the decoder says + assert response.data == {"a": "clever-cultural-reference-goes-here"} + + +def test_custom_decoding_applies_to_errors(client_class): + class MyDecoder(ResponseDecoder): + def get_body_json(self, response: requests.Response) -> t.Any: + return {"a": "clever-cultural-reference-goes-here"} + + my_transport = RequestsTransport() + my_transport.decoder = MyDecoder() + client = client_class(transport=my_transport) + + responses.add( + responses.POST, + "https://foo.api.globus.org/bar", + body="bye", + status=404, + headers={"Content-Type": "application/json"}, + ) + with pytest.raises(globus_sdk.GlobusAPIError) as excinfo: + client.post("/bar", data={"baz": 1}) + + my_transport.close() + + err = excinfo.value + + # the raw text is available + assert err.text == "bye" + # but the decoded data is whatever the decoder says + assert err.raw_json == {"a": "clever-cultural-reference-goes-here"} diff --git a/tests/unit/globus_app/test_authorizer_factory.py b/tests/unit/globus_app/test_authorizer_factory.py index 3982d0f51..3943b2547 100644 --- a/tests/unit/globus_app/test_authorizer_factory.py +++ b/tests/unit/globus_app/test_authorizer_factory.py @@ -90,7 +90,7 @@ def test_access_token_authorizer_factory_expired_access_token(): factory.get_authorizer("rs1") -def test_refresh_token_authorizer_factory(): +def test_refresh_token_authorizer_factory(mock_client_factory): initial_response = make_mock_token_response() mock_token_storage = _make_mem_token_storage( validators=(HasRefreshTokensValidator(),) @@ -98,7 +98,7 @@ def test_refresh_token_authorizer_factory(): mock_token_storage.store_token_response(initial_response) refresh_data = make_mock_token_response(token_number=2) - mock_auth_login_client = mock.Mock() + mock_auth_login_client = mock_client_factory() mock_refresh = mock.Mock() mock_refresh.return_value = refresh_data mock_auth_login_client.oauth2_refresh_token = mock_refresh @@ -127,7 +127,7 @@ def test_refresh_token_authorizer_factory(): assert authorizer3.get_authorization_header() == "Bearer rs1_access_token_1" -def test_refresh_token_authorizer_factory_expired_access_token(): +def test_refresh_token_authorizer_factory_expired_access_token(mock_client_factory): initial_response = make_mock_token_response() initial_response.by_resource_server["rs1"]["expires_at_seconds"] = int( time.time() - 3600 @@ -139,7 +139,7 @@ def test_refresh_token_authorizer_factory_expired_access_token(): mock_token_storage.store_token_response(initial_response) refresh_data = make_mock_token_response(token_number=2) - mock_auth_login_client = mock.Mock() + mock_auth_login_client = mock_client_factory() mock_refresh = mock.Mock() mock_refresh.return_value = refresh_data mock_auth_login_client.oauth2_refresh_token = mock_refresh diff --git a/tests/unit/responses/conftest.py b/tests/unit/responses/conftest.py index d8b00f5ff..637822847 100644 --- a/tests/unit/responses/conftest.py +++ b/tests/unit/responses/conftest.py @@ -4,7 +4,7 @@ @pytest.fixture -def make_oauth_token_response(make_response): +def make_oauth_token_response(make_response, mock_client_factory): """ response with conveniently formatted names to help with iteration in tests """ @@ -40,14 +40,14 @@ def f(client=None): }, ], }, - client=client, + client=client if client is not None else mock_client_factory(), ) return f @pytest.fixture -def make_oauth_dependent_token_response(make_response): +def make_oauth_dependent_token_response(make_response, mock_client_factory): """ response with conveniently formatted names to help with iteration in tests """ @@ -75,7 +75,7 @@ def f(client=None): "token_type": "bearer", }, ], - client=client, + client=client if client is not None else mock_client_factory(), ) return f diff --git a/tests/unit/responses/test_response.py b/tests/unit/responses/test_response.py index 591eeb298..6271e9e03 100644 --- a/tests/unit/responses/test_response.py +++ b/tests/unit/responses/test_response.py @@ -38,45 +38,55 @@ def _response(data=None, encoding="utf-8", headers=None, status: int = 200): return r -def _mk_json_response(data): - json_response = _response(data) - return _TestResponse(data, GlobusHTTPResponse(json_response, client=mock.Mock())) +@pytest.fixture +def response_factory(mock_client_factory): + def build(data, *args, **kwargs): + return _TestResponse( + data, GlobusHTTPResponse(*args, **kwargs, client=mock_client_factory()) + ) + + return build + + +@pytest.fixture +def json_response_factory(response_factory): + def build(data): + json_response = _response(data) + return response_factory(data, json_response) + + return build @pytest.fixture -def dict_response(): - return _mk_json_response({"label1": "value1", "label2": "value2"}) +def dict_response(json_response_factory): + return json_response_factory({"label1": "value1", "label2": "value2"}) @pytest.fixture -def list_response(): - return _mk_json_response(["value1", "value2", "value3"]) +def list_response(json_response_factory): + return json_response_factory(["value1", "value2", "value3"]) @pytest.fixture -def http_no_content_type_response(): +def http_no_content_type_response(response_factory): res = _response() assert "Content-Type" not in res.headers - return _TestResponse(None, GlobusHTTPResponse(res, client=mock.Mock())) + return response_factory(None, res) @pytest.fixture -def malformed_http_response(): +def malformed_http_response(response_factory): malformed_response = _response(b"{", headers={"Content-Type": "application/json"}) - return _TestResponse( - "{", GlobusHTTPResponse(malformed_response, client=mock.Mock()) - ) + return response_factory("{", malformed_response) @pytest.fixture -def text_http_response(): +def text_http_response(response_factory): text_data = "text data" text_response = _response( text_data, encoding="utf-8", headers={"Content-Type": "text/plain"} ) - return _TestResponse( - text_data, GlobusHTTPResponse(text_response, client=mock.Mock()) - ) + return response_factory(text_data, text_response) def test_data( @@ -109,7 +119,7 @@ def test_str(dict_response, list_response): assert "nonexistent" not in str(list_response.r) -def test_text_response_repr_and_str_contain_raw_data(): +def test_text_response_repr_and_str_contain_raw_data(mock_client_factory): expect_text = """pu-erh is a distinctive aged tea primarily produced in Yunnan depending on the tea used and how it is aged, it can be bright, floral, and fruity @@ -118,7 +128,7 @@ def test_text_response_repr_and_str_contain_raw_data(): raw = _response( expect_text, encoding="utf-8", headers={"Content-Type": "text/plain"} ) - res = GlobusHTTPResponse(raw, client=mock.Mock()) + res = GlobusHTTPResponse(raw, client=mock_client_factory()) assert expect_text in repr(res) assert expect_text in str(res) @@ -153,30 +163,33 @@ def test_contains(dict_response, list_response, text_http_response): assert "foo" not in text_http_response.r -def test_bool(dict_response, list_response): +def test_bool(dict_response, list_response, json_response_factory): assert bool(dict_response) is True assert bool(list_response) is True - empty_dict, empty_list = _mk_json_response({}), _mk_json_response([]) + empty_dict, empty_list = ( + json_response_factory({}), + json_response_factory([]), + ) assert bool(empty_dict.r) is False assert bool(empty_list.r) is False - null = _mk_json_response(None) + null = json_response_factory(None) assert bool(null.r) is False -def test_len_array(list_response): +def test_len_array(list_response, json_response_factory): array = ArrayResponse(list_response.r) assert len(array) == len(list_response.data) - empty_list = _mk_json_response([]) + empty_list = json_response_factory([]) empty_array = ArrayResponse(empty_list.r) assert len(empty_list.data) == 0 assert len(empty_array) == 0 -def test_len_array_bad_data(dict_response): - null_array = ArrayResponse(_mk_json_response(None).r) +def test_len_array_bad_data(dict_response, json_response_factory): + null_array = ArrayResponse(json_response_factory(None).r) with pytest.raises( TypeError, match=re.escape( @@ -193,8 +206,8 @@ def test_len_array_bad_data(dict_response): len(dict_array) -def test_iter_array_bad_data(dict_response): - null_array = ArrayResponse(_mk_json_response(None).r) +def test_iter_array_bad_data(dict_response, json_response_factory): + null_array = ArrayResponse(json_response_factory(None).r) with pytest.raises( TypeError, match=re.escape("Cannot iterate on ArrayResponse data when type is 'NoneType'"), @@ -249,55 +262,59 @@ def test_no_content_type_header(http_no_content_type_response): assert http_no_content_type_response.r.content_type is None -def test_client_required_with_requests_response(): +def test_client_required_with_requests_response(mock_client_factory): r = _response({"foo": 1}) - GlobusHTTPResponse(r, client=mock.Mock()) # ok + GlobusHTTPResponse(r, client=mock_client_factory()) # ok with pytest.raises(ValueError): GlobusHTTPResponse(r) # not ok -def test_client_forbidden_when_wrapping(): +def test_client_forbidden_when_wrapping(mock_client_factory): r = _response({"foo": 1}) - to_wrap = GlobusHTTPResponse(r, client=mock.Mock()) + to_wrap = GlobusHTTPResponse(r, client=mock_client_factory()) GlobusHTTPResponse(to_wrap) # ok with pytest.raises(ValueError): - GlobusHTTPResponse(to_wrap, client=mock.Mock()) # not ok + GlobusHTTPResponse(to_wrap, client=mock_client_factory()) # not ok -def test_value_error_indexing_on_non_json_data(): +def test_value_error_indexing_on_non_json_data(mock_client_factory): r = _response(b"foo: bar, baz: buzz") - res = GlobusHTTPResponse(r, client=mock.Mock()) + res = GlobusHTTPResponse(r, client=mock_client_factory()) with pytest.raises(ValueError): res["foo"] -def test_cannot_construct_base_iterable_response(): +def test_cannot_construct_base_iterable_response(mock_client_factory): r = _response(b"foo: bar, baz: buzz") with pytest.raises(TypeError): - IterableResponse(r, client=mock.Mock()) + IterableResponse(r, client=mock_client_factory()) -def test_iterable_response_using_iter_key(): +def test_iterable_response_using_iter_key(mock_client_factory): class MyIterableResponse(IterableResponse): default_iter_key = "default_iter" raw = _response({"default_iter": [0, 1], "other_iter": [3, 4]}) - default = MyIterableResponse(raw, client=mock.Mock()) + default = MyIterableResponse(raw, client=mock_client_factory()) assert list(default) == [0, 1] - withkey = MyIterableResponse(raw, client=mock.Mock(), iter_key="other_iter") + withkey = MyIterableResponse( + raw, client=mock_client_factory(), iter_key="other_iter" + ) assert list(withkey) == [3, 4] -def test_iterable_response_errors_on_non_dict_data(list_response): +def test_iterable_response_errors_on_non_dict_data( + list_response, json_response_factory +): class MyIterableResponse(IterableResponse): default_iter_key = "default_iter" list_iterable = MyIterableResponse(list_response.r) - null_iterable = MyIterableResponse(_mk_json_response(None).r) + null_iterable = MyIterableResponse(json_response_factory(None).r) with pytest.raises( TypeError, @@ -321,45 +338,53 @@ def test_can_iter_array_response(list_response): assert list(reversed(arr)) == list(reversed(list_response.data)) -def test_http_status_code_on_response(): +def test_http_status_code_on_response(mock_client_factory): r1 = _response(status=404) assert r1.status_code == 404 - r2 = GlobusHTTPResponse(r1, client=mock.Mock()) # handle a Response object + r2 = GlobusHTTPResponse( + r1, client=mock_client_factory() + ) # handle a Response object assert r2.http_status == 404 r3 = GlobusHTTPResponse(r2) # wrap another response assert r3.http_status == 404 -def test_http_reason_on_response(): +def test_http_reason_on_response(mock_client_factory): r1 = _response(status=404) - r2 = GlobusHTTPResponse(r1, client=mock.Mock()) # handle a Response object + r2 = GlobusHTTPResponse( + r1, client=mock_client_factory() + ) # handle a Response object r3 = GlobusHTTPResponse(r2) # wrap another response assert r1.reason == "Not Found" assert r2.http_reason == "Not Found" assert r3.http_reason == "Not Found" r4 = _response(status=200) - r5 = GlobusHTTPResponse(r4, client=mock.Mock()) # handle a Response object + r5 = GlobusHTTPResponse( + r4, client=mock_client_factory() + ) # handle a Response object r6 = GlobusHTTPResponse(r5) # wrap another response assert r4.reason == "OK" assert r5.http_reason == "OK" assert r6.http_reason == "OK" -def test_http_headers_from_response(): +def test_http_headers_from_response(mock_client_factory): r1 = _response(headers={"Content-Length": "5"}) assert r1.headers["content-length"] == "5" - r2 = GlobusHTTPResponse(r1, client=mock.Mock()) # handle a Response object + r2 = GlobusHTTPResponse( + r1, client=mock_client_factory() + ) # handle a Response object assert r2.headers["content-length"] == "5" r3 = GlobusHTTPResponse(r2) # wrap another response assert r3.headers["content-length"] == "5" -def test_streaming_response_does_not_read_body_on_init(): +def test_streaming_response_does_not_read_body_on_init(mock_client_factory): # create a streaming response with a trivial body responses.add("GET", "https://www.globus.org/", json={}) requests_response = requests.get("https://www.globus.org/", stream=True) @@ -371,7 +396,9 @@ def test_streaming_response_does_not_read_body_on_init(): requests_response.raw, "stream", side_effect=RuntimeError("ohnoez") ): # no error on init - sdk_response = GlobusHTTPResponse(requests_response, client=mock.Mock()) + sdk_response = GlobusHTTPResponse( + requests_response, client=mock_client_factory() + ) # but accessing data causes a read, and therefore an error from streaming with pytest.raises(RuntimeError, match="ohnoez"): diff --git a/tests/unit/services/auth/test_id_token_decoder.py b/tests/unit/services/auth/test_id_token_decoder.py index 883d83971..a45b2c2dc 100644 --- a/tests/unit/services/auth/test_id_token_decoder.py +++ b/tests/unit/services/auth/test_id_token_decoder.py @@ -39,15 +39,15 @@ def get_jwk(self): return mock.Mock() -def test_decoding_defaults_to_client_id_as_audience(): - fake_client = mock.Mock() - fake_client.client_id = str(uuid.uuid1()) +def test_decoding_defaults_to_client_id_as_audience(mock_client_factory): + client = mock_client_factory() + client.client_id = str(uuid.uuid1()) - decoder = MockDecoder(fake_client) + decoder = MockDecoder(client) with mock.patch("jwt.decode") as mock_jwt_decode: decoder.decode("") - assert mock_jwt_decode.call_args.kwargs["audience"] == fake_client.client_id + assert mock_jwt_decode.call_args.kwargs["audience"] == client.client_id @pytest.mark.parametrize("audience_value", (None, "myaud")) @@ -63,11 +63,11 @@ def get_jwt_audience(self): assert mock_jwt_decode.call_args.kwargs["audience"] == audience_value -def test_setting_oidc_config_on_default_decoder_unpacks_data(): +def test_setting_oidc_config_on_default_decoder_unpacks_data(mock_client_factory): oidc_config = {"x": 1} raw_response = mock.Mock(spec=requests.Response) raw_response.json.return_value = oidc_config - response = globus_sdk.GlobusHTTPResponse(raw_response, client=mock.Mock()) + response = globus_sdk.GlobusHTTPResponse(raw_response, client=mock_client_factory()) decoder = globus_sdk.IDTokenDecoder(mock.Mock()) decoder.store_openid_configuration(response) diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 06b506c90..96da97e2d 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -211,3 +211,19 @@ def test_service_url_from_env_var(): globus_sdk.config.get_webapp_url() == f"https://app.{env}.globuscs.info/" ) + + +@pytest.mark.parametrize( + "value, expected_result", + [(x, True) for x in ["1", "YES", "true", "t", "True", "ON"]] + + [(x, False) for x in ["0", "NO", "false", "f", "False", "OFF"]] + + [("invalid", ValueError), ("1.0", ValueError)], +) +def test_get_use_orjson(value, expected_result, monkeypatch): + monkeypatch.setenv("GLOBUS_SDK_USE_ORJSON", value) + if expected_result is not ValueError: + assert globus_sdk.config.get_use_orjson() == expected_result + + else: + with pytest.raises(expected_result): + globus_sdk.config.get_use_orjson() diff --git a/tests/unit/test_paging.py b/tests/unit/test_paging.py index 85b96afa1..d5d469abd 100644 --- a/tests/unit/test_paging.py +++ b/tests/unit/test_paging.py @@ -1,5 +1,4 @@ import json -from unittest import mock import pytest import requests @@ -12,7 +11,8 @@ class PagingSimulator: - def __init__(self, n) -> None: + def __init__(self, client, n) -> None: + self.client = client self.n = n # the number of simulated items def simulate_get(self, *args, **params): @@ -36,12 +36,13 @@ def simulate_get(self, *args, **params): response = requests.Response() response._content = json.dumps(data).encode() response.headers["Content-Type"] = "application/json" - return IterableTransferResponse(GlobusHTTPResponse(response, mock.Mock())) + return IterableTransferResponse(GlobusHTTPResponse(response, self.client)) class JSONAPIPagingSimulator: - def __init__(self, n) -> None: + def __init__(self, client, n) -> None: + self.client = client self.n = n # the number of simulated items self.page_size = 10 # arbitrary page size @@ -85,17 +86,17 @@ def simulate_get(self, *args, **params): response = requests.Response() response._content = json.dumps(response_top_level).encode() response.headers["Content-Type"] = "application/json" - return IterableJSONAPIResponse(GlobusHTTPResponse(response, mock.Mock())) + return IterableJSONAPIResponse(GlobusHTTPResponse(response, self.client)) @pytest.fixture -def paging_simulator(): - return PagingSimulator(N) +def paging_simulator(mock_client_factory): + return PagingSimulator(mock_client_factory(), N) @pytest.fixture -def jsonapi_paging_simulator(): - return JSONAPIPagingSimulator(N) +def jsonapi_paging_simulator(mock_client_factory): + return JSONAPIPagingSimulator(mock_client_factory(), N) def test_has_next_paginator(paging_simulator): diff --git a/tests/unit/transport/test_default_retry_policy.py b/tests/unit/transport/test_default_retry_policy.py index fd533300e..df7d80ea0 100644 --- a/tests/unit/transport/test_default_retry_policy.py +++ b/tests/unit/transport/test_default_retry_policy.py @@ -10,6 +10,7 @@ RetryConfig, RetryContext, ) +from globus_sdk.transport.decoders import ResponseDecoder from globus_sdk.transport.default_retry_checks import ( DEFAULT_RETRY_CHECKS, check_retry_after_header, @@ -28,7 +29,12 @@ def test_retry_policy_respects_retry_after(mocksleep, http_status): dummy_response.headers = {"Retry-After": "5"} dummy_response.status_code = http_status caller_info = RequestCallerInfo(retry_config=retry_config) - ctx = RetryContext(1, caller_info=caller_info, response=dummy_response) + ctx = RetryContext( + 1, + response_decoder=ResponseDecoder(), + caller_info=caller_info, + response=dummy_response, + ) assert checker.should_retry(ctx) is True mocksleep.assert_not_called() @@ -48,7 +54,12 @@ def test_retry_policy_ignores_retry_after_too_high(mocksleep, http_status): dummy_response.headers = {"Retry-After": "20"} dummy_response.status_code = http_status caller_info = RequestCallerInfo(retry_config=retry_config) - ctx = RetryContext(1, caller_info=caller_info, response=dummy_response) + ctx = RetryContext( + 1, + response_decoder=ResponseDecoder(), + caller_info=caller_info, + response=dummy_response, + ) assert checker.should_retry(ctx) is True mocksleep.assert_not_called() @@ -67,7 +78,12 @@ def test_retry_policy_ignores_malformed_retry_after(mocksleep, http_status): dummy_response.headers = {"Retry-After": "not-an-integer"} dummy_response.status_code = http_status caller_info = RequestCallerInfo(retry_config=retry_config) - ctx = RetryContext(1, caller_info=caller_info, response=dummy_response) + ctx = RetryContext( + 1, + response_decoder=ResponseDecoder(), + caller_info=caller_info, + response=dummy_response, + ) assert checker.should_retry(ctx) is True mocksleep.assert_not_called() @@ -84,5 +100,10 @@ def test_default_retry_check_noop_on_exception(check_method, mocksleep): retry_config = RetryConfig() retry_config.checks.register_many_checks(DEFAULT_RETRY_CHECKS) caller_info = RequestCallerInfo(retry_config=retry_config) - ctx = RetryContext(1, caller_info=caller_info, exception=Exception("foo")) + ctx = RetryContext( + 1, + response_decoder=ResponseDecoder(), + caller_info=caller_info, + exception=Exception("foo"), + ) assert check_method(ctx) is RetryCheckResult.no_decision diff --git a/tests/unit/transport/test_retry_check_runner.py b/tests/unit/transport/test_retry_check_runner.py index c4105d456..6bc6a6c2a 100644 --- a/tests/unit/transport/test_retry_check_runner.py +++ b/tests/unit/transport/test_retry_check_runner.py @@ -7,6 +7,7 @@ RetryConfig, RetryContext, ) +from globus_sdk.transport.decoders import ResponseDecoder from globus_sdk.transport.default_retry_checks import DEFAULT_RETRY_CHECKS @@ -15,13 +16,28 @@ def _make_test_retry_context(*, status=200, exception=None, response=None): retry_config.checks.register_many_checks(DEFAULT_RETRY_CHECKS) caller_info = RequestCallerInfo(retry_config=retry_config) if exception: - return RetryContext(1, caller_info=caller_info, exception=exception) + return RetryContext( + 1, + response_decoder=ResponseDecoder(), + caller_info=caller_info, + exception=exception, + ) elif response: - return RetryContext(1, caller_info=caller_info, response=response) + return RetryContext( + 1, + response_decoder=ResponseDecoder(), + caller_info=caller_info, + response=response, + ) dummy_response = mock.Mock() dummy_response.status_code = 200 - return RetryContext(1, caller_info=caller_info, response=dummy_response) + return RetryContext( + 1, + response_decoder=ResponseDecoder(), + caller_info=caller_info, + response=dummy_response, + ) def test_retry_check_runner_should_retry_explicit_on_first_check(): diff --git a/tests/unit/transport/test_transfer_transport.py b/tests/unit/transport/test_transfer_transport.py index cee273260..bd64376fe 100644 --- a/tests/unit/transport/test_transfer_transport.py +++ b/tests/unit/transport/test_transfer_transport.py @@ -8,6 +8,7 @@ RetryConfig, RetryContext, ) +from globus_sdk.transport.decoders import ResponseDecoder from globus_sdk.transport.default_retry_checks import DEFAULT_RETRY_CHECKS @@ -44,7 +45,12 @@ def test_transfer_does_not_retry_external(): dummy_response.json = lambda: body dummy_response.status_code = 502 caller_info = RequestCallerInfo(retry_config=retry_config) - ctx = RetryContext(1, caller_info=caller_info, response=dummy_response) + ctx = RetryContext( + 1, + response_decoder=ResponseDecoder(), + caller_info=caller_info, + response=dummy_response, + ) assert checker.should_retry(ctx) is False @@ -69,7 +75,12 @@ def test_transfer_does_not_retry_endpoint_error(): dummy_response.json = lambda: body dummy_response.status_code = 502 caller_info = RequestCallerInfo(retry_config=retry_config) - ctx = RetryContext(1, caller_info=caller_info, response=dummy_response) + ctx = RetryContext( + 1, + response_decoder=ResponseDecoder(), + caller_info=caller_info, + response=dummy_response, + ) assert checker.should_retry(ctx) is False @@ -86,6 +97,11 @@ def _raise_value_error(): dummy_response.json = _raise_value_error dummy_response.status_code = 502 caller_info = RequestCallerInfo(retry_config=retry_config) - ctx = RetryContext(1, caller_info=caller_info, response=dummy_response) + ctx = RetryContext( + 1, + response_decoder=ResponseDecoder(), + caller_info=caller_info, + response=dummy_response, + ) assert checker.should_retry(ctx) is True diff --git a/tests/unit/transport/test_transfer_v2_transport.py b/tests/unit/transport/test_transfer_v2_transport.py index eec4b8331..9bd0a3fa5 100644 --- a/tests/unit/transport/test_transfer_v2_transport.py +++ b/tests/unit/transport/test_transfer_v2_transport.py @@ -12,6 +12,7 @@ RetryConfig, RetryContext, ) +from globus_sdk.transport.decoders import ResponseDecoder from globus_sdk.transport.default_retry_checks import DEFAULT_RETRY_CHECKS @@ -152,7 +153,12 @@ def test_transfer_v2_default_retry_checks(body, status_code, expected_should_ret dummy_response.json = lambda: body dummy_response.status_code = status_code caller_info = RequestCallerInfo(retry_config=retry_config) - ctx = RetryContext(1, caller_info=caller_info, response=dummy_response) + ctx = RetryContext( + 1, + response_decoder=ResponseDecoder(), + caller_info=caller_info, + response=dummy_response, + ) assert checker.should_retry(ctx) is expected_should_retry @@ -170,6 +176,11 @@ def _raise_value_error(): dummy_response.json = _raise_value_error dummy_response.status_code = 502 caller_info = RequestCallerInfo(retry_config=retry_config) - ctx = RetryContext(1, caller_info=caller_info, response=dummy_response) + ctx = RetryContext( + 1, + response_decoder=ResponseDecoder(), + caller_info=caller_info, + response=dummy_response, + ) assert checker.should_retry(ctx) is True diff --git a/tests/unit/transport/test_transport.py b/tests/unit/transport/test_transport.py index 3a86ec83f..8784de848 100644 --- a/tests/unit/transport/test_transport.py +++ b/tests/unit/transport/test_transport.py @@ -4,8 +4,17 @@ import pytest from globus_sdk.transport import RequestsTransport, RetryConfig, RetryContext +from globus_sdk.transport.decoders import OrjsonResponseDecoder +from globus_sdk.transport.encoders import OrjsonRequestEncoder from globus_sdk.transport.retry_config import _exponential_backoff +try: + import orjson # noqa: F401 + + has_orjson = True +except ImportError: + has_orjson = False + def _linear_backoff(ctx: RetryContext) -> float: if ctx.backoff is not None: @@ -136,3 +145,20 @@ def test_transport_close_closes_session(): with mock.patch.object(transport, "session") as mocked_session: transport.close() mocked_session.close.assert_called_once_with() + + +@pytest.mark.skipif(has_orjson, reason="test requires that orjson is not installed") +def test_setting_orjson_flag_fails_if_not_installed(): + with pytest.raises( + RuntimeError, + match=r"'orjson' is not available but globus-sdk was configured to use it\.", + ): + RequestsTransport(use_orjson=True) + + +@pytest.mark.skipif(not has_orjson, reason="test requires that orjson is installed") +def test_setting_orjson_flag_works_if_installed(): + transport = RequestsTransport(use_orjson=True) + + assert isinstance(transport.decoder, OrjsonResponseDecoder) + assert isinstance(transport.encoder_map["json"], OrjsonRequestEncoder) diff --git a/tox.ini b/tox.ini index c842d66ee..3ec770253 100644 --- a/tox.ini +++ b/tox.ini @@ -6,6 +6,7 @@ envlist = test-lazy-imports coverage_clean py{3.14,3.13,3.12,3.11,3.10,3.9} + py{3.14,3.9}-orjson py3.9-mindeps py3.11-sphinxext coverage_report @@ -25,6 +26,9 @@ depends = coverage_clean,lint [testenv:py{3.14,3.13,3.12,3.11,3.10,3.9}-mindeps] deps = -r requirements/py{py_dot_ver}/test-mindeps.txt +[testenv:py{3.14,3.13,3.12,3.11,3.10,3.9}-orjson] +deps = -r requirements/py{py_dot_ver}/test-orjson.txt + [testenv:py{3.14,3.13,3.12,3.11,3.10,3.9}-sphinxext] deps = -r requirements/py{py_dot_ver}/test.txt @@ -112,6 +116,7 @@ deps = dependency-groups>=1,<2 commands = python -m dependency_groups test -o requirements/.test.in python -m dependency_groups typing -o requirements/.typing.in + python -m dependency_groups orjson -o requirements/.orjson.in python -m dependency_groups test-mindeps -o requirements/.test-mindeps.in python -m dependency_groups docs -o requirements/.docs.in [testenv:freezedeps-py{3.14,3.13,3.12,3.11,3.10,3.9}] @@ -127,6 +132,8 @@ commands = # Minimum dependencies are only tested against the lowest supported Python version. py3.9: pip-compile --strip-extras -q -U --resolver=backtracking .test-mindeps.in -o py{py_dot_ver}/test-mindeps.txt + # orjson testing happens on every version + pip-compile --strip-extras -q -U --resolver=backtracking -r .test.in -r .orjson.in -o py{py_dot_ver}/test-orjson.txt # The docs requirements are only generated for Python 3.11. py3.11: pip-compile --strip-extras -q -U --resolver=backtracking .docs.in -o py{py_dot_ver}/docs.txt From 9a0c311c5c894b4b2f8b18e67475567c04a943d5 Mon Sep 17 00:00:00 2001 From: Stephen Rosen Date: Thu, 7 May 2026 12:11:30 -0500 Subject: [PATCH 2/2] Pass response decoder via "current transport" 1. Add a contextvar to the transport module, which tracks the "current or active transport" 2. Add a private helper for setting that value 3. Add a *public* method which gets that value; LookupError if none 4. Add a private method which gets the current decoder out of the current transport, with a "safe" failover -- this is purely an internal convenience This replaces GlobusAPIError decoder injection, responses picking the decoder off of their client object, and retry contexts carrying the decoder explicitly. --- ...1_170758_sirosen_define_orjson_encoder.rst | 6 ++ src/globus_sdk/client.py | 5 +- src/globus_sdk/exc/api.py | 35 +-------- .../experimental/transfer_v2/transport.py | 9 ++- src/globus_sdk/globus_app/app.py | 14 ++-- src/globus_sdk/response.py | 9 ++- src/globus_sdk/services/transfer/transport.py | 9 ++- src/globus_sdk/transport/requests.py | 78 ++++++++++++++++++- src/globus_sdk/transport/retry.py | 7 -- .../functional/base_client/test_encodings.py | 2 +- .../transport/test_default_retry_policy.py | 29 +------ .../unit/transport/test_retry_check_runner.py | 22 +----- .../unit/transport/test_transfer_transport.py | 22 +----- .../transport/test_transfer_v2_transport.py | 15 +--- 14 files changed, 127 insertions(+), 135 deletions(-) diff --git a/changelog.d/20260501_170758_sirosen_define_orjson_encoder.rst b/changelog.d/20260501_170758_sirosen_define_orjson_encoder.rst index faba1337a..8083f8ddb 100644 --- a/changelog.d/20260501_170758_sirosen_define_orjson_encoder.rst +++ b/changelog.d/20260501_170758_sirosen_define_orjson_encoder.rst @@ -14,6 +14,12 @@ Added - In a future major version of the SDK, use of ``orjson`` will default to true when it is available. +- ``RequestsTransport`` objects are now visible via + ``RequestsTransport.get_current_transport()``, a staticmethod, while the + transport is sending a request or being used to handle a response. This + method raises a ``LookupError`` if there is no currently active transport. + (:pr:`NUMBER`) + Deprecated ---------- diff --git a/src/globus_sdk/client.py b/src/globus_sdk/client.py index 04a434abe..6b185ebfe 100644 --- a/src/globus_sdk/client.py +++ b/src/globus_sdk/client.py @@ -571,9 +571,10 @@ def request( if 200 <= r.status_code < 400: log.debug(f"request completed with response code: {r.status_code}") - return GlobusHTTPResponse(r, self) + with self.transport._as_current_transport(): + return GlobusHTTPResponse(r, self) log.debug(f"request completed with (error) response code: {r.status_code}") - with exc.GlobusAPIError._inject_response_decoder(self.transport.decoder): + with self.transport._as_current_transport(): err = self.error_class(r) raise err diff --git a/src/globus_sdk/exc/api.py b/src/globus_sdk/exc/api.py index 6580fa6a9..07981ae1c 100644 --- a/src/globus_sdk/exc/api.py +++ b/src/globus_sdk/exc/api.py @@ -1,7 +1,5 @@ from __future__ import annotations -import contextlib -import contextvars import enum import logging import sys @@ -9,7 +7,6 @@ import typing as t from globus_sdk._internal import guards -from globus_sdk.transport.decoders import ResponseDecoder from .base import GlobusError from .err_info import ErrorInfoContainer @@ -21,11 +18,6 @@ _CACHE_SENTINEL = object() -_DEFAULT_DECODER = ResponseDecoder() -_RESPONSE_DECODER: contextvars.ContextVar[ResponseDecoder] = contextvars.ContextVar( - "_RESPONSE_DECODER", default=_DEFAULT_DECODER -) - class _ErrorFormat(enum.Enum): undefined = enum.auto() @@ -51,6 +43,9 @@ class GlobusAPIError(GlobusError): RECOGNIZED_AUTHZ_SCHEMES = ["bearer", "basic", "globus-goauthtoken"] def __init__(self, r: requests.Response, *args: t.Any, **kwargs: t.Any) -> None: + # defer this import to avoid circularity between 'exc' and 'transport' + from globus_sdk.transport import RequestsTransport + self._cached_raw_json: t.Any = _CACHE_SENTINEL self.http_status = r.status_code @@ -63,7 +58,7 @@ def __init__(self, r: requests.Response, *args: t.Any, **kwargs: t.Any) -> None: self._info: ErrorInfoContainer | None = None self._underlying_response = r - self._decoder: ResponseDecoder = _RESPONSE_DECODER.get() + self._decoder = RequestsTransport._safe_get_current_decoder() self._parse_response() if sys.version_info >= (3, 11): @@ -76,28 +71,6 @@ def __init__(self, r: requests.Response, *args: t.Any, **kwargs: t.Any) -> None: ) super().__init__(*self._get_args()) - @staticmethod - @contextlib.contextmanager - def _inject_response_decoder(decoder: ResponseDecoder) -> t.Iterator[None]: - """ - This is a bypass method of passing a parameter into GlobusAPIError.__init__ - even though callers and subclasses of the error type are not aware of the - response decoder. - - A decoder can be set here, temporarily, and will be picked up during error init, - without requiring that subclasses properly pass args to super() or are even - aware of this interaction. - - This is purely for internal use, to pass the decoder used by the transport layer - which produced this error/response through to the errors which may be - subsequently raised. - """ - token = _RESPONSE_DECODER.set(decoder) - try: - yield - finally: - _RESPONSE_DECODER.reset(token) - @property def message(self) -> str | None: """ diff --git a/src/globus_sdk/experimental/transfer_v2/transport.py b/src/globus_sdk/experimental/transfer_v2/transport.py index 8be24dc26..ec097588a 100644 --- a/src/globus_sdk/experimental/transfer_v2/transport.py +++ b/src/globus_sdk/experimental/transfer_v2/transport.py @@ -5,7 +5,12 @@ from __future__ import annotations -from globus_sdk.transport import RetryCheck, RetryCheckResult, RetryContext +from globus_sdk.transport import ( + RequestsTransport, + RetryCheck, + RetryCheckResult, + RetryContext, +) from globus_sdk.transport.default_retry_checks import ( DEFAULT_RETRY_CHECKS, check_transient_error, @@ -28,7 +33,7 @@ def check_transfer_v2_transient_error(ctx: RetryContext) -> RetryCheckResult: if ctx.response is not None and ( ctx.response.status_code in retry_config.transient_error_status_codes ): - decoder = ctx.response_decoder + decoder = RequestsTransport._safe_get_current_decoder() try: # if any of the error objects have a `code` of ExternalError or diff --git a/src/globus_sdk/globus_app/app.py b/src/globus_sdk/globus_app/app.py index d8acd03bb..8daf9f48d 100644 --- a/src/globus_sdk/globus_app/app.py +++ b/src/globus_sdk/globus_app/app.py @@ -29,13 +29,13 @@ ValidatingTokenStorage, ) from globus_sdk.transport import ( + RequestsTransport, RetryCheck, RetryCheckFlags, RetryCheckResult, RetryContext, set_retry_check_flags, ) -from globus_sdk.transport.decoders import ResponseDecoder from .authorizer_factory import AuthorizerFactory from .config import DEFAULT_CONFIG, KNOWN_TOKEN_STORAGES, GlobusAppConfig @@ -579,9 +579,7 @@ def __call__(self, ctx: RetryContext) -> RetryCheckResult: if (resource_server := ctx.caller_info.resource_server) is None: return RetryCheckResult.no_decision - elif ( - gare := self._load_response_gare(ctx.response_decoder, ctx.response) - ) is None: + elif (gare := self._load_response_gare(ctx.response)) is None: return RetryCheckResult.no_decision log.debug("Intercepted re-drivable GARE; initiating app login.") @@ -593,14 +591,14 @@ def __call__(self, ctx: RetryContext) -> RetryCheckResult: return RetryCheckResult.do_retry @staticmethod - def _load_response_gare( - response_decoder: ResponseDecoder, response: Response | None - ) -> GARE | None: + def _load_response_gare(response: Response | None) -> GARE | None: """Return a parsed GARE from a 403 response or None if not possible.""" + decoder = RequestsTransport._safe_get_current_decoder() + if response is None or response.status_code != 403: return None try: - decoded_body = response_decoder.get_body_json(response) + decoded_body = decoder.get_body_json(response) except JSONDecodeError: return None else: diff --git a/src/globus_sdk/response.py b/src/globus_sdk/response.py index 2ba034c42..4f3b69b2b 100644 --- a/src/globus_sdk/response.py +++ b/src/globus_sdk/response.py @@ -7,6 +7,8 @@ from functools import cached_property from globus_sdk._internal import guards +from globus_sdk.transport import RequestsTransport +from globus_sdk.transport.decoders import ResponseDecoder log = logging.getLogger(__name__) @@ -55,6 +57,7 @@ def __init__( self._wrapped: GlobusHTTPResponse | None = response self._response: Response | None = None self.client: globus_sdk.BaseClient = self._wrapped.client + self._response_decoder: ResponseDecoder = response._response_decoder # init on a Response object, this is the "normal" case # _wrapped is None @@ -65,9 +68,9 @@ def __init__( self._response = response self.client = client - # lift the response decoder from the attached client; this will be used - # whenever response data decoding is needed - self._response_decoder = self.client.transport.decoder + # get the response decoder from the current transport; this will be used + # whenever response data decoding is needed + self._response_decoder = RequestsTransport._safe_get_current_decoder() @cached_property def _parsed_json(self) -> t.Any: diff --git a/src/globus_sdk/services/transfer/transport.py b/src/globus_sdk/services/transfer/transport.py index c35cdf3b8..eb880287a 100644 --- a/src/globus_sdk/services/transfer/transport.py +++ b/src/globus_sdk/services/transfer/transport.py @@ -5,7 +5,12 @@ from __future__ import annotations -from globus_sdk.transport import RetryCheck, RetryCheckResult, RetryContext +from globus_sdk.transport import ( + RequestsTransport, + RetryCheck, + RetryCheckResult, + RetryContext, +) from globus_sdk.transport.default_retry_checks import ( DEFAULT_RETRY_CHECKS, check_transient_error, @@ -25,7 +30,7 @@ def check_transfer_transient_error(ctx: RetryContext) -> RetryCheckResult: if ctx.response is not None and ( ctx.response.status_code in retry_config.transient_error_status_codes ): - decoder = ctx.response_decoder + decoder = RequestsTransport._safe_get_current_decoder() try: code = decoder.get_body_json(ctx.response)["code"] diff --git a/src/globus_sdk/transport/requests.py b/src/globus_sdk/transport/requests.py index 746295f03..c8f18959c 100644 --- a/src/globus_sdk/transport/requests.py +++ b/src/globus_sdk/transport/requests.py @@ -1,6 +1,7 @@ from __future__ import annotations import contextlib +import contextvars import functools import logging import pathlib @@ -31,6 +32,13 @@ _DEFAULT_JSON_ENCODER = JSONRequestEncoder() +_DEFAULT_DECODER = ResponseDecoder() + +# a global contextvar provides the SDK with a notion of "current transport object" +# used to retrieve decoders in responses, exceptions, and retry hooks +_CURRENT_TRANSPORT: contextvars.ContextVar[RequestsTransport | None] = ( + contextvars.ContextVar("_CURRENT_TRANSPORT", default=None) +) class RequestsTransport: @@ -128,6 +136,46 @@ def close(self) -> None: """ self.session.close() + @staticmethod + def get_current_transport() -> RequestsTransport: + """ + Get the currently active transport. LookupError if there isn't one. + + Transports are made active by the SDK in the following time windows: + + - while a request is being sent and retried by the transport + - when a base client is constructing an error or response + + Requests may nest (e.g., when doing an auth callout during retries). In such + cases, the current transport is the transport of the innermost request. + """ + value = _CURRENT_TRANSPORT.get() + if value is None: + raise LookupError( + "No current transport is set! " + "The current transport can only be fetched while a transport is active." + ) + return value + + @contextlib.contextmanager + def _as_current_transport(self) -> t.Iterator[None]: + """Mark self as the currently active transport.""" + token = _CURRENT_TRANSPORT.set(self) + try: + yield + finally: + _CURRENT_TRANSPORT.reset(token) + + @staticmethod + def _safe_get_current_decoder() -> ResponseDecoder: + """Retrieve the current transport decoder, with a fallback to the default.""" + try: + transport = RequestsTransport.get_current_transport() + except LookupError: + return _DEFAULT_DECODER + else: + return transport.decoder + @property def user_agent(self) -> str: return self._user_agent @@ -303,6 +351,32 @@ def request( :return: ``requests.Response`` object """ + with self._as_current_transport(): + return self._send_request( + method, + url, + caller_info=caller_info, + query_params=query_params, + data=data, + headers=headers, + encoding=encoding, + allow_redirects=allow_redirects, + stream=stream, + ) + + def _send_request( + self, + method: str, + url: str, + *, + caller_info: RequestCallerInfo, + query_params: dict[str, t.Any] | None = None, + data: dict[str, t.Any] | list[t.Any] | str | bytes | None = None, + headers: dict[str, str] | None = None, + encoding: str | None = None, + allow_redirects: bool = True, + stream: bool = False, + ) -> requests.Response: import requests log.debug("starting request for %s", url) @@ -319,9 +393,7 @@ def request( # done fresh for each request, to handle potential for refreshed credentials self._set_authz_header(caller_info.authorizer, req) - ctx = RetryContext( - attempt, response_decoder=self.decoder, caller_info=caller_info - ) + ctx = RetryContext(attempt, caller_info=caller_info) try: log.debug("request about to send") resp = ctx.response = self.session.send( diff --git a/src/globus_sdk/transport/retry.py b/src/globus_sdk/transport/retry.py index 50c1a111e..d1693dfb2 100644 --- a/src/globus_sdk/transport/retry.py +++ b/src/globus_sdk/transport/retry.py @@ -7,7 +7,6 @@ import requests from .caller_info import RequestCallerInfo - from .decoders import ResponseDecoder C = t.TypeVar("C", bound=t.Callable[..., t.Any]) @@ -27,9 +26,6 @@ class RetryContext: or ``exception`` will be present. :param attempt: The request attempt number, starting at 0. - :param response_decoder: The response decoder which the transport layer is using to - read response data. Checks should prefer to use this decoder when reading - responses. :param caller_info: Contextual information about the caller, including authorizer :param response: The response on a successful request :param exception: The error raised when trying to send the request @@ -39,15 +35,12 @@ def __init__( self, attempt: int, *, - response_decoder: ResponseDecoder, caller_info: RequestCallerInfo, response: requests.Response | None = None, exception: Exception | None = None, ) -> None: # retry attempt number self.attempt = attempt - # the response decoder shares a choice of implementation for decoding - self.response_decoder = response_decoder # caller info provides contextual information about the request self.caller_info = caller_info # the response or exception from a request diff --git a/tests/functional/base_client/test_encodings.py b/tests/functional/base_client/test_encodings.py index 2f57bbab7..d757f6c16 100644 --- a/tests/functional/base_client/test_encodings.py +++ b/tests/functional/base_client/test_encodings.py @@ -125,7 +125,7 @@ def get_body_json(self, response: requests.Response) -> t.Any: "https://foo.api.globus.org/bar", body="bye", status=404, - headers={"Content-Type": "application/json"}, + content_type="application/json", ) with pytest.raises(globus_sdk.GlobusAPIError) as excinfo: client.post("/bar", data={"baz": 1}) diff --git a/tests/unit/transport/test_default_retry_policy.py b/tests/unit/transport/test_default_retry_policy.py index df7d80ea0..fd533300e 100644 --- a/tests/unit/transport/test_default_retry_policy.py +++ b/tests/unit/transport/test_default_retry_policy.py @@ -10,7 +10,6 @@ RetryConfig, RetryContext, ) -from globus_sdk.transport.decoders import ResponseDecoder from globus_sdk.transport.default_retry_checks import ( DEFAULT_RETRY_CHECKS, check_retry_after_header, @@ -29,12 +28,7 @@ def test_retry_policy_respects_retry_after(mocksleep, http_status): dummy_response.headers = {"Retry-After": "5"} dummy_response.status_code = http_status caller_info = RequestCallerInfo(retry_config=retry_config) - ctx = RetryContext( - 1, - response_decoder=ResponseDecoder(), - caller_info=caller_info, - response=dummy_response, - ) + ctx = RetryContext(1, caller_info=caller_info, response=dummy_response) assert checker.should_retry(ctx) is True mocksleep.assert_not_called() @@ -54,12 +48,7 @@ def test_retry_policy_ignores_retry_after_too_high(mocksleep, http_status): dummy_response.headers = {"Retry-After": "20"} dummy_response.status_code = http_status caller_info = RequestCallerInfo(retry_config=retry_config) - ctx = RetryContext( - 1, - response_decoder=ResponseDecoder(), - caller_info=caller_info, - response=dummy_response, - ) + ctx = RetryContext(1, caller_info=caller_info, response=dummy_response) assert checker.should_retry(ctx) is True mocksleep.assert_not_called() @@ -78,12 +67,7 @@ def test_retry_policy_ignores_malformed_retry_after(mocksleep, http_status): dummy_response.headers = {"Retry-After": "not-an-integer"} dummy_response.status_code = http_status caller_info = RequestCallerInfo(retry_config=retry_config) - ctx = RetryContext( - 1, - response_decoder=ResponseDecoder(), - caller_info=caller_info, - response=dummy_response, - ) + ctx = RetryContext(1, caller_info=caller_info, response=dummy_response) assert checker.should_retry(ctx) is True mocksleep.assert_not_called() @@ -100,10 +84,5 @@ def test_default_retry_check_noop_on_exception(check_method, mocksleep): retry_config = RetryConfig() retry_config.checks.register_many_checks(DEFAULT_RETRY_CHECKS) caller_info = RequestCallerInfo(retry_config=retry_config) - ctx = RetryContext( - 1, - response_decoder=ResponseDecoder(), - caller_info=caller_info, - exception=Exception("foo"), - ) + ctx = RetryContext(1, caller_info=caller_info, exception=Exception("foo")) assert check_method(ctx) is RetryCheckResult.no_decision diff --git a/tests/unit/transport/test_retry_check_runner.py b/tests/unit/transport/test_retry_check_runner.py index 6bc6a6c2a..c4105d456 100644 --- a/tests/unit/transport/test_retry_check_runner.py +++ b/tests/unit/transport/test_retry_check_runner.py @@ -7,7 +7,6 @@ RetryConfig, RetryContext, ) -from globus_sdk.transport.decoders import ResponseDecoder from globus_sdk.transport.default_retry_checks import DEFAULT_RETRY_CHECKS @@ -16,28 +15,13 @@ def _make_test_retry_context(*, status=200, exception=None, response=None): retry_config.checks.register_many_checks(DEFAULT_RETRY_CHECKS) caller_info = RequestCallerInfo(retry_config=retry_config) if exception: - return RetryContext( - 1, - response_decoder=ResponseDecoder(), - caller_info=caller_info, - exception=exception, - ) + return RetryContext(1, caller_info=caller_info, exception=exception) elif response: - return RetryContext( - 1, - response_decoder=ResponseDecoder(), - caller_info=caller_info, - response=response, - ) + return RetryContext(1, caller_info=caller_info, response=response) dummy_response = mock.Mock() dummy_response.status_code = 200 - return RetryContext( - 1, - response_decoder=ResponseDecoder(), - caller_info=caller_info, - response=dummy_response, - ) + return RetryContext(1, caller_info=caller_info, response=dummy_response) def test_retry_check_runner_should_retry_explicit_on_first_check(): diff --git a/tests/unit/transport/test_transfer_transport.py b/tests/unit/transport/test_transfer_transport.py index bd64376fe..cee273260 100644 --- a/tests/unit/transport/test_transfer_transport.py +++ b/tests/unit/transport/test_transfer_transport.py @@ -8,7 +8,6 @@ RetryConfig, RetryContext, ) -from globus_sdk.transport.decoders import ResponseDecoder from globus_sdk.transport.default_retry_checks import DEFAULT_RETRY_CHECKS @@ -45,12 +44,7 @@ def test_transfer_does_not_retry_external(): dummy_response.json = lambda: body dummy_response.status_code = 502 caller_info = RequestCallerInfo(retry_config=retry_config) - ctx = RetryContext( - 1, - response_decoder=ResponseDecoder(), - caller_info=caller_info, - response=dummy_response, - ) + ctx = RetryContext(1, caller_info=caller_info, response=dummy_response) assert checker.should_retry(ctx) is False @@ -75,12 +69,7 @@ def test_transfer_does_not_retry_endpoint_error(): dummy_response.json = lambda: body dummy_response.status_code = 502 caller_info = RequestCallerInfo(retry_config=retry_config) - ctx = RetryContext( - 1, - response_decoder=ResponseDecoder(), - caller_info=caller_info, - response=dummy_response, - ) + ctx = RetryContext(1, caller_info=caller_info, response=dummy_response) assert checker.should_retry(ctx) is False @@ -97,11 +86,6 @@ def _raise_value_error(): dummy_response.json = _raise_value_error dummy_response.status_code = 502 caller_info = RequestCallerInfo(retry_config=retry_config) - ctx = RetryContext( - 1, - response_decoder=ResponseDecoder(), - caller_info=caller_info, - response=dummy_response, - ) + ctx = RetryContext(1, caller_info=caller_info, response=dummy_response) assert checker.should_retry(ctx) is True diff --git a/tests/unit/transport/test_transfer_v2_transport.py b/tests/unit/transport/test_transfer_v2_transport.py index 9bd0a3fa5..eec4b8331 100644 --- a/tests/unit/transport/test_transfer_v2_transport.py +++ b/tests/unit/transport/test_transfer_v2_transport.py @@ -12,7 +12,6 @@ RetryConfig, RetryContext, ) -from globus_sdk.transport.decoders import ResponseDecoder from globus_sdk.transport.default_retry_checks import DEFAULT_RETRY_CHECKS @@ -153,12 +152,7 @@ def test_transfer_v2_default_retry_checks(body, status_code, expected_should_ret dummy_response.json = lambda: body dummy_response.status_code = status_code caller_info = RequestCallerInfo(retry_config=retry_config) - ctx = RetryContext( - 1, - response_decoder=ResponseDecoder(), - caller_info=caller_info, - response=dummy_response, - ) + ctx = RetryContext(1, caller_info=caller_info, response=dummy_response) assert checker.should_retry(ctx) is expected_should_retry @@ -176,11 +170,6 @@ def _raise_value_error(): dummy_response.json = _raise_value_error dummy_response.status_code = 502 caller_info = RequestCallerInfo(retry_config=retry_config) - ctx = RetryContext( - 1, - response_decoder=ResponseDecoder(), - caller_info=caller_info, - response=dummy_response, - ) + ctx = RetryContext(1, caller_info=caller_info, response=dummy_response) assert checker.should_retry(ctx) is True