Skip to content

Commit

Permalink
Merge pull request #130 from DanCardin/dc/attribute-docstring
Browse files Browse the repository at this point in the history
feat: Allow "attribute docstrings" as additional method of documenting args.
  • Loading branch information
DanCardin authored Jun 26, 2024
2 parents 594a461 + 8d72276 commit 5aab7c7
Show file tree
Hide file tree
Showing 7 changed files with 219 additions and 55 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## 0.22.0

- feat: Allow "attribute docstrings" as additional method of documenting args.

## 0.21

### 0.21.2
Expand Down
26 changes: 26 additions & 0 deletions docs/source/help.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ descending order of priority:

- An explicit `help=` argument
- A PEP-727 `Doc` annotation
- The class "attribute docstring"
- The class docstring argument description

If none of the above sources produce help text, no description will be rendered.
Expand Down Expand Up @@ -53,6 +54,31 @@ class Command:
arg: Annotated[str, Doc('Arg help')]
```

### Class "attribute docstring" Parsing

An "attribute docstring" is a common convention whereby a declarative class attribute
is "annotated" similarly to a class docstring. For example:

```python
from dataclasses import dataclass

@dataclass
class Command:
arg: int
"""This arg is an int."""
```

```{note}
Attribute docstrings are not a first-class concept in python today, although
there is a [rejected PEP](https://peps.python.org/pep-0224/) associated with
the idea.
As such, `ast` traversal is required to obtain it, which implies a requirement
that `ast.getsource` be able to function on the given source class. For most
typical user-written situations this should not be an issue, but it's worth
noting the relative complexity involved with this option.
```

### Class Docstring Parsing

```{note}
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "cappa"
version = "0.21.2"
version = "0.22.0"
description = "Declarative CLI argument parser."

repository = "https://github.com/dancardin/cappa"
Expand Down
61 changes: 8 additions & 53 deletions src/cappa/command.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,19 @@
from __future__ import annotations

import dataclasses
import inspect
import sys
import typing
from collections.abc import Callable
from types import ModuleType

from cappa import class_inspect
from cappa.arg import Arg, ArgAction, Group
from cappa.docstring import ClassHelpText
from cappa.env import Env
from cappa.help import HelpFormatable, HelpFormatter, format_short_help
from cappa.output import Exit, Output, prompt_types
from cappa.subcommand import Subcommand
from cappa.typing import get_type_hints, missing

try:
import docstring_parser as _docstring_parser

docstring_parser: ModuleType | None = _docstring_parser
except ImportError: # pragma: no cover
docstring_parser = None

T = typing.TypeVar("T")


Expand Down Expand Up @@ -114,30 +106,14 @@ def real_name(self) -> str:
@classmethod
def collect(cls, command: Command[T]) -> Command[T]:
kwargs: CommandArgs = {}
arg_help_map = {}

if not (command.help and command.description):
doc = get_doc(command.cmd_cls)
if docstring_parser:
parsed_help = docstring_parser.parse(doc)
for param in parsed_help.params:
arg_help_map[param.arg_name] = param.description
summary = parsed_help.short_description
body = parsed_help.long_description
else:
doc = inspect.cleandoc(doc).split("\n", 1)
if len(doc) == 1:
summary = doc[0]
body = ""
else:
summary, body = doc
body = body.strip()

if not command.help:
kwargs["help"] = summary
help_text = ClassHelpText.collect(command.cmd_cls)

if not command.help:
kwargs["help"] = help_text.summary

if not command.description:
kwargs["description"] = body
if not command.description:
kwargs["description"] = help_text.body

if command.arguments:
arguments: list[Arg | Subcommand] = [
Expand All @@ -157,7 +133,7 @@ def collect(cls, command: Command[T]) -> Command[T]:

for field in fields:
type_hint = type_hints[field.name]
arg_help = arg_help_map.get(field.name)
arg_help = help_text.args.get(field.name)

maybe_subcommand = Subcommand.collect(
field, type_hint, help_formatter=command.help_formatter
Expand Down Expand Up @@ -294,27 +270,6 @@ class HasCommand(typing.Generic[H], typing.Protocol):
__cappa__: typing.ClassVar[Command]


def get_doc(cls):
"""Lifted from dataclasses source."""
doc = cls.__doc__ or ""

# Dataclasses will set the doc attribute to the below value if there was no
# explicit docstring. This is just annoying for us, so we treat that as though
# there wasn't one.
try:
# In some cases fetching a signature is not possible.
# But, we surely should not fail in this case.
text_sig = str(inspect.signature(cls)).replace(" -> None", "")
except (TypeError, ValueError): # pragma: no cover
text_sig = ""

dataclasses_docstring = cls.__name__ + text_sig

if doc == dataclasses_docstring:
return ""
return doc


def check_group_identity(args: list[Arg | Subcommand]):
group_identity: dict[str, Group] = {}

Expand Down
98 changes: 98 additions & 0 deletions src/cappa/docstring.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
from __future__ import annotations

import ast
import inspect
import textwrap
import typing
from dataclasses import dataclass
from types import ModuleType

from typing_extensions import Self

try:
import docstring_parser as _docstring_parser

docstring_parser: ModuleType | None = _docstring_parser
except ImportError: # pragma: no cover
docstring_parser = None


@dataclass
class ClassHelpText:
summary: str | None
body: str | None
args: dict[str, str]

@classmethod
def collect(cls, command: type) -> Self:
args = {}

doc = get_doc(command)
if docstring_parser:
parsed_help = docstring_parser.parse(doc)
for param in parsed_help.params:
args[param.arg_name] = param.description
summary = parsed_help.short_description
body = parsed_help.long_description
else:
doc = inspect.cleandoc(doc).split("\n", 1)
if len(doc) == 1:
summary = doc[0]
body = ""
else:
summary, body = doc
body = body.strip()

ast_args = get_attribute_docstrings(command)
args.update(ast_args)

return cls(summary=summary, body=body, args=args)


def get_doc(cls):
"""Lifted from dataclasses source."""
doc = cls.__doc__ or ""

# Dataclasses will set the doc attribute to the below value if there was no
# explicit docstring. This is just annoying for us, so we treat that as though
# there wasn't one.
try:
# In some cases fetching a signature is not possible.
# But, we surely should not fail in this case.
text_sig = str(inspect.signature(cls)).replace(" -> None", "")
except (TypeError, ValueError): # pragma: no cover
text_sig = ""

dataclasses_docstring = cls.__name__ + text_sig

if doc == dataclasses_docstring:
return ""
return doc


def get_attribute_docstrings(command: type) -> dict[str, str]:
result: dict[str, str] = {}

try:
raw_source = inspect.getsource(command)
except OSError:
return result

source = textwrap.dedent(raw_source)
module = ast.parse(source)

cls_node = module.body[0]
assert isinstance(cls_node, ast.ClassDef)

last_assignment: ast.AnnAssign | None = None
for node in cls_node.body:
if isinstance(node, ast.Expr):
if last_assignment:
name = typing.cast(ast.Name, last_assignment.target).id
value = typing.cast(ast.Constant, node.value).value
result[name] = value
continue

last_assignment = node if isinstance(node, ast.AnnAssign) else None

return result
81 changes: 81 additions & 0 deletions tests/help/test_docstring.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,3 +184,84 @@ class Escaped:
[-h, --help] Show this message and exit.
"""
)


@pytest.mark.help
@backends
def test_attribute_docstring(backend, capsys):
@dataclass
class Args:
"""Bah.
Arguments:
top_level: woo woo
foo: this should get superseded
"""

top_level: int

foo: int
"""This is a foo."""

bar: str
"""This is a bar."""

with pytest.raises(cappa.Exit):
parse(Args, "--help", backend=backend, completion=False)

result = strip_trailing_whitespace(capsys.readouterr().out)
assert result == dedent(
"""\
Usage: args TOP_LEVEL FOO BAR [-h]
Bah.
Arguments
TOP_LEVEL woo woo
FOO This is a foo.
BAR This is a bar.
Help
[-h, --help] Show this message and exit.
"""
)


@pytest.mark.help
@backends
def test_explicit_help_description_manual_args(backend, capsys):
@cappa.command(help="Title", description="longer description")
@dataclass
class Args2:
"""...
Args:
foo: nope
bar: yep
"""

foo: int
"""this is a foo"""

bar: int

with pytest.raises(cappa.Exit):
parse(Args2, "--help", backend=backend, completion=False)

result = strip_trailing_whitespace(capsys.readouterr().out)
assert result == dedent(
"""\
Usage: args2 FOO BAR [-h]
Title
longer description
Arguments
FOO this is a foo
BAR yep
Help
[-h, --help] Show this message and exit.
"""
)
2 changes: 1 addition & 1 deletion tests/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ def parse_completion(cls, *args, location=None) -> Union[str, None]:
def ignore_docstring_parser(monkeypatch):
import importlib

cappa_command = importlib.import_module("cappa.command")
cappa_command = importlib.import_module("cappa.docstring")

with monkeypatch.context() as m:
m.setattr(cappa_command, "docstring_parser", None)
Expand Down

0 comments on commit 5aab7c7

Please sign in to comment.