diff --git a/docstring_parser/common.py b/docstring_parser/common.py index d191987..3efafbb 100644 --- a/docstring_parser/common.py +++ b/docstring_parser/common.py @@ -184,3 +184,21 @@ def deprecation(self) -> T.Optional[DocstringDeprecated]: if isinstance(item, DocstringDeprecated): return item return None + +def strip_initial_whitespace(string): + """ + Removes initial whitespace up to a newline, if no characters are present. + Different from ''.strip(), since it will preserve indents on lines with text. + Also performs rstrip(). + """ + if not string: + return string + lines = string.splitlines() + lines[0] = lines[0].strip() + for i, line in enumerate(lines): + if line and not line.isspace(): + break + else: + i = 0 + return '\n'.join(lines[i:]).rstrip() + \ No newline at end of file diff --git a/docstring_parser/epydoc.py b/docstring_parser/epydoc.py index 2196734..0595fab 100644 --- a/docstring_parser/epydoc.py +++ b/docstring_parser/epydoc.py @@ -4,6 +4,7 @@ """ import inspect import re +import textwrap import typing as T from .common import ( @@ -15,11 +16,12 @@ DocstringStyle, ParseError, RenderingStyle, + strip_initial_whitespace ) def _clean_str(string: str) -> T.Optional[str]: - string = string.strip() + string = strip_initial_whitespace(string) if len(string) > 0: return string return None @@ -114,10 +116,7 @@ def parse(text: str) -> Docstring: 'Error parsing meta information near "{}".'.format(chunk) ) - desc = desc_chunk.strip() - if "\n" in desc: - first_line, rest = desc.split("\n", 1) - desc = first_line + "\n" + inspect.cleandoc(rest) + desc = textwrap.dedent(strip_initial_whitespace(desc_chunk)) stream.append((base, key, args, desc)) # Combine type_name, arg_name, and description information @@ -210,17 +209,16 @@ def compose( def process_desc(desc: T.Optional[str], is_type: bool) -> str: if not desc: return "" + ret = "\n".join( + [indent + line for line in desc.splitlines()] + ) if rendering_style == RenderingStyle.EXPANDED or ( rendering_style == RenderingStyle.CLEAN and not is_type ): - (first, *rest) = desc.splitlines() - return "\n".join( - ["\n" + indent + first] + [indent + line for line in rest] - ) - - (first, *rest) = desc.splitlines() - return "\n".join([" " + first] + [indent + line for line in rest]) + return "\n" + ret + # Skip first indent + return " " + ret[len(indent):] parts: T.List[str] = [] if docstring.short_description: diff --git a/docstring_parser/google.py b/docstring_parser/google.py index 6e90a66..f6d38de 100644 --- a/docstring_parser/google.py +++ b/docstring_parser/google.py @@ -2,6 +2,7 @@ import inspect import re +import textwrap import typing as T from collections import OrderedDict, namedtuple from enum import IntEnum @@ -19,6 +20,7 @@ DocstringStyle, ParseError, RenderingStyle, + strip_initial_whitespace ) @@ -112,11 +114,7 @@ def _build_meta(self, text: str, title: str) -> DocstringMeta: # Split spec and description before, desc = text.split(":", 1) if desc: - desc = desc[1:] if desc[0] == " " else desc - if "\n" in desc: - first_line, rest = desc.split("\n", 1) - desc = first_line + "\n" + inspect.cleandoc(rest) - desc = desc.strip("\n") + desc = textwrap.dedent(strip_initial_whitespace(desc)) return self._build_multi_meta(section, before, desc) @@ -277,7 +275,16 @@ def parse(self, text: str) -> Docstring: c_splits.append((c_matches[j].end(), c_matches[j + 1].start())) c_splits.append((c_matches[-1].end(), len(chunk))) for j, (start, end) in enumerate(c_splits): - part = chunk[start:end].strip("\n") + part = chunk[start:end] + # Take indent away from subsequent lines here, since inner scope + # doesn't know about the indent level + lines = [] + for line in part.splitlines(): + if line.startswith(indent): + line = line[len(indent):] + lines.append(line) + # Remove preliminary indentation + part = '\n'.join(lines) ret.meta.append(self._build_meta(part, title)) return ret diff --git a/docstring_parser/numpydoc.py b/docstring_parser/numpydoc.py index 3238fb5..937731a 100644 --- a/docstring_parser/numpydoc.py +++ b/docstring_parser/numpydoc.py @@ -6,6 +6,7 @@ import inspect import itertools import re +import textwrap import typing as T from .common import ( @@ -17,6 +18,7 @@ DocstringReturns, DocstringStyle, RenderingStyle, + strip_initial_whitespace ) @@ -27,7 +29,11 @@ def _pairwise(iterable: T.Iterable, end=None) -> T.Iterable: def _clean_str(string: str) -> T.Optional[str]: - string = string.strip() + # strip_initial_whitespace normally preserves indentation + # if a leading \n is present, but this shouldn't happen + # with numpy + string = textwrap.dedent(string.strip('\n')) + string = strip_initial_whitespace(string) if len(string) > 0: return string return None @@ -98,7 +104,7 @@ def parse(self, text: str) -> T.Iterable[DocstringMeta]: end = next_match.start() if next_match is not None else None value = text[start:end] yield self._parse_item( - key=match.group(), value=inspect.cleandoc(value) + key=match.group(), value=value ) diff --git a/docstring_parser/rest.py b/docstring_parser/rest.py index 92f5d56..f8f74ac 100644 --- a/docstring_parser/rest.py +++ b/docstring_parser/rest.py @@ -2,6 +2,7 @@ import inspect import re +import textwrap import typing as T from .common import ( @@ -19,6 +20,7 @@ DocstringStyle, ParseError, RenderingStyle, + strip_initial_whitespace ) @@ -138,10 +140,7 @@ def parse(text: str) -> Docstring: 'Error parsing meta information near "{}".'.format(chunk) ) from ex args = args_chunk.split() - desc = desc_chunk.strip() - if "\n" in desc: - first_line, rest = desc.split("\n", 1) - desc = first_line + "\n" + inspect.cleandoc(rest) + desc = textwrap.dedent(strip_initial_whitespace(desc_chunk)) ret.meta.append(_build_meta(args, desc)) diff --git a/docstring_parser/tests/test_epydoc.py b/docstring_parser/tests/test_epydoc.py index 731d4bb..5526b64 100644 --- a/docstring_parser/tests/test_epydoc.py +++ b/docstring_parser/tests/test_epydoc.py @@ -207,7 +207,7 @@ def test_meta_with_multiline_description() -> None: assert docstring.short_description == "Short description" assert len(docstring.meta) == 1 assert docstring.meta[0].args == ["meta"] - assert docstring.meta[0].description == "asd\n1\n 2\n3" + assert docstring.meta[0].description == "asd\n 1\n 2\n 3" def test_multiple_meta() -> None: @@ -227,7 +227,7 @@ def test_multiple_meta() -> None: assert docstring.short_description == "Short description" assert len(docstring.meta) == 3 assert docstring.meta[0].args == ["meta1"] - assert docstring.meta[0].description == "asd\n1\n 2\n3" + assert docstring.meta[0].description == "asd\n 1\n 2\n 3" assert docstring.meta[1].args == ["meta2"] assert docstring.meta[1].description == "herp" assert docstring.meta[2].args == ["meta3"] @@ -617,7 +617,7 @@ def test_broken_meta() -> None: ) def test_compose(source: str, expected: str) -> None: """Test compose in default mode.""" - assert compose(parse(source)) == expected + assert compose(parse(source), indent="") == expected @pytest.mark.parametrize( @@ -654,15 +654,14 @@ def test_compose(source: str, expected: str) -> None: "@type multiline: str?\n" "@param multiline:\n" " long description 5,\n" - " defaults to 'bye'", + " defaults to 'bye'", ), ], ) def test_compose_clean(source: str, expected: str) -> None: """Test compose in clean mode.""" assert ( - compose(parse(source), rendering_style=RenderingStyle.CLEAN) - == expected + compose(parse(source), rendering_style=RenderingStyle.CLEAN) == expected ) @@ -704,7 +703,7 @@ def test_compose_clean(source: str, expected: str) -> None: " str?\n" "@param multiline:\n" " long description 5,\n" - " defaults to 'bye'", + " defaults to 'bye'", ), ], ) diff --git a/docstring_parser/tests/test_google.py b/docstring_parser/tests/test_google.py index 3b6e8cb..13399bf 100644 --- a/docstring_parser/tests/test_google.py +++ b/docstring_parser/tests/test_google.py @@ -326,7 +326,7 @@ def test_meta_with_multiline_description() -> None: assert len(docstring.meta) == 1 assert docstring.meta[0].args == ["param", "spam"] assert docstring.meta[0].arg_name == "spam" - assert docstring.meta[0].description == "asd\n1\n 2\n3" + assert docstring.meta[0].description == "asd\n 1\n 2\n 3" def test_default_args(): @@ -379,7 +379,7 @@ def test_multiple_meta() -> None: assert len(docstring.meta) == 3 assert docstring.meta[0].args == ["param", "spam"] assert docstring.meta[0].arg_name == "spam" - assert docstring.meta[0].description == "asd\n1\n 2\n3" + assert docstring.meta[0].description == "asd\n 1\n 2\n 3" assert docstring.meta[1].args == ["raises", "bla"] assert docstring.meta[1].type_name == "bla" assert docstring.meta[1].description == "herp" @@ -436,7 +436,7 @@ def test_params() -> None: assert docstring.params[0].arg_name == "name" assert docstring.params[0].type_name is None assert docstring.params[0].description == ( - "description 1\nwith multi-line text" + "description 1\n with multi-line text" ) assert docstring.params[1].arg_name == "priority" assert docstring.params[1].type_name == "int" @@ -491,7 +491,7 @@ def test_attributes() -> None: assert docstring.params[0].arg_name == "name" assert docstring.params[0].type_name is None assert docstring.params[0].description == ( - "description 1\nwith multi-line text" + "description 1\n with multi-line text" ) assert docstring.params[1].arg_name == "priority" assert docstring.params[1].type_name == "int" @@ -873,7 +873,7 @@ def test_empty_example() -> None: message (str, optional): description 4, defaults to 'hello' multiline (str?): long description 5, - defaults to 'bye' + defaults to 'bye' """, "Short description\n" "\n" @@ -914,7 +914,7 @@ def test_compose(source: str, expected: str) -> None: message (str, optional): description 4, defaults to 'hello' multiline (str?): long description 5, - defaults to 'bye' + defaults to 'bye' """, "Short description\n" "\n" @@ -965,7 +965,7 @@ def test_compose_clean(source: str, expected: str) -> None: " description 4, defaults to 'hello'\n" " multiline (str, optional):\n" " long description 5,\n" - " defaults to 'bye'", + " defaults to 'bye'", ), ], ) diff --git a/docstring_parser/tests/test_rest.py b/docstring_parser/tests/test_rest.py index ec6190e..036cb52 100644 --- a/docstring_parser/tests/test_rest.py +++ b/docstring_parser/tests/test_rest.py @@ -207,7 +207,7 @@ def test_meta_with_multiline_description() -> None: assert docstring.short_description == "Short description" assert len(docstring.meta) == 1 assert docstring.meta[0].args == ["meta"] - assert docstring.meta[0].description == "asd\n1\n 2\n3" + assert docstring.meta[0].description == "asd\n 1\n 2\n 3" def test_multiple_meta() -> None: @@ -227,7 +227,7 @@ def test_multiple_meta() -> None: assert docstring.short_description == "Short description" assert len(docstring.meta) == 3 assert docstring.meta[0].args == ["meta1"] - assert docstring.meta[0].description == "asd\n1\n 2\n3" + assert docstring.meta[0].description == "asd\n 1\n 2\n 3" assert docstring.meta[1].args == ["meta2"] assert docstring.meta[1].description == "herp" assert docstring.meta[2].args == ["meta3"]