diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 88779cf2..591ebbf0 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 00000000..f227f008 --- /dev/null +++ b/changelog.d/20260501_170758_sirosen_define_orjson_encoder.rst @@ -0,0 +1,31 @@ +Added +----- + +- The SDK now supports use of ``orjson`` as an alternative JSON encoder and decoder. + When ``orjson`` is installed, the SDK will automatically use it in place of + the stdlib ``json`` module. (:pr:`NUMBER`) + +- ``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`) + +- The request encoders defined in ``globus_sdk.transport`` have been + refactored into ``RequestsRepresentationProvider``\s, objects responsible + for encoding and decoding ``requests`` data in specific formats. In order to + retain compatibility, they are still available aliased under their previous + names, as "encoders". + +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. Users who wish to + customize request encoding and response decoding should leverage the new + ``representation_providers`` instance variable instead. + +- The ``globus_sdk.transport.encoders`` module is deprecated. Use + ``globus_sdk.transport.representation_providers`` instead. diff --git a/pyproject.toml b/pyproject.toml index 9ec2a211..b1b2df7b 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 00000000..fe94e4fb --- /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.5.20 + # via requests +charset-normalizer==3.4.7 + # via requests +coverage==7.14.1 + # 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.16 + # via requests +iniconfig==2.3.0 + # via pytest +orjson==3.11.9 + # 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.34.2 + # via responses +responses==0.26.1 + # via -r .test.in +tomli==2.4.1 + # via + # coverage + # pytest +typing-extensions==4.15.0 + # via exceptiongroup +urllib3==2.7.0 + # via + # requests + # responses diff --git a/requirements/py3.10/test.txt b/requirements/py3.10/test.txt index 8994e716..4b259e49 100644 --- a/requirements/py3.10/test.txt +++ b/requirements/py3.10/test.txt @@ -4,11 +4,11 @@ # # tox p -m freezedeps # -certifi==2026.4.22 +certifi==2026.5.20 # via requests charset-normalizer==3.4.7 # via requests -coverage==7.14.0 +coverage==7.14.1 # via -r .test.in exceptiongroup==1.3.1 # via pytest @@ -16,7 +16,7 @@ execnet==2.1.2 # via pytest-xdist flaky==3.8.1 # via -r .test.in -idna==3.15 +idna==3.16 # via requests iniconfig==2.3.0 # via pytest @@ -37,9 +37,9 @@ pytest-xdist==3.8.0 # via -r .test.in pyyaml==6.0.3 # via responses -requests==2.34.1 +requests==2.34.2 # via responses -responses==0.26.0 +responses==0.26.1 # via -r .test.in tomli==2.4.1 # via diff --git a/requirements/py3.10/typing.txt b/requirements/py3.10/typing.txt index f2bf7920..6ed5c635 100644 --- a/requirements/py3.10/typing.txt +++ b/requirements/py3.10/typing.txt @@ -6,17 +6,17 @@ # alabaster==1.0.0 # via sphinx -ast-serialize==0.3.0 +ast-serialize==0.5.0 # via mypy babel==2.18.0 # via sphinx -certifi==2026.4.22 +certifi==2026.5.20 # via requests charset-normalizer==3.4.7 # via requests docutils==0.21.2 # via sphinx -idna==3.15 +idna==3.16 # via requests imagesize==2.0.0 # via sphinx @@ -30,6 +30,8 @@ mypy==2.1.0 # via -r .typing.in mypy-extensions==1.1.0 # via mypy +orjson==3.11.9 + # via -r .typing.in packaging==26.2 # via sphinx pathspec==1.1.1 @@ -38,13 +40,13 @@ pygments==2.20.0 # via sphinx pyyaml==6.0.3 # via responses -requests==2.34.1 +requests==2.34.2 # via # responses # sphinx -responses==0.26.0 +responses==0.26.1 # via -r .typing.in -snowballstemmer==3.0.1 +snowballstemmer==3.1.0 # via sphinx sphinx==8.1.3 # via -r .typing.in @@ -66,11 +68,11 @@ tomli==2.4.1 # sphinx types-cryptography==3.3.23.2 # via types-jwt -types-docutils==0.22.3.20260508 +types-docutils==0.22.3.20260518 # via -r .typing.in types-jwt==0.1.3 # via -r .typing.in -types-requests==2.33.0.20260513 +types-requests==2.33.0.20260518 # via -r .typing.in typing-extensions==4.15.0 # via diff --git a/requirements/py3.11/docs.txt b/requirements/py3.11/docs.txt index 60930e3b..9159f871 100644 --- a/requirements/py3.11/docs.txt +++ b/requirements/py3.11/docs.txt @@ -14,11 +14,11 @@ babel==2.18.0 # via sphinx beautifulsoup4==4.14.3 # via furo -certifi==2026.4.22 +certifi==2026.5.20 # via requests charset-normalizer==3.4.7 # via requests -click==8.3.3 +click==8.4.1 # via # click-log # scriv @@ -28,7 +28,7 @@ docutils==0.22.4 # via sphinx furo==2025.12.19 # via -r .docs.in -idna==3.15 +idna==3.16 # via requests imagesize==2.0.0 # via sphinx @@ -51,20 +51,20 @@ pygments==2.20.0 # sphinx pyyaml==6.0.3 # via responses -requests==2.34.1 +requests==2.34.2 # via # responses # scriv # sphinx -responses==0.26.0 +responses==0.26.1 # via -r .docs.in roman-numerals==4.1.0 # via sphinx scriv==1.8.0 # via -r .docs.in -snowballstemmer==3.0.1 +snowballstemmer==3.1.0 # via sphinx -soupsieve==2.8.3 +soupsieve==2.8.4 # via beautifulsoup4 sphinx==9.0.4 # via diff --git a/requirements/py3.11/test-orjson.txt b/requirements/py3.11/test-orjson.txt new file mode 100644 index 00000000..103feaf0 --- /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.5.20 + # via requests +charset-normalizer==3.4.7 + # via requests +coverage==7.14.1 + # via -r .test.in +execnet==2.1.2 + # via pytest-xdist +flaky==3.8.1 + # via -r .test.in +idna==3.16 + # via requests +iniconfig==2.3.0 + # via pytest +orjson==3.11.9 + # 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.34.2 + # via responses +responses==0.26.1 + # via -r .test.in +urllib3==2.7.0 + # via + # requests + # responses diff --git a/requirements/py3.11/test.txt b/requirements/py3.11/test.txt index df95d5a1..6cc757f0 100644 --- a/requirements/py3.11/test.txt +++ b/requirements/py3.11/test.txt @@ -4,17 +4,17 @@ # # tox p -m freezedeps # -certifi==2026.4.22 +certifi==2026.5.20 # via requests charset-normalizer==3.4.7 # via requests -coverage==7.14.0 +coverage==7.14.1 # via -r .test.in execnet==2.1.2 # via pytest-xdist flaky==3.8.1 # via -r .test.in -idna==3.15 +idna==3.16 # via requests iniconfig==2.3.0 # via pytest @@ -35,9 +35,9 @@ pytest-xdist==3.8.0 # via -r .test.in pyyaml==6.0.3 # via responses -requests==2.34.1 +requests==2.34.2 # via responses -responses==0.26.0 +responses==0.26.1 # via -r .test.in urllib3==2.7.0 # via diff --git a/requirements/py3.11/typing.txt b/requirements/py3.11/typing.txt index 5d5bdda3..a9057b38 100644 --- a/requirements/py3.11/typing.txt +++ b/requirements/py3.11/typing.txt @@ -6,17 +6,17 @@ # alabaster==1.0.0 # via sphinx -ast-serialize==0.3.0 +ast-serialize==0.5.0 # via mypy babel==2.18.0 # via sphinx -certifi==2026.4.22 +certifi==2026.5.20 # via requests charset-normalizer==3.4.7 # via requests docutils==0.22.4 # via sphinx -idna==3.15 +idna==3.16 # via requests imagesize==2.0.0 # via sphinx @@ -30,6 +30,8 @@ mypy==2.1.0 # via -r .typing.in mypy-extensions==1.1.0 # via mypy +orjson==3.11.9 + # via -r .typing.in packaging==26.2 # via sphinx pathspec==1.1.1 @@ -38,15 +40,15 @@ pygments==2.20.0 # via sphinx pyyaml==6.0.3 # via responses -requests==2.34.1 +requests==2.34.2 # via # responses # sphinx -responses==0.26.0 +responses==0.26.1 # via -r .typing.in roman-numerals==4.1.0 # via sphinx -snowballstemmer==3.0.1 +snowballstemmer==3.1.0 # via sphinx sphinx==9.0.4 # via -r .typing.in @@ -64,11 +66,11 @@ sphinxcontrib-serializinghtml==2.0.0 # via sphinx types-cryptography==3.3.23.2 # via types-jwt -types-docutils==0.22.3.20260508 +types-docutils==0.22.3.20260518 # via -r .typing.in types-jwt==0.1.3 # via -r .typing.in -types-requests==2.33.0.20260513 +types-requests==2.33.0.20260518 # via -r .typing.in typing-extensions==4.15.0 # via diff --git a/requirements/py3.12/test-orjson.txt b/requirements/py3.12/test-orjson.txt new file mode 100644 index 00000000..9ee6ff82 --- /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.5.20 + # via requests +charset-normalizer==3.4.7 + # via requests +coverage==7.14.1 + # via -r .test.in +execnet==2.1.2 + # via pytest-xdist +flaky==3.8.1 + # via -r .test.in +idna==3.16 + # via requests +iniconfig==2.3.0 + # via pytest +orjson==3.11.9 + # 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.34.2 + # via responses +responses==0.26.1 + # via -r .test.in +urllib3==2.7.0 + # via + # requests + # responses diff --git a/requirements/py3.12/test.txt b/requirements/py3.12/test.txt index a1468058..6e90eec6 100644 --- a/requirements/py3.12/test.txt +++ b/requirements/py3.12/test.txt @@ -4,17 +4,17 @@ # # tox p -m freezedeps # -certifi==2026.4.22 +certifi==2026.5.20 # via requests charset-normalizer==3.4.7 # via requests -coverage==7.14.0 +coverage==7.14.1 # via -r .test.in execnet==2.1.2 # via pytest-xdist flaky==3.8.1 # via -r .test.in -idna==3.15 +idna==3.16 # via requests iniconfig==2.3.0 # via pytest @@ -35,9 +35,9 @@ pytest-xdist==3.8.0 # via -r .test.in pyyaml==6.0.3 # via responses -requests==2.34.1 +requests==2.34.2 # via responses -responses==0.26.0 +responses==0.26.1 # via -r .test.in urllib3==2.7.0 # via diff --git a/requirements/py3.12/typing.txt b/requirements/py3.12/typing.txt index 879c2b02..f2f941d0 100644 --- a/requirements/py3.12/typing.txt +++ b/requirements/py3.12/typing.txt @@ -6,17 +6,17 @@ # alabaster==1.0.0 # via sphinx -ast-serialize==0.3.0 +ast-serialize==0.5.0 # via mypy babel==2.18.0 # via sphinx -certifi==2026.4.22 +certifi==2026.5.20 # via requests charset-normalizer==3.4.7 # via requests docutils==0.22.4 # via sphinx -idna==3.15 +idna==3.16 # via requests imagesize==2.0.0 # via sphinx @@ -30,6 +30,8 @@ mypy==2.1.0 # via -r .typing.in mypy-extensions==1.1.0 # via mypy +orjson==3.11.9 + # via -r .typing.in packaging==26.2 # via sphinx pathspec==1.1.1 @@ -38,15 +40,15 @@ pygments==2.20.0 # via sphinx pyyaml==6.0.3 # via responses -requests==2.34.1 +requests==2.34.2 # via # responses # sphinx -responses==0.26.0 +responses==0.26.1 # via -r .typing.in roman-numerals==4.1.0 # via sphinx -snowballstemmer==3.0.1 +snowballstemmer==3.1.0 # via sphinx sphinx==9.1.0 # via -r .typing.in @@ -64,11 +66,11 @@ sphinxcontrib-serializinghtml==2.0.0 # via sphinx types-cryptography==3.3.23.2 # via types-jwt -types-docutils==0.22.3.20260508 +types-docutils==0.22.3.20260518 # via -r .typing.in types-jwt==0.1.3 # via -r .typing.in -types-requests==2.33.0.20260513 +types-requests==2.33.0.20260518 # via -r .typing.in typing-extensions==4.15.0 # via diff --git a/requirements/py3.13/test-orjson.txt b/requirements/py3.13/test-orjson.txt new file mode 100644 index 00000000..2aad40ee --- /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.5.20 + # via requests +charset-normalizer==3.4.7 + # via requests +coverage==7.14.1 + # via -r .test.in +execnet==2.1.2 + # via pytest-xdist +flaky==3.8.1 + # via -r .test.in +idna==3.16 + # via requests +iniconfig==2.3.0 + # via pytest +orjson==3.11.9 + # 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.34.2 + # via responses +responses==0.26.1 + # via -r .test.in +urllib3==2.7.0 + # via + # requests + # responses diff --git a/requirements/py3.13/test.txt b/requirements/py3.13/test.txt index aefcaad1..64f4db27 100644 --- a/requirements/py3.13/test.txt +++ b/requirements/py3.13/test.txt @@ -4,17 +4,17 @@ # # tox p -m freezedeps # -certifi==2026.4.22 +certifi==2026.5.20 # via requests charset-normalizer==3.4.7 # via requests -coverage==7.14.0 +coverage==7.14.1 # via -r .test.in execnet==2.1.2 # via pytest-xdist flaky==3.8.1 # via -r .test.in -idna==3.15 +idna==3.16 # via requests iniconfig==2.3.0 # via pytest @@ -35,9 +35,9 @@ pytest-xdist==3.8.0 # via -r .test.in pyyaml==6.0.3 # via responses -requests==2.34.1 +requests==2.34.2 # via responses -responses==0.26.0 +responses==0.26.1 # via -r .test.in urllib3==2.7.0 # via diff --git a/requirements/py3.13/typing.txt b/requirements/py3.13/typing.txt index de251619..01d01039 100644 --- a/requirements/py3.13/typing.txt +++ b/requirements/py3.13/typing.txt @@ -6,17 +6,17 @@ # alabaster==1.0.0 # via sphinx -ast-serialize==0.3.0 +ast-serialize==0.5.0 # via mypy babel==2.18.0 # via sphinx -certifi==2026.4.22 +certifi==2026.5.20 # via requests charset-normalizer==3.4.7 # via requests docutils==0.22.4 # via sphinx -idna==3.15 +idna==3.16 # via requests imagesize==2.0.0 # via sphinx @@ -30,6 +30,8 @@ mypy==2.1.0 # via -r .typing.in mypy-extensions==1.1.0 # via mypy +orjson==3.11.9 + # via -r .typing.in packaging==26.2 # via sphinx pathspec==1.1.1 @@ -38,15 +40,15 @@ pygments==2.20.0 # via sphinx pyyaml==6.0.3 # via responses -requests==2.34.1 +requests==2.34.2 # via # responses # sphinx -responses==0.26.0 +responses==0.26.1 # via -r .typing.in roman-numerals==4.1.0 # via sphinx -snowballstemmer==3.0.1 +snowballstemmer==3.1.0 # via sphinx sphinx==9.1.0 # via -r .typing.in @@ -64,11 +66,11 @@ sphinxcontrib-serializinghtml==2.0.0 # via sphinx types-cryptography==3.3.23.2 # via types-jwt -types-docutils==0.22.3.20260508 +types-docutils==0.22.3.20260518 # via -r .typing.in types-jwt==0.1.3 # via -r .typing.in -types-requests==2.33.0.20260513 +types-requests==2.33.0.20260518 # via -r .typing.in typing-extensions==4.15.0 # via diff --git a/requirements/py3.14/test-orjson.txt b/requirements/py3.14/test-orjson.txt new file mode 100644 index 00000000..d897e539 --- /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.5.20 + # via requests +charset-normalizer==3.4.7 + # via requests +coverage==7.14.1 + # via -r .test.in +execnet==2.1.2 + # via pytest-xdist +flaky==3.8.1 + # via -r .test.in +idna==3.16 + # via requests +iniconfig==2.3.0 + # via pytest +orjson==3.11.9 + # 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.34.2 + # via responses +responses==0.26.1 + # via -r .test.in +urllib3==2.7.0 + # via + # requests + # responses diff --git a/requirements/py3.14/test.txt b/requirements/py3.14/test.txt index 03bb603b..17c8d53b 100644 --- a/requirements/py3.14/test.txt +++ b/requirements/py3.14/test.txt @@ -4,17 +4,17 @@ # # tox p -m freezedeps # -certifi==2026.4.22 +certifi==2026.5.20 # via requests charset-normalizer==3.4.7 # via requests -coverage==7.14.0 +coverage==7.14.1 # via -r .test.in execnet==2.1.2 # via pytest-xdist flaky==3.8.1 # via -r .test.in -idna==3.15 +idna==3.16 # via requests iniconfig==2.3.0 # via pytest @@ -35,9 +35,9 @@ pytest-xdist==3.8.0 # via -r .test.in pyyaml==6.0.3 # via responses -requests==2.34.1 +requests==2.34.2 # via responses -responses==0.26.0 +responses==0.26.1 # via -r .test.in urllib3==2.7.0 # via diff --git a/requirements/py3.14/typing.txt b/requirements/py3.14/typing.txt index 11a224d6..25993c96 100644 --- a/requirements/py3.14/typing.txt +++ b/requirements/py3.14/typing.txt @@ -6,17 +6,17 @@ # alabaster==1.0.0 # via sphinx -ast-serialize==0.3.0 +ast-serialize==0.5.0 # via mypy babel==2.18.0 # via sphinx -certifi==2026.4.22 +certifi==2026.5.20 # via requests charset-normalizer==3.4.7 # via requests docutils==0.22.4 # via sphinx -idna==3.15 +idna==3.16 # via requests imagesize==2.0.0 # via sphinx @@ -30,6 +30,8 @@ mypy==2.1.0 # via -r .typing.in mypy-extensions==1.1.0 # via mypy +orjson==3.11.9 + # via -r .typing.in packaging==26.2 # via sphinx pathspec==1.1.1 @@ -38,15 +40,15 @@ pygments==2.20.0 # via sphinx pyyaml==6.0.3 # via responses -requests==2.34.1 +requests==2.34.2 # via # responses # sphinx -responses==0.26.0 +responses==0.26.1 # via -r .typing.in roman-numerals==4.1.0 # via sphinx -snowballstemmer==3.0.1 +snowballstemmer==3.1.0 # via sphinx sphinx==9.1.0 # via -r .typing.in @@ -64,11 +66,11 @@ sphinxcontrib-serializinghtml==2.0.0 # via sphinx types-cryptography==3.3.23.2 # via types-jwt -types-docutils==0.22.3.20260508 +types-docutils==0.22.3.20260518 # via -r .typing.in types-jwt==0.1.3 # via -r .typing.in -types-requests==2.33.0.20260513 +types-requests==2.33.0.20260518 # via -r .typing.in typing-extensions==4.15.0 # via diff --git a/requirements/py3.9/test-mindeps.txt b/requirements/py3.9/test-mindeps.txt index f9f49953..b870f0ff 100644 --- a/requirements/py3.9/test-mindeps.txt +++ b/requirements/py3.9/test-mindeps.txt @@ -4,7 +4,7 @@ # # tox p -m freezedeps # -certifi==2026.4.22 +certifi==2026.5.20 # via requests cffi==2.0.0 # via cryptography diff --git a/requirements/py3.9/test-orjson.txt b/requirements/py3.9/test-orjson.txt new file mode 100644 index 00000000..f054ca92 --- /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.5.20 + # 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.16 + # 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.1 + # 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/test.txt b/requirements/py3.9/test.txt index c81e13fd..769b633c 100644 --- a/requirements/py3.9/test.txt +++ b/requirements/py3.9/test.txt @@ -4,7 +4,7 @@ # # tox p -m freezedeps # -certifi==2026.4.22 +certifi==2026.5.20 # via requests charset-normalizer==3.4.7 # via requests @@ -16,7 +16,7 @@ execnet==2.1.2 # via pytest-xdist flaky==3.8.1 # via -r .test.in -idna==3.15 +idna==3.16 # via requests importlib-metadata==8.7.1 # via pytest-randomly @@ -41,7 +41,7 @@ pyyaml==6.0.3 # via responses requests==2.32.5 # via responses -responses==0.26.0 +responses==0.26.1 # via -r .test.in tomli==2.4.1 # via diff --git a/requirements/py3.9/typing.txt b/requirements/py3.9/typing.txt index cf79c93d..5158e893 100644 --- a/requirements/py3.9/typing.txt +++ b/requirements/py3.9/typing.txt @@ -8,13 +8,13 @@ alabaster==0.7.16 # via sphinx babel==2.18.0 # via sphinx -certifi==2026.4.22 +certifi==2026.5.20 # via requests charset-normalizer==3.4.7 # via requests docutils==0.21.2 # via sphinx -idna==3.15 +idna==3.16 # via requests imagesize==1.5.0 # via sphinx @@ -30,6 +30,8 @@ mypy==1.19.1 # via -r .typing.in mypy-extensions==1.1.0 # via mypy +orjson==3.11.5 + # via -r .typing.in packaging==26.2 # via sphinx pathspec==1.1.1 @@ -42,9 +44,9 @@ requests==2.32.5 # via # responses # sphinx -responses==0.26.0 +responses==0.26.1 # via -r .typing.in -snowballstemmer==3.0.1 +snowballstemmer==3.1.0 # via sphinx sphinx==7.4.7 # via -r .typing.in diff --git a/src/globus_sdk/_internal/orjson_compat.py b/src/globus_sdk/_internal/orjson_compat.py new file mode 100644 index 00000000..bb7eb07d --- /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 fec238f6..6b185ebf 100644 --- a/src/globus_sdk/client.py +++ b/src/globus_sdk/client.py @@ -571,7 +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}") - raise self.error_class(r) + 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 393293dc..15932fa3 100644 --- a/src/globus_sdk/exc/api.py +++ b/src/globus_sdk/exc/api.py @@ -43,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 @@ -54,6 +57,8 @@ def __init__(self, r: requests.Response, *args: t.Any, **kwargs: t.Any) -> None: self._info: ErrorInfoContainer | None = None self._underlying_response = r + + self._json_provider = RequestsTransport._safe_get_current_json_provider() self._parse_response() if sys.version_info >= (3, 11): @@ -136,7 +141,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._json_provider.decode_body( + 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 c1860997..e29cbc6e 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,10 +33,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 ): + json_provider = RequestsTransport._safe_get_current_json_provider() + 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 = json_provider.decode_body(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 fc734804..8df0d33c 100644 --- a/src/globus_sdk/globus_app/app.py +++ b/src/globus_sdk/globus_app/app.py @@ -29,6 +29,7 @@ ValidatingTokenStorage, ) from globus_sdk.transport import ( + RequestsTransport, RetryCheck, RetryCheckFlags, RetryCheckResult, @@ -592,10 +593,12 @@ def __call__(self, ctx: RetryContext) -> RetryCheckResult: @staticmethod def _load_response_gare(response: Response | None) -> GARE | None: """Return a parsed GARE from a 403 response or None if not possible.""" + json_provider = RequestsTransport._safe_get_current_json_provider() + if response is None or response.status_code != 403: return None try: - decoded_body = response.json() + decoded_body = json_provider.decode_body(response) except JSONDecodeError: return None else: diff --git a/src/globus_sdk/response.py b/src/globus_sdk/response.py index 01d47f59..e2cea140 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.representation_providers import RequestsRepresentationProvider log = logging.getLogger(__name__) @@ -55,6 +57,9 @@ def __init__( self._wrapped: GlobusHTTPResponse | None = response self._response: Response | None = None self.client: globus_sdk.BaseClient = self._wrapped.client + self._json_provider: RequestsRepresentationProvider = ( + response._json_provider + ) # init on a Response object, this is the "normal" case # _wrapped is None @@ -65,6 +70,10 @@ def __init__( self._response = response self.client = client + # get the JSON provider from the current transport; this will be used + # whenever response data decoding is needed + self._json_provider = RequestsTransport._safe_get_current_json_provider() + @cached_property def _parsed_json(self) -> t.Any: # JSON decoding may raise a ValueError due to an invalid JSON @@ -79,7 +88,7 @@ def _parsed_json(self) -> t.Any: if self._response is not None: try: - return self._response.json() + return self._json_provider.decode_body(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 4ab2c688..1f057422 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,8 +30,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 ): + json_provider = RequestsTransport._safe_get_current_json_provider() + try: - code = ctx.response.json()["code"] + code = json_provider.decode_body(ctx.response)["code"] except (ValueError, KeyError): code = "" diff --git a/src/globus_sdk/transport/caller_info.py b/src/globus_sdk/transport/caller_info.py index 71e6a891..34e040ec 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/default_retry_checks.py b/src/globus_sdk/transport/default_retry_checks.py index b9d1129f..b87629ae 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 5052f47e..f01a913a 100644 --- a/src/globus_sdk/transport/encoders.py +++ b/src/globus_sdk/transport/encoders.py @@ -1,149 +1,25 @@ from __future__ import annotations -import enum -import typing as t -import uuid +import sys -import requests +from .representation_providers import ( + RequestsHttpFormProvider, + RequestsJsonProvider, + RequestsPlainTextProvider, +) -from globus_sdk._missing import MISSING, filter_missing +if sys.version_info >= (3, 10): + from typing import TypeAlias +else: + from typing_extensions import TypeAlias -class RequestEncoder: - """ - A RequestEncoder takes input parameters and outputs a requests.Requests object. +# shim these class names until the next major SDK version, at which point they can be +# removed +# +# ideally, after soft-deprecation, we should start emitting deprecation warnings when +# these names are imported, but this will also require handling in __init__.py - The default encoder requires that the data is text and is a no-op. It can also be - referred to as the ``"text"`` encoder. - """ - - def encode( - self, - method: str, - url: str, - params: dict[str, t.Any] | None, - data: t.Any, - headers: dict[str, str], - ) -> requests.Request: - if not isinstance(data, (str, bytes)): - raise TypeError( - "Cannot encode non-text in a text request. " - "Either manually encode the data or use `encoding=form|json` to " - "correctly format this data." - ) - return requests.Request( - method, - url, - data=self._prepare_data(data), - params=self._prepare_params(params), - headers=self._prepare_headers(headers), - ) - - def _format_primitive(self, value: t.Any) -> t.Any: - """ - Transformations for primitive values (e.g. stringifiable items) for query - params, headers, and body elements. - - Transforms data as follows: - - x: UUID -> str(x) - x: Enum -> x.value - x: _ -> x - """ - if isinstance(value, uuid.UUID): - return str(value) - if isinstance(value, enum.Enum): - return value.value - return value - - def _prepare_params( - self, params: dict[str, t.Any] | None - ) -> dict[str, t.Any] | None: - """ - Prepare the query params for a request. - - Filters out MISSING and formats primitives. - """ - if params is None: - return None - return filter_missing({k: self._format_primitive(v) for k, v in params.items()}) - - def _prepare_headers( - self, headers: dict[str, t.Any] | None - ) -> dict[str, t.Any] | None: - """ - Prepare the headers for a request. - - Filters out MISSING and formats primitives. - """ - if headers is None: - return None - return filter_missing( - {k: self._format_primitive(v) for k, v in headers.items()} - ) - - def _prepare_data(self, data: t.Any) -> t.Any: - """ - Prepare the data (body) for a request. - - If the body is a dict, list, or tuple, it will be recursively processed to - filter out MISSING and format primitives. - - Otherwise, it is returned as-is. - """ - if isinstance(data, dict): - return filter_missing({k: self._prepare_data(v) for k, v in data.items()}) - elif isinstance(data, (list, tuple)): - return [self._prepare_data(x) for x in data if x is not MISSING] - else: - return self._format_primitive(data) - - -class JSONRequestEncoder(RequestEncoder): - """ - This encoder prepares the data as JSON. It also ensures that content-type is set, so - that APIs requiring a content-type of "application/json" are able to read the data. - """ - - 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=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 - a dict -- any other datatype will result in errors. - """ - - def encode( - self, - method: str, - url: str, - params: dict[str, t.Any] | None, - data: t.Any, - headers: dict[str, str], - ) -> requests.Request: - if not isinstance(data, dict): - raise TypeError("FormRequestEncoder cannot encode non-dict data") - return requests.Request( - method, - url, - data=self._prepare_data(data), - params=self._prepare_params(params), - headers=self._prepare_headers(headers), - ) +RequestEncoder: TypeAlias = RequestsPlainTextProvider +JSONRequestEncoder: TypeAlias = RequestsJsonProvider +FormRequestEncoder: TypeAlias = RequestsHttpFormProvider diff --git a/src/globus_sdk/transport/representation_providers.py b/src/globus_sdk/transport/representation_providers.py new file mode 100644 index 00000000..074f1c28 --- /dev/null +++ b/src/globus_sdk/transport/representation_providers.py @@ -0,0 +1,238 @@ +from __future__ import annotations + +import enum +import typing as t +import uuid + +from globus_sdk._internal import orjson_compat +from globus_sdk._missing import MISSING, filter_missing + +if t.TYPE_CHECKING: + import requests + + +class RequestsRepresentationProvider: + """ + A ``RequestsRepresentationProvider`` defines transformations of data to and from + ``requests`` datatypes, for a given representation of the data -- primarily the + media type. + + Providers must have two methods: + + - ``encode()`` takes input parameters and outputs a requests.Requests object + + - ``decode_body()`` takes a requests.Response object and reads the body + """ + + def encode( + self, + method: str, + url: str, + params: dict[str, t.Any] | None, + data: t.Any, + headers: dict[str, str], + ) -> requests.Request: + """ + Formulate a ``requests.Request``, defaulting to plain-text for the body. + + :param method: the HTTP method name + :param url: the full URL for the request + :param params: query parameters, as a dict + :param data: the body, as a type which this provider can encode + :param headers: HTTP headers, as a dict + """ + import requests + + return requests.Request( + method, + url, + data=self._prepare_data(data), + params=self._prepare_params(params), + headers=self._prepare_headers(headers), + ) + + def decode_body(self, response: requests.Response) -> t.Any: + """ + Read a response as text. + + :param response: the ``requests.Response`` to read + """ + return response.text + + def _format_primitive(self, value: t.Any) -> t.Any: + """ + Transformations for primitive values (e.g. stringifiable items) for query + params, headers, and body elements. + + Transforms data as follows: + + x: UUID -> str(x) + x: Enum -> x.value + x: _ -> x + """ + if isinstance(value, uuid.UUID): + return str(value) + if isinstance(value, enum.Enum): + return value.value + return value + + def _prepare_params( + self, params: dict[str, t.Any] | None + ) -> dict[str, t.Any] | None: + """ + Prepare the query params for a request. + + Filters out MISSING and formats primitives. + """ + if params is None: + return None + return filter_missing({k: self._format_primitive(v) for k, v in params.items()}) + + def _prepare_headers( + self, headers: dict[str, t.Any] | None + ) -> dict[str, t.Any] | None: + """ + Prepare the headers for a request. + + Filters out MISSING and formats primitives. + """ + if headers is None: + return None + return filter_missing( + {k: self._format_primitive(v) for k, v in headers.items()} + ) + + def _prepare_data(self, data: t.Any) -> t.Any: + """ + Prepare the data (body) for a request. + + If the body is a dict, list, or tuple, it will be recursively processed to + filter out MISSING and format primitives. + + Otherwise, it is returned as-is. + """ + if isinstance(data, dict): + return filter_missing({k: self._prepare_data(v) for k, v in data.items()}) + elif isinstance(data, (list, tuple)): + return [self._prepare_data(x) for x in data if x is not MISSING] + else: + return self._format_primitive(data) + + +class RequestsPlainTextProvider(RequestsRepresentationProvider): + """The plain-text provider ensures that the body is text.""" + + def encode( + self, + method: str, + url: str, + params: dict[str, t.Any] | None, + data: t.Any, + headers: dict[str, str], + ) -> requests.Request: + if not isinstance(data, (str, bytes)): + raise TypeError( + "Cannot encode non-text in a text request. " + "Either manually encode the data or use `encoding=form|json` to " + "correctly format this data." + ) + return super().encode( + method, + url, + data=self._prepare_data(data), + params=self._prepare_params(params), + headers=self._prepare_headers(headers), # type: ignore[arg-type] + ) + + +class RequestsJsonProvider(RequestsRepresentationProvider): + """ + This provider prepares request data as JSON. It also ensures that content-type is + set, so that APIs requiring a content-type of "application/json" are able to read + the data. + + When decoding response bodies, it decodes them as JSON content. + + If the ``orjson`` library is installed, it will be used to provide accelerated + encoding and decoding. + """ + + 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} + + # use `orjson` if it's available + if orjson_compat.ORJSON_AVAILABLE: + body = prepared = self._prepare_data(data) + if body is not None: + body = orjson_compat.dumps(body) + + return requests.Request( + method, + url, + # passing both 'data' and 'json' ensures that both attributes are set, + # but only the 'data' will be used for the body of the prepared request + data=body, + json=prepared, + params=self._prepare_params(params), + headers=self._prepare_headers(headers), + ) + else: + return requests.Request( + method, + url, + json=self._prepare_data(data), + params=self._prepare_params(params), + headers=self._prepare_headers(headers), + ) + + def decode_body(self, response: requests.Response) -> t.Any: + if orjson_compat.ORJSON_AVAILABLE: + return orjson_compat.loads(response.content) + else: + return response.json() + + +class RequestsHttpFormProvider(RequestsRepresentationProvider): + """ + This provider prepares request data as a form-encoded body. + + It requires that the input data is a dict -- any other datatype will result in + errors. + + Decoding raises an error, as decoding with this format is not implemented. + """ + + def encode( + self, + method: str, + url: str, + params: dict[str, t.Any] | None, + data: t.Any, + headers: dict[str, str] | None, + ) -> requests.Request: + import requests + + if not isinstance(data, dict): + raise TypeError("HttpFormProvider cannot encode non-dict data") + return requests.Request( + method, + url, + data=self._prepare_data(data), + params=self._prepare_params(params), + headers=self._prepare_headers(headers), + ) + + def decode_body(self, response: requests.Response) -> t.Any: + raise NotImplementedError( + "globus-sdk does not currently implement form body decoding." + ) diff --git a/src/globus_sdk/transport/requests.py b/src/globus_sdk/transport/requests.py index 986bf9b3..97089d67 100644 --- a/src/globus_sdk/transport/requests.py +++ b/src/globus_sdk/transport/requests.py @@ -1,19 +1,19 @@ from __future__ import annotations import contextlib +import contextvars +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.encoders import ( - FormRequestEncoder, - JSONRequestEncoder, - RequestEncoder, +from globus_sdk.transport.representation_providers import ( + RequestsHttpFormProvider, + RequestsJsonProvider, + RequestsPlainTextProvider, + RequestsRepresentationProvider, ) from ._clientinfo import GlobusClientInfo @@ -22,9 +22,40 @@ 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__) +C = t.TypeVar("C", bound=t.Callable[..., t.Any]) + + +_DEFAULT_JSON_PROVIDER = RequestsJsonProvider() +_DEFAULT_TEXT_PROVIDER = RequestsPlainTextProvider() +_DEFAULT_HTTP_FORM_PROVIDER = RequestsHttpFormProvider() + + +# 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) +) + + +def _self_as_current_transport(func: C) -> C: + """A decorator to apply self._as_current_transport() automatically.""" + + @functools.wraps(func) + def wrapped(self: RequestsTransport, *args: t.Any, **kwargs: t.Any) -> t.Any: + with self._as_current_transport(): + return func(self, *args, **kwargs) + + return wrapped # type: ignore[return-value] + + class RequestsTransport: """ The RequestsTransport handles HTTP request sending and retries. @@ -52,11 +83,17 @@ 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] = { - "text": RequestEncoder(), - "json": JSONRequestEncoder(), - "form": FormRequestEncoder(), + #: The encoders are a mapping of encoding names to content-type providers. + #: + #: .. warning:: + #: + #: This interface is deprecated, in favor of the instance-level + #: ``representation_providers``. This is used to seed that mapping per instance + #: and will be removed in a future release. + encoders: t.ClassVar[dict[str, RequestsRepresentationProvider]] = { + "text": _DEFAULT_TEXT_PROVIDER, + "json": _DEFAULT_JSON_PROVIDER, + "form": _DEFAULT_HTTP_FORM_PROVIDER, } BASE_USER_AGENT = f"globus-sdk-py-{__version__}" @@ -66,6 +103,8 @@ def __init__( verify_ssl: bool | str | pathlib.Path | None = None, http_timeout: float | 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) @@ -79,6 +118,9 @@ def __init__( "X-Globus-Client-Info": self.globus_client_info.format(), } + # copy and return the class-level mapping + self.representation_providers = self.encoders.copy() + def close(self) -> None: """ Closes all resources owned by the transport, primarily the underlying @@ -86,6 +128,49 @@ 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_json_provider() -> RequestsRepresentationProvider: + """ + Retrieve the current transport content-type provider, with a fallback to + the default JSON one. + """ + try: + transport = RequestsTransport.get_current_transport() + except LookupError: + return _DEFAULT_JSON_PROVIDER + else: + return transport.json_provider + @property def user_agent(self) -> str: return self._user_agent @@ -162,6 +247,10 @@ def tune( self.http_timeout, ) = saved_settings + @functools.cached_property + def json_provider(self) -> RequestsRepresentationProvider: + return self.representation_providers["json"] + def _encode( self, method: str, @@ -182,12 +271,14 @@ def _encode( else: encoding = "json" - if encoding not in self.encoders: + if encoding not in self.representation_providers: 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.representation_providers[encoding].encode( + method, url, query_params, data, headers + ) def _set_authz_header( self, authorizer: GlobusAuthorizer | None, req: requests.Request @@ -216,6 +307,7 @@ def _retry_sleep(self, retry_config: RetryConfig, ctx: RetryContext) -> None: ) time.sleep(sleep_period) + @_self_as_current_transport def request( self, method: str, @@ -250,6 +342,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) diff --git a/src/globus_sdk/transport/retry.py b/src/globus_sdk/transport/retry.py index 08d46e70..d1693dfb 100644 --- a/src/globus_sdk/transport/retry.py +++ b/src/globus_sdk/transport/retry.py @@ -3,9 +3,9 @@ import enum import typing as t -import requests - if t.TYPE_CHECKING: + import requests + from .caller_info import RequestCallerInfo C = t.TypeVar("C", bound=t.Callable[..., t.Any]) diff --git a/tests/common/fast_json.py b/tests/common/fast_json.py new file mode 100644 index 00000000..6dca5690 --- /dev/null +++ b/tests/common/fast_json.py @@ -0,0 +1,16 @@ +# binding for dumps/loads which prefers orjson if it's available +import json + +from globus_sdk._internal import orjson_compat + +JSONDecodeError = json.JSONDecodeError + +if orjson_compat.ORJSON_AVAILABLE: + + def dumps(data): + return orjson_compat.dumps(data).decode() + + loads = orjson_compat.loads +else: + dumps = json.dumps + loads = json.loads diff --git a/tests/common/response_mock.py b/tests/common/response_mock.py index 7008f94e..94aa73bc 100644 --- a/tests/common/response_mock.py +++ b/tests/common/response_mock.py @@ -1,8 +1,9 @@ -import json from unittest import mock import requests +from . import fast_json + class PickleableMockResponse(mock.NonCallableMock): """ @@ -40,7 +41,8 @@ def __init__( self._json_body = json_body - self.text = text or (json.dumps(json_body) if json_body else "") + self.text = text or (fast_json.dumps(json_body) if json_body else "") + self.content = self.text.encode() def json(self): if self._json_body is not None: diff --git a/tests/conftest.py b/tests/conftest.py index 33c0a4be..ea96c2ec 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -27,7 +27,16 @@ def mocked_responses(): @pytest.fixture -def make_response(): +def mock_client_factory(): + def build(): + client = mock.Mock() + return client + + return build + + +@pytest.fixture +def make_response(mock_client_factory): def _make_response( response_class=None, status=200, @@ -48,7 +57,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 81aa7fb2..418f4f55 100644 --- a/tests/functional/base_client/test_encodings.py +++ b/tests/functional/base_client/test_encodings.py @@ -1,6 +1,19 @@ +from __future__ import annotations + +import typing as t + import pytest +import requests import responses +import globus_sdk +from globus_sdk.transport import RequestEncoder, RequestsTransport +from globus_sdk.transport.representation_providers import ( + RequestsJsonProvider, + RequestsRepresentationProvider, +) +from tests.common import fast_json + def test_cannot_encode_dict_as_text(client): with pytest.raises(TypeError): @@ -45,3 +58,89 @@ def test_text_encoding_can_send_non_ascii_utf8_bytes(client): last_req = responses.calls[-1].request assert last_req.body == '{"field“: "value“}'.encode() + + +@pytest.mark.parametrize( + "provider_base_class", (RequestEncoder, RequestsRepresentationProvider) +) +def test_can_configure_custom_encoding(client_class, provider_base_class): + class MyRequestEncoder(provider_base_class): + 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.representation_providers["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 fast_json.loads(last_req.body) == {"foo": {"baz": 1}} + + +def test_can_configure_custom_decoding(client_class): + class MyProvider(RequestsJsonProvider): + def decode_body(self, response: requests.Response) -> t.Any: + return {"a": "clever-cultural-reference-goes-here"} + + my_transport = RequestsTransport() + my_transport.json_provider = MyProvider() + 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 MyProvider(RequestsJsonProvider): + def decode_body(self, response: requests.Response) -> t.Any: + return {"a": "clever-cultural-reference-goes-here"} + + my_transport = RequestsTransport() + my_transport.json_provider = MyProvider() + client = client_class(transport=my_transport) + + responses.add( + responses.POST, + "https://foo.api.globus.org/bar", + body="bye", + status=404, + 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/functional/base_client/test_filter_missing.py b/tests/functional/base_client/test_filter_missing.py index f1b10c42..c095a9d7 100644 --- a/tests/functional/base_client/test_filter_missing.py +++ b/tests/functional/base_client/test_filter_missing.py @@ -1,10 +1,10 @@ -import json import urllib.parse import pytest from globus_sdk import MISSING from globus_sdk.testing import RegisteredResponse, get_last_request, load_response +from tests.common import fast_json @pytest.fixture(autouse=True) @@ -43,7 +43,7 @@ def test_json_body_can_filter_missing(client): res = client.post("/bar", data={"foo": "bar", "baz": MISSING}) assert res.http_status == 200 req = get_last_request() - sent = json.loads(req.body) + sent = fast_json.loads(req.body) assert sent == {"foo": "bar"} diff --git a/tests/functional/services/auth/service_client/test_create_project.py b/tests/functional/services/auth/service_client/test_create_project.py index c649945b..e89998fc 100644 --- a/tests/functional/services/auth/service_client/test_create_project.py +++ b/tests/functional/services/auth/service_client/test_create_project.py @@ -1,9 +1,9 @@ -import json import uuid import pytest from globus_sdk.testing import get_last_request, load_response +from tests.common import fast_json @pytest.mark.parametrize( @@ -32,7 +32,7 @@ def test_create_project_admin_id_styles(service_client, admin_id_style): assert res["project"]["id"] == meta["id"] last_req = get_last_request() - data = json.loads(last_req.body) + data = fast_json.loads(last_req.body) assert list(data) == ["project"], data # 'project' is the only key assert data["project"]["display_name"] == "My Project", data assert data["project"]["contact_email"] == "support@globus.org", data @@ -66,7 +66,7 @@ def test_create_project_group_id_styles(service_client, group_id_style): assert res["project"]["id"] == meta["id"] last_req = get_last_request() - data = json.loads(last_req.body) + data = fast_json.loads(last_req.body) assert list(data) == ["project"], data # 'project' is the only key assert data["project"]["display_name"] == "My Project", data assert data["project"]["contact_email"] == "support@globus.org", data diff --git a/tests/functional/services/auth/service_client/test_update_project.py b/tests/functional/services/auth/service_client/test_update_project.py index 185b7a6f..77048584 100644 --- a/tests/functional/services/auth/service_client/test_update_project.py +++ b/tests/functional/services/auth/service_client/test_update_project.py @@ -1,10 +1,10 @@ -import json import uuid import pytest from globus_sdk._missing import MISSING, filter_missing from globus_sdk.testing import get_last_request, load_response +from tests.common import fast_json @pytest.mark.parametrize( @@ -36,7 +36,7 @@ def test_update_project_admin_id_styles(service_client, admin_id_style): assert res["project"]["id"] == meta["id"] last_req = get_last_request() - data = json.loads(last_req.body) + data = fast_json.loads(last_req.body) assert list(data) == ["project"], data # 'project' is the only key if admin_id_style == "missing": assert filter_missing(data["project"]) == {"display_name": "My Project"} @@ -74,7 +74,7 @@ def test_update_project_group_id_styles(service_client, group_id_style): assert res["project"]["id"] == meta["id"] last_req = get_last_request() - data = json.loads(last_req.body) + data = fast_json.loads(last_req.body) assert list(data) == ["project"], data # 'project' is the only key assert data["project"] == { "contact_email": "support@globus.org", diff --git a/tests/functional/services/auth/test_id_token.py b/tests/functional/services/auth/test_id_token.py index 2c44caac..48901d66 100644 --- a/tests/functional/services/auth/test_id_token.py +++ b/tests/functional/services/auth/test_id_token.py @@ -1,11 +1,10 @@ -import json import time import jwt import pytest import globus_sdk -from tests.common import register_api_route +from tests.common import fast_json, register_api_route OIDC_CONFIG = { "issuer": "https://auth.globus.org", @@ -44,7 +43,7 @@ } ] } -JWK_PEM = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(JWK["keys"][0])) +JWK_PEM = jwt.algorithms.RSAAlgorithm.from_jwk(fast_json.dumps(JWK["keys"][0])) TOKEN_PAYLOAD = { "access_token": "auth_access_token", @@ -85,7 +84,7 @@ def client(): @pytest.fixture(autouse=True) def register_token_response(): register_api_route( - "auth", "/v2/oauth2/token", method="POST", body=json.dumps(TOKEN_PAYLOAD) + "auth", "/v2/oauth2/token", method="POST", body=fast_json.dumps(TOKEN_PAYLOAD) ) @@ -105,16 +104,16 @@ def test_decode_id_token(token_response): "auth", "/.well-known/openid-configuration", method="GET", - body=json.dumps(OIDC_CONFIG), + body=fast_json.dumps(OIDC_CONFIG), ) - register_api_route("auth", "/jwk.json", method="GET", body=json.dumps(JWK)) + register_api_route("auth", "/jwk.json", method="GET", body=fast_json.dumps(JWK)) decoded = token_response.decode_id_token(jwt_params={"verify_exp": False}) assert decoded["preferred_username"] == "sirosen2@globusid.org" def test_decode_id_token_with_saved_oidc_config(token_response): - register_api_route("auth", "/jwk.json", method="GET", body=json.dumps(JWK)) + register_api_route("auth", "/jwk.json", method="GET", body=fast_json.dumps(JWK)) decoded = token_response.decode_id_token( openid_configuration=OIDC_CONFIG, jwt_params={"verify_exp": False} @@ -141,9 +140,9 @@ def test_decode_id_token_with_leeway(token_response): "auth", "/.well-known/openid-configuration", method="GET", - body=json.dumps(OIDC_CONFIG), + body=fast_json.dumps(OIDC_CONFIG), ) - register_api_route("auth", "/jwk.json", method="GET", body=json.dumps(JWK)) + register_api_route("auth", "/jwk.json", method="GET", body=fast_json.dumps(JWK)) # do a decode with a leeway parameter set high enough that the ancient # expiration time will be tolerated diff --git a/tests/functional/services/flows/test_flow_crud.py b/tests/functional/services/flows/test_flow_crud.py index df1ef850..972e34df 100644 --- a/tests/functional/services/flows/test_flow_crud.py +++ b/tests/functional/services/flows/test_flow_crud.py @@ -1,11 +1,10 @@ -import json - import pytest from responses import matchers from globus_sdk import MISSING, FlowsAPIError from globus_sdk.testing import get_last_request, load_response from globus_sdk.testing.models import RegisteredResponse +from tests.common import fast_json @pytest.mark.parametrize("subscription_id", [MISSING, None, "dummy_subscription_id"]) @@ -18,7 +17,7 @@ def test_create_flow(flows_client, subscription_id): assert resp.data["title"] == "Multi Step Transfer" last_req = get_last_request() - req_body = json.loads(last_req.body) + req_body = fast_json.loads(last_req.body) if subscription_id is not MISSING: assert req_body["subscription_id"] == subscription_id else: @@ -65,7 +64,7 @@ def test_create_flow_run_role_serialization(flows_client, key, value): flows_client.create_flow(**request_body) last_req = get_last_request() - req_body = json.loads(last_req.body) + req_body = fast_json.loads(last_req.body) if value is MISSING: assert key not in req_body @@ -118,7 +117,7 @@ def test_update_flow_run_role_serialization(flows_client, key, value): flows_client.update_flow(metadata["flow_id"], **params) last_req = get_last_request() - req_body = json.loads(last_req.body) + req_body = fast_json.loads(last_req.body) if value is MISSING: assert key not in req_body diff --git a/tests/functional/services/flows/test_flow_validate.py b/tests/functional/services/flows/test_flow_validate.py index 0497343a..4c84d924 100644 --- a/tests/functional/services/flows/test_flow_validate.py +++ b/tests/functional/services/flows/test_flow_validate.py @@ -1,9 +1,8 @@ -import json - import pytest from globus_sdk import MISSING, FlowsAPIError from globus_sdk.testing import get_last_request, load_response +from tests.common import fast_json @pytest.mark.parametrize("input_schema", [MISSING, {}]) @@ -22,7 +21,7 @@ def test_validate_flow(flows_client, input_schema): # Check what was actually sent last_req = get_last_request() - req_body = json.loads(last_req.body) + req_body = fast_json.loads(last_req.body) # Ensure the input schema is not sent if omitted assert req_body == payload diff --git a/tests/functional/services/flows/test_run_crud.py b/tests/functional/services/flows/test_run_crud.py index e9c30de5..099e7b7b 100644 --- a/tests/functional/services/flows/test_run_crud.py +++ b/tests/functional/services/flows/test_run_crud.py @@ -1,9 +1,8 @@ -import json - import pytest from globus_sdk import FlowsAPIError from globus_sdk.testing import get_last_request, load_response +from tests.common import fast_json def test_get_run_definition(flows_client): @@ -48,7 +47,7 @@ def test_update_run(flows_client, values): request = get_last_request() assert request.method == "PUT" assert request.url.endswith(f"/runs/{metadata['run_id']}") - assert json.loads(request.body) == values + assert fast_json.loads(request.body) == values # Ensure deprecated routes are not used. assert f"/flows/{metadata['flow_id']}" not in request.url @@ -77,7 +76,7 @@ def test_update_run_additional_fields(flows_client): request = get_last_request() assert request.method == "PUT" assert request.url.endswith(f"/runs/{metadata['run_id']}") - assert json.loads(request.body) == additional_fields + assert fast_json.loads(request.body) == additional_fields def test_delete_run_success(flows_client): diff --git a/tests/functional/services/flows/test_run_flow.py b/tests/functional/services/flows/test_run_flow.py index 116ba387..60a38e28 100644 --- a/tests/functional/services/flows/test_run_flow.py +++ b/tests/functional/services/flows/test_run_flow.py @@ -1,12 +1,11 @@ from __future__ import annotations -import json - import pytest import globus_sdk from globus_sdk import FlowsAPIError, SpecificFlowClient from globus_sdk.testing import get_last_request, load_response +from tests.common import fast_json def test_run_flow(specific_flow_client_class: type[SpecificFlowClient]): @@ -45,7 +44,7 @@ def test_run_flow_without_activity_notification_policy( assert resp.http_status == 200 last_req = get_last_request() - sent_payload = json.loads(last_req.body) + sent_payload = fast_json.loads(last_req.body) assert "activity_notification_policy" not in sent_payload @@ -59,7 +58,7 @@ def test_run_flow_with_empty_activity_notification_policy( flow_client.run_flow({}, activity_notification_policy=policy) last_req = get_last_request() - sent_payload = json.loads(last_req.body) + sent_payload = fast_json.loads(last_req.body) assert "activity_notification_policy" in sent_payload assert sent_payload["activity_notification_policy"] == {} @@ -74,7 +73,7 @@ def test_run_flow_with_activity_notification_policy( flow_client.run_flow({}, activity_notification_policy=policy) last_req = get_last_request() - sent_payload = json.loads(last_req.body) + sent_payload = fast_json.loads(last_req.body) assert "activity_notification_policy" in sent_payload assert sent_payload["activity_notification_policy"] == { "status": ["FAILED", "INACTIVE"] diff --git a/tests/functional/services/gcs/test_roles.py b/tests/functional/services/gcs/test_roles.py index 2bc545f7..326e5dc3 100644 --- a/tests/functional/services/gcs/test_roles.py +++ b/tests/functional/services/gcs/test_roles.py @@ -1,8 +1,6 @@ -import json - from globus_sdk import GCSRoleDocument from globus_sdk.testing import get_last_request -from tests.common import register_api_route_fixture_file +from tests.common import fast_json, register_api_route_fixture_file def test_get_role_list(client): @@ -79,7 +77,7 @@ def test_create_role(client): res = client.create_role(data) assert res["id"] == "{role_id_1}" - json_body = json.loads(get_last_request().body) + json_body = fast_json.loads(get_last_request().body) assert json_body["collection"] in (None, "{collection_id_1}") assert json_body["principal"] == "urn:globus:auth:identity:{user_id_1}" diff --git a/tests/functional/services/gcs/test_user_credential.py b/tests/functional/services/gcs/test_user_credential.py index 61c092d7..5d3b0964 100644 --- a/tests/functional/services/gcs/test_user_credential.py +++ b/tests/functional/services/gcs/test_user_credential.py @@ -1,7 +1,6 @@ -import json - from globus_sdk import ConnectorTable, UserCredentialDocument from globus_sdk.testing import get_last_request, load_response +from tests.common import fast_json def test_get_user_credential_list(client): @@ -54,7 +53,7 @@ def test_create_user_credential(client): assert res.full_data["DATA_TYPE"] == "result#1.0.0" assert res.full_data["message"] == f"Created User Credential {uc_id}" - req_body = req_body = json.loads(get_last_request().body) + req_body = req_body = fast_json.loads(get_last_request().body) assert req_body["DATA_TYPE"] == "user_credential#1.0.0" assert req_body["policies"]["DATA_TYPE"] == "s3_user_credential_policies#1.0.0" for key, value in req_body.items(): @@ -74,7 +73,7 @@ def test_update_user_credential(client): assert res.full_data["DATA_TYPE"] == "result#1.0.0" assert res.full_data["message"] == f"Updated User Credential {uc_id}" - req_body = json.loads(get_last_request().body) + req_body = fast_json.loads(get_last_request().body) assert req_body["display_name"] == "updated_posix_credential" diff --git a/tests/functional/services/groups/test_create_group.py b/tests/functional/services/groups/test_create_group.py index b630a948..b9731541 100644 --- a/tests/functional/services/groups/test_create_group.py +++ b/tests/functional/services/groups/test_create_group.py @@ -1,6 +1,5 @@ -import json - from globus_sdk.testing import get_last_request, load_response +from tests.common import fast_json def test_create_group(groups_client): @@ -15,7 +14,7 @@ def test_create_group(groups_client): assert res["id"] == meta["group_id"] req = get_last_request() - req_body = json.loads(req.body) + req_body = fast_json.loads(req.body) assert req_body["description"] == "No stairs allowed." @@ -31,5 +30,5 @@ def test_create_group_via_manager(groups_manager, groups_client): assert res["id"] == meta["group_id"] req = get_last_request() - req_body = json.loads(req.body) + req_body = fast_json.loads(req.body) assert req_body["description"] == "No stairs allowed." diff --git a/tests/functional/services/groups/test_group_memberships.py b/tests/functional/services/groups/test_group_memberships.py index 18adf173..e8bb8aab 100644 --- a/tests/functional/services/groups/test_group_memberships.py +++ b/tests/functional/services/groups/test_group_memberships.py @@ -1,11 +1,10 @@ -import json import uuid import pytest from globus_sdk import BatchMembershipActions, GroupRole from globus_sdk.testing import RegisteredResponse, get_last_request, load_response -from tests.common import register_api_route_fixture_file +from tests.common import fast_json, register_api_route_fixture_file def test_approve_pending(groups_manager): @@ -54,7 +53,7 @@ def test_add_member(groups_manager, role): assert data["add"][0]["role"] == "admin" req = get_last_request() - req_body = json.loads(req.body) + req_body = fast_json.loads(req.body) assert req_body["add"][0]["role"] == rolestr @@ -101,7 +100,7 @@ def test_batch_action_payload(groups_client, role): # send the request and confirm that the data is serialized correctly groups_client.batch_membership_action(group_id, batch_action) req = get_last_request() - req_body = json.loads(req.body) + req_body = fast_json.loads(req.body) # role should be stringified if it was an enum member assert all(member["role"] == rolestr for member in req_body["add"]) # UUIDs should have been stringified diff --git a/tests/functional/services/groups/test_set_group_policies.py b/tests/functional/services/groups/test_set_group_policies.py index e181ee1d..5308680c 100644 --- a/tests/functional/services/groups/test_set_group_policies.py +++ b/tests/functional/services/groups/test_set_group_policies.py @@ -1,5 +1,3 @@ -import json - import pytest from globus_sdk import ( @@ -10,6 +8,7 @@ GroupVisibility, ) from globus_sdk.testing import get_last_request, load_response +from tests.common import fast_json @pytest.mark.parametrize( @@ -66,7 +65,7 @@ def test_set_group_policies( assert "address1" in resp.data["signup_fields"] # ensure enums were stringified correctly req = get_last_request() - req_body = json.loads(req.body) + req_body = fast_json.loads(req.body) assert req_body["group_visibility"] == group_vis_str assert req_body["group_members_visibility"] == group_member_vis_str assert req_body["signup_fields"] == signup_fields_str @@ -140,7 +139,7 @@ def test_set_group_policies_explicit_payload( groups_client.set_group_policies(meta["group_id"], payload) # ensure enums were stringified correctly, but also that the raw string came through req = get_last_request() - req_body = json.loads(req.body) + req_body = fast_json.loads(req.body) assert req_body["group_visibility"] == group_vis_str assert req_body["group_members_visibility"] == group_member_vis_str assert req_body["signup_fields"] == signup_fields_str diff --git a/tests/functional/services/groups/test_set_subscription_admin_verified.py b/tests/functional/services/groups/test_set_subscription_admin_verified.py index 84a07682..7a3a9869 100644 --- a/tests/functional/services/groups/test_set_subscription_admin_verified.py +++ b/tests/functional/services/groups/test_set_subscription_admin_verified.py @@ -1,6 +1,5 @@ -import json - from globus_sdk.testing import get_last_request, load_response +from tests.common import fast_json def test_set_subscription_admin_verified(groups_client): @@ -15,5 +14,5 @@ def test_set_subscription_admin_verified(groups_client): assert res.data["subscription_admin_verified_id"] == meta["subscription_id"] req = get_last_request() - req = json.loads(req.body) + req = fast_json.loads(req.body) assert req == {"subscription_admin_verified_id": meta["subscription_id"]} diff --git a/tests/functional/services/search/test_batch_delete_by_subject.py b/tests/functional/services/search/test_batch_delete_by_subject.py index c3cf2a2c..9276f8ba 100644 --- a/tests/functional/services/search/test_batch_delete_by_subject.py +++ b/tests/functional/services/search/test_batch_delete_by_subject.py @@ -1,6 +1,5 @@ -import json - from globus_sdk.testing import get_last_request, load_response +from tests.common import fast_json def test_batch_delete_by_subject(client): @@ -17,7 +16,7 @@ def test_batch_delete_by_subject(client): assert res["task_id"] == meta["task_id"] req = get_last_request() - sent_data = json.loads(req.body) + sent_data = fast_json.loads(req.body) assert sent_data == {"subjects": input_subjects} @@ -41,7 +40,7 @@ def test_batch_delete_by_subject_accepts_string(client): assert res["task_id"] == meta["task_id"] req = get_last_request() - sent_data = json.loads(req.body) + sent_data = fast_json.loads(req.body) assert sent_data == {"subjects": [input_subject]} @@ -63,5 +62,5 @@ def test_batch_delete_by_subject_allows_additional_params(client): assert res["task_id"] == meta["task_id"] req = get_last_request() - sent_data = json.loads(req.body) + sent_data = fast_json.loads(req.body) assert sent_data == {"subjects": input_subjects, "foo": "snork"} diff --git a/tests/functional/services/search/test_search.py b/tests/functional/services/search/test_search.py index 442bffd6..08ed6fae 100644 --- a/tests/functional/services/search/test_search.py +++ b/tests/functional/services/search/test_search.py @@ -1,4 +1,3 @@ -import json import urllib.parse import uuid @@ -8,7 +7,7 @@ import globus_sdk from globus_sdk._missing import filter_missing from globus_sdk.testing import get_last_request, load_response -from tests.common import register_api_route_fixture_file +from tests.common import fast_json, register_api_route_fixture_file @pytest.fixture @@ -47,7 +46,7 @@ def test_search_post_query_simple(search_client, query_doc): req = get_last_request() assert req.body is not None - req_body = json.loads(req.body) + req_body = fast_json.loads(req.body) assert req_body == dict(query_doc) @@ -64,7 +63,7 @@ def test_search_post_query_simple_with_v1_helper(search_client): req = get_last_request() assert req.body is not None - req_body = json.loads(req.body) + req_body = fast_json.loads(req.body) assert req_body == {"@version": "query#1.0.0", "q": "foo"} @@ -87,7 +86,7 @@ def test_search_post_query_arg_overrides(search_client, doc_type): req = get_last_request() assert req.body is not None - req_body = json.loads(req.body) + req_body = fast_json.loads(req.body) assert req_body != dict(query_doc) assert req_body["q"] == query_doc["q"] assert req_body["limit"] == 100 diff --git a/tests/functional/services/search/test_search_roles.py b/tests/functional/services/search/test_search_roles.py index afd77b93..7b761a35 100644 --- a/tests/functional/services/search/test_search_roles.py +++ b/tests/functional/services/search/test_search_roles.py @@ -1,9 +1,8 @@ -import json - import pytest import globus_sdk from globus_sdk.testing import get_last_request, load_response +from tests.common import fast_json @pytest.fixture @@ -26,7 +25,7 @@ def test_search_role_create(search_client): assert res["role_name"] == "writer" last_req = get_last_request() - sent = json.loads(last_req.body) + sent = fast_json.loads(last_req.body) assert sent == send_data diff --git a/tests/functional/services/timers/test_create_timer.py b/tests/functional/services/timers/test_create_timer.py index 13faf188..64ad33cb 100644 --- a/tests/functional/services/timers/test_create_timer.py +++ b/tests/functional/services/timers/test_create_timer.py @@ -1,8 +1,7 @@ -import json - import globus_sdk from globus_sdk._missing import filter_missing from globus_sdk.testing import get_last_request, load_response +from tests.common import fast_json def test_dummy_timer_creation(client): @@ -14,7 +13,7 @@ def test_dummy_timer_creation(client): assert timer["timer"]["job_id"] == meta["timer_id"] req = get_last_request() - sent = json.loads(req.body) + sent = fast_json.loads(req.body) assert sent == {"timer": {"foo": "bar"}} @@ -38,7 +37,7 @@ def test_transfer_timer_creation(client): assert timer["timer"]["job_id"] == meta["timer_id"] req = get_last_request() - sent = json.loads(req.body) + sent = fast_json.loads(req.body) assert sent["timer"]["schedule"] == { "type": "recurring", "interval_seconds": 60, @@ -66,7 +65,7 @@ def test_flow_timer_creation(client): # Verify req = get_last_request() - sent = json.loads(req.body) + sent = fast_json.loads(req.body) assert sent["timer"]["flow_id"] == meta["flow_id"] assert sent["timer"]["body"] == meta["callback_body"] assert sent["timer"]["schedule"] == meta["schedule"] diff --git a/tests/functional/services/timers/test_jobs.py b/tests/functional/services/timers/test_jobs.py index a69e37c9..6c1a1442 100644 --- a/tests/functional/services/timers/test_jobs.py +++ b/tests/functional/services/timers/test_jobs.py @@ -1,10 +1,10 @@ import datetime -import json import pytest from globus_sdk import TimerJob, TimersAPIError from globus_sdk.testing import get_last_request, load_response +from tests.common import fast_json def test_list_jobs(client): @@ -45,7 +45,7 @@ def test_create_job(client, start, interval): assert response.http_status == 201 assert response.data["job_id"] == meta["job_id"] - req_body = json.loads(get_last_request().body) + req_body = fast_json.loads(get_last_request().body) if isinstance(start, datetime.datetime): assert req_body["start"] == start.isoformat() else: @@ -107,5 +107,5 @@ def test_resume_job(update_credentials, client): response = client.resume_job(meta["job_id"], **kwargs) assert response.http_status == 200 - assert json.loads(response._raw_response.request.body) == kwargs + assert fast_json.loads(response._raw_response.request.body) == kwargs assert "Successfully resumed" in response.data["message"] diff --git a/tests/functional/services/transfer/test_create_tunnel.py b/tests/functional/services/transfer/test_create_tunnel.py index 3a7026c0..4bfd859f 100644 --- a/tests/functional/services/transfer/test_create_tunnel.py +++ b/tests/functional/services/transfer/test_create_tunnel.py @@ -1,4 +1,3 @@ -import json import uuid import pytest @@ -6,6 +5,7 @@ from globus_sdk import exc from globus_sdk.services.transfer import CreateTunnelData from globus_sdk.testing import get_last_request, load_response +from tests.common import fast_json def test_create_tunnel(client): @@ -24,7 +24,7 @@ def test_create_tunnel(client): assert res["data"]["type"] == "Tunnel" req = get_last_request() - sent = json.loads(req.body) + sent = fast_json.loads(req.body) assert ( sent["data"]["relationships"]["initiator"]["data"]["id"] == meta["initiator_ap"] ) @@ -46,7 +46,7 @@ def test_create_tunnel_no_submission(client): assert res.http_status == 200 req = get_last_request() - sent = json.loads(req.body) + sent = fast_json.loads(req.body) assert sent["data"]["attributes"]["submission_id"] is not None diff --git a/tests/functional/services/transfer/test_operation_mkdir.py b/tests/functional/services/transfer/test_operation_mkdir.py index e9111acb..b43b55a5 100644 --- a/tests/functional/services/transfer/test_operation_mkdir.py +++ b/tests/functional/services/transfer/test_operation_mkdir.py @@ -1,10 +1,10 @@ -import json import urllib.parse import pytest from globus_sdk import MISSING from globus_sdk.testing import get_last_request, load_response +from tests.common import fast_json _OMIT = object() @@ -31,7 +31,7 @@ def test_operation_mkdir(client, local_user): assert res["code"] == "DirectoryCreated" req = get_last_request() - body = json.loads(req.body) + body = fast_json.loads(req.body) assert body["path"] == "~/dir/" if local_user not in (_OMIT, MISSING): assert body["local_user"] == local_user diff --git a/tests/functional/services/transfer/test_operation_rename.py b/tests/functional/services/transfer/test_operation_rename.py index e4e59e0d..ed9211b5 100644 --- a/tests/functional/services/transfer/test_operation_rename.py +++ b/tests/functional/services/transfer/test_operation_rename.py @@ -1,10 +1,10 @@ -import json import urllib.parse import pytest from globus_sdk import MISSING from globus_sdk.testing import get_last_request, load_response +from tests.common import fast_json _OMIT = object() @@ -33,7 +33,7 @@ def test_operation_rename(client, local_user): assert res["code"] == "FileRenamed" req = get_last_request() - body = json.loads(req.body) + body = fast_json.loads(req.body) assert body["old_path"] == "~/old-name" assert body["new_path"] == "~/new-name" if local_user not in (_OMIT, MISSING): diff --git a/tests/functional/services/transfer/test_simple.py b/tests/functional/services/transfer/test_simple.py index 1e03b88b..5b52da09 100644 --- a/tests/functional/services/transfer/test_simple.py +++ b/tests/functional/services/transfer/test_simple.py @@ -1,9 +1,9 @@ -import json import uuid import pytest from globus_sdk.testing import get_last_request, load_response +from tests.common import fast_json def test_get_endpoint(client): @@ -38,4 +38,4 @@ def test_update_endpoint(epid_type, client): assert update_doc["message"] == "Endpoint updated successfully" req = get_last_request() - assert json.loads(req.body) == update_data + assert fast_json.loads(req.body) == update_data diff --git a/tests/functional/services/transfer/test_task_submit.py b/tests/functional/services/transfer/test_task_submit.py index c2953fc4..8c203838 100644 --- a/tests/functional/services/transfer/test_task_submit.py +++ b/tests/functional/services/transfer/test_task_submit.py @@ -2,13 +2,11 @@ Tests for submitting Transfer and Delete tasks """ -import json - import pytest from globus_sdk import DeleteData, TransferAPIError, TransferData from globus_sdk.testing import get_last_request, load_response -from tests.common import GO_EP1_ID, GO_EP2_ID +from tests.common import GO_EP1_ID, GO_EP2_ID, fast_json def test_transfer_submit_failure(client): @@ -48,7 +46,7 @@ def test_transfer_submit_success(client): assert res["submission_id"] == meta["submission_id"] assert res["task_id"] == meta["task_id"] - req_body = json.loads(get_last_request().body) + req_body = fast_json.loads(get_last_request().body) assert req_body["source_local_user"] == "my-source-user" assert req_body["destination_local_user"] == "my-dest-user" @@ -74,7 +72,7 @@ def test_delete_submit_success(client): assert res["submission_id"] == meta["submission_id"] assert res["task_id"] == meta["task_id"] - req_body = json.loads(get_last_request().body) + req_body = fast_json.loads(get_last_request().body) assert req_body["local_user"] == "my-user" @@ -90,7 +88,7 @@ def test_submit_adds_missing_submission_id_to_data(client, datatype): client.submit_delete(data) assert "submission_id" in data assert data["submission_id"] == meta["submission_id"] - req_body = json.loads(get_last_request().body) + req_body = fast_json.loads(get_last_request().body) assert req_body == data @@ -106,5 +104,5 @@ def test_submit_does_not_overwrite_existing_submission_id(client, datatype): client.submit_delete(data) assert data["submission_id"] == "foo" assert data["submission_id"] != meta["submission_id"] - req_body = json.loads(get_last_request().body) + req_body = fast_json.loads(get_last_request().body) assert req_body == data diff --git a/tests/functional/services/transfer/test_update_tunnel.py b/tests/functional/services/transfer/test_update_tunnel.py index cc8e2368..b0b63465 100644 --- a/tests/functional/services/transfer/test_update_tunnel.py +++ b/tests/functional/services/transfer/test_update_tunnel.py @@ -1,6 +1,5 @@ -import json - from globus_sdk.testing import get_last_request, load_response +from tests.common import fast_json def test_update_tunnel(client): @@ -20,5 +19,5 @@ def test_update_tunnel(client): assert res["data"]["type"] == "Tunnel" req = get_last_request() - sent = json.loads(req.body) + sent = fast_json.loads(req.body) assert sent["data"]["attributes"]["label"] == label diff --git a/tests/functional/services/transfer/v2/test_create_bookmark.py b/tests/functional/services/transfer/v2/test_create_bookmark.py index 9a33c272..5bb527b0 100644 --- a/tests/functional/services/transfer/v2/test_create_bookmark.py +++ b/tests/functional/services/transfer/v2/test_create_bookmark.py @@ -1,7 +1,6 @@ -import json - from globus_sdk.experimental import BookmarkCreateDocument from globus_sdk.testing import get_last_request, load_response +from tests.common import fast_json def test_create_bookmark(client): @@ -19,7 +18,7 @@ def test_create_bookmark(client): assert res["data"]["type"] == "Bookmark" req = get_last_request() - sent = json.loads(req.body) + sent = fast_json.loads(req.body) assert sent["data"]["attributes"]["name"] == meta["name"] assert sent["data"]["attributes"]["path"] == meta["path"] assert ( diff --git a/tests/functional/services/transfer/v2/test_create_tunnel.py b/tests/functional/services/transfer/v2/test_create_tunnel.py index 4bd55062..462509b5 100644 --- a/tests/functional/services/transfer/v2/test_create_tunnel.py +++ b/tests/functional/services/transfer/v2/test_create_tunnel.py @@ -1,8 +1,8 @@ -import json import uuid from globus_sdk.experimental import TunnelCreateDocument from globus_sdk.testing import get_last_request, load_response +from tests.common import fast_json def test_create_tunnel(client): @@ -21,7 +21,7 @@ def test_create_tunnel(client): assert res["data"]["type"] == "Tunnel" req = get_last_request() - sent = json.loads(req.body) + sent = fast_json.loads(req.body) assert ( sent["data"]["relationships"]["initiator"]["data"]["id"] == meta["initiator_ap"] ) @@ -47,5 +47,5 @@ def test_create_tunnel_no_submission(client): assert res.http_status == 200 req = get_last_request() - sent = json.loads(req.body) + sent = fast_json.loads(req.body) assert sent["data"]["attributes"]["submission_id"] == str(generated_uuid) diff --git a/tests/functional/services/transfer/v2/test_update_bookmark.py b/tests/functional/services/transfer/v2/test_update_bookmark.py index 5f30d296..d3be7ca8 100644 --- a/tests/functional/services/transfer/v2/test_update_bookmark.py +++ b/tests/functional/services/transfer/v2/test_update_bookmark.py @@ -1,7 +1,6 @@ -import json - from globus_sdk.experimental import BookmarkUpdateDocument from globus_sdk.testing import get_last_request, load_response +from tests.common import fast_json def test_update_bookmark(client): @@ -22,7 +21,7 @@ def test_update_bookmark(client): ) req = get_last_request() - sent = json.loads(req.body) + sent = fast_json.loads(req.body) assert sent["data"]["attributes"]["name"] == meta["name"] assert sent["data"]["attributes"]["path"] == meta["path"] assert sent["data"]["attributes"]["pinned"] == meta["pinned"] diff --git a/tests/functional/services/transfer/v2/test_update_tunnel.py b/tests/functional/services/transfer/v2/test_update_tunnel.py index 3f80e4c7..28089ea6 100644 --- a/tests/functional/services/transfer/v2/test_update_tunnel.py +++ b/tests/functional/services/transfer/v2/test_update_tunnel.py @@ -1,7 +1,6 @@ -import json - from globus_sdk.experimental import TunnelUpdateDocument from globus_sdk.testing import get_last_request, load_response +from tests.common import fast_json def test_update_tunnel(client): @@ -14,5 +13,5 @@ def test_update_tunnel(client): assert res["data"]["type"] == "Tunnel" req = get_last_request() - sent = json.loads(req.body) + sent = fast_json.loads(req.body) assert sent["data"]["attributes"]["label"] == label diff --git a/tests/functional/tokenstorage/v1/test_simplejson_file.py b/tests/functional/tokenstorage/v1/test_simplejson_file.py index b2eb8730..253d3fe0 100644 --- a/tests/functional/tokenstorage/v1/test_simplejson_file.py +++ b/tests/functional/tokenstorage/v1/test_simplejson_file.py @@ -1,10 +1,10 @@ -import json import os import pytest from globus_sdk import __version__ from globus_sdk.token_storage.legacy import SimpleJSONFileAdapter +from tests.common import fast_json IS_WINDOWS = os.name == "nt" @@ -30,7 +30,7 @@ def test_store(json_file, mock_response): assert not adapter.file_exists() adapter.store(mock_response) - data = json.loads(json_file.read_text()) + data = fast_json.loads(json_file.read_text()) assert data["globus-sdk.version"] == __version__ assert data["by_rs"]["resource_server_1"]["access_token"] == "access_token_1" assert data["by_rs"]["resource_server_2"]["access_token"] == "access_token_2" diff --git a/tests/functional/tokenstorage/v2/test_json_tokenstorage.py b/tests/functional/tokenstorage/v2/test_json_tokenstorage.py index dc78b4f6..7f09bd3c 100644 --- a/tests/functional/tokenstorage/v2/test_json_tokenstorage.py +++ b/tests/functional/tokenstorage/v2/test_json_tokenstorage.py @@ -1,4 +1,3 @@ -import json import os import pytest @@ -6,6 +5,7 @@ from globus_sdk import __version__ from globus_sdk.token_storage import JSONTokenStorage from globus_sdk.token_storage.legacy import SimpleJSONFileAdapter +from tests.common import fast_json IS_WINDOWS = os.name == "nt" @@ -47,7 +47,7 @@ def test_store_token_response_with_namespace(json_file, mock_response): assert not adapter.file_exists() adapter.store_token_response(mock_response) - data = json.loads(json_file.read_text()) + data = fast_json.loads(json_file.read_text()) assert data["globus-sdk.version"] == __version__ assert data["data"]["foo"]["resource_server_1"]["access_token"] == "access_token_1" assert data["data"]["foo"]["resource_server_2"]["access_token"] == "access_token_2" @@ -103,5 +103,5 @@ def test_migrate_from_v1_adapter(json_file, mock_response): # confirm version is overwritten on next store new_adapter.store_token_response(mock_response) - data = json.loads(json_file.read_text()) + data = fast_json.loads(json_file.read_text()) assert data["format_version"] == "2.0" diff --git a/tests/unit/globus_app/test_authorizer_factory.py b/tests/unit/globus_app/test_authorizer_factory.py index 3982d0f5..3943b254 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 d8b00f5f..63782284 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 591eeb29..41de7e6e 100644 --- a/tests/unit/responses/test_response.py +++ b/tests/unit/responses/test_response.py @@ -1,4 +1,3 @@ -import json import re from collections import namedtuple from unittest import mock @@ -8,6 +7,7 @@ import responses from globus_sdk.response import ArrayResponse, GlobusHTTPResponse, IterableResponse +from tests.common import fast_json _TestResponse = namedtuple("_TestResponse", ("data", "r")) @@ -17,7 +17,7 @@ def _response(data=None, encoding="utf-8", headers=None, status: int = 200): is_json = isinstance(data, (dict, list)) - datastr = json.dumps(data) if is_json else data + datastr = fast_json.dumps(data) if is_json else data if datastr is not None: if isinstance(datastr, str): r._content = datastr.encode("utf-8") @@ -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 883d8397..bf48989b 100644 --- a/tests/unit/services/auth/test_id_token_decoder.py +++ b/tests/unit/services/auth/test_id_token_decoder.py @@ -5,6 +5,7 @@ import requests import globus_sdk +from tests.common import fast_json class MockDecoder(globus_sdk.IDTokenDecoder): @@ -39,15 +40,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 +64,12 @@ 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()) + raw_response.content = fast_json.dumps(oidc_config).encode() + 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/sphinxext/test_enumerate_fixtures.py b/tests/unit/sphinxext/test_enumerate_fixtures.py index 57485252..38a4dea0 100644 --- a/tests/unit/sphinxext/test_enumerate_fixtures.py +++ b/tests/unit/sphinxext/test_enumerate_fixtures.py @@ -1,10 +1,10 @@ -import json import re import types import pytest import globus_sdk +from tests.common import fast_json def test_enumerate_fixtures_rejects_wrong_object_type(sphinx_runner, capsys): @@ -62,8 +62,8 @@ def test_enumerate_fixtures_of_search_client(sphinx_runner): assert example_block.get("language") == "json" content = example_block.text try: - json.loads(content) - except json.JSONDecodeError: + fast_json.loads(content) + except fast_json.JSONDecodeError: pytest.fail( f"{fixture_title} in SearchClient fixture docs didn't have JSON content" ) diff --git a/tests/unit/sphinxext/test_expand_testing_fixture.py b/tests/unit/sphinxext/test_expand_testing_fixture.py index 46de311f..70f23181 100644 --- a/tests/unit/sphinxext/test_expand_testing_fixture.py +++ b/tests/unit/sphinxext/test_expand_testing_fixture.py @@ -1,7 +1,7 @@ -import json - import pytest +from tests.common import fast_json + def test_expand_testing_fixture_fails_on_bad_reference(sphinx_runner, capsys): sphinx_runner.ensure_failure( @@ -35,7 +35,7 @@ def test_expand_testing_fixture_on_valid_fixture(sphinx_runner): assert code_block.get("language") == "json" # check against the known values for this fixture - data = json.loads(code_block.text) + data = fast_json.loads(code_block.text) assert data["is_high_assurance"] is False assert data["group_visibility"] == "private" @@ -54,6 +54,6 @@ def test_expand_testing_fixture_on_non_default_case(sphinx_runner): assert code_block.get("language") == "json" # check against the known values for this fixture - data = json.loads(code_block.text) + data = fast_json.loads(code_block.text) assert data["error_description"] == "Unauthorized" assert data["errors"][0]["status"] == "401" diff --git a/tests/unit/test_base_client.py b/tests/unit/test_base_client.py index 4b3b15fd..9bdd0a65 100644 --- a/tests/unit/test_base_client.py +++ b/tests/unit/test_base_client.py @@ -1,4 +1,3 @@ -import json import logging import os import uuid @@ -13,6 +12,7 @@ from globus_sdk.testing import RegisteredResponse, get_last_request from globus_sdk.token_storage import TokenValidationError from globus_sdk.transport import RequestsTransport +from tests.common import fast_json @pytest.fixture @@ -163,7 +163,7 @@ def test_http_methods(method, allows_body, base_client): req = get_last_request() assert req.method == methodname - assert req.body == json.dumps(jsonbody).encode("utf-8") + assert fast_json.loads(req.body) == jsonbody assert "x" in res assert res["x"] == "y" diff --git a/tests/unit/test_paging.py b/tests/unit/test_paging.py index 85b96afa..08b74a12 100644 --- a/tests/unit/test_paging.py +++ b/tests/unit/test_paging.py @@ -1,18 +1,17 @@ -import json -from unittest import mock - import pytest import requests from globus_sdk.paging import HasNextPaginator, JSONAPIPaginator from globus_sdk.response import GlobusHTTPResponse, IterableJSONAPIResponse from globus_sdk.services.transfer.response import IterableTransferResponse +from tests.common import fast_json N = 25 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): @@ -34,14 +33,15 @@ def simulate_get(self, *args, **params): # make the simulated response response = requests.Response() - response._content = json.dumps(data).encode() + response._content = fast_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 @@ -83,19 +83,19 @@ def simulate_get(self, *args, **params): # make the simulated response response = requests.Response() - response._content = json.dumps(response_top_level).encode() + response._content = fast_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/tokenstorage/v1/test_simplejson_adapter.py b/tests/unit/tokenstorage/v1/test_simplejson_adapter.py index fd09b238..d3f27e3b 100644 --- a/tests/unit/tokenstorage/v1/test_simplejson_adapter.py +++ b/tests/unit/tokenstorage/v1/test_simplejson_adapter.py @@ -1,9 +1,8 @@ -import json - import pytest from globus_sdk import __version__ as sdkversion from globus_sdk.token_storage.legacy import SimpleJSONFileAdapter +from tests.common import fast_json def test_simplejson_reading_bad_data(tmp_path): @@ -19,7 +18,7 @@ def test_simplejson_reading_bad_data(tmp_path): bar_file = tmp_path / "bar.json" bar_file.write_text( - json.dumps( + fast_json.dumps( {"by_rs": [], "format_version": "1.0", "globus-sdk.version": sdkversion} ) ) @@ -34,7 +33,7 @@ def test_simplejson_reading_unsupported_format_version(tmp_path): # adapter explicitly that it is in a format which is unknown / not supported foo_file = tmp_path / "foo.json" foo_file.write_text( - json.dumps( + fast_json.dumps( {"by_rs": {}, "format_version": "0.0", "globus-sdk.version": sdkversion} ) ) diff --git a/tests/unit/transport/test_transfer_transport.py b/tests/unit/transport/test_transfer_transport.py index cee27326..e6574f5d 100644 --- a/tests/unit/transport/test_transfer_transport.py +++ b/tests/unit/transport/test_transfer_transport.py @@ -9,6 +9,7 @@ RetryContext, ) from globus_sdk.transport.default_retry_checks import DEFAULT_RETRY_CHECKS +from tests.common import fast_json def test_transfer_only_replaces_checks(): @@ -42,6 +43,7 @@ def test_transfer_does_not_retry_external(): dummy_response = mock.Mock() dummy_response.json = lambda: body + dummy_response.content = fast_json.dumps(body).encode() dummy_response.status_code = 502 caller_info = RequestCallerInfo(retry_config=retry_config) ctx = RetryContext(1, caller_info=caller_info, response=dummy_response) @@ -67,6 +69,7 @@ def test_transfer_does_not_retry_endpoint_error(): dummy_response = mock.Mock() dummy_response.json = lambda: body + dummy_response.content = fast_json.dumps(body).encode() dummy_response.status_code = 502 caller_info = RequestCallerInfo(retry_config=retry_config) ctx = RetryContext(1, caller_info=caller_info, response=dummy_response) diff --git a/tests/unit/transport/test_transfer_v2_transport.py b/tests/unit/transport/test_transfer_v2_transport.py index eec4b833..0405b9e4 100644 --- a/tests/unit/transport/test_transfer_v2_transport.py +++ b/tests/unit/transport/test_transfer_v2_transport.py @@ -13,6 +13,7 @@ RetryContext, ) from globus_sdk.transport.default_retry_checks import DEFAULT_RETRY_CHECKS +from tests.common import fast_json def test_transfer_only_replaces_checks(): @@ -150,6 +151,7 @@ def test_transfer_v2_default_retry_checks(body, status_code, expected_should_ret dummy_response = mock.Mock() dummy_response.json = lambda: body + dummy_response.content = fast_json.dumps(body).encode() dummy_response.status_code = status_code caller_info = RequestCallerInfo(retry_config=retry_config) ctx = RetryContext(1, caller_info=caller_info, response=dummy_response) diff --git a/tox.ini b/tox.ini index c842d66e..3ec77025 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