diff --git a/docstring_parser/numpydoc.py b/docstring_parser/numpydoc.py index eca5233..7965b68 100644 --- a/docstring_parser/numpydoc.py +++ b/docstring_parser/numpydoc.py @@ -39,10 +39,22 @@ def _clean_str(string: str) -> T.Optional[str]: PARAM_KEY_REGEX = re.compile(r"^(?P.*?)(?:\s*:\s*(?P.*?))?$") PARAM_OPTIONAL_REGEX = re.compile(r"(?P.*?)(?:, optional|\(optional\))$") -# numpydoc format has no formal grammar for this, -# but we can make some educated guesses... +# Ideally, default value will be specified in the type declaration, +# for which the following are supported: +# +# copy : bool, default True +# copy : bool, default=True +# copy : bool, default: True +# PARAM_DEFAULT_REGEX = re.compile( - r"(?[\w\-\.]*\w)" + r"(?P.*?)(?:, default|\(default\))(?: | |=| = |= |: |)*(?P.*)$" # pylint: disable=C0301 +) + +# If the default value isn't specified in the type declaration, +# it might be in the description. There isn't any formal grammar for this +# in numpydoc, but we can make some educated guesses. +PARAM_DEFAULT_REGEX_IN_DESC = re.compile( + r"(?(?:['\"]).*?(?:['\"])|[\w\-\.]*\w)" # pylint: disable=C0301 ) RETURN_KEY_REGEX = re.compile(r"^(?:(?P.*?)\s*:\s*)?(?P.*?)$") @@ -131,7 +143,7 @@ class ParamSection(_KVSection): def _parse_item(self, key: str, value: str) -> DocstringParam: match = PARAM_KEY_REGEX.match(key) - arg_name = type_name = is_optional = None + arg_name = type_name = is_optional = default = None if match is not None: arg_name = match.group("name") type_name = match.group("type") @@ -143,9 +155,16 @@ def _parse_item(self, key: str, value: str) -> DocstringParam: else: is_optional = False - default = None - if len(value) > 0: - default_match = PARAM_DEFAULT_REGEX.search(value) + default_match = PARAM_DEFAULT_REGEX.match(type_name) + if default_match is not None: + is_optional = True + type_name = default_match.group("type") + default = default_match.group("value") + + # If the default wasn't specifified in the type declaration, + # try and see if we can find it in the description. + if len(value) > 0 and default is None: + default_match = PARAM_DEFAULT_REGEX_IN_DESC.search(value) if default_match is not None: default = default_match.group("value") @@ -409,8 +428,14 @@ def process_one( elif not head: head = "" - if isinstance(one, DocstringParam) and one.is_optional: - head += ", optional" + # If this is a parameter, check if it's optional. + # If it is and there's a not-None default, include that in the type + # declaration, otherwise just mark it as optional. + if isinstance(one, DocstringParam): + if one.default not in [None, "None"]: + head += f", default={one.default}" + elif one.is_optional or one.default == "None": + head += ", optional" if one.description: body = f"\n{indent}".join([head] + one.description.splitlines()) @@ -510,6 +535,16 @@ def process_sect(name: str, args: T.List[T.Any]): [item for item in docstring.raises or [] if item.args[0] == "warns"], ) + if len(docstring.examples) > 0: + parts.append("") + parts.append("Examples") + parts.append("--------") + for example in docstring.examples: + if example.snippet: + parts.append(example.snippet) + if example.description: + parts.append(example.description) + for meta in docstring.meta: if isinstance( meta, @@ -518,6 +553,7 @@ def process_sect(name: str, args: T.List[T.Any]): DocstringParam, DocstringReturns, DocstringRaises, + DocstringExample, ), ): continue # Already handled diff --git a/docstring_parser/tests/test_numpydoc.py b/docstring_parser/tests/test_numpydoc.py index 2f2ef42..0118a13 100644 --- a/docstring_parser/tests/test_numpydoc.py +++ b/docstring_parser/tests/test_numpydoc.py @@ -3,7 +3,96 @@ import typing as T import pytest -from docstring_parser.numpydoc import compose, parse +from docstring_parser.numpydoc import ( + DEFAULT_SECTIONS, + PARAM_DEFAULT_REGEX, + PARAM_DEFAULT_REGEX_IN_DESC, + PARAM_KEY_REGEX, + PARAM_OPTIONAL_REGEX, + NumpydocParser, + Section, + compose, + parse, +) + + +@pytest.mark.parametrize( + "input_str, expected_name, expected_type", + [ + ("arg_name", "arg_name", None), + ("arg_name : type", "arg_name", "type"), + ("arg_name : type, optional", "arg_name", "type, optional"), + ("arg_name : type, default=10", "arg_name", "type, default=10"), + ], +) +def test_param_key_regex(input_str, expected_name, expected_type): + """Test parsing parameter keys.""" + match = PARAM_KEY_REGEX.match(input_str) + assert match is not None + assert match.group("name") == expected_name + assert match.group("type") == expected_type + + +@pytest.mark.parametrize( + "input_str, expected_match", + [ + ("", False), + ("type", False), + ("type, optional", True), + ], +) +def test_param_optional_regex(input_str, expected_match): + """Test parsing parameter optionality.""" + match = PARAM_OPTIONAL_REGEX.match(input_str) + if expected_match: + assert match is not None + else: + assert match is None + + +@pytest.mark.parametrize( + "input_str, expected_match, default_value", + [ + ("", False, None), + ("type", False, None), + ("type, default=10", True, "10"), + ("type, default='hello'", True, "'hello'"), + ('type, default="world"', True, '"world"'), + ("type, default: 1.5", True, "1.5"), + ("type, default 1.5", True, "1.5"), + ], +) +def test_param_default_regex(input_str, expected_match, default_value): + """Test parsing parameter default values.""" + match = PARAM_DEFAULT_REGEX.match(input_str) + if expected_match: + assert match is not None + assert match.group("type") == "type" + assert match.group("value") == default_value + else: + assert match is None + + +@pytest.mark.parametrize( + "input_str, expected_match, default_value", + [ + ("", False, None), + ("description without default", False, None), + ("description with default, default=10", True, "10"), + ("description with default, Default: 'hello'", True, "'hello'"), + ("description with default, defaults to 'world'", True, "'world'"), + ("description with default, defaults to 1.5", True, "1.5"), + ("description with default, default is 1.5", True, "1.5"), + ], +) +def test_param_default_regex_in_desc(input_str, expected_match, default_value): + """Test parsing parameter default values in descriptions.""" + match = PARAM_DEFAULT_REGEX_IN_DESC.search(input_str) + if expected_match: + assert match is not None + assert match.group("value") == default_value + else: + assert match is None @pytest.mark.parametrize( @@ -269,7 +358,40 @@ def test_meta_with_multiline_description() -> None: """ Parameters ---------- - arg4 : Optional[Dict[str, Any]], optional + arg3 : float, default=1.0 + The third arg. + """, + True, + "float", + "1.0", + ), + ( + """ + Parameters + ---------- + arg4: int, default 1 + The fourth arg. + """, + True, + "int", + "1", + ), + ( + """ + Parameters + ---------- + arg5: str, default: 'hello' + The fifth arg. + """, + True, + "str", + "'hello'", + ), + ( + """ + Parameters + ---------- + arg6 : Optional[Dict[str, Any]], optional The fourth arg. Defaults to None """, True, @@ -280,7 +402,7 @@ def test_meta_with_multiline_description() -> None: """ Parameters ---------- - arg5 : str, optional + arg7 : str, optional The fifth arg. Default: DEFAULT_ARGS """, True, @@ -830,16 +952,17 @@ def test_deprecation( @pytest.mark.parametrize( - "source, expected", + "source, expected, expected_num_metas", [ - ("", ""), - ("\n", ""), - ("Short description", "Short description"), - ("\nShort description\n", "Short description"), - ("\n Short description\n", "Short description"), + ("", "", 0), + ("\n", "", 0), + ("Short description", "Short description", 0), + ("\nShort description\n", "Short description", 0), + ("\n Short description\n", "Short description", 0), ( "Short description\n\nLong description", "Short description\n\nLong description", + 0, ), ( """ @@ -848,6 +971,7 @@ def test_deprecation( Long description """, "Short description\n\nLong description", + 0, ), ( """ @@ -857,10 +981,12 @@ def test_deprecation( Second line """, "Short description\n\nLong description\nSecond line", + 0, ), ( "Short description\nLong description", "Short description\nLong description", + 0, ), ( """ @@ -868,10 +994,12 @@ def test_deprecation( Long description """, "Short description\nLong description", + 0, ), ( "\nShort description\nLong description\n", "Short description\nLong description", + 0, ), ( """ @@ -880,45 +1008,49 @@ def test_deprecation( Second line """, "Short description\nLong description\nSecond line", + 0, ), ( """ Short description - Meta: - ----- - asd + Meta + ---- + asd """, - "Short description\nMeta:\n-----\n asd", + "Short description\n\nMeta\n----\nasd", + 1, ), ( """ Short description Long description - Meta: - ----- - asd + Meta + ---- + asd """, "Short description\n" - "Long description\n" - "Meta:\n" - "-----\n" - " asd", + "Long description\n\n" + "Meta\n" + "----\n" + "asd", + 1, ), ( """ Short description First line Second line - Meta: - ----- - asd + Meta + ---- + asd """, "Short description\n" "First line\n" - " Second line\n" - "Meta:\n" - "-----\n" - " asd", + " Second line\n\n" + "Meta\n" + "----\n" + "asd", + 1, ), ( """ @@ -926,17 +1058,18 @@ def test_deprecation( First line Second line - Meta: - ----- - asd + Meta + ---- + asd """, "Short description\n" "\n" "First line\n" - " Second line\n" - "Meta:\n" - "-----\n" - " asd", + " Second line\n\n" + "Meta\n" + "----\n" + "asd", + 1, ), ( """ @@ -944,124 +1077,128 @@ def test_deprecation( First line Second line - - Meta: - ----- - asd + Meta + ---- + asd """, "Short description\n" "\n" "First line\n" - " Second line\n" - "\n" - "Meta:\n" - "-----\n" - " asd", + " Second line\n\n" + "Meta\n" + "----\n" + "asd", + 1, ), ( """ Short description - Meta: - ----- - asd - 1 - 2 - 3 + Meta + ---- + asd + 1 + 2 + 3 """, - "Short description\n" - "\n" - "Meta:\n" - "-----\n" - " asd\n" - " 1\n" - " 2\n" - " 3", + "Short description\n\n\n" + "Meta\n" + "----\n" + "asd\n" + " 1\n" + " 2\n" + " 3", + 1, ), ( """ Short description - Meta1: - ------ - asd - 1 - 2 - 3 - Meta2: - ------ - herp - Meta3: - ------ - derp + Meta1 + ----- + asd + 1 + 2 + 3 + Meta2 + ----- + herp + Meta3 + ----- + derp """, - "Short description\n" - "\n" - "Meta1:\n" - "------\n" - " asd\n" - " 1\n" - " 2\n" - " 3\n" - "Meta2:\n" - "------\n" - " herp\n" - "Meta3:\n" - "------\n" - " derp", + "Short description\n\n\n" + "Meta1\n" + "-----\n" + "asd\n" + "1\n" + " 2\n" + "3\n\n" + "Meta2\n" + "-----\n" + "herp\n\n" + "Meta3\n" + "-----\n" + "derp", + 3, ), ( """ Short description - Parameters: - ----------- - name - description 1 - priority: int - description 2 - sender: str, optional - description 3 - message: str, optional - description 4, defaults to 'hello' - multiline: str, optional - long description 5, - defaults to 'bye' + Parameters + ---------- + name + description 1 + priority: int + description 2 + sender: str, optional + description 3 + message: str, optional + description 4, defaults to 'hello' + multiline: str, optional + long description 5, + defaults to 'bye' + default_arg: str, default=10 + description 6, defaults to 10 """, - "Short description\n" - "\n" - "Parameters:\n" - "-----------\n" - " name\n" - " description 1\n" - " priority: int\n" - " description 2\n" - " sender: str, optional\n" - " description 3\n" - " message: str, optional\n" - " description 4, defaults to 'hello'\n" - " multiline: str, optional\n" - " long description 5,\n" - " defaults to 'bye'", + "Short description\n\n\n" + "Parameters\n" + "----------\n" + "name\n" + " description 1\n" + "priority : int\n" + " description 2\n" + "sender : str, optional\n" + " description 3\n" + "message : str, default='hello'\n" + " description 4, defaults to 'hello'\n" + "multiline : str, default='bye'\n" + " long description 5,\n" + " defaults to 'bye'\n" + "default_arg : str, default=10\n" + " description 6, defaults to 10", + 6, ), ( """ Short description - Raises: - ------- - ValueError - description + Raises + ------ + ValueError + description """, - "Short description\n" - "Raises:\n" - "-------\n" - " ValueError\n" - " description", + "Short description\n\n" + "Raises\n" + "------\n" + "ValueError\n" + " description", + 1, ), ( """ Description - Examples: + Examples -------- >>> test1a >>> test1b @@ -1072,8 +1209,8 @@ def test_deprecation( desc2a desc2b """, - "Description\n" - "Examples:\n" + "Description\n\n" + "Examples\n" "--------\n" ">>> test1a\n" ">>> test1b\n" @@ -1083,9 +1220,24 @@ def test_deprecation( ">>> test2b\n" "desc2a\n" "desc2b", + 2, ), ], ) -def test_compose(source: str, expected: str) -> None: +def test_compose(source: str, expected: str, expected_num_metas: int) -> None: """Test compose in default mode.""" - assert compose(parse(source)) == expected + + # Test cases use `Meta` and `Meta#`, which aren't included in the defaults. + addtl_sections = [Section(f"Meta{i}", f"meta{i}") for i in range(1, 4)] + [ + Section("Meta", "meta") + ] + parser_w_meta_section = NumpydocParser( + sections=(DEFAULT_SECTIONS + addtl_sections) + ) + docstring = parser_w_meta_section.parse(source) + + # We want to make sure that parse is correctly parsing the docstring and + # not returning the whole thing as a description. + assert len(docstring.meta) == expected_num_metas + + assert compose(docstring) == expected