diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 7a498b9a..51cfc20f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -38,7 +38,7 @@ jobs: - name: Install Vulture from wheel (For Windows) if: ${{ matrix.os == 'windows-2019' }} - run: "python -m pip install --only-binary=:all: --no-index --ignore-installed --find-links=dist/ vulture" + run: "python -m pip install --only-binary=:all: --ignore-installed --find-links=dist/ vulture" - name: Run Vulture run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d5ee123..57497e12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ `obj.constant_string` (jingw, #219). * Fix false positives when assigning to `x.some_name` but reading via `some_name`, at the cost of potential false negatives (jingw, #221). +* Allow reading options from `pyproject.toml` (Michel Albert, #164, #215). # 2.0 (2020-08-11) diff --git a/README.md b/README.md index 93e10078..0936ddba 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,25 @@ def foo(arg: Sequence): if you're using Python 3.7+. + +## Configuration + +You can also store command line arguments in `pyproject.toml` under the +`tool.vulture` section. Simply remove leading dashes and replace all +remaining dashes with underscores. Example: + +``` toml +[tool.vulture] +exclude = ["file*.py", "dir/"] +ignore_decorators = ["@app.route", "@require_*"] +ignore_names = ["visit_*", "do_*"] +make_whitelist = true +min_confidence = 80 +sort_by_size = true +verbose = true +paths = ["myscript.py", "mydir"] +``` + ## How does it work? Vulture uses the `ast` module to build abstract syntax trees for all diff --git a/dev/release.sh b/dev/release.sh index 468decd2..4647a8a8 100755 --- a/dev/release.sh +++ b/dev/release.sh @@ -28,7 +28,7 @@ git pull tox # Bump version. -sed -i -e "s/__version__ = \".*\"/__version__ = \"$VERSION\"/" vulture/core.py +sed -i -e "s/__version__ = \".*\"/__version__ = \"$VERSION\"/" vulture/version.py git commit -am "Update version number to ${VERSION} for release." git tag -a "v$VERSION" -m "v$VERSION" HEAD diff --git a/setup.py b/setup.py index df63c856..eb68f095 100644 --- a/setup.py +++ b/setup.py @@ -28,7 +28,7 @@ def find_version(*file_parts): setuptools.setup( name="vulture", - version=find_version("vulture", "core.py"), + version=find_version("vulture", "version.py"), description="Find dead code", long_description=long_description, long_description_content_type="text/markdown", @@ -51,6 +51,7 @@ def find_version(*file_parts): "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Software Development :: Quality Assurance", ], + install_requires=["toml"], entry_points={"console_scripts": ["vulture = vulture.core:main"]}, python_requires=">=3.6", packages=setuptools.find_packages(exclude=["tests"]), diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 00000000..947da34b --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,178 @@ +""" +Unit tests for config file and CLI argument parsing. +""" + +from io import StringIO +from textwrap import dedent + +import pytest + +from vulture.config import ( + DEFAULTS, + _check_input_config, + _parse_args, + _parse_toml, + make_config, +) + + +def test_cli_args(): + """ + Ensure that CLI arguments are converted to a config object. + """ + expected = dict( + paths=["path1", "path2"], + exclude=["file*.py", "dir/"], + ignore_decorators=["deco1", "deco2"], + ignore_names=["name1", "name2"], + make_whitelist=True, + min_confidence=10, + sort_by_size=True, + verbose=True, + ) + result = _parse_args( + [ + "--exclude=file*.py,dir/", + "--ignore-decorators=deco1,deco2", + "--ignore-names=name1,name2", + "--make-whitelist", + "--min-confidence=10", + "--sort-by-size", + "--verbose", + "path1", + "path2", + ] + ) + assert isinstance(result, dict) + assert result == expected + + +def test_toml_config(): + """ + Ensure parsing of TOML files results in a valid config object. + """ + expected = dict( + paths=["path1", "path2"], + exclude=["file*.py", "dir/"], + ignore_decorators=["deco1", "deco2"], + ignore_names=["name1", "name2"], + make_whitelist=True, + min_confidence=10, + sort_by_size=True, + verbose=True, + ) + data = StringIO( + dedent( + """\ + [tool.vulture] + exclude = ["file*.py", "dir/"] + ignore_decorators = ["deco1", "deco2"] + ignore_names = ["name1", "name2"] + make_whitelist = true + min_confidence = 10 + sort_by_size = true + verbose = true + paths = ["path1", "path2"] + """ + ) + ) + result = _parse_toml(data) + assert isinstance(result, dict) + assert result == expected + + +def test_config_merging(): + """ + If we have both CLI args and a ``pyproject.toml`` file, the CLI args should + have precedence. + """ + toml = StringIO( + dedent( + """\ + [tool.vulture] + exclude = ["toml_exclude"] + ignore_decorators = ["toml_deco"] + ignore_names = ["toml_name"] + make_whitelist = false + min_confidence = 10 + sort_by_size = false + verbose = false + paths = ["toml_path"] + """ + ) + ) + cliargs = [ + "--exclude=cli_exclude", + "--ignore-decorators=cli_deco", + "--ignore-names=cli_name", + "--make-whitelist", + "--min-confidence=20", + "--sort-by-size", + "--verbose", + "cli_path", + ] + result = make_config(cliargs, toml) + expected = dict( + paths=["cli_path"], + exclude=["cli_exclude"], + ignore_decorators=["cli_deco"], + ignore_names=["cli_name"], + make_whitelist=True, + min_confidence=20, + sort_by_size=True, + verbose=True, + ) + assert result == expected + + +def test_config_merging_missing(): + """ + If we have set a boolean value in the TOML file, but not on the CLI, we + want the TOML value to be taken. + """ + toml = StringIO( + dedent( + """\ + [tool.vulture] + verbose = true + ignore_names = ["name1"] + """ + ) + ) + cliargs = [ + "cli_path", + ] + result = make_config(cliargs, toml) + assert result["verbose"] is True + assert result["ignore_names"] == ["name1"] + + +def test_invalid_config_options_output(): + """ + If the config file contains unknown options we want to abort. + """ + + with pytest.raises(SystemExit): + _check_input_config({"unknown_key_1": 1}) + + +@pytest.mark.parametrize( + "key, value", list(DEFAULTS.items()), +) +def test_incompatible_option_type(key, value): + """ + If a config value has a different type from the default value we abort. + """ + wrong_types = {int, str, list, bool} - {type(value)} + for wrong_type in wrong_types: + test_value = wrong_type() + with pytest.raises(SystemExit): + _check_input_config({key: test_value}) + + +def test_missing_paths(): + """ + If the script is run without any paths, we want to abort. + """ + with pytest.raises(SystemExit): + make_config([]) diff --git a/vulture/__init__.py b/vulture/__init__.py index c2b28f01..c5c331ce 100644 --- a/vulture/__init__.py +++ b/vulture/__init__.py @@ -1,4 +1,5 @@ -from vulture.core import __version__, Vulture +from vulture.core import Vulture +from vulture.version import __version__ assert __version__ assert Vulture diff --git a/vulture/config.py b/vulture/config.py new file mode 100644 index 00000000..45ae0c38 --- /dev/null +++ b/vulture/config.py @@ -0,0 +1,207 @@ +""" +This module handles retrieval of configuration values from either the +command-line arguments or the pyproject.toml file. +""" +import argparse +import sys +from os.path import abspath, exists + +import toml + +from .version import __version__ + +#: Possible configuration options and their respective defaults +DEFAULTS = { + "min_confidence": 0, + "paths": [], + "exclude": [], + "ignore_decorators": [], + "ignore_names": [], + "make_whitelist": False, + "sort_by_size": False, + "verbose": False, +} + + +def _check_input_config(data): + """ + Checks the types of the values in *data* against the expected types of + config-values. If a value is of the wrong type it will raise a SystemExit. + """ + for key, value in data.items(): + if key not in DEFAULTS: + sys.exit(f"Unknown configuration key: {key}") + # The linter suggests to use "isinstance" here but this fails to + # detect the difference between `int` and `bool`. + if type(value) is not type(DEFAULTS[key]): # noqa: E721 + expected_type = type(DEFAULTS[key]).__name__ + sys.exit(f"Data type for {key} must be {expected_type!r}") + + +def _check_output_config(config): + """ + Run sanity checks on the generated config after all parsing and + preprocessing is done. + + Exit the application if an error is encountered. + """ + if not config["paths"]: + sys.exit("Please pass at least one file or directory") + + +def _parse_toml(infile): + """ + Parse a TOML file for config values. + + It will search for a section named ``[tool.vulture]`` which contains the + same keys as the CLI arguments seen with ``--help``. All leading dashes are + removed and other dashes are replaced by underscores (so ``--sort-by-size`` + becomes ``sort_by_size``). + + Arguments containing multiple values are standard TOML lists. + + Example:: + + [tool.vulture] + exclude = ["file*.py", "dir/"] + ignore_decorators = ["deco1", "deco2"] + ignore_names = ["name1", "name2"] + make_whitelist = true + min_confidence = 10 + sort_by_size = true + verbose = true + paths = ["path1", "path2"] + """ + data = toml.load(infile) + settings = data.get("tool", {}).get("vulture", {}) + _check_input_config(settings) + return settings + + +def _parse_args(args=None): + """ + Parse CLI arguments. + + :param args: A list of strings representing the CLI arguments. If left to + the default, this will default to ``sys.argv``. + """ + + # Sentinel value to distinguish between "False" and "no default given". + missing = object() + + def csv(exclude): + return exclude.split(",") + + usage = "%(prog)s [options] [PATH ...]" + version = f"vulture {__version__}" + glob_help = "Patterns may contain glob wildcards (*, ?, [abc], [!abc])." + parser = argparse.ArgumentParser(prog="vulture", usage=usage) + parser.add_argument( + "paths", + nargs="*", + metavar="PATH", + help="Paths may be Python files or directories. For each directory" + " Vulture analyzes all contained *.py files.", + ) + parser.add_argument( + "--exclude", + metavar="PATTERNS", + type=csv, + default=missing, + help=f"Comma-separated list of paths to ignore (e.g.," + f' "*settings.py,docs/*.py"). {glob_help} A PATTERN without glob' + f" wildcards is treated as *PATTERN*.", + ) + parser.add_argument( + "--ignore-decorators", + metavar="PATTERNS", + type=csv, + default=missing, + help=f"Comma-separated list of decorators. Functions and classes using" + f' these decorators are ignored (e.g., "@app.route,@require_*").' + f" {glob_help}", + ) + parser.add_argument( + "--ignore-names", + metavar="PATTERNS", + type=csv, + default=missing, + help=f'Comma-separated list of names to ignore (e.g., "visit_*,do_*").' + f" {glob_help}", + ) + parser.add_argument( + "--make-whitelist", + action="store_true", + default=missing, + help="Report unused code in a format that can be added to a" + " whitelist module.", + ) + parser.add_argument( + "--min-confidence", + type=int, + default=missing, + help="Minimum confidence (between 0 and 100) for code to be" + " reported as unused.", + ) + parser.add_argument( + "--sort-by-size", + action="store_true", + default=missing, + help="Sort unused functions and classes by their lines of code.", + ) + parser.add_argument( + "-v", "--verbose", action="store_true", default=missing + ) + parser.add_argument("--version", action="version", version=version) + namespace = parser.parse_args(args) + cli_args = { + key: value + for key, value in vars(namespace).items() + if value is not missing + } + _check_input_config(cli_args) + return cli_args + + +def make_config(argv=None, tomlfile=None): + """ + Returns a config object for vulture, merging both ``pyproject.toml`` and + CLI arguments (CLI arguments have precedence). + + :param argv: The CLI arguments to be parsed. This value is transparently + passed through to :py:meth:`argparse.ArgumentParser.parse_args`. + :param tomlfile: An IO instance containing TOML data. By default this will + auto-detect an existing ``pyproject.toml`` file and exists solely for + unit-testing. + """ + # If we loaded data from a TOML file, we want to print this out on stdout + # in verbose mode so we need to keep the value around. + detected_toml_path = "" + + if tomlfile: + config = _parse_toml(tomlfile) + detected_toml_path = str(tomlfile) + else: + toml_path = abspath("pyproject.toml") + if exists(toml_path): + with open(toml_path) as config: + config = _parse_toml(config) + detected_toml_path = toml_path + else: + config = {} + + cli_config = _parse_args(argv) + + # Overwrite TOML options with CLI options, if given. + config.update(cli_config) + + # Set defaults for missing options. + for key, value in DEFAULTS.items(): + config.setdefault(key, value) + + if detected_toml_path and config["verbose"]: + print(f"Reading configuration from {detected_toml_path}") + + _check_output_config(config) + + return config diff --git a/vulture/core.py b/vulture/core.py index e2f5f543..cb7708d9 100644 --- a/vulture/core.py +++ b/vulture/core.py @@ -10,6 +10,7 @@ from vulture import lines from vulture import noqa from vulture import utils +from vulture.config import make_config __version__ = "2.0" @@ -724,17 +725,17 @@ def csv(exclude): def main(): - args = _parse_args() + config = make_config() vulture = Vulture( - verbose=args.verbose, - ignore_names=args.ignore_names, - ignore_decorators=args.ignore_decorators, + verbose=config["verbose"], + ignore_names=config["ignore_names"], + ignore_decorators=config["ignore_decorators"], ) - vulture.scavenge(args.paths, exclude=args.exclude) + vulture.scavenge(config["paths"], exclude=config["exclude"]) sys.exit( vulture.report( - min_confidence=args.min_confidence, - sort_by_size=args.sort_by_size, - make_whitelist=args.make_whitelist, + min_confidence=config["min_confidence"], + sort_by_size=config["sort_by_size"], + make_whitelist=config["make_whitelist"], ) ) diff --git a/vulture/version.py b/vulture/version.py new file mode 100644 index 00000000..a904ab98 --- /dev/null +++ b/vulture/version.py @@ -0,0 +1 @@ +__version__ = "1.5"