summaryrefslogtreecommitdiff
path: root/src/tox/config/cli/parser.py
blob: aeee7f1f10db3b368a8808b3f8666eae4288d20c (plain)
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
"""
Customize argparse logic for tox (also contains the base options).
"""
from __future__ import annotations

import argparse
import logging
import os
import sys
from argparse import SUPPRESS, Action, ArgumentDefaultsHelpFormatter, ArgumentParser, Namespace
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Sequence, Tuple, Type, TypeVar, cast

from tox.config.loader.str_convert import StrConvert
from tox.plugin import NAME

from .env_var import get_env_var
from .ini import IniConfig

if sys.version_info >= (3, 8):  # pragma: no cover (py38+)
    from typing import Literal
else:  # pragma: no cover (py38+)
    from typing_extensions import Literal

if TYPE_CHECKING:
    from tox.session.state import State


class ArgumentParserWithEnvAndConfig(ArgumentParser):
    """
    Argument parser which updates its defaults by checking the configuration files and environmental variables.
    """

    def __init__(self, *args: Any, **kwargs: Any) -> None:
        # sub-parsers also construct an instance of the parser, but they don't get their own file config, but inherit
        self.file_config = kwargs.pop("file_config") if "file_config" in kwargs else IniConfig()
        kwargs["epilog"] = self.file_config.epilog
        super().__init__(*args, **kwargs)

    def fix_defaults(self) -> None:
        for action in self._actions:
            self.fix_default(action)

    def fix_default(self, action: Action) -> None:
        if hasattr(action, "default") and hasattr(action, "dest") and action.default != SUPPRESS:
            of_type = self.get_type(action)
            key = action.dest
            outcome = get_env_var(key, of_type=of_type)
            if outcome is None and self.file_config:
                outcome = self.file_config.get(key, of_type=of_type)
            if outcome is not None:
                action.default, default_value = outcome
                action.default_source = default_value  # type: ignore[attr-defined]
        if isinstance(action, argparse._SubParsersAction):
            for values in action.choices.values():
                if not isinstance(values, ToxParser):  # pragma: no cover
                    raise RuntimeError("detected sub-parser added without using our own add command")
                values.fix_defaults()

    @staticmethod
    def get_type(action: Action) -> type[Any]:
        of_type: type[Any] | None = getattr(action, "of_type", None)
        if of_type is None:
            if isinstance(action, argparse._AppendAction):
                of_type = List[action.type]  # type: ignore[name-defined]
            elif isinstance(action, argparse._StoreAction) and action.choices:
                loc = locals()
                loc["Literal"] = Literal
                as_literal = f"Literal[{', '.join(repr(i) for i in action.choices)}]"
                of_type = eval(as_literal, globals(), loc)
            elif action.default is not None:
                of_type = type(action.default)
            elif isinstance(action, argparse._StoreConstAction) and action.const is not None:
                of_type = type(action.const)
            else:
                raise TypeError(action)
        return of_type

    def parse_args(  # type: ignore # avoid defining all overloads
        self,
        args: Sequence[str] | None = None,
        namespace: Namespace | None = None,
    ) -> Namespace:
        res, argv = self.parse_known_args(args, namespace)
        if argv:
            self.error(
                f'unrecognized arguments: {" ".join(argv)}\n'
                "hint: if you tried to pass arguments to a command use -- to separate them from tox ones",
            )
        return res


class HelpFormatter(ArgumentDefaultsHelpFormatter):
    """
    A help formatter that provides the default value and the source it comes from.
    """

    def __init__(self, prog: str) -> None:
        super().__init__(prog, max_help_position=30, width=240)

    def _get_help_string(self, action: Action) -> str | None:
        text: str = super()._get_help_string(action) or ""
        if hasattr(action, "default_source"):
            default = " (default: %(default)s)"
            if text.endswith(default):  # pragma: no branch
                text = f"{text[: -len(default)]} (default: %(default)s -> from %(default_source)s)"
        return text

    def add_raw_text(self, text: str | None) -> None:
        def keep(content: str) -> str:
            return content

        if text is not SUPPRESS and text is not None:
            self._add_item(keep, [text])


ToxParserT = TypeVar("ToxParserT", bound="ToxParser")
DEFAULT_VERBOSITY = 2


class Parsed(Namespace):
    """CLI options"""

    @property
    def verbosity(self) -> int:
        """:return: reporting verbosity"""
        result: int = max(self.verbose - self.quiet, 0)
        return result

    @property
    def is_colored(self) -> bool:
        """:return: flag indicating if the output is colored or not"""
        return cast(bool, self.colored == "yes")

    exit_and_dump_after: int


ArgumentArgs = Tuple[Tuple[str, ...], Optional[Type[Any]], Dict[str, Any]]


class ToxParser(ArgumentParserWithEnvAndConfig):
    """Argument parser for tox."""

    def __init__(self, *args: Any, root: bool = False, add_cmd: bool = False, **kwargs: Any) -> None:
        self.of_cmd: str | None = None
        self.handlers: dict[str, tuple[Any, Callable[[State], int]]] = {}
        self._arguments: list[ArgumentArgs] = []
        self._groups: list[tuple[Any, dict[str, Any], list[tuple[dict[str, Any], list[ArgumentArgs]]]]] = []
        super().__init__(*args, **kwargs)
        if root is True:
            self._add_base_options()
        if add_cmd is True:
            msg = "tox command to execute (by default legacy)"
            self._cmd: Any | None = self.add_subparsers(title="subcommands", description=msg, dest="command")
            self._cmd.required = False
            self._cmd.default = "legacy"
        else:
            self._cmd = None

    def add_command(
        self,
        cmd: str,
        aliases: Sequence[str],
        help_msg: str,
        handler: Callable[[State], int],
    ) -> ArgumentParser:
        if self._cmd is None:
            raise RuntimeError("no sub-command group allowed")
        sub_parser: ToxParser = self._cmd.add_parser(
            cmd,
            help=help_msg,
            aliases=aliases,
            formatter_class=HelpFormatter,
            file_config=self.file_config,
        )
        sub_parser.of_cmd = cmd  # mark it as parser for a sub-command
        content = sub_parser, handler
        self.handlers[cmd] = content
        for alias in aliases:
            self.handlers[alias] = content
        for args, of_type, kwargs in self._arguments:
            sub_parser.add_argument(*args, of_type=of_type, **kwargs)
        for args, kwargs, excl in self._groups:
            group = sub_parser.add_argument_group(*args, **kwargs)
            for e_kwargs, arguments in excl:
                excl_group = group.add_mutually_exclusive_group(**e_kwargs)
                for a_args, _, a_kwargs in arguments:
                    excl_group.add_argument(*a_args, **a_kwargs)
        return sub_parser

    def add_argument_group(self, *args: Any, **kwargs: Any) -> Any:
        result = super().add_argument_group(*args, **kwargs)
        if self.of_cmd is None:
            if args not in (("positional arguments",), ("optional arguments",)):

                def add_mutually_exclusive_group(**e_kwargs: Any) -> Any:
                    def add_argument(*a_args: str, of_type: type[Any] | None = None, **a_kwargs: Any) -> Action:
                        res_args: Action = prev_add_arg(*a_args, **a_kwargs)  # type: ignore[has-type]
                        arguments.append((a_args, of_type, a_kwargs))
                        return res_args

                    arguments: list[ArgumentArgs] = []
                    excl.append((e_kwargs, arguments))
                    res_excl = prev_excl(**kwargs)
                    prev_add_arg = res_excl.add_argument
                    res_excl.add_argument = add_argument  # type: ignore[assignment]
                    return res_excl

                prev_excl = result.add_mutually_exclusive_group
                result.add_mutually_exclusive_group = add_mutually_exclusive_group  # type: ignore[assignment]
                excl: list[tuple[dict[str, Any], list[ArgumentArgs]]] = []
                self._groups.append((args, kwargs, excl))
        return result

    def add_argument(self, *args: str, of_type: type[Any] | None = None, **kwargs: Any) -> Action:
        result = super().add_argument(*args, **kwargs)
        if self.of_cmd is None and result.dest != "help":
            self._arguments.append((args, of_type, kwargs))
            if hasattr(self, "_cmd") and self._cmd is not None and hasattr(self._cmd, "choices"):
                for parser in {id(v): v for k, v in self._cmd.choices.items()}.values():
                    parser.add_argument(*args, of_type=of_type, **kwargs)
        if of_type is not None:
            result.of_type = of_type  # type: ignore[attr-defined]
        return result

    @classmethod
    def base(cls: type[ToxParserT]) -> ToxParserT:
        return cls(add_help=False, root=True)

    @classmethod
    def core(cls: type[ToxParserT]) -> ToxParserT:
        return cls(
            prog=NAME,
            formatter_class=HelpFormatter,
            add_cmd=True,
            root=True,
            description="create and set up environments to run command(s) in them",
        )

    def _add_base_options(self) -> None:
        """Argument options that always make sense."""
        add_core_arguments(self)
        self.fix_defaults()

    def parse_known_args(  # type: ignore[override]
        self,
        args: Sequence[str] | None = None,
        namespace: Parsed | None = None,
    ) -> tuple[Parsed, list[str]]:
        if args is None:
            args = sys.argv[1:]
        cmd_at: int | None = None
        if self._cmd is not None and args:
            for at, arg in enumerate(args):
                if arg in self._cmd.choices:
                    cmd_at = at
                    break
            else:
                cmd_at = None
        if cmd_at is not None:  # if we found a command move it to the start
            args = args[cmd_at], *args[:cmd_at], *args[cmd_at + 1 :]
        elif args not in (("--help",), ("-h",)) and (self._cmd is not None and "legacy" in self._cmd.choices):
            # on help no mangling needed, and we also want to insert once we have legacy to insert
            args = "legacy", *args
        result = Parsed() if namespace is None else namespace
        _, args = super().parse_known_args(args, namespace=result)
        return result, args


def add_verbosity_flags(parser: ArgumentParser) -> None:
    from tox.report import LEVELS

    level_map = "|".join(f"{c}={logging.getLevelName(l)}" for c, l in sorted(LEVELS.items()))
    verbosity_group = parser.add_argument_group("verbosity")
    verbosity_group.description = (
        f"every -v increases, every -q decreases verbosity level, "
        f"default {logging.getLevelName(LEVELS[DEFAULT_VERBOSITY])}, map {level_map}"
    )
    verbosity = verbosity_group.add_mutually_exclusive_group()
    verbosity.add_argument(
        "-v",
        "--verbose",
        action="count",
        dest="verbose",
        help="increase verbosity",
        default=DEFAULT_VERBOSITY,
    )
    verbosity.add_argument("-q", "--quiet", action="count", dest="quiet", help="decrease verbosity", default=0)


def add_color_flags(parser: ArgumentParser) -> None:
    converter = StrConvert()
    if os.environ.get("NO_COLOR", "") != "":
        color = "no"
    elif converter.to_bool(os.environ.get("FORCE_COLOR", "")):
        color = "yes"
    elif os.environ.get("TERM", "") == "dumb":
        color = "no"
    else:
        color = "yes" if sys.stdout.isatty() else "no"

    parser.add_argument(
        "--colored",
        default=color,
        choices=["yes", "no"],
        help="should output be enriched with colors, default is yes unless TERM=dumb or NO_COLOR is defined.",
    )


def add_exit_and_dump_after(parser: ArgumentParser) -> None:
    parser.add_argument(
        "--exit-and-dump-after",
        dest="exit_and_dump_after",
        metavar="seconds",
        default=0,
        type=int,
        help="dump tox threads after n seconds and exit the app - useful to debug when tox hangs, 0 means disabled",
    )


def add_core_arguments(parser: ArgumentParser) -> None:
    add_color_flags(parser)
    add_verbosity_flags(parser)
    add_exit_and_dump_after(parser)
    parser.add_argument(
        "-c",
        "--conf",
        dest="config_file",
        metavar="file",
        default=None,
        type=Path,
        of_type=Optional[Path],
        help="configuration file/folder for tox (if not specified will discover one)",
    )
    parser.add_argument(
        "--workdir",
        dest="work_dir",
        metavar="dir",
        default=None,
        type=Path,
        of_type=Optional[Path],
        help="tox working directory (if not specified will be the folder of the config file)",
    )
    parser.add_argument(
        "--root",
        dest="root_dir",
        metavar="dir",
        default=None,
        type=Path,
        of_type=Optional[Path],
        help="project root directory (if not specified will be the folder of the config file)",
    )


__all__ = (
    "DEFAULT_VERBOSITY",
    "Parsed",
    "ToxParser",
    "HelpFormatter",
)