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

oir / startle / 23171246509

16 Mar 2026 11:43PM UTC coverage: 98.606% (+0.02%) from 98.586%
23171246509

push

github

web-flow
Tabularize usage line in help text (#139)

249 of 249 branches covered (100.0%)

Branch coverage included in aggregate %.

1307 of 1329 relevant lines covered (98.34%)

0.98 hits per line

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

98.8
startle/args.py
1
import sys
1✔
2
from collections.abc import Iterable
1✔
3
from dataclasses import dataclass, field
1✔
4
from typing import TYPE_CHECKING, Any, Literal
1✔
5

6
from ._help import (
1✔
7
    Sty,
8
    help,
9
    usage,
10
    var_args_usage_line,
11
    var_kwargs_usage_line,
12
    wrap_usage,
13
)
14
from .arg import Arg, Name
1✔
15
from .error import (
1✔
16
    DuplicateOptionError,
17
    DuplicatePositionalArgumentError,
18
    FlagWithValueError,
19
    MissingContainerTypeError,
20
    MissingNameError,
21
    MissingOptionNameError,
22
    MissingOptionValueError,
23
    MissingRequiredOptionError,
24
    MissingRequiredPositionalArgumentError,
25
    NonFlagInShortNameCombinationError,
26
    ParserOptionError,
27
    UnexpectedOptionError,
28
    UnexpectedPositionalArgumentError,
29
)
30

31
if TYPE_CHECKING:
32
    from rich.console import Console
33

34

35
@dataclass
1✔
36
class _ParsingState:
1✔
37
    """
38
    A class to hold the state of the parsing process.
39
    """
40

41
    idx: int = 0
1✔
42
    positional_idx: int = 0
1✔
43

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

49

50
class Missing:
1✔
51
    """
52
    A sentinel class to represent a missing value.
53
    Used to differentiate between None as a value and no value provided.
54
    This is used for TypedDict optional keys, where instead of using None as
55
    a value for the key, the key is simply not present.
56
    """
57

58
    pass
1✔
59

60

61
@dataclass
1✔
62
class Args:
1✔
63
    """
64
    A parser class to parse command-line arguments.
65
    Contains positional and named arguments, as well as var args
66
    (unknown positional arguments) and var kwargs (unknown options).
67
    """
68

69
    brief: str = ""
1✔
70
    program_name: str = ""
1✔
71

72
    _positional_args: list[Arg] = field(default_factory=list[Arg])
1✔
73
    _named_args: list[Arg] = field(default_factory=list[Arg])
1✔
74
    _name2idx: dict[str, int] = field(default_factory=dict[str, int])
1✔
75
    # note that _name2idx is many to one, because a name can be both short and long
76

77
    _var_args: Arg | None = None  # remaining unk args for functions with *args
1✔
78
    _var_kwargs: Arg | None = None  # remaining unk options for functions with **kwargs
1✔
79
    _parent: "Args | None" = None  # parent Args instance
1✔
80

81
    @property
1✔
82
    def _args(self) -> list[Arg]:
1✔
83
        """
84
        Uniquely listed arguments. Note that an argument can be both positional and named,
85
        hence be in both lists.
86
        """
87
        seen = set[int]()
1✔
88
        unique_args: list[Arg] = []
1✔
89
        for arg in self._positional_args + self._named_args:
1✔
90
            if id(arg) not in seen:
1✔
91
                unique_args.append(arg)
1✔
92
                seen.add(id(arg))
1✔
93
        return unique_args
1✔
94

95
    @property
1✔
96
    def _children(self) -> Iterable["tuple[Arg, Args]"]:
1✔
97
        """
98
        Yield all child Args instances. Only relevant when parsing recursively.
99
        """
100
        for arg in self._args:
1✔
101
            if arg.args:
1✔
102
                yield arg, arg.args
1✔
103

104
    @staticmethod
1✔
105
    def _is_name(value: str) -> str | Literal[False]:
1✔
106
        """
107
        Check if a string, as provided in the command-line arguments, looks
108
        like an option name (starts with - or --).
109

110
        Returns:
111
            The name of the option if it is an option, otherwise False.
112
        """
113
        if value.startswith("--"):
1✔
114
            name = value[2:]
1✔
115
            return name
1✔
116
        if value.startswith("-"):
1✔
117
            name = value[1:]
1✔
118
            if not name:
1✔
119
                raise MissingOptionNameError()
1✔
120
            return name
1✔
121
        return False
1✔
122

123
    @staticmethod
1✔
124
    def _is_combined_short_names(value: str) -> str | Literal[False]:
1✔
125
        """
126
        Check if a string, as provided in the command-line arguments, looks
127
        like multiple combined short names (e.g. -abc).
128
        """
129
        if value.startswith("-") and not value.startswith("--"):
1✔
130
            value = value[1:].split("=", 1)[0]
1✔
131
            if len(value) > 1:
1✔
132
                return value
1✔
133
        return False
1✔
134

135
    def _find_arg_by_name(self, name: str) -> Arg | None:
1✔
136
        """
137
        Find an argument by its name (short or long) among self or the children.
138
        Returns the Arg if found, otherwise None.
139
        """
140
        if name in self._name2idx:
1✔
141
            return self._named_args[self._name2idx[name]]
1✔
142
        for _, child_args in self._children:
1✔
143
            result = child_args._find_arg_by_name(name)
1✔
144
            if result is not None:
1✔
145
                return result
1✔
146
        return None
1✔
147

148
    def add(self, arg: Arg):
1✔
149
        """
150
        Add an argument to the parser.
151
        """
152
        if arg.is_positional:  # positional argument
1✔
153
            self._positional_args.append(arg)
1✔
154
        if arg.is_named:  # named argument
1✔
155
            if not arg.name.long_or_short:
1✔
156
                raise MissingNameError()
1✔
157
            self._named_args.append(arg)
1✔
158
            if arg.name.short:
1✔
159
                self._name2idx[arg.name.short] = len(self._named_args) - 1
1✔
160
            if arg.name.long:
1✔
161
                self._name2idx[arg.name.long] = len(self._named_args) - 1
1✔
162

163
    def enable_unknown_args(self, arg: Arg) -> None:
1✔
164
        """
165
        Enable variadic positional arguments for parsing unknown positional arguments.
166
        This argument stores remaining positional arguments as if it was a list[T] type.
167
        """
168
        if arg.is_nary and arg.container_type is None:
1✔
169
            raise MissingContainerTypeError()
1✔
170
        self._var_args = arg
1✔
171

172
    def enable_unknown_opts(self, arg: Arg) -> None:
1✔
173
        """
174
        Enable variadic keyword arguments for parsing unknown named options.
175
        This Arg itself is not used to store anything, it is used as a reference to generate
176
        Arg objects as needed on the fly.
177
        """
178
        if arg.is_nary and arg.container_type is None:
1✔
179
            raise MissingContainerTypeError()
1✔
180
        self._var_kwargs = arg
1✔
181

182
    def _parse_equals_syntax(self, name: str, state: _ParsingState) -> _ParsingState:
1✔
183
        """
184
        Parse a cli argument as a named argument using the equals syntax (e.g. `--name=value`).
185
        Return new index after consuming the argument.
186
        This requires the argument to be not a flag.
187
        If the argument is n-ary, it can be repeated.
188
        """
189
        name, value = name.split("=", 1)
1✔
190
        normal_name = name.replace("_", "-")
1✔
191
        if normal_name not in self._name2idx:
1✔
192
            if self._var_kwargs:
1✔
193
                self.add(
1✔
194
                    Arg(
195
                        name=Name(long=normal_name),  # does long always work?
196
                        type_=self._var_kwargs.type_,
197
                        container_type=self._var_kwargs.container_type,
198
                        is_named=True,
199
                        is_nary=self._var_kwargs.is_nary,
200
                    )
201
                )
202
            else:
203
                raise UnexpectedOptionError(name)
1✔
204
        opt = self._named_args[self._name2idx[normal_name]]
1✔
205
        if opt.is_parsed and not opt.is_nary:
1✔
206
            raise DuplicateOptionError(str(opt.name))
1✔
207
        if opt.is_flag:
1✔
208
            raise FlagWithValueError(str(opt.name))
1✔
209
        opt.parse(value)
1✔
210
        state.idx += 1
1✔
211
        return state
1✔
212

213
    def _parse_combined_short_names(
1✔
214
        self, names: str, args: list[str], state: _ParsingState
215
    ) -> _ParsingState:
216
        """
217
        Parse a cli argument as a combined short names (e.g. -abc).
218
        Return new index after consuming the argument.
219
        """
220
        for i, name in enumerate(names):
1✔
221
            if name == "?":
1✔
222
                self.print_help()
1✔
223
                raise SystemExit(0)
1✔
224
            opt = self._find_arg_by_name(name)
1✔
225
            if opt is None:
1✔
226
                raise UnexpectedOptionError(name)
1✔
227
            if opt.is_parsed and not opt.is_nary:
1✔
228
                raise DuplicateOptionError(str(opt.name))
1✔
229

230
            if i < len(names) - 1:
1✔
231
                # up until the last option, all options must be flags
232
                if not opt.is_flag:
1✔
233
                    raise NonFlagInShortNameCombinationError(str(opt.name))
1✔
234
                opt.parse()
1✔
235
            else:
236
                # last option can be a flag or a regular option
237
                if "=" in args[state.idx]:
1✔
238
                    value = args[state.idx].split("=", 1)[1]
1✔
239
                    last = f"{name}={value}"
1✔
240
                    return self._parse_equals_syntax(last, state)
1✔
241
                if opt.is_flag:
1✔
242
                    opt.parse()
1✔
243
                    state.idx += 1
1✔
244
                    return state
1✔
245
                if opt.is_nary:
1✔
246
                    # n-ary option
247
                    values: list[str] = []
1✔
248
                    state.idx += 1
1✔
249
                    while (
1✔
250
                        state.idx < len(args)
251
                        and self._is_name(args[state.idx]) is False
252
                    ):
253
                        values.append(args[state.idx])
1✔
254
                        state.idx += 1
1✔
255
                    if not values:
1✔
256
                        raise MissingOptionValueError(str(opt.name))
1✔
257
                    for value in values:
1✔
258
                        opt.parse(value)
1✔
259
                    return state
1✔
260
                # not a flag, not n-ary
261
                if state.idx + 1 >= len(args):
1✔
262
                    raise MissingOptionValueError(str(opt.name))
1✔
263
                opt.parse(args[state.idx + 1])
1✔
264
                state.idx += 2
1✔
265
                return state
1✔
266

267
        raise RuntimeError("Programmer error: should not reach here!")
×
268

269
    def _parse_named(
1✔
270
        self, name: str, args: list[str], state: _ParsingState
271
    ) -> _ParsingState:
272
        """
273
        Parse a cli argument as a named argument / option.
274
        Return new index after consuming the argument.
275
        """
276
        if name in ["help", "?"]:
1✔
277
            self.print_help()
1✔
278
            raise SystemExit(0)
1✔
279

280
        for _, child_args in self._children:
1✔
281
            try:
1✔
282
                return child_args._parse_named(name, args, state)
1✔
283
            except ParserOptionError:
1✔
284
                pass
1✔
285

286
        if "=" in name:
1✔
287
            return self._parse_equals_syntax(name, state)
1✔
288
        normal_name = name.replace("_", "-")
1✔
289
        if normal_name not in self._name2idx:
1✔
290
            if self._var_kwargs:
1✔
291
                assert self._var_kwargs.type_ is not None
1✔
292
                self.add(
1✔
293
                    Arg(
294
                        name=Name(long=normal_name),  # does long always work?
295
                        type_=self._var_kwargs.type_,
296
                        container_type=self._var_kwargs.container_type,
297
                        is_named=True,
298
                        is_nary=self._var_kwargs.is_nary,
299
                    )
300
                )
301
            else:
302
                raise UnexpectedOptionError(name)
1✔
303
        opt = self._named_args[self._name2idx[normal_name]]
1✔
304
        if opt.is_parsed and not opt.is_nary:
1✔
305
            raise DuplicateOptionError(str(opt.name))
1✔
306

307
        if opt.is_flag:
1✔
308
            opt.parse()
1✔
309
            state.idx += 1
1✔
310
            return state
1✔
311
        if opt.is_nary:
1✔
312
            # n-ary option
313
            values: list[str] = []
1✔
314
            state.idx += 1
1✔
315
            while state.idx < len(args) and self._is_name(args[state.idx]) is False:
1✔
316
                values.append(args[state.idx])
1✔
317
                state.idx += 1
1✔
318
            if not values:
1✔
319
                raise MissingOptionValueError(str(opt.name))
1✔
320
            for value in values:
1✔
321
                opt.parse(value)
1✔
322
            return state
1✔
323

324
        # not a flag, not n-ary
325
        if state.idx + 1 >= len(args):
1✔
326
            raise MissingOptionValueError(str(opt.name))
1✔
327
        opt.parse(args[state.idx + 1])
1✔
328
        state.idx += 2
1✔
329
        return state
1✔
330

331
    def _parse_positional(self, args: list[str], state: _ParsingState) -> _ParsingState:
1✔
332
        """
333
        Parse a cli argument as a positional argument.
334
        Return new indices after consuming the argument.
335
        """
336

337
        # skip already parsed positional arguments
338
        # (because they could have also been named)
339
        while (
1✔
340
            state.positional_idx < len(self._positional_args)
341
            and self._positional_args[state.positional_idx].is_parsed
342
        ):
343
            state.positional_idx += 1
1✔
344

345
        if not state.positional_idx < len(self._positional_args):
1✔
346
            if self._var_args:
1✔
347
                self._var_args.parse(args[state.idx])
1✔
348
                state.idx += 1
1✔
349
                return state
1✔
350
            else:
351
                raise UnexpectedPositionalArgumentError(args[state.idx])
1✔
352

353
        arg = self._positional_args[state.positional_idx]
1✔
354
        if arg.is_parsed:
1✔
355
            raise DuplicatePositionalArgumentError(args[state.idx])
×
356
        if arg.is_nary:
1✔
357
            # n-ary positional arg
358
            values: list[str] = []
1✔
359
            while state.idx < len(args) and self._is_name(args[state.idx]) is False:
1✔
360
                values.append(args[state.idx])
1✔
361
                state.idx += 1
1✔
362
            for value in values:
1✔
363
                arg.parse(value)
1✔
364
        else:
365
            # regular positional arg
366
            arg.parse(args[state.idx])
1✔
367
            state.idx += 1
1✔
368
        state.positional_idx += 1
1✔
369
        return state
1✔
370

371
    def _check_completion(self) -> None:
1✔
372
        for child, child_args in self._children:
1✔
373
            try:
1✔
374
                child_args._check_completion()
1✔
375

376
                # construct the actual object
377
                init_args, init_kwargs = child_args.make_func_args()
1✔
378
                child._value = child.type_(*init_args, **init_kwargs)  # type: ignore
1✔
379
                child._parsed = True  # type: ignore
1✔
380
            except MissingRequiredOptionError as e:
1✔
381
                if not child.required:
1✔
382
                    child._value = child.default  # type: ignore
1✔
383
                    child._parsed = True  # type: ignore
1✔
384
                    continue
1✔
385
                else:
386
                    raise e
1✔
387

388
        # check if all required arguments are given, assign defaults otherwise
389
        for arg in self._positional_args + self._named_args:
1✔
390
            if not arg.is_parsed:
1✔
391
                if arg.required:
1✔
392
                    if arg.is_named:
1✔
393
                        # if a positional arg is also named, prefer this type of error message
394
                        raise MissingRequiredOptionError(str(arg.name))
1✔
395
                    else:
396
                        raise MissingRequiredPositionalArgumentError(str(arg.name))
1✔
397
                else:
398
                    arg._value = arg.default  # type: ignore
1✔
399
                    arg._parsed = True  # type: ignore
1✔
400

401
    def _parse(self, args: list[str]):
1✔
402
        state = _ParsingState()
1✔
403

404
        while state.idx < len(args):
1✔
405
            if not state.positional_only and args[state.idx] == "--":
1✔
406
                # all subsequent arguments will be attempted to be parsed as positional
407
                state.positional_only = True
1✔
408
                state.idx += 1
1✔
409
                continue
1✔
410
            name = self._is_name(args[state.idx])
1✔
411
            if not state.positional_only and name:
1✔
412
                # this must be a named argument / option
413
                if names := self._is_combined_short_names(args[state.idx]):
1✔
414
                    state = self._parse_combined_short_names(names, args, state)
1✔
415
                else:
416
                    try:
1✔
417
                        state = self._parse_named(name, args, state)
1✔
418
                    except UnexpectedOptionError as e:
1✔
419
                        if self._var_args:
1✔
420
                            self._var_args.parse(args[state.idx])
1✔
421
                            state.idx += 1
1✔
422
                        else:
423
                            raise e
1✔
424
            else:
425
                # this must be a positional argument
426
                state = self._parse_positional(args, state)
1✔
427

428
        self._check_completion()
1✔
429

430
    def make_func_args(self) -> tuple[list[Any], dict[str, Any]]:
1✔
431
        """
432
        Transform parsed arguments into function arguments.
433

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

437
        For arguments that are both positional and named, the positional argument
438
        is preferred, to handle variadic args correctly.
439
        """
440

441
        def var(opt: Arg) -> str:
1✔
442
            return opt.name.long_or_short.replace("-", "_").split(".")[-1]
1✔
443

444
        positional_args = [arg.value for arg in self._positional_args]
1✔
445
        named_args = {
1✔
446
            var(opt): opt.value
447
            for opt in self._named_args
448
            if opt not in self._positional_args and opt.value is not Missing
449
        }
450

451
        if not self._parent and self._var_args and self._var_args.value:
1✔
452
            # Append variadic positional arguments to the end of positional args.
453
            # This is only done for the top-level Args, not for child Args, as _var_args
454
            # for child Args is only used to pass remaining args to the parent.
455
            positional_args += self._var_args.value
1✔
456

457
        return positional_args, named_args
1✔
458

459
    def parse(self, cli_args: list[str] | None = None) -> "Args":
1✔
460
        """
461
        Parse the command-line arguments.
462

463
        Args:
464
            cli_args: The arguments to parse. If None, uses the arguments from the CLI.
465
        Returns:
466
            Self, for chaining.
467
        """
468
        if cli_args is not None:
1✔
469
            self._parse(cli_args)
1✔
470
        else:
471
            self._parse(sys.argv[1:])
1✔
472
        return self
1✔
473

474
    def _traverse_args(self) -> tuple[list[Arg], list[Arg], list[Arg]]:
1✔
475
        """
476
        Recursively traverse all Args and return three lists as a tuple of
477
        (positional only, positional and named, named only).
478
        Skips var args and var kwargs.
479
        """
480
        positional_only: list[Arg] = []
1✔
481
        positional_and_named: list[Arg] = []
1✔
482
        named_only: list[Arg] = []
1✔
483

484
        for arg in self._positional_args:
1✔
485
            if arg.args:
1✔
486
                child_pos_only, child_pos_and_named, child_named_only = (
1✔
487
                    arg.args._traverse_args()
488
                )
489
                positional_only += child_pos_only
1✔
490
                positional_and_named += child_pos_and_named
1✔
491
                named_only += child_named_only
1✔
492
            else:
493
                if arg.is_positional and not arg.is_named:
1✔
494
                    positional_only.append(arg)
1✔
495
                elif arg.is_positional and arg.is_named:
1✔
496
                    positional_and_named.append(arg)
1✔
497
        for opt in self._named_args:
1✔
498
            if opt.is_named and not opt.is_positional:
1✔
499
                if opt.args:
1✔
500
                    child_pos_only, child_pos_and_named, child_named_only = (
1✔
501
                        opt.args._traverse_args()
502
                    )
503
                    positional_only += child_pos_only
1✔
504
                    positional_and_named += child_pos_and_named
1✔
505
                    named_only += child_named_only
1✔
506
                else:
507
                    named_only.append(opt)
1✔
508

509
        return positional_only, positional_and_named, named_only
1✔
510

511
    def print_help(
1✔
512
        self, console: "Console | None" = None, usage_only: bool = False
513
    ) -> None:
514
        """
515
        Print the help message to the console.
516

517
        Args:
518
            console: A rich console to print to. If None, uses the default console.
519
            usage_only: Whether to print only the usage line.
520
        """
521
        import sys
1✔
522

523
        from rich.console import Console
1✔
524
        from rich.markdown import Markdown
1✔
525
        from rich.table import Table
1✔
526
        from rich.text import Text
1✔
527

528
        if self._parent:
1✔
529
            # only the top-level Args can print help
530
            return self._parent.print_help(console, usage_only)
×
531

532
        name = self.program_name or sys.argv[0]
1✔
533

534
        positional_only, positional_and_named, named_only = self._traverse_args()
1✔
535

536
        # (1) print brief if it exists
537
        console = console or Console()
1✔
538
        console.print()
1✔
539
        if self.brief and not usage_only:
1✔
540
            try:
1✔
541
                md = Markdown(self.brief)
1✔
542
                console.print(md)
1✔
543
                console.print()
1✔
544
            except Exception:
×
545
                console.print(self.brief + "\n")
×
546

547
        # (2) then print usage line
548
        console.print(Text("Usage:", style=Sty.title))
1✔
549
        usage_components: list[Text] = []
1✔
550
        pos_only_str = Text(" ").join([
1✔
551
            usage(arg, "usage line") for arg in positional_only
552
        ])
553
        if pos_only_str:
1✔
554
            usage_components.append(pos_only_str)
1✔
555
        for opt in positional_and_named:
1✔
556
            usage_components.append(usage(opt, "usage line"))
1✔
557
        if self._var_args:
1✔
558
            usage_components.append(var_args_usage_line(self._var_args))
1✔
559
        for opt in named_only:
1✔
560
            usage_components.append(usage(opt, "usage line"))
1✔
561
        if self._var_kwargs:
1✔
562
            usage_components.append(var_kwargs_usage_line(self._var_kwargs))
1✔
563

564
        console.print(wrap_usage(f"  {name}", usage_components, console.width or 80))
1✔
565

566
        if usage_only:
1✔
567
            console.print()
1✔
568
            return
1✔
569

570
        # (3) then print help message for each argument
571
        console.print(Text("\nwhere", style=Sty.title))
1✔
572

573
        table = Table(show_header=False, box=None, padding=(0, 0, 0, 2))
1✔
574

575
        for arg in positional_only:
1✔
576
            table.add_row("[dim](positional)[/dim]", usage(arg), help(arg))
1✔
577
        for opt in positional_and_named:
1✔
578
            table.add_row("[dim](pos. or opt.)[/dim]", usage(opt), help(opt))
1✔
579
        if self._var_args:
1✔
580
            table.add_row(
1✔
581
                "[dim](positional)[/dim]",
582
                usage(self._var_args),
583
                help(self._var_args),
584
            )
585
        for opt in named_only:
1✔
586
            table.add_row("[dim](option)[/dim]", usage(opt), help(opt))
1✔
587
        if self._var_kwargs:
1✔
588
            table.add_row(
1✔
589
                "[dim](option)[/dim]",
590
                usage(self._var_kwargs),
591
                help(self._var_kwargs),
592
            )
593

594
        table.add_row(
1✔
595
            "[dim](option)[/dim]",
596
            Text.assemble(
597
                ("-?", f"{Sty.name} {Sty.opt} dim"),
598
                ("|", f"{Sty.opt} dim"),
599
                ("--help", f"{Sty.name} {Sty.opt} dim"),
600
            ),
601
            "[i dim]Show this help message and exit.[/]",
602
        )
603

604
        console.print(table)
1✔
605
        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

© 2026 Coveralls, Inc