diff --git a/doc/install.rst b/doc/install.rst index 963819b9..480bd59a 100644 --- a/doc/install.rst +++ b/doc/install.rst @@ -138,3 +138,17 @@ numpydoc_validation_exclude : set validation. Only has an effect when docstring validation is activated, i.e. ``numpydoc_validation_checks`` is not an empty set. +numpydoc_validation_overrides : dict + A dictionary mapping :ref:`validation checks ` to a + container of strings using :py:mod:`re` syntax specifying patterns to + ignore for docstring validation. + For example, the following skips the ``SS02`` check for docstrings + starting with the word ``Process``:: + + numpydoc_validation_overrides = {"SS02": [r'^Process ']} + + The default is an empty dictionary meaning no overrides. + Only has an effect when docstring validation is activated, i.e. + ``numpydoc_validation_checks`` is not an empty set. Use + :ref:`inline ignore comments ` to turn off + specific checks for parts of your code. diff --git a/doc/validation.rst b/doc/validation.rst index 09196ea5..93bd9b0b 100644 --- a/doc/validation.rst +++ b/doc/validation.rst @@ -2,6 +2,8 @@ Validation ========== +.. _pre_commit_hook: + Docstring Validation using Pre-Commit Hook ------------------------------------------ @@ -22,44 +24,55 @@ command line options for this hook: $ python -m numpydoc.hooks.validate_docstrings --help -Using a config file provides additional customization. Both -``pyproject.toml`` and ``setup.cfg`` are supported; however, if the -project contains both you must use the ``pyproject.toml`` file. -The example below configures the pre-commit hook to ignore three checks -and specifies exceptions to the checks ``SS05`` (allow docstrings to -start with "Process ", "Assess ", or "Access ") and ``GL08`` (allow -the class/method/function with name "__init__" to not have a docstring). +Using a config file provides additional customization. Both ``pyproject.toml`` +and ``setup.cfg`` are supported; however, if the project contains both +you must use the ``pyproject.toml`` file. The example below configures +the pre-commit hook as follows: + +* ``checks``: Report findings on all checks except ``EX01``, ``SA01``, and + ``ES01`` (using the same logic as the :ref:`validation during Sphinx build + ` for ``numpydoc_validation_checks``). +* ``exclude``: Don't report issues on objects matching any of the regular + regular expressions ``\.undocumented_method$`` or ``\.__repr__$``. This + maps to ``numpydoc_validation_exclude`` from the + :ref:`Sphinx build configuration `. +* ``override_SS05``: Allow docstrings to start with "Process ", "Assess ", + or "Access ". To override different checks, add a field for each code in + the form of ``override_`` with a collection of regular expression(s) + to search for in the contents of a docstring, not the object name. This + maps to ``numpydoc_validation_overrides`` from the + :ref:`Sphinx build configuration `. ``pyproject.toml``:: [tool.numpydoc_validation] - ignore = [ + checks = [ + "all", # report on all checks, except the below "EX01", "SA01", "ES01", ] - override_SS05 = '^((Process|Assess|Access) )' - override_GL08 = '^(__init__)$' + exclude = [ # don't report on objects that match any of these regex + '\.undocumented_method$', + '\.__repr__$', + ] + override_SS05 = [ # override SS05 to allow docstrings starting with these words + '^Process ', + '^Assess ', + '^Access ', + ] ``setup.cfg``:: [tool:numpydoc_validation] - ignore = EX01,SA01,ES01 - override_SS05 = ^((Process|Assess|Access) ) - override_GL08 = ^(__init__)$ - -For more fine-tuned control, you can also include inline comments to tell the -validation hook to ignore certain checks: + checks = all,EX01,SA01,ES01 + exclude = \.undocumented_method$,\.__repr__$ + override_SS05 = ^Process ,^Assess ,^Access , -.. code-block:: python - - class SomeClass: # numpydoc ignore=EX01,SA01,ES01 - """This is the docstring for SomeClass.""" +In addition to the above, :ref:`inline ignore comments ` +can be used to ignore findings on a case by case basis. - def __init__(self): # numpydoc ignore=GL08 - pass - -If any issues are found when commiting, a report is printed out and the +If any issues are found when commiting, a report is printed out, and the commit is halted: .. code-block:: output @@ -77,7 +90,9 @@ commit is halted: | src/pkg/module.py:33 | module.MyClass.parse | RT03 | Return value has no description | +----------------------+----------------------+---------+--------------------------------------+ -See below for a full listing of checks. +See :ref:`below ` for a full listing of checks. + +.. _validation_via_cli: Docstring Validation using Python --------------------------------- @@ -94,12 +109,16 @@ This will validate that the docstring can be built. 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. +issues. Note that this will honor :ref:`inline ignore comments `, +but will not look for any configuration like the :ref:`pre-commit hook ` +or :ref:`Sphinx extension ` do. + +.. _validation_during_sphinx_build: Docstring Validation during Sphinx Build ---------------------------------------- -It is also possible to run docstring validation as part of the sphinx build +It is also possible to run docstring validation as part of the Sphinx build process. This behavior is controlled by the ``numpydoc_validation_checks`` configuration parameter in ``conf.py``. @@ -109,7 +128,7 @@ following line to ``conf.py``:: numpydoc_validation_checks = {"PR01"} -This will cause a sphinx warning to be raised for any (non-module) docstring +This will cause a Sphinx warning to be raised for any (non-module) docstring that has undocumented parameters in the signature. The full set of validation checks can be activated by:: @@ -125,6 +144,48 @@ special keyword ``"all"``:: # Report warnings for all validation checks except GL01, GL02, and GL05 numpydoc_validation_checks = {"all", "GL01", "GL02", "GL05"} +In addition, you can exclude any findings on certain objects with +``numpydoc_validation_exclude``, which maps to ``exclude`` in the +:ref:`pre-commit hook setup `:: + + # don't report on objects that match any of these regex + numpydoc_validation_exclude = [ + '\.undocumented_method$', + '\.__repr__$', + ] + +Overrides based on docstring contents are also supported, but the structure +is slightly different than the :ref:`pre-commit hook setup `:: + + numpydoc_validation_overrides = { + "SS02": [ # override SS05 to allow docstrings starting with these words + '^Process ', + '^Assess ', + '^Access ', + ] + } + +.. _inline_ignore_comments: + +Ignoring Validation Checks with Inline Comments +----------------------------------------------- + +Sometimes you only want to ignore a specific check or set of checks for a +specific piece of code. This level of fine-tuned control is provided via +inline comments: + +.. code-block:: python + + class SomeClass: # numpydoc ignore=EX01,SA01,ES01 + """This is the docstring for SomeClass.""" + + def __init__(self): # numpydoc ignore=GL08 + pass + +This is supported by the :ref:`CLI `, +:ref:`pre-commit hook `, and +:ref:`Sphinx extension `. + .. _validation_checks: Built-in Validation Checks diff --git a/numpydoc/hooks/validate_docstrings.py b/numpydoc/hooks/validate_docstrings.py index 9570ba18..db8141d8 100644 --- a/numpydoc/hooks/validate_docstrings.py +++ b/numpydoc/hooks/validate_docstrings.py @@ -6,7 +6,6 @@ import os import re import sys -import tokenize try: import tomllib @@ -22,10 +21,6 @@ from .utils import find_project_root -# inline comments that can suppress individual checks per line -IGNORE_COMMENT_PATTERN = re.compile("(?:.* numpydoc ignore[=|:] ?)(.+)") - - class AstValidator(validate.Validator): """ Overrides the :class:`Validator` to work entirely with the AST. @@ -138,19 +133,14 @@ class DocstringVisitor(ast.NodeVisitor): The absolute or relative path to the file to inspect. config : dict Configuration options for reviewing flagged issues. - numpydoc_ignore_comments : dict - A mapping of line number to checks to ignore. - Derived from comments in the source code. """ def __init__( self, filepath: str, config: dict, - numpydoc_ignore_comments: dict, ) -> None: self.config: dict = config - self.numpydoc_ignore_comments = numpydoc_ignore_comments self.filepath: str = filepath self.module_name: str = Path(self.filepath).stem self.stack: list[str] = [] @@ -170,30 +160,18 @@ def _ignore_issue(self, node: ast.AST, check: str) -> bool: Return ------ bool - Whether the issue should be exluded from the report. + Whether the issue should be excluded from the report. """ - if check in self.config["exclusions"]: + if check not in self.config["checks"]: return True if self.config["overrides"]: try: - if check == "GL08": - pattern = self.config["overrides"].get("GL08") - if pattern and re.match(pattern, node.name): - return True - except AttributeError: # ast.Module nodes don't have a name - pass - - if check == "SS05": - pattern = self.config["overrides"].get("SS05") - if pattern and re.match(pattern, ast.get_docstring(node)) is not None: + pattern = self.config["overrides"][check] + if re.search(pattern, ast.get_docstring(node)) is not None: return True - - try: - if check in self.numpydoc_ignore_comments[getattr(node, "lineno", 1)]: - return True - except KeyError: - pass + except KeyError: + pass return False @@ -233,7 +211,13 @@ def visit(self, node: ast.AST) -> None: self.stack.append( self.module_name if isinstance(node, ast.Module) else node.name ) - self._get_numpydoc_issues(node) + + if not ( + self.config["exclude"] + and re.search(self.config["exclude"], ".".join(self.stack)) + ): + self._get_numpydoc_issues(node) + self.generic_visit(node) _ = self.stack.pop() @@ -261,47 +245,74 @@ def parse_config(dir_path: os.PathLike = None) -> dict: dict Config options for the numpydoc validation hook. """ - options = {"exclusions": [], "overrides": {}} + options = {"checks": {"all"}, "exclude": set(), "overrides": {}} dir_path = Path(dir_path).expanduser().resolve() toml_path = dir_path / "pyproject.toml" cfg_path = dir_path / "setup.cfg" + def compile_regex(expressions): + return ( + re.compile(r"|".join(exp for exp in expressions if exp)) + if expressions + else None + ) + + def extract_check_overrides(options, config_items): + for option, value in config_items: + if option.startswith("override_"): + _, check = option.split("_") + if value: + options["overrides"][check.upper()] = compile_regex(value) + if toml_path.is_file(): with open(toml_path, "rb") as toml_file: pyproject_toml = tomllib.load(toml_file) config = pyproject_toml.get("tool", {}).get("numpydoc_validation", {}) - options["exclusions"] = config.get("ignore", []) - for check in ["SS05", "GL08"]: - regex = config.get(f"override_{check}") - if regex: - options["overrides"][check] = re.compile(regex) + options["checks"] = set(config.get("checks", options["checks"])) + + global_exclusions = config.get("exclude", options["exclude"]) + options["exclude"] = set( + global_exclusions + if not isinstance(global_exclusions, str) + else [global_exclusions] + ) + + extract_check_overrides(options, config.items()) + elif cfg_path.is_file(): config = configparser.ConfigParser() config.read(cfg_path) numpydoc_validation_config_section = "tool:numpydoc_validation" try: try: - options["exclusions"] = config.get( - numpydoc_validation_config_section, "ignore" - ).split(",") - except configparser.NoOptionError: - pass - try: - options["overrides"]["SS05"] = re.compile( - config.get(numpydoc_validation_config_section, "override_SS05") + options["checks"] = set( + config.get(numpydoc_validation_config_section, "checks") + .rstrip(",") + .split(",") + or options["checks"] ) except configparser.NoOptionError: pass try: - options["overrides"]["GL08"] = re.compile( - config.get(numpydoc_validation_config_section, "override_GL08") + options["exclude"] = set( + config.get(numpydoc_validation_config_section, "exclude") + .rstrip(",") + .split(",") + or options["exclude"] ) except configparser.NoOptionError: pass + + extract_check_overrides( + options, config.items(numpydoc_validation_config_section) + ) + except configparser.NoSectionError: pass + options["checks"] = validate.get_validation_checks(options["checks"]) + options["exclude"] = compile_regex(options["exclude"]) return options @@ -324,24 +335,7 @@ def process_file(filepath: os.PathLike, config: dict) -> "list[list[str]]": with open(filepath) as file: module_node = ast.parse(file.read(), filepath) - with open(filepath) as file: - numpydoc_ignore_comments = {} - last_declaration = 1 - declarations = ["def", "class"] - for token in tokenize.generate_tokens(file.readline): - if token.type == tokenize.NAME and token.string in declarations: - last_declaration = token.start[0] - if token.type == tokenize.COMMENT: - match = re.match(IGNORE_COMMENT_PATTERN, token.string) - if match: - rules = match.group(1).split(",") - numpydoc_ignore_comments[last_declaration] = rules - - docstring_visitor = DocstringVisitor( - filepath=str(filepath), - config=config, - numpydoc_ignore_comments=numpydoc_ignore_comments, - ) + docstring_visitor = DocstringVisitor(filepath=str(filepath), config=config) docstring_visitor.visit(module_node) return docstring_visitor.findings @@ -357,7 +351,7 @@ def main(argv: Union[Sequence[str], None] = None) -> int: + "\n ".join( [ f"- {check}: {validate.ERROR_MSGS[check]}" - for check in config_options["exclusions"] + for check in set(validate.ERROR_MSGS.keys()) - config_options["checks"] ] ) + "\n" @@ -391,7 +385,7 @@ def main(argv: Union[Sequence[str], None] = None) -> int: ' Currently ignoring the following from ' f'{Path(project_root_from_cwd) / config_file}: {ignored_checks}' 'Values provided here will be in addition to the above, unless an alternate config is provided.' - if config_options["exclusions"] else '' + if config_options["checks"] else '' }""" ), ) @@ -399,7 +393,7 @@ def main(argv: Union[Sequence[str], None] = None) -> int: args = parser.parse_args(argv) project_root, _ = find_project_root(args.files) config_options = parse_config(args.config or project_root) - config_options["exclusions"].extend(args.ignore or []) + config_options["checks"] -= set(args.ignore or []) findings = [] for file in args.files: diff --git a/numpydoc/numpydoc.py b/numpydoc/numpydoc.py index 629fa045..284c7da1 100644 --- a/numpydoc/numpydoc.py +++ b/numpydoc/numpydoc.py @@ -34,7 +34,7 @@ raise RuntimeError("Sphinx 5 or newer is required") from .docscrape_sphinx import get_doc_object -from .validate import validate, ERROR_MSGS +from .validate import validate, ERROR_MSGS, get_validation_checks from .xref import DEFAULT_LINKS from . import __version__ @@ -207,7 +207,19 @@ def mangle_docstrings(app, what, name, obj, options, lines): # 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"] + report = validate(doc) + errors = [ + err + for err in report["errors"] + if not ( + ( + overrides := app.config.numpydoc_validation_overrides.get( + err[0] + ) + ) + and re.search(overrides, report["docstring"]) + ) + ] if {err[0] for err in errors} & app.config.numpydoc_validation_checks: msg = ( f"[numpydoc] Validation warnings while processing " @@ -285,6 +297,7 @@ def setup(app, get_doc_object_=get_doc_object): 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) + app.add_config_value("numpydoc_validation_overrides", dict(), False) # Extra mangling domains app.add_domain(NumpyPythonDomain) @@ -310,17 +323,9 @@ def update_config(app, config=None): # 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}" - ) + config.numpydoc_validation_checks = get_validation_checks( + config.numpydoc_validation_checks + ) # Generate the regexp for docstrings to ignore during validation if isinstance(config.numpydoc_validation_exclude, str): @@ -335,6 +340,11 @@ def update_config(app, config=None): ) config.numpydoc_validation_excluder = exclude_expr + for check, patterns in config.numpydoc_validation_overrides.items(): + config.numpydoc_validation_overrides[check] = re.compile( + r"|".join(exp for exp in patterns) + ) + # ------------------------------------------------------------------------------ # Docstring-mangling domains diff --git a/numpydoc/tests/hooks/example_module.py b/numpydoc/tests/hooks/example_module.py index 6cfa4d47..b36f519a 100644 --- a/numpydoc/tests/hooks/example_module.py +++ b/numpydoc/tests/hooks/example_module.py @@ -28,3 +28,7 @@ def do_something(self, *args, **kwargs): def process(self): """Process stuff.""" pass + + +class NewClass: + pass diff --git a/numpydoc/tests/hooks/test_validate_hook.py b/numpydoc/tests/hooks/test_validate_hook.py index 7c8b8997..5c635dfb 100644 --- a/numpydoc/tests/hooks/test_validate_hook.py +++ b/numpydoc/tests/hooks/test_validate_hook.py @@ -67,6 +67,8 @@ def test_validate_hook(example_module, config, capsys): +-------------------------------------------+-------------------------------------+---------+----------------------------------------------------+ | numpydoc/tests/hooks/example_module.py:28 | example_module.MyClass.process | EX01 | No examples section found | +-------------------------------------------+-------------------------------------+---------+----------------------------------------------------+ + | numpydoc/tests/hooks/example_module.py:33 | example_module.NewClass | GL08 | The object does not have a docstring | + +-------------------------------------------+-------------------------------------+---------+----------------------------------------------------+ """ ) @@ -102,6 +104,8 @@ def test_validate_hook_with_ignore(example_module, capsys): | | | | person (e.g. use "Generate" instead of | | | | | "Generates") | +-------------------------------------------+-------------------------------------+---------+----------------------------------------------------+ + | numpydoc/tests/hooks/example_module.py:33 | example_module.NewClass | GL08 | The object does not have a docstring | + +-------------------------------------------+-------------------------------------+---------+----------------------------------------------------+ """ ) @@ -121,13 +125,18 @@ def test_validate_hook_with_toml_config(example_module, tmp_path, capsys): inspect.cleandoc( """ [tool.numpydoc_validation] - ignore = [ + checks = [ + "all", "EX01", "SA01", "ES01", ] - override_SS05 = '^((Process|Assess|Access) )' - override_GL08 = '^(__init__)$' + exclude = '\\.__init__$' + override_SS05 = [ + '^Process', + '^Assess', + '^Access', + ] """ ) ) @@ -143,6 +152,8 @@ def test_validate_hook_with_toml_config(example_module, tmp_path, capsys): +-------------------------------------------+-------------------------------------+---------+----------------------------------------+ | numpydoc/tests/hooks/example_module.py:18 | example_module.MyClass.do_something | PR07 | Parameter "*args" has no description | +-------------------------------------------+-------------------------------------+---------+----------------------------------------+ + | numpydoc/tests/hooks/example_module.py:33 | example_module.NewClass | GL08 | The object does not have a docstring | + +-------------------------------------------+-------------------------------------+---------+----------------------------------------+ """ ) @@ -162,9 +173,9 @@ def test_validate_hook_with_setup_cfg(example_module, tmp_path, capsys): inspect.cleandoc( """ [tool:numpydoc_validation] - ignore = EX01,SA01,ES01 - override_SS05 = ^((Process|Assess|Access) ) - override_GL08 = ^(__init__)$ + checks = all,EX01,SA01,ES01 + exclude = \\.__init__$ + override_SS05 = ^Process,^Assess,^Access """ ) ) @@ -180,6 +191,8 @@ def test_validate_hook_with_setup_cfg(example_module, tmp_path, capsys): +-------------------------------------------+-------------------------------------+---------+----------------------------------------+ | numpydoc/tests/hooks/example_module.py:18 | example_module.MyClass.do_something | PR07 | Parameter "*args" has no description | +-------------------------------------------+-------------------------------------+---------+----------------------------------------+ + | numpydoc/tests/hooks/example_module.py:33 | example_module.NewClass | GL08 | The object does not have a docstring | + +-------------------------------------------+-------------------------------------+---------+----------------------------------------+ """ ) @@ -198,3 +211,87 @@ def test_validate_hook_help(capsys): out = capsys.readouterr().out assert "--ignore" in out assert "--config" in out + + +def test_validate_hook_exclude_option_pyproject(example_module, tmp_path, capsys): + """ + Test that a file is correctly processed with the config coming from + a pyproject.toml file and exclusions provided. + """ + + with open(tmp_path / "pyproject.toml", "w") as config_file: + config_file.write( + inspect.cleandoc( + r""" + [tool.numpydoc_validation] + checks = [ + "all", + "EX01", + "SA01", + "ES01", + ] + exclude = [ + '\.do_something$', + '\.__init__$', + ] + override_SS05 = [ + '^Process', + '^Assess', + '^Access', + ] + """ + ) + ) + + expected = inspect.cleandoc( + """ + +-------------------------------------------+------------------------------+---------+--------------------------------------+ + | file | item | check | description | + +===========================================+==============================+=========+======================================+ + | numpydoc/tests/hooks/example_module.py:4 | example_module.some_function | PR01 | Parameters {'name'} not documented | + +-------------------------------------------+------------------------------+---------+--------------------------------------+ + | numpydoc/tests/hooks/example_module.py:33 | example_module.NewClass | GL08 | The object does not have a docstring | + +-------------------------------------------+------------------------------+---------+--------------------------------------+ + """ + ) + + return_code = main([example_module, "--config", str(tmp_path)]) + assert return_code == 1 + assert capsys.readouterr().err.rstrip() == expected + + +def test_validate_hook_exclude_option_setup_cfg(example_module, tmp_path, capsys): + """ + Test that a file is correctly processed with the config coming from + a setup.cfg file and exclusions provided. + """ + + with open(tmp_path / "setup.cfg", "w") as config_file: + config_file.write( + inspect.cleandoc( + """ + [tool:numpydoc_validation] + checks = all,EX01,SA01,ES01 + exclude = \\.NewClass$,\\.__init__$ + override_SS05 = ^Process,^Assess,^Access + """ + ) + ) + + expected = inspect.cleandoc( + """ + +-------------------------------------------+-------------------------------------+---------+----------------------------------------+ + | file | item | check | description | + +===========================================+=====================================+=========+========================================+ + | numpydoc/tests/hooks/example_module.py:4 | example_module.some_function | PR01 | Parameters {'name'} not documented | + +-------------------------------------------+-------------------------------------+---------+----------------------------------------+ + | numpydoc/tests/hooks/example_module.py:18 | example_module.MyClass.do_something | PR01 | Parameters {'**kwargs'} not documented | + +-------------------------------------------+-------------------------------------+---------+----------------------------------------+ + | numpydoc/tests/hooks/example_module.py:18 | example_module.MyClass.do_something | PR07 | Parameter "*args" has no description | + +-------------------------------------------+-------------------------------------+---------+----------------------------------------+ + """ + ) + + return_code = main([example_module, "--config", str(tmp_path)]) + assert return_code == 1 + assert capsys.readouterr().err.rstrip() == expected diff --git a/numpydoc/tests/test_docscrape.py b/numpydoc/tests/test_docscrape.py index 4d340b11..1e70008b 100644 --- a/numpydoc/tests/test_docscrape.py +++ b/numpydoc/tests/test_docscrape.py @@ -1576,6 +1576,7 @@ def __init__(self, a, b): # numpydoc.update_config fails if this config option not present self.numpydoc_validation_checks = set() self.numpydoc_validation_exclude = set() + self.numpydoc_validation_overrides = dict() 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 16290786..f92bd82f 100644 --- a/numpydoc/tests/test_numpydoc.py +++ b/numpydoc/tests/test_numpydoc.py @@ -23,6 +23,7 @@ class MockConfig: numpydoc_attributes_as_param_list = True numpydoc_validation_checks = set() numpydoc_validation_exclude = set() + numpydoc_validation_overrides = dict() class MockBuilder: @@ -212,6 +213,39 @@ def function_with_bad_docstring(): assert warning.getvalue() == "" +@pytest.mark.parametrize("overrides", [{}, {"SS02"}, {"SS02", "SS03"}]) +def test_mangle_docstrings_overrides(overrides): + def process_something_noop_function(): + """Process something.""" + + app = MockApp() + app.config.numpydoc_validation_checks = {"all"} + app.config.numpydoc_validation_overrides = { + check: [r"^Process "] # overrides are regex on docstring content + for check in overrides + } + update_config(app) + + # Setup for catching warnings + status, warning = StringIO(), StringIO() + logging.setup(app, status, warning) + + # Run mangle docstrings on process_something_noop_function + mangle_docstrings( + app, + "function", + process_something_noop_function.__name__, + process_something_noop_function, + None, + process_something_noop_function.__doc__.split("\n"), + ) + + findings = warning.getvalue() + assert " EX01: " in findings # should always be there + for check in overrides: + assert f" {check}: " not in findings + + def test_update_config_invalid_validation_set(): app = MockApp() # Results in {'a', 'l'} instead of {"all"} diff --git a/numpydoc/tests/test_validate.py b/numpydoc/tests/test_validate.py index f01cde50..0191e82a 100644 --- a/numpydoc/tests/test_validate.py +++ b/numpydoc/tests/test_validate.py @@ -1,10 +1,75 @@ import pytest import warnings -import numpydoc.validate + +from numpydoc import validate import numpydoc.tests -validate_one = numpydoc.validate.validate +validate_one = validate.validate + +ALL_CHECKS = set(validate.ERROR_MSGS.keys()) + + +@pytest.mark.parametrize( + ["checks", "expected"], + [ + [{"all"}, ALL_CHECKS], + [set(), set()], + [{"EX01"}, {"EX01"}], + [{"EX01", "SA01"}, {"EX01", "SA01"}], + [{"all", "EX01", "SA01"}, ALL_CHECKS - {"EX01", "SA01"}], + [{"all", "PR01"}, ALL_CHECKS - {"PR01"}], + ], +) +def test_utils_get_validation_checks(checks, expected): + """Ensure check selection is working.""" + assert validate.get_validation_checks(checks) == expected + + +@pytest.mark.parametrize( + "checks", + [ + {"every"}, + {None}, + {"SM10"}, + {"EX01", "SM10"}, + ], +) +def test_get_validation_checks_validity(checks): + """Ensure that invalid checks are flagged.""" + with pytest.raises(ValueError, match="Unrecognized validation code"): + _ = validate.get_validation_checks(checks) + + +@pytest.mark.parametrize( + ["file_contents", "expected"], + [ + ["class MyClass:\n pass", {}], + ["class MyClass: # numpydoc ignore=EX01\n pass", {1: ["EX01"]}], + [ + "class MyClass: # numpydoc ignore= EX01,SA01\n pass", + {1: ["EX01", "SA01"]}, + ], + [ + "class MyClass:\n def my_method(): # numpydoc ignore:EX01\n pass", + {2: ["EX01"]}, + ], + [ + "class MyClass:\n def my_method(): # numpydoc ignore: EX01,PR01\n pass", + {2: ["EX01", "PR01"]}, + ], + [ + "class MyClass: # numpydoc ignore=GL08\n def my_method(): # numpydoc ignore:EX01,PR01\n pass", + {1: ["GL08"], 2: ["EX01", "PR01"]}, + ], + ], +) +def test_extract_ignore_validation_comments(tmp_path, file_contents, expected): + """Test that extraction of validation ignore comments is working.""" + filepath = tmp_path / "ignore_comments.py" + with open(filepath, "w") as file: + file.write(file_contents) + assert validate.extract_ignore_validation_comments(filepath) == expected class GoodDocStrings: diff --git a/numpydoc/validate.py b/numpydoc/validate.py index 8c7f9b83..481b309e 100644 --- a/numpydoc/validate.py +++ b/numpydoc/validate.py @@ -5,13 +5,19 @@ Call ``validate(object_name_to_validate)`` to get a dictionary with all the detected errors. """ + +from copy import deepcopy +from typing import Dict, List, Set import ast import collections import importlib import inspect +import os import pydoc import re import textwrap +import tokenize + from .docscrape import get_doc_object @@ -101,6 +107,72 @@ # Ignore these when evaluating end-of-line-"." checks IGNORE_STARTS = (" ", "* ", "- ") +# inline comments that can suppress individual checks per line +IGNORE_COMMENT_PATTERN = re.compile("(?:.* numpydoc ignore[=|:] ?)(.+)") + + +def extract_ignore_validation_comments(filepath: os.PathLike) -> Dict[int, List[str]]: + """ + Extract inline comments indicating certain validation checks should be ignored. + + Parameters + ---------- + filepath : os.PathLike + Path to the file being inspected. + + Returns + ------- + dict[int, list[str]] + Mapping of line number to a list of checks to ignore. + """ + with open(filepath) as file: + numpydoc_ignore_comments = {} + last_declaration = 1 + declarations = ["def", "class"] + for token in tokenize.generate_tokens(file.readline): + if token.type == tokenize.NAME and token.string in declarations: + last_declaration = token.start[0] + if token.type == tokenize.COMMENT: + match = re.match(IGNORE_COMMENT_PATTERN, token.string) + if match: + rules = match.group(1).split(",") + numpydoc_ignore_comments[last_declaration] = rules + return numpydoc_ignore_comments + + +def get_validation_checks(validation_checks: Set[str]) -> Set[str]: + """ + Get the set of validation checks to report on. + + Parameters + ---------- + validation_checks : set[str] + A set of validation checks to report on. If the set is ``{"all"}``, + all checks will be reported. If the set contains just specific checks, + only those will be reported on. If the set contains both ``"all"`` and + specific checks, all checks except those included in the set will be + reported on. + + Returns + ------- + set[str] + The set of validation checks to report on. + """ + valid_error_codes = set(ERROR_MSGS.keys()) + if "all" in validation_checks: + block = deepcopy(validation_checks) + validation_checks = valid_error_codes - block + + # Ensure that the validation check set contains only valid error codes + invalid_error_codes = 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}" + ) + + return validation_checks + def error(code, **kwargs): """ @@ -506,9 +578,16 @@ def validate(obj_name, validator_cls=None, **validator_kwargs): else: doc = validator_cls(obj_name=obj_name, **validator_kwargs) + # lineno is only 0 if we have a module docstring in the file and we are + # validating that, so we change to 1 for readability of the output + ignore_validation_comments = extract_ignore_validation_comments( + doc.source_file_name + ).get(doc.source_file_def_line or 1, []) + errs = [] if not doc.raw_doc: - errs.append(error("GL08")) + if "GL08" not in ignore_validation_comments: + errs.append(error("GL08")) return { "type": doc.type, "docstring": doc.clean_doc, @@ -630,6 +709,9 @@ def validate(obj_name, validator_cls=None, **validator_kwargs): if not doc.examples: errs.append(error("EX01")) + + errs = [err for err in errs if err[0] not in ignore_validation_comments] + return { "type": doc.type, "docstring": doc.clean_doc,