From 6e3fdb6ff0fa31fd120058be19e777cf65b13eb9 Mon Sep 17 00:00:00 2001 From: Stephen Rosen Date: Wed, 1 Jul 2026 16:56:35 -0500 Subject: [PATCH 1/7] Setup sphinx doctest - enable the plugin - disable default console block behavior - new tox env - new CI config --- .github/workflows/test.yaml | 3 +++ docs/conf.py | 4 ++++ tox.ini | 4 ++++ 3 files changed, 11 insertions(+) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 5f6712be5..270ac9856 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -50,6 +50,7 @@ jobs: - "py3.9-orjson" - "py3.14-orjson" - "py3.11-sphinxext" + - "doctest" - "coverage_report" - name: "macOS" @@ -59,6 +60,7 @@ jobs: tox-post-environments: - "py3.11-sphinxext" - "py3.14-orjson" + - "doctest" - "coverage_report" - name: "Windows" @@ -70,6 +72,7 @@ jobs: - "py3.9-mindeps" - "py3.14-orjson" - "py3.11-sphinxext" + - "doctest" - "coverage_report" - name: "Quality" diff --git a/docs/conf.py b/docs/conf.py index 5e12036bd..4c8400af4 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -15,12 +15,16 @@ # merely because they are type annotated autodoc_typehints_description_target = "documented_params" +# disable doctest on `>>>` blocks which are not annotated as `doctest` blocks +doctest_test_doctest_blocks = "" + # sphinx extensions (minimally, we want autodoc and viewcode to build the site) # plus, we have our own custom extension in the SDK to include extensions = [ # sphinx-included extensions "sphinx.ext.autodoc", + "sphinx.ext.doctest", "sphinx.ext.intersphinx", "sphinx.ext.viewcode", # other packages diff --git a/tox.ini b/tox.ini index 3ec770253..500f33d07 100644 --- a/tox.ini +++ b/tox.ini @@ -91,6 +91,10 @@ globus_sdk_rmtree = docs/_build changedir = docs/ commands = sphinx-build -j auto -d _build/doctrees -b html -W . _build/html {posargs} +[testenv:doctest] +base = docs +commands = sphinx-build -j auto -d _build/doctrees -b doctest -W . _build/html {posargs} + [testenv:twine-check] skip_install = true deps = build From 69a2b51c3b32f377810eb79bbf73825f39c9856e Mon Sep 17 00:00:00 2001 From: Stephen Rosen Date: Wed, 1 Jul 2026 17:05:42 -0500 Subject: [PATCH 2/7] Convert Timers data module docstrings to doctest --- src/globus_sdk/services/timers/data.py | 60 ++++++++++++++++++-------- 1 file changed, 43 insertions(+), 17 deletions(-) diff --git a/src/globus_sdk/services/timers/data.py b/src/globus_sdk/services/timers/data.py index 1fd9d048a..80592df38 100644 --- a/src/globus_sdk/services/timers/data.py +++ b/src/globus_sdk/services/timers/data.py @@ -43,23 +43,27 @@ class TransferTimer(GlobusPayload): **Example Schedules** + .. testsetup:: schedules + + from globus_sdk import OnceTimerSchedule, RecurringTimerSchedule + .. tab-set:: .. tab-item:: Run Once, Right Now - .. code-block:: python + .. testcode:: schedules schedule = OnceTimerSchedule() .. tab-item:: Run Once, At a Specific Time - .. code-block:: python + .. testcode:: schedules schedule = OnceTimerSchedule(datetime="2023-09-22T00:00:00Z") .. tab-item:: Run Every 5 Minutes, Until a Specific Time - .. code-block:: python + .. testcode:: schedules schedule = RecurringTimerSchedule( interval_seconds=300, @@ -68,7 +72,7 @@ class TransferTimer(GlobusPayload): .. tab-item:: Run Every 30 Minutes, 10 Times - .. code-block:: python + .. testcode:: schedules schedule = RecurringTimerSchedule( interval_seconds=1800, @@ -77,23 +81,36 @@ class TransferTimer(GlobusPayload): .. tab-item:: Run Every 10 Minutes, Indefinitely - .. code-block:: python + .. testcode:: schedules schedule = RecurringTimerSchedule(interval_seconds=600) Using these schedules, you can create a timer from a ``TransferData`` object: - .. code-block:: pycon + .. testsetup:: + + from unittest import mock + from globus_sdk import OnceTimerSchedule + + my_schedule = OnceTimerSchedule() + + patch = mock.patch("globus_sdk.TransferData") + patch.start() + + .. doctest:: >>> from globus_sdk import TransferData, TransferTimer - >>> schedule = ... >>> transfer_data = TransferData(...) >>> timer = TransferTimer( ... name="my timer", - ... schedule=schedule, + ... schedule=my_schedule, ... body=transfer_data, ... ) + .. testcleanup:: + + patch.stop() + Submit the timer to the Timers service with :meth:`create_timer `. """ @@ -149,23 +166,27 @@ class FlowTimer(GlobusPayload): **Example Schedules** + .. testsetup:: schedules + + from globus_sdk import OnceTimerSchedule, RecurringTimerSchedule + .. tab-set:: .. tab-item:: Run Once, Right Now - .. code-block:: python + .. testcode:: schedules schedule = OnceTimerSchedule() .. tab-item:: Run Once, At a Specific Time - .. code-block:: python + .. testcode:: schedules schedule = OnceTimerSchedule(datetime="2023-09-22T00:00:00Z") .. tab-item:: Run Every 5 Minutes, Until a Specific Time - .. code-block:: python + .. testcode:: schedules schedule = RecurringTimerSchedule( interval_seconds=300, @@ -174,7 +195,7 @@ class FlowTimer(GlobusPayload): .. tab-item:: Run Every 30 Minutes, 10 Times - .. code-block:: python + .. testcode:: schedules schedule = RecurringTimerSchedule( interval_seconds=1800, @@ -183,20 +204,25 @@ class FlowTimer(GlobusPayload): .. tab-item:: Run Every 10 Minutes, Indefinitely - .. code-block:: python + .. testcode:: schedules - schedule = RecurringTimerSchedule(interval_seconds=600) + my_schedule = RecurringTimerSchedule(interval_seconds=600) Using these schedules, you can create a timer: - .. code-block:: pycon + .. testsetup:: + + from globus_sdk import OnceTimerSchedule + + my_schedule = OnceTimerSchedule() + + .. doctest:: >>> from globus_sdk import FlowTimer - >>> schedule = ... >>> timer = FlowTimer( ... name="my timer", ... flow_id="00000000-19a9-44e6-9c1a-867da59d84ab", - ... schedule=schedule, + ... schedule=my_schedule, ... body={ ... "body": { ... "input_key": "input_value", From 2722b712b071395f1a859d1334ff6940724a5c7b Mon Sep 17 00:00:00 2001 From: Stephen Rosen Date: Wed, 1 Jul 2026 17:20:36 -0500 Subject: [PATCH 3/7] Add TimersClient to doctest and refine config Improve our doctest config with - a wrapped version of `mock.patch` which does auto-rollback - pre-set imports for all doctests --- docs/conf.py | 16 ++++++++++++++++ src/globus_sdk/services/timers/client.py | 9 +++++++-- src/globus_sdk/services/timers/data.py | 17 +++-------------- 3 files changed, 26 insertions(+), 16 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 4c8400af4..6a996442e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -18,6 +18,22 @@ # disable doctest on `>>>` blocks which are not annotated as `doctest` blocks doctest_test_doctest_blocks = "" +doctest_global_setup = """\ +import globus_sdk +from unittest import mock + +UNDO_STACK = [] + +def sdk_doctest_patch(*args): + p = mock.patch(*args) + p.start() + UNDO_STACK.append(p.stop) +""" +doctest_global_cleanup = """\ +for undo_callable in UNDO_STACK: + undo_callable() +""" + # sphinx extensions (minimally, we want autodoc and viewcode to build the site) # plus, we have our own custom extension in the SDK to include diff --git a/src/globus_sdk/services/timers/client.py b/src/globus_sdk/services/timers/client.py index 910602838..5ee262fed 100644 --- a/src/globus_sdk/services/timers/client.py +++ b/src/globus_sdk/services/timers/client.py @@ -205,9 +205,14 @@ def create_timer( .. tab-item:: Example Usage - .. code-block:: pycon + .. testsetup:: create-timer-example - >>> transfer_data = TransferData(...) + sdk_doctest_patch("globus_sdk.TransferData") + sdk_doctest_patch("globus_sdk.TimersClient") + + .. doctest:: create-timer-example + + >>> transfer_data = globus_sdk.TransferData(...) >>> timers_client = globus_sdk.TimersClient(...) >>> create_doc = globus_sdk.TransferTimer( ... name="my-timer", diff --git a/src/globus_sdk/services/timers/data.py b/src/globus_sdk/services/timers/data.py index 80592df38..7da00a617 100644 --- a/src/globus_sdk/services/timers/data.py +++ b/src/globus_sdk/services/timers/data.py @@ -89,13 +89,8 @@ class TransferTimer(GlobusPayload): .. testsetup:: - from unittest import mock - from globus_sdk import OnceTimerSchedule - - my_schedule = OnceTimerSchedule() - - patch = mock.patch("globus_sdk.TransferData") - patch.start() + my_schedule = globus_sdk.OnceTimerSchedule() + sdk_doctest_patch("globus_sdk.TransferData") .. doctest:: @@ -107,10 +102,6 @@ class TransferTimer(GlobusPayload): ... body=transfer_data, ... ) - .. testcleanup:: - - patch.stop() - Submit the timer to the Timers service with :meth:`create_timer `. """ @@ -212,9 +203,7 @@ class FlowTimer(GlobusPayload): .. testsetup:: - from globus_sdk import OnceTimerSchedule - - my_schedule = OnceTimerSchedule() + my_schedule = globus_sdk.OnceTimerSchedule() .. doctest:: From 0c556892dd6ee180ad66ea77f5fc0e226856731d Mon Sep 17 00:00:00 2001 From: Stephen Rosen Date: Wed, 1 Jul 2026 17:36:30 -0500 Subject: [PATCH 4/7] Convert GCS ConnectorTable to doctests --- src/globus_sdk/services/gcs/connector_table.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/globus_sdk/services/gcs/connector_table.py b/src/globus_sdk/services/gcs/connector_table.py index d147d97bf..c0f882de4 100644 --- a/src/globus_sdk/services/gcs/connector_table.py +++ b/src/globus_sdk/services/gcs/connector_table.py @@ -31,23 +31,30 @@ class ConnectorTable: It supports access by attribute or via a helper method for doing lookups. For example, all of the following three usages retrieve the Azure Blob connector: - .. code-block:: pycon + .. testsetup:: connector-table + + from globus_sdk import ConnectorTable + + .. doctest:: connector-table >>> ConnectorTable.AZURE_BLOB + GlobusConnectServerConnector(name='Azure Blob', connector_id='9436da0c-a444-11eb-af93-12704e0d6a4d') >>> ConnectorTable.lookup("Azure Blob") + GlobusConnectServerConnector(name='Azure Blob', connector_id='9436da0c-a444-11eb-af93-12704e0d6a4d') >>> ConnectorTable.lookup("9436da0c-a444-11eb-af93-12704e0d6a4d") + GlobusConnectServerConnector(name='Azure Blob', connector_id='9436da0c-a444-11eb-af93-12704e0d6a4d') Given the results of such a lookup, you can retrieve the canonical name and ID for a connector like so: - .. code-block:: pycon + .. doctest:: connector-table >>> connector = ConnectorTable.AZURE_BLOB >>> connector.name 'Azure Blob' >>> connector.connector_id '9436da0c-a444-11eb-af93-12704e0d6a4d' - """ + """ # noqa: E501 _connectors: t.ClassVar[tuple[tuple[str, str, str], ...]] = ( ("ACTIVESCALE", "ActiveScale", "7251f6c8-93c9-11eb-95ba-12704e0d6a4d"), @@ -129,7 +136,7 @@ def extend( Usage example: - .. code-block:: pycon + .. doctest:: connector-table >>> MyTable = ConnectorTable.extend( ... connector_name="Star Trek Transporter", From 354b11b91c5484d0a330d835681c4eee11f723fd Mon Sep 17 00:00:00 2001 From: Stephen Rosen Date: Thu, 2 Jul 2026 01:26:01 -0500 Subject: [PATCH 5/7] Convert scope documentation to doctests --- docs/authorization/scopes_and_consents/scopes.rst | 4 ++-- src/globus_sdk/scopes/parser.py | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/authorization/scopes_and_consents/scopes.rst b/docs/authorization/scopes_and_consents/scopes.rst index 48c71ebfc..b51e55ed1 100644 --- a/docs/authorization/scopes_and_consents/scopes.rst +++ b/docs/authorization/scopes_and_consents/scopes.rst @@ -62,7 +62,7 @@ strings. All scope objects support this by means of their defined ``__str__`` method. For example, the following is an example of ``str()`` and ``repr()`` usage: -.. code-block:: pycon +.. doctest:: >>> from globus_sdk.scopes import Scope >>> foo = Scope("foo") @@ -78,7 +78,7 @@ strings. All scope objects support this by means of their defined >>> print(str(alpha)) alpha[*beta] >>> print(repr(alpha)) - Scope("alpha", dependencies=(Scope("beta", optional=True),)) + Scope('alpha', dependencies=(Scope('beta', optional=True),)) Reference ~~~~~~~~~ diff --git a/src/globus_sdk/scopes/parser.py b/src/globus_sdk/scopes/parser.py index c95d5e996..574ca8760 100644 --- a/src/globus_sdk/scopes/parser.py +++ b/src/globus_sdk/scopes/parser.py @@ -95,7 +95,11 @@ def serialize( Example usage: - .. code-block:: pycon + .. testsetup:: + + from globus_sdk.scopes import ScopeParser, Scope + + .. doctest:: >>> ScopeParser.serialize([Scope("foo"), "bar", Scope("qux")]) 'foo bar qux' From c2f1ac288e5e08e719f7586af3f3a3b6a3a37501 Mon Sep 17 00:00:00 2001 From: Stephen Rosen Date: Thu, 2 Jul 2026 01:45:34 -0500 Subject: [PATCH 6/7] Doctest part of the relative deadline doc --- .../data_transfer/transfer_relative_deadline/index.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/user_guide/usage_patterns/data_transfer/transfer_relative_deadline/index.rst b/docs/user_guide/usage_patterns/data_transfer/transfer_relative_deadline/index.rst index 31f2f63b6..96e094741 100644 --- a/docs/user_guide/usage_patterns/data_transfer/transfer_relative_deadline/index.rst +++ b/docs/user_guide/usage_patterns/data_transfer/transfer_relative_deadline/index.rst @@ -40,7 +40,7 @@ We'll express that idea in a function which takes a :class:`datetime.timedelta` as an ``offset``, an amount of time into the future. This gives us a generic phrasing of getting a future date: -.. code-block:: python +.. testcode:: relative-deadline import datetime @@ -65,7 +65,7 @@ Creating a Task with the Deadline Along with all of our other parameters to create the Transfer Task, here's a sample task document with a deadline set for "an hour from now": -.. code-block:: python +.. testcode:: relative-deadline # Globus Tutorial Collection 1 # https://app.globus.org/file-manager/collections/6c54cade-bde5-45c1-bdea-f4bd71dba2cc From 54f13452e237dbc24fa8a5d4e59dddc7f3073017 Mon Sep 17 00:00:00 2001 From: Stephen Rosen Date: Thu, 2 Jul 2026 01:55:15 -0500 Subject: [PATCH 7/7] Add changelog for introduction of doctest --- changelog.d/20260702_015418_sirosen_doctest.rst | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelog.d/20260702_015418_sirosen_doctest.rst diff --git a/changelog.d/20260702_015418_sirosen_doctest.rst b/changelog.d/20260702_015418_sirosen_doctest.rst new file mode 100644 index 000000000..9bfce74cb --- /dev/null +++ b/changelog.d/20260702_015418_sirosen_doctest.rst @@ -0,0 +1,5 @@ +Development +----------- + +- ``globus-sdk`` testing now leverages Sphinx ``doctest`` to make select + examples testable. (:pr:`NUMBER`)