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

oir / startle / 18825572803

26 Oct 2025 11:53PM UTC coverage: 99.123% (+0.02%) from 99.104%
18825572803

Pull #113

github

web-flow
Merge 1707d38d9 into 74908a8b6
Pull Request #113: Add support for non-total `TypedDict`s

237 of 237 branches covered (100.0%)

Branch coverage included in aggregate %.

1119 of 1131 relevant lines covered (98.94%)

0.99 hits per line

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

98.75
startle/args.py
1
import sys
1✔
2
from dataclasses import dataclass, field
1✔
3
from typing import TYPE_CHECKING, 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
if TYPE_CHECKING:
10
    from rich.console import Console
11

12

13
@dataclass
1✔
14
class _ParsingState:
1✔
15
    """
16
    A class to hold the state of the parsing process.
17
    """
18

19
    idx: int = 0
1✔
20
    positional_idx: int = 0
1✔
21

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

27

28
class Missing:
1✔
29
    """
30
    A sentinel class to represent a missing value.
31
    Used to differentiate between None as a value and no value provided.
32
    This is used for TypedDict optional keys, where instead of using None as
33
    a value for the key, the key is simply not present.
34
    """
35

36
    pass
1✔
37

38

39
@dataclass
1✔
40
class Args:
1✔
41
    """
42
    A parser class to parse command-line arguments.
43
    Contains positional and named arguments, as well as var args
44
    (unknown positional arguments) and var kwargs (unknown options).
45
    """
46

47
    brief: str = ""
1✔
48
    program_name: str = ""
1✔
49

50
    _positional_args: list[Arg] = field(default_factory=list[Arg])
1✔
51
    _named_args: list[Arg] = field(default_factory=list[Arg])
1✔
52
    _name2idx: dict[str, int] = field(default_factory=dict[str, int])
1✔
53
    # note that _name2idx is many to one, because a name can be both short and long
54

55
    _var_args: Arg | None = None  # remaining unk args for functions with *args
1✔
56
    _var_kwargs: Arg | None = None  # remaining unk options for functions with **kwargs
1✔
57
    _parent: "Args | None" = None  # parent Args instance
1✔
58

59
    @property
1✔
60
    def _args(self) -> list[Arg]:
1✔
61
        """
62
        Uniquely listed arguments. Note that an argument can be both positional and named,
63
        hence be in both lists.
64
        """
65
        seen = set[int]()
1✔
66
        unique_args: list[Arg] = []
1✔
67
        for arg in self._positional_args + self._named_args:
1✔
68
            if id(arg) not in seen:
1✔
69
                unique_args.append(arg)
1✔
70
                seen.add(id(arg))
1✔
71
        return unique_args
1✔
72

73
    @staticmethod
1✔
74
    def _is_name(value: str) -> str | Literal[False]:
1✔
75
        """
76
        Check if a string, as provided in the command-line arguments, looks
77
        like an option name (starts with - or --).
78

79
        Returns:
80
            The name of the option if it is an option, otherwise False.
81
        """
82
        if value.startswith("--"):
1✔
83
            name = value[2:]
1✔
84
            return name
1✔
85
        if value.startswith("-"):
1✔
86
            name = value[1:]
1✔
87
            if not name:
1✔
88
                raise ParserOptionError("Prefix `-` is not followed by an option!")
1✔
89
            return name
1✔
90
        return False
1✔
91

92
    @staticmethod
1✔
93
    def _is_combined_short_names(value: str) -> str | Literal[False]:
1✔
94
        """
95
        Check if a string, as provided in the command-line arguments, looks
96
        like multiple combined short names (e.g. -abc).
97
        """
98
        if value.startswith("-") and not value.startswith("--"):
1✔
99
            value = value[1:].split("=", 1)[0]
1✔
100
            if len(value) > 1:
1✔
101
                return value
1✔
102
        return False
1✔
103

104
    def add(self, arg: Arg):
1✔
105
        """
106
        Add an argument to the parser.
107
        """
108
        if arg.is_positional:  # positional argument
1✔
109
            self._positional_args.append(arg)
1✔
110
        if arg.is_named:  # named argument
1✔
111
            if not arg.name.long_or_short:
1✔
112
                raise ParserConfigError(
1✔
113
                    "Named arguments should have at least one name!"
114
                )
115
            self._named_args.append(arg)
1✔
116
            if arg.name.short:
1✔
117
                self._name2idx[arg.name.short] = len(self._named_args) - 1
1✔
118
            if arg.name.long:
1✔
119
                self._name2idx[arg.name.long] = len(self._named_args) - 1
1✔
120

121
    def enable_unknown_args(self, arg: Arg) -> None:
1✔
122
        """
123
        Enable variadic positional arguments for parsing unknown positional arguments.
124
        This argument stores remaining positional arguments as if it was a list[T] type.
125
        """
126
        if arg.is_nary and arg.container_type is None:
1✔
127
            raise ParserConfigError(
1✔
128
                "Container type must be specified for n-ary options!"
129
            )
130
        self._var_args = arg
1✔
131

132
    def enable_unknown_opts(self, arg: Arg) -> None:
1✔
133
        """
134
        Enable variadic keyword arguments for parsing unknown named options.
135
        This Arg itself is not used to store anything, it is used as a reference to generate
136
        Arg objects as needed on the fly.
137
        """
138
        if arg.is_nary and arg.container_type is None:
1✔
139
            raise ParserConfigError(
1✔
140
                "Container type must be specified for n-ary options!"
141
            )
142
        self._var_kwargs = arg
1✔
143

144
    def _parse_equals_syntax(self, name: str, state: _ParsingState) -> _ParsingState:
1✔
145
        """
146
        Parse a cli argument as a named argument using the equals syntax (e.g. `--name=value`).
147
        Return new index after consuming the argument.
148
        This requires the argument to be not a flag.
149
        If the argument is n-ary, it can be repeated.
150
        """
151
        name, value = name.split("=", 1)
1✔
152
        normal_name = name.replace("_", "-")
1✔
153
        if normal_name not in self._name2idx:
1✔
154
            if self._var_kwargs:
1✔
155
                self.add(
1✔
156
                    Arg(
157
                        name=Name(long=normal_name),  # does long always work?
158
                        type_=self._var_kwargs.type_,
159
                        container_type=self._var_kwargs.container_type,
160
                        is_named=True,
161
                        is_nary=self._var_kwargs.is_nary,
162
                    )
163
                )
164
            else:
165
                raise ParserOptionError(f"Unexpected option `{name}`!")
1✔
166
        opt = self._named_args[self._name2idx[normal_name]]
1✔
167
        if opt.is_parsed and not opt.is_nary:
1✔
168
            raise ParserOptionError(f"Option `{opt.name}` is multiply given!")
1✔
169
        if opt.is_flag:
1✔
170
            raise ParserOptionError(
1✔
171
                f"Option `{opt.name}` is a flag and cannot be assigned a value!"
172
            )
173
        opt.parse(value)
1✔
174
        state.idx += 1
1✔
175
        return state
1✔
176

177
    def _parse_combined_short_names(
1✔
178
        self, names: str, args: list[str], state: _ParsingState
179
    ) -> _ParsingState:
180
        """
181
        Parse a cli argument as a combined short names (e.g. -abc).
182
        Return new index after consuming the argument.
183
        """
184
        for i, name in enumerate(names):
1✔
185
            if name == "?":
1✔
186
                self.print_help()
1✔
187
                raise SystemExit(0)
1✔
188
            if name not in self._name2idx:
1✔
189
                raise ParserOptionError(f"Unexpected option `{name}`!")
1✔
190
            opt = self._named_args[self._name2idx[name]]
1✔
191
            if opt.is_parsed and not opt.is_nary:
1✔
192
                raise ParserOptionError(f"Option `{opt.name}` is multiply given!")
1✔
193

194
            if i < len(names) - 1:
1✔
195
                # up until the last option, all options must be flags
196
                if not opt.is_flag:
1✔
197
                    raise ParserOptionError(
1✔
198
                        f"Option `{opt.name}` is not a flag and cannot be combined!"
199
                    )
200
                opt.parse()
1✔
201
            else:
202
                # last option can be a flag or a regular option
203
                if "=" in args[state.idx]:
1✔
204
                    value = args[state.idx].split("=", 1)[1]
1✔
205
                    last = f"{name}={value}"
1✔
206
                    return self._parse_equals_syntax(last, state)
1✔
207
                if opt.is_flag:
1✔
208
                    opt.parse()
1✔
209
                    state.idx += 1
1✔
210
                    return state
1✔
211
                if opt.is_nary:
1✔
212
                    # n-ary option
213
                    values: list[str] = []
1✔
214
                    state.idx += 1
1✔
215
                    while (
1✔
216
                        state.idx < len(args)
217
                        and self._is_name(args[state.idx]) is False
218
                    ):
219
                        values.append(args[state.idx])
1✔
220
                        state.idx += 1
1✔
221
                    if not values:
1✔
222
                        raise ParserOptionError(
1✔
223
                            f"Option `{opt.name}` is missing argument!"
224
                        )
225
                    for value in values:
1✔
226
                        opt.parse(value)
1✔
227
                    return state
1✔
228
                # not a flag, not n-ary
229
                if state.idx + 1 >= len(args):
1✔
230
                    raise ParserOptionError(f"Option `{opt.name}` is missing argument!")
1✔
231
                opt.parse(args[state.idx + 1])
1✔
232
                state.idx += 2
1✔
233
                return state
1✔
234

235
        raise RuntimeError("Programmer error: should not reach here!")
×
236

237
    def _parse_named(
1✔
238
        self, name: str, args: list[str], state: _ParsingState
239
    ) -> _ParsingState:
240
        """
241
        Parse a cli argument as a named argument / option.
242
        Return new index after consuming the argument.
243
        """
244
        if name in ["help", "?"]:
1✔
245
            self.print_help()
1✔
246
            raise SystemExit(0)
1✔
247
        if "=" in name:
1✔
248
            return self._parse_equals_syntax(name, state)
1✔
249
        normal_name = name.replace("_", "-")
1✔
250
        if normal_name not in self._name2idx:
1✔
251
            if self._var_kwargs:
1✔
252
                assert self._var_kwargs.type_ is not None
1✔
253
                self.add(
1✔
254
                    Arg(
255
                        name=Name(long=normal_name),  # does long always work?
256
                        type_=self._var_kwargs.type_,
257
                        container_type=self._var_kwargs.container_type,
258
                        is_named=True,
259
                        is_nary=self._var_kwargs.is_nary,
260
                    )
261
                )
262
            else:
263
                raise ParserOptionError(f"Unexpected option `{name}`!")
1✔
264
        opt = self._named_args[self._name2idx[normal_name]]
1✔
265
        if opt.is_parsed and not opt.is_nary:
1✔
266
            raise ParserOptionError(f"Option `{opt.name}` is multiply given!")
1✔
267

268
        if opt.is_flag:
1✔
269
            opt.parse()
1✔
270
            state.idx += 1
1✔
271
            return state
1✔
272
        if opt.is_nary:
1✔
273
            # n-ary option
274
            values: list[str] = []
1✔
275
            state.idx += 1
1✔
276
            while state.idx < len(args) and self._is_name(args[state.idx]) is False:
1✔
277
                values.append(args[state.idx])
1✔
278
                state.idx += 1
1✔
279
            if not values:
1✔
280
                raise ParserOptionError(f"Option `{opt.name}` is missing argument!")
1✔
281
            for value in values:
1✔
282
                opt.parse(value)
1✔
283
            return state
1✔
284

285
        # not a flag, not n-ary
286
        if state.idx + 1 >= len(args):
1✔
287
            raise ParserOptionError(f"Option `{opt.name}` is missing argument!")
1✔
288
        opt.parse(args[state.idx + 1])
1✔
289
        state.idx += 2
1✔
290
        return state
1✔
291

292
    def _parse_positional(self, args: list[str], state: _ParsingState) -> _ParsingState:
1✔
293
        """
294
        Parse a cli argument as a positional argument.
295
        Return new indices after consuming the argument.
296
        """
297

298
        # skip already parsed positional arguments
299
        # (because they could have also been named)
300
        while (
1✔
301
            state.positional_idx < len(self._positional_args)
302
            and self._positional_args[state.positional_idx].is_parsed
303
        ):
304
            state.positional_idx += 1
1✔
305

306
        if not state.positional_idx < len(self._positional_args):
1✔
307
            if self._var_args:
1✔
308
                self._var_args.parse(args[state.idx])
1✔
309
                state.idx += 1
1✔
310
                return state
1✔
311
            else:
312
                raise ParserOptionError(
1✔
313
                    f"Unexpected positional argument: `{args[state.idx]}`!"
314
                )
315

316
        arg = self._positional_args[state.positional_idx]
1✔
317
        if arg.is_parsed:
1✔
318
            raise ParserOptionError(
×
319
                f"Positional argument `{args[state.idx]}` is multiply given!"
320
            )
321
        if arg.is_nary:
1✔
322
            # n-ary positional arg
323
            values: list[str] = []
1✔
324
            while state.idx < len(args) and self._is_name(args[state.idx]) is False:
1✔
325
                values.append(args[state.idx])
1✔
326
                state.idx += 1
1✔
327
            for value in values:
1✔
328
                arg.parse(value)
1✔
329
        else:
330
            # regular positional arg
331
            arg.parse(args[state.idx])
1✔
332
            state.idx += 1
1✔
333
        state.positional_idx += 1
1✔
334
        return state
1✔
335

336
    def _maybe_parse_children(self, args: list[str]) -> list[str]:
1✔
337
        """
338
        Parse child Args, if any.
339
        This method is only relevant when recurse=True is used in start() or parse().
340

341
        Returns:
342
            Remaining args after parsing child Args.
343
        """
344
        remaining_args = args.copy()
1✔
345
        for arg in self._args:
1✔
346
            if child_args := arg.args:
1✔
347
                try:
1✔
348
                    child_args.parse(remaining_args)
1✔
349
                except ParserOptionError as e:
1✔
350
                    estr = str(e)
1✔
351
                    if estr.startswith("Required option") and estr.endswith(
1✔
352
                        " is not provided!"
353
                    ):
354
                        # this is allowed if arg has a default value
355
                        if not arg.required:
1✔
356
                            arg._value = arg.default  # type: ignore
1✔
357
                            arg._parsed = True  # type: ignore
1✔
358
                            continue
1✔
359
                        # note that we do not consume any args, even partially
360
                    raise e
1✔
361

362
                assert child_args._var_args is not None, "Programming error!"
1✔
363
                remaining_args: list[str] = child_args._var_args.value or []
1✔
364

365
                # construct the actual object
366
                init_args, init_kwargs = child_args.make_func_args()
1✔
367
                arg._value = arg.type_(*init_args, **init_kwargs)  # type: ignore
1✔
368
                arg._parsed = True  # type: ignore
1✔
369

370
        return remaining_args
1✔
371

372
    def _parse(self, args: list[str]):
1✔
373
        args = self._maybe_parse_children(args)
1✔
374
        state = _ParsingState()
1✔
375

376
        while state.idx < len(args):
1✔
377
            if not state.positional_only and args[state.idx] == "--":
1✔
378
                # all subsequent arguments will be attempted to be parsed as positional
379
                state.positional_only = True
1✔
380
                state.idx += 1
1✔
381
                continue
1✔
382
            name = self._is_name(args[state.idx])
1✔
383
            if not state.positional_only and name:
1✔
384
                # this must be a named argument / option
385
                if names := self._is_combined_short_names(args[state.idx]):
1✔
386
                    state = self._parse_combined_short_names(names, args, state)
1✔
387
                else:
388
                    try:
1✔
389
                        state = self._parse_named(name, args, state)
1✔
390
                    except ParserOptionError as e:
1✔
391
                        if self._var_args and str(e).startswith("Unexpected option"):
1✔
392
                            self._var_args.parse(args[state.idx])
1✔
393
                            state.idx += 1
1✔
394
                        else:
395
                            raise
1✔
396
            else:
397
                # this must be a positional argument
398
                state = self._parse_positional(args, state)
1✔
399

400
        # check if all required arguments are given, assign defaults otherwise
401
        for arg in self._positional_args + self._named_args:
1✔
402
            if not arg.is_parsed:
1✔
403
                if arg.required:
1✔
404
                    if arg.is_named:
1✔
405
                        # if a positional arg is also named, prefer this type of error message
406
                        raise ParserOptionError(
1✔
407
                            f"Required option `{arg.name}` is not provided!"
408
                        )
409
                    else:
410
                        raise ParserOptionError(
1✔
411
                            f"Required positional argument <{arg.name.long}> is not provided!"
412
                        )
413
                else:
414
                    arg._value = arg.default  # type: ignore
1✔
415
                    arg._parsed = True  # type: ignore
1✔
416

417
    def make_func_args(self) -> tuple[list[Any], dict[str, Any]]:
1✔
418
        """
419
        Transform parsed arguments into function arguments.
420

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

424
        For arguments that are both positional and named, the positional argument
425
        is preferred, to handle variadic args correctly.
426
        """
427

428
        def var(opt: Arg) -> str:
1✔
429
            return opt.name.long_or_short.replace("-", "_")
1✔
430

431
        positional_args = [arg.value for arg in self._positional_args]
1✔
432
        named_args = {
1✔
433
            var(opt): opt.value
434
            for opt in self._named_args
435
            if opt not in self._positional_args and opt.value is not Missing
436
        }
437

438
        if not self._parent and self._var_args and self._var_args.value:
1✔
439
            # Append variadic positional arguments to the end of positional args.
440
            # This is only done for the top-level Args, not for child Args, as _var_args
441
            # for child Args is only used to pass remaining args to the parent.
442
            positional_args += self._var_args.value
1✔
443

444
        return positional_args, named_args
1✔
445

446
    def parse(self, cli_args: list[str] | None = None) -> "Args":
1✔
447
        """
448
        Parse the command-line arguments.
449

450
        Args:
451
            cli_args: The arguments to parse. If None, uses the arguments from the CLI.
452
        Returns:
453
            Self, for chaining.
454
        """
455
        if cli_args is not None:
1✔
456
            self._parse(cli_args)
1✔
457
        else:
458
            self._parse(sys.argv[1:])
1✔
459
        return self
1✔
460

461
    def _traverse_args(self) -> tuple[list[Arg], list[Arg], list[Arg]]:
1✔
462
        """
463
        Recursively traverse all Args and return three lists as a tuple of
464
        (positional only, positional and named, named only).
465
        Skips var args and var kwargs.
466
        """
467
        positional_only: list[Arg] = []
1✔
468
        positional_and_named: list[Arg] = []
1✔
469
        named_only: list[Arg] = []
1✔
470

471
        for arg in self._positional_args:
1✔
472
            if arg.args:
1✔
473
                child_pos_only, child_pos_and_named, child_named_only = (
1✔
474
                    arg.args._traverse_args()
475
                )
476
                positional_only += child_pos_only
1✔
477
                positional_and_named += child_pos_and_named
1✔
478
                named_only += child_named_only
1✔
479
            else:
480
                if arg.is_positional and not arg.is_named:
1✔
481
                    positional_only.append(arg)
1✔
482
                elif arg.is_positional and arg.is_named:
1✔
483
                    positional_and_named.append(arg)
1✔
484
        for opt in self._named_args:
1✔
485
            if opt.is_named and not opt.is_positional:
1✔
486
                if opt.args:
1✔
487
                    child_pos_only, child_pos_and_named, child_named_only = (
1✔
488
                        opt.args._traverse_args()
489
                    )
490
                    positional_only += child_pos_only
1✔
491
                    positional_and_named += child_pos_and_named
1✔
492
                    named_only += child_named_only
1✔
493
                else:
494
                    named_only.append(opt)
1✔
495

496
        return positional_only, positional_and_named, named_only
1✔
497

498
    def print_help(
1✔
499
        self, console: "Console | None" = None, usage_only: bool = False
500
    ) -> None:
501
        """
502
        Print the help message to the console.
503

504
        Args:
505
            console: A rich console to print to. If None, uses the default console.
506
            usage_only: Whether to print only the usage line.
507
        """
508
        import sys
1✔
509

510
        from rich.console import Console
1✔
511
        from rich.markdown import Markdown
1✔
512
        from rich.table import Table
1✔
513
        from rich.text import Text
1✔
514

515
        if self._parent:
1✔
516
            # only the top-level Args can print help
517
            return self._parent.print_help(console, usage_only)
×
518

519
        name = self.program_name or sys.argv[0]
1✔
520

521
        positional_only, positional_and_named, named_only = self._traverse_args()
1✔
522

523
        # (1) print brief if it exists
524
        console = console or Console()
1✔
525
        console.print()
1✔
526
        if self.brief and not usage_only:
1✔
527
            try:
1✔
528
                md = Markdown(self.brief)
1✔
529
                console.print(md)
1✔
530
                console.print()
1✔
531
            except Exception:
×
532
                console.print(self.brief + "\n")
×
533

534
        # (2) then print usage line
535
        console.print(Text("Usage:", style=Sty.title))
1✔
536
        usage_components = [Text(f"  {name}")]
1✔
537
        pos_only_str = Text(" ").join(
1✔
538
            [usage(arg, "usage line") for arg in positional_only]
539
        )
540
        if pos_only_str:
1✔
541
            usage_components.append(pos_only_str)
1✔
542
        for opt in positional_and_named:
1✔
543
            usage_components.append(usage(opt, "usage line"))
1✔
544
        if self._var_args:
1✔
545
            usage_components.append(var_args_usage_line(self._var_args))
1✔
546
        for opt in named_only:
1✔
547
            usage_components.append(usage(opt, "usage line"))
1✔
548
        if self._var_kwargs:
1✔
549
            usage_components.append(var_kwargs_usage_line(self._var_kwargs))
1✔
550

551
        console.print(Text(" ").join(usage_components))
1✔
552

553
        if usage_only:
1✔
554
            console.print()
1✔
555
            return
1✔
556

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

560
        table = Table(show_header=False, box=None, padding=(0, 0, 0, 2))
1✔
561

562
        for arg in positional_only:
1✔
563
            table.add_row("[dim](positional)[/dim]", usage(arg), help(arg))
1✔
564
        for opt in positional_and_named:
1✔
565
            table.add_row("[dim](pos. or opt.)[/dim]", usage(opt), help(opt))
1✔
566
        if self._var_args:
1✔
567
            table.add_row(
1✔
568
                "[dim](positional)[/dim]",
569
                usage(self._var_args),
570
                help(self._var_args),
571
            )
572
        for opt in named_only:
1✔
573
            table.add_row("[dim](option)[/dim]", usage(opt), help(opt))
1✔
574
        if self._var_kwargs:
1✔
575
            table.add_row(
1✔
576
                "[dim](option)[/dim]",
577
                usage(self._var_kwargs),
578
                help(self._var_kwargs),
579
            )
580

581
        table.add_row(
1✔
582
            "[dim](option)[/dim]",
583
            Text.assemble(
584
                ("-?", f"{Sty.name} {Sty.opt} dim"),
585
                ("|", f"{Sty.opt} dim"),
586
                ("--help", f"{Sty.name} {Sty.opt} dim"),
587
            ),
588
            "[i dim]Show this help message and exit.[/]",
589
        )
590

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