Skip to content

Commit

Permalink
Napoleon: Unify the type preprocessing logic
Browse files Browse the repository at this point in the history
Previously, there were two type preprocessing functions:
`_convert_type_spec` (used in Google-style docstrings) and
`_convert_numpy_type_spec` (used in Numpy-style docstrings).

The Google version simply applied type-alias translations or wrapped
the text in a `:py:class:` role.

The Numpy version does the same, plus adds special handling for keywords
`optional` and `default` and delimiter words `or`, `of`, and `and`. This
allows one to write in natural language, like `Array of int` instead of
`Array[int]` or `Widget, optional` instead of `Optional[Widget]` or
`Widget | None`. Numpy style is described in full at:
https://numpydoc.readthedocs.io/en/latest/format.html#parameters

This commit eliminates the distinction and allows Google-style
docstrings to use these preprocessing rules.
  • Loading branch information
cbarrick committed Nov 24, 2024
1 parent bb27724 commit 8f2a345
Show file tree
Hide file tree
Showing 2 changed files with 35 additions and 38 deletions.
67 changes: 32 additions & 35 deletions sphinx/ext/napoleon/docstring.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ def postprocess(item: str) -> list[str]:
return tokens


def _token_type(token: str, location: str | None = None) -> str:
def _token_type(token: str, debug_location: str | None = None) -> str:
def is_numeric(token: str) -> bool:
try:
# use complex to make sure every numeric value is detected as literal
Expand All @@ -177,28 +177,28 @@ def is_numeric(token: str) -> bool:
logger.warning(
__('invalid value set (missing closing brace): %s'),
token,
location=location,
location=debug_location,
)
type_ = 'literal'
elif token.endswith('}'):
logger.warning(
__('invalid value set (missing opening brace): %s'),
token,
location=location,
location=debug_location,
)
type_ = 'literal'
elif token.startswith(("'", '"')):
logger.warning(
__('malformed string literal (missing closing quote): %s'),
token,
location=location,
location=debug_location,
)
type_ = 'literal'
elif token.endswith(("'", '"')):
logger.warning(
__('malformed string literal (missing opening quote): %s'),
token,
location=location,
location=debug_location,
)
type_ = 'literal'
elif token in {'optional', 'default'}:
Expand All @@ -213,10 +213,10 @@ def is_numeric(token: str) -> bool:
return type_


def _convert_numpy_type_spec(
def _convert_type_spec(
_type: str,
location: str | None = None,
translations: dict[str, str] | None = None,
debug_location: str | None = None,
) -> str:
if translations is None:
translations = {}
Expand All @@ -240,7 +240,7 @@ def convert_obj(

tokens = _tokenize_type_spec(_type)
combined_tokens = _recombine_set_tokens(tokens)
types = [(token, _token_type(token, location)) for token in combined_tokens]
types = [(token, _token_type(token, debug_location)) for token in combined_tokens]

converters = {
'literal': lambda x: '``%s``' % x,
Expand All @@ -258,15 +258,6 @@ def convert_obj(
return converted


def _convert_type_spec(_type: str, translations: dict[str, str] | None = None) -> str:
"""Convert type specification to reference in reST."""
if translations is not None and _type in translations:
return translations[_type]
if _type == 'None':
return ':py:obj:`None`'
return f':py:class:`{_type}`'


class GoogleDocstring:
"""Convert Google style docstrings to reStructuredText.
Expand Down Expand Up @@ -433,6 +424,20 @@ def __str__(self) -> str:
"""
return '\n'.join(self.lines())

def _get_location(self) -> str | None:
try:
filepath = inspect.getfile(self._obj) if self._obj is not None else None
except TypeError:
filepath = None
name = self._name

if filepath is None and name is None:
return None
elif filepath is None:
filepath = ''

return f'{filepath}:docstring of {name}'

def lines(self) -> list[str]:
"""Return the parsed lines of the docstring in reStructuredText format.
Expand Down Expand Up @@ -490,7 +495,11 @@ def _consume_field(
_type, _name = _name, _type

if _type and self._config.napoleon_preprocess_types:
_type = _convert_type_spec(_type, self._config.napoleon_type_aliases or {})
_type = _convert_type_spec(
_type,
translations=self._config.napoleon_type_aliases or {},
debug_location=self._get_location(),
)

indent = self._get_indent(line) + 1
_descs = [_desc, *self._dedent(self._consume_indented_block(indent))]
Expand Down Expand Up @@ -538,7 +547,9 @@ def _consume_returns_section(

if _type and preprocess_types and self._config.napoleon_preprocess_types:
_type = _convert_type_spec(
_type, self._config.napoleon_type_aliases or {}
_type,
translations=self._config.napoleon_type_aliases or {},
debug_location=self._get_location(),
)

_desc = self.__class__(_desc, self._config).lines()
Expand Down Expand Up @@ -1202,20 +1213,6 @@ def __init__(
self._directive_sections = ['.. index::']
super().__init__(docstring, config, app, what, name, obj, options)

def _get_location(self) -> str | None:
try:
filepath = inspect.getfile(self._obj) if self._obj is not None else None
except TypeError:
filepath = None
name = self._name

if filepath is None and name is None:
return None
elif filepath is None:
filepath = ''

return f'{filepath}:docstring of {name}'

def _escape_args_and_kwargs(self, name: str) -> str:
func = super()._escape_args_and_kwargs

Expand All @@ -1242,10 +1239,10 @@ def _consume_field(
_type, _name = _name, _type

if self._config.napoleon_preprocess_types:
_type = _convert_numpy_type_spec(
_type = _convert_type_spec(
_type,
location=self._get_location(),
translations=self._config.napoleon_type_aliases or {},
debug_location=self._get_location(),
)

indent = self._get_indent(line) + 1
Expand Down
6 changes: 3 additions & 3 deletions tests/test_extensions/test_ext_napoleon_docstring.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from sphinx.ext.napoleon.docstring import (
GoogleDocstring,
NumpyDocstring,
_convert_numpy_type_spec,
_convert_type_spec,
_recombine_set_tokens,
_token_type,
_tokenize_type_spec,
Expand Down Expand Up @@ -1332,7 +1332,7 @@ def test_preprocess_types(self):
expected = """\
Do as you please
:Yields: :py:class:`str` -- Extended
:Yields: :class:`str` -- Extended
"""
assert str(actual) == expected

Expand Down Expand Up @@ -2677,7 +2677,7 @@ def test_convert_numpy_type_spec(self):
)

for spec, expected in zip(specs, converted, strict=True):
actual = _convert_numpy_type_spec(spec, translations=translations)
actual = _convert_type_spec(spec, translations=translations)
assert actual == expected

def test_parameter_types(self):
Expand Down

0 comments on commit 8f2a345

Please sign in to comment.