diff --git a/doc/install.rst b/doc/install.rst index c72d777f..87f4a13a 100644 --- a/doc/install.rst +++ b/doc/install.rst @@ -139,6 +139,18 @@ 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_exclude_files : set + A container of strings using :py:mod:`re` syntax specifying path patterns to + ignore for docstring validation. + For example, to skip docstring validation for all objects in + ``tests\``:: + + numpydoc_validation_exclude_files = {"$.*tests/.*"} + + The default is an empty set meaning no paths are excluded from docstring + validation. + Only has an effect when docstring validation is activated, i.e. + ``numpydoc_validation_checks`` is not an empty set. numpydoc_validation_overrides : dict A dictionary mapping :ref:`validation checks ` to a container of strings using :py:mod:`re` syntax specifying patterns to diff --git a/doc/validation.rst b/doc/validation.rst index 3d2babae..0912e5c0 100644 --- a/doc/validation.rst +++ b/doc/validation.rst @@ -36,6 +36,10 @@ the pre-commit hook as follows: expressions ``\.undocumented_method$`` or ``\.__repr__$``. This maps to ``numpydoc_validation_exclude`` from the :ref:`Sphinx build configuration `. +* ``exclude_files``: Exclude file paths matching the regular expressions + ``^tests/.*$`` or ``^module/gui.*$``. This maps to + ``numpydoc_validation_exclude_files`` 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) @@ -57,6 +61,10 @@ the pre-commit hook as follows: '\.undocumented_method$', '\.__repr__$', ] + exclude_files = [ # don't process filepaths that match these regex + '^tests/.*', + '^module/gui.*', + ] override_SS05 = [ # override SS05 to allow docstrings starting with these words '^Process ', '^Assess ', diff --git a/numpydoc/hooks/validate_docstrings.py b/numpydoc/hooks/validate_docstrings.py index aa318e47..a718e4ef 100644 --- a/numpydoc/hooks/validate_docstrings.py +++ b/numpydoc/hooks/validate_docstrings.py @@ -273,7 +273,12 @@ def parse_config(dir_path: os.PathLike = None) -> dict: dict Config options for the numpydoc validation hook. """ - options = {"checks": {"all"}, "exclude": set(), "overrides": {}} + options = { + "checks": {"all"}, + "exclude": set(), + "overrides": {}, + "exclude_files": set(), + } dir_path = Path(dir_path).expanduser().resolve() toml_path = dir_path / "pyproject.toml" @@ -306,6 +311,13 @@ def extract_check_overrides(options, config_items): else [global_exclusions] ) + file_exclusions = config.get("exclude_files", options["exclude_files"]) + options["exclude_files"] = set( + file_exclusions + if not isinstance(file_exclusions, str) + else [file_exclusions] + ) + extract_check_overrides(options, config.items()) elif cfg_path.is_file(): @@ -332,6 +344,16 @@ def extract_check_overrides(options, config_items): except configparser.NoOptionError: pass + try: + options["exclude_files"] = set( + config.get(numpydoc_validation_config_section, "exclude_files") + .rstrip(",") + .split(",") + or options["exclude_files"] + ) + except configparser.NoOptionError: + pass + extract_check_overrides( options, config.items(numpydoc_validation_config_section) ) @@ -341,6 +363,7 @@ def extract_check_overrides(options, config_items): options["checks"] = validate.get_validation_checks(options["checks"]) options["exclude"] = compile_regex(options["exclude"]) + options["exclude_files"] = compile_regex(options["exclude_files"]) return options @@ -395,9 +418,12 @@ def run_hook( project_root, _ = find_project_root(files) config_options = parse_config(config or project_root) config_options["checks"] -= set(ignore or []) + exclude_re = config_options["exclude_files"] findings = False for file in files: + if exclude_re and exclude_re.match(file): + continue if file_issues := process_file(file, config_options): findings = True diff --git a/numpydoc/numpydoc.py b/numpydoc/numpydoc.py index 9a920e35..ff41b0ba 100644 --- a/numpydoc/numpydoc.py +++ b/numpydoc/numpydoc.py @@ -22,11 +22,13 @@ import itertools import pydoc import re +import sys from collections.abc import Callable from copy import deepcopy from docutils.nodes import Text, citation, comment, inline, reference, section from sphinx.addnodes import desc_content, pending_xref +from sphinx.application import Sphinx as SphinxApp from sphinx.util import logging from . import __version__ @@ -52,7 +54,7 @@ def _traverse_or_findall(node, condition, **kwargs): ) -def rename_references(app, what, name, obj, options, lines): +def rename_references(app: SphinxApp, what, name, obj, options, lines): # decorate reference numbers so that there are no duplicates # these are later undecorated in the doctree, in relabel_references references = set() @@ -114,7 +116,7 @@ def is_docstring_section(node): return False -def relabel_references(app, doc): +def relabel_references(app: SphinxApp, doc): # Change 'hash-ref' to 'ref' in label text for citation_node in _traverse_or_findall(doc, citation): if not _is_cite_in_numpydoc_docstring(citation_node): @@ -141,7 +143,7 @@ def matching_pending_xref(node): ref.replace(ref_text, new_text.copy()) -def clean_backrefs(app, doc, docname): +def clean_backrefs(app: SphinxApp, doc, docname): # only::latex directive has resulted in citation backrefs without reference known_ref_ids = set() for ref in _traverse_or_findall(doc, reference, descend=True): @@ -161,7 +163,7 @@ def clean_backrefs(app, doc, docname): DEDUPLICATION_TAG = " !! processed by numpydoc !!" -def mangle_docstrings(app, what, name, obj, options, lines): +def mangle_docstrings(app: SphinxApp, what, name, obj, options, lines): if DEDUPLICATION_TAG in lines: return show_inherited_class_members = app.config.numpydoc_show_inherited_class_members @@ -190,6 +192,23 @@ def mangle_docstrings(app, what, name, obj, options, lines): title_re = re.compile(pattern, re.IGNORECASE | re.DOTALL) lines[:] = title_re.sub("", u_NL.join(lines)).split(u_NL) else: + # Test the obj to find the module path, and skip the check if it's path is matched by + # numpydoc_validation_exclude_files + if ( + app.config.numpydoc_validation_exclude_files + and app.config.numpydoc_validation_checks + ): + excluder = app.config.numpydoc_validation_files_excluder + module = inspect.getmodule(obj) + try: + path = module.__file__ if module else None + except AttributeError: + path = None + + if path and excluder and excluder.search(path): + # Skip validation for this object. + return + try: doc = get_doc_object( obj, what, u_NL.join(lines), config=cfg, builder=app.builder @@ -239,7 +258,7 @@ def mangle_docstrings(app, what, name, obj, options, lines): lines += ["..", DEDUPLICATION_TAG] -def mangle_signature(app, what, name, obj, options, sig, retann): +def mangle_signature(app: SphinxApp, what, name, obj, options, sig, retann): # Do not try to inspect classes that don't define `__init__` if inspect.isclass(obj) and ( not hasattr(obj, "__init__") @@ -273,7 +292,7 @@ def _clean_text_signature(sig): return start_sig + sig + ")" -def setup(app, get_doc_object_=get_doc_object): +def setup(app: SphinxApp, get_doc_object_=get_doc_object): if not hasattr(app, "add_config_value"): return None # probably called by nose, better bail out @@ -299,6 +318,7 @@ def setup(app, get_doc_object_=get_doc_object): app.add_config_value("numpydoc_xref_ignore", set(), True, types=[set, str]) app.add_config_value("numpydoc_validation_checks", set(), True) app.add_config_value("numpydoc_validation_exclude", set(), False) + app.add_config_value("numpydoc_validation_exclude_files", set(), False) app.add_config_value("numpydoc_validation_overrides", dict(), False) # Extra mangling domains @@ -309,7 +329,7 @@ def setup(app, get_doc_object_=get_doc_object): return metadata -def update_config(app, config=None): +def update_config(app: SphinxApp, config=None): """Update the configuration with default values.""" if config is None: # needed for testing and old Sphinx config = app.config @@ -342,6 +362,21 @@ def update_config(app, config=None): ) config.numpydoc_validation_excluder = exclude_expr + # Generate the regexp for files to ignore during validation + if isinstance(config.numpydoc_validation_exclude_files, str): + raise ValueError( + f"numpydoc_validation_exclude_files must be a container of strings, " + f"e.g. [{config.numpydoc_validation_exclude_files!r}]." + ) + + config.numpydoc_validation_files_excluder = None + if config.numpydoc_validation_exclude_files: + exclude_files_expr = re.compile( + r"|".join(exp for exp in config.numpydoc_validation_exclude_files) + ) + config.numpydoc_validation_files_excluder = exclude_files_expr + + # Generate the regexp for validation overrides for check, patterns in config.numpydoc_validation_overrides.items(): config.numpydoc_validation_overrides[check] = re.compile( r"|".join(exp for exp in patterns) diff --git a/numpydoc/tests/hooks/test_validate_hook.py b/numpydoc/tests/hooks/test_validate_hook.py index 47f315c2..b235313b 100644 --- a/numpydoc/tests/hooks/test_validate_hook.py +++ b/numpydoc/tests/hooks/test_validate_hook.py @@ -246,3 +246,69 @@ def test_validate_hook_exclude_option_setup_cfg(example_module, tmp_path, capsys return_code = run_hook([example_module], config=tmp_path) assert return_code == 1 assert capsys.readouterr().err.strip() == expected + + +@pytest.mark.parametrize( + "regex, expected_code", + [(".*(/|\\\\)example.*\.py", 0), (".*/non_existent_match.*\.py", 1)], +) +def test_validate_hook_exclude_files_option_pyproject( + example_module, regex, expected_code, tmp_path +): + """ + Test that the hook correctly processes the toml config and either includes + or excludes files based on the `exclude_files` option. + """ + + with open(tmp_path / "pyproject.toml", "w") as config_file: + config_file.write( + inspect.cleandoc( + f""" + [tool.numpydoc_validation] + checks = [ + "all", + "EX01", + "SA01", + "ES01", + ] + exclude = '\\.__init__$' + override_SS05 = [ + '^Creates', + ] + exclude_files = [ + '{regex}', + ]""" + ) + ) + + return_code = run_hook([example_module], config=tmp_path) + assert return_code == expected_code # Should not-report/report findings. + + +@pytest.mark.parametrize( + "regex, expected_code", + [(".*(/|\\\\)example.*\.py", 0), (".*/non_existent_match.*\.py", 1)], +) +def test_validate_hook_exclude_files_option_setup_cfg( + example_module, regex, expected_code, tmp_path +): + """ + Test that the hook correctly processes the setup config and either includes + or excludes files based on the `exclude_files` option. + """ + + with open(tmp_path / "setup.cfg", "w") as config_file: + config_file.write( + inspect.cleandoc( + f""" + [tool:numpydoc_validation] + checks = all,EX01,SA01,ES01 + exclude = \\.NewClass$,\\.__init__$ + override_SS05 = ^Creates + exclude_files = {regex} + """ + ) + ) + + return_code = run_hook([example_module], config=tmp_path) + assert return_code == expected_code # Should not-report/report findings. diff --git a/numpydoc/tests/test_docscrape.py b/numpydoc/tests/test_docscrape.py index f10adc45..c122515a 100644 --- a/numpydoc/tests/test_docscrape.py +++ b/numpydoc/tests/test_docscrape.py @@ -1593,6 +1593,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_exclude_files = set() self.numpydoc_validation_overrides = dict() xref_aliases_complete = deepcopy(DEFAULT_LINKS) diff --git a/numpydoc/tests/test_numpydoc.py b/numpydoc/tests/test_numpydoc.py index bedef5fa..f879d7fb 100644 --- a/numpydoc/tests/test_numpydoc.py +++ b/numpydoc/tests/test_numpydoc.py @@ -31,6 +31,7 @@ class MockConfig: numpydoc_attributes_as_param_list = True numpydoc_validation_checks = set() numpydoc_validation_exclude = set() + numpydoc_validation_exclude_files = set() numpydoc_validation_overrides = dict() @@ -287,6 +288,61 @@ def test_clean_backrefs(): assert "id1" in citation["backrefs"] +@pytest.mark.parametrize( + "exclude_files, has_warnings", + [ + ( + [ + r"^doesnt_match_any_file$", + ], + True, + ), + ( + [ + r"^.*test_numpydoc\.py$", + ], + False, + ), + ], +) +def test_mangle_skip_exclude_files(exclude_files, has_warnings): + """ + Check that the regex expressions in numpydoc_validation_files_exclude + are correctly used to skip checks on files that match the patterns. + """ + + def process_something_noop_function(): + """Process something.""" + + app = MockApp() + app.config.numpydoc_validation_checks = {"all"} + + # Class attributes for config persist - need to reset them to unprocessed states. + app.config.numpydoc_validation_exclude = set() # Reset to default... + app.config.numpydoc_validation_overrides = dict() # Reset to default... + + app.config.numpydoc_validation_exclude_files = exclude_files + update_config(app) + + # Setup for catching warnings + status, warning = StringIO(), StringIO() + logging.setup(app, status, warning) + + # Simulate a file that matches the exclude pattern + mangle_docstrings( + app, + "function", + process_something_noop_function.__name__, + process_something_noop_function, + None, + process_something_noop_function.__doc__.split("\n"), + ) + + # Are warnings generated? + print(warning.getvalue()) + assert bool(warning.getvalue()) is has_warnings + + if __name__ == "__main__": import pytest