From 51bcea6e624e82da93069073240c870964122642 Mon Sep 17 00:00:00 2001 From: Stefanie Molin <24376333+stefmolin@users.noreply.github.com> Date: Sat, 1 Jul 2023 14:59:25 -0400 Subject: [PATCH 01/21] Extract check selection logic from sphinx side into utility function. --- numpydoc/numpydoc.py | 15 ++++----------- numpydoc/utils.py | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 11 deletions(-) create mode 100644 numpydoc/utils.py diff --git a/numpydoc/numpydoc.py b/numpydoc/numpydoc.py index 629fa045..03a4fd2b 100644 --- a/numpydoc/numpydoc.py +++ b/numpydoc/numpydoc.py @@ -34,6 +34,7 @@ raise RuntimeError("Sphinx 5 or newer is required") from .docscrape_sphinx import get_doc_object +from .utils import get_validation_checks from .validate import validate, ERROR_MSGS from .xref import DEFAULT_LINKS from . import __version__ @@ -310,17 +311,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): diff --git a/numpydoc/utils.py b/numpydoc/utils.py new file mode 100644 index 00000000..c5978f66 --- /dev/null +++ b/numpydoc/utils.py @@ -0,0 +1,41 @@ +"""Utility functions for numpydoc.""" + +from copy import deepcopy +from typing import Set + +from .validate import ERROR_MSGS + + +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. + """ + # TODO: add tests + 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 From 61b83cfb9618970f1d59c86a491f05b2d6655266 Mon Sep 17 00:00:00 2001 From: Stefanie Molin <24376333+stefmolin@users.noreply.github.com> Date: Sat, 1 Jul 2023 14:59:55 -0400 Subject: [PATCH 02/21] Add test cases for get_validation_checks(). --- numpydoc/tests/test_utils.py | 39 ++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 numpydoc/tests/test_utils.py diff --git a/numpydoc/tests/test_utils.py b/numpydoc/tests/test_utils.py new file mode 100644 index 00000000..dc5c9b23 --- /dev/null +++ b/numpydoc/tests/test_utils.py @@ -0,0 +1,39 @@ +"""Test numpydoc.utils functions.""" + +import pytest + +from numpydoc.validate import ERROR_MSGS +from numpydoc.utils import get_validation_checks + +ALL_CHECKS = set(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 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"): + _ = get_validation_checks(checks) From 5a4c88bf89117fb45f59b31a35461b763f50ad39 Mon Sep 17 00:00:00 2001 From: Stefanie Molin <24376333+stefmolin@users.noreply.github.com> Date: Sat, 1 Jul 2023 15:01:41 -0400 Subject: [PATCH 03/21] Change pre-commit hook behavior from check exclusion to check selection like sphinx side. --- numpydoc/hooks/validate_docstrings.py | 21 ++++++++++++--------- numpydoc/tests/hooks/test_validate_hook.py | 5 +++-- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/numpydoc/hooks/validate_docstrings.py b/numpydoc/hooks/validate_docstrings.py index 9570ba18..71de15bf 100644 --- a/numpydoc/hooks/validate_docstrings.py +++ b/numpydoc/hooks/validate_docstrings.py @@ -20,6 +20,7 @@ from .. import docscrape, validate from .utils import find_project_root +from ..utils import get_validation_checks # inline comments that can suppress individual checks per line @@ -172,7 +173,7 @@ def _ignore_issue(self, node: ast.AST, check: str) -> bool: bool Whether the issue should be exluded from the report. """ - if check in self.config["exclusions"]: + if check not in self.config["checks"]: return True if self.config["overrides"]: @@ -261,7 +262,7 @@ def parse_config(dir_path: os.PathLike = None) -> dict: dict Config options for the numpydoc validation hook. """ - options = {"exclusions": [], "overrides": {}} + options = {"checks": {"all"}, "overrides": {}} dir_path = Path(dir_path).expanduser().resolve() toml_path = dir_path / "pyproject.toml" @@ -271,7 +272,7 @@ def parse_config(dir_path: os.PathLike = None) -> dict: 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", []) + options["checks"] = set(config.get("checks", options["checks"])) for check in ["SS05", "GL08"]: regex = config.get(f"override_{check}") if regex: @@ -282,9 +283,10 @@ def parse_config(dir_path: os.PathLike = None) -> dict: numpydoc_validation_config_section = "tool:numpydoc_validation" try: try: - options["exclusions"] = config.get( - numpydoc_validation_config_section, "ignore" - ).split(",") + options["checks"] = set( + config.get(numpydoc_validation_config_section, "checks").split(",") + or options["checks"] + ) except configparser.NoOptionError: pass try: @@ -302,6 +304,7 @@ def parse_config(dir_path: os.PathLike = None) -> dict: except configparser.NoSectionError: pass + options["checks"] = get_validation_checks(options["checks"]) return options @@ -357,7 +360,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 +394,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 +402,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/tests/hooks/test_validate_hook.py b/numpydoc/tests/hooks/test_validate_hook.py index 7c8b8997..73cb0044 100644 --- a/numpydoc/tests/hooks/test_validate_hook.py +++ b/numpydoc/tests/hooks/test_validate_hook.py @@ -121,7 +121,8 @@ def test_validate_hook_with_toml_config(example_module, tmp_path, capsys): inspect.cleandoc( """ [tool.numpydoc_validation] - ignore = [ + checks = [ + "all", "EX01", "SA01", "ES01", @@ -162,7 +163,7 @@ def test_validate_hook_with_setup_cfg(example_module, tmp_path, capsys): inspect.cleandoc( """ [tool:numpydoc_validation] - ignore = EX01,SA01,ES01 + checks = all,EX01,SA01,ES01 override_SS05 = ^((Process|Assess|Access) ) override_GL08 = ^(__init__)$ """ From 729e563b6f3a2eb50dd807cb3d60fa83baa8d5b9 Mon Sep 17 00:00:00 2001 From: Stefanie Molin <24376333+stefmolin@users.noreply.github.com> Date: Sat, 1 Jul 2023 15:09:03 -0400 Subject: [PATCH 04/21] Update example configs for pre-commit hook in docs. --- doc/validation.rst | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/doc/validation.rst b/doc/validation.rst index 09196ea5..25051fc7 100644 --- a/doc/validation.rst +++ b/doc/validation.rst @@ -22,10 +22,11 @@ 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 +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 (using the same logic as the +:ref:`validation during Sphinx build <_validation_during_sphinx_build>`) 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). @@ -33,7 +34,8 @@ the class/method/function with name "__init__" to not have a docstring). ``pyproject.toml``:: [tool.numpydoc_validation] - ignore = [ + checks = [ + "all", # run all checks, except the below "EX01", "SA01", "ES01", @@ -44,7 +46,7 @@ the class/method/function with name "__init__" to not have a docstring). ``setup.cfg``:: [tool:numpydoc_validation] - ignore = EX01,SA01,ES01 + checks = all,EX01,SA01,ES01 override_SS05 = ^((Process|Assess|Access) ) override_GL08 = ^(__init__)$ @@ -96,6 +98,8 @@ For an exhaustive validation of the formatting of the docstring, use the incorrect capitalization, wrong order of the sections, and many other issues. +.. _validation_during_sphinx_build + Docstring Validation during Sphinx Build ---------------------------------------- From 4c3b90f8322811bf8ed3af976d49496c21f137a2 Mon Sep 17 00:00:00 2001 From: Stefanie Molin <24376333+stefmolin@users.noreply.github.com> Date: Sat, 1 Jul 2023 16:04:14 -0400 Subject: [PATCH 05/21] Add global exclusion option to pre-commit hook. --- numpydoc/hooks/validate_docstrings.py | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/numpydoc/hooks/validate_docstrings.py b/numpydoc/hooks/validate_docstrings.py index 71de15bf..884d8715 100644 --- a/numpydoc/hooks/validate_docstrings.py +++ b/numpydoc/hooks/validate_docstrings.py @@ -176,6 +176,11 @@ def _ignore_issue(self, node: ast.AST, check: str) -> bool: if check not in self.config["checks"]: return True + if self.config["exclude"] and re.search( + self.config["exclude"], ".".join(self.stack) + ): + return True + if self.config["overrides"]: try: if check == "GL08": @@ -262,7 +267,7 @@ def parse_config(dir_path: os.PathLike = None) -> dict: dict Config options for the numpydoc validation hook. """ - options = {"checks": {"all"}, "overrides": {}} + options = {"checks": {"all"}, "exclude": set(), "overrides": {}} dir_path = Path(dir_path).expanduser().resolve() toml_path = dir_path / "pyproject.toml" @@ -273,6 +278,7 @@ def parse_config(dir_path: os.PathLike = None) -> dict: pyproject_toml = tomllib.load(toml_file) config = pyproject_toml.get("tool", {}).get("numpydoc_validation", {}) options["checks"] = set(config.get("checks", options["checks"])) + options["exclude"] = set(config.get("exclude", options["exclude"])) for check in ["SS05", "GL08"]: regex = config.get(f"override_{check}") if regex: @@ -284,11 +290,22 @@ def parse_config(dir_path: os.PathLike = None) -> dict: try: try: options["checks"] = set( - config.get(numpydoc_validation_config_section, "checks").split(",") + config.get(numpydoc_validation_config_section, "checks") + .rstrip(",") + .split(",") or options["checks"] ) except configparser.NoOptionError: pass + try: + options["exclude"] = set( + config.get(numpydoc_validation_config_section, "exclude") + .rstrip(",") + .split(",") + or options["exclude"] + ) + except configparser.NoOptionError: + pass try: options["overrides"]["SS05"] = re.compile( config.get(numpydoc_validation_config_section, "override_SS05") @@ -305,6 +322,11 @@ def parse_config(dir_path: os.PathLike = None) -> dict: pass options["checks"] = get_validation_checks(options["checks"]) + options["exclude"] = ( + re.compile(r"|".join(exp for exp in options["exclude"])) + if options["exclude"] + else None + ) return options From cd4316caae56981b570b8d81972cff0b8849ff05 Mon Sep 17 00:00:00 2001 From: Stefanie Molin <24376333+stefmolin@users.noreply.github.com> Date: Sat, 1 Jul 2023 16:05:24 -0400 Subject: [PATCH 06/21] Add test cases for global exclusion pre-commit option. --- numpydoc/tests/hooks/example_module.py | 4 + numpydoc/tests/hooks/test_validate_hook.py | 89 ++++++++++++++++++++++ 2 files changed, 93 insertions(+) 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 73cb0044..33e9f389 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 | + +-------------------------------------------+-------------------------------------+---------+----------------------------------------------------+ """ ) @@ -144,6 +148,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 | + +-------------------------------------------+-------------------------------------+---------+----------------------------------------+ """ ) @@ -181,6 +187,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 | + +-------------------------------------------+-------------------------------------+---------+----------------------------------------+ """ ) @@ -199,3 +207,84 @@ 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", + ] + override_SS05 = '^((Process|Assess|Access) )' + exclude = [ + '\.do_something$', + '\.__init__$', + ] + """ + ) + ) + + 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 + override_SS05 = ^((Process|Assess|Access) ) + override_GL08 = ^(__init__)$ + exclude = \\.NewClass$, + """ + ) + ) + + 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 From 8a88bda745630975fa33665b49fb83b08f6f4638 Mon Sep 17 00:00:00 2001 From: Stefanie Molin <24376333+stefmolin@users.noreply.github.com> Date: Sat, 1 Jul 2023 16:15:37 -0400 Subject: [PATCH 07/21] Update docs for global exclusion option. --- doc/validation.rst | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/doc/validation.rst b/doc/validation.rst index 25051fc7..1c48b824 100644 --- a/doc/validation.rst +++ b/doc/validation.rst @@ -25,11 +25,15 @@ command line options for this hook: 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 (using the same logic as the -:ref:`validation during Sphinx build <_validation_during_sphinx_build>`) -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). +the pre-commit hook as follows: + +* ``checks``: Run all checks except ``EX01``, ``SA01``, and ``ES01`` (using the + same logic as the :ref:`validation during Sphinx build + <_validation_during_sphinx_build>`). +* ``exclude``: Don't report any issues on anything matching the regular + regular expressions ``\.undocumented_method$`` or ``\.__repr__$``. +* ``override_SS05``: Allow docstrings to start with "Process ", "Assess ", + or "Access ". ``pyproject.toml``:: @@ -40,6 +44,10 @@ the class/method/function with name "__init__" to not have a docstring). "SA01", "ES01", ] + exclude = [ + '\.undocumented_method$', + '\.__repr__$', + ] override_SS05 = '^((Process|Assess|Access) )' override_GL08 = '^(__init__)$' @@ -47,6 +55,7 @@ the class/method/function with name "__init__" to not have a docstring). [tool:numpydoc_validation] checks = all,EX01,SA01,ES01 + exclude = \.undocumented_method$,\.__repr__$ override_SS05 = ^((Process|Assess|Access) ) override_GL08 = ^(__init__)$ From 4a6c2ec39114a83591bbed52eabbec591c5f699b Mon Sep 17 00:00:00 2001 From: Stefanie Molin <24376333+stefmolin@users.noreply.github.com> Date: Sat, 1 Jul 2023 16:23:42 -0400 Subject: [PATCH 08/21] Deprecate override_GL08 since global exclusion covers this. --- numpydoc/hooks/validate_docstrings.py | 25 ++++++++-------------- numpydoc/tests/hooks/test_validate_hook.py | 7 +++--- 2 files changed, 12 insertions(+), 20 deletions(-) diff --git a/numpydoc/hooks/validate_docstrings.py b/numpydoc/hooks/validate_docstrings.py index 884d8715..5c35e16c 100644 --- a/numpydoc/hooks/validate_docstrings.py +++ b/numpydoc/hooks/validate_docstrings.py @@ -182,14 +182,6 @@ def _ignore_issue(self, node: ast.AST, check: str) -> bool: 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: @@ -278,8 +270,15 @@ def parse_config(dir_path: os.PathLike = None) -> dict: pyproject_toml = tomllib.load(toml_file) config = pyproject_toml.get("tool", {}).get("numpydoc_validation", {}) options["checks"] = set(config.get("checks", options["checks"])) - options["exclude"] = set(config.get("exclude", options["exclude"])) - for check in ["SS05", "GL08"]: + + global_exclusions = config.get("exclude", options["exclude"]) + options["exclude"] = set( + global_exclusions + if isinstance(global_exclusions, list) + else [global_exclusions] + ) + + for check in ["SS05"]: regex = config.get(f"override_{check}") if regex: options["overrides"][check] = re.compile(regex) @@ -312,12 +311,6 @@ def parse_config(dir_path: os.PathLike = None) -> dict: ) except configparser.NoOptionError: pass - try: - options["overrides"]["GL08"] = re.compile( - config.get(numpydoc_validation_config_section, "override_GL08") - ) - except configparser.NoOptionError: - pass except configparser.NoSectionError: pass diff --git a/numpydoc/tests/hooks/test_validate_hook.py b/numpydoc/tests/hooks/test_validate_hook.py index 33e9f389..4d40290a 100644 --- a/numpydoc/tests/hooks/test_validate_hook.py +++ b/numpydoc/tests/hooks/test_validate_hook.py @@ -132,7 +132,7 @@ def test_validate_hook_with_toml_config(example_module, tmp_path, capsys): "ES01", ] override_SS05 = '^((Process|Assess|Access) )' - override_GL08 = '^(__init__)$' + exclude = '\\.__init__$' """ ) ) @@ -170,8 +170,8 @@ def test_validate_hook_with_setup_cfg(example_module, tmp_path, capsys): """ [tool:numpydoc_validation] checks = all,EX01,SA01,ES01 + exclude = \\.__init__$ override_SS05 = ^((Process|Assess|Access) ) - override_GL08 = ^(__init__)$ """ ) ) @@ -265,8 +265,7 @@ def test_validate_hook_exclude_option_setup_cfg(example_module, tmp_path, capsys [tool:numpydoc_validation] checks = all,EX01,SA01,ES01 override_SS05 = ^((Process|Assess|Access) ) - override_GL08 = ^(__init__)$ - exclude = \\.NewClass$, + exclude = \\.NewClass$,\\.__init__$ """ ) ) From ff8db01880e1ff4c7612fb682e0188bb1c88e4e1 Mon Sep 17 00:00:00 2001 From: Stefanie Molin <24376333+stefmolin@users.noreply.github.com> Date: Sat, 1 Jul 2023 16:47:48 -0400 Subject: [PATCH 09/21] Expand override logic for pre-commit hook to any check. --- numpydoc/hooks/validate_docstrings.py | 45 +++++++++++++--------- numpydoc/tests/hooks/test_validate_hook.py | 16 ++++++-- 2 files changed, 39 insertions(+), 22 deletions(-) diff --git a/numpydoc/hooks/validate_docstrings.py b/numpydoc/hooks/validate_docstrings.py index 5c35e16c..8a63202e 100644 --- a/numpydoc/hooks/validate_docstrings.py +++ b/numpydoc/hooks/validate_docstrings.py @@ -182,10 +182,12 @@ def _ignore_issue(self, node: ast.AST, check: str) -> bool: return True if self.config["overrides"]: - if check == "SS05": - pattern = self.config["overrides"].get("SS05") - if pattern and re.match(pattern, ast.get_docstring(node)) is not None: + try: + pattern = self.config["overrides"][check] + if re.search(pattern, ast.get_docstring(node)) is not None: return True + except KeyError: + pass try: if check in self.numpydoc_ignore_comments[getattr(node, "lineno", 1)]: @@ -265,6 +267,20 @@ def parse_config(dir_path: os.PathLike = None) -> dict: 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) @@ -278,10 +294,8 @@ def parse_config(dir_path: os.PathLike = None) -> dict: else [global_exclusions] ) - for check in ["SS05"]: - regex = config.get(f"override_{check}") - if regex: - options["overrides"][check] = re.compile(regex) + extract_check_overrides(options, config.items()) + elif cfg_path.is_file(): config = configparser.ConfigParser() config.read(cfg_path) @@ -305,21 +319,16 @@ def parse_config(dir_path: os.PathLike = None) -> dict: ) except configparser.NoOptionError: pass - try: - options["overrides"]["SS05"] = re.compile( - config.get(numpydoc_validation_config_section, "override_SS05") - ) - except configparser.NoOptionError: - pass + + extract_check_overrides( + options, config.items(numpydoc_validation_config_section) + ) + except configparser.NoSectionError: pass options["checks"] = get_validation_checks(options["checks"]) - options["exclude"] = ( - re.compile(r"|".join(exp for exp in options["exclude"])) - if options["exclude"] - else None - ) + options["exclude"] = compile_regex(options["exclude"]) return options diff --git a/numpydoc/tests/hooks/test_validate_hook.py b/numpydoc/tests/hooks/test_validate_hook.py index 4d40290a..5c635dfb 100644 --- a/numpydoc/tests/hooks/test_validate_hook.py +++ b/numpydoc/tests/hooks/test_validate_hook.py @@ -131,8 +131,12 @@ def test_validate_hook_with_toml_config(example_module, tmp_path, capsys): "SA01", "ES01", ] - override_SS05 = '^((Process|Assess|Access) )' exclude = '\\.__init__$' + override_SS05 = [ + '^Process', + '^Assess', + '^Access', + ] """ ) ) @@ -171,7 +175,7 @@ def test_validate_hook_with_setup_cfg(example_module, tmp_path, capsys): [tool:numpydoc_validation] checks = all,EX01,SA01,ES01 exclude = \\.__init__$ - override_SS05 = ^((Process|Assess|Access) ) + override_SS05 = ^Process,^Assess,^Access """ ) ) @@ -226,11 +230,15 @@ def test_validate_hook_exclude_option_pyproject(example_module, tmp_path, capsys "SA01", "ES01", ] - override_SS05 = '^((Process|Assess|Access) )' exclude = [ '\.do_something$', '\.__init__$', ] + override_SS05 = [ + '^Process', + '^Assess', + '^Access', + ] """ ) ) @@ -264,8 +272,8 @@ def test_validate_hook_exclude_option_setup_cfg(example_module, tmp_path, capsys """ [tool:numpydoc_validation] checks = all,EX01,SA01,ES01 - override_SS05 = ^((Process|Assess|Access) ) exclude = \\.NewClass$,\\.__init__$ + override_SS05 = ^Process,^Assess,^Access """ ) ) From 437212293ae275e19ad799769e0d079168d8a70a Mon Sep 17 00:00:00 2001 From: Stefanie Molin <24376333+stefmolin@users.noreply.github.com> Date: Sat, 1 Jul 2023 16:53:15 -0400 Subject: [PATCH 10/21] Update override examples in docs. --- doc/validation.rst | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/doc/validation.rst b/doc/validation.rst index 1c48b824..c6fac9a6 100644 --- a/doc/validation.rst +++ b/doc/validation.rst @@ -33,7 +33,8 @@ the pre-commit hook as follows: * ``exclude``: Don't report any issues on anything matching the regular regular expressions ``\.undocumented_method$`` or ``\.__repr__$``. * ``override_SS05``: Allow docstrings to start with "Process ", "Assess ", - or "Access ". + or "Access ". To override different checks, add a field for each code in + the form of ``override_``. ``pyproject.toml``:: @@ -48,16 +49,18 @@ the pre-commit hook as follows: '\.undocumented_method$', '\.__repr__$', ] - override_SS05 = '^((Process|Assess|Access) )' - override_GL08 = '^(__init__)$' + override_SS05 = [ + '^Process', + '^Assess', + '^Access', + ] ``setup.cfg``:: [tool:numpydoc_validation] checks = all,EX01,SA01,ES01 exclude = \.undocumented_method$,\.__repr__$ - override_SS05 = ^((Process|Assess|Access) ) - override_GL08 = ^(__init__)$ + override_SS05 = ^Process,^Assess,^Access For more fine-tuned control, you can also include inline comments to tell the validation hook to ignore certain checks: From 06049e2f06502575787a9be894c76815791aa75c Mon Sep 17 00:00:00 2001 From: Stefanie Molin <24376333+stefmolin@users.noreply.github.com> Date: Sat, 1 Jul 2023 17:33:37 -0400 Subject: [PATCH 11/21] Check for exclusion before running checks. --- numpydoc/hooks/validate_docstrings.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/numpydoc/hooks/validate_docstrings.py b/numpydoc/hooks/validate_docstrings.py index 8a63202e..a6a5a6a0 100644 --- a/numpydoc/hooks/validate_docstrings.py +++ b/numpydoc/hooks/validate_docstrings.py @@ -176,11 +176,6 @@ def _ignore_issue(self, node: ast.AST, check: str) -> bool: if check not in self.config["checks"]: return True - if self.config["exclude"] and re.search( - self.config["exclude"], ".".join(self.stack) - ): - return True - if self.config["overrides"]: try: pattern = self.config["overrides"][check] @@ -233,7 +228,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() From bb311e8022fcd589671698ea670e282599f8d5ee Mon Sep 17 00:00:00 2001 From: Stefanie Molin <24376333+stefmolin@users.noreply.github.com> Date: Sat, 1 Jul 2023 18:55:07 -0400 Subject: [PATCH 12/21] Port inline comments for ignoring checks to sphinx side. --- numpydoc/hooks/validate_docstrings.py | 25 +------- numpydoc/numpydoc.py | 3 +- numpydoc/tests/test_utils.py | 39 ------------- numpydoc/tests/test_validate.py | 69 +++++++++++++++++++++- numpydoc/utils.py | 41 ------------- numpydoc/validate.py | 83 ++++++++++++++++++++++++++- 6 files changed, 153 insertions(+), 107 deletions(-) delete mode 100644 numpydoc/tests/test_utils.py delete mode 100644 numpydoc/utils.py diff --git a/numpydoc/hooks/validate_docstrings.py b/numpydoc/hooks/validate_docstrings.py index a6a5a6a0..b89164fb 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 @@ -20,11 +19,6 @@ from .. import docscrape, validate from .utils import find_project_root -from ..utils import get_validation_checks - - -# inline comments that can suppress individual checks per line -IGNORE_COMMENT_PATTERN = re.compile("(?:.* numpydoc ignore[=|:] ?)(.+)") class AstValidator(validate.Validator): @@ -171,7 +165,7 @@ 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 not in self.config["checks"]: return True @@ -328,7 +322,7 @@ def extract_check_overrides(options, config_items): except configparser.NoSectionError: pass - options["checks"] = get_validation_checks(options["checks"]) + options["checks"] = validate.get_validation_checks(options["checks"]) options["exclude"] = compile_regex(options["exclude"]) return options @@ -352,23 +346,10 @@ 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, + numpydoc_ignore_comments=validate.extract_ignore_validation_comments(filepath), ) docstring_visitor.visit(module_node) diff --git a/numpydoc/numpydoc.py b/numpydoc/numpydoc.py index 03a4fd2b..d347c7bc 100644 --- a/numpydoc/numpydoc.py +++ b/numpydoc/numpydoc.py @@ -34,8 +34,7 @@ raise RuntimeError("Sphinx 5 or newer is required") from .docscrape_sphinx import get_doc_object -from .utils import get_validation_checks -from .validate import validate, ERROR_MSGS +from .validate import validate, ERROR_MSGS, get_validation_checks from .xref import DEFAULT_LINKS from . import __version__ diff --git a/numpydoc/tests/test_utils.py b/numpydoc/tests/test_utils.py deleted file mode 100644 index dc5c9b23..00000000 --- a/numpydoc/tests/test_utils.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Test numpydoc.utils functions.""" - -import pytest - -from numpydoc.validate import ERROR_MSGS -from numpydoc.utils import get_validation_checks - -ALL_CHECKS = set(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 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"): - _ = get_validation_checks(checks) 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/utils.py b/numpydoc/utils.py deleted file mode 100644 index c5978f66..00000000 --- a/numpydoc/utils.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Utility functions for numpydoc.""" - -from copy import deepcopy -from typing import Set - -from .validate import ERROR_MSGS - - -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. - """ - # TODO: add tests - 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 diff --git a/numpydoc/validate.py b/numpydoc/validate.py index 8c7f9b83..7858d2fd 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 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,15 @@ def validate(obj_name, validator_cls=None, **validator_kwargs): else: doc = validator_cls(obj_name=obj_name, **validator_kwargs) + # module docstring will be lineno 0, which 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 +708,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, From e857c12b8c48985c367f5f67ea318e439161c04e Mon Sep 17 00:00:00 2001 From: Stefanie Molin <24376333+stefmolin@users.noreply.github.com> Date: Sat, 1 Jul 2023 18:57:53 -0400 Subject: [PATCH 13/21] Move note in docs on inline comments to its own section now that both sides support it. --- doc/validation.rst | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/doc/validation.rst b/doc/validation.rst index c6fac9a6..cc6607e1 100644 --- a/doc/validation.rst +++ b/doc/validation.rst @@ -62,17 +62,6 @@ the pre-commit hook as follows: exclude = \.undocumented_method$,\.__repr__$ override_SS05 = ^Process,^Assess,^Access -For more fine-tuned control, you can also include inline comments to tell the -validation hook to ignore certain checks: - -.. code-block:: python - - class SomeClass: # numpydoc ignore=EX01,SA01,ES01 - """This is the docstring for SomeClass.""" - - def __init__(self): # numpydoc ignore=GL08 - pass - If any issues are found when commiting, a report is printed out and the commit is halted: @@ -159,3 +148,17 @@ The full mapping of validation checks is given below. .. literalinclude:: ../numpydoc/validate.py :start-after: start-err-msg :end-before: end-err-msg + +Ignoring Validation Checks with Inline Comments +----------------------------------------------- + +For more fine-tuned control, you can also include inline comments +to ignore certain checks: + +.. code-block:: python + + class SomeClass: # numpydoc ignore=EX01,SA01,ES01 + """This is the docstring for SomeClass.""" + + def __init__(self): # numpydoc ignore=GL08 + pass From 6733384fcf757adfa6315f477ea19056c55b219a Mon Sep 17 00:00:00 2001 From: Stefanie Molin <24376333+stefmolin@users.noreply.github.com> Date: Sat, 1 Jul 2023 19:20:27 -0400 Subject: [PATCH 14/21] Remove ignore comment logic from hook now that it is in validate(). --- numpydoc/hooks/validate_docstrings.py | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/numpydoc/hooks/validate_docstrings.py b/numpydoc/hooks/validate_docstrings.py index b89164fb..e5eab4a6 100644 --- a/numpydoc/hooks/validate_docstrings.py +++ b/numpydoc/hooks/validate_docstrings.py @@ -133,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] = [] @@ -178,12 +173,6 @@ def _ignore_issue(self, node: ast.AST, check: str) -> bool: except KeyError: pass - try: - if check in self.numpydoc_ignore_comments[getattr(node, "lineno", 1)]: - return True - except KeyError: - pass - return False def _get_numpydoc_issues(self, node: ast.AST) -> None: @@ -346,11 +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) - docstring_visitor = DocstringVisitor( - filepath=str(filepath), - config=config, - numpydoc_ignore_comments=validate.extract_ignore_validation_comments(filepath), - ) + docstring_visitor = DocstringVisitor(filepath=str(filepath), config=config) docstring_visitor.visit(module_node) return docstring_visitor.findings From 3c3069944b54010103562db15ef9b5a0de94e9d0 Mon Sep 17 00:00:00 2001 From: Stefanie Molin <24376333+stefmolin@users.noreply.github.com> Date: Sun, 2 Jul 2023 16:23:23 -0400 Subject: [PATCH 15/21] Add note on inline comment usage for --validate. --- doc/validation.rst | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/doc/validation.rst b/doc/validation.rst index cc6607e1..96ee4b61 100644 --- a/doc/validation.rst +++ b/doc/validation.rst @@ -97,9 +97,11 @@ 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 pre-commit hook or Sphinx +extension do. -.. _validation_during_sphinx_build +.. _validation_during_sphinx_build: Docstring Validation during Sphinx Build ---------------------------------------- @@ -149,6 +151,8 @@ The full mapping of validation checks is given below. :start-after: start-err-msg :end-before: end-err-msg +.. _inline_ignore_comments: + Ignoring Validation Checks with Inline Comments ----------------------------------------------- From e593a1040c4a2d701531a9763b8c623d2d45946c Mon Sep 17 00:00:00 2001 From: Stefanie Molin <24376333+stefmolin@users.noreply.github.com> Date: Sun, 2 Jul 2023 16:25:46 -0400 Subject: [PATCH 16/21] Add numpydoc_validation_overrides option to Sphinx extension side. --- numpydoc/numpydoc.py | 20 ++++++++++++++++++- numpydoc/tests/test_docscrape.py | 1 + numpydoc/tests/test_numpydoc.py | 34 ++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/numpydoc/numpydoc.py b/numpydoc/numpydoc.py index d347c7bc..284c7da1 100644 --- a/numpydoc/numpydoc.py +++ b/numpydoc/numpydoc.py @@ -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) @@ -327,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/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"} From ddb7b49b38d641953b7bc81336d0ae85676b38ef Mon Sep 17 00:00:00 2001 From: Stefanie Molin <24376333+stefmolin@users.noreply.github.com> Date: Sun, 2 Jul 2023 16:26:13 -0400 Subject: [PATCH 17/21] Add numpydoc_validation_overrides option to the docs. --- doc/install.rst | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/doc/install.rst b/doc/install.rst index 963819b9..a8151841 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, to skip 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. From 40269baba997c2a26c64ba95ad30d84bd60397ac Mon Sep 17 00:00:00 2001 From: Stefanie Molin <24376333+stefmolin@users.noreply.github.com> Date: Sun, 2 Jul 2023 17:05:25 -0400 Subject: [PATCH 18/21] Refine additions to docs. --- doc/install.rst | 4 +- doc/validation.rst | 108 ++++++++++++++++++++++++++++++--------------- 2 files changed, 75 insertions(+), 37 deletions(-) diff --git a/doc/install.rst b/doc/install.rst index a8151841..480bd59a 100644 --- a/doc/install.rst +++ b/doc/install.rst @@ -142,8 +142,8 @@ 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, to skip the ``SS02`` check for docstrings starting with - the word ``Process``:: + For example, the following skips the ``SS02`` check for docstrings + starting with the word ``Process``:: numpydoc_validation_overrides = {"SS02": [r'^Process ']} diff --git a/doc/validation.rst b/doc/validation.rst index 96ee4b61..93bd9b0b 100644 --- a/doc/validation.rst +++ b/doc/validation.rst @@ -2,6 +2,8 @@ Validation ========== +.. _pre_commit_hook: + Docstring Validation using Pre-Commit Hook ------------------------------------------ @@ -27,32 +29,37 @@ 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``: Run all checks except ``EX01``, ``SA01``, and ``ES01`` (using the - same logic as the :ref:`validation during Sphinx build - <_validation_during_sphinx_build>`). -* ``exclude``: Don't report any issues on anything matching the regular - regular expressions ``\.undocumented_method$`` or ``\.__repr__$``. +* ``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_``. + 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] checks = [ - "all", # run all checks, except the below + "all", # report on all checks, except the below "EX01", "SA01", "ES01", ] - exclude = [ + exclude = [ # don't report on objects that match any of these regex '\.undocumented_method$', '\.__repr__$', ] - override_SS05 = [ - '^Process', - '^Assess', - '^Access', + override_SS05 = [ # override SS05 to allow docstrings starting with these words + '^Process ', + '^Assess ', + '^Access ', ] ``setup.cfg``:: @@ -60,9 +67,12 @@ the pre-commit hook as follows: [tool:numpydoc_validation] checks = all,EX01,SA01,ES01 exclude = \.undocumented_method$,\.__repr__$ - override_SS05 = ^Process,^Assess,^Access + override_SS05 = ^Process ,^Assess ,^Access , + +In addition to the above, :ref:`inline ignore comments ` +can be used to ignore findings on a case by case basis. -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 @@ -80,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 --------------------------------- @@ -98,15 +110,15 @@ 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. Note that this will honor :ref:`inline ignore comments `, -but will not look for any configuration like the pre-commit hook or Sphinx -extension do. +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``. @@ -116,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:: @@ -132,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 @@ -150,19 +204,3 @@ The full mapping of validation checks is given below. .. literalinclude:: ../numpydoc/validate.py :start-after: start-err-msg :end-before: end-err-msg - -.. _inline_ignore_comments: - -Ignoring Validation Checks with Inline Comments ------------------------------------------------ - -For more fine-tuned control, you can also include inline comments -to ignore certain checks: - -.. code-block:: python - - class SomeClass: # numpydoc ignore=EX01,SA01,ES01 - """This is the docstring for SomeClass.""" - - def __init__(self): # numpydoc ignore=GL08 - pass From 33db2661eedb1fc8945a47a2a2aa1b4329853b0a Mon Sep 17 00:00:00 2001 From: Stefanie Molin <24376333+stefmolin@users.noreply.github.com> Date: Sun, 2 Jul 2023 17:24:14 -0400 Subject: [PATCH 19/21] Clarify comment. --- numpydoc/validate.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/numpydoc/validate.py b/numpydoc/validate.py index 7858d2fd..15ef0dda 100644 --- a/numpydoc/validate.py +++ b/numpydoc/validate.py @@ -578,7 +578,8 @@ def validate(obj_name, validator_cls=None, **validator_kwargs): else: doc = validator_cls(obj_name=obj_name, **validator_kwargs) - # module docstring will be lineno 0, which we change to 1 for readability of the output + # 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, []) From 3f927581625033be4d49f6475cc900f9b318a861 Mon Sep 17 00:00:00 2001 From: Stefanie Molin <24376333+stefmolin@users.noreply.github.com> Date: Sun, 2 Jul 2023 17:43:56 -0400 Subject: [PATCH 20/21] Switch to typing for Python 3.8 --- numpydoc/validate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/numpydoc/validate.py b/numpydoc/validate.py index 15ef0dda..481b309e 100644 --- a/numpydoc/validate.py +++ b/numpydoc/validate.py @@ -7,7 +7,7 @@ """ from copy import deepcopy -from typing import Set +from typing import Dict, List, Set import ast import collections import importlib @@ -111,7 +111,7 @@ IGNORE_COMMENT_PATTERN = re.compile("(?:.* numpydoc ignore[=|:] ?)(.+)") -def extract_ignore_validation_comments(filepath: os.PathLike) -> dict[int, list[str]]: +def extract_ignore_validation_comments(filepath: os.PathLike) -> Dict[int, List[str]]: """ Extract inline comments indicating certain validation checks should be ignored. From 76f265b62e5e2a89aab9051ada2ef4618ca568c8 Mon Sep 17 00:00:00 2001 From: Stefanie Molin <24376333+stefmolin@users.noreply.github.com> Date: Sun, 2 Jul 2023 20:31:55 -0400 Subject: [PATCH 21/21] Fix check on type. --- numpydoc/hooks/validate_docstrings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/numpydoc/hooks/validate_docstrings.py b/numpydoc/hooks/validate_docstrings.py index e5eab4a6..db8141d8 100644 --- a/numpydoc/hooks/validate_docstrings.py +++ b/numpydoc/hooks/validate_docstrings.py @@ -274,7 +274,7 @@ def extract_check_overrides(options, config_items): global_exclusions = config.get("exclude", options["exclude"]) options["exclude"] = set( global_exclusions - if isinstance(global_exclusions, list) + if not isinstance(global_exclusions, str) else [global_exclusions] )