1"""
2This module handles retrieval of configuration values from either the
3command-line arguments or the pyproject.toml file.
4"""
5import argparse
6import pathlib
7import sys
8
9import toml
10
11from .version import __version__
12
13#: Possible configuration options and their respective defaults
14DEFAULTS = {
15    "min_confidence": 0,
16    "paths": [],
17    "exclude": [],
18    "ignore_decorators": [],
19    "ignore_names": [],
20    "make_whitelist": False,
21    "sort_by_size": False,
22    "verbose": False,
23}
24
25
26def _check_input_config(data):
27    """
28    Checks the types of the values in *data* against the expected types of
29    config-values. If a value is of the wrong type it will raise a SystemExit.
30    """
31    for key, value in data.items():
32        if key not in DEFAULTS:
33            sys.exit(f"Unknown configuration key: {key}")
34        # The linter suggests to use "isinstance" here but this fails to
35        # detect the difference between `int` and `bool`.
36        if type(value) is not type(DEFAULTS[key]):  # noqa: E721
37            expected_type = type(DEFAULTS[key]).__name__
38            sys.exit(f"Data type for {key} must be {expected_type!r}")
39
40
41def _check_output_config(config):
42    """
43    Run sanity checks on the generated config after all parsing and
44    preprocessing is done.
45
46    Exit the application if an error is encountered.
47    """
48    if not config["paths"]:
49        sys.exit("Please pass at least one file or directory")
50
51
52def _parse_toml(infile):
53    """
54    Parse a TOML file for config values.
55
56    It will search for a section named ``[tool.vulture]`` which contains the
57    same keys as the CLI arguments seen with ``--help``. All leading dashes are
58    removed and other dashes are replaced by underscores (so ``--sort-by-size``
59    becomes ``sort_by_size``).
60
61    Arguments containing multiple values are standard TOML lists.
62
63    Example::
64
65        [tool.vulture]
66        exclude = ["file*.py", "dir/"]
67        ignore_decorators = ["deco1", "deco2"]
68        ignore_names = ["name1", "name2"]
69        make_whitelist = true
70        min_confidence = 10
71        sort_by_size = true
72        verbose = true
73        paths = ["path1", "path2"]
74    """
75    data = toml.load(infile)
76    settings = data.get("tool", {}).get("vulture", {})
77    _check_input_config(settings)
78    return settings
79
80
81def _parse_args(args=None):
82    """
83    Parse CLI arguments.
84
85    :param args: A list of strings representing the CLI arguments. If left to
86        the default, this will default to ``sys.argv``.
87    """
88
89    # Sentinel value to distinguish between "False" and "no default given".
90    missing = object()
91
92    def csv(exclude):
93        return exclude.split(",")
94
95    usage = "%(prog)s [options] [PATH ...]"
96    version = f"vulture {__version__}"
97    glob_help = "Patterns may contain glob wildcards (*, ?, [abc], [!abc])."
98    parser = argparse.ArgumentParser(prog="vulture", usage=usage)
99    parser.add_argument(
100        "paths",
101        nargs="*",
102        metavar="PATH",
103        default=missing,
104        help="Paths may be Python files or directories. For each directory"
105        " Vulture analyzes all contained *.py files.",
106    )
107    parser.add_argument(
108        "--exclude",
109        metavar="PATTERNS",
110        type=csv,
111        default=missing,
112        help=f"Comma-separated list of paths to ignore (e.g.,"
113        f' "*settings.py,docs/*.py"). {glob_help} A PATTERN without glob'
114        f" wildcards is treated as *PATTERN*.",
115    )
116    parser.add_argument(
117        "--ignore-decorators",
118        metavar="PATTERNS",
119        type=csv,
120        default=missing,
121        help=f"Comma-separated list of decorators. Functions and classes using"
122        f' these decorators are ignored (e.g., "@app.route,@require_*").'
123        f" {glob_help}",
124    )
125    parser.add_argument(
126        "--ignore-names",
127        metavar="PATTERNS",
128        type=csv,
129        default=missing,
130        help=f'Comma-separated list of names to ignore (e.g., "visit_*,do_*").'
131        f" {glob_help}",
132    )
133    parser.add_argument(
134        "--make-whitelist",
135        action="store_true",
136        default=missing,
137        help="Report unused code in a format that can be added to a"
138        " whitelist module.",
139    )
140    parser.add_argument(
141        "--min-confidence",
142        type=int,
143        default=missing,
144        help="Minimum confidence (between 0 and 100) for code to be"
145        " reported as unused.",
146    )
147    parser.add_argument(
148        "--sort-by-size",
149        action="store_true",
150        default=missing,
151        help="Sort unused functions and classes by their lines of code.",
152    )
153    parser.add_argument(
154        "-v", "--verbose", action="store_true", default=missing
155    )
156    parser.add_argument("--version", action="version", version=version)
157    namespace = parser.parse_args(args)
158    cli_args = {
159        key: value
160        for key, value in vars(namespace).items()
161        if value is not missing
162    }
163    _check_input_config(cli_args)
164    return cli_args
165
166
167def make_config(argv=None, tomlfile=None):
168    """
169    Returns a config object for vulture, merging both ``pyproject.toml`` and
170    CLI arguments (CLI arguments have precedence).
171
172    :param argv: The CLI arguments to be parsed. This value is transparently
173        passed through to :py:meth:`argparse.ArgumentParser.parse_args`.
174    :param tomlfile: An IO instance containing TOML data. By default this will
175        auto-detect an existing ``pyproject.toml`` file and exists solely for
176        unit-testing.
177    """
178    # If we loaded data from a TOML file, we want to print this out on stdout
179    # in verbose mode so we need to keep the value around.
180    detected_toml_path = ""
181
182    if tomlfile:
183        config = _parse_toml(tomlfile)
184        detected_toml_path = str(tomlfile)
185    else:
186        toml_path = pathlib.Path("pyproject.toml").resolve()
187        if toml_path.is_file():
188            with open(toml_path) as fconfig:
189                config = _parse_toml(fconfig)
190            detected_toml_path = str(toml_path)
191        else:
192            config = {}
193
194    cli_config = _parse_args(argv)
195
196    # Overwrite TOML options with CLI options, if given.
197    config.update(cli_config)
198
199    # Set defaults for missing options.
200    for key, value in DEFAULTS.items():
201        config.setdefault(key, value)
202
203    if detected_toml_path and config["verbose"]:
204        print(f"Reading configuration from {detected_toml_path}")
205
206    _check_output_config(config)
207
208    return config
209