diff --git a/doc/conf.py b/doc/conf.py index e9197631..185b6b18 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -84,6 +84,8 @@ version = re.sub(r'(\.dev\d+).*?$', r'\1', version) numpydoc_xref_param_type = True numpydoc_xref_ignore = {'optional', 'type_without_description', 'BadException'} +# Run docstring validation as part of build process +numpydoc_validation_checks = {"all", "GL01", "SA04", "RT03"} # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/doc/install.rst b/doc/install.rst index 6139efea..82c7d6ba 100644 --- a/doc/install.rst +++ b/doc/install.rst @@ -96,6 +96,42 @@ numpydoc_xref_ignore : set or ``"all"`` desired cross reference mappings in ``numpydoc_xref_aliases`` and setting ``numpydoc_xref_ignore="all"`` is more convenient than explicitly listing terms to ignore in a set. +numpydoc_validation_checks : set + The set of validation checks to report during the sphinx build process. + The default is an empty set, so docstring validation is not run by + default. + If ``"all"`` is in the set, then the results of all of the + :ref:`built-in validation checks ` are reported. + If the set includes ``"all"`` and additional error codes, then all + validation checks *except* the listed error codes will be run. + If the set contains *only* individual error codes, then only those checks + will be run. + For example:: + + # Report warnings for all validation checks + numpydoc_validation_checks = {"all"} + + # Report warnings for all checks *except* for GL01, GL02, and GL05 + numpydoc_validation_checks = {"all", "GL01", "GL02", "GL05"} + + # Only report warnings for the SA01 and EX01 checks + numpydoc_validation_checks = {"SA01", "EX01"} +numpydoc_validation_exclude : set + A container of strings using :py:mod:`re` syntax specifying patterns to + ignore for docstring validation. + For example, to skip docstring validation for all objects in + ``mypkg.mymodule``:: + + numpydoc_validation_exclude = {"mypkg.mymodule."} + + If you wanted to also skip getter methods of ``MyClass``:: + + numpydoc_validation_exclude = {r"mypkg\.mymodule\.", r"MyClass\.get$"} + + The default is an empty set meaning no objects are excluded from docstring + validation. + Only has an effect when docstring validation is activated, i.e. + ``numpydoc_validation_checks`` is not an empty set. numpydoc_edit_link : bool .. deprecated:: 0.7.0 diff --git a/doc/validation.rst b/doc/validation.rst index 3672d839..e1b2ee03 100644 --- a/doc/validation.rst +++ b/doc/validation.rst @@ -15,3 +15,21 @@ For an exhaustive validation of the formatting of the docstring, use the ``--validate`` parameter. This will report the errors detected, such as incorrect capitalization, wrong order of the sections, and many other issues. + +.. _validation_checks: + +Built-in Validation Checks +-------------------------- + +The ``numpydoc.validation`` module provides a mapping with all of the checks +that are run as part of the validation procedure. +The mapping is of the form: ``error_code : `` where ``error_code`` +provides a shorthand for the check being run, and ```` provides +a more detailed message. For example:: + + "EX01" : "No examples section found" + +The full mapping of validation checks is given below. + +.. literalinclude:: ../numpydoc/validate.py + :lines: 36-90 diff --git a/numpydoc/__main__.py b/numpydoc/__main__.py index 534e9e0b..f342cc3a 100644 --- a/numpydoc/__main__.py +++ b/numpydoc/__main__.py @@ -6,13 +6,13 @@ import ast from .docscrape_sphinx import get_doc_object -from .validate import validate, Docstring +from .validate import validate, Validator def render_object(import_path, config=None): """Test numpydoc docstring generation for a given object""" - # TODO: Move Docstring._load_obj to a better place than validate - print(get_doc_object(Docstring(import_path).obj, + # TODO: Move Validator._load_obj to a better place than validate + print(get_doc_object(Validator._load_obj(import_path), config=dict(config or []))) return 0 diff --git a/numpydoc/docscrape.py b/numpydoc/docscrape.py index 15b4e10d..395801e4 100644 --- a/numpydoc/docscrape.py +++ b/numpydoc/docscrape.py @@ -411,8 +411,16 @@ def _parse(self): else: self[section] = content + @property + def _obj(self): + if hasattr(self, '_cls'): + return self._cls + elif hasattr(self, '_f'): + return self._f + return None + def _error_location(self, msg, error=True): - if hasattr(self, '_obj') and self._obj is not None: + if self._obj is not None: # we know where the docs came from: try: filename = inspect.getsourcefile(self._obj) @@ -581,6 +589,12 @@ def __str__(self): return out +class ObjDoc(NumpyDocString): + def __init__(self, obj, doc=None, config={}): + self._f = obj + NumpyDocString.__init__(self, doc, config=config) + + class ClassDoc(NumpyDocString): extra_public_methods = ['__call__'] @@ -663,3 +677,24 @@ def _is_show_member(self, name): if name not in self._cls.__dict__: return False # class member is inherited, we do not show it return True + + +def get_doc_object(obj, what=None, doc=None, config={}): + if what is None: + if inspect.isclass(obj): + what = 'class' + elif inspect.ismodule(obj): + what = 'module' + elif isinstance(obj, Callable): + what = 'function' + else: + what = 'object' + + if what == 'class': + return ClassDoc(obj, func_doc=FunctionDoc, doc=doc, config=config) + elif what in ('function', 'method'): + return FunctionDoc(obj, doc=doc, config=config) + else: + if doc is None: + doc = pydoc.getdoc(obj) + return ObjDoc(obj, doc, config=config) diff --git a/numpydoc/docscrape_sphinx.py b/numpydoc/docscrape_sphinx.py index 38a49ba2..b109fe7b 100644 --- a/numpydoc/docscrape_sphinx.py +++ b/numpydoc/docscrape_sphinx.py @@ -10,7 +10,7 @@ import sphinx from sphinx.jinja2glue import BuiltinTemplateLoader -from .docscrape import NumpyDocString, FunctionDoc, ClassDoc +from .docscrape import NumpyDocString, FunctionDoc, ClassDoc, ObjDoc from .xref import make_xref @@ -229,14 +229,6 @@ def _str_param_list(self, name, fake_autosummary=False): return out - @property - def _obj(self): - if hasattr(self, '_cls'): - return self._cls - elif hasattr(self, '_f'): - return self._f - return None - def _str_member_list(self, name): """ Generate a member listing, autosummary:: table where possible, @@ -411,13 +403,13 @@ def __init__(self, obj, doc=None, func_doc=None, config={}): ClassDoc.__init__(self, obj, doc=doc, func_doc=None, config=config) -class SphinxObjDoc(SphinxDocString): +class SphinxObjDoc(SphinxDocString, ObjDoc): def __init__(self, obj, doc=None, config={}): - self._f = obj self.load_config(config) - SphinxDocString.__init__(self, doc, config=config) + ObjDoc.__init__(self, obj, doc=doc, config=config) +# TODO: refactor to use docscrape.get_doc_object def get_doc_object(obj, what=None, doc=None, config={}, builder=None): if what is None: if inspect.isclass(obj): diff --git a/numpydoc/numpydoc.py b/numpydoc/numpydoc.py index 93cd975d..87a81689 100644 --- a/numpydoc/numpydoc.py +++ b/numpydoc/numpydoc.py @@ -34,6 +34,7 @@ raise RuntimeError("Sphinx 1.6.5 or newer is required") from .docscrape_sphinx import get_doc_object +from .validate import validate, ERROR_MSGS from .xref import DEFAULT_LINKS from . import __version__ @@ -173,6 +174,28 @@ def mangle_docstrings(app, what, name, obj, options, lines): logger.error('[numpydoc] While processing docstring for %r', name) raise + if app.config.numpydoc_validation_checks: + # If the user has supplied patterns to ignore via the + # numpydoc_validation_exclude config option, skip validation for + # any objs whose name matches any of the patterns + excluder = app.config.numpydoc_validation_excluder + exclude_from_validation = excluder.search(name) if excluder else False + if not exclude_from_validation: + # TODO: Currently, all validation checks are run and only those + # selected via config are reported. It would be more efficient to + # only run the selected checks. + errors = validate(doc)["errors"] + if {err[0] for err in errors} & app.config.numpydoc_validation_checks: + msg = ( + f"[numpydoc] Validation warnings while processing " + f"docstring for {name!r}:\n" + ) + for err in errors: + if err[0] in app.config.numpydoc_validation_checks: + msg += f" {err[0]}: {err[1]}\n" + logger.warning(msg) + + if (app.config.numpydoc_edit_link and hasattr(obj, '__name__') and obj.__name__): if hasattr(obj, '__module__'): @@ -254,6 +277,8 @@ def setup(app, get_doc_object_=get_doc_object): app.add_config_value('numpydoc_xref_param_type', False, True) app.add_config_value('numpydoc_xref_aliases', dict(), True) app.add_config_value('numpydoc_xref_ignore', set(), True) + app.add_config_value('numpydoc_validation_checks', set(), True) + app.add_config_value('numpydoc_validation_exclude', set(), False) # Extra mangling domains app.add_domain(NumpyPythonDomain) @@ -278,6 +303,33 @@ def update_config(app, config=None): numpydoc_xref_aliases_complete[key] = value config.numpydoc_xref_aliases_complete = numpydoc_xref_aliases_complete + # Processing to determine whether numpydoc_validation_checks is treated + # as a blocklist or allowlist + valid_error_codes = set(ERROR_MSGS.keys()) + if "all" in config.numpydoc_validation_checks: + block = deepcopy(config.numpydoc_validation_checks) + config.numpydoc_validation_checks = valid_error_codes - block + # Ensure that the validation check set contains only valid error codes + invalid_error_codes = config.numpydoc_validation_checks - valid_error_codes + if invalid_error_codes: + raise ValueError( + f"Unrecognized validation code(s) in numpydoc_validation_checks " + f"config value: {invalid_error_codes}" + ) + + # Generate the regexp for docstrings to ignore during validation + if isinstance(config.numpydoc_validation_exclude, str): + raise ValueError( + f"numpydoc_validation_exclude must be a container of strings, " + f"e.g. [{config.numpydoc_validation_exclude!r}]." + ) + config.numpydoc_validation_excluder = None + if config.numpydoc_validation_exclude: + exclude_expr = re.compile( + r"|".join(exp for exp in config.numpydoc_validation_exclude) + ) + config.numpydoc_validation_excluder = exclude_expr + # ------------------------------------------------------------------------------ # Docstring-mangling domains diff --git a/numpydoc/tests/test_docscrape.py b/numpydoc/tests/test_docscrape.py index 080e8cec..0706b6d0 100644 --- a/numpydoc/tests/test_docscrape.py +++ b/numpydoc/tests/test_docscrape.py @@ -1497,6 +1497,9 @@ class Config(): def __init__(self, a, b): self.numpydoc_xref_aliases = a self.numpydoc_xref_aliases_complete = b + # numpydoc.update_config fails if this config option not present + self.numpydoc_validation_checks = set() + self.numpydoc_validation_exclude = set() xref_aliases_complete = deepcopy(DEFAULT_LINKS) for key in xref_aliases: diff --git a/numpydoc/tests/test_numpydoc.py b/numpydoc/tests/test_numpydoc.py index 77e75400..26ff3d28 100644 --- a/numpydoc/tests/test_numpydoc.py +++ b/numpydoc/tests/test_numpydoc.py @@ -1,8 +1,13 @@ # -*- encoding:utf-8 -*- +import pytest +from io import StringIO from copy import deepcopy -from numpydoc.numpydoc import mangle_docstrings, _clean_text_signature +from numpydoc.numpydoc import ( + mangle_docstrings, _clean_text_signature, update_config +) from numpydoc.xref import DEFAULT_LINKS from sphinx.ext.autodoc import ALL +from sphinx.util import logging class MockConfig(): @@ -19,6 +24,8 @@ class MockConfig(): numpydoc_edit_link = False numpydoc_citation_re = '[a-z0-9_.-]+' numpydoc_attributes_as_param_list = True + numpydoc_validation_checks = set() + numpydoc_validation_exclude = set() class MockBuilder(): @@ -30,9 +37,12 @@ class MockApp(): builder = MockBuilder() translator = None - -app = MockApp() -app.builder.app = app + def __init__(self): + self.builder.app = self + # Attrs required for logging + self.verbosity = 2 + self._warncount = 0 + self.warningiserror = False def test_mangle_docstrings(): @@ -92,6 +102,99 @@ def test_clean_text_signature(): assert _clean_text_signature('func($self, *args)') == 'func(*args)' +@pytest.fixture +def f(): + def _function_without_seealso_and_examples(): + """ + A function whose docstring has no examples or see also section. + + Expect SA01 and EX01 errors if validation enabled. + """ + pass + return _function_without_seealso_and_examples + + +@pytest.mark.parametrize( + ( + 'numpydoc_validation_checks', + 'expected_warn', + 'non_warnings', + ), + ( + # Validation configured off - expect no warnings + (set(), [], []), + # Validation on with expected warnings + (set(['SA01', 'EX01']), ('SA01', 'EX01'), []), + # Validation on with only one activated check + (set(['SA01']), ('SA01',), ('EX01',)), + ), +) +def test_mangle_docstring_validation_warnings( + f, + numpydoc_validation_checks, + expected_warn, + non_warnings, +): + app = MockApp() + # Set up config for test + app.config.numpydoc_validation_checks = numpydoc_validation_checks + # Update configuration + update_config(app) + # Set up logging + status, warning = StringIO(), StringIO() + logging.setup(app, status, warning) + # Run mangle docstrings with the above configuration + mangle_docstrings(app, 'function', 'f', f, None, f.__doc__.split('\n')) + # Assert that all (and only) expected warnings are logged + warnings = warning.getvalue() + for w in expected_warn: + assert w in warnings + for w in non_warnings: + assert w not in warnings + + +def test_mangle_docstring_validation_exclude(): + def function_with_bad_docstring(): + """ + This docstring will raise docstring validation warnings.""" + app = MockApp() + app.config.numpydoc_validation_checks = {"all"} + app.config.numpydoc_validation_exclude = [r"_bad_"] + # Call update_config to construct regexp from config value + update_config(app) + # Setup for catching warnings + status, warning = StringIO(), StringIO() + logging.setup(app, status, warning) + # Run mangle docstrings on function_with_bad_docstring + mangle_docstrings( + app, + 'function', + function_with_bad_docstring.__name__, + function_with_bad_docstring, + None, + function_with_bad_docstring.__doc__.split('\n'), + ) + # Validation is skipped due to exclude pattern matching fn name, therefore + # no warnings expected + assert warning.getvalue() == "" + + +def test_update_config_invalid_validation_set(): + app = MockApp() + # Results in {'a', 'l'} instead of {"all"} + app.config.numpydoc_validation_checks = set("all") + with pytest.raises(ValueError, match="Unrecognized validation code"): + update_config(app) + + +def test_update_config_exclude_str(): + app = MockApp() + app.config.numpydoc_validation_checks = set() + app.config.numpydoc_validation_exclude = "shouldnt-be-a-str" + with pytest.raises(ValueError, match="\['shouldnt-be-a-str'\]"): + update_config(app) + + if __name__ == "__main__": import pytest pytest.main() diff --git a/numpydoc/tests/test_validate.py b/numpydoc/tests/test_validate.py index b8f4d873..704a25ef 100644 --- a/numpydoc/tests/test_validate.py +++ b/numpydoc/tests/test_validate.py @@ -1312,12 +1312,12 @@ def test_bad_docstrings(self, capsys, klass, func, msgs): assert msg in " ".join(err[1] for err in result["errors"]) -class TestDocstringClass: +class TestValidatorClass: @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) + numpydoc.validate.Validator._load_obj(invalid_name) @pytest.mark.parametrize( "invalid_name", ["datetime.BadClassName", "datetime.bad_method_name"] @@ -1327,4 +1327,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) + numpydoc.validate.Validator._load_obj(invalid_name) diff --git a/numpydoc/validate.py b/numpydoc/validate.py index be50885b..b33ea780 100644 --- a/numpydoc/validate.py +++ b/numpydoc/validate.py @@ -12,7 +12,7 @@ import pydoc import re import textwrap -from .docscrape import NumpyDocString +from .docscrape import get_doc_object DIRECTIVES = ["versionadded", "versionchanged", "deprecated"] @@ -34,58 +34,58 @@ ] 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)", + "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)", + "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", + "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", + "whitespace only", "GL06": 'Found unknown section "{section}". Allowed sections are: ' - "{allowed_sections}", + "{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)", + "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")', + '(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}", + "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}"', + 'of "{wrong_type}"', "PR07": 'Parameter "{param_name}" has no description', "PR08": 'Parameter "{param_name}" description should start with a ' - "capital letter", + "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", + "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", + "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', + '"{reference_name}" reference', "SA03": "Description should be capitalized for See Also " - '"{reference_name}" reference', + '"{reference_name}" reference', "SA04": 'Missing description for See Also "{reference_name}" reference', "EX01": "No examples section found", } @@ -121,16 +121,18 @@ def error(code, **kwargs): return (code, ERROR_MSGS[code].format(**kwargs)) -class Docstring: +class Validator: # 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 = inspect.unwrap(obj) - self.raw_doc = obj.__doc__ or "" - self.clean_doc = pydoc.getdoc(obj) - self.doc = NumpyDocString(self.clean_doc) + def __init__(self, doc_object): + self.doc = doc_object + self.obj = self.doc._obj + self.code_obj = inspect.unwrap(self.obj) + self.raw_doc = self.obj.__doc__ or "" + self.clean_doc = pydoc.getdoc(self.obj) + + @property + def name(self): + return '.'.join([self.obj.__module__, self.obj.__name__]) @staticmethod def _load_obj(name): @@ -149,7 +151,7 @@ def _load_obj(name): Examples -------- - >>> Docstring._load_obj('datetime.datetime') + >>> Validator._load_obj('datetime.datetime') """ for maxsplit in range(0, name.count(".") + 1): @@ -468,7 +470,10 @@ def validate(obj_name): they are validated, are not documented more than in the source code of this function. """ - doc = Docstring(obj_name) + if isinstance(obj_name, str): + doc = Validator(get_doc_object(Validator._load_obj(obj_name))) + else: + doc = Validator(obj_name) errs = [] if not doc.raw_doc: