Skip to content
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
5 changes: 5 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
Changelog
=========

Unreleased
----------
* Unknown reStructuredText directives and their contents are ignored when
parsing docstrings.

7.0.0 (2025-06-15)
------------------
* Dropped support for Python 3.5 and 3.6.
Expand Down
38 changes: 32 additions & 6 deletions src/defopt.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@

import docutils.core
from docutils.nodes import NodeVisitor, SkipNode, TextElement
from docutils.parsers.rst import Parser
from docutils.parsers.rst.states import Body

try:
Expand Down Expand Up @@ -355,7 +356,7 @@ def _recurse_functions(funcs, subparsers):
# If this item is callable, then add it to the current
# subparser using this name.
doc = inspect.getdoc(_unwrap_partial(func))
sp_help = signature(doc).doc.split('\n\n', 1)[0]
sp_help = _parse_docstring(doc).doc.split('\n\n', 1)[0]
subparser = subparsers.add_parser(
name, formatter_class=RawTextHelpFormatter, help=sp_help)
yield func, subparser
Expand Down Expand Up @@ -476,12 +477,13 @@ def signature(func: Union[Callable, str]):
This API is provisional and may be adjusted depending on feedback.
"""
if isinstance(func, str) or func is None:
return _parse_docstring(func)
return _parse_docstring(
inspect.cleandoc(func) if func is not None else None)
else:
inspect_sig = _preprocess_inspect_signature(
func, inspect.signature(func))
doc_sig = _preprocess_doc_signature(
func, signature(inspect.getdoc(_unwrap_partial(func))))
func, _parse_docstring(inspect.getdoc(_unwrap_partial(func))))
return _merge_signatures(inspect_sig, doc_sig)


Expand Down Expand Up @@ -792,6 +794,31 @@ def _is_optional_list_like(type_):
## Docstring parsing.


class _DocstringParser(Parser):
def __init__(self):
super().__init__()

class State:
def __init__(self, state_machine, debug=False):
super().__init__(state_machine, debug)
# Keep nested parsing and its cache local to this parser.
self.nested_sm_kwargs['state_classes'] = state_classes
self.nested_sm_cache = []

def unknown_directive(self, type_name):
# Consume the same block as Docutils' unknown-directive error.
_, _, _, blank_finish = \
self.state_machine.get_first_known_indented(
0, strip_indent=False)
return [], blank_finish

# Docutils selects states by class name.
state_classes = tuple(
type(state.__name__, (State, state), {})
for state in self.state_classes)
self.state_classes = state_classes


@contextlib.contextmanager
def _sphinx_common_roles():
# Standard roles:
Expand Down Expand Up @@ -844,7 +871,7 @@ def _sphinx_common_roles():

def _parse_docstring(doc):
"""
Extract documentation from a function's docstring into a `.Signature`
Extract documentation from a cleaned docstring into a `.Signature`
object *with unevaluated annotations*.
"""

Expand All @@ -855,7 +882,6 @@ def _parse_docstring(doc):
# (Should do nothing if not in either style.)
# use_ivar avoids generating an unhandled .. attribute:: directive for
# Attribute blocks, preferring a benign :ivar: field.
doc = inspect.cleandoc(doc)
cfg = Config(napoleon_use_ivar=True)
doc = str(GoogleDocstring(doc, cfg))
doc = str(NumpyDocstring(doc, cfg))
Expand All @@ -866,7 +892,7 @@ def _parse_docstring(doc):
# - Disable syntax highlighting, as 1) pygments is not a dependency
# 2) we don't render with colors and 3) SH breaks the assumption
# that literal blocks contain a single text element.
doc, settings_overrides={
doc, parser=_DocstringParser(), settings_overrides={
'halt_level': 3, 'syntax_highlight': 'none'})

class Visitor(NodeVisitor):
Expand Down
68 changes: 66 additions & 2 deletions test_defopt.py
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,30 @@ def ok(foo):

self.assertEqual(defopt.run(ok, argv=["foo"]).s, "foo")

def test_implicit_parser_unknown_directive(self):
class WithDirective(ConstructibleFromStr):
"""
.. versionchanged:: 1.0

Ignored constructor documentation.
"""

def main(value: WithDirective):
return value

main.__doc__ = WithDirective.__doc__
self.assertEqual(defopt.run(main, argv=['value']).s, 'value')
self.assertEqual(defopt.signature(WithDirective.__doc__).doc, '')
for funcs, argv in [(main, ['--help']), ([main], ['--help']),
([main], ['main', '--help'])]:
with self.subTest(funcs=funcs, argv=argv):
with contextlib.redirect_stdout(StringIO()) as stdout:
with self.assertRaises(SystemExit) as cm:
defopt.run(funcs, argv=argv)
self.assertEqual(cm.exception.code, 0)
self.assertNotIn('Ignored constructor documentation.',
stdout.getvalue())

def test_implicit_noparser(self):
def notok(foo):
""":type foo: NotConstructibleFromStr"""
Expand Down Expand Up @@ -1148,8 +1172,11 @@ def test_bad_doc(self):
- bad
- indent
"""
with self.assertRaises(SystemMessage):
defopt._parse_docstring(inspect.cleandoc(doc))
for prefix in ['', '.. versionchanged:: 1.0\n\n']:
with self.subTest(prefix=prefix):
with self.assertRaisesRegex(
SystemMessage, 'Unexpected indentation'):
defopt._parse_docstring(prefix + inspect.cleandoc(doc))


class TestAnnotations(unittest.TestCase):
Expand Down Expand Up @@ -1213,6 +1240,43 @@ def test_partial_sub_commands(self):


class TestHelp(unittest.TestCase):
def test_unknown_directive(self):
def main(value: int = 1):
"""
Before the directive.

.. versionchanged:: 133.0
:ignored: Ignored option

Ignored body.
- invalid
- indent

After the directive.

.. rubric:: Visible heading

:param value: Visible parameter.

.. versionadded:: 132.0

Ignored nested body.

Visible parameter continuation.
"""
with contextlib.redirect_stdout(StringIO()) as stdout:
with self.assertRaises(SystemExit) as cm:
defopt.run(main, argv=['--help'])
self.assertEqual(cm.exception.code, 0)
help_text = stdout.getvalue()
for text in ['Before the directive.', 'After the directive.',
'Visible heading:', 'Visible parameter.',
'Visible parameter continuation.']:
self.assertIn(text, help_text)
self.assertNotRegex(
help_text,
'versionchanged|versionadded|133[.]0|132[.]0|[Ii]gnored')

def test_type(self):
def foo(bar):
""":param int bar: baz"""
Expand Down