Skip to content

Commit bad5c0b

Browse files
authored
Added "exclude_files" option for pyproject.toml config usage. (#635)
* feat(numpydoc/hooks/validate_docstrings.py): Added a pyproject.toml config option `exclude_files` that allows regex path exclusions Solution to Issue #497 * fix(test_validate_hook.py): Corrected test for exclude_files toml option, which should have 0 findings * refactor(test_validate_hook.py): Added extra testcase to verify no-matching exclude_files option * refactor(test_validate_hook.py): Modified toml exclude_files test mark, to correct parameter order * refactor(test_validate_hook.py): Change string type * test(test_validate_hook.py): Added correct pyproject.toml and setup.cfg test cases for the `exclude_files` option * feat(numpydoc.py): Added config option `numpydoc_validation_exclude_files` for Sphinx plugin Uses very similar regex processing to `numpydoc_validation_exclude` but instead applies to a module check before any numpydoc validation is performed. * fix(numpydoc.py): Corrected module path check for `numpydoc_validation_exclude_files` option * refactor(numpydoc.py): Changed `numpydoc_validation_exclue_files` sphinx option to use `inspect`, and simplified path checking using `__file__` * fix(Modified-`numpydoc_validation_exclude_files`-option-to-only-activate-if-`numpydoc_validation_checks`-is-not-empty): Mimicing same behaviour as `numpydoc_validation_exclude` * docs(validation.rst,-install.rst): Added docs for new feature `numpydoc_validation_exclude_files` for Sphinx and `exclude_files` for pyproject.toml * docs(validation.rst,-install.rst): Fixed indentation and linebreaks to properly format * chore(numpydoc.py): use package-relative filename instead of absolute. * docs(validation.rst,-install.rst): indicate relative path not absolute path for `exclude_files` options
1 parent 8c66165 commit bad5c0b

File tree

7 files changed

+229
-8
lines changed

7 files changed

+229
-8
lines changed

doc/install.rst

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,18 @@ numpydoc_validation_exclude : set
139139
validation.
140140
Only has an effect when docstring validation is activated, i.e.
141141
``numpydoc_validation_checks`` is not an empty set.
142+
numpydoc_validation_exclude_files : set
143+
A container of strings using :py:mod:`re` syntax specifying path patterns to
144+
ignore for docstring validation, relative to the package root.
145+
For example, to skip docstring validation for all objects in
146+
``tests\``::
147+
148+
numpydoc_validation_exclude_files = {"^tests/.*$"}
149+
150+
The default is an empty set meaning no paths are excluded from docstring
151+
validation.
152+
Only has an effect when docstring validation is activated, i.e.
153+
``numpydoc_validation_checks`` is not an empty set.
142154
numpydoc_validation_overrides : dict
143155
A dictionary mapping :ref:`validation checks <validation_checks>` to a
144156
container of strings using :py:mod:`re` syntax specifying patterns to

doc/validation.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ the pre-commit hook as follows:
3636
expressions ``\.undocumented_method$`` or ``\.__repr__$``. This
3737
maps to ``numpydoc_validation_exclude`` from the
3838
:ref:`Sphinx build configuration <validation_during_sphinx_build>`.
39+
* ``exclude_files``: Exclude file paths (relative to the package root) matching
40+
the regular expressions ``^tests/.*$`` or ``^module/gui.*$``. This maps to
41+
``numpydoc_validation_exclude_files`` from the
42+
:ref:`Sphinx build configuration <validation_during_sphinx_build>`.
3943
* ``override_SS05``: Allow docstrings to start with "Process ", "Assess ",
4044
or "Access ". To override different checks, add a field for each code in
4145
the form of ``override_<code>`` with a collection of regular expression(s)
@@ -57,6 +61,10 @@ the pre-commit hook as follows:
5761
'\.undocumented_method$',
5862
'\.__repr__$',
5963
]
64+
exclude_files = [ # don't process filepaths that match these regex
65+
'^tests/.*',
66+
'^module/gui.*',
67+
]
6068
override_SS05 = [ # override SS05 to allow docstrings starting with these words
6169
'^Process ',
6270
'^Assess ',

numpydoc/hooks/validate_docstrings.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,12 @@ def parse_config(dir_path: os.PathLike = None) -> dict:
273273
dict
274274
Config options for the numpydoc validation hook.
275275
"""
276-
options = {"checks": {"all"}, "exclude": set(), "overrides": {}}
276+
options = {
277+
"checks": {"all"},
278+
"exclude": set(),
279+
"overrides": {},
280+
"exclude_files": set(),
281+
}
277282
dir_path = Path(dir_path).expanduser().resolve()
278283

279284
toml_path = dir_path / "pyproject.toml"
@@ -306,6 +311,13 @@ def extract_check_overrides(options, config_items):
306311
else [global_exclusions]
307312
)
308313

314+
file_exclusions = config.get("exclude_files", options["exclude_files"])
315+
options["exclude_files"] = set(
316+
file_exclusions
317+
if not isinstance(file_exclusions, str)
318+
else [file_exclusions]
319+
)
320+
309321
extract_check_overrides(options, config.items())
310322

311323
elif cfg_path.is_file():
@@ -332,6 +344,16 @@ def extract_check_overrides(options, config_items):
332344
except configparser.NoOptionError:
333345
pass
334346

347+
try:
348+
options["exclude_files"] = set(
349+
config.get(numpydoc_validation_config_section, "exclude_files")
350+
.rstrip(",")
351+
.split(",")
352+
or options["exclude_files"]
353+
)
354+
except configparser.NoOptionError:
355+
pass
356+
335357
extract_check_overrides(
336358
options, config.items(numpydoc_validation_config_section)
337359
)
@@ -341,6 +363,7 @@ def extract_check_overrides(options, config_items):
341363

342364
options["checks"] = validate.get_validation_checks(options["checks"])
343365
options["exclude"] = compile_regex(options["exclude"])
366+
options["exclude_files"] = compile_regex(options["exclude_files"])
344367
return options
345368

346369

@@ -395,9 +418,12 @@ def run_hook(
395418
project_root, _ = find_project_root(files)
396419
config_options = parse_config(config or project_root)
397420
config_options["checks"] -= set(ignore or [])
421+
exclude_re = config_options["exclude_files"]
398422

399423
findings = False
400424
for file in files:
425+
if exclude_re and exclude_re.match(file):
426+
continue
401427
if file_issues := process_file(file, config_options):
402428
findings = True
403429

numpydoc/numpydoc.py

Lines changed: 59 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,19 @@
1818
"""
1919

2020
import hashlib
21+
import importlib
2122
import inspect
2223
import itertools
2324
import pydoc
2425
import re
26+
import sys
2527
from collections.abc import Callable
2628
from copy import deepcopy
29+
from pathlib import Path
2730

2831
from docutils.nodes import Text, citation, comment, inline, reference, section
2932
from sphinx.addnodes import desc_content, pending_xref
33+
from sphinx.application import Sphinx as SphinxApp
3034
from sphinx.util import logging
3135

3236
from . import __version__
@@ -52,7 +56,7 @@ def _traverse_or_findall(node, condition, **kwargs):
5256
)
5357

5458

55-
def rename_references(app, what, name, obj, options, lines):
59+
def rename_references(app: SphinxApp, what, name, obj, options, lines):
5660
# decorate reference numbers so that there are no duplicates
5761
# these are later undecorated in the doctree, in relabel_references
5862
references = set()
@@ -114,7 +118,7 @@ def is_docstring_section(node):
114118
return False
115119

116120

117-
def relabel_references(app, doc):
121+
def relabel_references(app: SphinxApp, doc):
118122
# Change 'hash-ref' to 'ref' in label text
119123
for citation_node in _traverse_or_findall(doc, citation):
120124
if not _is_cite_in_numpydoc_docstring(citation_node):
@@ -141,7 +145,7 @@ def matching_pending_xref(node):
141145
ref.replace(ref_text, new_text.copy())
142146

143147

144-
def clean_backrefs(app, doc, docname):
148+
def clean_backrefs(app: SphinxApp, doc, docname):
145149
# only::latex directive has resulted in citation backrefs without reference
146150
known_ref_ids = set()
147151
for ref in _traverse_or_findall(doc, reference, descend=True):
@@ -161,7 +165,7 @@ def clean_backrefs(app, doc, docname):
161165
DEDUPLICATION_TAG = " !! processed by numpydoc !!"
162166

163167

164-
def mangle_docstrings(app, what, name, obj, options, lines):
168+
def mangle_docstrings(app: SphinxApp, what, name, obj, options, lines):
165169
if DEDUPLICATION_TAG in lines:
166170
return
167171
show_inherited_class_members = app.config.numpydoc_show_inherited_class_members
@@ -190,6 +194,38 @@ def mangle_docstrings(app, what, name, obj, options, lines):
190194
title_re = re.compile(pattern, re.IGNORECASE | re.DOTALL)
191195
lines[:] = title_re.sub("", u_NL.join(lines)).split(u_NL)
192196
else:
197+
# Test the obj to find the module path, and skip the check if it's path is matched by
198+
# numpydoc_validation_exclude_files
199+
if (
200+
app.config.numpydoc_validation_exclude_files
201+
and app.config.numpydoc_validation_checks
202+
):
203+
excluder = app.config.numpydoc_validation_files_excluder
204+
module = inspect.getmodule(obj)
205+
try:
206+
# Get the module relative path from the name
207+
if module:
208+
mod_path = Path(module.__file__)
209+
package_rel_path = mod_path.parent.relative_to(
210+
Path(
211+
importlib.import_module(
212+
module.__name__.split(".")[0]
213+
).__file__
214+
).parent
215+
).as_posix()
216+
module_file = mod_path.as_posix().replace(
217+
mod_path.parent.as_posix(), ""
218+
)
219+
path = package_rel_path + module_file
220+
else:
221+
path = None
222+
except AttributeError as e:
223+
path = None
224+
225+
if path and excluder and excluder.search(path):
226+
# Skip validation for this object.
227+
return
228+
193229
try:
194230
doc = get_doc_object(
195231
obj, what, u_NL.join(lines), config=cfg, builder=app.builder
@@ -239,7 +275,7 @@ def mangle_docstrings(app, what, name, obj, options, lines):
239275
lines += ["..", DEDUPLICATION_TAG]
240276

241277

242-
def mangle_signature(app, what, name, obj, options, sig, retann):
278+
def mangle_signature(app: SphinxApp, what, name, obj, options, sig, retann):
243279
# Do not try to inspect classes that don't define `__init__`
244280
if inspect.isclass(obj) and (
245281
not hasattr(obj, "__init__")
@@ -273,7 +309,7 @@ def _clean_text_signature(sig):
273309
return start_sig + sig + ")"
274310

275311

276-
def setup(app, get_doc_object_=get_doc_object):
312+
def setup(app: SphinxApp, get_doc_object_=get_doc_object):
277313
if not hasattr(app, "add_config_value"):
278314
return None # probably called by nose, better bail out
279315

@@ -299,6 +335,7 @@ def setup(app, get_doc_object_=get_doc_object):
299335
app.add_config_value("numpydoc_xref_ignore", set(), True, types=[set, str])
300336
app.add_config_value("numpydoc_validation_checks", set(), True)
301337
app.add_config_value("numpydoc_validation_exclude", set(), False)
338+
app.add_config_value("numpydoc_validation_exclude_files", set(), False)
302339
app.add_config_value("numpydoc_validation_overrides", dict(), False)
303340

304341
# Extra mangling domains
@@ -309,7 +346,7 @@ def setup(app, get_doc_object_=get_doc_object):
309346
return metadata
310347

311348

312-
def update_config(app, config=None):
349+
def update_config(app: SphinxApp, config=None):
313350
"""Update the configuration with default values."""
314351
if config is None: # needed for testing and old Sphinx
315352
config = app.config
@@ -342,6 +379,21 @@ def update_config(app, config=None):
342379
)
343380
config.numpydoc_validation_excluder = exclude_expr
344381

382+
# Generate the regexp for files to ignore during validation
383+
if isinstance(config.numpydoc_validation_exclude_files, str):
384+
raise ValueError(
385+
f"numpydoc_validation_exclude_files must be a container of strings, "
386+
f"e.g. [{config.numpydoc_validation_exclude_files!r}]."
387+
)
388+
389+
config.numpydoc_validation_files_excluder = None
390+
if config.numpydoc_validation_exclude_files:
391+
exclude_files_expr = re.compile(
392+
r"|".join(exp for exp in config.numpydoc_validation_exclude_files)
393+
)
394+
config.numpydoc_validation_files_excluder = exclude_files_expr
395+
396+
# Generate the regexp for validation overrides
345397
for check, patterns in config.numpydoc_validation_overrides.items():
346398
config.numpydoc_validation_overrides[check] = re.compile(
347399
r"|".join(exp for exp in patterns)

numpydoc/tests/hooks/test_validate_hook.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,3 +246,69 @@ def test_validate_hook_exclude_option_setup_cfg(example_module, tmp_path, capsys
246246
return_code = run_hook([example_module], config=tmp_path)
247247
assert return_code == 1
248248
assert capsys.readouterr().err.strip() == expected
249+
250+
251+
@pytest.mark.parametrize(
252+
"regex, expected_code",
253+
[(".*(/|\\\\)example.*\.py", 0), (".*/non_existent_match.*\.py", 1)],
254+
)
255+
def test_validate_hook_exclude_files_option_pyproject(
256+
example_module, regex, expected_code, tmp_path
257+
):
258+
"""
259+
Test that the hook correctly processes the toml config and either includes
260+
or excludes files based on the `exclude_files` option.
261+
"""
262+
263+
with open(tmp_path / "pyproject.toml", "w") as config_file:
264+
config_file.write(
265+
inspect.cleandoc(
266+
f"""
267+
[tool.numpydoc_validation]
268+
checks = [
269+
"all",
270+
"EX01",
271+
"SA01",
272+
"ES01",
273+
]
274+
exclude = '\\.__init__$'
275+
override_SS05 = [
276+
'^Creates',
277+
]
278+
exclude_files = [
279+
'{regex}',
280+
]"""
281+
)
282+
)
283+
284+
return_code = run_hook([example_module], config=tmp_path)
285+
assert return_code == expected_code # Should not-report/report findings.
286+
287+
288+
@pytest.mark.parametrize(
289+
"regex, expected_code",
290+
[(".*(/|\\\\)example.*\.py", 0), (".*/non_existent_match.*\.py", 1)],
291+
)
292+
def test_validate_hook_exclude_files_option_setup_cfg(
293+
example_module, regex, expected_code, tmp_path
294+
):
295+
"""
296+
Test that the hook correctly processes the setup config and either includes
297+
or excludes files based on the `exclude_files` option.
298+
"""
299+
300+
with open(tmp_path / "setup.cfg", "w") as config_file:
301+
config_file.write(
302+
inspect.cleandoc(
303+
f"""
304+
[tool:numpydoc_validation]
305+
checks = all,EX01,SA01,ES01
306+
exclude = \\.NewClass$,\\.__init__$
307+
override_SS05 = ^Creates
308+
exclude_files = {regex}
309+
"""
310+
)
311+
)
312+
313+
return_code = run_hook([example_module], config=tmp_path)
314+
assert return_code == expected_code # Should not-report/report findings.

numpydoc/tests/test_docscrape.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1593,6 +1593,7 @@ def __init__(self, a, b):
15931593
# numpydoc.update_config fails if this config option not present
15941594
self.numpydoc_validation_checks = set()
15951595
self.numpydoc_validation_exclude = set()
1596+
self.numpydoc_validation_exclude_files = set()
15961597
self.numpydoc_validation_overrides = dict()
15971598

15981599
xref_aliases_complete = deepcopy(DEFAULT_LINKS)

0 commit comments

Comments
 (0)