Skip to content

Added "exclude_files" option for pyproject.toml config usage. #635

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions doc/install.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <validation_checks>` to a
container of strings using :py:mod:`re` syntax specifying patterns to
Expand Down
8 changes: 8 additions & 0 deletions doc/validation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <validation_during_sphinx_build>`.
* ``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 <validation_during_sphinx_build>`.
* ``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_<code>`` with a collection of regular expression(s)
Expand All @@ -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 ',
Expand Down
28 changes: 27 additions & 1 deletion numpydoc/hooks/validate_docstrings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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():
Expand All @@ -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)
)
Expand All @@ -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


Expand Down Expand Up @@ -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

Expand Down
49 changes: 42 additions & 7 deletions numpydoc/numpydoc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__
Expand All @@ -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()
Expand Down Expand Up @@ -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):
Expand All @@ -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):
Expand All @@ -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
Expand Down Expand Up @@ -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
Copy link
Contributor Author

@mattgebert mattgebert Jun 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Possibly problematic to get global path to module file, rather than relative path to a package root? Perhaps this is fine.

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
Expand Down Expand Up @@ -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__")
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
66 changes: 66 additions & 0 deletions numpydoc/tests/hooks/test_validate_hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 1 addition & 0 deletions numpydoc/tests/test_docscrape.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
56 changes: 56 additions & 0 deletions numpydoc/tests/test_numpydoc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()


Expand Down Expand Up @@ -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

Expand Down
Loading