"""Directive for rendering docstrings."""
from __future__ import annotations
from contextlib import contextmanager
import typing as t
from docutils import nodes
from docutils.parsers import Parser, get_parser_class
from docutils.parsers.rst import directives, roles
from docutils.statemachine import StringList
from sphinx.util.docutils import SphinxDirective, new_document
from sphinx.util.logging import prefixed_warnings
from autodoc2.sphinx.utils import get_database, nested_parse_generated, warn_sphinx
from autodoc2.utils import WarningSubtypes
if t.TYPE_CHECKING:
from docutils.parsers.rst.states import RSTStateMachine
[docs]def parser_options(argument: str) -> Parser | None:
"""
Return a docutils parser whose name matches the argument.
(Directive option conversion function.)
Return `None`, if the argument evaluates to `False`.
Raise `ValueError` if importing the parser module fails.
"""
if not argument or not argument.strip():
return None
if argument in ("myst", "markdown", "md"):
# we want to use the sphinx parser, not the docutils one
argument = "myst_parser.sphinx_"
try:
return get_parser_class(argument)
except ImportError as err:
raise ValueError(str(err))
[docs]def summary_option(argument: str) -> int | None:
"""Must be empty or a positive integer."""
if argument and argument.strip():
try:
value = int(argument)
except ValueError:
raise ValueError("non-integer value; must be an integer")
if value < 0:
raise ValueError("negative value; must be positive or zero")
return value
else:
return None
[docs]class DocstringRenderer(SphinxDirective):
"""Directive to render a docstring of an object."""
has_content = False
required_arguments = 1 # the full name
optional_arguments = 0
final_argument_whitespace = True
option_spec: t.ClassVar[dict[str, t.Any]] = {
"parser": parser_options,
"allowtitles": directives.flag, # used for module docstrings
"summary": summary_option, # number of children to return
"literal": directives.flag, # return the literal docstring
"literal-lexer": directives.unchanged, # the lexer to use for literal
"literal-linenos": directives.flag, # add line numbers to literal
}
[docs] def run(self) -> list[nodes.Node]:
"""Run the directive {a}`1`."""
directive_source, directive_line = self.get_source_info()
# warnings take the docname and line number
warning_loc = (self.env.docname, directive_line)
# find the database item for this object
full_name: str = self.arguments[0]
autodoc2_db = get_database(self.env)
item = autodoc2_db.get_item(full_name)
if item is None:
if "summary" not in self.options:
# summaries can include items imported from external modules
# which may not be in the database, so we don't warn about those
warn_sphinx(
f"Could not find {full_name}",
WarningSubtypes.NAME_NOT_FOUND,
location=warning_loc,
)
return []
# find the source path for this object, by walking up the parent tree
source_name = item["doc_inherited"] if item.get("doc_inherited") else full_name
source_path: str | None = None
for ancestor in autodoc2_db.get_ancestors(source_name, include_self=True):
if ancestor is None:
break # should never happen
if "file_path" in ancestor:
source_path = ancestor["file_path"]
break
source_item = autodoc2_db.get_item(source_name)
# also get the line number within the file
source_offset = (
source_item["range"][0] if source_item and ("range" in source_item) else 0
)
if source_path:
# ensure rebuilds when the source file changes
self.env.note_dependency(source_path)
if not item["doc"].strip():
return []
if "literal" in self.options:
# return the literal docstring
literal = nodes.literal_block(text=item["doc"])
self.set_source_info(literal)
if "literal-lexer" in self.options:
literal["language"] = self.options["literal-lexer"]
if "literal-linenos" in self.options:
literal["linenos"] = True
literal["highlight_args"] = {"linenostart": 1 + source_offset}
return [literal]
# now we run the actual parsing
# here things get a little tricky:
# 1. We want to parse the docstring according to the correct parser,
# which, may not be the same as the current parser.
# 2. We want to set the source path and line number correctly
# so that warnings and errors are reported against the actual source documentation.
with prefixed_warnings("[Autodoc2]:"):
if self.options.get("parser", None):
# parse into a dummy document and return created nodes
parser: Parser = self.options["parser"]()
document = new_document(
source_path or self.state.document["source"],
self.state.document.settings,
)
document.reporter.get_source_and_line = lambda li: (
source_path,
li + source_offset,
)
with parsing_context():
parser.parse(item["doc"], document)
children = document.children or []
else:
doc_lines = item["doc"].splitlines()
if source_path:
# Here we perform a nested render, but temporarily setup the document/reporter
# with the correct document path and lineno for the included file.
with change_source(
self.state, source_path, source_offset - directive_line
):
base = nodes.Element()
base.source = source_path
base.line = source_offset
content = StringList(
doc_lines,
source=source_path,
items=[
(source_path, i + source_offset + 1)
for i in range(len(doc_lines))
],
)
self.state.nested_parse(
content, 0, base, match_titles="allowtitles" in self.options
)
else:
base = nested_parse_generated(
self.state,
doc_lines,
directive_source,
directive_line,
match_titles="allowtitles" in self.options,
)
children = base.children or []
if children and ("summary" in self.options):
if self.options["summary"] in (None, 1):
return [children[0]]
return children[: self.options["summary"]]
return children
[docs]@contextmanager
def parsing_context() -> t.Generator[None, None, None]:
"""Restore the parsing context after a nested parse with a different parser."""
should_restore = False
if "" in roles._roles:
blankrole = roles._roles[""]
try:
yield
finally:
if should_restore:
roles._roles[""] = blankrole
[docs]@contextmanager
def change_source(
state: RSTStateMachine, source_path: str, line_offset: int
) -> t.Generator[None, None, None]:
"""Temporarily change the source and line number."""
# TODO also override the warning message to include the original source
source = state.document["source"]
rsource = state.reporter.source
line_func = getattr(state.reporter, "get_source_and_line", None)
try:
state.document["source"] = source_path
state.reporter.source = source_path
state.reporter.get_source_and_line = lambda li: (
source_path,
li + line_offset,
)
yield
finally:
state.document["source"] = source
state.reporter.source = rsource
if line_func is not None:
state.reporter.get_source_and_line = line_func
else:
del state.reporter.get_source_and_line
[docs]def _example(a: int, b: str) -> None:
"""This is an example docstring, written in MyST.
It has a code fence:
```python
a = "hallo"
```
and a table:
| a | b | c |
| - | - | - |
| 1 | 2 | 3 |
and, using the `fieldlist` extension, a field list:
:param a: the first parameter
:param b: the second parameter
:return: the return value
"""