Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Extract and dynamically re-inject base client sphinx param doc into client classes, for better sphinx and runtime doc #1131

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
Original file line number Diff line number Diff line change
@@ -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`)
3 changes: 3 additions & 0 deletions src/globus_sdk/services/auth/client/service_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://docs.globus.org/api/auth/>`_

.. 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 <globus_sdk.client.BaseClient>` of ``get``, ``put``,
Expand Down
6 changes: 6 additions & 0 deletions src/globus_sdk/services/compute/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
"""

Expand Down Expand Up @@ -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
"""
5 changes: 5 additions & 0 deletions src/globus_sdk/services/flows/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
"""

Expand Down Expand Up @@ -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.
Expand All @@ -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
"""
Expand Down
2 changes: 2 additions & 0 deletions src/globus_sdk/services/gcs/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
"""
Expand Down
3 changes: 3 additions & 0 deletions src/globus_sdk/services/groups/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://docs.globus.org/api/groups/>`_.

.. 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.
Expand Down
3 changes: 3 additions & 0 deletions src/globus_sdk/services/search/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,13 @@
log = logging.getLogger(__name__)


@utils.inject_sphinx_params_of(client.BaseClient)
class SearchClient(client.BaseClient):
Comment on lines +17 to 18
Copy link
Contributor

Choose a reason for hiding this comment

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

Is there a way to do this without the annotation by looking at the client class's mro instead of annotating?

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.
Expand Down
5 changes: 4 additions & 1 deletion src/globus_sdk/services/timers/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
"""

Expand Down
3 changes: 3 additions & 0 deletions src/globus_sdk/services/transfer/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://docs.globus.org/api/transfer/>`_.

.. 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.
Expand Down
86 changes: 86 additions & 0 deletions src/globus_sdk/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"):
Copy link
Contributor

Choose a reason for hiding this comment

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

Surely sphinx loads these as a part of its docstring parsing these. Do we have any mechanism to tap into that in a plugin instead of doing it ourselves?

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
49 changes: 49 additions & 0 deletions tests/unit/test_utils.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import textwrap
import uuid

import pytest
Expand Down Expand Up @@ -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()
Loading