diff --git a/src/pytkdocs/parsers/docstrings.py b/src/pytkdocs/parsers/docstrings.py index 1d619c3..9ecacbe 100644 --- a/src/pytkdocs/parsers/docstrings.py +++ b/src/pytkdocs/parsers/docstrings.py @@ -2,17 +2,8 @@ import inspect import re -from textwrap import dedent from typing import Any, List, Optional, Pattern, Sequence, Tuple -try: - from typing import GenericMeta # python 3.6 -except ImportError: - # in 3.7, GenericMeta doesn't exist but we don't need it - class GenericMeta(type): # type: ignore - pass - - empty = inspect.Signature.empty @@ -26,12 +17,6 @@ class GenericMeta(type): # type: ignore """Titles to match for "returns" sections.""" -RE_OPTIONAL: Pattern = re.compile(r"Union\[(.+), NoneType\]") -"""Regular expression to match optional annotations of the form `Union[T, NoneType]`.""" - -RE_FORWARD_REF: Pattern = re.compile(r"_?ForwardRef\('([^']+)'\)") -"""Regular expression to match forward-reference annotations of the form `_ForwardRef('T')`.""" - RE_GOOGLE_STYLE_ADMONITION: Pattern = re.compile(r"^(?P\s*)(?P[\w-]+):((?:\s+)(?P.+))?$") """Regular expressions to match lines starting admonitions, of the form `TYPE: [TITLE]`.""" @@ -43,13 +28,6 @@ def __init__(self, annotation, description): self.annotation = annotation self.description = description - def __str__(self): - return self.annotation_string - - @property - def annotation_string(self): - return annotation_to_string(self.annotation) - class Parameter(AnnotatedObject): """A helper class to store information about a signature parameter.""" @@ -165,37 +143,43 @@ def parse(self, admonitions: bool = True) -> List[Section]: while i < len(lines): line_lower = lines[i].lower() + if in_code_block: if line_lower.lstrip(" ").startswith("```"): in_code_block = False current_section.append(lines[i]) + elif line_lower in TITLES_PARAMETERS: if current_section: if any(current_section): - sections.append(Section(Section.Type.MARKDOWN, current_section)) + sections.append(Section(Section.Type.MARKDOWN, "\n".join(current_section))) current_section = [] section, i = self.read_parameters_section(lines, i + 1) if section: sections.append(section) + elif line_lower in TITLES_EXCEPTIONS: if current_section: if any(current_section): - sections.append(Section(Section.Type.MARKDOWN, current_section)) + sections.append(Section(Section.Type.MARKDOWN, "\n".join(current_section))) current_section = [] section, i = self.read_exceptions_section(lines, i + 1) if section: sections.append(section) + elif line_lower in TITLES_RETURN: if current_section: if any(current_section): - sections.append(Section(Section.Type.MARKDOWN, current_section)) + sections.append(Section(Section.Type.MARKDOWN, "\n".join(current_section))) current_section = [] section, i = self.read_return_section(lines, i + 1) if section: sections.append(section) + elif line_lower.lstrip(" ").startswith("```"): in_code_block = True current_section.append(lines[i]) + else: if admonitions and not in_code_block and i + 1 < len(lines): match = RE_GOOGLE_STYLE_ADMONITION.match(lines[i]) @@ -207,20 +191,24 @@ def parse(self, admonitions: bool = True) -> List[Section]: if groups["title"]: lines[i] += f' "{groups["title"]}"' current_section.append(lines[i]) + i += 1 if current_section: - sections.append(Section(Section.Type.MARKDOWN, current_section)) + sections.append(Section(Section.Type.MARKDOWN, "\n".join(current_section))) return sections @staticmethod - def read_block_items(lines: List[str], start_index: int) -> Tuple[List[str], int]: + def is_empty_line(line): + return not line.strip() + + def read_block_items(self, lines: List[str], start_index: int) -> Tuple[List[str], int]: """ Parse an indented block as a list of items. - Each item is indented by four spaces. Every line indented with more than five spaces are concatenated - back into the previous line. + The first indentation level is used as a reference to determine if the next lines are new items + or continuation lines. Arguments: lines: The block lines. @@ -229,28 +217,65 @@ def read_block_items(lines: List[str], start_index: int) -> Tuple[List[str], int Returns: A tuple containing the list of concatenated lines and the index at which to continue parsing. """ + if start_index >= len(lines): + return [], start_index + i = start_index - block: List[str] = [] - prefix = " " - while i < len(lines) and (lines[i].startswith(" ") or not lines[i].strip()): - if block and lines[i].startswith(" "): - block[-1] += prefix + lines[i].lstrip(" ") - prefix = " " - elif block and not lines[i].strip(): - block[-1] += "\n\n" - prefix = "" + items: List[str] = [] + + # skip first empty lines + while self.is_empty_line(lines[i]): + i += 1 + + # get initial indent + indent = len(lines[i]) - len(lines[i].lstrip()) + + if indent == 0: + # first non-empty line was not indented, abort + return [], i - 1 + + # start processing first item + current_item = [lines[i][indent:]] + i += 1 + + # loop on next lines + while i < len(lines): + line = lines[i] + + if line.startswith(indent * 2 * " "): + # continuation line + current_item.append(line[indent * 2 :]) + + elif line.startswith((indent + 1) * " "): + # indent between initial and continuation: append but add error + cont_indent = len(line) - len(line.lstrip()) + current_item.append(line[cont_indent:]) + self.parsing_errors.append( + f"{self.path}: Confusing indentation for continuation line {i+1} in docstring, " + f"should be {indent} * 2 = {indent*2} spaces, not {cont_indent}" + ) + + elif line.startswith(indent * " "): + # indent equal to initial one: new item + items.append("\n".join(current_item)) + current_item = [line[indent:]] + + elif self.is_empty_line(line): + # empty line: preserve it in the current item + current_item.append("") + else: - block.append(lines[i]) + # indent lower than initial one: end of section + break + i += 1 - cleaned_up_block = [] - for line in block: - stripped = line.strip() - if stripped: - cleaned_up_block.append(stripped) - return cleaned_up_block, i - 1 - @staticmethod - def read_block(lines: List[str], start_index: int) -> Tuple[List[str], int]: + if current_item: + items.append("\n".join(current_item).rstrip("\n")) + + return items, i - 1 + + def read_block(self, lines: List[str], start_index: int) -> Tuple[str, int]: """ Parse an indented block. @@ -261,12 +286,33 @@ def read_block(lines: List[str], start_index: int) -> Tuple[List[str], int]: Returns: A tuple containing the list of lines and the index at which to continue parsing. """ + if start_index >= len(lines): + return "", start_index + i = start_index - block = [] - while i < len(lines) and (lines[i].startswith(" ") or not lines[i].strip()): - block.append(lines[i]) + block: List[str] = [] + + # skip first empty lines + while self.is_empty_line(lines[i]): + i += 1 + + # get initial indent + indent = len(lines[i]) - len(lines[i].lstrip()) + + if indent == 0: + # first non-empty line was not indented, abort + return "", i - 1 + + # start processing first item + block.append(lines[i].lstrip()) + i += 1 + + # loop on next lines + while i < len(lines) and (lines[i].startswith(indent * " ") or self.is_empty_line(lines[i])): + block.append(lines[i][indent:]) i += 1 - return block, i - 1 + + return "\n".join(block).rstrip("\n"), i - 1 def read_parameters_section(self, lines: List[str], start_index: int) -> Tuple[Optional[Section], int]: """ @@ -282,13 +328,16 @@ def read_parameters_section(self, lines: List[str], start_index: int) -> Tuple[O parameters = [] type_: Any block, i = self.read_block_items(lines, start_index) + for param_line in block: try: - name_with_type, description = param_line.lstrip(" ").split(":", 1) + name_with_type, description = param_line.split(":", 1) except ValueError: self.parsing_errors.append(f"{self.path}: Failed to get 'name: description' pair from '{param_line}'") continue + description = description.lstrip() + if " " in name_with_type: name, type_ = name_with_type.split(" ", 1) type_ = type_.strip("()") @@ -314,9 +363,7 @@ def read_parameters_section(self, lines: List[str], start_index: int) -> Tuple[O kind = signature_param.kind parameters.append( - Parameter( - name=name, annotation=annotation, description=description.lstrip(" "), default=default, kind=kind, - ) + Parameter(name=name, annotation=annotation, description=description, default=default, kind=kind,) ) if parameters: @@ -338,6 +385,7 @@ def read_exceptions_section(self, lines: List[str], start_index: int) -> Tuple[O """ exceptions = [] block, i = self.read_block_items(lines, start_index) + for exception_line in block: try: annotation, description = exception_line.split(": ", 1) @@ -347,6 +395,7 @@ def read_exceptions_section(self, lines: List[str], start_index: int) -> Tuple[O ) else: exceptions.append(AnnotatedObject(annotation, description.lstrip(" "))) + if exceptions: return Section(Section.Type.EXCEPTIONS, exceptions), i @@ -364,57 +413,30 @@ def read_return_section(self, lines: List[str], start_index: int) -> Tuple[Optio Returns: A tuple containing a `Section` (or `None`) and the index at which to continue parsing. """ - block, i = self.read_block(lines, start_index) + text, i = self.read_block(lines, start_index) + if self.signature: annotation = self.signature.return_annotation else: annotation = self.return_type if annotation is empty: - if not block: + if not text: self.parsing_errors.append(f"{self.path}: No return type annotation") else: try: - type_, first_line = block[0].split(":", 1) + type_, text = text.split(":", 1) except ValueError: self.parsing_errors.append(f"{self.path}: No type in return description") else: - annotation = type_.lstrip(" ") - block[0] = first_line.lstrip(" ") + annotation = type_.lstrip() + text = text.lstrip() - description = dedent("\n".join(block)) - if annotation is empty and not description: + if annotation is empty and not text: self.parsing_errors.append(f"{self.path}: Empty return section at line {start_index}") return None, i - return Section(Section.Type.RETURN, AnnotatedObject(annotation, description)), i - - -def rebuild_optional(matched_group: str) -> str: - brackets_level = 0 - for char in matched_group: - if char == "," and brackets_level == 0: - return f"Union[{matched_group}]" - elif char == "[": - brackets_level += 1 - elif char == "]": - brackets_level -= 1 - return matched_group - - -def annotation_to_string(annotation: Any): - if annotation is empty: - return "" - - if inspect.isclass(annotation) and not isinstance(annotation, GenericMeta): - s = annotation.__name__ - else: - s = str(annotation).replace("typing.", "") - - s = RE_FORWARD_REF.sub(lambda match: match.group(1), s) - s = RE_OPTIONAL.sub(lambda match: f"Optional[{rebuild_optional(match.group(1))}]", s) - - return s + return Section(Section.Type.RETURN, AnnotatedObject(annotation, text)), i def parse( diff --git a/src/pytkdocs/serializer.py b/src/pytkdocs/serializer.py index 8d3322e..dfff54f 100644 --- a/src/pytkdocs/serializer.py +++ b/src/pytkdocs/serializer.py @@ -6,10 +6,52 @@ import inspect -from typing import Optional +import re +from typing import Any, Optional, Pattern from .objects import Object, Source -from .parsers.docstrings import AnnotatedObject, Parameter, Section, annotation_to_string +from .parsers.docstrings import AnnotatedObject, Parameter, Section + +try: + from typing import GenericMeta # python 3.6 +except ImportError: + # in 3.7, GenericMeta doesn't exist but we don't need it + class GenericMeta(type): # type: ignore + pass + + +RE_OPTIONAL: Pattern = re.compile(r"Union\[(.+), NoneType\]") +"""Regular expression to match optional annotations of the form `Union[T, NoneType]`.""" + +RE_FORWARD_REF: Pattern = re.compile(r"_?ForwardRef\('([^']+)'\)") +"""Regular expression to match forward-reference annotations of the form `_ForwardRef('T')`.""" + + +def rebuild_optional(matched_group: str) -> str: + brackets_level = 0 + for char in matched_group: + if char == "," and brackets_level == 0: + return f"Union[{matched_group}]" + elif char == "[": + brackets_level += 1 + elif char == "]": + brackets_level -= 1 + return matched_group + + +def annotation_to_string(annotation: Any): + if annotation is inspect.Signature.empty: + return "" + + if inspect.isclass(annotation) and not isinstance(annotation, GenericMeta): + s = annotation.__name__ + else: + s = str(annotation).replace("typing.", "") + + s = RE_FORWARD_REF.sub(lambda match: match.group(1), s) + s = RE_OPTIONAL.sub(lambda match: f"Optional[{rebuild_optional(match.group(1))}]", s) + + return s def serialize_annotated_object(obj: AnnotatedObject) -> dict: @@ -22,7 +64,7 @@ def serialize_annotated_object(obj: AnnotatedObject) -> dict: Returns: A JSON-serializable dictionary. """ - return dict(description=obj.description, annotation=obj.annotation_string) + return dict(description=obj.description, annotation=annotation_to_string(obj.annotation)) def serialize_parameter(parameter: Parameter) -> dict: @@ -100,7 +142,7 @@ def serialize_docstring_section(section: Section) -> dict: """ serialized = dict(type=section.type) if section.type == section.Type.MARKDOWN: - serialized.update(dict(value="\n".join(section.value))) + serialized.update(dict(value=section.value)) elif section.type == section.Type.RETURN: serialized.update(dict(value=serialize_annotated_object(section.value))) elif section.type == section.Type.EXCEPTIONS: diff --git a/tests/test_parsers/test_docstrings.py b/tests/test_parsers/test_docstrings.py index c8cd155..840ec91 100644 --- a/tests/test_parsers/test_docstrings.py +++ b/tests/test_parsers/test_docstrings.py @@ -11,354 +11,404 @@ def parse(docstring, signature=None, return_type=inspect.Signature.empty, admoni return _parse("o", dedent(docstring).strip(), signature, return_type, admonitions) -class TestDocstringParser: - def test_simple_docstring(self): - sections, errors = parse("A simple docstring.") - assert len(sections) == 1 - assert not errors - - def test_multi_line_docstring(self): - sections, errors = parse( - """ - A somewhat longer docstring. - - Blablablabla. - """ - ) - assert len(sections) == 1 - assert not errors - - def test_sections_without_signature(self): - sections, errors = parse( - """ - Sections without signature. +def test_simple_docstring(): + sections, errors = parse("A simple docstring.") + assert len(sections) == 1 + assert not errors - Parameters: - void: SEGFAULT. - niet: SEGFAULT. - nada: SEGFAULT. - rien: SEGFAULT. - - Exceptions: - GlobalError: when nothing works as expected. - - Returns: - Itself. - """ - ) - - assert len(sections) == 4 - assert len(errors) == 5 # missing annotations for params and return - for error in errors[:-1]: - assert "param" in error - assert "return" in errors[-1] - - def test_property_docstring(self): - class_ = Loader().get_object_documentation("tests.fixtures.parsing.docstrings.NotDefinedYet") - prop = class_.attributes[0] - sections, errors = prop.docstring_sections, prop.docstring_errors - assert len(sections) == 2 - assert not errors - - def test_function_without_annotations(self): - def f(x, y): - """ - This function has no annotations. - - Parameters: - x: X value. - y: Y value. - - Returns: - Sum X + Y. - """ - return x + y - - sections, errors = parse(inspect.getdoc(f), inspect.signature(f)) - assert len(sections) == 3 - assert len(errors) == 1 - assert "No type in return" in errors[0] - - def test_function_with_annotations(self): - def f(x: int, y: int) -> int: - """ - This function has annotations. - - Parameters: - x: X value. - y: Y value. - Returns: - Sum X + Y. - """ - return x + y +def test_multi_line_docstring(): + sections, errors = parse( + """ + A somewhat longer docstring. + + Blablablabla. + """ + ) + assert len(sections) == 1 + assert not errors - sections, errors = parse(inspect.getdoc(f), inspect.signature(f)) - assert len(sections) == 3 - assert not errors - def test_types_in_docstring(self): - def f(x, y): - """ - The types are written in the docstring. +def test_sections_without_signature(): + sections, errors = parse( + """ + Sections without signature. + + Parameters: + void: SEGFAULT. + niet: SEGFAULT. + nada: SEGFAULT. + rien: SEGFAULT. - Parameters: - x (int): X value. - y (int): Y value. - - Returns: - int: Sum X + Y. - """ - return x + y - - sections, errors = parse(inspect.getdoc(f), inspect.signature(f)) - assert len(sections) == 3 - assert not errors - - x, y = sections[1].value - r = sections[2].value + Exceptions: + GlobalError: when nothing works as expected. + + Returns: + Itself. + """ + ) - assert x.name == "x" - assert x.annotation == "int" - assert x.description == "X value." - assert x.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD - assert x.default is inspect.Signature.empty + assert len(sections) == 4 + assert len(errors) == 5 # missing annotations for params and return + for error in errors[:-1]: + assert "param" in error + assert "return" in errors[-1] - assert y.name == "y" - assert y.annotation == "int" - assert y.description == "Y value." - assert y.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD - assert y.default is inspect.Signature.empty - - assert r.annotation == "int" - assert r.description == "Sum X + Y." - - def test_types_and_optional_in_docstring(self): - def f(x=1, y=None): - """ - The types are written in the docstring. - - Parameters: - x (int): X value. - y (int, optional): Y value. - Returns: - int: Sum X + Y. - """ - return x + (y or 1) +def test_property_docstring(): + class_ = Loader().get_object_documentation("tests.fixtures.parsing.docstrings.NotDefinedYet") + prop = class_.attributes[0] + sections, errors = prop.docstring_sections, prop.docstring_errors + assert len(sections) == 2 + assert not errors + + +def test_function_without_annotations(): + def f(x, y): + """ + This function has no annotations. + + Parameters: + x: X value. + y: Y value. - sections, errors = parse(inspect.getdoc(f), inspect.signature(f)) - assert len(sections) == 3 - assert not errors + Returns: + Sum X + Y. + """ + return x + y - x, y = sections[1].value + sections, errors = parse(inspect.getdoc(f), inspect.signature(f)) + assert len(sections) == 3 + assert len(errors) == 1 + assert "No type in return" in errors[0] - assert x.name == "x" - assert x.annotation == "int" - assert x.description == "X value." - assert x.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD - assert x.default == 1 - assert y.name == "y" - assert y.annotation == "int" - assert y.description == "Y value." - assert y.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD - assert y.default is None +def test_function_with_annotations(): + def f(x: int, y: int) -> int: + """ + This function has annotations. + + Parameters: + x: X value. + y: Y value. + + Returns: + Sum X + Y. + """ + return x + y + + sections, errors = parse(inspect.getdoc(f), inspect.signature(f)) + assert len(sections) == 3 + assert not errors + + +def test_types_in_docstring(): + def f(x, y): + """ + The types are written in the docstring. + + Parameters: + x (int): X value. + y (int): Y value. + + Returns: + int: Sum X + Y. + """ + return x + y + + sections, errors = parse(inspect.getdoc(f), inspect.signature(f)) + assert len(sections) == 3 + assert not errors + + x, y = sections[1].value + r = sections[2].value + + assert x.name == "x" + assert x.annotation == "int" + assert x.description == "X value." + assert x.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD + assert x.default is inspect.Signature.empty + + assert y.name == "y" + assert y.annotation == "int" + assert y.description == "Y value." + assert y.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD + assert y.default is inspect.Signature.empty + + assert r.annotation == "int" + assert r.description == "Sum X + Y." + + +def test_types_and_optional_in_docstring(): + def f(x=1, y=None): + """ + The types are written in the docstring. + + Parameters: + x (int): X value. + y (int, optional): Y value. + + Returns: + int: Sum X + Y. + """ + return x + (y or 1) + + sections, errors = parse(inspect.getdoc(f), inspect.signature(f)) + assert len(sections) == 3 + assert not errors + + x, y = sections[1].value + + assert x.name == "x" + assert x.annotation == "int" + assert x.description == "X value." + assert x.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD + assert x.default == 1 + + assert y.name == "y" + assert y.annotation == "int" + assert y.description == "Y value." + assert y.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD + assert y.default is None + + +def test_types_in_signature_and_docstring(): + def f(x: int, y: int) -> int: + """ + The types are written both in the signature and in the docstring. + + Parameters: + x (int): X value. + y (int): Y value. + + Returns: + int: Sum X + Y. + """ + return x + y + + sections, errors = parse(inspect.getdoc(f), inspect.signature(f)) + assert len(sections) == 3 + assert not errors + + +def test_close_sections(): + def f(x, y, z): + """ + Parameters: + x: X. + Parameters: + y: Y. + + Parameters: + z: Z. + Exceptions: + Error2: error. + Exceptions: + Error1: error. + Returns: + 1. + Returns: + 2. + """ + return x + y + z + + sections, errors = parse(inspect.getdoc(f), inspect.signature(f)) + assert len(sections) == 7 + assert len(errors) == 2 # no return type annotations + + +def test_code_blocks(): + def f(s): + """ + This docstring contains a docstring in a code block o_O! + + ```python + \"\"\" + This docstring is contained in another docstring O_o! + + Parameters: + s: A string. + \"\"\" + ``` + """ + return s + + sections, errors = parse(inspect.getdoc(f), inspect.signature(f)) + assert len(sections) == 1 + assert not errors + + +def test_indented_code_block(): + def f(s): + """ + This docstring contains a docstring in a code block o_O! - def test_types_in_signature_and_docstring(self): - def f(x: int, y: int) -> int: - """ - The types are written both in the signature and in the docstring. - - Parameters: - x (int): X value. - y (int): Y value. - - Returns: - int: Sum X + Y. - """ - return x + y - - sections, errors = parse(inspect.getdoc(f), inspect.signature(f)) - assert len(sections) == 3 - assert not errors - - def test_close_sections(self): - def f(x, y, z): - """ - Parameters: - x: X. - Parameters: - y: Y. - - Parameters: - z: Z. - Exceptions: - Error2: error. - Exceptions: - Error1: error. - Returns: - 1. - Returns: - 2. - """ - return x + y + z - - sections, errors = parse(inspect.getdoc(f), inspect.signature(f)) - assert len(sections) == 7 - assert len(errors) == 2 # no return type annotations - - def test_code_blocks(self): - def f(s): - """ - This docstring contains a docstring in a code block o_O! - - ```python \"\"\" This docstring is contained in another docstring O_o! Parameters: s: A string. \"\"\" - ``` - """ - return s - - sections, errors = parse(inspect.getdoc(f), inspect.signature(f)) - assert len(sections) == 1 - assert not errors - - def test_indented_code_block(self): - def f(s): - """ - This docstring contains a docstring in a code block o_O! - - \"\"\" - This docstring is contained in another docstring O_o! - - Parameters: - s: A string. - \"\"\" - """ - return s - - sections, errors = parse(inspect.getdoc(f), inspect.signature(f)) - assert len(sections) == 1 - assert not errors - - def test_extra_parameter(self): - def f(x): - """ - Parameters: - x: Integer. - y: Integer. - """ - return x - - sections, errors = parse(inspect.getdoc(f), inspect.signature(f)) - assert len(sections) == 1 - assert len(errors) == 1 - assert "No type" in errors[0] - - def test_missing_parameter(self): - def f(x, y): - """ - Parameters: - x: Integer. - """ - return x + y - - sections, errors = parse(inspect.getdoc(f), inspect.signature(f)) - assert len(sections) == 1 - assert not errors - - def test_param_line_without_colon(self): - def f(x: int): - """ - Parameters: - x is an integer. - """ - return x - - sections, errors = parse(inspect.getdoc(f), inspect.signature(f)) - assert not sections # getting x fails, so the section is empty and discarded - assert len(errors) == 2 - assert "pair" in errors[0] - assert "Empty" in errors[1] - - def test_admonitions(self): - def f(): - """ - Note: - Hello. - - Note: With title. - Hello again. - - Something: - Something. - """ - - sections, errors = parse(inspect.getdoc(f), inspect.signature(f)) - assert len(sections) == 1 - assert not errors - - def test_invalid_sections(self): - def f(): - """ - Parameters: - Exceptions: - Exceptions: - - Returns: - Note: - - Important: - """ - - sections, errors = parse(inspect.getdoc(f), inspect.signature(f)) - assert len(sections) == 1 - for error in errors[:3]: - assert "Empty" in error - assert "No return type" in errors[3] - assert "Empty" in errors[-1] - - def test_multiple_lines_in_sections_items(self): - def f(p: str, q: str): - """ - Hi. - - Arguments: - p: This argument - has a description - spawning on multiple lines. - - It even has blank lines in it. - Some of these lines - are indented for no reason. - q: - What if the first line is blank? - """ - return p + q - - sections, errors = parse(inspect.getdoc(f), inspect.signature(f)) - assert len(sections) == 2 - assert not errors - - def test_parse_args_kwargs(self): - def f(a, *args, **kwargs): - """ - Arguments: - a: a parameter. - *args: args parameters. - **kwargs: kwargs parameters. - """ - return 1 - - sections, errors = parse(inspect.getdoc(f), inspect.signature(f)) - assert len(sections) == 1 - expected_parameters = {"a": "a parameter.", "*args": "args parameters.", "**kwargs": "kwargs parameters."} - for param in sections[0].value: - assert param.name in expected_parameters - assert expected_parameters[param.name] == param.description - assert not errors + """ + return s + + sections, errors = parse(inspect.getdoc(f), inspect.signature(f)) + assert len(sections) == 1 + assert not errors + + +def test_extra_parameter(): + def f(x): + """ + Parameters: + x: Integer. + y: Integer. + """ + return x + + sections, errors = parse(inspect.getdoc(f), inspect.signature(f)) + assert len(sections) == 1 + assert len(errors) == 1 + assert "No type" in errors[0] + + +def test_missing_parameter(): + def f(x, y): + """ + Parameters: + x: Integer. + """ + return x + y + + sections, errors = parse(inspect.getdoc(f), inspect.signature(f)) + assert len(sections) == 1 + assert not errors + + +def test_param_line_without_colon(): + def f(x: int): + """ + Parameters: + x is an integer. + """ + return x + + sections, errors = parse(inspect.getdoc(f), inspect.signature(f)) + assert not sections # getting x fails, so the section is empty and discarded + assert len(errors) == 2 + assert "pair" in errors[0] + assert "Empty" in errors[1] + + +def test_admonitions(): + def f(): + """ + Note: + Hello. + + Note: With title. + Hello again. + + Something: + Something. + """ + + sections, errors = parse(inspect.getdoc(f), inspect.signature(f)) + assert len(sections) == 1 + assert not errors + + +def test_invalid_sections(): + def f(): + """ + Parameters: + Exceptions: + Exceptions: + + Returns: + Note: + + Important: + """ + + sections, errors = parse(inspect.getdoc(f), inspect.signature(f)) + assert len(sections) == 1 + for error in errors[:3]: + assert "Empty" in error + assert "No return type" in errors[3] + assert "Empty" in errors[-1] + + +def test_multiple_lines_in_sections_items(): + def f(p: str, q: str): + """ + Hi. + + Arguments: + p: This argument + has a description + spawning on multiple lines. + + It even has blank lines in it. + Some of these lines + are indented for no reason. + q: + What if the first line is blank? + """ + return p + q + + sections, errors = parse(inspect.getdoc(f), inspect.signature(f)) + assert len(sections) == 2 + assert len(sections[1].value) == 2 + assert errors + for error in errors: + assert "should be 4 * 2 = 8 spaces, not" in error + + +def test_parse_args_kwargs(): + def f(a, *args, **kwargs): + """ + Arguments: + a: a parameter. + *args: args parameters. + **kwargs: kwargs parameters. + """ + return 1 + + sections, errors = parse(inspect.getdoc(f), inspect.signature(f)) + assert len(sections) == 1 + expected_parameters = {"a": "a parameter.", "*args": "args parameters.", "**kwargs": "kwargs parameters."} + for param in sections[0].value: + assert param.name in expected_parameters + assert expected_parameters[param.name] == param.description + assert not errors + + +def test_different_indentation(): + def f(): + """ + Hello. + + Raises: + StartAt5: this section's items starts with 5 spaces of indentation. + Well indented continuation line. + Badly indented continuation line (will trigger an error). + + Empty lines are preserved, as well as extra-indentation (this line is a code block). + AnyOtherLine: ...starting with exactly 5 spaces is a new item. + AnyLine: ...indented with less than 5 spaces signifies the end of the section. + """ + + sections, errors = parse(inspect.getdoc(f), inspect.signature(f)) + assert len(sections) == 3 + assert len(sections[1].value) == 2 + assert sections[1].value[0].description == ( + "this section's items starts with 5 spaces of indentation.\n" + "Well indented continuation line.\n" + "Badly indented continuation line (will trigger an error).\n" + "\n" + " Empty lines are preserved, as well as extra-indentation (this line is a code block)." + ) + assert sections[2].value == " AnyLine: ...indented with less than 5 spaces signifies the end of the section." + assert len(errors) == 1 + assert "should be 5 * 2 = 10 spaces, not 6" in errors[0]