• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

oir / startle / 17195282036

24 Aug 2025 11:40PM UTC coverage: 98.525% (-0.2%) from 98.703%
17195282036

Pull #103

github

web-flow
Merge 09048dc1d into 20093f4ef
Pull Request #103: Render brief as markdown when printing help

192 of 192 branches covered (100.0%)

Branch coverage included in aggregate %.

877 of 893 relevant lines covered (98.21%)

0.98 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

97.0
startle/args.py
1
import sys
1✔
2
from dataclasses import dataclass, field
1✔
3
from typing import Any, Literal
1✔
4

5
from ._help import _Sty, help, usage, var_args_usage_line, var_kwargs_usage_line
1✔
6
from .arg import Arg, Name
1✔
7
from .error import ParserConfigError, ParserOptionError
1✔
8

9

10
@dataclass
1✔
11
class ParsingState:
1✔
12
    """
13
    A class to hold the state of the parsing process.
14
    """
15

16
    idx: int = 0
1✔
17
    positional_idx: int = 0
1✔
18

19
    # if `--` token is observed, all following arguments are treated as positional
20
    # even if they look like options (e.g. `--foo`). This attribute is used to
21
    # track that state.
22
    positional_only: bool = False
1✔
23

24

25
@dataclass
1✔
26
class Args:
1✔
27
    """
28
    A parser class to parse command-line arguments.
29
    Contains positional and named arguments, as well as var args
30
    (unknown positional arguments) and var kwargs (unknown options).
31
    """
32

33
    brief: str = ""
1✔
34
    program_name: str = ""
1✔
35

36
    _positional_args: list[Arg] = field(default_factory=list)
1✔
37
    _named_args: list[Arg] = field(default_factory=list)
1✔
38
    _name2idx: dict[str, int] = field(default_factory=dict)
1✔
39
    # note that _name2idx is many to one, because a name can be both short and long
40

41
    _var_args: Arg | None = None  # remaining unk args for functions with *args
1✔
42
    _var_kwargs: Arg | None = None  # remaining unk options for functions with **kwargs
1✔
43

44
    @staticmethod
1✔
45
    def _is_name(value: str) -> str | Literal[False]:
1✔
46
        """
47
        Check if a string, as provided in the command-line arguments, looks
48
        like an option name (starts with - or --).
49

50
        Returns:
51
            The name of the option if it is an option, otherwise False.
52
        """
53
        if value.startswith("--"):
1✔
54
            name = value[2:]
1✔
55
            # if not name:
56
            #     raise ParserOptionError("Prefix `--` is not followed by an option!")
57
            # this case should not happen anymore
58
            return name
1✔
59
        if value.startswith("-"):
1✔
60
            name = value[1:]
1✔
61
            if not name:
1✔
62
                raise ParserOptionError("Prefix `-` is not followed by an option!")
1✔
63
            return name
1✔
64
        return False
1✔
65

66
    @staticmethod
1✔
67
    def _is_combined_short_names(value: str) -> str | Literal[False]:
1✔
68
        """
69
        Check if a string, as provided in the command-line arguments, looks
70
        like multiple combined short names (e.g. -abc).
71
        """
72
        if value.startswith("-") and not value.startswith("--"):
1✔
73
            value = value[1:].split("=", 1)[0]
1✔
74
            if len(value) > 1:
1✔
75
                return value
1✔
76
        return False
1✔
77

78
    def add(self, arg: Arg):
1✔
79
        """
80
        Add an argument to the parser.
81
        """
82
        if arg.is_positional:  # positional argument
1✔
83
            self._positional_args.append(arg)
1✔
84
        if arg.is_named:  # named argument
1✔
85
            if not arg.name.long_or_short:
1✔
86
                raise ParserConfigError(
×
87
                    "Named arguments should have at least one name!"
88
                )
89
            self._named_args.append(arg)
1✔
90
            if arg.name.short:
1✔
91
                self._name2idx[arg.name.short] = len(self._named_args) - 1
1✔
92
            if arg.name.long:
1✔
93
                self._name2idx[arg.name.long] = len(self._named_args) - 1
1✔
94

95
    def enable_unknown_args(self, arg: Arg) -> None:
1✔
96
        """
97
        Enable variadic positional arguments for parsing unknown positional arguments.
98
        This argument stores remaining positional arguments as if it was a list[T] type.
99
        """
100
        if arg.is_nary and arg.container_type is None:
1✔
101
            raise ParserConfigError(
×
102
                "Container type must be specified for n-ary options!"
103
            )
104
        self._var_args = arg
1✔
105

106
    def enable_unknown_opts(self, arg: Arg) -> None:
1✔
107
        """
108
        Enable variadic keyword arguments for parsing unknown named options.
109
        This Arg itself is not used to store anything, it is used as a reference to generate
110
        Arg objects as needed on the fly.
111
        """
112
        if arg.is_nary and arg.container_type is None:
1✔
113
            raise ParserConfigError(
×
114
                "Container type must be specified for n-ary options!"
115
            )
116
        self._var_kwargs = arg
1✔
117

118
    def _parse_equals_syntax(self, name: str, state: ParsingState) -> ParsingState:
1✔
119
        """
120
        Parse a cli argument as a named argument using the equals syntax (e.g. `--name=value`).
121
        Return new index after consuming the argument.
122
        This requires the argument to be not a flag.
123
        If the argument is n-ary, it can be repeated.
124
        """
125
        name, value = name.split("=", 1)
1✔
126
        normal_name = name.replace("_", "-")
1✔
127
        if normal_name not in self._name2idx:
1✔
128
            if self._var_kwargs:
1✔
129
                self.add(
1✔
130
                    Arg(
131
                        name=Name(long=normal_name),  # does long always work?
132
                        type_=self._var_kwargs.type_,
133
                        container_type=self._var_kwargs.container_type,
134
                        is_named=True,
135
                        is_nary=self._var_kwargs.is_nary,
136
                    )
137
                )
138
            else:
139
                raise ParserOptionError(f"Unexpected option `{name}`!")
1✔
140
        opt = self._named_args[self._name2idx[normal_name]]
1✔
141
        if opt._parsed and not opt.is_nary:
1✔
142
            raise ParserOptionError(f"Option `{opt.name}` is multiply given!")
1✔
143
        if opt.is_flag:
1✔
144
            raise ParserOptionError(
1✔
145
                f"Option `{opt.name}` is a flag and cannot be assigned a value!"
146
            )
147
        opt.parse(value)
1✔
148
        state.idx += 1
1✔
149
        return state
1✔
150

151
    def _parse_combined_short_names(
1✔
152
        self, names: str, args: list[str], state: ParsingState
153
    ) -> ParsingState:
154
        """
155
        Parse a cli argument as a combined short names (e.g. -abc).
156
        Return new index after consuming the argument.
157
        """
158
        for i, name in enumerate(names):
1✔
159
            if name == "?":
1✔
160
                self.print_help()
×
161
                raise SystemExit(0)
×
162
            if name not in self._name2idx:
1✔
163
                raise ParserOptionError(f"Unexpected option `{name}`!")
1✔
164
            opt = self._named_args[self._name2idx[name]]
1✔
165
            if opt._parsed and not opt.is_nary:
1✔
166
                raise ParserOptionError(f"Option `{opt.name}` is multiply given!")
1✔
167

168
            if i < len(names) - 1:
1✔
169
                # up until the last option, all options must be flags
170
                if not opt.is_flag:
1✔
171
                    raise ParserOptionError(
1✔
172
                        f"Option `{opt.name}` is not a flag and cannot be combined!"
173
                    )
174
                opt.parse()
1✔
175
            else:
176
                # last option can be a flag or a regular option
177
                if "=" in args[state.idx]:
1✔
178
                    value = args[state.idx].split("=", 1)[1]
1✔
179
                    last = f"{name}={value}"
1✔
180
                    return self._parse_equals_syntax(last, state)
1✔
181
                if opt.is_flag:
1✔
182
                    opt.parse()
1✔
183
                    state.idx += 1
1✔
184
                    return state
1✔
185
                if opt.is_nary:
1✔
186
                    # n-ary option
187
                    values = []
1✔
188
                    state.idx += 1
1✔
189
                    while (
1✔
190
                        state.idx < len(args)
191
                        and self._is_name(args[state.idx]) is False
192
                    ):
193
                        values.append(args[state.idx])
1✔
194
                        state.idx += 1
1✔
195
                    if not values:
1✔
196
                        raise ParserOptionError(
1✔
197
                            f"Option `{opt.name}` is missing argument!"
198
                        )
199
                    for value in values:
1✔
200
                        opt.parse(value)
1✔
201
                    return state
1✔
202
                # not a flag, not n-ary
203
                if state.idx + 1 >= len(args):
1✔
204
                    raise ParserOptionError(f"Option `{opt.name}` is missing argument!")
1✔
205
                opt.parse(args[state.idx + 1])
1✔
206
                state.idx += 2
1✔
207
                return state
1✔
208

209
        raise RuntimeError("Programmer error: should not reach here!")
×
210

211
    def _parse_named(
1✔
212
        self, name: str, args: list[str], state: ParsingState
213
    ) -> ParsingState:
214
        """
215
        Parse a cli argument as a named argument / option.
216
        Return new index after consuming the argument.
217
        """
218
        if name in ["help", "?"]:
1✔
219
            self.print_help()
1✔
220
            raise SystemExit(0)
1✔
221
        if "=" in name:
1✔
222
            return self._parse_equals_syntax(name, state)
1✔
223
        normal_name = name.replace("_", "-")
1✔
224
        if normal_name not in self._name2idx:
1✔
225
            if self._var_kwargs:
1✔
226
                assert self._var_kwargs.type_ is not None
1✔
227
                self.add(
1✔
228
                    Arg(
229
                        name=Name(long=normal_name),  # does long always work?
230
                        type_=self._var_kwargs.type_,
231
                        container_type=self._var_kwargs.container_type,
232
                        is_named=True,
233
                        is_nary=self._var_kwargs.is_nary,
234
                    )
235
                )
236
            else:
237
                raise ParserOptionError(f"Unexpected option `{name}`!")
1✔
238
        opt = self._named_args[self._name2idx[normal_name]]
1✔
239
        if opt._parsed and not opt.is_nary:
1✔
240
            raise ParserOptionError(f"Option `{opt.name}` is multiply given!")
1✔
241

242
        if opt.is_flag:
1✔
243
            opt.parse()
1✔
244
            state.idx += 1
1✔
245
            return state
1✔
246
        if opt.is_nary:
1✔
247
            # n-ary option
248
            values = []
1✔
249
            state.idx += 1
1✔
250
            while state.idx < len(args) and self._is_name(args[state.idx]) is False:
1✔
251
                values.append(args[state.idx])
1✔
252
                state.idx += 1
1✔
253
            if not values:
1✔
254
                raise ParserOptionError(f"Option `{opt.name}` is missing argument!")
1✔
255
            for value in values:
1✔
256
                opt.parse(value)
1✔
257
            return state
1✔
258

259
        # not a flag, not n-ary
260
        if state.idx + 1 >= len(args):
1✔
261
            raise ParserOptionError(f"Option `{opt.name}` is missing argument!")
1✔
262
        opt.parse(args[state.idx + 1])
1✔
263
        state.idx += 2
1✔
264
        return state
1✔
265

266
    def _parse_positional(self, args: list[str], state: ParsingState) -> ParsingState:
1✔
267
        """
268
        Parse a cli argument as a positional argument.
269
        Return new indices after consuming the argument.
270
        """
271

272
        # skip already parsed positional arguments
273
        # (because they could have also been named)
274
        while (
1✔
275
            state.positional_idx < len(self._positional_args)
276
            and self._positional_args[state.positional_idx]._parsed
277
        ):
278
            state.positional_idx += 1
1✔
279

280
        if not state.positional_idx < len(self._positional_args):
1✔
281
            if self._var_args:
1✔
282
                self._var_args.parse(args[state.idx])
1✔
283
                state.idx += 1
1✔
284
                return state
1✔
285
            else:
286
                raise ParserOptionError(
1✔
287
                    f"Unexpected positional argument: `{args[state.idx]}`!"
288
                )
289

290
        arg = self._positional_args[state.positional_idx]
1✔
291
        if arg._parsed:
1✔
292
            raise ParserOptionError(
×
293
                f"Positional argument `{args[state.idx]}` is multiply given!"
294
            )
295
        if arg.is_nary:
1✔
296
            # n-ary positional arg
297
            values = []
1✔
298
            while state.idx < len(args) and self._is_name(args[state.idx]) is False:
1✔
299
                values.append(args[state.idx])
1✔
300
                state.idx += 1
1✔
301
            for value in values:
1✔
302
                arg.parse(value)
1✔
303
        else:
304
            # regular positional arg
305
            arg.parse(args[state.idx])
1✔
306
            state.idx += 1
1✔
307
        state.positional_idx += 1
1✔
308
        return state
1✔
309

310
    def _parse(self, args: list[str]):
1✔
311
        state = ParsingState()
1✔
312

313
        while state.idx < len(args):
1✔
314
            if not state.positional_only and args[state.idx] == "--":
1✔
315
                # all subsequent arguments will be attempted to be parsed as positional
316
                state.positional_only = True
1✔
317
                state.idx += 1
1✔
318
                continue
1✔
319
            name = self._is_name(args[state.idx])
1✔
320
            if not state.positional_only and name:
1✔
321
                # this must be a named argument / option
322
                if names := self._is_combined_short_names(args[state.idx]):
1✔
323
                    state = self._parse_combined_short_names(names, args, state)
1✔
324
                else:
325
                    try:
1✔
326
                        state = self._parse_named(name, args, state)
1✔
327
                    except ParserOptionError as e:
1✔
328
                        if self._var_args and str(e).startswith("Unexpected option"):
1✔
329
                            self._var_args.parse(args[state.idx])
1✔
330
                            state.idx += 1
1✔
331
                        else:
332
                            raise
1✔
333
            else:
334
                # this must be a positional argument
335
                state = self._parse_positional(args, state)
1✔
336

337
        # check if all required arguments are given, assign defaults otherwise
338
        for arg in self._positional_args + self._named_args:
1✔
339
            if not arg._parsed:
1✔
340
                if arg.required:
1✔
341
                    if arg.is_named:
1✔
342
                        # if a positional arg is also named, prefer this type of error message
343
                        raise ParserOptionError(
1✔
344
                            f"Required option `{arg.name}` is not provided!"
345
                        )
346
                    else:
347
                        raise ParserOptionError(
1✔
348
                            f"Required positional argument <{arg.name.long}> is not provided!"
349
                        )
350
                else:
351
                    arg._value = arg.default
1✔
352
                    arg._parsed = True
1✔
353

354
    def make_func_args(self) -> tuple[list[Any], dict[str, Any]]:
1✔
355
        """
356
        Transform parsed arguments into function arguments.
357

358
        Returns a tuple of positional arguments and named arguments, such that
359
        the function can be called like `func(*positional_args, **named_args)`.
360

361
        For arguments that are both positional and named, the positional argument
362
        is preferred, to handle variadic args correctly.
363
        """
364

365
        def var(opt: Arg | str) -> str:
1✔
366
            if isinstance(opt, str):
1✔
367
                return opt.replace("-", "_")
×
368
            return opt.name.long_or_short.replace("-", "_")
1✔
369

370
        positional_args = [arg._value for arg in self._positional_args]
1✔
371
        named_args = {
1✔
372
            var(opt): opt._value
373
            for opt in self._named_args
374
            if opt not in self._positional_args
375
        }
376

377
        if self._var_args and self._var_args._value:
1✔
378
            positional_args += self._var_args._value
1✔
379

380
        return positional_args, named_args
1✔
381

382
    def parse(self, cli_args: list[str] | None = None) -> "Args":
1✔
383
        """
384
        Parse the command-line arguments.
385

386
        Args:
387
            cli_args: The arguments to parse. If None, uses the arguments from the CLI.
388
        Returns:
389
            Self, for chaining.
390
        """
391
        if cli_args is not None:
1✔
392
            self._parse(cli_args)
1✔
393
        else:
394
            self._parse(sys.argv[1:])
1✔
395
        return self
1✔
396

397
    def print_help(self, console=None, usage_only: bool = False) -> None:
1✔
398
        """
399
        Print the help message to the console.
400

401
        Args:
402
            console: A rich console to print to. If None, uses the default console.
403
            usage_only: Whether to print only the usage line.
404
        """
405
        import sys
1✔
406

407
        from rich.console import Console
1✔
408
        from rich.markdown import Markdown
1✔
409
        from rich.table import Table
1✔
410
        from rich.text import Text
1✔
411

412
        name = self.program_name or sys.argv[0]
1✔
413

414
        positional_only = [
1✔
415
            arg
416
            for arg in self._positional_args
417
            if arg.is_positional and not arg.is_named
418
        ]
419
        positional_and_named = [
1✔
420
            arg for arg in self._positional_args if arg.is_positional and arg.is_named
421
        ]
422
        named_only = [
1✔
423
            opt for opt in self._named_args if opt.is_named and not opt.is_positional
424
        ]
425

426
        # (1) print brief if it exists
427
        console = console or Console()
1✔
428
        console.print()
1✔
429
        if self.brief and not usage_only:
1✔
430
            try:
1✔
431
                md = Markdown(self.brief)
1✔
432
                console.print(md)
1✔
433
                console.print()
1✔
434
            except Exception:
×
435
                console.print(self.brief + "\n")
×
436

437
        # (2) then print usage line
438
        console.print(Text("Usage:", style=_Sty.title))
1✔
439
        usage_components = [Text(f"  {name}")]
1✔
440
        pos_only_str = Text(" ").join(
1✔
441
            [usage(arg, "usage line") for arg in positional_only]
442
        )
443
        if pos_only_str:
1✔
444
            usage_components.append(pos_only_str)
1✔
445
        for opt in positional_and_named:
1✔
446
            usage_components.append(usage(opt, "usage line"))
1✔
447
        if self._var_args:
1✔
448
            usage_components.append(var_args_usage_line(self._var_args))
1✔
449
        for opt in named_only:
1✔
450
            usage_components.append(usage(opt, "usage line"))
1✔
451
        if self._var_kwargs:
1✔
452
            usage_components.append(var_kwargs_usage_line(self._var_kwargs))
1✔
453

454
        console.print(Text(" ").join(usage_components))
1✔
455

456
        if usage_only:
1✔
457
            console.print()
1✔
458
            return
1✔
459

460
        # (3) then print help message for each argument
461
        console.print(Text("\nwhere", style=_Sty.title))
1✔
462

463
        table = Table(show_header=False, box=None, padding=(0, 0, 0, 2))
1✔
464

465
        for arg in positional_only:
1✔
466
            table.add_row("[dim](positional)[/dim]", usage(arg), help(arg))
1✔
467
        for opt in positional_and_named:
1✔
468
            table.add_row("[dim](pos. or opt.)[/dim]", usage(opt), help(opt))
1✔
469
        if self._var_args:
1✔
470
            table.add_row(
1✔
471
                "[dim](positional)[/dim]",
472
                usage(self._var_args),
473
                help(self._var_args),
474
            )
475
        for opt in named_only:
1✔
476
            table.add_row("[dim](option)[/dim]", usage(opt), help(opt))
1✔
477
        if self._var_kwargs:
1✔
478
            table.add_row(
1✔
479
                "[dim](option)[/dim]",
480
                usage(self._var_kwargs),
481
                help(self._var_kwargs),
482
            )
483

484
        table.add_row(
1✔
485
            "[dim](option)[/dim]",
486
            Text.assemble(
487
                ("-?", f"{_Sty.name} {_Sty.opt} dim"),
488
                ("|", f"{_Sty.opt} dim"),
489
                ("--help", f"{_Sty.name} {_Sty.opt} dim"),
490
            ),
491
            "[i dim]Show this help message and exit.[/]",
492
        )
493

494
        console.print(table)
1✔
495
        console.print()
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc