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

mosquito / argclass / 20684798109

04 Jan 2026 12:04AM UTC coverage: 93.265% (-1.1%) from 94.334%
20684798109

push

github

web-flow
Merge pull request #30 from mosquito/feature/python3.14-support

migrate to uv and python 3.14 support

242 of 262 branches covered (92.37%)

Branch coverage included in aggregate %.

574 of 593 new or added lines in 8 files covered. (96.8%)

575 of 614 relevant lines covered (93.65%)

4.68 hits per line

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

97.56
/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 ._secret import SecretString
5✔
29
from ._store import AbstractGroup, AbstractParser, TypedArgument
5✔
30
from ._types import Actions, Nargs
5✔
31
from ._utils import (
5✔
32
    _unwrap_container_type,
33
    deep_getattr,
34
    merge_annotations,
35
    parse_bool,
36
    read_configs,
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:
NEW
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
                if _type_is_bool(kind):
5✔
114
                    # For inherited bools, Ellipsis means use default False
115
                    if argument is Ellipsis:
5✔
116
                        argument = False
5✔
117
                    argument = _make_action_true_argument(kind, argument)
5✔
118
                else:
119
                    optional_type = unwrap_optional(kind)
5✔
120
                    if optional_type is not None:
5✔
121
                        is_required = False
5✔
122
                        kind = optional_type
5✔
123

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

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

162
                    # Then check for container types
163
                    container_info = _unwrap_container_type(kind)
5✔
164
                    if container_info is not None:
5✔
165
                        container_type, element_type = container_info
5✔
166
                        argument.type = element_type
5✔
167
                        # Only set nargs if not already specified
168
                        if argument.nargs is None:
5!
NEW
169
                            argument.nargs = Nargs.ZERO_OR_MORE
×
170
                        # Only set converter for non-list containers
171
                        is_non_list = container_type is not list
5✔
172
                        if is_non_list and argument.converter is None:
5!
NEW
173
                            argument.converter = container_type
×
174
                    else:
175
                        argument.type = kind
5✔
176
                arguments[key] = argument
5✔
177
            elif isinstance(argument, AbstractGroup):
5✔
178
                argument_groups[key] = argument
5✔
179

180
            if isinstance(kind, EnumMeta):
5✔
181
                arguments[key] = EnumArgument(kind)
5✔
182

183
        for key, value in attrs.items():
5✔
184
            if key.startswith("_"):
5✔
185
                continue
5✔
186

187
            if isinstance(value, TypedArgument):
5✔
188
                arguments[key] = value
5✔
189
            elif isinstance(value, AbstractGroup):
5✔
190
                argument_groups[key] = value
5✔
191
            elif isinstance(value, AbstractParser):
5✔
192
                subparsers[key] = value
5✔
193

194
        setattr(cls, "__arguments__", MappingProxyType(arguments))
5✔
195
        setattr(cls, "__argument_groups__", MappingProxyType(argument_groups))
5✔
196
        setattr(cls, "__subparsers__", MappingProxyType(subparsers))
5✔
197
        return cls
5✔
198

199

200
class Base(metaclass=Meta):
5✔
201
    """Base class for Parser and Group."""
202

203
    __arguments__: Mapping[str, TypedArgument]
5✔
204
    __argument_groups__: Mapping[str, "Group"]
5✔
205
    __subparsers__: Mapping[str, "Parser"]
5✔
206

207
    def __getattribute__(self, item: str) -> Any:
5✔
208
        value = super().__getattribute__(item)
5✔
209
        if item.startswith("_"):
5✔
210
            return value
5✔
211

212
        if item in self.__arguments__:
5✔
213
            class_value = getattr(self.__class__, item, None)
5✔
214
            if value is class_value:
5✔
215
                raise AttributeError(f"Attribute {item!r} was not parsed")
5✔
216
        return value
5✔
217

218
    def __repr__(self) -> str:
5✔
219
        return (
5✔
220
            f"<{self.__class__.__name__}: "
221
            f"{len(self.__arguments__)} arguments, "
222
            f"{len(self.__argument_groups__)} groups, "
223
            f"{len(self.__subparsers__)} subparsers>"
224
        )
225

226

227
class Destination(NamedTuple):
5✔
228
    """Stores destination information for parsed arguments."""
229

230
    target: Base
5✔
231
    attribute: str
5✔
232
    argument: Optional[TypedArgument]
5✔
233
    action: Optional[Action]
5✔
234

235

236
DestinationsType = MutableMapping[str, Set[Destination]]
5✔
237

238

239
class Group(AbstractGroup, Base):
5✔
240
    """Argument group for organizing related arguments."""
241

242
    def __init__(
5✔
243
        self,
244
        title: Optional[str] = None,
245
        description: Optional[str] = None,
246
        prefix: Optional[str] = None,
247
        defaults: Optional[Mapping[str, Any]] = None,
248
    ):
249
        self._title = title
5✔
250
        self._description = description
5✔
251
        self._prefix = prefix
5✔
252
        self._defaults: Mapping[str, Any] = defaults or {}
5✔
253

254

255
ParserType = TypeVar("ParserType", bound="Parser")
5✔
256

257

258
# noinspection PyProtectedMember
259
class Parser(AbstractParser, Base):
5✔
260
    """Main parser class for command-line argument parsing."""
261

262
    HELP_APPENDIX_PREAMBLE = (
5✔
263
        " Default values will based on following "
264
        "configuration files {configs}. "
265
    )
266
    HELP_APPENDIX_CURRENT = (
5✔
267
        "Now {num_existent} files has been applied {existent}. "
268
    )
269
    HELP_APPENDIX_END = (
5✔
270
        "The configuration files is INI-formatted files "
271
        "where configuration groups is INI sections. "
272
        "See more https://pypi.org/project/argclass/#configs"
273
    )
274

275
    def _add_argument(
5✔
276
        self,
277
        parser: Any,
278
        argument: TypedArgument,
279
        dest: str,
280
        *aliases: str,
281
    ) -> Tuple[str, Action]:
282
        kwargs = argument.get_kwargs()
5✔
283

284
        if not argument.is_positional:
5✔
285
            kwargs["dest"] = dest
5✔
286

287
        if (
5✔
288
            argument.default is not None
289
            and argument.default is not ...
290
            and not argument.secret
291
        ):
292
            kwargs["help"] = (
5✔
293
                f"{kwargs.get('help', '')} (default: {argument.default})"
294
            ).strip()
295

296
        if argument.env_var is not None:
5✔
297
            default = kwargs.get("default")
5✔
298
            kwargs["default"] = os.getenv(argument.env_var, default)
5✔
299

300
            if kwargs["default"] and argument.is_nargs:
5✔
301
                kwargs["default"] = list(
5✔
302
                    map(
303
                        argument.type or str,
304
                        ast.literal_eval(kwargs["default"]),
305
                    ),
306
                )
307

308
            kwargs["help"] = (
5✔
309
                f"{kwargs.get('help', '')} [ENV: {argument.env_var}]"
310
            ).strip()
311

312
            if argument.env_var in os.environ:
5✔
313
                self._used_env_vars.add(argument.env_var)
5✔
314

315
        # Convert string boolean values from config/env to proper bools
316
        action = kwargs.get("action")
5✔
317
        default = kwargs.get("default")
5✔
318
        if isinstance(default, str) and action in (
5✔
319
            Actions.STORE_TRUE,
320
            Actions.STORE_FALSE,
321
            "store_true",
322
            "store_false",
323
        ):
324
            kwargs["default"] = parse_bool(default)
5✔
325

326
        default = kwargs.get("default")
5✔
327
        if default is not None and default is not ...:
5✔
328
            kwargs["required"] = False
5✔
329

330
        return dest, parser.add_argument(*aliases, **kwargs)
5✔
331

332
    @staticmethod
5✔
333
    def get_cli_name(name: str) -> str:
5✔
334
        return name.replace("_", "-")
5✔
335

336
    def get_env_var(self, name: str, argument: TypedArgument) -> Optional[str]:
5✔
337
        if argument.env_var is not None:
5✔
338
            return argument.env_var
5✔
339
        if self._auto_env_var_prefix is not None:
5✔
340
            return f"{self._auto_env_var_prefix}{name}".upper()
5✔
341
        return None
5✔
342

343
    def __init__(
5✔
344
        self,
345
        config_files: Iterable[Union[str, Path]] = (),
346
        auto_env_var_prefix: Optional[str] = None,
347
        strict_config: bool = False,
348
        **kwargs: Any,
349
    ):
350
        super().__init__()
5✔
351
        self.current_subparsers: Tuple[AbstractParser, ...] = ()
5✔
352
        self._config_files = config_files
5✔
353
        self._config, filenames = read_configs(
5✔
354
            *config_files,
355
            strict=strict_config,
356
        )
357

358
        self._epilog = kwargs.pop("epilog", "")
5✔
359

360
        if config_files:
5✔
361
            # If not config files, we don't need to add any to the epilog
362
            self._epilog += self.HELP_APPENDIX_PREAMBLE.format(
5✔
363
                configs=repr(config_files),
364
            )
365

366
            if filenames:
5✔
367
                self._epilog += self.HELP_APPENDIX_CURRENT.format(
5✔
368
                    num_existent=len(filenames),
369
                    existent=repr(list(map(str, filenames))),
370
                )
371
            self._epilog += self.HELP_APPENDIX_END
5✔
372

373
        self._auto_env_var_prefix = auto_env_var_prefix
5✔
374
        self._parser_kwargs = kwargs
5✔
375
        self._used_env_vars: Set[str] = set()
5✔
376

377
    @property
5✔
378
    def current_subparser(self) -> Optional["AbstractParser"]:
5✔
379
        if not self.current_subparsers:
5✔
380
            return None
5✔
381
        return self.current_subparsers[0]
5✔
382

383
    def _make_parser(
5✔
384
        self,
385
        parser: Optional[ArgumentParser] = None,
386
        parent_chain: Tuple["AbstractParser", ...] = (),
387
    ) -> Tuple[ArgumentParser, DestinationsType]:
388
        if parser is None:
5✔
389
            parser = ArgumentParser(
5✔
390
                epilog=self._epilog,
391
                **self._parser_kwargs,
392
            )
393

394
        destinations: DestinationsType = defaultdict(set)
5✔
395
        self._fill_arguments(destinations, parser)
5✔
396
        self._fill_groups(destinations, parser)
5✔
397
        if self.__subparsers__:
5✔
398
            self._fill_subparsers(destinations, parser, parent_chain)
5✔
399

400
        return parser, destinations
5✔
401

402
    def _fill_arguments(
5✔
403
        self,
404
        destinations: DestinationsType,
405
        parser: ArgumentParser,
406
    ) -> None:
407
        for name, argument in self.__arguments__.items():
5✔
408
            aliases = set(argument.aliases)
5✔
409

410
            # Add default alias
411
            if not aliases:
5✔
412
                aliases.add(f"--{self.get_cli_name(name)}")
5✔
413

414
            default = self._config.get(name, argument.default)
5✔
415
            argument = argument.copy(
5✔
416
                aliases=aliases,
417
                env_var=self.get_env_var(name, argument),
418
                default=default,
419
            )
420

421
            if default is not None and default is not ... and argument.required:
5✔
422
                argument = argument.copy(required=False)
5✔
423

424
            dest, action = self._add_argument(parser, argument, name, *aliases)
5✔
425
            destinations[dest].add(
5✔
426
                Destination(
427
                    target=self,
428
                    attribute=name,
429
                    argument=argument,
430
                    action=action,
431
                ),
432
            )
433

434
    def _fill_groups(
5✔
435
        self,
436
        destinations: DestinationsType,
437
        parser: ArgumentParser,
438
    ) -> None:
439
        for group_name, group in self.__argument_groups__.items():
5✔
440
            group_parser = parser.add_argument_group(
5✔
441
                title=group._title,
442
                description=group._description,
443
            )
444
            config = self._config.get(group_name, {})
5✔
445

446
            for name, argument in group.__arguments__.items():
5✔
447
                aliases = set(argument.aliases)
5✔
448
                if group._prefix is not None:
5✔
449
                    prefix = group._prefix
5✔
450
                else:
451
                    prefix = group_name
5✔
452
                dest = f"{prefix}_{name}" if prefix else name
5✔
453

454
                if not aliases:
5✔
455
                    aliases.add(f"--{self.get_cli_name(dest)}")
5✔
456

457
                default = config.get(
5✔
458
                    name,
459
                    group._defaults.get(name, argument.default),
460
                )
461
                argument = argument.copy(
5✔
462
                    default=default,
463
                    env_var=self.get_env_var(dest, argument),
464
                )
465
                dest, action = self._add_argument(
5✔
466
                    group_parser,
467
                    argument,
468
                    dest,
469
                    *aliases,
470
                )
471
                destinations[dest].add(
5✔
472
                    Destination(
473
                        target=group,
474
                        attribute=name,
475
                        argument=argument,
476
                        action=action,
477
                    ),
478
                )
479

480
    def _fill_subparsers(
5✔
481
        self,
482
        destinations: DestinationsType,
483
        parser: ArgumentParser,
484
        parent_chain: Tuple["AbstractParser", ...] = (),
485
    ) -> None:
486
        subparsers = parser.add_subparsers()
5✔
487
        subparser: AbstractParser
488
        destinations["current_subparsers"].add(
5✔
489
            Destination(
490
                target=self,
491
                attribute="current_subparsers",
492
                argument=None,
493
                action=None,
494
            ),
495
        )
496

497
        for subparser_name, subparser in self.__subparsers__.items():
5✔
498
            # Build the chain for this subparser level
499
            subparser_chain = (subparser,) + parent_chain
5✔
500
            current_parser, subparser_dests = subparser._make_parser(
5✔
501
                subparsers.add_parser(
502
                    subparser_name,
503
                    **subparser._parser_kwargs,
504
                ),
505
                parent_chain=subparser_chain,
506
            )
507
            subparser.__parent__ = self
5✔
508
            current_parser.set_defaults(
5✔
509
                current_subparsers=subparser_chain,
510
            )
511
            for key, value in subparser_dests.items():
5✔
512
                destinations[key].update(value)
5✔
513

514
    def parse_args(
5✔
515
        self: ParserType,
516
        args: Optional[List[str]] = None,
517
    ) -> ParserType:
518
        parser, destinations = self._make_parser()
5✔
519
        parsed_ns = parser.parse_args(args=args)
5✔
520

521
        # Get the chain of selected subparsers from the namespace
522
        selected_subparsers: Tuple[AbstractParser, ...] = getattr(
5✔
523
            parsed_ns, "current_subparsers", ()
524
        )
525

526
        for key, dests in destinations.items():
5✔
527
            for dest in dests:
5✔
528
                target = dest.target
5✔
529
                name = dest.attribute
5✔
530
                argument = dest.argument
5✔
531
                action = dest.action
5✔
532

533
                # Skip subparsers that weren't selected
534
                if (
5✔
535
                    isinstance(target, AbstractParser)
536
                    and target is not self
537
                    and target not in selected_subparsers
538
                ):
539
                    continue
5✔
540

541
                parsed_value = getattr(parsed_ns, key, None)
5✔
542

543
                if isinstance(action, ConfigAction):
5✔
544
                    action(parser, parsed_ns, parsed_value, None)
5✔
545
                    parsed_value = getattr(parsed_ns, key)
5✔
546

547
                if argument is not None:
5✔
548
                    if argument.secret and isinstance(parsed_value, str):
5✔
549
                        parsed_value = SecretString(parsed_value)
5✔
550
                    if argument.converter is not None:
5✔
551
                        if argument.nargs and parsed_value is None:
5✔
552
                            parsed_value = []
5✔
553
                        parsed_value = argument.converter(parsed_value)
5✔
554

555
                # Ensure current_subparsers is always a tuple, not None
556
                if name == "current_subparsers" and parsed_value is None:
5✔
557
                    parsed_value = ()
5✔
558

559
                setattr(target, name, parsed_value)
5✔
560

561
        return self
5✔
562

563
    def print_help(self) -> None:
5✔
564
        parser, _ = self._make_parser()
5✔
565
        return parser.print_help()
5✔
566

567
    def sanitize_env(self) -> None:
5✔
568
        for name in self._used_env_vars:
5✔
569
            os.environ.pop(name, None)
5✔
570
        self._used_env_vars.clear()
5✔
571

572
    def __call__(self) -> Any:
5✔
573
        """
574
        Override this function if you want to equip your parser with an action.
575

576
        By default this calls the current_subparser's __call__ method if
577
        there is a current_subparser, otherwise returns None.
578

579
        Example:
580
            class Parser(argclass.Parser):
581
                def __call__(self) -> Any:
582
                    print("Hello world!")
583

584
            parser = Parser()
585
            parser.parse_args([])
586
            parser()  # Will print "Hello world!"
587

588
        When you have subparsers:
589
            class SubParser(argclass.Parser):
590
                def __call__(self) -> Any:
591
                    print("In subparser!")
592

593
            class Parser(argclass.Parser):
594
                sub = SubParser()
595

596
            parser = Parser()
597
            parser.parse_args(["sub"])
598
            parser()  # Will print "In subparser!"
599
        """
600
        if self.current_subparser is not None:
5!
601
            return self.current_subparser()
5✔
NEW
602
        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