diff --git a/changelog.d/20250118_122417_sirosen_better_client_param_doc.rst b/changelog.d/20250118_122417_sirosen_better_client_param_doc.rst
new file mode 100644
index 000000000..45ca5e2d6
--- /dev/null
+++ b/changelog.d/20250118_122417_sirosen_better_client_param_doc.rst
@@ -0,0 +1,5 @@
+Added
+~~~~~
+
+- Most client classes now have their ``__doc__`` attribute modified at runtime
+ to provide better ``help()`` and sphinx documentation. (:pr:`NUMBER`)
diff --git a/src/globus_sdk/services/auth/client/service_client.py b/src/globus_sdk/services/auth/client/service_client.py
index a12f452e5..d5e267f02 100644
--- a/src/globus_sdk/services/auth/client/service_client.py
+++ b/src/globus_sdk/services/auth/client/service_client.py
@@ -71,11 +71,14 @@ def wrapper(self: t.Any, *args: t.Any, **kwargs: t.Any) -> t.Any:
return t.cast(F, wrapper)
+@utils.inject_sphinx_params_of(client.BaseClient)
class AuthClient(client.BaseClient):
"""
A client for using the
`Globus Auth API `_
+ .. globus-sdk-inject-doc-params
+
This class provides helper methods for most common resources in the
Auth API, and the common low-level interface from
:class:`BaseClient ` of ``get``, ``put``,
diff --git a/src/globus_sdk/services/compute/client.py b/src/globus_sdk/services/compute/client.py
index 01386853e..0e82b1bcd 100644
--- a/src/globus_sdk/services/compute/client.py
+++ b/src/globus_sdk/services/compute/client.py
@@ -12,10 +12,13 @@
log = logging.getLogger(__name__)
+@utils.inject_sphinx_params_of(client.BaseClient)
class ComputeClientV2(client.BaseClient):
r"""
Client for the Globus Compute API, version 2.
+ .. globus-sdk-inject-doc-params
+
.. automethodlist:: globus_sdk.ComputeClientV2
"""
@@ -348,10 +351,13 @@ def submit(
return self.post(f"/v3/endpoints/{endpoint_id}/submit", data=data)
+@utils.inject_sphinx_params_of(client.BaseClient)
class ComputeClient(ComputeClientV2):
r"""
Canonical client for the Globus Compute API, with support exclusively for
API version 2.
+ .. globus-sdk-inject-doc-params
+
.. automethodlist:: globus_sdk.ComputeClient
"""
diff --git a/src/globus_sdk/services/flows/client.py b/src/globus_sdk/services/flows/client.py
index fdef16df9..17b514361 100644
--- a/src/globus_sdk/services/flows/client.py
+++ b/src/globus_sdk/services/flows/client.py
@@ -20,10 +20,13 @@
log = logging.getLogger(__name__)
+@utils.inject_sphinx_params_of(client.BaseClient)
class FlowsClient(client.BaseClient):
r"""
Client for the Globus Flows API.
+ .. globus-sdk-inject-doc-params
+
.. automethodlist:: globus_sdk.FlowsClient
"""
@@ -826,6 +829,7 @@ def delete_run(self, run_id: UUIDLike) -> GlobusHTTPResponse:
return self.post(f"/runs/{run_id}/release")
+@utils.inject_sphinx_params_of(client.BaseClient)
class SpecificFlowClient(client.BaseClient):
r"""
Client for interacting with a specific flow through the Globus Flows API.
@@ -834,6 +838,7 @@ class SpecificFlowClient(client.BaseClient):
arguments are the same as those for :class:`~globus_sdk.BaseClient`.
:param flow_id: The generated UUID associated with a flow
+ .. globus-sdk-inject-doc-params
.. automethodlist:: globus_sdk.SpecificFlowClient
"""
diff --git a/src/globus_sdk/services/gcs/client.py b/src/globus_sdk/services/gcs/client.py
index be497fd69..72c7143dc 100644
--- a/src/globus_sdk/services/gcs/client.py
+++ b/src/globus_sdk/services/gcs/client.py
@@ -23,6 +23,7 @@
C = t.TypeVar("C", bound=t.Callable[..., t.Any])
+@utils.inject_sphinx_params_of(client.BaseClient)
class GCSClient(client.BaseClient):
"""
A GCSClient provides communication with the GCS Manager API of a Globus Connect
@@ -35,6 +36,7 @@ class GCSClient(client.BaseClient):
:class:`~globus_sdk.BaseClient`.
:param gcs_address: The FQDN (DNS name) or HTTPS URL for the GCS Manager API.
+ .. globus-sdk-inject-doc-params
.. automethodlist:: globus_sdk.GCSClient
"""
diff --git a/src/globus_sdk/services/groups/client.py b/src/globus_sdk/services/groups/client.py
index 410c91bb5..a6b0612b8 100644
--- a/src/globus_sdk/services/groups/client.py
+++ b/src/globus_sdk/services/groups/client.py
@@ -10,11 +10,14 @@
from .errors import GroupsAPIError
+@utils.inject_sphinx_params_of(client.BaseClient)
class GroupsClient(client.BaseClient):
"""
Client for the
`Globus Groups API `_.
+ .. globus-sdk-inject-doc-params
+
This provides a relatively low level client to public groups API endpoints.
You may also consider looking at the GroupsManager as a simpler interface
to more common actions.
diff --git a/src/globus_sdk/services/search/client.py b/src/globus_sdk/services/search/client.py
index 1a9ced42d..e4d2d5ae6 100644
--- a/src/globus_sdk/services/search/client.py
+++ b/src/globus_sdk/services/search/client.py
@@ -14,10 +14,13 @@
log = logging.getLogger(__name__)
+@utils.inject_sphinx_params_of(client.BaseClient)
class SearchClient(client.BaseClient):
r"""
Client for the Globus Search API
+ .. globus-sdk-inject-doc-params
+
This class provides helper methods for most common resources in the
API, and basic ``get``, ``put``, ``post``, and ``delete`` methods
from the base client that can be used to access any API resource.
diff --git a/src/globus_sdk/services/timers/client.py b/src/globus_sdk/services/timers/client.py
index a8ac2acc8..1a8bc8d80 100644
--- a/src/globus_sdk/services/timers/client.py
+++ b/src/globus_sdk/services/timers/client.py
@@ -4,7 +4,7 @@
import typing as t
import uuid
-from globus_sdk import _guards, client, exc, response
+from globus_sdk import _guards, client, exc, response, utils
from globus_sdk._types import UUIDLike
from globus_sdk.scopes import (
GCSCollectionScopeBuilder,
@@ -19,10 +19,13 @@
log = logging.getLogger(__name__)
+@utils.inject_sphinx_params_of(client.BaseClient)
class TimersClient(client.BaseClient):
r"""
Client for the Globus Timer API.
+ .. globus-sdk-inject-doc-params
+
.. automethodlist:: globus_sdk.TimersClient
"""
diff --git a/src/globus_sdk/services/transfer/client.py b/src/globus_sdk/services/transfer/client.py
index 3410e35e5..1bc4f99f5 100644
--- a/src/globus_sdk/services/transfer/client.py
+++ b/src/globus_sdk/services/transfer/client.py
@@ -41,11 +41,14 @@ def _get_page_size(paged_result: IterableTransferResponse) -> int:
return len(paged_result["DATA"])
+@utils.inject_sphinx_params_of(client.BaseClient)
class TransferClient(client.BaseClient):
r"""
Client for the
`Globus Transfer API `_.
+ .. globus-sdk-inject-doc-params
+
This class provides helper methods for most common resources in the
REST API, and basic ``get``, ``put``, ``post``, and ``delete`` methods
from the base rest client that can be used to access any REST resource.
diff --git a/src/globus_sdk/utils.py b/src/globus_sdk/utils.py
index 3e5996085..9957cbb8b 100644
--- a/src/globus_sdk/utils.py
+++ b/src/globus_sdk/utils.py
@@ -2,10 +2,12 @@
import collections
import collections.abc
+import functools
import hashlib
import os
import platform
import sys
+import textwrap
import typing as t
import uuid
from base64 import b64encode
@@ -276,3 +278,87 @@ def classproperty(func: t.Callable[[T], R]) -> _classproperty[T, R]:
def classproperty(func: t.Callable[[T], R]) -> _classproperty[T, R]:
# type cast to convert instance method to class method
return _classproperty(t.cast(t.Callable[[t.Type[T]], R], func))
+
+
+@functools.lru_cache(maxsize=None)
+def read_sphinx_params(docstring: str) -> tuple[str, ...]:
+ """
+ Given a docstring, extract the `:param:` declarations into a tuple of strings.
+
+ :param docstring: The ``__doc__`` to parse, as it appeared on the original object
+
+ Params start with `:param ...` and end
+ - at the end of the string
+ - at the next param
+ - when a non-indented, non-param line is found
+
+ Whitespace lines within a param doc are supported.
+
+ All produced param strings are dedented.
+ """
+ docstring = textwrap.dedent(docstring)
+
+ result: list[str] = []
+ current: list[str] = []
+ for line in docstring.splitlines():
+ if not current:
+ if line.startswith(":param"):
+ current = [line]
+ else:
+ continue
+ else:
+ # a new param -- flush the current one and restart
+ if line.startswith(":param"):
+ result.append("\n".join(current).strip())
+ current = [line]
+ # a continuation line for the current param (indented)
+ # or a blank line -- it *could* be a continuation of param doc (include it)
+ elif line != line.lstrip() or not line:
+ current.append(line)
+ # otherwise this is a populated line, not indented, and without a `:param`
+ # start -- stop looking for more param doc
+ else:
+ break
+ if current:
+ result.append("\n".join(current).strip())
+
+ return tuple(result)
+
+
+def inject_sphinx_params(doc_params: tuple[str, ...], target: t.Any) -> None:
+ """
+ Given a tuple of sphinx doc params, and a target object with a `__doc__` attribute,
+ inject the params to replace the marker comment 'globus-sdk-inject-doc-params'.
+
+ :param doc_params: Params, as parsed by ``read_sphinx_params``
+ :param target: The object where `__doc__` is being updated
+ (usually a class or function)
+ """
+ target_doc_lines = target.__doc__.splitlines()
+
+ result = []
+ for line in target_doc_lines:
+ stripped_line = line.strip()
+ if stripped_line == ".. globus-sdk-inject-doc-params":
+ indent = len(line) - len(stripped_line)
+ result.append(textwrap.indent("\n".join(doc_params), " " * indent))
+ else:
+ result.append(line)
+
+ target.__doc__ = "\n".join(result)
+
+
+def inject_sphinx_params_of(source: t.Any) -> t.Callable[[T], T]:
+ """
+ Read params from a source object, and inject them into a destination object.
+
+ :param source: An object whose ``__doc__`` attribute is being parsed for
+ parameter docs
+ """
+ params = read_sphinx_params(source.__doc__)
+
+ def apply(target: T) -> T:
+ inject_sphinx_params(params, target)
+ return target
+
+ return apply
diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py
index c00001bd6..9841982ed 100644
--- a/tests/unit/test_utils.py
+++ b/tests/unit/test_utils.py
@@ -1,3 +1,4 @@
+import textwrap
import uuid
import pytest
@@ -117,3 +118,51 @@ def y(self_or_cls):
)
def test_safe_strseq_iter(value, expected_result):
assert list(utils.safe_strseq_iter(value)) == expected_result
+
+
+def test_read_sphinx_params():
+ docstring = """
+ preamble
+
+ :param param1: some doc on one line
+ :param param2: other doc
+ spanning multiple lines
+ :param param3: a doc
+ spanning
+ many
+ lines
+ :param param4: a doc
+ spanning lines
+
+ with a break in the middle ^
+ :param param5: another
+
+
+ :param param6: and a final one after some whitespace
+
+ epilogue
+ """
+ params = utils.read_sphinx_params(docstring)
+ assert len(params) == 6
+ assert params[0] == ":param param1: some doc on one line"
+ assert params[1] == ":param param2: other doc\n spanning multiple lines"
+ assert params[2] == textwrap.dedent(
+ """\
+ :param param3: a doc
+ spanning
+ many
+ lines"""
+ )
+ assert params[3] == textwrap.dedent(
+ """\
+ :param param4: a doc
+ spanning lines
+
+ with a break in the middle ^"""
+ )
+ assert params[4] == ":param param5: another"
+ assert params[5] == ":param param6: and a final one after some whitespace"
+
+ # clear the cache after the test
+ # not essential, but reduces the risk that this impacts some future test
+ utils.read_sphinx_params.cache_clear()