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()