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

mosquito / argclass / 20698413075

04 Jan 2026 08:05PM UTC coverage: 91.723% (-0.1%) from 91.865%
20698413075

Pull #32

github

web-flow
Merge d5f40702a into 6a47bb92e
Pull Request #32: more docs and new method create_parser

275 of 305 branches covered (90.16%)

Branch coverage included in aggregate %.

13 of 15 new or added lines in 1 file covered. (86.67%)

1 existing line in 1 file now uncovered.

667 of 722 relevant lines covered (92.38%)

4.57 hits per line

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

96.99
/argclass/_parser.py
1
"""Core parser classes for argclass."""
2

3
import ast
5✔
4
import os
5✔
5
from abc import ABCMeta
5✔
6
from argparse import Action, ArgumentParser
5✔
7
from collections import defaultdict
5✔
8
from enum import EnumMeta
5✔
9
from pathlib import Path
5✔
10
from types import MappingProxyType
5✔
11
from typing import (
5✔
12
    Any,
13
    Dict,
14
    Iterable,
15
    List,
16
    Mapping,
17
    MutableMapping,
18
    NamedTuple,
19
    Optional,
20
    Set,
21
    Tuple,
22
    Type,
23
    TypeVar,
24
    Union,
25
)
26

27
from ._actions import ConfigAction
5✔
28
from ._defaults import AbstractDefaultsParser, INIDefaultsParser
5✔
29
from ._secret import SecretString
5✔
30
from ._store import AbstractGroup, AbstractParser, TypedArgument
5✔
31
from ._types import Actions, Nargs
5✔
32
from ._utils import (
5✔
33
    _unwrap_container_type,
34
    deep_getattr,
35
    merge_annotations,
36
    parse_bool,
37
    unwrap_optional,
38
)
39

40

41
def _make_action_true_argument(
5✔
42
    kind: Type,
43
    default: Any = None,
44
) -> TypedArgument:
45
    """Create a TypedArgument for boolean types."""
46
    kw: Dict[str, Any] = {"type": kind}
5✔
47
    if kind is bool:
5✔
48
        if default is False:
5✔
49
            kw["action"] = Actions.STORE_TRUE
5✔
50
            kw["default"] = False
5✔
51
        elif default is True:
5!
52
            kw["action"] = Actions.STORE_FALSE
5✔
53
            kw["default"] = True
5✔
54
        else:
55
            raise TypeError(f"Can not set default {default!r} for bool")
×
56
    elif kind == Optional[bool]:
5!
57
        kw["action"] = Actions.STORE
5✔
58
        kw["type"] = parse_bool
5✔
59
        kw["default"] = None
5✔
60
    return TypedArgument(**kw)
5✔
61

62

63
def _type_is_bool(kind: Type) -> bool:
5✔
64
    """Check if a type is bool or Optional[bool]."""
65
    return kind is bool or kind == Optional[bool]
5✔
66

67

68
class Meta(ABCMeta):
5✔
69
    """Metaclass for Parser and Group classes."""
70

71
    def __new__(
5✔
72
        mcs,
73
        name: str,
74
        bases: Tuple[Type["Meta"], ...],
75
        attrs: Dict[str, Any],
76
    ) -> "Meta":
77
        # Import here to avoid circular import
78
        from ._factory import EnumArgument
5✔
79

80
        # Create the class first to ensure annotations are available
81
        # Python 3.14+ (PEP 649) defers annotation evaluation, so
82
        # __annotations__ may not be in attrs during class creation
83
        cls = super().__new__(mcs, name, bases, attrs)
5✔
84

85
        # Now get annotations from the created class
86
        annotations = merge_annotations(
5✔
87
            getattr(cls, "__annotations__", {}),
88
            *bases,
89
        )
90

91
        arguments = {}
5✔
92
        argument_groups = {}
5✔
93
        subparsers = {}
5✔
94
        for key, kind in annotations.items():
5✔
95
            if key.startswith("_"):
5✔
96
                continue
5✔
97

98
            try:
5✔
99
                argument = deep_getattr(key, attrs, *bases)
5✔
100
            except KeyError:
5✔
101
                argument = None
5✔
102
                if kind is bool:
5✔
103
                    argument = False
5✔
104

105
            if not isinstance(
5✔
106
                argument,
107
                (TypedArgument, AbstractGroup, AbstractParser),
108
            ):
109
                setattr(cls, key, ...)
5✔
110

111
                is_required = argument is None or argument is Ellipsis
5✔
112

113
                # Handle Enum types with auto-generated EnumArgument
114
                if isinstance(kind, EnumMeta):
5✔
115
                    argument = EnumArgument(kind, default=argument)
5✔
116
                elif _type_is_bool(kind):
5✔
117
                    # For inherited bools, Ellipsis means use default False
118
                    if argument is Ellipsis:
5✔
119
                        argument = False
5✔
120
                    argument = _make_action_true_argument(kind, argument)
5✔
121
                else:
122
                    optional_type = unwrap_optional(kind)
5✔
123
                    if optional_type is not None:
5✔
124
                        is_required = False
5✔
125
                        kind = optional_type
5✔
126

127
                    # Handle container types like list[str], List[int], etc.
128
                    container_info = _unwrap_container_type(kind)
5✔
129
                    if container_info is not None:
5✔
130
                        container_type, element_type = container_info
5✔
131
                        # Use nargs="+" for required, "*" for optional
132
                        if is_required:
5✔
133
                            nargs: Union[str, Nargs] = Nargs.ONE_OR_MORE
5✔
134
                        else:
135
                            nargs = Nargs.ZERO_OR_MORE
5✔
136
                        # Use converter for non-list containers
137
                        if container_type is not list:
5✔
138
                            converter = container_type
5✔
139
                        else:
140
                            converter = None
5✔
141
                        default = None if argument is Ellipsis else argument
5✔
142
                        argument = TypedArgument(
5✔
143
                            type=element_type,
144
                            default=default,
145
                            required=is_required,
146
                            nargs=nargs,
147
                            converter=converter,
148
                        )
149
                    else:
150
                        argument = TypedArgument(
5✔
151
                            type=kind,
152
                            default=argument,
153
                            required=is_required,
154
                        )
155

156
            if isinstance(argument, TypedArgument):
5✔
157
                if argument.type is None and argument.converter is None:
5✔
158
                    # First try to unwrap optional
159
                    optional_inner = unwrap_optional(kind)
5✔
160
                    if optional_inner is not None:
5✔
161
                        kind = optional_inner
5✔
162
                        if argument.default is None:
5!
163
                            argument.default = None
5✔
164

165
                    # Handle bool type: set STORE_TRUE/STORE_FALSE action
166
                    if kind is bool and argument.action == Actions.default():
5✔
167
                        default = argument.default
5✔
168
                        if default is False or default is None:
5✔
169
                            argument = argument.copy(
5✔
170
                                action=Actions.STORE_TRUE,
171
                                default=False,
172
                                type=None,
173
                            )
174
                        elif default is True:
5!
175
                            argument = argument.copy(
5✔
176
                                action=Actions.STORE_FALSE,
177
                                default=True,
178
                                type=None,
179
                            )
180
                    # Then check for container types
181
                    elif (
5✔
182
                        container_info := _unwrap_container_type(kind)
183
                    ) is not None:
184
                        container_type, element_type = container_info
5✔
185
                        argument.type = element_type
5✔
186
                        # Only set nargs if not already specified
187
                        if argument.nargs is None:
5!
188
                            argument.nargs = Nargs.ZERO_OR_MORE
×
189
                        # Only set converter for non-list containers
190
                        is_non_list = container_type is not list
5✔
191
                        if is_non_list and argument.converter is None:
5!
192
                            argument.converter = container_type
×
193
                    else:
194
                        argument.type = kind
5✔
195
                arguments[key] = argument
5✔
196
            elif isinstance(argument, AbstractGroup):
5✔
197
                argument_groups[key] = argument
5✔
198

199

200
        for key, value in attrs.items():
5✔
201
            if key.startswith("_"):
5✔
202
                continue
5✔
203

204
            # Skip if already processed from annotations
205
            if key in arguments or key in argument_groups or key in subparsers:
5✔
206
                continue
5✔
207

208
            if isinstance(value, TypedArgument):
5✔
209
                arguments[key] = value
5✔
210
            elif isinstance(value, AbstractGroup):
5✔
211
                argument_groups[key] = value
5✔
212
            elif isinstance(value, AbstractParser):
5✔
213
                subparsers[key] = value
5✔
214

215
        setattr(cls, "__arguments__", MappingProxyType(arguments))
5✔
216
        setattr(cls, "__argument_groups__", MappingProxyType(argument_groups))
5✔
217
        setattr(cls, "__subparsers__", MappingProxyType(subparsers))
5✔
218
        return cls
5✔
219

220

221
class Base(metaclass=Meta):
5✔
222
    """Base class for Parser and Group."""
223

224
    __arguments__: Mapping[str, TypedArgument]
5✔
225
    __argument_groups__: Mapping[str, "Group"]
5✔
226
    __subparsers__: Mapping[str, "Parser"]
5✔
227

228
    def __getattribute__(self, item: str) -> Any:
5✔
229
        value = super().__getattribute__(item)
5✔
230
        if item.startswith("_"):
5✔
231
            return value
5✔
232

233
        if item in self.__arguments__:
5✔
234
            class_value = getattr(self.__class__, item, None)
5✔
235
            if value is class_value:
5✔
236
                raise AttributeError(f"Attribute {item!r} was not parsed")
5✔
237
        return value
5✔
238

239
    def __repr__(self) -> str:
5✔
240
        return (
5✔
241
            f"<{self.__class__.__name__}: "
242
            f"{len(self.__arguments__)} arguments, "
243
            f"{len(self.__argument_groups__)} groups, "
244
            f"{len(self.__subparsers__)} subparsers>"
245
        )
246

247

248
class Destination(NamedTuple):
5✔
249
    """Stores destination information for parsed arguments."""
250

251
    target: Base
5✔
252
    attribute: str
5✔
253
    argument: Optional[TypedArgument]
5✔
254
    action: Optional[Action]
5✔
255

256

257
DestinationsType = MutableMapping[str, Set[Destination]]
5✔
258

259

260
class Group(AbstractGroup, Base):
5✔
261
    """Argument group for organizing related arguments."""
262

263
    def __init__(
5✔
264
        self,
265
        title: Optional[str] = None,
266
        description: Optional[str] = None,
267
        prefix: Optional[str] = None,
268
        defaults: Optional[Mapping[str, Any]] = None,
269
    ):
270
        self._title = title
5✔
271
        self._description = description
5✔
272
        self._prefix = prefix
5✔
273
        self._defaults: Mapping[str, Any] = defaults or {}
5✔
274

275

276
ParserType = TypeVar("ParserType", bound="Parser")
5✔
277

278

279
# noinspection PyProtectedMember
280
class Parser(AbstractParser, Base):
5✔
281
    """Main parser class for command-line argument parsing."""
282

283
    HELP_APPENDIX_PREAMBLE = (
5✔
284
        " Default values will based on following "
285
        "configuration files {configs}. "
286
    )
287
    HELP_APPENDIX_CURRENT = (
5✔
288
        "Now {num_existent} files has been applied {existent}. "
289
    )
290
    HELP_APPENDIX_END = (
5✔
291
        "The configuration files is INI-formatted files "
292
        "where configuration groups is INI sections. "
293
        "See more https://docs.argclass.com/config-files.html"
294
    )
295

296
    def _add_argument(
5✔
297
        self,
298
        parser: Any,
299
        argument: TypedArgument,
300
        dest: str,
301
        *aliases: str,
302
    ) -> Tuple[str, Action]:
303
        kwargs = argument.get_kwargs()
5✔
304

305
        if not argument.is_positional:
5✔
306
            kwargs["dest"] = dest
5✔
307

308
        if (
5✔
309
            argument.default is not None
310
            and argument.default is not ...
311
            and not argument.secret
312
        ):
313
            kwargs["help"] = (
5✔
314
                f"{kwargs.get('help', '')} (default: {argument.default})"
315
            ).strip()
316

317
        if argument.env_var is not None:
5✔
318
            default = kwargs.get("default")
5✔
319
            kwargs["default"] = os.getenv(argument.env_var, default)
5✔
320

321
            if kwargs["default"] and argument.is_nargs:
5✔
322
                kwargs["default"] = list(
5✔
323
                    map(
324
                        argument.type or str,
325
                        ast.literal_eval(kwargs["default"]),
326
                    ),
327
                )
328

329
            kwargs["help"] = (
5✔
330
                f"{kwargs.get('help', '')} [ENV: {argument.env_var}]"
331
            ).strip()
332

333
            if argument.env_var in os.environ:
5✔
334
                self._used_env_vars.add(argument.env_var)
5✔
335

336
        # Convert string boolean values from config/env to proper bools
337
        action = kwargs.get("action")
5✔
338
        default = kwargs.get("default")
5✔
339
        if isinstance(default, str) and action in (
5✔
340
            Actions.STORE_TRUE,
341
            Actions.STORE_FALSE,
342
            "store_true",
343
            "store_false",
344
        ):
345
            kwargs["default"] = parse_bool(default)
5✔
346

347
        default = kwargs.get("default")
5✔
348
        if default is not None and default is not ...:
5✔
349
            kwargs["required"] = False
5✔
350

351
        return dest, parser.add_argument(*aliases, **kwargs)
5✔
352

353
    @staticmethod
5✔
354
    def get_cli_name(name: str) -> str:
5✔
355
        return name.replace("_", "-")
5✔
356

357
    def get_env_var(self, name: str, argument: TypedArgument) -> Optional[str]:
5✔
358
        if argument.env_var is not None:
5✔
359
            return argument.env_var
5✔
360
        if self._auto_env_var_prefix is not None:
5✔
361
            return f"{self._auto_env_var_prefix}{name}".upper()
5✔
362
        return None
5✔
363

364
    def __init__(
5✔
365
        self,
366
        config_files: Iterable[Union[str, Path]] = (),
367
        auto_env_var_prefix: Optional[str] = None,
368
        strict_config: bool = False,
369
        config_parser_class: Type[AbstractDefaultsParser] = INIDefaultsParser,
370
        **kwargs: Any,
371
    ):
372
        super().__init__()
5✔
373
        self.current_subparsers: Tuple[AbstractParser, ...] = ()
5✔
374
        self._config_files = config_files
5✔
375

376
        # Parse config files using the specified parser class
377
        config_parser = config_parser_class(config_files, strict=strict_config)
5✔
378
        self._config = config_parser.parse()
5✔
379
        filenames = config_parser.loaded_files
5✔
380

381
        self._epilog = kwargs.pop("epilog", "")
5✔
382

383
        if config_files:
5✔
384
            # If not config files, we don't need to add any to the epilog
385
            self._epilog += self.HELP_APPENDIX_PREAMBLE.format(
5✔
386
                configs=repr(config_files),
387
            )
388

389
            if filenames:
5✔
390
                self._epilog += self.HELP_APPENDIX_CURRENT.format(
5✔
391
                    num_existent=len(filenames),
392
                    existent=repr(list(map(str, filenames))),
393
                )
394
            self._epilog += self.HELP_APPENDIX_END
5✔
395

396
        self._auto_env_var_prefix = auto_env_var_prefix
5✔
397
        self._parser_kwargs = kwargs
5✔
398
        self._used_env_vars: Set[str] = set()
5✔
399

400
    @property
5✔
401
    def current_subparser(self) -> Optional["AbstractParser"]:
5✔
402
        if not self.current_subparsers:
5✔
403
            return None
5✔
404
        return self.current_subparsers[0]
5✔
405

406
    def _make_parser(
5✔
407
        self,
408
        parser: Optional[ArgumentParser] = None,
409
        parent_chain: Tuple["AbstractParser", ...] = (),
410
    ) -> Tuple[ArgumentParser, DestinationsType]:
411
        if parser is None:
5✔
412
            parser = ArgumentParser(
5✔
413
                epilog=self._epilog,
414
                **self._parser_kwargs,
415
            )
416

417
        destinations: DestinationsType = defaultdict(set)
5✔
418
        self._fill_arguments(destinations, parser)
5✔
419
        self._fill_groups(destinations, parser)
5✔
420
        if self.__subparsers__:
5✔
421
            self._fill_subparsers(destinations, parser, parent_chain)
5✔
422

423
        return parser, destinations
5✔
424

425
    def create_parser(self) -> ArgumentParser:
5✔
426
        """
427
        Create an ArgumentParser instance without parsing arguments.
428
        Can be used to inspect the parser structure in external integrations.
429
        NOT AN ALTERNATIVE TO parse_args, because it does not back populates
430
        the parser attributes.
431
        """
NEW
432
        parser, _ = self._make_parser()
×
NEW
433
        return parser
×
434

435
    def _fill_arguments(
5✔
436
        self,
437
        destinations: DestinationsType,
438
        parser: ArgumentParser,
439
    ) -> None:
440
        for name, argument in self.__arguments__.items():
5✔
441
            aliases = set(argument.aliases)
5✔
442

443
            # Add default alias
444
            if not aliases:
5✔
445
                aliases.add(f"--{self.get_cli_name(name)}")
5✔
446

447
            default = self._config.get(name, argument.default)
5✔
448
            argument = argument.copy(
5✔
449
                aliases=aliases,
450
                env_var=self.get_env_var(name, argument),
451
                default=default,
452
            )
453

454
            if default is not None and default is not ... and argument.required:
5✔
455
                argument = argument.copy(required=False)
5✔
456

457
            dest, action = self._add_argument(parser, argument, name, *aliases)
5✔
458
            destinations[dest].add(
5✔
459
                Destination(
460
                    target=self,
461
                    attribute=name,
462
                    argument=argument,
463
                    action=action,
464
                ),
465
            )
466

467
    def _fill_groups(
5✔
468
        self,
469
        destinations: DestinationsType,
470
        parser: ArgumentParser,
471
    ) -> None:
472
        for group_name, group in self.__argument_groups__.items():
5✔
473
            group_parser = parser.add_argument_group(
5✔
474
                title=group._title,
475
                description=group._description,
476
            )
477
            config = self._config.get(group_name, {})
5✔
478

479
            for name, argument in group.__arguments__.items():
5✔
480
                aliases = set(argument.aliases)
5✔
481
                if group._prefix is not None:
5✔
482
                    prefix = group._prefix
5✔
483
                else:
484
                    prefix = group_name
5✔
485
                dest = f"{prefix}_{name}" if prefix else name
5✔
486

487
                if not aliases:
5✔
488
                    aliases.add(f"--{self.get_cli_name(dest)}")
5✔
489

490
                default = config.get(
5✔
491
                    name,
492
                    group._defaults.get(name, argument.default),
493
                )
494
                argument = argument.copy(
5✔
495
                    default=default,
496
                    env_var=self.get_env_var(dest, argument),
497
                )
498
                dest, action = self._add_argument(
5✔
499
                    group_parser,
500
                    argument,
501
                    dest,
502
                    *aliases,
503
                )
504
                destinations[dest].add(
5✔
505
                    Destination(
506
                        target=group,
507
                        attribute=name,
508
                        argument=argument,
509
                        action=action,
510
                    ),
511
                )
512

513
    def _fill_subparsers(
5✔
514
        self,
515
        destinations: DestinationsType,
516
        parser: ArgumentParser,
517
        parent_chain: Tuple["AbstractParser", ...] = (),
518
    ) -> None:
519
        subparsers = parser.add_subparsers()
5✔
520
        subparser: AbstractParser
521
        destinations["current_subparsers"].add(
5✔
522
            Destination(
523
                target=self,
524
                attribute="current_subparsers",
525
                argument=None,
526
                action=None,
527
            ),
528
        )
529

530
        for subparser_name, subparser in self.__subparsers__.items():
5✔
531
            # Build the chain for this subparser level
532
            subparser_chain = (subparser,) + parent_chain
5✔
533
            current_parser, subparser_dests = subparser._make_parser(
5✔
534
                subparsers.add_parser(
535
                    subparser_name,
536
                    **subparser._parser_kwargs,
537
                ),
538
                parent_chain=subparser_chain,
539
            )
540
            subparser.__parent__ = self
5✔
541
            current_parser.set_defaults(
5✔
542
                current_subparsers=subparser_chain,
543
            )
544
            for key, value in subparser_dests.items():
5✔
545
                destinations[key].update(value)
5✔
546

547
    def parse_args(
5✔
548
        self: ParserType,
549
        args: Optional[List[str]] = None,
550
    ) -> ParserType:
551
        parser, destinations = self._make_parser()
5✔
552
        parsed_ns = parser.parse_args(args=args)
5✔
553

554
        # Get the chain of selected subparsers from the namespace
555
        selected_subparsers: Tuple[AbstractParser, ...] = getattr(
5✔
556
            parsed_ns, "current_subparsers", ()
557
        )
558

559
        for key, dests in destinations.items():
5✔
560
            for dest in dests:
5✔
561
                target = dest.target
5✔
562
                name = dest.attribute
5✔
563
                argument = dest.argument
5✔
564
                action = dest.action
5✔
565

566
                # Skip subparsers that weren't selected
567
                if (
5✔
568
                    isinstance(target, AbstractParser)
569
                    and target is not self
570
                    and target not in selected_subparsers
571
                ):
572
                    continue
5✔
573

574
                parsed_value = getattr(parsed_ns, key, None)
5✔
575

576
                if isinstance(action, ConfigAction):
5✔
577
                    action(parser, parsed_ns, parsed_value, None)
5✔
578
                    parsed_value = getattr(parsed_ns, key)
5✔
579

580
                if argument is not None:
5✔
581
                    if argument.secret and isinstance(parsed_value, str):
5✔
582
                        parsed_value = SecretString(parsed_value)
5✔
583
                    if argument.converter is not None:
5✔
584
                        if argument.nargs and parsed_value is None:
5✔
585
                            parsed_value = []
5✔
586
                        parsed_value = argument.converter(parsed_value)
5✔
587

588
                # Ensure current_subparsers is always a tuple, not None
589
                if name == "current_subparsers" and parsed_value is None:
5✔
590
                    parsed_value = ()
5✔
591

592
                setattr(target, name, parsed_value)
5✔
593

594
        return self
5✔
595

596
    def print_help(self) -> None:
5✔
597
        parser, _ = self._make_parser()
5✔
598
        return parser.print_help()
5✔
599

600
    def sanitize_env(self) -> None:
5✔
601
        for name in self._used_env_vars:
5✔
602
            os.environ.pop(name, None)
5✔
603
        self._used_env_vars.clear()
5✔
604

605
    def __call__(self) -> Any:
5✔
606
        """
607
        Override this function if you want to equip your parser with an action.
608

609
        By default this calls the current_subparser's __call__ method if
610
        there is a current_subparser, otherwise returns None.
611

612
        Example:
613
            class Parser(argclass.Parser):
614
                def __call__(self) -> Any:
615
                    print("Hello world!")
616

617
            parser = Parser()
618
            parser.parse_args([])
619
            parser()  # Will print "Hello world!"
620

621
        When you have subparsers:
622
            class SubParser(argclass.Parser):
623
                def __call__(self) -> Any:
624
                    print("In subparser!")
625

626
            class Parser(argclass.Parser):
627
                sub = SubParser()
628

629
            parser = Parser()
630
            parser.parse_args(["sub"])
631
            parser()  # Will print "In subparser!"
632
        """
633
        if self.current_subparser is not None:
5!
634
            return self.current_subparser()
5✔
UNCOV
635
        return None
×
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