File: settings.py

package info (click to toggle)
python-refurb 1.27.0-1
  • links: PTS, VCS
  • area: main
  • in suites: sid
  • size: 1,700 kB
  • sloc: python: 9,468; makefile: 40; sh: 6
file content (363 lines) | stat: -rw-r--r-- 11,162 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
from __future__ import annotations

import os
import re
import sys
from dataclasses import dataclass, field, replace
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, TypeVar

if TYPE_CHECKING:
    from collections.abc import Callable, Iterator

if sys.version_info >= (3, 11):
    import tomllib  # pragma: no cover
else:
    import tomli as tomllib  # pragma: no cover

from .error import ErrorCategory, ErrorClassifier, ErrorCode


def get_python_version() -> tuple[int, int]:
    return sys.version_info[:2]


@dataclass
class Settings:
    files: list[str] = field(default_factory=list)
    explain: ErrorCode | None = None
    ignore: set[ErrorClassifier] = field(default_factory=set)
    load: list[str] = field(default_factory=list)
    enable: set[ErrorClassifier] = field(default_factory=set)
    disable: set[ErrorClassifier] = field(default_factory=set)
    debug: bool = False
    generate: bool = False
    help: bool = False
    version: bool = False
    quiet: bool = False
    enable_all: bool = False
    disable_all: bool = False
    config_file: str | None = None
    python_version: tuple[int, int] | None = None
    mypy_args: list[str] = field(default_factory=list)
    format: Literal["text", "github"] | None = None
    sort_by: Literal["filename", "error"] | None = None
    verbose: bool = False
    timing_stats: Path | None = None
    color: bool = True

    def __post_init__(self) -> None:
        if self.enable_all and self.disable_all:
            raise ValueError(
                'refurb: "enable all" and "disable all" can\'t be used at the same time'  # noqa: E501
            )

        if os.getenv("NO_COLOR") or not sys.stdout.isatty():
            self.color = False

    @staticmethod
    def merge(old: Settings, new: Settings) -> Settings:
        if not old.disable_all and new.disable_all:
            enable = new.enable
            disable = set()

        elif not old.enable_all and new.enable_all:
            disable = new.disable
            enable = set()

        else:
            disable = old.disable | new.disable
            enable = (old.enable | new.enable) - disable

        return Settings(
            files=old.files + new.files,
            explain=old.explain or new.explain,
            ignore=old.ignore | new.ignore,
            enable=enable,
            disable=disable,
            load=old.load + new.load,
            debug=old.debug or new.debug,
            generate=old.generate or new.generate,
            help=old.help or new.help,
            version=old.version or new.version,
            disable_all=old.disable_all or new.disable_all,
            enable_all=old.enable_all or new.enable_all,
            quiet=old.quiet or new.quiet,
            config_file=old.config_file or new.config_file,
            python_version=new.python_version or old.python_version,
            mypy_args=new.mypy_args or old.mypy_args,
            format=new.format or old.format,
            sort_by=new.sort_by or old.sort_by,
            verbose=old.verbose or new.verbose,
            timing_stats=old.timing_stats or new.timing_stats,
            color=old.color and new.color,
        )

    def get_python_version(self) -> tuple[int, int]:
        return self.python_version or get_python_version()


ERROR_ID_REGEX = re.compile("^([A-Z]{3,4})?(\\d{3})$")


def parse_error_classifier(err: str) -> ErrorCategory | ErrorCode:
    return parse_error_category(err) or parse_error_id(err)


def parse_error_category(err: str) -> ErrorCategory | None:
    return ErrorCategory(err[1:]) if err.startswith("#") else None


def parse_error_id(err: str) -> ErrorCode:
    if match := ERROR_ID_REGEX.match(err):
        groups = match.groups()

        return ErrorCode(prefix=groups[0] or "FURB", id=int(groups[1]))

    raise ValueError(f'refurb: "{err}" must be in form FURB123 or 123')


def parse_python_version(version: str) -> tuple[int, int]:
    nums = version.split(".")

    if len(nums) == 2 and all(num.isnumeric() for num in nums):
        return tuple(int(num) for num in nums)[:2]  # type: ignore

    raise ValueError("refurb: version must be in form `x.y`")


def validate_format(format: str) -> Literal["github", "text"]:
    if format in {"github", "text"}:
        return format  # type: ignore

    raise ValueError(f'refurb: "{format}" is not a valid format')


def validate_sort_by(sort_by: str) -> Literal["filename", "error"]:
    if sort_by in {"filename", "error"}:
        return sort_by  # type: ignore

    raise ValueError(f'refurb: cannot sort by "{sort_by}"')


def parse_amend_error(err: str, path: Path) -> ErrorClassifier:
    classifier = parse_error_classifier(err)

    return replace(classifier, path=path)


def parse_amendment(amendment: dict[str, Any]) -> set[ErrorClassifier]:  # type: ignore
    match amendment:
        case {"path": str(path), "ignore": list(ignored), **extra}:
            if extra:
                raise ValueError('refurb: only "path" and "ignore" fields are supported')

            return {parse_amend_error(str(error), Path(path)) for error in ignored}

    raise ValueError('refurb: "path" or "ignore" fields are missing or malformed')


T = TypeVar("T")


def pop_type(ty: type[T], type_name: str = "") -> Callable[..., T]:  # type: ignore[misc]
    def inner(config: dict[str, Any], name: str, *, default: T | None = None) -> T:  # type: ignore[misc]
        x = config.pop(name, default or ty())

        if isinstance(x, ty):
            return x

        raise ValueError(f'refurb: "{name}" must be a {type_name or ty.__name__}')

    return inner


pop_list = pop_type(list)
pop_bool = pop_type(bool)
pop_str = pop_type(str, "string")


def parse_config_file(contents: str) -> Settings:
    tool = tomllib.loads(contents).get("tool")

    if not tool:
        return Settings()

    config = tool.get("refurb")

    if not config:
        return Settings()

    settings = Settings()

    settings.load = pop_list(config, "load")
    settings.quiet = pop_bool(config, "quiet")
    settings.disable_all = pop_bool(config, "disable_all")
    settings.enable_all = pop_bool(config, "enable_all")
    settings.color = pop_bool(config, "color", default=True)

    enable = pop_list(config, "enable")
    disable = pop_list(config, "disable")
    settings.enable = {parse_error_classifier(str(x)) for x in enable}
    settings.disable = {parse_error_classifier(str(x)) for x in disable}
    settings.enable -= settings.disable

    ignore = pop_list(config, "ignore")
    settings.ignore = {parse_error_classifier(str(x)) for x in ignore}

    mypy_args = pop_list(config, "mypy_args")
    settings.mypy_args = [str(x) for x in mypy_args]

    if "python_version" in config:
        version = pop_str(config, "python_version")

        settings.python_version = parse_python_version(version)

    if "format" in config:
        settings.format = validate_format(pop_str(config, "format"))

    if "sort_by" in config:
        settings.sort_by = validate_sort_by(pop_str(config, "sort_by"))

    amendments: list[dict[str, Any]] = config.pop("amend", [])  # type: ignore

    if not isinstance(amendments, list):
        raise ValueError('refurb: "amend" field(s) must be a TOML table')

    for amendment in amendments:
        settings.ignore.update(parse_amendment(amendment))

    if config:
        raise ValueError(f"refurb: unknown field(s): {', '.join(config.keys())}")

    return settings


def parse_command_line_args(args: list[str]) -> Settings:
    if not args:
        return Settings(help=True)

    if len(args) == 1 and args[0] == "gen":
        return Settings(generate=True)

    iargs = iter(args)

    settings = Settings()

    def get_next_arg(arg: str, args: Iterator[str]) -> str:
        if (value := next(args, None)) is not None:
            return value

        raise ValueError(f'refurb: missing argument after "{arg}"')

    for arg in iargs:
        if arg == "--debug":
            settings.debug = True

        elif arg in {"--help", "-h"}:
            settings.help = True

        elif arg == "--version":
            settings.version = True

        elif arg == "--quiet":
            settings.quiet = True

        elif arg == "--disable-all":
            settings.enable.clear()
            settings.disable_all = True

        elif arg == "--enable-all":
            settings.disable.clear()
            settings.enable_all = True

        elif arg == "--explain":
            settings.explain = parse_error_id(get_next_arg(arg, iargs))

        elif arg == "--ignore":
            classifiers = get_next_arg(arg, iargs).split(",")

            settings.ignore.update(map(parse_error_classifier, classifiers))

        elif arg == "--enable":
            error_codes = {
                parse_error_classifier(classifier)
                for classifier in get_next_arg(arg, iargs).split(",")
            }

            settings.enable |= error_codes
            settings.disable -= error_codes

        elif arg == "--disable":
            error_codes = {
                parse_error_classifier(classifier)
                for classifier in get_next_arg(arg, iargs).split(",")
            }

            settings.disable |= error_codes
            settings.enable -= error_codes

        elif arg == "--load":
            settings.load.append(get_next_arg(arg, iargs))

        elif arg == "--config-file":
            settings.config_file = get_next_arg(arg, iargs)

        elif arg == "--python-version":
            version = get_next_arg(arg, iargs)

            settings.python_version = parse_python_version(version)

        elif arg == "--format":
            settings.format = validate_format(get_next_arg(arg, iargs))

        elif arg == "--sort":
            settings.sort_by = validate_sort_by(get_next_arg(arg, iargs))

        elif arg in {"--verbose", "-v"}:
            settings.verbose = True

        elif arg == "--timing-stats":
            settings.timing_stats = Path(get_next_arg(arg, iargs))

        elif arg == "--no-color":
            settings.color = False

        elif arg == "--":
            settings.mypy_args = list(iargs)

        elif arg.startswith("-"):
            raise ValueError(f'refurb: unsupported option "{arg}"')

        elif arg:
            settings.files.append(arg)

        else:
            raise ValueError("refurb: argument cannot be empty")

    if len(args) > 1 and (settings.help or settings.version):
        msg = f"refurb: unexpected value before/after `{args[0]}`"

        raise ValueError(msg)

    return settings


def load_settings(args: list[str]) -> Settings:
    cli_args = parse_command_line_args(args)

    file = Path(cli_args.config_file or "pyproject.toml")

    try:
        config_file = parse_config_file(file.read_text())

    except IsADirectoryError as ex:
        raise ValueError(f'refurb: "{file}" is a directory') from ex

    except FileNotFoundError as ex:
        if cli_args.config_file:
            raise ValueError(f'refurb: "{file}" was not found') from ex

        config_file = Settings()  # pragma: no cover

    return Settings.merge(config_file, cli_args)