From 3b52d729546e8a0edd084454d96eaf254e6ec4f3 Mon Sep 17 00:00:00 2001 From: Marc Garcia Date: Sun, 20 Oct 2019 18:45:36 -0500 Subject: [PATCH 01/18] First version of the script with no pandas stuff (some tests will need to be removed) --- numpydoc/tests/test_validate.py | 1448 +++++++++++++++++++++++++++++++ numpydoc/validate.py | 958 ++++++++++++++++++++ 2 files changed, 2406 insertions(+) create mode 100644 numpydoc/tests/test_validate.py create mode 100755 numpydoc/validate.py diff --git a/numpydoc/tests/test_validate.py b/numpydoc/tests/test_validate.py new file mode 100644 index 00000000..b1b5be6d --- /dev/null +++ b/numpydoc/tests/test_validate.py @@ -0,0 +1,1448 @@ +import io +import random +import string +import textwrap + +import numpy as np +import pytest +import validate_docstrings + +import pandas as pd + +validate_one = validate_docstrings.validate_one + + +class GoodDocStrings: + """ + Collection of good doc strings. + + This class contains a lot of docstrings that should pass the validation + script without any errors. + """ + + def plot(self, kind, color="blue", **kwargs): + """ + Generate a plot. + + Render the data in the Series as a matplotlib plot of the + specified kind. + + Parameters + ---------- + kind : str + Kind of matplotlib plot. + color : str, default 'blue' + Color name or rgb code. + **kwargs + These parameters will be passed to the matplotlib plotting + function. + """ + pass + + def swap(self, arr, i, j, *args, **kwargs): + """ + Swap two indicies on an array. + + Parameters + ---------- + arr : list + The list having indexes swapped. + i, j : int + The indexes being swapped. + *args, **kwargs + Extraneous parameters are being permitted. + """ + pass + + def sample(self): + """ + Generate and return a random number. + + The value is sampled from a continuous uniform distribution between + 0 and 1. + + Returns + ------- + float + Random number generated. + """ + return random.random() + + def random_letters(self): + """ + Generate and return a sequence of random letters. + + The length of the returned string is also random, and is also + returned. + + Returns + ------- + length : int + Length of the returned string. + letters : str + String of random letters. + """ + length = random.randint(1, 10) + letters = "".join(random.sample(string.ascii_lowercase, length)) + return length, letters + + def sample_values(self): + """ + Generate an infinite sequence of random numbers. + + The values are sampled from a continuous uniform distribution between + 0 and 1. + + Yields + ------ + float + Random number generated. + """ + while True: + yield random.random() + + def head(self): + """ + Return the first 5 elements of the Series. + + This function is mainly useful to preview the values of the + Series without displaying the whole of it. + + Returns + ------- + Series + Subset of the original series with the 5 first values. + + See Also + -------- + Series.tail : Return the last 5 elements of the Series. + Series.iloc : Return a slice of the elements in the Series, + which can also be used to return the first or last n. + """ + return self.iloc[:5] + + def head1(self, n=5): + """ + Return the first elements of the Series. + + This function is mainly useful to preview the values of the + Series without displaying the whole of it. + + Parameters + ---------- + n : int + Number of values to return. + + Returns + ------- + Series + Subset of the original series with the n first values. + + See Also + -------- + tail : Return the last n elements of the Series. + + Examples + -------- + >>> s = pd.Series(['Ant', 'Bear', 'Cow', 'Dog', 'Falcon']) + >>> s.head() + 0 Ant + 1 Bear + 2 Cow + 3 Dog + 4 Falcon + dtype: object + + With the `n` parameter, we can change the number of returned rows: + + >>> s.head(n=3) + 0 Ant + 1 Bear + 2 Cow + dtype: object + """ + return self.iloc[:n] + + def contains(self, pat, case=True, na=np.nan): + """ + Return whether each value contains `pat`. + + In this case, we are illustrating how to use sections, even + if the example is simple enough and does not require them. + + Parameters + ---------- + pat : str + Pattern to check for within each element. + case : bool, default True + Whether check should be done with case sensitivity. + na : object, default np.nan + Fill value for missing data. + + Examples + -------- + >>> s = pd.Series(['Antelope', 'Lion', 'Zebra', np.nan]) + >>> s.str.contains(pat='a') + 0 False + 1 False + 2 True + 3 NaN + dtype: object + + **Case sensitivity** + + With `case_sensitive` set to `False` we can match `a` with both + `a` and `A`: + + >>> s.str.contains(pat='a', case=False) + 0 True + 1 False + 2 True + 3 NaN + dtype: object + + **Missing values** + + We can fill missing values in the output using the `na` parameter: + + >>> s.str.contains(pat='a', na=False) + 0 False + 1 False + 2 True + 3 False + dtype: bool + """ + pass + + def mode(self, axis, numeric_only): + """ + Ensure reST directives don't affect checks for leading periods. + + Parameters + ---------- + axis : str + Sentence ending in period, followed by single directive. + + .. versionchanged:: 0.1.2 + + numeric_only : bool + Sentence ending in period, followed by multiple directives. + + .. versionadded:: 0.1.2 + .. deprecated:: 0.00.0 + A multiline description, + which spans another line. + """ + pass + + def good_imports(self): + """ + Ensure import other than numpy and pandas are fine. + + Examples + -------- + This example does not import pandas or import numpy. + >>> import datetime + >>> datetime.MAXYEAR + 9999 + """ + pass + + def no_returns(self): + """ + Say hello and have no returns. + """ + pass + + def empty_returns(self): + """ + Say hello and always return None. + + Since this function never returns a value, this + docstring doesn't need a return section. + """ + + def say_hello(): + return "Hello World!" + + say_hello() + if True: + return + else: + return None + + def multiple_variables_on_one_line(self, matrix, a, b, i, j): + """ + Swap two values in a matrix. + + Parameters + ---------- + matrix : list of list + A double list that represents a matrix. + a, b : int + The indicies of the first value. + i, j : int + The indicies of the second value. + """ + pass + + +class BadGenericDocStrings: + """Everything here has a bad docstring + """ + + def func(self): + + """Some function. + + With several mistakes in the docstring. + + It has a blank like after the signature `def func():`. + + The text 'Some function' should go in the line after the + opening quotes of the docstring, not in the same line. + + There is a blank line between the docstring and the first line + of code `foo = 1`. + + The closing quotes should be in the next line, not in this one.""" + + foo = 1 + bar = 2 + return foo + bar + + def astype(self, dtype): + """ + Casts Series type. + + Verb in third-person of the present simple, should be infinitive. + """ + pass + + def astype1(self, dtype): + """ + Method to cast Series type. + + Does not start with verb. + """ + pass + + def astype2(self, dtype): + """ + Cast Series type + + Missing dot at the end. + """ + pass + + def astype3(self, dtype): + """ + Cast Series type from its current type to the new type defined in + the parameter dtype. + + Summary is too verbose and doesn't fit in a single line. + """ + pass + + def two_linebreaks_between_sections(self, foo): + """ + Test linebreaks message GL03. + + Note 2 blank lines before parameters section. + + + Parameters + ---------- + foo : str + Description of foo parameter. + """ + pass + + def linebreak_at_end_of_docstring(self, foo): + """ + Test linebreaks message GL03. + + Note extra blank line at end of docstring. + + Parameters + ---------- + foo : str + Description of foo parameter. + + """ + pass + + def plot(self, kind, **kwargs): + """ + Generate a plot. + + Render the data in the Series as a matplotlib plot of the + specified kind. + + Note the blank line between the parameters title and the first + parameter. Also, note that after the name of the parameter `kind` + and before the colon, a space is missing. + + Also, note that the parameter descriptions do not start with a + capital letter, and do not finish with a dot. + + Finally, the `**kwargs` parameter is missing. + + Parameters + ---------- + + kind: str + kind of matplotlib plot + """ + pass + + def method(self, foo=None, bar=None): + """ + A sample DataFrame method. + + Do not import numpy and pandas. + + Try to use meaningful data, when it makes the example easier + to understand. + + Try to avoid positional arguments like in `df.method(1)`. They + can be alright if previously defined with a meaningful name, + like in `present_value(interest_rate)`, but avoid them otherwise. + + When presenting the behavior with different parameters, do not place + all the calls one next to the other. Instead, add a short sentence + explaining what the example shows. + + Examples + -------- + >>> import numpy as np + >>> import pandas as pd + >>> df = pd.DataFrame(np.ones((3, 3)), + ... columns=('a', 'b', 'c')) + >>> df.all(1) + 0 True + 1 True + 2 True + dtype: bool + >>> df.all(bool_only=True) + Series([], dtype: bool) + """ + pass + + def private_classes(self): + """ + This mentions NDFrame, which is not correct. + """ + + def unknown_section(self): + """ + This section has an unknown section title. + + Unknown Section + --------------- + This should raise an error in the validation. + """ + + def sections_in_wrong_order(self): + """ + This docstring has the sections in the wrong order. + + Parameters + ---------- + name : str + This section is in the right position. + + Examples + -------- + >>> print('So far Examples is good, as it goes before Parameters') + So far Examples is good, as it goes before Parameters + + See Also + -------- + function : This should generate an error, as See Also needs to go + before Examples. + """ + + def deprecation_in_wrong_order(self): + """ + This docstring has the deprecation warning in the wrong order. + + This is the extended summary. The correct order should be + summary, deprecation warning, extended summary. + + .. deprecated:: 1.0 + This should generate an error as it needs to go before + extended summary. + """ + + def method_wo_docstrings(self): + pass + + def directives_without_two_colons(self, first, second): + """ + Ensure reST directives have trailing colons. + + Parameters + ---------- + first : str + Sentence ending in period, followed by single directive w/o colons. + + .. versionchanged 0.1.2 + + second : bool + Sentence ending in period, followed by multiple directives w/o + colons. + + .. versionadded 0.1.2 + .. deprecated 0.00.0 + + """ + pass + + +class BadSummaries: + def wrong_line(self): + """Exists on the wrong line""" + pass + + def no_punctuation(self): + """ + Has the right line but forgets punctuation + """ + pass + + def no_capitalization(self): + """ + provides a lowercase summary. + """ + pass + + def no_infinitive(self): + """ + Started with a verb that is not infinitive. + """ + + def multi_line(self): + """ + Extends beyond one line + which is not correct. + """ + + def two_paragraph_multi_line(self): + """ + Extends beyond one line + which is not correct. + + Extends beyond one line, which in itself is correct but the + previous short summary should still be an issue. + """ + + +class BadParameters: + """ + Everything here has a problem with its Parameters section. + """ + + def missing_params(self, kind, **kwargs): + """ + Lacks kwargs in Parameters. + + Parameters + ---------- + kind : str + Foo bar baz. + """ + + def bad_colon_spacing(self, kind): + """ + Has bad spacing in the type line. + + Parameters + ---------- + kind: str + Needs a space after kind. + """ + + def no_description_period(self, kind): + """ + Forgets to add a period to the description. + + Parameters + ---------- + kind : str + Doesn't end with a dot + """ + + def no_description_period_with_directive(self, kind): + """ + Forgets to add a period, and also includes a directive. + + Parameters + ---------- + kind : str + Doesn't end with a dot + + .. versionadded:: 0.00.0 + """ + + def no_description_period_with_directives(self, kind): + """ + Forgets to add a period, and also includes multiple directives. + + Parameters + ---------- + kind : str + Doesn't end with a dot + + .. versionchanged:: 0.00.0 + .. deprecated:: 0.00.0 + """ + + def parameter_capitalization(self, kind): + """ + Forgets to capitalize the description. + + Parameters + ---------- + kind : str + this is not capitalized. + """ + + def blank_lines(self, kind): + """ + Adds a blank line after the section header. + + Parameters + ---------- + + kind : str + Foo bar baz. + """ + pass + + def integer_parameter(self, kind): + """ + Uses integer instead of int. + + Parameters + ---------- + kind : integer + Foo bar baz. + """ + pass + + def string_parameter(self, kind): + """ + Uses string instead of str. + + Parameters + ---------- + kind : string + Foo bar baz. + """ + pass + + def boolean_parameter(self, kind): + """ + Uses boolean instead of bool. + + Parameters + ---------- + kind : boolean + Foo bar baz. + """ + pass + + def list_incorrect_parameter_type(self, kind): + """ + Uses list of boolean instead of list of bool. + + Parameters + ---------- + kind : list of boolean, integer, float or string + Foo bar baz. + """ + pass + + def bad_parameter_spacing(self, a, b): + """ + The parameters on the same line have an extra space between them. + + Parameters + ---------- + a, b : int + Foo bar baz. + """ + pass + + +class BadReturns: + def return_not_documented(self): + """ + Lacks section for Returns + """ + return "Hello world!" + + def yield_not_documented(self): + """ + Lacks section for Yields + """ + yield "Hello world!" + + def no_type(self): + """ + Returns documented but without type. + + Returns + ------- + Some value. + """ + return "Hello world!" + + def no_description(self): + """ + Provides type but no descrption. + + Returns + ------- + str + """ + return "Hello world!" + + def no_punctuation(self): + """ + Provides type and description but no period. + + Returns + ------- + str + A nice greeting + """ + return "Hello world!" + + def named_single_return(self): + """ + Provides name but returns only one value. + + Returns + ------- + s : str + A nice greeting. + """ + return "Hello world!" + + def no_capitalization(self): + """ + Forgets capitalization in return values description. + + Returns + ------- + foo : str + The first returned string. + bar : str + the second returned string. + """ + return "Hello", "World!" + + def no_period_multi(self): + """ + Forgets period in return values description. + + Returns + ------- + foo : str + The first returned string + bar : str + The second returned string. + """ + return "Hello", "World!" + + +class BadSeeAlso: + def desc_no_period(self): + """ + Return the first 5 elements of the Series. + + See Also + -------- + Series.tail : Return the last 5 elements of the Series. + Series.iloc : Return a slice of the elements in the Series, + which can also be used to return the first or last n + """ + pass + + def desc_first_letter_lowercase(self): + """ + Return the first 5 elements of the Series. + + See Also + -------- + Series.tail : return the last 5 elements of the Series. + Series.iloc : Return a slice of the elements in the Series, + which can also be used to return the first or last n. + """ + pass + + def prefix_pandas(self): + """ + Have `pandas` prefix in See Also section. + + See Also + -------- + pandas.Series.rename : Alter Series index labels or name. + DataFrame.head : The first `n` rows of the caller object. + """ + pass + + +class BadExamples: + def unused_import(self): + """ + Examples + -------- + >>> import pandas as pdf + >>> df = pd.DataFrame(np.ones((3, 3)), columns=('a', 'b', 'c')) + """ + pass + + def missing_whitespace_around_arithmetic_operator(self): + """ + Examples + -------- + >>> 2+5 + 7 + """ + pass + + def indentation_is_not_a_multiple_of_four(self): + """ + Examples + -------- + >>> if 2 + 5: + ... pass + """ + pass + + def missing_whitespace_after_comma(self): + """ + Examples + -------- + >>> df = pd.DataFrame(np.ones((3,3)),columns=('a','b', 'c')) + """ + pass + + +class TestValidator: + def _import_path(self, klass=None, func=None): + """ + Build the required import path for tests in this module. + + Parameters + ---------- + klass : str + Class name of object in module. + func : str + Function name of object in module. + + Returns + ------- + str + Import path of specified object in this module + """ + base_path = "scripts.tests.test_validate_docstrings" + + if klass: + base_path = ".".join([base_path, klass]) + + if func: + base_path = ".".join([base_path, func]) + + return base_path + + def test_good_class(self, capsys): + errors = validate_one(self._import_path(klass="GoodDocStrings"))["errors"] + assert isinstance(errors, list) + assert not errors + + @pytest.mark.parametrize( + "func", + [ + "plot", + "swap", + "sample", + "random_letters", + "sample_values", + "head", + "head1", + "contains", + "mode", + "good_imports", + "no_returns", + "empty_returns", + "multiple_variables_on_one_line", + ], + ) + def test_good_functions(self, capsys, func): + errors = validate_one(self._import_path(klass="GoodDocStrings", func=func))[ + "errors" + ] + assert isinstance(errors, list) + assert not errors + + def test_bad_class(self, capsys): + errors = validate_one(self._import_path(klass="BadGenericDocStrings"))["errors"] + assert isinstance(errors, list) + assert errors + + @pytest.mark.parametrize( + "func", + [ + "func", + "astype", + "astype1", + "astype2", + "astype3", + "plot", + "method", + "private_classes", + "directives_without_two_colons", + ], + ) + def test_bad_generic_functions(self, capsys, func): + errors = validate_one( + self._import_path(klass="BadGenericDocStrings", func=func) # noqa:F821 + )["errors"] + assert isinstance(errors, list) + assert errors + + @pytest.mark.parametrize( + "klass,func,msgs", + [ + # See Also tests + ( + "BadGenericDocStrings", + "private_classes", + ( + "Private classes (NDFrame) should not be mentioned in public " + "docstrings", + ), + ), + ( + "BadGenericDocStrings", + "unknown_section", + ('Found unknown section "Unknown Section".',), + ), + ( + "BadGenericDocStrings", + "sections_in_wrong_order", + ( + "Sections are in the wrong order. Correct order is: Parameters, " + "See Also, Examples", + ), + ), + ( + "BadGenericDocStrings", + "deprecation_in_wrong_order", + ("Deprecation warning should precede extended summary",), + ), + ( + "BadGenericDocStrings", + "directives_without_two_colons", + ( + "reST directives ['versionchanged', 'versionadded', " + "'deprecated'] must be followed by two colons", + ), + ), + ( + "BadSeeAlso", + "desc_no_period", + ('Missing period at end of description for See Also "Series.iloc"',), + ), + ( + "BadSeeAlso", + "desc_first_letter_lowercase", + ('should be capitalized for See Also "Series.tail"',), + ), + # Summary tests + ( + "BadSummaries", + "wrong_line", + ("should start in the line immediately after the opening quotes",), + ), + ("BadSummaries", "no_punctuation", ("Summary does not end with a period",)), + ( + "BadSummaries", + "no_capitalization", + ("Summary does not start with a capital letter",), + ), + ( + "BadSummaries", + "no_capitalization", + ("Summary must start with infinitive verb",), + ), + ("BadSummaries", "multi_line", ("Summary should fit in a single line",)), + ( + "BadSummaries", + "two_paragraph_multi_line", + ("Summary should fit in a single line",), + ), + # Parameters tests + ( + "BadParameters", + "missing_params", + ("Parameters {**kwargs} not documented",), + ), + ( + "BadParameters", + "bad_colon_spacing", + ( + 'Parameter "kind" requires a space before the colon ' + "separating the parameter name and type", + ), + ), + ( + "BadParameters", + "no_description_period", + ('Parameter "kind" description should finish with "."',), + ), + ( + "BadParameters", + "no_description_period_with_directive", + ('Parameter "kind" description should finish with "."',), + ), + ( + "BadParameters", + "parameter_capitalization", + ('Parameter "kind" description should start with a capital letter',), + ), + ( + "BadParameters", + "integer_parameter", + ('Parameter "kind" type should use "int" instead of "integer"',), + ), + ( + "BadParameters", + "string_parameter", + ('Parameter "kind" type should use "str" instead of "string"',), + ), + ( + "BadParameters", + "boolean_parameter", + ('Parameter "kind" type should use "bool" instead of "boolean"',), + ), + ( + "BadParameters", + "list_incorrect_parameter_type", + ('Parameter "kind" type should use "bool" instead of "boolean"',), + ), + ( + "BadParameters", + "list_incorrect_parameter_type", + ('Parameter "kind" type should use "int" instead of "integer"',), + ), + ( + "BadParameters", + "list_incorrect_parameter_type", + ('Parameter "kind" type should use "str" instead of "string"',), + ), + ( + "BadParameters", + "bad_parameter_spacing", + ("Parameters {b} not documented", "Unknown parameters { b}"), + ), + pytest.param( + "BadParameters", + "blank_lines", + ("No error yet?",), + marks=pytest.mark.xfail, + ), + # Returns tests + ("BadReturns", "return_not_documented", ("No Returns section found",)), + ("BadReturns", "yield_not_documented", ("No Yields section found",)), + pytest.param("BadReturns", "no_type", ("foo",), marks=pytest.mark.xfail), + ("BadReturns", "no_description", ("Return value has no description",)), + ( + "BadReturns", + "no_punctuation", + ('Return value description should finish with "."',), + ), + ( + "BadReturns", + "named_single_return", + ( + "The first line of the Returns section should contain only the " + "type, unless multiple values are being returned", + ), + ), + ( + "BadReturns", + "no_capitalization", + ("Return value description should start with a capital letter",), + ), + ( + "BadReturns", + "no_period_multi", + ('Return value description should finish with "."',), + ), + # Examples tests + ( + "BadGenericDocStrings", + "method", + ("Do not import numpy, as it is imported automatically",), + ), + ( + "BadGenericDocStrings", + "method", + ("Do not import pandas, as it is imported automatically",), + ), + ( + "BadGenericDocStrings", + "method_wo_docstrings", + ("The object does not have a docstring",), + ), + # See Also tests + ( + "BadSeeAlso", + "prefix_pandas", + ( + "pandas.Series.rename in `See Also` section " + "does not need `pandas` prefix", + ), + ), + # Examples tests + ( + "BadExamples", + "unused_import", + ("flake8 error: F401 'pandas as pdf' imported but unused",), + ), + ( + "BadExamples", + "indentation_is_not_a_multiple_of_four", + ("flake8 error: E111 indentation is not a multiple of four",), + ), + ( + "BadExamples", + "missing_whitespace_around_arithmetic_operator", + ( + "flake8 error: " + "E226 missing whitespace around arithmetic operator", + ), + ), + ( + "BadExamples", + "missing_whitespace_after_comma", + ("flake8 error: E231 missing whitespace after ',' (3 times)",), + ), + ( + "BadGenericDocStrings", + "two_linebreaks_between_sections", + ( + "Double line break found; please use only one blank line to " + "separate sections or paragraphs, and do not leave blank lines " + "at the end of docstrings", + ), + ), + ( + "BadGenericDocStrings", + "linebreak_at_end_of_docstring", + ( + "Double line break found; please use only one blank line to " + "separate sections or paragraphs, and do not leave blank lines " + "at the end of docstrings", + ), + ), + ], + ) + def test_bad_docstrings(self, capsys, klass, func, msgs): + result = validate_one(self._import_path(klass=klass, func=func)) + for msg in msgs: + assert msg in " ".join(err[1] for err in result["errors"]) + + def test_validate_all_ignore_deprecated(self, monkeypatch): + monkeypatch.setattr( + validate_docstrings, + "validate_one", + lambda func_name: { + "docstring": "docstring1", + "errors": [ + ("ER01", "err desc"), + ("ER02", "err desc"), + ("ER03", "err desc"), + ], + "warnings": [], + "examples_errors": "", + "deprecated": True, + }, + ) + result = validate_docstrings.validate_all(prefix=None, ignore_deprecated=True) + assert len(result) == 0 + + +class TestApiItems: + @property + def api_doc(self): + return io.StringIO( + textwrap.dedent( + """ + .. currentmodule:: itertools + + Itertools + --------- + + Infinite + ~~~~~~~~ + + .. autosummary:: + + cycle + count + + Finite + ~~~~~~ + + .. autosummary:: + + chain + + .. currentmodule:: random + + Random + ------ + + All + ~~~ + + .. autosummary:: + + seed + randint + """ + ) + ) + + @pytest.mark.parametrize( + "idx,name", + [ + (0, "itertools.cycle"), + (1, "itertools.count"), + (2, "itertools.chain"), + (3, "random.seed"), + (4, "random.randint"), + ], + ) + def test_item_name(self, idx, name): + result = list(validate_docstrings.get_api_items(self.api_doc)) + assert result[idx][0] == name + + @pytest.mark.parametrize( + "idx,func", + [(0, "cycle"), (1, "count"), (2, "chain"), (3, "seed"), (4, "randint")], + ) + def test_item_function(self, idx, func): + result = list(validate_docstrings.get_api_items(self.api_doc)) + assert callable(result[idx][1]) + assert result[idx][1].__name__ == func + + @pytest.mark.parametrize( + "idx,section", + [ + (0, "Itertools"), + (1, "Itertools"), + (2, "Itertools"), + (3, "Random"), + (4, "Random"), + ], + ) + def test_item_section(self, idx, section): + result = list(validate_docstrings.get_api_items(self.api_doc)) + assert result[idx][2] == section + + @pytest.mark.parametrize( + "idx,subsection", + [(0, "Infinite"), (1, "Infinite"), (2, "Finite"), (3, "All"), (4, "All")], + ) + def test_item_subsection(self, idx, subsection): + result = list(validate_docstrings.get_api_items(self.api_doc)) + assert result[idx][3] == subsection + + +class TestDocstringClass: + @pytest.mark.parametrize( + "name, expected_obj", + [ + ("pandas.isnull", pd.isnull), + ("pandas.DataFrame", pd.DataFrame), + ("pandas.Series.sum", pd.Series.sum), + ], + ) + def test_resolves_class_name(self, name, expected_obj): + d = validate_docstrings.Docstring(name) + assert d.obj is expected_obj + + @pytest.mark.parametrize("invalid_name", ["panda", "panda.DataFrame"]) + def test_raises_for_invalid_module_name(self, invalid_name): + msg = 'No module can be imported from "{}"'.format(invalid_name) + with pytest.raises(ImportError, match=msg): + validate_docstrings.Docstring(invalid_name) + + @pytest.mark.parametrize( + "invalid_name", ["pandas.BadClassName", "pandas.Series.bad_method_name"] + ) + def test_raises_for_invalid_attribute_name(self, invalid_name): + name_components = invalid_name.split(".") + obj_name, invalid_attr_name = name_components[-2], name_components[-1] + msg = "'{}' has no attribute '{}'".format(obj_name, invalid_attr_name) + with pytest.raises(AttributeError, match=msg): + validate_docstrings.Docstring(invalid_name) + + @pytest.mark.parametrize( + "name", ["pandas.Series.str.isdecimal", "pandas.Series.str.islower"] + ) + def test_encode_content_write_to_file(self, name): + # GH25466 + docstr = validate_docstrings.Docstring(name).validate_pep8() + # the list of pep8 errors should be empty + assert not list(docstr) + + +class TestMainFunction: + def test_exit_status_for_validate_one(self, monkeypatch): + monkeypatch.setattr( + validate_docstrings, + "validate_one", + lambda func_name: { + "docstring": "docstring1", + "errors": [ + ("ER01", "err desc"), + ("ER02", "err desc"), + ("ER03", "err desc"), + ], + "warnings": [], + "examples_errors": "", + }, + ) + exit_status = validate_docstrings.main( + func_name="docstring1", + prefix=None, + errors=[], + output_format="default", + ignore_deprecated=False, + ) + assert exit_status == 0 + + def test_exit_status_errors_for_validate_all(self, monkeypatch): + monkeypatch.setattr( + validate_docstrings, + "validate_all", + lambda prefix, ignore_deprecated=False: { + "docstring1": { + "errors": [ + ("ER01", "err desc"), + ("ER02", "err desc"), + ("ER03", "err desc"), + ], + "file": "module1.py", + "file_line": 23, + }, + "docstring2": { + "errors": [("ER04", "err desc"), ("ER05", "err desc")], + "file": "module2.py", + "file_line": 925, + }, + }, + ) + exit_status = validate_docstrings.main( + func_name=None, + prefix=None, + errors=[], + output_format="default", + ignore_deprecated=False, + ) + assert exit_status == 5 + + def test_no_exit_status_noerrors_for_validate_all(self, monkeypatch): + monkeypatch.setattr( + validate_docstrings, + "validate_all", + lambda prefix, ignore_deprecated=False: { + "docstring1": {"errors": [], "warnings": [("WN01", "warn desc")]}, + "docstring2": {"errors": []}, + }, + ) + exit_status = validate_docstrings.main( + func_name=None, + prefix=None, + errors=[], + output_format="default", + ignore_deprecated=False, + ) + assert exit_status == 0 + + def test_exit_status_for_validate_all_json(self, monkeypatch): + print("EXECUTED") + monkeypatch.setattr( + validate_docstrings, + "validate_all", + lambda prefix, ignore_deprecated=False: { + "docstring1": { + "errors": [ + ("ER01", "err desc"), + ("ER02", "err desc"), + ("ER03", "err desc"), + ] + }, + "docstring2": {"errors": [("ER04", "err desc"), ("ER05", "err desc")]}, + }, + ) + exit_status = validate_docstrings.main( + func_name=None, + prefix=None, + errors=[], + output_format="json", + ignore_deprecated=False, + ) + assert exit_status == 0 + + def test_errors_param_filters_errors(self, monkeypatch): + monkeypatch.setattr( + validate_docstrings, + "validate_all", + lambda prefix, ignore_deprecated=False: { + "Series.foo": { + "errors": [ + ("ER01", "err desc"), + ("ER02", "err desc"), + ("ER03", "err desc"), + ], + "file": "series.py", + "file_line": 142, + }, + "DataFrame.bar": { + "errors": [("ER01", "err desc"), ("ER02", "err desc")], + "file": "frame.py", + "file_line": 598, + }, + "Series.foobar": { + "errors": [("ER01", "err desc")], + "file": "series.py", + "file_line": 279, + }, + }, + ) + exit_status = validate_docstrings.main( + func_name=None, + prefix=None, + errors=["ER01"], + output_format="default", + ignore_deprecated=False, + ) + assert exit_status == 3 + + exit_status = validate_docstrings.main( + func_name=None, + prefix=None, + errors=["ER03"], + output_format="default", + ignore_deprecated=False, + ) + assert exit_status == 1 diff --git a/numpydoc/validate.py b/numpydoc/validate.py new file mode 100755 index 00000000..37ecb153 --- /dev/null +++ b/numpydoc/validate.py @@ -0,0 +1,958 @@ +#!/usr/bin/env python +""" +Analyze docstrings to detect errors. + +If no argument is provided, it does a quick check of docstrings and returns +a csv with all API functions and results of basic checks. + +If a function or method is provided in the form "pandas.function", +"pandas.module.class.method", etc. a list of all errors in the docstring for +the specified function or method. + +Usage:: + $ ./validate_docstrings.py + $ ./validate_docstrings.py pandas.DataFrame.head +""" +import argparse +import ast +import collections +import doctest +import functools +import glob +import importlib +import inspect +import json +import pydoc +import re +import sys +import textwrap +try: + from io import StringIO +except ImportError: + from cStringIO import StringIO +import numpydoc.docscrape + + +DIRECTIVES = ["versionadded", "versionchanged", "deprecated"] +DIRECTIVE_PATTERN = re.compile(rf"^\s*\.\. ({'|'.join(DIRECTIVES)})(?!::)", re.I | re.M) +ALLOWED_SECTIONS = [ + "Parameters", + "Attributes", + "Methods", + "Returns", + "Yields", + "Other Parameters", + "Raises", + "Warns", + "See Also", + "Notes", + "References", + "Examples", +] +ERROR_MSGS = { + "GL01": "Docstring text (summary) should start in the line immediately " + "after the opening quotes (not in the same line, or leaving a " + "blank line in between)", + "GL02": "Closing quotes should be placed in the line after the last text " + "in the docstring (do not close the quotes in the same line as " + "the text, or leave a blank line between the last text and the " + "quotes)", + "GL03": "Double line break found; please use only one blank line to " + "separate sections or paragraphs, and do not leave blank lines " + "at the end of docstrings", + "GL05": 'Tabs found at the start of line "{line_with_tabs}", please use ' + "whitespace only", + "GL06": 'Found unknown section "{section}". Allowed sections are: ' + "{allowed_sections}", + "GL07": "Sections are in the wrong order. Correct order is: {correct_sections}", + "GL08": "The object does not have a docstring", + "GL09": "Deprecation warning should precede extended summary", + "GL10": "reST directives {directives} must be followed by two colons", + "SS01": "No summary found (a short summary in a single line should be " + "present at the beginning of the docstring)", + "SS02": "Summary does not start with a capital letter", + "SS03": "Summary does not end with a period", + "SS04": "Summary contains heading whitespaces", + "SS05": "Summary must start with infinitive verb, not third person " + '(e.g. use "Generate" instead of "Generates")', + "SS06": "Summary should fit in a single line", + "ES01": "No extended summary found", + "PR01": "Parameters {missing_params} not documented", + "PR02": "Unknown parameters {unknown_params}", + "PR03": "Wrong parameters order. Actual: {actual_params}. " + "Documented: {documented_params}", + "PR04": 'Parameter "{param_name}" has no type', + "PR05": 'Parameter "{param_name}" type should not finish with "."', + "PR06": 'Parameter "{param_name}" type should use "{right_type}" instead ' + 'of "{wrong_type}"', + "PR07": 'Parameter "{param_name}" has no description', + "PR08": 'Parameter "{param_name}" description should start with a ' + "capital letter", + "PR09": 'Parameter "{param_name}" description should finish with "."', + "PR10": 'Parameter "{param_name}" requires a space before the colon ' + "separating the parameter name and type", + "RT01": "No Returns section found", + "RT02": "The first line of the Returns section should contain only the " + "type, unless multiple values are being returned", + "RT03": "Return value has no description", + "RT04": "Return value description should start with a capital letter", + "RT05": 'Return value description should finish with "."', + "YD01": "No Yields section found", + "SA01": "See Also section not found", + "SA02": "Missing period at end of description for See Also " + '"{reference_name}" reference', + "SA03": "Description should be capitalized for See Also " + '"{reference_name}" reference', + "SA04": 'Missing description for See Also "{reference_name}" reference', + "SA05": "{reference_name} in `See Also` section does not need `pandas` " + "prefix, use {right_reference} instead.", + "EX01": "No examples section found", + "EX02": "Examples do not pass tests:\n{doctest_log}", +} + + +def error(code, **kwargs): + """ + Return a tuple with the error code and the message with variables replaced. + + This is syntactic sugar so instead of: + - `('EX02', ERROR_MSGS['EX02'].format(doctest_log=log))` + + We can simply use: + - `error('EX02', doctest_log=log)` + + Parameters + ---------- + code : str + Error code. + **kwargs + Values for the variables in the error messages + + Returns + ------- + code : str + Error code. + message : str + Error message with variables replaced. + """ + return (code, ERROR_MSGS[code].format(**kwargs)) + + +def get_api_items(api_doc_fd): + """ + Yield information about all public API items. + + Parse api.rst file from the documentation, and extract all the functions, + methods, classes, attributes... This should include all pandas public API. + + Parameters + ---------- + api_doc_fd : file descriptor + A file descriptor of the API documentation page, containing the table + of contents with all the public API. + + Yields + ------ + name : str + The name of the object (e.g. 'pandas.Series.str.upper). + func : function + The object itself. In most cases this will be a function or method, + but it can also be classes, properties, cython objects... + section : str + The name of the section in the API page where the object item is + located. + subsection : str + The name of the subsection in the API page where the object item is + located. + """ + current_module = "" # Use to be pandas, not sure if this will fail now + previous_line = current_section = current_subsection = "" + position = None + for line in api_doc_fd: + line = line.strip() + if len(line) == len(previous_line): + if set(line) == set("-"): + current_section = previous_line + continue + if set(line) == set("~"): + current_subsection = previous_line + continue + + if line.startswith(".. currentmodule::"): + current_module = line.replace(".. currentmodule::", "").strip() + continue + + if line == ".. autosummary::": + position = "autosummary" + continue + + if position == "autosummary": + if line == "": + position = "items" + continue + + if position == "items": + if line == "": + position = None + continue + item = line.strip() + func = importlib.import_module(current_module) + for part in item.split("."): + func = getattr(func, part) + + yield ( + ".".join([current_module, item]), + func, + current_section, + current_subsection, + ) + + previous_line = line + + +class Docstring: + def __init__(self, name): + self.name = name + obj = self._load_obj(name) + self.obj = obj + self.code_obj = self._to_original_callable(obj) + self.raw_doc = obj.__doc__ or "" + self.clean_doc = pydoc.getdoc(obj) + self.doc = numpydoc.docscrape.NumpyDocString(self.clean_doc) + + def __len__(self): + return len(self.raw_doc) + + @staticmethod + def _load_obj(name): + """ + Import Python object from its name as string. + + Parameters + ---------- + name : str + Object name to import (e.g. pandas.Series.str.upper) + + Returns + ------- + object + Python object that can be a class, method, function... + + Examples + -------- + >>> Docstring._load_obj('pandas.Series') + + """ + for maxsplit in range(1, name.count(".") + 1): + # TODO when py3 only replace by: module, *func_parts = ... + func_name_split = name.rsplit(".", maxsplit) + module = func_name_split[0] + func_parts = func_name_split[1:] + try: + obj = importlib.import_module(module) + except ImportError: + pass + else: + continue + + if "obj" not in locals(): + raise ImportError("No module can be imported " 'from "{}"'.format(name)) + + for part in func_parts: + obj = getattr(obj, part) + return obj + + @staticmethod + def _to_original_callable(obj): + """ + Find the Python object that contains the source code of the object. + + This is useful to find the place in the source code (file and line + number) where a docstring is defined. It does not currently work for + all cases, but it should help find some (properties...). + """ + while True: + if inspect.isfunction(obj) or inspect.isclass(obj): + f = inspect.getfile(obj) + if f.startswith("<") and f.endswith(">"): + return None + return obj + if inspect.ismethod(obj): + obj = obj.__func__ + elif isinstance(obj, functools.partial): + obj = obj.func + elif isinstance(obj, property): + obj = obj.fget + else: + return None + + @property + def type(self): + return type(self.obj).__name__ + + @property + def is_function_or_method(self): + # TODO(py27): remove ismethod + return inspect.isfunction(self.obj) or inspect.ismethod(self.obj) + + @property + def source_file_name(self): + """ + File name where the object is implemented (e.g. pandas/core/frame.py). + """ + try: + fname = inspect.getsourcefile(self.code_obj) + except TypeError: + # In some cases the object is something complex like a cython + # object that can't be easily introspected. An it's better to + # return the source code file of the object as None, than crash + pass + else: + return fname + + @property + def source_file_def_line(self): + """ + Number of line where the object is defined in its file. + """ + try: + return inspect.getsourcelines(self.code_obj)[-1] + except (OSError, TypeError): + # In some cases the object is something complex like a cython + # object that can't be easily introspected. An it's better to + # return the line number as None, than crash + pass + + @property + def start_blank_lines(self): + i = None + if self.raw_doc: + for i, row in enumerate(self.raw_doc.split("\n")): + if row.strip(): + break + return i + + @property + def end_blank_lines(self): + i = None + if self.raw_doc: + for i, row in enumerate(reversed(self.raw_doc.split("\n"))): + if row.strip(): + break + return i + + @property + def double_blank_lines(self): + prev = True + for row in self.raw_doc.split("\n"): + if not prev and not row.strip(): + return True + prev = row.strip() + return False + + @property + def section_titles(self): + sections = [] + self.doc._doc.reset() + while not self.doc._doc.eof(): + content = self.doc._read_to_next_section() + if ( + len(content) > 1 + and len(content[0]) == len(content[1]) + and set(content[1]) == {"-"} + ): + sections.append(content[0]) + return sections + + @property + def summary(self): + return " ".join(self.doc["Summary"]) + + @property + def num_summary_lines(self): + return len(self.doc["Summary"]) + + @property + def extended_summary(self): + if not self.doc["Extended Summary"] and len(self.doc["Summary"]) > 1: + return " ".join(self.doc["Summary"]) + return " ".join(self.doc["Extended Summary"]) + + @property + def needs_summary(self): + return not (bool(self.summary) and bool(self.extended_summary)) + + @property + def doc_parameters(self): + parameters = collections.OrderedDict() + for names, type_, desc in self.doc["Parameters"]: + for name in names.split(", "): + parameters[name] = (type_, "".join(desc)) + return parameters + + @property + def signature_parameters(self): + if inspect.isclass(self.obj): + if hasattr(self.obj, "_accessors") and ( + self.name.split(".")[-1] in self.obj._accessors + ): + # accessor classes have a signature but don't want to show this + return tuple() + try: + sig = inspect.getfullargspec(self.obj) + except (TypeError, ValueError): + # Some objects, mainly in C extensions do not support introspection + # of the signature + return tuple() + params = sig.args + if sig.varargs: + params.append("*" + sig.varargs) + if sig.varkw: + params.append("**" + sig.varkw) + params = tuple(params) + if params and params[0] in ("self", "cls"): + return params[1:] + return params + + @property + def parameter_mismatches(self): + errs = [] + signature_params = self.signature_parameters + doc_params = tuple(self.doc_parameters) + missing = set(signature_params) - set(doc_params) + if missing: + errs.append(error("PR01", missing_params=str(missing))) + extra = set(doc_params) - set(signature_params) + if extra: + errs.append(error("PR02", unknown_params=str(extra))) + if ( + not missing + and not extra + and signature_params != doc_params + and not (not signature_params and not doc_params) + ): + errs.append( + error( + "PR03", actual_params=signature_params, documented_params=doc_params + ) + ) + + return errs + + @property + def correct_parameters(self): + return not bool(self.parameter_mismatches) + + @property + def directives_without_two_colons(self): + return DIRECTIVE_PATTERN.findall(self.raw_doc) + + def parameter_type(self, param): + return self.doc_parameters[param][0] + + def parameter_desc(self, param): + desc = self.doc_parameters[param][1] + # Find and strip out any sphinx directives + for directive in DIRECTIVES: + full_directive = ".. {}".format(directive) + if full_directive in desc: + # Only retain any description before the directive + desc = desc[: desc.index(full_directive)] + return desc + + @property + def see_also(self): + result = collections.OrderedDict() + for funcs, desc in self.doc["See Also"]: + for func, _ in funcs: + result[func] = "".join(desc) + + return result + + @property + def examples(self): + return self.doc["Examples"] + + @property + def returns(self): + return self.doc["Returns"] + + @property + def yields(self): + return self.doc["Yields"] + + @property + def method_source(self): + try: + source = inspect.getsource(self.obj) + except TypeError: + return "" + return textwrap.dedent(source) + + @property + def method_returns_something(self): + """ + Check if the docstrings method can return something. + + Bare returns, returns valued None and returns from nested functions are + disconsidered. + + Returns + ------- + bool + Whether the docstrings method can return something. + """ + + def get_returns_not_on_nested_functions(node): + returns = [node] if isinstance(node, ast.Return) else [] + for child in ast.iter_child_nodes(node): + # Ignore nested functions and its subtrees. + if not isinstance(child, ast.FunctionDef): + child_returns = get_returns_not_on_nested_functions(child) + returns.extend(child_returns) + return returns + + tree = ast.parse(self.method_source).body + if tree: + returns = get_returns_not_on_nested_functions(tree[0]) + return_values = [r.value for r in returns] + # Replace NameConstant nodes valued None for None. + for i, v in enumerate(return_values): + if isinstance(v, ast.NameConstant) and v.value is None: + return_values[i] = None + return any(return_values) + else: + return False + + @property + def first_line_ends_in_dot(self): + if self.doc: + return self.doc.split("\n")[0][-1] == "." + + @property + def deprecated(self): + return ".. deprecated:: " in (self.summary + self.extended_summary) + + @property + def examples_errors(self): + flags = doctest.NORMALIZE_WHITESPACE | doctest.IGNORE_EXCEPTION_DETAIL + finder = doctest.DocTestFinder() + runner = doctest.DocTestRunner(optionflags=flags) + error_msgs = "" + for test in finder.find(self.raw_doc, self.name): + f = StringIO() + runner.run(test, out=f.write) + error_msgs += f.getvalue() + return error_msgs + + @property + def examples_source_code(self): + lines = doctest.DocTestParser().get_examples(self.raw_doc) + return [line.source for line in lines] + + +def get_validation_data(doc): + """ + Validate the docstring. + + Parameters + ---------- + doc : Docstring + A Docstring object with the given function name. + + Returns + ------- + tuple + errors : list of tuple + Errors occurred during validation. + warnings : list of tuple + Warnings occurred during validation. + examples_errs : str + Examples usage displayed along the error, otherwise empty string. + + Notes + ----- + The errors codes are defined as: + - First two characters: Section where the error happens: + * GL: Global (no section, like section ordering errors) + * SS: Short summary + * ES: Extended summary + * PR: Parameters + * RT: Returns + * YD: Yields + * RS: Raises + * WN: Warns + * SA: See Also + * NT: Notes + * RF: References + * EX: Examples + - Last two characters: Numeric error code inside the section + + For example, EX02 is the second codified error in the Examples section + (which in this case is assigned to examples that do not pass the tests). + + The error codes, their corresponding error messages, and the details on how + they are validated, are not documented more than in the source code of this + function. + """ + + errs = [] + wrns = [] + if not doc.raw_doc: + errs.append(error("GL08")) + return errs, wrns, "" + + if doc.start_blank_lines != 1: + errs.append(error("GL01")) + if doc.end_blank_lines != 1: + errs.append(error("GL02")) + if doc.double_blank_lines: + errs.append(error("GL03")) + for line in doc.raw_doc.splitlines(): + if re.match("^ *\t", line): + errs.append(error("GL05", line_with_tabs=line.lstrip())) + + unexpected_sections = [ + section for section in doc.section_titles if section not in ALLOWED_SECTIONS + ] + for section in unexpected_sections: + errs.append( + error("GL06", section=section, allowed_sections=", ".join(ALLOWED_SECTIONS)) + ) + + correct_order = [ + section for section in ALLOWED_SECTIONS if section in doc.section_titles + ] + if correct_order != doc.section_titles: + errs.append(error("GL07", correct_sections=", ".join(correct_order))) + + if doc.deprecated and not doc.extended_summary.startswith(".. deprecated:: "): + errs.append(error("GL09")) + + directives_without_two_colons = doc.directives_without_two_colons + if directives_without_two_colons: + errs.append(error("GL10", directives=directives_without_two_colons)) + + if not doc.summary: + errs.append(error("SS01")) + else: + if not doc.summary[0].isupper(): + errs.append(error("SS02")) + if doc.summary[-1] != ".": + errs.append(error("SS03")) + if doc.summary != doc.summary.lstrip(): + errs.append(error("SS04")) + elif doc.is_function_or_method and doc.summary.split(" ")[0][-1] == "s": + errs.append(error("SS05")) + if doc.num_summary_lines > 1: + errs.append(error("SS06")) + + if not doc.extended_summary: + wrns.append(("ES01", "No extended summary found")) + + # PR01: Parameters not documented + # PR02: Unknown parameters + # PR03: Wrong parameters order + errs += doc.parameter_mismatches + + for param in doc.doc_parameters: + if not param.startswith("*"): # Check can ignore var / kwargs + if not doc.parameter_type(param): + if ":" in param: + errs.append(error("PR10", param_name=param.split(":")[0])) + else: + errs.append(error("PR04", param_name=param)) + else: + if doc.parameter_type(param)[-1] == ".": + errs.append(error("PR05", param_name=param)) + common_type_errors = [ + ("integer", "int"), + ("boolean", "bool"), + ("string", "str"), + ] + for wrong_type, right_type in common_type_errors: + if wrong_type in doc.parameter_type(param): + errs.append( + error( + "PR06", + param_name=param, + right_type=right_type, + wrong_type=wrong_type, + ) + ) + if not doc.parameter_desc(param): + errs.append(error("PR07", param_name=param)) + else: + if not doc.parameter_desc(param)[0].isupper(): + errs.append(error("PR08", param_name=param)) + if doc.parameter_desc(param)[-1] != ".": + errs.append(error("PR09", param_name=param)) + + if doc.is_function_or_method: + if not doc.returns: + if doc.method_returns_something: + errs.append(error("RT01")) + else: + if len(doc.returns) == 1 and doc.returns[0].name: + errs.append(error("RT02")) + for name_or_type, type_, desc in doc.returns: + if not desc: + errs.append(error("RT03")) + else: + desc = " ".join(desc) + if not desc[0].isupper(): + errs.append(error("RT04")) + if not desc.endswith("."): + errs.append(error("RT05")) + + if not doc.yields and "yield" in doc.method_source: + errs.append(error("YD01")) + + if not doc.see_also: + wrns.append(error("SA01")) + else: + for rel_name, rel_desc in doc.see_also.items(): + if rel_desc: + if not rel_desc.endswith("."): + errs.append(error("SA02", reference_name=rel_name)) + if not rel_desc[0].isupper(): + errs.append(error("SA03", reference_name=rel_name)) + else: + errs.append(error("SA04", reference_name=rel_name)) + if rel_name.startswith("pandas."): + errs.append( + error( + "SA05", + reference_name=rel_name, + right_reference=rel_name[len("pandas.") :], + ) + ) + + examples_errs = "" + if not doc.examples: + wrns.append(error("EX01")) + else: + examples_errs = doc.examples_errors + if examples_errs: + errs.append(error("EX02", doctest_log=examples_errs)) + return errs, wrns, examples_errs + + +def validate_one(func_name): + """ + Validate the docstring for the given func_name + + Parameters + ---------- + func_name : function + Function whose docstring will be evaluated (e.g. pandas.read_csv). + + Returns + ------- + dict + A dictionary containing all the information obtained from validating + the docstring. + """ + doc = Docstring(func_name) + errs, wrns, examples_errs = get_validation_data(doc) + return { + "type": doc.type, + "docstring": doc.clean_doc, + "deprecated": doc.deprecated, + "file": doc.source_file_name, + "file_line": doc.source_file_def_line, + "errors": errs, + "warnings": wrns, + "examples_errors": examples_errs, + } + + +def validate_all(api_path, prefix, ignore_deprecated=False): + """ + Execute the validation of all docstrings, and return a dict with the + results. + + Parameters + ---------- + api_path : str + Path where the public API is defined. For example ``doc/source/api.rst`` + or ``doc/source/reference/*.rst``. The docstrings to analyze will be + obtained from the autosummary sections. + prefix : str or None + If provided, only the docstrings that start with this pattern will be + validated. If None, all docstrings will be validated. + ignore_deprecated: bool, default False + If True, deprecated objects are ignored when validating docstrings. + + Returns + ------- + dict + A dictionary with an item for every function/method... containing + all the validation information. + """ + result = {} + seen = {} + + # functions from the API docs + api_items = [] + for api_doc_fname in glob.glob(api_path): + with open(api_doc_fname) as f: + api_items += list(get_api_items(f)) + for func_name, func_obj, section, subsection in api_items: + if prefix and not func_name.startswith(prefix): + continue + doc_info = validate_one(func_name) + if ignore_deprecated and doc_info["deprecated"]: + continue + result[func_name] = doc_info + + shared_code_key = doc_info["file"], doc_info["file_line"] + shared_code = seen.get(shared_code_key, "") + result[func_name].update( + { + "in_api": True, + "section": section, + "subsection": subsection, + "shared_code_with": shared_code, + } + ) + + seen[shared_code_key] = func_name + + return result + + +def main(func_name, prefix, errors, output_format, ignore_deprecated): + def header(title, width=80, char="#"): + full_line = char * width + side_len = (width - len(title) - 2) // 2 + adj = "" if len(title) % 2 == 0 else " " + title_line = "{side} {title}{adj} {side}".format( + side=char * side_len, title=title, adj=adj + ) + + return "\n{full_line}\n{title_line}\n{full_line}\n\n".format( + full_line=full_line, title_line=title_line + ) + + exit_status = 0 + if func_name is None: + result = validate_all(prefix, ignore_deprecated) + + if output_format == "json": + output = json.dumps(result) + else: + if output_format == "default": + output_format = "{text}\n" + elif output_format == "azure": + output_format = ( + "##vso[task.logissue type=error;" + "sourcepath={path};" + "linenumber={row};" + "code={code};" + "]{text}\n" + ) + else: + raise ValueError('Unknown output_format "{}"'.format(output_format)) + + output = "" + for name, res in result.items(): + for err_code, err_desc in res["errors"]: + # The script would be faster if instead of filtering the + # errors after validating them, it didn't validate them + # initially. But that would complicate the code too much + if errors and err_code not in errors: + continue + exit_status += 1 + output += output_format.format( + name=name, + path=res["file"], + row=res["file_line"], + code=err_code, + text="{}: {}".format(name, err_desc), + ) + + sys.stdout.write(output) + + else: + result = validate_one(func_name) + sys.stderr.write(header("Docstring ({})".format(func_name))) + sys.stderr.write("{}\n".format(result["docstring"])) + sys.stderr.write(header("Validation")) + if result["errors"]: + sys.stderr.write("{} Errors found:\n".format(len(result["errors"]))) + for err_code, err_desc in result["errors"]: + # Failing examples are printed at the end + if err_code == "EX02": + sys.stderr.write("\tExamples do not pass tests\n") + continue + sys.stderr.write("\t{}\n".format(err_desc)) + if result["warnings"]: + sys.stderr.write("{} Warnings found:\n".format(len(result["warnings"]))) + for wrn_code, wrn_desc in result["warnings"]: + sys.stderr.write("\t{}\n".format(wrn_desc)) + + if not result["errors"]: + sys.stderr.write('Docstring for "{}" correct. :)\n'.format(func_name)) + + if result["examples_errors"]: + sys.stderr.write(header("Doctests")) + sys.stderr.write(result["examples_errors"]) + + return exit_status + + +if __name__ == "__main__": + format_opts = "default", "json", "azure" + func_help = ( + "function or method to validate (e.g. pandas.DataFrame.head) " + "if not provided, all docstrings are validated and returned " + "as JSON" + ) + argparser = argparse.ArgumentParser(description="validate pandas docstrings") + argparser.add_argument("function", nargs="?", default=None, help=func_help) + argparser.add_argument( + "--format", + default="default", + choices=format_opts, + help="format of the output when validating " + "multiple docstrings (ignored when validating one)." + "It can be {}".format(str(format_opts)[1:-1]), + ) + argparser.add_argument( + "--prefix", + default=None, + help="pattern for the " + "docstring names, in order to decide which ones " + 'will be validated. A prefix "pandas.Series.str.' + "will make the script validate all the docstrings" + "of methods starting by this pattern. It is " + "ignored if parameter function is provided", + ) + argparser.add_argument( + "--errors", + default=None, + help="comma separated " + "list of error codes to validate. By default it " + "validates all errors (ignored when validating " + "a single docstring)", + ) + argparser.add_argument( + "--ignore_deprecated", + default=False, + action="store_true", + help="if this flag is set, " + "deprecated objects are ignored when validating " + "all docstrings", + ) + + args = argparser.parse_args() + sys.exit( + main( + args.function, + args.prefix, + args.errors.split(",") if args.errors else None, + args.format, + args.ignore_deprecated, + ) + ) From e4ccb18f94a3635f38a45defc7ca6fb452647da2 Mon Sep 17 00:00:00 2001 From: Marc Garcia Date: Sun, 20 Oct 2019 22:50:27 -0500 Subject: [PATCH 02/18] Updating tests --- numpydoc/tests/test_validate.py | 217 +++++++------------------------- numpydoc/validate.py | 10 +- 2 files changed, 51 insertions(+), 176 deletions(-) diff --git a/numpydoc/tests/test_validate.py b/numpydoc/tests/test_validate.py index b1b5be6d..babcc4f3 100644 --- a/numpydoc/tests/test_validate.py +++ b/numpydoc/tests/test_validate.py @@ -2,14 +2,11 @@ import random import string import textwrap - -import numpy as np import pytest -import validate_docstrings +import numpydoc.validate -import pandas as pd -validate_one = validate_docstrings.validate_one +validate_one = numpydoc.validate.validate_one class GoodDocStrings: @@ -144,26 +141,18 @@ def head1(self, n=5): Examples -------- - >>> s = pd.Series(['Ant', 'Bear', 'Cow', 'Dog', 'Falcon']) - >>> s.head() - 0 Ant - 1 Bear - 2 Cow - 3 Dog - 4 Falcon - dtype: object + >>> s = 10 + >>> s + 10 With the `n` parameter, we can change the number of returned rows: - >>> s.head(n=3) - 0 Ant - 1 Bear - 2 Cow - dtype: object + >>> s + 1 + 11 """ return self.iloc[:n] - def contains(self, pat, case=True, na=np.nan): + def contains(self, pat, case=True, na=float('NaN')): """ Return whether each value contains `pat`. @@ -181,36 +170,24 @@ def contains(self, pat, case=True, na=np.nan): Examples -------- - >>> s = pd.Series(['Antelope', 'Lion', 'Zebra', np.nan]) - >>> s.str.contains(pat='a') - 0 False - 1 False - 2 True - 3 NaN - dtype: object + >>> s = 25 + >>> s + 25 **Case sensitivity** With `case_sensitive` set to `False` we can match `a` with both `a` and `A`: - >>> s.str.contains(pat='a', case=False) - 0 True - 1 False - 2 True - 3 NaN - dtype: object + >>> s + 1 + 26 **Missing values** We can fill missing values in the output using the `na` parameter: - >>> s.str.contains(pat='a', na=False) - 0 False - 1 False - 2 True - 3 False - dtype: bool + >>> s * 2 + 50 """ pass @@ -396,44 +373,6 @@ def plot(self, kind, **kwargs): """ pass - def method(self, foo=None, bar=None): - """ - A sample DataFrame method. - - Do not import numpy and pandas. - - Try to use meaningful data, when it makes the example easier - to understand. - - Try to avoid positional arguments like in `df.method(1)`. They - can be alright if previously defined with a meaningful name, - like in `present_value(interest_rate)`, but avoid them otherwise. - - When presenting the behavior with different parameters, do not place - all the calls one next to the other. Instead, add a short sentence - explaining what the example shows. - - Examples - -------- - >>> import numpy as np - >>> import pandas as pd - >>> df = pd.DataFrame(np.ones((3, 3)), - ... columns=('a', 'b', 'c')) - >>> df.all(1) - 0 True - 1 True - 2 True - dtype: bool - >>> df.all(bool_only=True) - Series([], dtype: bool) - """ - pass - - def private_classes(self): - """ - This mentions NDFrame, which is not correct. - """ - def unknown_section(self): """ This section has an unknown section title. @@ -849,7 +788,7 @@ def _import_path(self, klass=None, func=None): str Import path of specified object in this module """ - base_path = "scripts.tests.test_validate_docstrings" + base_path = "numpydoc.tests.test_validate" if klass: base_path = ".".join([base_path, klass]) @@ -903,8 +842,6 @@ def test_bad_class(self, capsys): "astype2", "astype3", "plot", - "method", - "private_classes", "directives_without_two_colons", ], ) @@ -919,14 +856,6 @@ def test_bad_generic_functions(self, capsys, func): "klass,func,msgs", [ # See Also tests - ( - "BadGenericDocStrings", - "private_classes", - ( - "Private classes (NDFrame) should not be mentioned in public " - "docstrings", - ), - ), ( "BadGenericDocStrings", "unknown_section", @@ -990,7 +919,7 @@ def test_bad_generic_functions(self, capsys, func): ( "BadParameters", "missing_params", - ("Parameters {**kwargs} not documented",), + ("Parameters {'**kwargs'} not documented",), ), ( "BadParameters", @@ -1048,7 +977,7 @@ def test_bad_generic_functions(self, capsys, func): ( "BadParameters", "bad_parameter_spacing", - ("Parameters {b} not documented", "Unknown parameters { b}"), + ("Parameters {'b'} not documented", "Unknown parameters {' b'}"), ), pytest.param( "BadParameters", @@ -1085,16 +1014,6 @@ def test_bad_generic_functions(self, capsys, func): ('Return value description should finish with "."',), ), # Examples tests - ( - "BadGenericDocStrings", - "method", - ("Do not import numpy, as it is imported automatically",), - ), - ( - "BadGenericDocStrings", - "method", - ("Do not import pandas, as it is imported automatically",), - ), ( "BadGenericDocStrings", "method_wo_docstrings", @@ -1110,29 +1029,6 @@ def test_bad_generic_functions(self, capsys, func): ), ), # Examples tests - ( - "BadExamples", - "unused_import", - ("flake8 error: F401 'pandas as pdf' imported but unused",), - ), - ( - "BadExamples", - "indentation_is_not_a_multiple_of_four", - ("flake8 error: E111 indentation is not a multiple of four",), - ), - ( - "BadExamples", - "missing_whitespace_around_arithmetic_operator", - ( - "flake8 error: " - "E226 missing whitespace around arithmetic operator", - ), - ), - ( - "BadExamples", - "missing_whitespace_after_comma", - ("flake8 error: E231 missing whitespace after ',' (3 times)",), - ), ( "BadGenericDocStrings", "two_linebreaks_between_sections", @@ -1160,7 +1056,7 @@ def test_bad_docstrings(self, capsys, klass, func, msgs): def test_validate_all_ignore_deprecated(self, monkeypatch): monkeypatch.setattr( - validate_docstrings, + numpydoc.validate, "validate_one", lambda func_name: { "docstring": "docstring1", @@ -1174,7 +1070,7 @@ def test_validate_all_ignore_deprecated(self, monkeypatch): "deprecated": True, }, ) - result = validate_docstrings.validate_all(prefix=None, ignore_deprecated=True) + result = numpydoc.validate.validate_all('api.rst', prefix=None, ignore_deprecated=True) assert len(result) == 0 @@ -1231,7 +1127,7 @@ def api_doc(self): ], ) def test_item_name(self, idx, name): - result = list(validate_docstrings.get_api_items(self.api_doc)) + result = list(numpydoc.validate.get_api_items(self.api_doc)) assert result[idx][0] == name @pytest.mark.parametrize( @@ -1239,7 +1135,7 @@ def test_item_name(self, idx, name): [(0, "cycle"), (1, "count"), (2, "chain"), (3, "seed"), (4, "randint")], ) def test_item_function(self, idx, func): - result = list(validate_docstrings.get_api_items(self.api_doc)) + result = list(numpydoc.validate.get_api_items(self.api_doc)) assert callable(result[idx][1]) assert result[idx][1].__name__ == func @@ -1254,7 +1150,7 @@ def test_item_function(self, idx, func): ], ) def test_item_section(self, idx, section): - result = list(validate_docstrings.get_api_items(self.api_doc)) + result = list(numpydoc.validate.get_api_items(self.api_doc)) assert result[idx][2] == section @pytest.mark.parametrize( @@ -1262,28 +1158,16 @@ def test_item_section(self, idx, section): [(0, "Infinite"), (1, "Infinite"), (2, "Finite"), (3, "All"), (4, "All")], ) def test_item_subsection(self, idx, subsection): - result = list(validate_docstrings.get_api_items(self.api_doc)) + result = list(numpydoc.validate.get_api_items(self.api_doc)) assert result[idx][3] == subsection class TestDocstringClass: - @pytest.mark.parametrize( - "name, expected_obj", - [ - ("pandas.isnull", pd.isnull), - ("pandas.DataFrame", pd.DataFrame), - ("pandas.Series.sum", pd.Series.sum), - ], - ) - def test_resolves_class_name(self, name, expected_obj): - d = validate_docstrings.Docstring(name) - assert d.obj is expected_obj - @pytest.mark.parametrize("invalid_name", ["panda", "panda.DataFrame"]) def test_raises_for_invalid_module_name(self, invalid_name): msg = 'No module can be imported from "{}"'.format(invalid_name) with pytest.raises(ImportError, match=msg): - validate_docstrings.Docstring(invalid_name) + numpydoc.validate.Docstring(invalid_name) @pytest.mark.parametrize( "invalid_name", ["pandas.BadClassName", "pandas.Series.bad_method_name"] @@ -1293,22 +1177,13 @@ def test_raises_for_invalid_attribute_name(self, invalid_name): obj_name, invalid_attr_name = name_components[-2], name_components[-1] msg = "'{}' has no attribute '{}'".format(obj_name, invalid_attr_name) with pytest.raises(AttributeError, match=msg): - validate_docstrings.Docstring(invalid_name) - - @pytest.mark.parametrize( - "name", ["pandas.Series.str.isdecimal", "pandas.Series.str.islower"] - ) - def test_encode_content_write_to_file(self, name): - # GH25466 - docstr = validate_docstrings.Docstring(name).validate_pep8() - # the list of pep8 errors should be empty - assert not list(docstr) + numpydoc.validate.Docstring(invalid_name) class TestMainFunction: def test_exit_status_for_validate_one(self, monkeypatch): monkeypatch.setattr( - validate_docstrings, + numpydoc.validate, "validate_one", lambda func_name: { "docstring": "docstring1", @@ -1321,7 +1196,7 @@ def test_exit_status_for_validate_one(self, monkeypatch): "examples_errors": "", }, ) - exit_status = validate_docstrings.main( + exit_status = numpydoc.validate.main( func_name="docstring1", prefix=None, errors=[], @@ -1332,9 +1207,9 @@ def test_exit_status_for_validate_one(self, monkeypatch): def test_exit_status_errors_for_validate_all(self, monkeypatch): monkeypatch.setattr( - validate_docstrings, + numpydoc.validate, "validate_all", - lambda prefix, ignore_deprecated=False: { + lambda api_path, prefix, ignore_deprecated=False: { "docstring1": { "errors": [ ("ER01", "err desc"), @@ -1351,8 +1226,8 @@ def test_exit_status_errors_for_validate_all(self, monkeypatch): }, }, ) - exit_status = validate_docstrings.main( - func_name=None, + exit_status = numpydoc.validate.main( + func_name='api.rst', prefix=None, errors=[], output_format="default", @@ -1362,15 +1237,15 @@ def test_exit_status_errors_for_validate_all(self, monkeypatch): def test_no_exit_status_noerrors_for_validate_all(self, monkeypatch): monkeypatch.setattr( - validate_docstrings, + numpydoc.validate, "validate_all", - lambda prefix, ignore_deprecated=False: { + lambda api_path, prefix, ignore_deprecated=False: { "docstring1": {"errors": [], "warnings": [("WN01", "warn desc")]}, "docstring2": {"errors": []}, }, ) - exit_status = validate_docstrings.main( - func_name=None, + exit_status = numpydoc.validate.main( + func_name='api.rst', prefix=None, errors=[], output_format="default", @@ -1381,9 +1256,9 @@ def test_no_exit_status_noerrors_for_validate_all(self, monkeypatch): def test_exit_status_for_validate_all_json(self, monkeypatch): print("EXECUTED") monkeypatch.setattr( - validate_docstrings, + numpydoc.validate, "validate_all", - lambda prefix, ignore_deprecated=False: { + lambda api_path, prefix, ignore_deprecated=False: { "docstring1": { "errors": [ ("ER01", "err desc"), @@ -1394,8 +1269,8 @@ def test_exit_status_for_validate_all_json(self, monkeypatch): "docstring2": {"errors": [("ER04", "err desc"), ("ER05", "err desc")]}, }, ) - exit_status = validate_docstrings.main( - func_name=None, + exit_status = numpydoc.validate.main( + func_name='api.rst', prefix=None, errors=[], output_format="json", @@ -1405,9 +1280,9 @@ def test_exit_status_for_validate_all_json(self, monkeypatch): def test_errors_param_filters_errors(self, monkeypatch): monkeypatch.setattr( - validate_docstrings, + numpydoc.validate, "validate_all", - lambda prefix, ignore_deprecated=False: { + lambda api_path, prefix, ignore_deprecated=False: { "Series.foo": { "errors": [ ("ER01", "err desc"), @@ -1429,8 +1304,8 @@ def test_errors_param_filters_errors(self, monkeypatch): }, }, ) - exit_status = validate_docstrings.main( - func_name=None, + exit_status = numpydoc.validate.main( + func_name='api.rst', prefix=None, errors=["ER01"], output_format="default", @@ -1438,8 +1313,8 @@ def test_errors_param_filters_errors(self, monkeypatch): ) assert exit_status == 3 - exit_status = validate_docstrings.main( - func_name=None, + exit_status = numpydoc.validate.main( + func_name='api.rst', prefix=None, errors=["ER03"], output_format="default", diff --git a/numpydoc/validate.py b/numpydoc/validate.py index 37ecb153..eb9b2162 100755 --- a/numpydoc/validate.py +++ b/numpydoc/validate.py @@ -836,8 +836,8 @@ def header(title, width=80, char="#"): ) exit_status = 0 - if func_name is None: - result = validate_all(prefix, ignore_deprecated) + if func_name.endswith('.rst'): + result = validate_all(func_name, prefix, ignore_deprecated) if output_format == "json": output = json.dumps(result) @@ -906,11 +906,11 @@ def header(title, width=80, char="#"): format_opts = "default", "json", "azure" func_help = ( "function or method to validate (e.g. pandas.DataFrame.head) " - "if not provided, all docstrings are validated and returned " - "as JSON" + "or rst file(s) with the public API autosummaries " + "(e.g. pandas/doc/source/reference/*.rst)" ) argparser = argparse.ArgumentParser(description="validate pandas docstrings") - argparser.add_argument("function", nargs="?", default=None, help=func_help) + argparser.add_argument("function", help=func_help) argparser.add_argument( "--format", default="default", From 51aaebc10f6693801a1d6da50cc05c8f7dbcb703 Mon Sep 17 00:00:00 2001 From: Marc Garcia Date: Mon, 21 Oct 2019 14:13:44 -0500 Subject: [PATCH 03/18] Removing the part of the validation that gets the public API objects, and receiving them as a parameter --- numpydoc/validate.py | 286 +++---------------------------------------- 1 file changed, 14 insertions(+), 272 deletions(-) mode change 100755 => 100644 numpydoc/validate.py diff --git a/numpydoc/validate.py b/numpydoc/validate.py old mode 100755 new mode 100644 index eb9b2162..e2880650 --- a/numpydoc/validate.py +++ b/numpydoc/validate.py @@ -2,29 +2,16 @@ """ Analyze docstrings to detect errors. -If no argument is provided, it does a quick check of docstrings and returns -a csv with all API functions and results of basic checks. - -If a function or method is provided in the form "pandas.function", -"pandas.module.class.method", etc. a list of all errors in the docstring for -the specified function or method. - -Usage:: - $ ./validate_docstrings.py - $ ./validate_docstrings.py pandas.DataFrame.head +Call ``validate_all(list_of_object_names_to_validate)`` to get a dictionary +with all the detected errors. """ -import argparse import ast import collections import doctest -import functools -import glob import importlib import inspect -import json import pydoc import re -import sys import textwrap try: from io import StringIO @@ -34,7 +21,8 @@ DIRECTIVES = ["versionadded", "versionchanged", "deprecated"] -DIRECTIVE_PATTERN = re.compile(rf"^\s*\.\. ({'|'.join(DIRECTIVES)})(?!::)", re.I | re.M) +DIRECTIVE_PATTERN = re.compile(rf"^\s*\.\. ({'|'.join(DIRECTIVES)})(?!::)", + re.I | re.M) ALLOWED_SECTIONS = [ "Parameters", "Attributes", @@ -138,84 +126,13 @@ def error(code, **kwargs): return (code, ERROR_MSGS[code].format(**kwargs)) -def get_api_items(api_doc_fd): - """ - Yield information about all public API items. - - Parse api.rst file from the documentation, and extract all the functions, - methods, classes, attributes... This should include all pandas public API. - - Parameters - ---------- - api_doc_fd : file descriptor - A file descriptor of the API documentation page, containing the table - of contents with all the public API. - - Yields - ------ - name : str - The name of the object (e.g. 'pandas.Series.str.upper). - func : function - The object itself. In most cases this will be a function or method, - but it can also be classes, properties, cython objects... - section : str - The name of the section in the API page where the object item is - located. - subsection : str - The name of the subsection in the API page where the object item is - located. - """ - current_module = "" # Use to be pandas, not sure if this will fail now - previous_line = current_section = current_subsection = "" - position = None - for line in api_doc_fd: - line = line.strip() - if len(line) == len(previous_line): - if set(line) == set("-"): - current_section = previous_line - continue - if set(line) == set("~"): - current_subsection = previous_line - continue - - if line.startswith(".. currentmodule::"): - current_module = line.replace(".. currentmodule::", "").strip() - continue - - if line == ".. autosummary::": - position = "autosummary" - continue - - if position == "autosummary": - if line == "": - position = "items" - continue - - if position == "items": - if line == "": - position = None - continue - item = line.strip() - func = importlib.import_module(current_module) - for part in item.split("."): - func = getattr(func, part) - - yield ( - ".".join([current_module, item]), - func, - current_section, - current_subsection, - ) - - previous_line = line - - class Docstring: + # TODO Can all this class be merged into NumpyDocString? def __init__(self, name): self.name = name obj = self._load_obj(name) self.obj = obj - self.code_obj = self._to_original_callable(obj) + self.code_obj = inspect.unwrap(obj) self.raw_doc = obj.__doc__ or "" self.clean_doc = pydoc.getdoc(obj) self.doc = numpydoc.docscrape.NumpyDocString(self.clean_doc) @@ -240,8 +157,8 @@ def _load_obj(name): Examples -------- - >>> Docstring._load_obj('pandas.Series') - + >>> Docstring._load_obj('datetime.datetime') + """ for maxsplit in range(1, name.count(".") + 1): # TODO when py3 only replace by: module, *func_parts = ... @@ -262,30 +179,6 @@ def _load_obj(name): obj = getattr(obj, part) return obj - @staticmethod - def _to_original_callable(obj): - """ - Find the Python object that contains the source code of the object. - - This is useful to find the place in the source code (file and line - number) where a docstring is defined. It does not currently work for - all cases, but it should help find some (properties...). - """ - while True: - if inspect.isfunction(obj) or inspect.isclass(obj): - f = inspect.getfile(obj) - if f.startswith("<") and f.endswith(">"): - return None - return obj - if inspect.ismethod(obj): - obj = obj.__func__ - elif isinstance(obj, functools.partial): - obj = obj.func - elif isinstance(obj, property): - obj = obj.fget - else: - return None - @property def type(self): return type(self.obj).__name__ @@ -767,17 +660,16 @@ def validate_one(func_name): } -def validate_all(api_path, prefix, ignore_deprecated=False): +def validate_all(api_items, prefix=None, ignore_deprecated=False): """ Execute the validation of all docstrings, and return a dict with the results. Parameters ---------- - api_path : str - Path where the public API is defined. For example ``doc/source/api.rst`` - or ``doc/source/reference/*.rst``. The docstrings to analyze will be - obtained from the autosummary sections. + api_items : iterable of str + List or iterable returning the object names in the public API to validate. + (e.g. ['pandas.DataFrame.head', 'pandas.DataFrame.tail']) prefix : str or None If provided, only the docstrings that start with this pattern will be validated. If None, all docstrings will be validated. @@ -792,13 +684,7 @@ def validate_all(api_path, prefix, ignore_deprecated=False): """ result = {} seen = {} - - # functions from the API docs - api_items = [] - for api_doc_fname in glob.glob(api_path): - with open(api_doc_fname) as f: - api_items += list(get_api_items(f)) - for func_name, func_obj, section, subsection in api_items: + for func_name in api_items: if prefix and not func_name.startswith(prefix): continue doc_info = validate_one(func_name) @@ -808,151 +694,7 @@ def validate_all(api_path, prefix, ignore_deprecated=False): shared_code_key = doc_info["file"], doc_info["file_line"] shared_code = seen.get(shared_code_key, "") - result[func_name].update( - { - "in_api": True, - "section": section, - "subsection": subsection, - "shared_code_with": shared_code, - } - ) - + result[func_name]["shared_code_with"] = shared_code seen[shared_code_key] = func_name return result - - -def main(func_name, prefix, errors, output_format, ignore_deprecated): - def header(title, width=80, char="#"): - full_line = char * width - side_len = (width - len(title) - 2) // 2 - adj = "" if len(title) % 2 == 0 else " " - title_line = "{side} {title}{adj} {side}".format( - side=char * side_len, title=title, adj=adj - ) - - return "\n{full_line}\n{title_line}\n{full_line}\n\n".format( - full_line=full_line, title_line=title_line - ) - - exit_status = 0 - if func_name.endswith('.rst'): - result = validate_all(func_name, prefix, ignore_deprecated) - - if output_format == "json": - output = json.dumps(result) - else: - if output_format == "default": - output_format = "{text}\n" - elif output_format == "azure": - output_format = ( - "##vso[task.logissue type=error;" - "sourcepath={path};" - "linenumber={row};" - "code={code};" - "]{text}\n" - ) - else: - raise ValueError('Unknown output_format "{}"'.format(output_format)) - - output = "" - for name, res in result.items(): - for err_code, err_desc in res["errors"]: - # The script would be faster if instead of filtering the - # errors after validating them, it didn't validate them - # initially. But that would complicate the code too much - if errors and err_code not in errors: - continue - exit_status += 1 - output += output_format.format( - name=name, - path=res["file"], - row=res["file_line"], - code=err_code, - text="{}: {}".format(name, err_desc), - ) - - sys.stdout.write(output) - - else: - result = validate_one(func_name) - sys.stderr.write(header("Docstring ({})".format(func_name))) - sys.stderr.write("{}\n".format(result["docstring"])) - sys.stderr.write(header("Validation")) - if result["errors"]: - sys.stderr.write("{} Errors found:\n".format(len(result["errors"]))) - for err_code, err_desc in result["errors"]: - # Failing examples are printed at the end - if err_code == "EX02": - sys.stderr.write("\tExamples do not pass tests\n") - continue - sys.stderr.write("\t{}\n".format(err_desc)) - if result["warnings"]: - sys.stderr.write("{} Warnings found:\n".format(len(result["warnings"]))) - for wrn_code, wrn_desc in result["warnings"]: - sys.stderr.write("\t{}\n".format(wrn_desc)) - - if not result["errors"]: - sys.stderr.write('Docstring for "{}" correct. :)\n'.format(func_name)) - - if result["examples_errors"]: - sys.stderr.write(header("Doctests")) - sys.stderr.write(result["examples_errors"]) - - return exit_status - - -if __name__ == "__main__": - format_opts = "default", "json", "azure" - func_help = ( - "function or method to validate (e.g. pandas.DataFrame.head) " - "or rst file(s) with the public API autosummaries " - "(e.g. pandas/doc/source/reference/*.rst)" - ) - argparser = argparse.ArgumentParser(description="validate pandas docstrings") - argparser.add_argument("function", help=func_help) - argparser.add_argument( - "--format", - default="default", - choices=format_opts, - help="format of the output when validating " - "multiple docstrings (ignored when validating one)." - "It can be {}".format(str(format_opts)[1:-1]), - ) - argparser.add_argument( - "--prefix", - default=None, - help="pattern for the " - "docstring names, in order to decide which ones " - 'will be validated. A prefix "pandas.Series.str.' - "will make the script validate all the docstrings" - "of methods starting by this pattern. It is " - "ignored if parameter function is provided", - ) - argparser.add_argument( - "--errors", - default=None, - help="comma separated " - "list of error codes to validate. By default it " - "validates all errors (ignored when validating " - "a single docstring)", - ) - argparser.add_argument( - "--ignore_deprecated", - default=False, - action="store_true", - help="if this flag is set, " - "deprecated objects are ignored when validating " - "all docstrings", - ) - - args = argparser.parse_args() - sys.exit( - main( - args.function, - args.prefix, - args.errors.split(",") if args.errors else None, - args.format, - args.ignore_deprecated, - ) - ) From d5f793c1b28cf91f785823fd55c18710d9211640 Mon Sep 17 00:00:00 2001 From: Marc Garcia Date: Mon, 21 Oct 2019 14:25:52 -0500 Subject: [PATCH 04/18] Updating tests --- numpydoc/tests/test_validate.py | 245 +------------------------------- 1 file changed, 2 insertions(+), 243 deletions(-) diff --git a/numpydoc/tests/test_validate.py b/numpydoc/tests/test_validate.py index babcc4f3..1270965e 100644 --- a/numpydoc/tests/test_validate.py +++ b/numpydoc/tests/test_validate.py @@ -1,7 +1,5 @@ -import io import random import string -import textwrap import pytest import numpydoc.validate @@ -735,15 +733,6 @@ def prefix_pandas(self): class BadExamples: - def unused_import(self): - """ - Examples - -------- - >>> import pandas as pdf - >>> df = pd.DataFrame(np.ones((3, 3)), columns=('a', 'b', 'c')) - """ - pass - def missing_whitespace_around_arithmetic_operator(self): """ Examples @@ -766,7 +755,8 @@ def missing_whitespace_after_comma(self): """ Examples -------- - >>> df = pd.DataFrame(np.ones((3,3)),columns=('a','b', 'c')) + >>> import datetime + >>> value = datetime.date(2019,1,1) """ pass @@ -1074,94 +1064,6 @@ def test_validate_all_ignore_deprecated(self, monkeypatch): assert len(result) == 0 -class TestApiItems: - @property - def api_doc(self): - return io.StringIO( - textwrap.dedent( - """ - .. currentmodule:: itertools - - Itertools - --------- - - Infinite - ~~~~~~~~ - - .. autosummary:: - - cycle - count - - Finite - ~~~~~~ - - .. autosummary:: - - chain - - .. currentmodule:: random - - Random - ------ - - All - ~~~ - - .. autosummary:: - - seed - randint - """ - ) - ) - - @pytest.mark.parametrize( - "idx,name", - [ - (0, "itertools.cycle"), - (1, "itertools.count"), - (2, "itertools.chain"), - (3, "random.seed"), - (4, "random.randint"), - ], - ) - def test_item_name(self, idx, name): - result = list(numpydoc.validate.get_api_items(self.api_doc)) - assert result[idx][0] == name - - @pytest.mark.parametrize( - "idx,func", - [(0, "cycle"), (1, "count"), (2, "chain"), (3, "seed"), (4, "randint")], - ) - def test_item_function(self, idx, func): - result = list(numpydoc.validate.get_api_items(self.api_doc)) - assert callable(result[idx][1]) - assert result[idx][1].__name__ == func - - @pytest.mark.parametrize( - "idx,section", - [ - (0, "Itertools"), - (1, "Itertools"), - (2, "Itertools"), - (3, "Random"), - (4, "Random"), - ], - ) - def test_item_section(self, idx, section): - result = list(numpydoc.validate.get_api_items(self.api_doc)) - assert result[idx][2] == section - - @pytest.mark.parametrize( - "idx,subsection", - [(0, "Infinite"), (1, "Infinite"), (2, "Finite"), (3, "All"), (4, "All")], - ) - def test_item_subsection(self, idx, subsection): - result = list(numpydoc.validate.get_api_items(self.api_doc)) - assert result[idx][3] == subsection - - class TestDocstringClass: @pytest.mark.parametrize("invalid_name", ["panda", "panda.DataFrame"]) def test_raises_for_invalid_module_name(self, invalid_name): @@ -1178,146 +1080,3 @@ def test_raises_for_invalid_attribute_name(self, invalid_name): msg = "'{}' has no attribute '{}'".format(obj_name, invalid_attr_name) with pytest.raises(AttributeError, match=msg): numpydoc.validate.Docstring(invalid_name) - - -class TestMainFunction: - def test_exit_status_for_validate_one(self, monkeypatch): - monkeypatch.setattr( - numpydoc.validate, - "validate_one", - lambda func_name: { - "docstring": "docstring1", - "errors": [ - ("ER01", "err desc"), - ("ER02", "err desc"), - ("ER03", "err desc"), - ], - "warnings": [], - "examples_errors": "", - }, - ) - exit_status = numpydoc.validate.main( - func_name="docstring1", - prefix=None, - errors=[], - output_format="default", - ignore_deprecated=False, - ) - assert exit_status == 0 - - def test_exit_status_errors_for_validate_all(self, monkeypatch): - monkeypatch.setattr( - numpydoc.validate, - "validate_all", - lambda api_path, prefix, ignore_deprecated=False: { - "docstring1": { - "errors": [ - ("ER01", "err desc"), - ("ER02", "err desc"), - ("ER03", "err desc"), - ], - "file": "module1.py", - "file_line": 23, - }, - "docstring2": { - "errors": [("ER04", "err desc"), ("ER05", "err desc")], - "file": "module2.py", - "file_line": 925, - }, - }, - ) - exit_status = numpydoc.validate.main( - func_name='api.rst', - prefix=None, - errors=[], - output_format="default", - ignore_deprecated=False, - ) - assert exit_status == 5 - - def test_no_exit_status_noerrors_for_validate_all(self, monkeypatch): - monkeypatch.setattr( - numpydoc.validate, - "validate_all", - lambda api_path, prefix, ignore_deprecated=False: { - "docstring1": {"errors": [], "warnings": [("WN01", "warn desc")]}, - "docstring2": {"errors": []}, - }, - ) - exit_status = numpydoc.validate.main( - func_name='api.rst', - prefix=None, - errors=[], - output_format="default", - ignore_deprecated=False, - ) - assert exit_status == 0 - - def test_exit_status_for_validate_all_json(self, monkeypatch): - print("EXECUTED") - monkeypatch.setattr( - numpydoc.validate, - "validate_all", - lambda api_path, prefix, ignore_deprecated=False: { - "docstring1": { - "errors": [ - ("ER01", "err desc"), - ("ER02", "err desc"), - ("ER03", "err desc"), - ] - }, - "docstring2": {"errors": [("ER04", "err desc"), ("ER05", "err desc")]}, - }, - ) - exit_status = numpydoc.validate.main( - func_name='api.rst', - prefix=None, - errors=[], - output_format="json", - ignore_deprecated=False, - ) - assert exit_status == 0 - - def test_errors_param_filters_errors(self, monkeypatch): - monkeypatch.setattr( - numpydoc.validate, - "validate_all", - lambda api_path, prefix, ignore_deprecated=False: { - "Series.foo": { - "errors": [ - ("ER01", "err desc"), - ("ER02", "err desc"), - ("ER03", "err desc"), - ], - "file": "series.py", - "file_line": 142, - }, - "DataFrame.bar": { - "errors": [("ER01", "err desc"), ("ER02", "err desc")], - "file": "frame.py", - "file_line": 598, - }, - "Series.foobar": { - "errors": [("ER01", "err desc")], - "file": "series.py", - "file_line": 279, - }, - }, - ) - exit_status = numpydoc.validate.main( - func_name='api.rst', - prefix=None, - errors=["ER01"], - output_format="default", - ignore_deprecated=False, - ) - assert exit_status == 3 - - exit_status = numpydoc.validate.main( - func_name='api.rst', - prefix=None, - errors=["ER03"], - output_format="default", - ignore_deprecated=False, - ) - assert exit_status == 1 From fa57b589926393923beaab1cf57762fee5c45162 Mon Sep 17 00:00:00 2001 From: Marc Garcia Date: Mon, 21 Oct 2019 14:49:04 -0500 Subject: [PATCH 05/18] Fixed bug making examples fail when pytest was called in verbose mode --- numpydoc/validate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/numpydoc/validate.py b/numpydoc/validate.py index e2880650..e30f3de8 100644 --- a/numpydoc/validate.py +++ b/numpydoc/validate.py @@ -430,7 +430,7 @@ def deprecated(self): def examples_errors(self): flags = doctest.NORMALIZE_WHITESPACE | doctest.IGNORE_EXCEPTION_DETAIL finder = doctest.DocTestFinder() - runner = doctest.DocTestRunner(optionflags=flags) + runner = doctest.DocTestRunner(verbose=False, optionflags=flags) error_msgs = "" for test in finder.find(self.raw_doc, self.name): f = StringIO() From 55248aebb2842b5627de96089c1a8be3b07a018c Mon Sep 17 00:00:00 2001 From: Marc Garcia Date: Mon, 21 Oct 2019 15:00:43 -0500 Subject: [PATCH 06/18] Replacing pandas by a stdlib module to pass tests when pandas is not available --- numpydoc/tests/test_validate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/numpydoc/tests/test_validate.py b/numpydoc/tests/test_validate.py index 1270965e..f3d5f4f4 100644 --- a/numpydoc/tests/test_validate.py +++ b/numpydoc/tests/test_validate.py @@ -1065,14 +1065,14 @@ def test_validate_all_ignore_deprecated(self, monkeypatch): class TestDocstringClass: - @pytest.mark.parametrize("invalid_name", ["panda", "panda.DataFrame"]) + @pytest.mark.parametrize("invalid_name", ["unknown_mod", "unknown_mod.MyClass"]) def test_raises_for_invalid_module_name(self, invalid_name): msg = 'No module can be imported from "{}"'.format(invalid_name) with pytest.raises(ImportError, match=msg): numpydoc.validate.Docstring(invalid_name) @pytest.mark.parametrize( - "invalid_name", ["pandas.BadClassName", "pandas.Series.bad_method_name"] + "invalid_name", ["datetime.BadClassName", "datetime.bad_method_name"] ) def test_raises_for_invalid_attribute_name(self, invalid_name): name_components = invalid_name.split(".") From 2a392bebbf1bbbc071c2d1918382c8d336d64832 Mon Sep 17 00:00:00 2001 From: Marc Garcia Date: Mon, 21 Oct 2019 15:06:20 -0500 Subject: [PATCH 07/18] Making code py2 compatible --- numpydoc/validate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/numpydoc/validate.py b/numpydoc/validate.py index e30f3de8..61d9a032 100644 --- a/numpydoc/validate.py +++ b/numpydoc/validate.py @@ -21,7 +21,7 @@ DIRECTIVES = ["versionadded", "versionchanged", "deprecated"] -DIRECTIVE_PATTERN = re.compile(rf"^\s*\.\. ({'|'.join(DIRECTIVES)})(?!::)", +DIRECTIVE_PATTERN = re.compile(r"^\s*\.\. ({})(?!::)".format('|'.join(DIRECTIVES)), re.I | re.M) ALLOWED_SECTIONS = [ "Parameters", From 819ce71f56559cd68434f81488ac5166b710a85d Mon Sep 17 00:00:00 2001 From: Marc Garcia Date: Mon, 21 Oct 2019 15:37:08 -0500 Subject: [PATCH 08/18] Simplified script (just one validate function + Docstring), and removed concept of warning --- numpydoc/tests/test_validate.py | 155 ++++++++++++++++++++++++-------- numpydoc/validate.py | 107 +++++----------------- 2 files changed, 136 insertions(+), 126 deletions(-) diff --git a/numpydoc/tests/test_validate.py b/numpydoc/tests/test_validate.py index f3d5f4f4..a224ea6f 100644 --- a/numpydoc/tests/test_validate.py +++ b/numpydoc/tests/test_validate.py @@ -4,7 +4,7 @@ import numpydoc.validate -validate_one = numpydoc.validate.validate_one +validate_one = numpydoc.validate.validate class GoodDocStrings: @@ -13,6 +13,14 @@ class GoodDocStrings: This class contains a lot of docstrings that should pass the validation script without any errors. + + See Also + -------- + AnotherClass : With its description. + + Examples + -------- + >>> result = 1 + 1 """ def plot(self, kind, color="blue", **kwargs): @@ -31,6 +39,14 @@ def plot(self, kind, color="blue", **kwargs): **kwargs These parameters will be passed to the matplotlib plotting function. + + See Also + -------- + related : Something related. + + Examples + -------- + >>> result = 1 + 1 """ pass @@ -38,6 +54,9 @@ def swap(self, arr, i, j, *args, **kwargs): """ Swap two indicies on an array. + The extended summary can be multiple paragraphs, but just one + is enough to pass the validation. + Parameters ---------- arr : list @@ -46,6 +65,14 @@ def swap(self, arr, i, j, *args, **kwargs): The indexes being swapped. *args, **kwargs Extraneous parameters are being permitted. + + See Also + -------- + related : Something related. + + Examples + -------- + >>> result = 1 + 1 """ pass @@ -60,8 +87,16 @@ def sample(self): ------- float Random number generated. + + See Also + -------- + related : Something related. + + Examples + -------- + >>> result = 1 + 1 """ - return random.random() + pass def random_letters(self): """ @@ -76,10 +111,16 @@ def random_letters(self): Length of the returned string. letters : str String of random letters. + + See Also + -------- + related : Something related. + + Examples + -------- + >>> result = 1 + 1 """ - length = random.randint(1, 10) - letters = "".join(random.sample(string.ascii_lowercase, length)) - return length, letters + pass def sample_values(self): """ @@ -92,9 +133,16 @@ def sample_values(self): ------ float Random number generated. + + See Also + -------- + related : Something related. + + Examples + -------- + >>> result = 1 + 1 """ - while True: - yield random.random() + pass def head(self): """ @@ -105,7 +153,7 @@ def head(self): Returns ------- - Series + int Subset of the original series with the 5 first values. See Also @@ -113,8 +161,13 @@ def head(self): Series.tail : Return the last 5 elements of the Series. Series.iloc : Return a slice of the elements in the Series, which can also be used to return the first or last n. + + Examples + -------- + >>> 1 + 1 + 2 """ - return self.iloc[:5] + return 1 def head1(self, n=5): """ @@ -130,7 +183,7 @@ def head1(self, n=5): Returns ------- - Series + int Subset of the original series with the n first values. See Also @@ -148,7 +201,7 @@ def head1(self, n=5): >>> s + 1 11 """ - return self.iloc[:n] + return 1 def contains(self, pat, case=True, na=float('NaN')): """ @@ -166,6 +219,10 @@ def contains(self, pat, case=True, na=float('NaN')): na : object, default np.nan Fill value for missing data. + See Also + -------- + related : Something related. + Examples -------- >>> s = 25 @@ -193,6 +250,9 @@ def mode(self, axis, numeric_only): """ Ensure reST directives don't affect checks for leading periods. + The extended summary can be multiple paragraphs, but just one + is enough to pass the validation. + Parameters ---------- axis : str @@ -207,6 +267,14 @@ def mode(self, axis, numeric_only): .. deprecated:: 0.00.0 A multiline description, which spans another line. + + See Also + -------- + related : Something related. + + Examples + -------- + >>> result = 1 + 1 """ pass @@ -214,6 +282,13 @@ def good_imports(self): """ Ensure import other than numpy and pandas are fine. + The extended summary can be multiple paragraphs, but just one + is enough to pass the validation. + + See Also + -------- + related : Something related. + Examples -------- This example does not import pandas or import numpy. @@ -226,6 +301,17 @@ def good_imports(self): def no_returns(self): """ Say hello and have no returns. + + The extended summary can be multiple paragraphs, but just one + is enough to pass the validation. + + See Also + -------- + related : Something related. + + Examples + -------- + >>> result = 1 + 1 """ pass @@ -235,6 +321,14 @@ def empty_returns(self): Since this function never returns a value, this docstring doesn't need a return section. + + See Also + -------- + related : Something related. + + Examples + -------- + >>> result = 1 + 1 """ def say_hello(): @@ -250,6 +344,9 @@ def multiple_variables_on_one_line(self, matrix, a, b, i, j): """ Swap two values in a matrix. + The extended summary can be multiple paragraphs, but just one + is enough to pass the validation. + Parameters ---------- matrix : list of list @@ -258,6 +355,14 @@ def multiple_variables_on_one_line(self, matrix, a, b, i, j): The indicies of the first value. i, j : int The indicies of the second value. + + See Also + -------- + related : Something related. + + Examples + -------- + >>> result = 1 + 1 """ pass @@ -1009,15 +1114,6 @@ def test_bad_generic_functions(self, capsys, func): "method_wo_docstrings", ("The object does not have a docstring",), ), - # See Also tests - ( - "BadSeeAlso", - "prefix_pandas", - ( - "pandas.Series.rename in `See Also` section " - "does not need `pandas` prefix", - ), - ), # Examples tests ( "BadGenericDocStrings", @@ -1044,25 +1140,6 @@ def test_bad_docstrings(self, capsys, klass, func, msgs): for msg in msgs: assert msg in " ".join(err[1] for err in result["errors"]) - def test_validate_all_ignore_deprecated(self, monkeypatch): - monkeypatch.setattr( - numpydoc.validate, - "validate_one", - lambda func_name: { - "docstring": "docstring1", - "errors": [ - ("ER01", "err desc"), - ("ER02", "err desc"), - ("ER03", "err desc"), - ], - "warnings": [], - "examples_errors": "", - "deprecated": True, - }, - ) - result = numpydoc.validate.validate_all('api.rst', prefix=None, ignore_deprecated=True) - assert len(result) == 0 - class TestDocstringClass: @pytest.mark.parametrize("invalid_name", ["unknown_mod", "unknown_mod.MyClass"]) diff --git a/numpydoc/validate.py b/numpydoc/validate.py index 61d9a032..7a3d0cb8 100644 --- a/numpydoc/validate.py +++ b/numpydoc/validate.py @@ -2,7 +2,7 @@ """ Analyze docstrings to detect errors. -Call ``validate_all(list_of_object_names_to_validate)`` to get a dictionary +Call ``validate(object_name_to_validate)`` to get a dictionary with all the detected errors. """ import ast @@ -92,8 +92,6 @@ "SA03": "Description should be capitalized for See Also " '"{reference_name}" reference', "SA04": 'Missing description for See Also "{reference_name}" reference', - "SA05": "{reference_name} in `See Also` section does not need `pandas` " - "prefix, use {right_reference} instead.", "EX01": "No examples section found", "EX02": "Examples do not pass tests:\n{doctest_log}", } @@ -444,24 +442,20 @@ def examples_source_code(self): return [line.source for line in lines] -def get_validation_data(doc): +def validate(func_name): """ Validate the docstring. Parameters ---------- - doc : Docstring - A Docstring object with the given function name. + func_name : function + Function whose docstring will be evaluated (e.g. pandas.read_csv). Returns ------- - tuple - errors : list of tuple - Errors occurred during validation. - warnings : list of tuple - Warnings occurred during validation. - examples_errs : str - Examples usage displayed along the error, otherwise empty string. + dict + A dictionary containing all the information obtained from validating + the docstring. Notes ----- @@ -488,12 +482,20 @@ def get_validation_data(doc): they are validated, are not documented more than in the source code of this function. """ + doc = Docstring(func_name) errs = [] - wrns = [] if not doc.raw_doc: errs.append(error("GL08")) - return errs, wrns, "" + return { + "type": doc.type, + "docstring": doc.clean_doc, + "deprecated": doc.deprecated, + "file": doc.source_file_name, + "file_line": doc.source_file_def_line, + "errors": errs, + "examples_errors": "", + } if doc.start_blank_lines != 1: errs.append(error("GL01")) @@ -541,7 +543,7 @@ def get_validation_data(doc): errs.append(error("SS06")) if not doc.extended_summary: - wrns.append(("ES01", "No extended summary found")) + errs.append(("ES01", "No extended summary found")) # PR01: Parameters not documented # PR02: Unknown parameters @@ -602,7 +604,7 @@ def get_validation_data(doc): errs.append(error("YD01")) if not doc.see_also: - wrns.append(error("SA01")) + errs.append(error("SA01")) else: for rel_name, rel_desc in doc.see_also.items(): if rel_desc: @@ -612,42 +614,14 @@ def get_validation_data(doc): errs.append(error("SA03", reference_name=rel_name)) else: errs.append(error("SA04", reference_name=rel_name)) - if rel_name.startswith("pandas."): - errs.append( - error( - "SA05", - reference_name=rel_name, - right_reference=rel_name[len("pandas.") :], - ) - ) examples_errs = "" if not doc.examples: - wrns.append(error("EX01")) + errs.append(error("EX01")) else: examples_errs = doc.examples_errors if examples_errs: errs.append(error("EX02", doctest_log=examples_errs)) - return errs, wrns, examples_errs - - -def validate_one(func_name): - """ - Validate the docstring for the given func_name - - Parameters - ---------- - func_name : function - Function whose docstring will be evaluated (e.g. pandas.read_csv). - - Returns - ------- - dict - A dictionary containing all the information obtained from validating - the docstring. - """ - doc = Docstring(func_name) - errs, wrns, examples_errs = get_validation_data(doc) return { "type": doc.type, "docstring": doc.clean_doc, @@ -655,46 +629,5 @@ def validate_one(func_name): "file": doc.source_file_name, "file_line": doc.source_file_def_line, "errors": errs, - "warnings": wrns, "examples_errors": examples_errs, } - - -def validate_all(api_items, prefix=None, ignore_deprecated=False): - """ - Execute the validation of all docstrings, and return a dict with the - results. - - Parameters - ---------- - api_items : iterable of str - List or iterable returning the object names in the public API to validate. - (e.g. ['pandas.DataFrame.head', 'pandas.DataFrame.tail']) - prefix : str or None - If provided, only the docstrings that start with this pattern will be - validated. If None, all docstrings will be validated. - ignore_deprecated: bool, default False - If True, deprecated objects are ignored when validating docstrings. - - Returns - ------- - dict - A dictionary with an item for every function/method... containing - all the validation information. - """ - result = {} - seen = {} - for func_name in api_items: - if prefix and not func_name.startswith(prefix): - continue - doc_info = validate_one(func_name) - if ignore_deprecated and doc_info["deprecated"]: - continue - result[func_name] = doc_info - - shared_code_key = doc_info["file"], doc_info["file_line"] - shared_code = seen.get(shared_code_key, "") - result[func_name]["shared_code_with"] = shared_code - seen[shared_code_key] = func_name - - return result From 6bfaad689b7c457952474883a36933b903492497 Mon Sep 17 00:00:00 2001 From: Marc Garcia Date: Tue, 22 Oct 2019 01:36:04 -0500 Subject: [PATCH 09/18] Fixing imports in py2 --- numpydoc/tests/test_validate.py | 5 +++-- numpydoc/validate.py | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/numpydoc/tests/test_validate.py b/numpydoc/tests/test_validate.py index a224ea6f..a52add34 100644 --- a/numpydoc/tests/test_validate.py +++ b/numpydoc/tests/test_validate.py @@ -1,5 +1,6 @@ -import random -import string +# -*- encoding:utf-8 -*- +from __future__ import absolute_import + import pytest import numpydoc.validate diff --git a/numpydoc/validate.py b/numpydoc/validate.py index 7a3d0cb8..ff14abb7 100644 --- a/numpydoc/validate.py +++ b/numpydoc/validate.py @@ -17,7 +17,7 @@ from io import StringIO except ImportError: from cStringIO import StringIO -import numpydoc.docscrape +from .docscrape import NumpyDocString DIRECTIVES = ["versionadded", "versionchanged", "deprecated"] @@ -133,7 +133,7 @@ def __init__(self, name): self.code_obj = inspect.unwrap(obj) self.raw_doc = obj.__doc__ or "" self.clean_doc = pydoc.getdoc(obj) - self.doc = numpydoc.docscrape.NumpyDocString(self.clean_doc) + self.doc = NumpyDocString(self.clean_doc) def __len__(self): return len(self.raw_doc) From 174688bbb65066992ba41e356631a08c95f0f72b Mon Sep 17 00:00:00 2001 From: Marc Garcia Date: Tue, 22 Oct 2019 01:46:38 -0500 Subject: [PATCH 10/18] Changing import to see if py2 is happy --- numpydoc/tests/test_validate.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/numpydoc/tests/test_validate.py b/numpydoc/tests/test_validate.py index a52add34..f5e96eeb 100644 --- a/numpydoc/tests/test_validate.py +++ b/numpydoc/tests/test_validate.py @@ -1,11 +1,9 @@ # -*- encoding:utf-8 -*- -from __future__ import absolute_import - import pytest -import numpydoc.validate +from .. import validate -validate_one = numpydoc.validate.validate +validate_one = validate.validate class GoodDocStrings: @@ -1147,7 +1145,7 @@ class TestDocstringClass: def test_raises_for_invalid_module_name(self, invalid_name): msg = 'No module can be imported from "{}"'.format(invalid_name) with pytest.raises(ImportError, match=msg): - numpydoc.validate.Docstring(invalid_name) + validate.Docstring(invalid_name) @pytest.mark.parametrize( "invalid_name", ["datetime.BadClassName", "datetime.bad_method_name"] @@ -1157,4 +1155,4 @@ def test_raises_for_invalid_attribute_name(self, invalid_name): obj_name, invalid_attr_name = name_components[-2], name_components[-1] msg = "'{}' has no attribute '{}'".format(obj_name, invalid_attr_name) with pytest.raises(AttributeError, match=msg): - numpydoc.validate.Docstring(invalid_name) + validate.Docstring(invalid_name) From 5602863afabdabc7ef1090a774d18b69e925a3af Mon Sep 17 00:00:00 2001 From: Marc Garcia Date: Tue, 22 Oct 2019 01:54:23 -0500 Subject: [PATCH 11/18] Restoring imports, and calling pytest as a module --- .travis.yml | 2 +- numpydoc/tests/test_validate.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.travis.yml b/.travis.yml index 5a733caf..788fb7a6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -21,7 +21,7 @@ script: python setup.py sdist cd dist pip install numpydoc* -v - - pytest -v --pyargs numpydoc + - python -m pytest -v --pyargs numpydoc - | cd ../doc make SPHINXOPTS=$SPHINXOPTS html diff --git a/numpydoc/tests/test_validate.py b/numpydoc/tests/test_validate.py index f5e96eeb..fa880f23 100644 --- a/numpydoc/tests/test_validate.py +++ b/numpydoc/tests/test_validate.py @@ -1,9 +1,9 @@ # -*- encoding:utf-8 -*- import pytest -from .. import validate +import numpydoc.validate -validate_one = validate.validate +validate_one = numpydoc.validate.validate class GoodDocStrings: @@ -1145,7 +1145,7 @@ class TestDocstringClass: def test_raises_for_invalid_module_name(self, invalid_name): msg = 'No module can be imported from "{}"'.format(invalid_name) with pytest.raises(ImportError, match=msg): - validate.Docstring(invalid_name) + numpydoc.validate.Docstring(invalid_name) @pytest.mark.parametrize( "invalid_name", ["datetime.BadClassName", "datetime.bad_method_name"] @@ -1155,4 +1155,4 @@ def test_raises_for_invalid_attribute_name(self, invalid_name): obj_name, invalid_attr_name = name_components[-2], name_components[-1] msg = "'{}' has no attribute '{}'".format(obj_name, invalid_attr_name) with pytest.raises(AttributeError, match=msg): - validate.Docstring(invalid_name) + numpydoc.validate.Docstring(invalid_name) From 1161558cc17ef2e5870704f9cb7cbed20d3f850d Mon Sep 17 00:00:00 2001 From: Marc Garcia Date: Tue, 22 Oct 2019 23:53:52 -0500 Subject: [PATCH 12/18] Getting new changes from pandas sprint, and removing py2 stuff --- numpydoc/validate.py | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/numpydoc/validate.py b/numpydoc/validate.py index ff14abb7..31e2f853 100644 --- a/numpydoc/validate.py +++ b/numpydoc/validate.py @@ -159,10 +159,7 @@ def _load_obj(name): """ for maxsplit in range(1, name.count(".") + 1): - # TODO when py3 only replace by: module, *func_parts = ... - func_name_split = name.rsplit(".", maxsplit) - module = func_name_split[0] - func_parts = func_name_split[1:] + module, *func_parts = name.rsplit(".", maxsplit) try: obj = importlib.import_module(module) except ImportError: @@ -183,8 +180,7 @@ def type(self): @property def is_function_or_method(self): - # TODO(py27): remove ismethod - return inspect.isfunction(self.obj) or inspect.ismethod(self.obj) + return inspect.isfunction(self.obj) @property def source_file_name(self): @@ -283,6 +279,17 @@ def doc_parameters(self): @property def signature_parameters(self): + def add_stars(param_name, info): + """ + Add stars to *args and **kwargs parameters + """ + if info.kind == inspect.Parameter.VAR_POSITIONAL: + return "*{}".format(param_name) + elif info.kind == inspect.Parameter.VAR_KEYWORD: + return "**{}".format(param_name) + else: + return param_name + if inspect.isclass(self.obj): if hasattr(self.obj, "_accessors") and ( self.name.split(".")[-1] in self.obj._accessors @@ -290,17 +297,16 @@ def signature_parameters(self): # accessor classes have a signature but don't want to show this return tuple() try: - sig = inspect.getfullargspec(self.obj) + sig = inspect.signature(self.obj) except (TypeError, ValueError): # Some objects, mainly in C extensions do not support introspection # of the signature return tuple() - params = sig.args - if sig.varargs: - params.append("*" + sig.varargs) - if sig.varkw: - params.append("**" + sig.varkw) - params = tuple(params) + + params = tuple( + add_stars(parameter, sig.parameters[parameter]) + for parameter in sig.parameters + ) if params and params[0] in ("self", "cls"): return params[1:] return params From bb6df11018f42c9fbfe867752dc364da12a8dde9 Mon Sep 17 00:00:00 2001 From: Marc Garcia Date: Wed, 23 Oct 2019 00:16:29 -0500 Subject: [PATCH 13/18] Fixing import error in tests --- .travis.yml | 2 +- numpydoc/tests/test_validate.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 788fb7a6..5a733caf 100644 --- a/.travis.yml +++ b/.travis.yml @@ -21,7 +21,7 @@ script: python setup.py sdist cd dist pip install numpydoc* -v - - python -m pytest -v --pyargs numpydoc + - pytest -v --pyargs numpydoc - | cd ../doc make SPHINXOPTS=$SPHINXOPTS html diff --git a/numpydoc/tests/test_validate.py b/numpydoc/tests/test_validate.py index fa880f23..3bc8b374 100644 --- a/numpydoc/tests/test_validate.py +++ b/numpydoc/tests/test_validate.py @@ -1,6 +1,6 @@ -# -*- encoding:utf-8 -*- import pytest import numpydoc.validate +import numpydoc.tests validate_one = numpydoc.validate.validate From b47a45acea9bfd84ab1213cdbd9411c6b89087f2 Mon Sep 17 00:00:00 2001 From: Marc Garcia Date: Wed, 23 Oct 2019 00:54:08 -0500 Subject: [PATCH 14/18] Adding tests and removing unused code (improving coverage) --- numpydoc/tests/test_validate.py | 88 ++++++++++++++++++++++++++++++++- numpydoc/validate.py | 28 +---------- 2 files changed, 88 insertions(+), 28 deletions(-) diff --git a/numpydoc/tests/test_validate.py b/numpydoc/tests/test_validate.py index 3bc8b374..272cff75 100644 --- a/numpydoc/tests/test_validate.py +++ b/numpydoc/tests/test_validate.py @@ -542,6 +542,24 @@ def directives_without_two_colons(self, first, second): class BadSummaries: + def no_summary(self): + """ + Returns + ------- + int + Always one. + """ + + def heading_whitespaces(self): + """ + Summary with heading whitespaces. + + Returns + ------- + int + Always one. + """ + def wrong_line(self): """Exists on the wrong line""" pass @@ -583,6 +601,34 @@ class BadParameters: """ Everything here has a problem with its Parameters section. """ + def no_type(self, value): + """ + Lacks the type. + + Parameters + ---------- + value + A parameter without type. + """ + + def type_with_period(self, value): + """ + Has period after type. + + Parameters + ---------- + value : str. + A parameter type should not finish with period. + """ + + def no_description(self, value): + """ + Lacks the description. + + Parameters + ---------- + value : str + """ def missing_params(self, kind, **kwargs): """ @@ -800,6 +846,16 @@ def no_period_multi(self): class BadSeeAlso: + def no_desc(self): + """ + Return the first 5 elements of the Series. + + See Also + -------- + Series.tail + """ + pass + def desc_no_period(self): """ Return the first 5 elements of the Series. @@ -976,6 +1032,11 @@ def test_bad_generic_functions(self, capsys, func): "'deprecated'] must be followed by two colons", ), ), + ( + "BadSeeAlso", + "no_desc", + ('Missing description for See Also "Series.tail" reference',), + ), ( "BadSeeAlso", "desc_no_period", @@ -987,6 +1048,16 @@ def test_bad_generic_functions(self, capsys, func): ('should be capitalized for See Also "Series.tail"',), ), # Summary tests + ( + "BadSummaries", + "no_summary", + ("No summary found",), + ), + ( + "BadSummaries", + "heading_whitespaces", + ("Summary contains heading whitespaces",), + ), ( "BadSummaries", "wrong_line", @@ -1010,6 +1081,21 @@ def test_bad_generic_functions(self, capsys, func): ("Summary should fit in a single line",), ), # Parameters tests + ( + "BadParameters", + "no_type", + ('Parameter "value" has no type',), + ), + ( + "BadParameters", + "type_with_period", + ('Parameter "value" type should not finish with "."',), + ), + ( + "BadParameters", + "no_description", + ('Parameter "value" has no description',), + ), ( "BadParameters", "missing_params", @@ -1107,13 +1193,11 @@ def test_bad_generic_functions(self, capsys, func): "no_period_multi", ('Return value description should finish with "."',), ), - # Examples tests ( "BadGenericDocStrings", "method_wo_docstrings", ("The object does not have a docstring",), ), - # Examples tests ( "BadGenericDocStrings", "two_linebreaks_between_sections", diff --git a/numpydoc/validate.py b/numpydoc/validate.py index 31e2f853..d498fce4 100644 --- a/numpydoc/validate.py +++ b/numpydoc/validate.py @@ -13,10 +13,7 @@ import pydoc import re import textwrap -try: - from io import StringIO -except ImportError: - from cStringIO import StringIO +import io from .docscrape import NumpyDocString @@ -135,9 +132,6 @@ def __init__(self, name): self.clean_doc = pydoc.getdoc(obj) self.doc = NumpyDocString(self.clean_doc) - def __len__(self): - return len(self.raw_doc) - @staticmethod def _load_obj(name): """ @@ -265,10 +259,6 @@ def extended_summary(self): return " ".join(self.doc["Summary"]) return " ".join(self.doc["Extended Summary"]) - @property - def needs_summary(self): - return not (bool(self.summary) and bool(self.extended_summary)) - @property def doc_parameters(self): parameters = collections.OrderedDict() @@ -336,10 +326,6 @@ def parameter_mismatches(self): return errs - @property - def correct_parameters(self): - return not bool(self.parameter_mismatches) - @property def directives_without_two_colons(self): return DIRECTIVE_PATTERN.findall(self.raw_doc) @@ -421,11 +407,6 @@ def get_returns_not_on_nested_functions(node): else: return False - @property - def first_line_ends_in_dot(self): - if self.doc: - return self.doc.split("\n")[0][-1] == "." - @property def deprecated(self): return ".. deprecated:: " in (self.summary + self.extended_summary) @@ -437,16 +418,11 @@ def examples_errors(self): runner = doctest.DocTestRunner(verbose=False, optionflags=flags) error_msgs = "" for test in finder.find(self.raw_doc, self.name): - f = StringIO() + f = io.StringIO() runner.run(test, out=f.write) error_msgs += f.getvalue() return error_msgs - @property - def examples_source_code(self): - lines = doctest.DocTestParser().get_examples(self.raw_doc) - return [line.source for line in lines] - def validate(func_name): """ From bfed5736e22ace8e6edcc51b01f00d61fd41d74f Mon Sep 17 00:00:00 2001 From: Marc Garcia Date: Wed, 23 Oct 2019 09:45:18 -0500 Subject: [PATCH 15/18] Better implementation of module import based on code review --- numpydoc/validate.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/numpydoc/validate.py b/numpydoc/validate.py index d498fce4..180086c7 100644 --- a/numpydoc/validate.py +++ b/numpydoc/validate.py @@ -159,9 +159,8 @@ def _load_obj(name): except ImportError: pass else: - continue - - if "obj" not in locals(): + break + else: raise ImportError("No module can be imported " 'from "{}"'.format(name)) for part in func_parts: From f3ef8f6e43be93e19acdd81fb33b69b12799aed5 Mon Sep 17 00:00:00 2001 From: Marc Garcia Date: Wed, 23 Oct 2019 09:49:45 -0500 Subject: [PATCH 16/18] Remove running examples --- numpydoc/validate.py | 29 ++++------------------------- 1 file changed, 4 insertions(+), 25 deletions(-) diff --git a/numpydoc/validate.py b/numpydoc/validate.py index 180086c7..76fd284c 100644 --- a/numpydoc/validate.py +++ b/numpydoc/validate.py @@ -7,13 +7,11 @@ """ import ast import collections -import doctest import importlib import inspect import pydoc import re import textwrap -import io from .docscrape import NumpyDocString @@ -90,7 +88,6 @@ '"{reference_name}" reference', "SA04": 'Missing description for See Also "{reference_name}" reference', "EX01": "No examples section found", - "EX02": "Examples do not pass tests:\n{doctest_log}", } @@ -99,10 +96,10 @@ def error(code, **kwargs): Return a tuple with the error code and the message with variables replaced. This is syntactic sugar so instead of: - - `('EX02', ERROR_MSGS['EX02'].format(doctest_log=log))` + - `('PR02', ERROR_MSGS['PR02'].format(doctest_log=log))` We can simply use: - - `error('EX02', doctest_log=log)` + - `error('PR02', doctest_log=log)` Parameters ---------- @@ -410,18 +407,6 @@ def get_returns_not_on_nested_functions(node): def deprecated(self): return ".. deprecated:: " in (self.summary + self.extended_summary) - @property - def examples_errors(self): - flags = doctest.NORMALIZE_WHITESPACE | doctest.IGNORE_EXCEPTION_DETAIL - finder = doctest.DocTestFinder() - runner = doctest.DocTestRunner(verbose=False, optionflags=flags) - error_msgs = "" - for test in finder.find(self.raw_doc, self.name): - f = io.StringIO() - runner.run(test, out=f.write) - error_msgs += f.getvalue() - return error_msgs - def validate(func_name): """ @@ -456,8 +441,8 @@ def validate(func_name): * EX: Examples - Last two characters: Numeric error code inside the section - For example, EX02 is the second codified error in the Examples section - (which in this case is assigned to examples that do not pass the tests). + For example, PR02 is the second codified error in the Parameters section + (which in this case is assigned to the error when unknown parameters are documented). The error codes, their corresponding error messages, and the details on how they are validated, are not documented more than in the source code of this @@ -596,13 +581,8 @@ def validate(func_name): else: errs.append(error("SA04", reference_name=rel_name)) - examples_errs = "" if not doc.examples: errs.append(error("EX01")) - else: - examples_errs = doc.examples_errors - if examples_errs: - errs.append(error("EX02", doctest_log=examples_errs)) return { "type": doc.type, "docstring": doc.clean_doc, @@ -610,5 +590,4 @@ def validate(func_name): "file": doc.source_file_name, "file_line": doc.source_file_def_line, "errors": errs, - "examples_errors": examples_errs, } From 34052537322d3119cd7bbd37abd77a01a8c4fc25 Mon Sep 17 00:00:00 2001 From: Marc Garcia Date: Wed, 23 Oct 2019 10:59:42 -0500 Subject: [PATCH 17/18] Require first letter to be upper case only if it's a letter --- numpydoc/tests/test_validate.py | 35 +++++++++++++++++++++++++++++++++ numpydoc/validate.py | 8 ++++---- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/numpydoc/tests/test_validate.py b/numpydoc/tests/test_validate.py index 272cff75..47831051 100644 --- a/numpydoc/tests/test_validate.py +++ b/numpydoc/tests/test_validate.py @@ -202,6 +202,40 @@ def head1(self, n=5): """ return 1 + def summary_starts_with_number(self, n=5): + """ + 2nd rule of summaries should allow this. + + 3 Starting the summary with a number instead of a capital letter. + Also in parameters, returns, see also... + + Parameters + ---------- + n : int + 4 Number of values to return. + + Returns + ------- + int + 5 Subset of the original series with the n first values. + + See Also + -------- + tail : 6 Return the last n elements of the Series. + + Examples + -------- + >>> s = 10 + >>> s + 10 + + 7 With the `n` parameter, we can change the number of returned rows: + + >>> s + 1 + 11 + """ + return 1 + def contains(self, pat, case=True, na=float('NaN')): """ Return whether each value contains `pat`. @@ -963,6 +997,7 @@ def test_good_class(self, capsys): "sample_values", "head", "head1", + "summary_starts_with_number", "contains", "mode", "good_imports", diff --git a/numpydoc/validate.py b/numpydoc/validate.py index 76fd284c..c0aa8f91 100644 --- a/numpydoc/validate.py +++ b/numpydoc/validate.py @@ -497,7 +497,7 @@ def validate(func_name): if not doc.summary: errs.append(error("SS01")) else: - if not doc.summary[0].isupper(): + if doc.summary[0].isalpha() and not doc.summary[0].isupper(): errs.append(error("SS02")) if doc.summary[-1] != ".": errs.append(error("SS03")) @@ -544,7 +544,7 @@ def validate(func_name): if not doc.parameter_desc(param): errs.append(error("PR07", param_name=param)) else: - if not doc.parameter_desc(param)[0].isupper(): + if doc.parameter_desc(param)[0].isalpha() and not doc.parameter_desc(param)[0].isupper(): errs.append(error("PR08", param_name=param)) if doc.parameter_desc(param)[-1] != ".": errs.append(error("PR09", param_name=param)) @@ -561,7 +561,7 @@ def validate(func_name): errs.append(error("RT03")) else: desc = " ".join(desc) - if not desc[0].isupper(): + if desc[0].isalpha() and not desc[0].isupper(): errs.append(error("RT04")) if not desc.endswith("."): errs.append(error("RT05")) @@ -576,7 +576,7 @@ def validate(func_name): if rel_desc: if not rel_desc.endswith("."): errs.append(error("SA02", reference_name=rel_name)) - if not rel_desc[0].isupper(): + if rel_desc[0].isalpha() and not rel_desc[0].isupper(): errs.append(error("SA03", reference_name=rel_name)) else: errs.append(error("SA04", reference_name=rel_name)) From ce8e66a05172179a0344c15abab852a362fef007 Mon Sep 17 00:00:00 2001 From: Marc Garcia Date: Wed, 23 Oct 2019 11:47:44 -0500 Subject: [PATCH 18/18] Allow one liner docstrings with quotes in the same line. --- numpydoc/tests/test_validate.py | 17 +++++++++++++++-- numpydoc/validate.py | 4 ++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/numpydoc/tests/test_validate.py b/numpydoc/tests/test_validate.py index 47831051..3f162139 100644 --- a/numpydoc/tests/test_validate.py +++ b/numpydoc/tests/test_validate.py @@ -21,6 +21,10 @@ class GoodDocStrings: -------- >>> result = 1 + 1 """ + def one_liner(self): + """Allow one liner docstrings (including quotes).""" + # This should fail, but not because of the position of the quotes + pass def plot(self, kind, color="blue", **kwargs): """ @@ -595,7 +599,9 @@ def heading_whitespaces(self): """ def wrong_line(self): - """Exists on the wrong line""" + """Quotes are on the wrong line. + + Both opening and closing.""" pass def no_punctuation(self): @@ -982,6 +988,12 @@ def _import_path(self, klass=None, func=None): return base_path + def test_one_liner(self, capsys): + result = validate_one(self._import_path(klass="GoodDocStrings", func='one_liner')) + errors = " ".join(err[1] for err in result["errors"]) + assert 'should start in the line immediately after the opening quotes' not in errors + assert 'should be placed in the line after the last text' not in errors + def test_good_class(self, capsys): errors = validate_one(self._import_path(klass="GoodDocStrings"))["errors"] assert isinstance(errors, list) @@ -1096,7 +1108,8 @@ def test_bad_generic_functions(self, capsys, func): ( "BadSummaries", "wrong_line", - ("should start in the line immediately after the opening quotes",), + ("should start in the line immediately after the opening quotes", + "should be placed in the line after the last text"), ), ("BadSummaries", "no_punctuation", ("Summary does not end with a period",)), ( diff --git a/numpydoc/validate.py b/numpydoc/validate.py index c0aa8f91..f268d8b8 100644 --- a/numpydoc/validate.py +++ b/numpydoc/validate.py @@ -463,9 +463,9 @@ def validate(func_name): "examples_errors": "", } - if doc.start_blank_lines != 1: + if doc.start_blank_lines != 1 and "\n" in doc.raw_doc: errs.append(error("GL01")) - if doc.end_blank_lines != 1: + if doc.end_blank_lines != 1 and "\n" in doc.raw_doc: errs.append(error("GL02")) if doc.double_blank_lines: errs.append(error("GL03"))