From e7c6baf00f5f73a4a8f8318d0cb4e04949c9a5d1 Mon Sep 17 00:00:00 2001 From: Stefanie Molin <24376333+stefmolin@users.noreply.github.com> Date: Tue, 30 Apr 2024 01:08:43 -0400 Subject: [PATCH] Unify CLIs (#537) * Combine CLIs into a single one, exposing as an entrypoint. * Update tests for CLI changes * Update python -m references in the docs * Update test.yml workflow * Use older type annotations * Show help when no subcommand is provided * Refactor lint subparser and hook tests * Rename test to match subcommand --- .github/workflows/test.yml | 16 +-- .pre-commit-hooks.yaml | 2 +- doc/validation.rst | 6 +- numpydoc/__main__.py | 53 +-------- numpydoc/cli.py | 128 +++++++++++++++++++++ numpydoc/hooks/validate_docstrings.py | 86 +++++--------- numpydoc/tests/hooks/test_validate_hook.py | 31 ++--- numpydoc/tests/test_main.py | 72 +++++++++--- pyproject.toml | 2 +- 9 files changed, 235 insertions(+), 161 deletions(-) create mode 100644 numpydoc/cli.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b58ef62a..853c8539 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -57,10 +57,10 @@ jobs: - name: Make sure CLI works run: | - python -m numpydoc numpydoc.tests.test_main._capture_stdout - echo '! python -m numpydoc numpydoc.tests.test_main._invalid_docstring' | bash - python -m numpydoc --validate numpydoc.tests.test_main._capture_stdout - echo '! python -m numpydoc --validate numpydoc.tests.test_main._docstring_with_errors' | bash + numpydoc render numpydoc.tests.test_main._capture_stdout + echo '! numpydoc render numpydoc.tests.test_main._invalid_docstring' | bash + numpydoc validate numpydoc.tests.test_main._capture_stdout + echo '! numpydoc validate numpydoc.tests.test_main._docstring_with_errors' | bash - name: Setup for doc build run: | @@ -110,10 +110,10 @@ jobs: - name: Make sure CLI works run: | - python -m numpydoc numpydoc.tests.test_main._capture_stdout - echo '! python -m numpydoc numpydoc.tests.test_main._invalid_docstring' | bash - python -m numpydoc --validate numpydoc.tests.test_main._capture_stdout - echo '! python -m numpydoc --validate numpydoc.tests.test_main._docstring_with_errors' | bash + numpydoc render numpydoc.tests.test_main._capture_stdout + echo '! numpydoc render numpydoc.tests.test_main._invalid_docstring' | bash + numpydoc validate numpydoc.tests.test_main._capture_stdout + echo '! numpydoc validate numpydoc.tests.test_main._docstring_with_errors' | bash - name: Setup for doc build run: | diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index b88e9ed2..2244d460 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -1,7 +1,7 @@ - id: numpydoc-validation name: numpydoc-validation description: This hook validates that docstrings in committed files adhere to numpydoc standards. - entry: validate-docstrings + entry: numpydoc lint require_serial: true language: python types: [python] diff --git a/doc/validation.rst b/doc/validation.rst index 4ce89017..858a67cc 100644 --- a/doc/validation.rst +++ b/doc/validation.rst @@ -22,7 +22,7 @@ command line options for this hook: .. code-block:: bash - $ python -m numpydoc.hooks.validate_docstrings --help + $ numpydoc lint --help Using a config file provides additional customization. Both ``pyproject.toml`` and ``setup.cfg`` are supported; however, if the project contains both @@ -102,12 +102,12 @@ can be called. For example, to do it for ``numpy.ndarray``, use: .. code-block:: bash - $ python -m numpydoc numpy.ndarray + $ numpydoc validate numpy.ndarray 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 +``validate`` subcommand. 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 :ref:`pre-commit hook ` diff --git a/numpydoc/__main__.py b/numpydoc/__main__.py index 53d5c504..b3b6d159 100644 --- a/numpydoc/__main__.py +++ b/numpydoc/__main__.py @@ -2,55 +2,6 @@ Implementing `python -m numpydoc` functionality. """ -import sys -import argparse -import ast +from .cli import main -from .docscrape_sphinx import get_doc_object -from .validate import validate, Validator - - -def render_object(import_path, config=None): - """Test numpydoc docstring generation for a given object""" - # TODO: Move Validator._load_obj to a better place than validate - print(get_doc_object(Validator._load_obj(import_path), config=dict(config or []))) - return 0 - - -def validate_object(import_path): - exit_status = 0 - results = validate(import_path) - for err_code, err_desc in results["errors"]: - exit_status += 1 - print(":".join([import_path, err_code, err_desc])) - return exit_status - - -if __name__ == "__main__": - ap = argparse.ArgumentParser(description=__doc__) - ap.add_argument("import_path", help="e.g. numpy.ndarray") - - def _parse_config(s): - key, _, value = s.partition("=") - value = ast.literal_eval(value) - return key, value - - ap.add_argument( - "-c", - "--config", - type=_parse_config, - action="append", - help="key=val where val will be parsed by literal_eval, " - "e.g. -c use_plots=True. Multiple -c can be used.", - ) - ap.add_argument( - "--validate", action="store_true", help="validate the object and report errors" - ) - args = ap.parse_args() - - if args.validate: - exit_code = validate_object(args.import_path) - else: - exit_code = render_object(args.import_path, args.config) - - sys.exit(exit_code) +raise SystemExit(main()) diff --git a/numpydoc/cli.py b/numpydoc/cli.py new file mode 100644 index 00000000..de47d51d --- /dev/null +++ b/numpydoc/cli.py @@ -0,0 +1,128 @@ +"""The CLI for numpydoc.""" + +import argparse +import ast +from pathlib import Path +from typing import List, Sequence, Union + +from .docscrape_sphinx import get_doc_object +from .hooks import utils, validate_docstrings +from .validate import ERROR_MSGS, Validator, validate + + +def render_object(import_path: str, config: Union[List[str], None] = None) -> int: + """Test numpydoc docstring generation for a given object.""" + # TODO: Move Validator._load_obj to a better place than validate + print(get_doc_object(Validator._load_obj(import_path), config=dict(config or []))) + return 0 + + +def validate_object(import_path: str) -> int: + """Run numpydoc docstring validation for a given object.""" + exit_status = 0 + results = validate(import_path) + for err_code, err_desc in results["errors"]: + exit_status += 1 + print(":".join([import_path, err_code, err_desc])) + return exit_status + + +def get_parser() -> argparse.ArgumentParser: + """ + Build an argument parser. + + Returns + ------- + argparse.ArgumentParser + The argument parser. + """ + ap = argparse.ArgumentParser(prog="numpydoc", description=__doc__) + subparsers = ap.add_subparsers(title="subcommands") + + def _parse_config(s): + key, _, value = s.partition("=") + value = ast.literal_eval(value) + return key, value + + render = subparsers.add_parser( + "render", + description="Generate an expanded RST-version of the docstring.", + help="generate the RST docstring with numpydoc", + ) + render.add_argument("import_path", help="e.g. numpy.ndarray") + render.add_argument( + "-c", + "--config", + type=_parse_config, + action="append", + help="key=val where val will be parsed by literal_eval, " + "e.g. -c use_plots=True. Multiple -c can be used.", + ) + render.set_defaults(func=render_object) + + validate = subparsers.add_parser( + "validate", + description="Validate an object's docstring against the numpydoc standard.", + help="validate the object's docstring and report errors", + ) + validate.add_argument("import_path", help="e.g. numpy.ndarray") + validate.set_defaults(func=validate_object) + + project_root_from_cwd, config_file = utils.find_project_root(["."]) + config_options = validate_docstrings.parse_config(project_root_from_cwd) + ignored_checks = [ + f"- {check}: {ERROR_MSGS[check]}" + for check in set(ERROR_MSGS.keys()) - config_options["checks"] + ] + ignored_checks_text = "\n " + "\n ".join(ignored_checks) + "\n" + + lint_parser = subparsers.add_parser( + "lint", + description="Run numpydoc validation on files with option to ignore individual checks.", + help="validate all docstrings in file(s) using the abstract syntax tree", + formatter_class=argparse.RawTextHelpFormatter, + ) + lint_parser.add_argument( + "files", type=str, nargs="+", help="File(s) to run numpydoc validation on." + ) + lint_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." + ), + ) + lint_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_text}' + 'Values provided here will be in addition to the above, unless an alternate config is provided.' + if ignored_checks else '' + }""" + ), + ) + lint_parser.set_defaults(func=validate_docstrings.run_hook) + + return ap + + +def main(argv: Union[Sequence[str], None] = None) -> int: + """CLI for numpydoc.""" + ap = get_parser() + + args = vars(ap.parse_args(argv)) + + try: + func = args.pop("func") + return func(**args) + except KeyError: + ap.exit(status=2, message=ap.format_help()) diff --git a/numpydoc/hooks/validate_docstrings.py b/numpydoc/hooks/validate_docstrings.py index db8141d8..562a1f09 100644 --- a/numpydoc/hooks/validate_docstrings.py +++ b/numpydoc/hooks/validate_docstrings.py @@ -1,6 +1,5 @@ """Run numpydoc validation on contents of a file.""" -import argparse import ast import configparser import os @@ -13,7 +12,7 @@ import tomli as tomllib from pathlib import Path -from typing import Sequence, Tuple, Union +from typing import Any, Dict, List, Tuple, Union from tabulate import tabulate @@ -341,62 +340,35 @@ def process_file(filepath: os.PathLike, config: dict) -> "list[list[str]]": return docstring_visitor.findings -def main(argv: Union[Sequence[str], None] = None) -> int: - """Run the numpydoc validation hook.""" +def run_hook( + files: List[str], + *, + config: Union[Dict[str, Any], None] = None, + ignore: Union[List[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 set(validate.ERROR_MSGS.keys()) - config_options["checks"] - ] - ) - + "\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["checks"] else '' - }""" - ), - ) - - args = parser.parse_args(argv) - project_root, _ = find_project_root(args.files) - config_options = parse_config(args.config or project_root) - config_options["checks"] -= set(args.ignore or []) + Parameters + ---------- + files : list[str] + The absolute or relative paths to the files to inspect. + config : Union[dict[str, Any], None], optional + Configuration options for reviewing flagged issues. + ignore : Union[list[str], None], optional + Checks to ignore in the results. + + Returns + ------- + int + The return status: 1 if issues were found, 0 otherwise. + """ + project_root, _ = find_project_root(files) + config_options = parse_config(config or project_root) + config_options["checks"] -= set(ignore or []) findings = [] - for file in args.files: + for file in files: findings.extend(process_file(file, config_options)) if findings: @@ -411,7 +383,3 @@ def main(argv: Union[Sequence[str], None] = None) -> int: ) return 1 return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/numpydoc/tests/hooks/test_validate_hook.py b/numpydoc/tests/hooks/test_validate_hook.py index 5c635dfb..4e79f506 100644 --- a/numpydoc/tests/hooks/test_validate_hook.py +++ b/numpydoc/tests/hooks/test_validate_hook.py @@ -5,7 +5,7 @@ import pytest -from numpydoc.hooks.validate_docstrings import main +from numpydoc.hooks.validate_docstrings import run_hook @pytest.fixture @@ -72,11 +72,7 @@ def test_validate_hook(example_module, config, capsys): """ ) - args = [example_module] - if config: - args.append(f"--{config=}") - - return_code = main(args) + return_code = run_hook([example_module], config=config) assert return_code == 1 assert capsys.readouterr().err.rstrip() == expected @@ -109,7 +105,8 @@ def test_validate_hook_with_ignore(example_module, capsys): """ ) - return_code = main([example_module, "--ignore", "ES01", "SA01", "EX01"]) + return_code = run_hook([example_module], ignore=["ES01", "SA01", "EX01"]) + assert return_code == 1 assert capsys.readouterr().err.rstrip() == expected @@ -157,7 +154,7 @@ def test_validate_hook_with_toml_config(example_module, tmp_path, capsys): """ ) - return_code = main([example_module, "--config", str(tmp_path)]) + return_code = run_hook([example_module], config=tmp_path) assert return_code == 1 assert capsys.readouterr().err.rstrip() == expected @@ -196,23 +193,11 @@ def test_validate_hook_with_setup_cfg(example_module, tmp_path, capsys): """ ) - return_code = main([example_module, "--config", str(tmp_path)]) + return_code = run_hook([example_module], config=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 - - def test_validate_hook_exclude_option_pyproject(example_module, tmp_path, capsys): """ Test that a file is correctly processed with the config coming from @@ -255,7 +240,7 @@ def test_validate_hook_exclude_option_pyproject(example_module, tmp_path, capsys """ ) - return_code = main([example_module, "--config", str(tmp_path)]) + return_code = run_hook([example_module], config=tmp_path) assert return_code == 1 assert capsys.readouterr().err.rstrip() == expected @@ -292,6 +277,6 @@ def test_validate_hook_exclude_option_setup_cfg(example_module, tmp_path, capsys """ ) - return_code = main([example_module, "--config", str(tmp_path)]) + return_code = run_hook([example_module], config=tmp_path) assert return_code == 1 assert capsys.readouterr().err.rstrip() == expected diff --git a/numpydoc/tests/test_main.py b/numpydoc/tests/test_main.py index 1f90b967..8023e1c9 100644 --- a/numpydoc/tests/test_main.py +++ b/numpydoc/tests/test_main.py @@ -1,8 +1,11 @@ -import sys +import inspect import io +import sys + import pytest + import numpydoc -import numpydoc.__main__ +import numpydoc.cli def _capture_stdout(func_name, *args, **kwargs): @@ -65,41 +68,40 @@ def _invalid_docstring(): def test_renders_package_docstring(): - out = _capture_stdout(numpydoc.__main__.render_object, "numpydoc") + out = _capture_stdout(numpydoc.cli.render_object, "numpydoc") assert out.startswith("This package provides the numpydoc Sphinx") -def test_renders_module_docstring(): - out = _capture_stdout(numpydoc.__main__.render_object, "numpydoc.__main__") - assert out.startswith("Implementing `python -m numpydoc` functionality.") +def test_renders_module_docstring(capsys): + numpydoc.cli.main(["render", "numpydoc.cli"]) + out = capsys.readouterr().out.strip("\n\r") + assert out.startswith(numpydoc.cli.__doc__) def test_renders_function_docstring(): out = _capture_stdout( - numpydoc.__main__.render_object, "numpydoc.tests.test_main._capture_stdout" + numpydoc.cli.render_object, "numpydoc.tests.test_main._capture_stdout" ) assert out.startswith("Return stdout of calling") def test_render_object_returns_correct_exit_status(): - exit_status = numpydoc.__main__.render_object( - "numpydoc.tests.test_main._capture_stdout" - ) + exit_status = numpydoc.cli.render_object("numpydoc.tests.test_main._capture_stdout") assert exit_status == 0 with pytest.raises(ValueError): - numpydoc.__main__.render_object("numpydoc.tests.test_main._invalid_docstring") + numpydoc.cli.render_object("numpydoc.tests.test_main._invalid_docstring") def test_validate_detects_errors(): out = _capture_stdout( - numpydoc.__main__.validate_object, + numpydoc.cli.validate_object, "numpydoc.tests.test_main._docstring_with_errors", ) assert "SS02" in out assert "Summary does not start with a capital letter" in out - exit_status = numpydoc.__main__.validate_object( + exit_status = numpydoc.cli.validate_object( "numpydoc.tests.test_main._docstring_with_errors" ) assert exit_status > 0 @@ -107,11 +109,51 @@ def test_validate_detects_errors(): def test_validate_perfect_docstring(): out = _capture_stdout( - numpydoc.__main__.validate_object, "numpydoc.tests.test_main._capture_stdout" + numpydoc.cli.validate_object, "numpydoc.tests.test_main._capture_stdout" ) assert out == "" - exit_status = numpydoc.__main__.validate_object( + exit_status = numpydoc.cli.validate_object( "numpydoc.tests.test_main._capture_stdout" ) assert exit_status == 0 + + +@pytest.mark.parametrize("args", [[], ["--ignore", "ES01", "SA01", "EX01"]]) +def test_lint(capsys, args): + argv = ["lint", "numpydoc/__main__.py"] + args + if args: + expected = "" + expected_status = 0 + else: + expected = inspect.cleandoc( + """ + +------------------------+----------+---------+----------------------------+ + | file | item | check | description | + +========================+==========+=========+============================+ + | numpydoc/__main__.py:1 | __main__ | ES01 | No extended summary found | + +------------------------+----------+---------+----------------------------+ + | numpydoc/__main__.py:1 | __main__ | SA01 | See Also section not found | + +------------------------+----------+---------+----------------------------+ + | numpydoc/__main__.py:1 | __main__ | EX01 | No examples section found | + +------------------------+----------+---------+----------------------------+ + """ + ) + expected_status = 1 + + return_status = numpydoc.cli.main(argv) + err = capsys.readouterr().err.strip("\n\r") + assert err == expected + assert return_status == expected_status + + +def test_lint_help(capsys): + """Test that lint help section is displaying.""" + + with pytest.raises(SystemExit): + return_code = numpydoc.cli.main(["lint", "--help"]) + assert return_code == 0 + + out = capsys.readouterr().out + assert "--ignore" in out + assert "--config" in out diff --git a/pyproject.toml b/pyproject.toml index 3d57ce47..89cca8fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,7 +61,7 @@ test = [ ] [project.scripts] -validate-docstrings = 'numpydoc.hooks.validate_docstrings:main' +numpydoc = 'numpydoc.cli:main' [tool.changelist] title_template = "{version}"