diff --git a/sphinx/ext/napoleon/docstring.py b/sphinx/ext/napoleon/docstring.py index b3daa06f1f4..f5683ad3a5d 100644 --- a/sphinx/ext/napoleon/docstring.py +++ b/sphinx/ext/napoleon/docstring.py @@ -158,7 +158,7 @@ def __init__(self, docstring: Union[str, List[str]], config: SphinxConfig = None if not hasattr(self, '_directive_sections'): self._directive_sections = [] # type: List[str] if not hasattr(self, '_sections'): - self._sections = { + self._sections: Dict[str, Callable] = { 'args': self._parse_parameters_section, 'arguments': self._parse_parameters_section, 'attention': partial(self._parse_admonition, 'attention'), @@ -191,7 +191,7 @@ def __init__(self, docstring: Union[str, List[str]], config: SphinxConfig = None 'warns': self._parse_warns_section, 'yield': self._parse_yields_section, 'yields': self._parse_yields_section, - } # type: Dict[str, Callable] + } self._load_custom_sections() @@ -222,8 +222,10 @@ def lines(self) -> List[str]: def _consume_indented_block(self, indent: int = 1) -> List[str]: lines = [] line = self._line_iter.peek() - while(not self._is_section_break() and - (not line or self._is_indented(line, indent))): + while ( + not self._is_section_break() and + (not line or self._is_indented(line, indent)) + ): lines.append(next(self._line_iter)) line = self._line_iter.peek() return lines @@ -337,15 +339,25 @@ def _dedent(self, lines: List[str], full: bool = False) -> List[str]: return [line[min_indent:] for line in lines] def _escape_args_and_kwargs(self, name: str) -> str: - if name.endswith('_') and getattr(self._config, 'strip_signature_backslash', False): - name = name[:-1] + r'\_' + def _escape_single(token: str) -> str: + if token.endswith('_') and getattr( + self._config, 'strip_signature_backslash', False + ): + token = token[:-1] + r'\_' + + if token[:2] == '**': + return r'\*\*' + token[2:] + elif token[:1] == '*': + return r'\*' + token[1:] + else: + return token - if name[:2] == '**': - return r'\*\*' + name[2:] - elif name[:1] == '*': - return r'\*' + name[1:] - else: - return name + parts = [part for part in re.split(r'\s*,\s*', name.strip()) if part] + if not parts: + return '' + + escaped = [_escape_single(part) for part in parts] + return ', '.join(escaped) def _fix_field_desc(self, desc: List[str]) -> List[str]: if self._is_list(desc): @@ -1081,11 +1093,12 @@ def _get_location(self) -> str: def _escape_args_and_kwargs(self, name: str) -> str: func = super()._escape_args_and_kwargs + parts = [part for part in re.split(r'\s*,\s*', name.strip()) if part] - if ", " in name: - return ", ".join(func(param) for param in name.split(", ")) - else: - return func(name) + if len(parts) <= 1: + return func(parts[0] if parts else '') + + return ', '.join(func(part) for part in parts) def _consume_field(self, parse_type: bool = True, prefer_type: bool = False ) -> Tuple[str, str, List[str]]: diff --git a/tests/test_ext_napoleon_docstring.py b/tests/test_ext_napoleon_docstring.py index bf3c878a8e0..7ac9d728ad8 100644 --- a/tests/test_ext_napoleon_docstring.py +++ b/tests/test_ext_napoleon_docstring.py @@ -1230,7 +1230,7 @@ class NumpyDocstringTest(BaseDocstringTest): """ Single line summary - :Parameters: * **arg1** (*str*) -- Extended description of arg1 + :Parameters: * **arg1** (:class:`str`) -- Extended description of arg1 * **\\*args, \\*\\*kwargs** -- Variable length argument list and arbitrary keyword arguments. """ ), ( @@ -1363,6 +1363,80 @@ def test_parameters_without_class_reference(self): """ self.assertEqual(expected, actual) + def test_parameters_multi_name_optional_use_param_true(self): + docstring = """\ +Parameters +---------- +x1, x2 : array_like, optional + Input arrays. +""" + + config = Config(napoleon_use_param=True) + actual = str(NumpyDocstring(docstring, config)) + expected = """\ +:param x1, x2: Input arrays. +:type x1, x2: :class:`array_like`, *optional* +""" + self.assertEqual(expected, actual) + + for raw_name in ("x1,x2", "x1, x2"): + variant = f"""\ +Parameters +---------- +{raw_name} : array_like, optional + Input arrays. +""" + actual = str(NumpyDocstring(variant, config)) + self.assertEqual(expected, actual) + + def test_parameters_multi_name_optional_use_param_false(self): + docstring = """\ +Parameters +---------- +x1, x2 : array_like, optional + Input arrays. +""" + + config = Config(napoleon_use_param=False) + actual = str(NumpyDocstring(docstring, config)) + expected = """\ +:Parameters: **x1, x2** (:class:`array_like`, *optional*) -- Input arrays. +""" + self.assertEqual(expected, actual) + + for raw_name in ("x1,x2", "x1, x2"): + variant = f"""\ +Parameters +---------- +{raw_name} : array_like, optional + Input arrays. +""" + actual = str(NumpyDocstring(variant, config)) + self.assertEqual(expected, actual) + + def test_parameters_multi_name_without_optional(self): + docstring = """\ +Parameters +---------- +x1, x2 : array_like + Input arrays. +""" + + expected_param = """\ +:param x1, x2: Input arrays. +:type x1, x2: :class:`array_like` +""" + config = Config(napoleon_use_param=True) + actual = str(NumpyDocstring(docstring, config)) + self.assertEqual(expected_param, actual) + + config = Config(napoleon_use_param=False) + actual = str(NumpyDocstring(docstring, config)) + expected_list = """\ +:Parameters: **x1, x2** (:class:`array_like`) -- Input arrays. +""" + self.assertEqual(expected_list, actual) + def test_see_also_refs(self): docstring = """\ numpy.multivariate_normal(mean, cov, shape=None, spam=None)