Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ jobs:
- "py3.9-orjson"
- "py3.14-orjson"
- "py3.11-sphinxext"
- "doctest"
- "coverage_report"

- name: "macOS"
Expand All @@ -59,6 +60,7 @@ jobs:
tox-post-environments:
- "py3.11-sphinxext"
- "py3.14-orjson"
- "doctest"
- "coverage_report"

- name: "Windows"
Expand All @@ -70,6 +72,7 @@ jobs:
- "py3.9-mindeps"
- "py3.14-orjson"
- "py3.11-sphinxext"
- "doctest"
- "coverage_report"

- name: "Quality"
Expand Down
5 changes: 5 additions & 0 deletions changelog.d/20260702_015418_sirosen_doctest.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Development
-----------

- ``globus-sdk`` testing now leverages Sphinx ``doctest`` to make select
examples testable. (:pr:`NUMBER`)
4 changes: 2 additions & 2 deletions docs/authorization/scopes_and_consents/scopes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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
~~~~~~~~~
Expand Down
20 changes: 20 additions & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,32 @@
# 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 = ""

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
extensions = [
# sphinx-included extensions
"sphinx.ext.autodoc",
"sphinx.ext.doctest",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm concerned about how maintainers discover what different sphinx directives mean in this repository as we expand the number of internally-maintained & dependency extensions.

With the context of reviewing this PR which adds the sphinx.ext.doctest extension in a separate file, I was eventually able to find documentation for what testsetup:: doctest::, and testcode:: meant as symbols. If I encountered those in the context of browsing through a file though, I don't think I would've been able to backtrace why those symbols are meaningful (maybe with the exception of doctest because the extension happens to be named the same).

I think it's time that we have some in-repository documentation (a DOCUMENTATION.md or similar) that provides a central reference for which directives we're importing, which we've specialized, which we've built ourselves, etc. linking out to relevant docs like https://www.sphinx-doc.org/en/master/usage/extensions/doctest.html

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with this comment: that information belongs in our contribution docs. I do not think we should block this addition on these grounds, in a case of "the perfect is the enemy of the good".

Can we describe the follow-up we would like in the form of an issue or story? (I think in-repo issue tracking would be appropriate in this case.)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was able to find doctest trivially with a search for "python sphinx testcode". I think we need to discuss what level of detail is needed in our contributor documentation. 🙂

@derek-globus derek-globus Jul 21, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we describe the follow-up we would like in the form of an issue or story?

Yeah sure.

I was able to find doctest trivially with a search for "python sphinx testcode". I think we need to discuss what level of detail is needed in our contributor documentation. 🙂

I wouldn't expect a contributor to know that the thing they encountered in a random docstring in the form .. SomethingSomething:: <customer-symbol> is a sphinx directive so I think that assumes too much contextual knowledge.

It took me a while to figure out where to find documentation around these directives while I was reviewing the PR that introduced the extension which they use. If I were more familiar with exploring the extension space of custom sphinx directives, I probably could've gotten there quicker, but I am not & I don't think our average contributor (internal or external) will be.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, that's fair! Our contributor documentation should provide that necessary context.

We should not try to document every directive exhaustively ourselves.

"sphinx.ext.intersphinx",
"sphinx.ext.viewcode",
# other packages
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
6 changes: 5 additions & 1 deletion src/globus_sdk/scopes/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
15 changes: 11 additions & 4 deletions src/globus_sdk/services/gcs/connector_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -129,7 +136,7 @@ def extend(

Usage example:

.. code-block:: pycon
.. doctest:: connector-table

>>> MyTable = ConnectorTable.extend(
... connector_name="Star Trek Transporter",
Expand Down
9 changes: 7 additions & 2 deletions src/globus_sdk/services/timers/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
49 changes: 32 additions & 17 deletions src/globus_sdk/services/timers/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -77,20 +81,24 @@ 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::

my_schedule = globus_sdk.OnceTimerSchedule()
sdk_doctest_patch("globus_sdk.TransferData")

.. doctest::

>>> from globus_sdk import TransferData, TransferTimer
>>> schedule = ...
>>> transfer_data = TransferData(...)
>>> timer = TransferTimer(
... name="my timer",
... schedule=schedule,
... schedule=my_schedule,
... body=transfer_data,
... )

Expand Down Expand Up @@ -149,23 +157,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,
Expand All @@ -174,7 +186,7 @@ class FlowTimer(GlobusPayload):

.. tab-item:: Run Every 30 Minutes, 10 Times

.. code-block:: python
.. testcode:: schedules

schedule = RecurringTimerSchedule(
interval_seconds=1800,
Expand All @@ -183,20 +195,23 @@ 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::

my_schedule = globus_sdk.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",
Expand Down
4 changes: 4 additions & 0 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading