diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml new file mode 100644 index 00000000..b88e9ed2 --- /dev/null +++ b/.pre-commit-hooks.yaml @@ -0,0 +1,7 @@ +- id: numpydoc-validation + name: numpydoc-validation + description: This hook validates that docstrings in committed files adhere to numpydoc standards. + entry: validate-docstrings + require_serial: true + language: python + types: [python] diff --git a/doc/validation.rst b/doc/validation.rst index ee6e08d2..09196ea5 100644 --- a/doc/validation.rst +++ b/doc/validation.rst @@ -2,6 +2,83 @@ Validation ========== +Docstring Validation using Pre-Commit Hook +------------------------------------------ + +To enable validation of docstrings as you commit files, add the +following to your ``.pre-commit-config.yaml`` file: + +.. code-block:: yaml + + - repo: https://github.com/numpy/numpydoc + rev: + hooks: + - id: numpydoc-validation + +After installing ``numpydoc``, run the following to see available +command line options for this hook: + +.. code-block:: bash + + $ python -m numpydoc.hooks.validate_docstrings --help + +Using a config file provides additional customization. Both +``pyproject.toml`` and ``setup.cfg`` are supported; however, if the +project contains both you must use the ``pyproject.toml`` file. +The example below configures the pre-commit hook to ignore three checks +and specifies exceptions to the checks ``SS05`` (allow docstrings to +start with "Process ", "Assess ", or "Access ") and ``GL08`` (allow +the class/method/function with name "__init__" to not have a docstring). + +``pyproject.toml``:: + + [tool.numpydoc_validation] + ignore = [ + "EX01", + "SA01", + "ES01", + ] + override_SS05 = '^((Process|Assess|Access) )' + override_GL08 = '^(__init__)$' + +``setup.cfg``:: + + [tool:numpydoc_validation] + ignore = EX01,SA01,ES01 + override_SS05 = ^((Process|Assess|Access) ) + override_GL08 = ^(__init__)$ + +For more fine-tuned control, you can also include inline comments to tell the +validation hook to ignore certain checks: + +.. 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: + +.. code-block:: output + + numpydoc-validation......................................................Failed + - hook id: numpydoc-validation + - exit code: 1 + + +----------------------+----------------------+---------+--------------------------------------+ + | file | item | check | description | + +======================+======================+=========+======================================+ + | src/pkg/utils.py:1 | utils | GL08 | The object does not have a docstring | + | src/pkg/utils.py:90 | utils.normalize | PR04 | Parameter "field" has no type | + | src/pkg/module.py:12 | module.MyClass | GL08 | The object does not have a docstring | + | src/pkg/module.py:33 | module.MyClass.parse | RT03 | Return value has no description | + +----------------------+----------------------+---------+--------------------------------------+ + +See below for a full listing of checks. + Docstring Validation using Python --------------------------------- diff --git a/numpydoc/hooks/__init__.py b/numpydoc/hooks/__init__.py new file mode 100644 index 00000000..f05d7fca --- /dev/null +++ b/numpydoc/hooks/__init__.py @@ -0,0 +1 @@ +"""Pre-commit hooks using numpydoc.""" diff --git a/numpydoc/hooks/utils.py b/numpydoc/hooks/utils.py new file mode 100644 index 00000000..4f1d82aa --- /dev/null +++ b/numpydoc/hooks/utils.py @@ -0,0 +1,48 @@ +"""Utility functions for pre-commit hooks.""" + +import itertools +import os +from pathlib import Path +from typing import Sequence + + +def find_project_root(srcs: Sequence[str]): + """ + Return a directory containing .git, .hg, pyproject.toml, or setup.cfg. + + That directory can be one of the directories passed in ``srcs`` or their + common parent. If no directory in the tree contains a marker that would + specify it's the project root, the root of the file system is returned. + + Parameters + ---------- + srcs : Sequence[str] + The filepaths to run the hook on. + + Returns + ------- + str + The project root directory. + + See Also + -------- + black.find_project_root : + This function was adapted from + `Black `_. + """ + if not srcs: + return Path(".").resolve(), "current directory" + + common_path = Path( + os.path.commonpath([Path(src).expanduser().resolve() for src in srcs]) + ) + + for dir in itertools.chain([common_path], common_path.parents): + if (dir / "pyproject.toml").is_file(): + return dir, "pyproject.toml" + if (dir / "setup.cfg").is_file(): + return dir, "setup.cfg" + if (dir / ".git").exists() or (dir / ".hg").is_dir(): + return dir, "version control" + + return dir, "file system root" diff --git a/numpydoc/hooks/validate_docstrings.py b/numpydoc/hooks/validate_docstrings.py new file mode 100644 index 00000000..9570ba18 --- /dev/null +++ b/numpydoc/hooks/validate_docstrings.py @@ -0,0 +1,423 @@ +"""Run numpydoc validation on contents of a file.""" + +import argparse +import ast +import configparser +import os +import re +import sys +import tokenize + +try: + import tomllib +except ImportError: + import tomli as tomllib + +from pathlib import Path +from typing import Sequence, Tuple, Union + +from tabulate import tabulate + +from .. import docscrape, validate +from .utils import find_project_root + + +# inline comments that can suppress individual checks per line +IGNORE_COMMENT_PATTERN = re.compile("(?:.* numpydoc ignore[=|:] ?)(.+)") + + +class AstValidator(validate.Validator): + """ + Overrides the :class:`Validator` to work entirely with the AST. + + Parameters + ---------- + ast_node : ast.AST + The node under inspection. + filename : os.PathLike + The file where the node is defined. + obj_name : str + A name for the node to use in the listing of issues for the file as a whole. + """ + + def __init__( + self, *, ast_node: ast.AST, filename: os.PathLike, obj_name: str + ) -> None: + self.node: ast.AST = ast_node + self.raw_doc: str = ast.get_docstring(self.node, clean=False) or "" + self.clean_doc: str = ast.get_docstring(self.node, clean=True) + self.doc: docscrape.NumpyDocString = docscrape.NumpyDocString(self.raw_doc) + + self._source_file: os.PathLike = Path(filename).resolve() + self._name: str = obj_name + + self.is_class: bool = isinstance(ast_node, ast.ClassDef) + self.is_module: bool = isinstance(ast_node, ast.Module) + + @staticmethod + def _load_obj(name): + raise NotImplementedError("AstValidator does not support this method.") + + @property + def name(self) -> str: + return self._name + + @property + def is_function_or_method(self) -> bool: + return isinstance(self.node, (ast.FunctionDef, ast.AsyncFunctionDef)) + + @property + def is_generator_function(self) -> bool: + if not self.is_function_or_method: + return False + for child in ast.iter_child_nodes(self.node): + if isinstance(child, ast.Expr) and isinstance(child.value, ast.Yield): + return True + return False + + @property + def type(self) -> str: + if self.is_function_or_method: + return "function" + if self.is_class: + return "type" + if self.is_module: + return "module" + raise ValueError("Unknown type.") + + @property + def source_file_name(self) -> str: + return self._source_file + + @property + def source_file_def_line(self) -> int: + return self.node.lineno if not self.is_module else 1 + + @property + def signature_parameters(self) -> Tuple[str]: + def extract_signature(node): + args_node = node.args + params = [] + for arg_type in ["posonlyargs", "args", "vararg", "kwonlyargs", "kwarg"]: + entries = getattr(args_node, arg_type) + if arg_type in ["vararg", "kwarg"]: + if entries and arg_type == "vararg": + params.append(f"*{entries.arg}") + if entries and arg_type == "kwarg": + params.append(f"**{entries.arg}") + else: + params.extend([arg.arg for arg in entries]) + params = tuple(params) + if params and params[0] in {"self", "cls"}: + return params[1:] + return params + + params = tuple() + if self.is_function_or_method: + params = extract_signature(self.node) + elif self.is_class: + for child in self.node.body: + if isinstance(child, ast.FunctionDef) and child.name == "__init__": + params = extract_signature(child) + return params + + @property + def method_source(self) -> str: + with open(self.source_file_name) as file: + source = ast.get_source_segment(file.read(), self.node) + return source + + +class DocstringVisitor(ast.NodeVisitor): + """ + Visits nodes in the AST from a given module and reporting numpydoc issues. + + Parameters + ---------- + filepath : str + 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] = [] + self.findings: list = [] + + def _ignore_issue(self, node: ast.AST, check: str) -> bool: + """ + Check whether the issue should be ignored. + + Parameters + ---------- + node : ast.AST + The node under inspection. + check : str + The code for the check being evaluated. + + Return + ------ + bool + Whether the issue should be exluded from the report. + """ + if check in self.config["exclusions"]: + 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: + return True + + 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: + """ + Get numpydoc validation issues. + + Parameters + ---------- + node : ast.AST + The node under inspection. + """ + name = ".".join(self.stack) + report = validate.validate( + name, AstValidator, ast_node=node, filename=self.filepath + ) + self.findings.extend( + [ + [f'{self.filepath}:{report["file_line"]}', name, check, description] + for check, description in report["errors"] + if not self._ignore_issue(node, check) + ] + ) + + def visit(self, node: ast.AST) -> None: + """ + Visit a node in the AST and report on numpydoc validation issues. + + Parameters + ---------- + node : ast.AST + The node to visit. + """ + if isinstance( + node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef) + ): + self.stack.append( + self.module_name if isinstance(node, ast.Module) else node.name + ) + self._get_numpydoc_issues(node) + self.generic_visit(node) + _ = self.stack.pop() + + +def parse_config(dir_path: os.PathLike = None) -> dict: + """ + Parse config information from a pyproject.toml or setup.cfg file. + + This function looks in the provided directory path first for a + pyproject.toml file. If it finds that, it won't look for a setup.cfg + file. + + Parameters + ---------- + dir_path : os.PathLike + An absolute or relative path to a directory containing + either a pyproject.toml file specifying a + [tool.numpydoc_validation] section or a setup.cfg file + specifying a [tool:numpydoc_validation] section. + For example, ``~/my_project``. If not provided, the hook + will try to find the project root directory. + + Returns + ------- + dict + Config options for the numpydoc validation hook. + """ + options = {"exclusions": [], "overrides": {}} + dir_path = Path(dir_path).expanduser().resolve() + + toml_path = dir_path / "pyproject.toml" + cfg_path = dir_path / "setup.cfg" + + if toml_path.is_file(): + with open(toml_path, "rb") as toml_file: + pyproject_toml = tomllib.load(toml_file) + config = pyproject_toml.get("tool", {}).get("numpydoc_validation", {}) + options["exclusions"] = config.get("ignore", []) + for check in ["SS05", "GL08"]: + regex = config.get(f"override_{check}") + if regex: + options["overrides"][check] = re.compile(regex) + elif cfg_path.is_file(): + config = configparser.ConfigParser() + config.read(cfg_path) + numpydoc_validation_config_section = "tool:numpydoc_validation" + try: + try: + options["exclusions"] = config.get( + numpydoc_validation_config_section, "ignore" + ).split(",") + except configparser.NoOptionError: + pass + try: + options["overrides"]["SS05"] = re.compile( + config.get(numpydoc_validation_config_section, "override_SS05") + ) + 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 + + return options + + +def process_file(filepath: os.PathLike, config: dict) -> "list[list[str]]": + """ + Run numpydoc validation on a file. + + Parameters + ---------- + filepath : path-like + The absolute or relative path to the file to inspect. + config : dict + Configuration options for reviewing flagged issues. + + Returns + ------- + list[list[str]] + A list of [name, check, description] lists for flagged issues. + """ + with open(filepath) as file: + module_node = ast.parse(file.read(), filepath) + + with open(filepath) as file: + numpydoc_ignore_comments = {} + last_declaration = 1 + declarations = ["def", "class"] + for token in tokenize.generate_tokens(file.readline): + if token.type == tokenize.NAME and token.string in declarations: + last_declaration = token.start[0] + if token.type == tokenize.COMMENT: + match = re.match(IGNORE_COMMENT_PATTERN, token.string) + if match: + rules = match.group(1).split(",") + numpydoc_ignore_comments[last_declaration] = rules + + docstring_visitor = DocstringVisitor( + filepath=str(filepath), + config=config, + numpydoc_ignore_comments=numpydoc_ignore_comments, + ) + docstring_visitor.visit(module_node) + + return docstring_visitor.findings + + +def main(argv: Union[Sequence[str], None] = None) -> int: + """Run the numpydoc validation hook.""" + + project_root_from_cwd, config_file = find_project_root(["."]) + config_options = parse_config(project_root_from_cwd) + ignored_checks = ( + "\n " + + "\n ".join( + [ + f"- {check}: {validate.ERROR_MSGS[check]}" + for check in config_options["exclusions"] + ] + ) + + "\n" + ) + + parser = argparse.ArgumentParser( + description="Run numpydoc validation on files with option to ignore individual checks.", + formatter_class=argparse.RawTextHelpFormatter, + ) + parser.add_argument( + "files", type=str, nargs="+", help="File(s) to run numpydoc validation on." + ) + parser.add_argument( + "--config", + type=str, + help=( + "Path to a directory containing a pyproject.toml or setup.cfg file.\n" + "The hook will look for it in the root project directory.\n" + "If both are present, only pyproject.toml will be used.\n" + "Options must be placed under\n" + " - [tool:numpydoc_validation] for setup.cfg files and\n" + " - [tool.numpydoc_validation] for pyproject.toml files." + ), + ) + parser.add_argument( + "--ignore", + type=str, + nargs="*", + help=( + f"""Check codes to ignore.{ + ' 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 '' + }""" + ), + ) + + 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 []) + + findings = [] + for file in args.files: + findings.extend(process_file(file, config_options)) + + if findings: + print( + tabulate( + findings, + headers=["file", "item", "check", "description"], + tablefmt="grid", + maxcolwidths=50, + ), + file=sys.stderr, + ) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/numpydoc/tests/hooks/__init__.py b/numpydoc/tests/hooks/__init__.py new file mode 100644 index 00000000..f2d333a5 --- /dev/null +++ b/numpydoc/tests/hooks/__init__.py @@ -0,0 +1 @@ +"""Tests for hooks.""" diff --git a/numpydoc/tests/hooks/example_module.py b/numpydoc/tests/hooks/example_module.py new file mode 100644 index 00000000..6cfa4d47 --- /dev/null +++ b/numpydoc/tests/hooks/example_module.py @@ -0,0 +1,30 @@ +"""Test module for hook.""" # numpydoc ignore=ES01,SA01 + + +def some_function(name): + """Welcome to some function.""" + pass + + +class MyClass: + """This is MyClass.""" + + def __init__(self): + pass + + def __repr__(self): # numpydoc ignore=GL08 + pass + + def do_something(self, *args, **kwargs): + """ + Do something. + + Parameters + ---------- + *args + """ + pass + + def process(self): + """Process stuff.""" + pass diff --git a/numpydoc/tests/hooks/test_utils.py b/numpydoc/tests/hooks/test_utils.py new file mode 100644 index 00000000..5db63762 --- /dev/null +++ b/numpydoc/tests/hooks/test_utils.py @@ -0,0 +1,34 @@ +"""Test utility functions for hooks.""" + +from pathlib import Path + +import pytest + +from numpydoc.hooks import utils + + +@pytest.mark.parametrize( + ["reason_file", "files", "expected_reason"], + [ + (None, None, "current directory"), + (None, ["x.py"], "file system root"), + (".git", ["x.py"], "version control"), + ("pyproject.toml", ["x.py"], "pyproject.toml"), + ("setup.cfg", ["x.py"], "setup.cfg"), + ], +) +def test_find_project_root(tmp_path, request, reason_file, files, expected_reason): + """Test the process of finding the project root.""" + if reason_file: + (tmp_path / reason_file).touch() + + if files: + expected_dir = Path("/") if expected_reason == "file system root" else tmp_path + for file in files: + (tmp_path / file).touch() + else: + expected_dir = request.config.rootdir + + root_dir, reason = utils.find_project_root(files if not files else [tmp_path]) + assert reason == expected_reason + assert root_dir == expected_dir diff --git a/numpydoc/tests/hooks/test_validate_hook.py b/numpydoc/tests/hooks/test_validate_hook.py new file mode 100644 index 00000000..7c8b8997 --- /dev/null +++ b/numpydoc/tests/hooks/test_validate_hook.py @@ -0,0 +1,200 @@ +"""Test the numpydoc validate pre-commit hook.""" + +import inspect +from pathlib import Path + +import pytest + +from numpydoc.hooks.validate_docstrings import main + + +@pytest.fixture +def example_module(request): + fullpath = ( + Path(request.config.rootdir) + / "numpydoc" + / "tests" + / "hooks" + / "example_module.py" + ) + return str(fullpath.relative_to(request.config.rootdir)) + + +@pytest.mark.parametrize("config", [None, "fake_dir"]) +def test_validate_hook(example_module, config, capsys): + """Test that a file is correctly processed in the absence of config files.""" + + expected = inspect.cleandoc( + """ + +-------------------------------------------+-------------------------------------+---------+----------------------------------------------------+ + | file | item | check | description | + +===========================================+=====================================+=========+====================================================+ + | numpydoc/tests/hooks/example_module.py:1 | example_module | EX01 | No examples section found | + +-------------------------------------------+-------------------------------------+---------+----------------------------------------------------+ + | numpydoc/tests/hooks/example_module.py:4 | example_module.some_function | ES01 | No extended summary found | + +-------------------------------------------+-------------------------------------+---------+----------------------------------------------------+ + | numpydoc/tests/hooks/example_module.py:4 | example_module.some_function | PR01 | Parameters {'name'} not documented | + +-------------------------------------------+-------------------------------------+---------+----------------------------------------------------+ + | numpydoc/tests/hooks/example_module.py:4 | example_module.some_function | SA01 | See Also section not found | + +-------------------------------------------+-------------------------------------+---------+----------------------------------------------------+ + | numpydoc/tests/hooks/example_module.py:4 | example_module.some_function | EX01 | No examples section found | + +-------------------------------------------+-------------------------------------+---------+----------------------------------------------------+ + | numpydoc/tests/hooks/example_module.py:9 | example_module.MyClass | ES01 | No extended summary found | + +-------------------------------------------+-------------------------------------+---------+----------------------------------------------------+ + | numpydoc/tests/hooks/example_module.py:9 | example_module.MyClass | SA01 | See Also section not found | + +-------------------------------------------+-------------------------------------+---------+----------------------------------------------------+ + | numpydoc/tests/hooks/example_module.py:9 | example_module.MyClass | EX01 | No examples section found | + +-------------------------------------------+-------------------------------------+---------+----------------------------------------------------+ + | numpydoc/tests/hooks/example_module.py:12 | example_module.MyClass.__init__ | GL08 | The object does not have a docstring | + +-------------------------------------------+-------------------------------------+---------+----------------------------------------------------+ + | numpydoc/tests/hooks/example_module.py:18 | example_module.MyClass.do_something | ES01 | No extended summary found | + +-------------------------------------------+-------------------------------------+---------+----------------------------------------------------+ + | 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 | + +-------------------------------------------+-------------------------------------+---------+----------------------------------------------------+ + | numpydoc/tests/hooks/example_module.py:18 | example_module.MyClass.do_something | SA01 | See Also section not found | + +-------------------------------------------+-------------------------------------+---------+----------------------------------------------------+ + | numpydoc/tests/hooks/example_module.py:18 | example_module.MyClass.do_something | EX01 | No examples section found | + +-------------------------------------------+-------------------------------------+---------+----------------------------------------------------+ + | numpydoc/tests/hooks/example_module.py:28 | example_module.MyClass.process | SS05 | Summary must start with infinitive verb, not third | + | | | | person (e.g. use "Generate" instead of | + | | | | "Generates") | + +-------------------------------------------+-------------------------------------+---------+----------------------------------------------------+ + | numpydoc/tests/hooks/example_module.py:28 | example_module.MyClass.process | ES01 | No extended summary found | + +-------------------------------------------+-------------------------------------+---------+----------------------------------------------------+ + | numpydoc/tests/hooks/example_module.py:28 | example_module.MyClass.process | SA01 | See Also section not found | + +-------------------------------------------+-------------------------------------+---------+----------------------------------------------------+ + | numpydoc/tests/hooks/example_module.py:28 | example_module.MyClass.process | EX01 | No examples section found | + +-------------------------------------------+-------------------------------------+---------+----------------------------------------------------+ + """ + ) + + args = [example_module] + if config: + args.append(f"--{config=}") + + return_code = main(args) + assert return_code == 1 + assert capsys.readouterr().err.rstrip() == expected + + +def test_validate_hook_with_ignore(example_module, capsys): + """ + Test that a file is correctly processed in the absence of config files + with command line ignore options. + """ + + 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:12 | example_module.MyClass.__init__ | GL08 | The object does not have a docstring | + +-------------------------------------------+-------------------------------------+---------+----------------------------------------------------+ + | 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 | + +-------------------------------------------+-------------------------------------+---------+----------------------------------------------------+ + | numpydoc/tests/hooks/example_module.py:28 | example_module.MyClass.process | SS05 | Summary must start with infinitive verb, not third | + | | | | person (e.g. use "Generate" instead of | + | | | | "Generates") | + +-------------------------------------------+-------------------------------------+---------+----------------------------------------------------+ + """ + ) + + return_code = main([example_module, "--ignore", "ES01", "SA01", "EX01"]) + assert return_code == 1 + assert capsys.readouterr().err.rstrip() == expected + + +def test_validate_hook_with_toml_config(example_module, tmp_path, capsys): + """ + Test that a file is correctly processed with the config coming from + a pyproject.toml file. + """ + + with open(tmp_path / "pyproject.toml", "w") as config_file: + config_file.write( + inspect.cleandoc( + """ + [tool.numpydoc_validation] + ignore = [ + "EX01", + "SA01", + "ES01", + ] + override_SS05 = '^((Process|Assess|Access) )' + override_GL08 = '^(__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: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 + + +def test_validate_hook_with_setup_cfg(example_module, tmp_path, capsys): + """ + Test that a file is correctly processed with the config coming from + a setup.cfg file. + """ + + with open(tmp_path / "setup.cfg", "w") as config_file: + config_file.write( + inspect.cleandoc( + """ + [tool:numpydoc_validation] + ignore = EX01,SA01,ES01 + override_SS05 = ^((Process|Assess|Access) ) + override_GL08 = ^(__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: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 + + +def test_validate_hook_help(capsys): + """Test that help section is displaying.""" + + with pytest.raises(SystemExit): + return_code = main(["--help"]) + assert return_code == 0 + + out = capsys.readouterr().out + assert "--ignore" in out + assert "--config" in out diff --git a/numpydoc/validate.py b/numpydoc/validate.py index b975f831..8c7f9b83 100644 --- a/numpydoc/validate.py +++ b/numpydoc/validate.py @@ -450,7 +450,7 @@ def _check_desc(desc, code_no_desc, code_no_upper, code_no_period, **kwargs): return errs -def validate(obj_name): +def validate(obj_name, validator_cls=None, **validator_kwargs): """ Validate the docstring. @@ -461,6 +461,11 @@ def validate(obj_name): 'pandas.read_csv'. The string must include the full, unabbreviated package/module names, i.e. 'pandas.read_csv', not 'pd.read_csv' or 'read_csv'. + validator_cls : Validator, optional + The Validator class to use. By default, :class:`Validator` will be used. + **validator_kwargs + Keyword arguments to pass to ``validator_cls`` upon initialization. + Note that ``obj_name`` will be passed as a named argument as well. Returns ------- @@ -493,10 +498,13 @@ def validate(obj_name): they are validated, are not documented more than in the source code of this function. """ - if isinstance(obj_name, str): - doc = Validator(get_doc_object(Validator._load_obj(obj_name))) + if not validator_cls: + if isinstance(obj_name, str): + doc = Validator(get_doc_object(Validator._load_obj(obj_name))) + else: + doc = Validator(obj_name) else: - doc = Validator(obj_name) + doc = validator_cls(obj_name=obj_name, **validator_kwargs) errs = [] if not doc.raw_doc: diff --git a/setup.cfg b/setup.cfg index a1f52209..8ce12840 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,3 +3,7 @@ addopts = --showlocals --doctest-modules -ra --cov-report= --cov=numpydoc --junit-xml=junit-results.xml --ignore=doc/ junit_family = xunit2 + +[options.entry_points] +console_scripts = + validate-docstrings = numpydoc.hooks.validate_docstrings:main diff --git a/setup.py b/setup.py index c2a60bb0..4825254e 100644 --- a/setup.py +++ b/setup.py @@ -30,7 +30,7 @@ def read(fname): setup( name="numpydoc", - packages=["numpydoc"], + packages=["numpydoc", "numpydoc.hooks"], version=version, description="Sphinx extension to support docstrings in Numpy format", long_description=read("README.rst"), @@ -52,7 +52,12 @@ def read(fname): author_email="pav@iki.fi", url="https://numpydoc.readthedocs.io", license="BSD", - install_requires=["sphinx>=5", "Jinja2>=2.10"], + install_requires=[ + "sphinx>=5", + "Jinja2>=2.10", + "tabulate>=0.8.10", + "tomli>=1.1.0;python_version<'3.11'", + ], python_requires=">=3.8", extras_require={ "testing": [