Skip to content

cli

ArgparseDataclass

Bases: DataclassMixin

Mixin class providing a means of setting up an argparse parser with the dataclass fields, and then converting the namespace of parsed arguments into an instance of the class.

The parser's argument names and types will be derived from the dataclass's fields.

Per-field settings can be passed into the metadata argument of each dataclasses.field. See ArgparseDataclassFieldSettings for the full list of settings.

Source code in fancy_dataclass/cli.py
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
class ArgparseDataclass(DataclassMixin):
    """Mixin class providing a means of setting up an [`argparse`](https://docs.python.org/3/library/argparse.html) parser with the dataclass fields, and then converting the namespace of parsed arguments into an instance of the class.

    The parser's argument names and types will be derived from the dataclass's fields.

    Per-field settings can be passed into the `metadata` argument of each `dataclasses.field`. See [`ArgparseDataclassFieldSettings`][fancy_dataclass.cli.ArgparseDataclassFieldSettings] for the full list of settings."""

    __settings_type__ = ArgparseDataclassSettings
    __settings__ = ArgparseDataclassSettings()
    __field_settings_type__ = ArgparseDataclassFieldSettings

    # name of subcommand field, if present
    subcommand_field_name: ClassVar[Optional[str]] = None
    # name of the `argparse.Namespace` attribute associated with the subcommand
    # The convention is for this name to contain both the subcommand name and the class name.
    # This is because nested `ArgparseDataclass` fields may have the same subcommand name, causing conflicts.
    subcommand_dest_name: ClassVar[str]

    @classmethod
    def __init_subclass__(cls, **kwargs: Any) -> None:
        super().__init_subclass__(**kwargs)
        cls.subcommand_dest_name = f'_subcommand_{cls.__name__}'
        # if command_name was not specified in the settings, use a default name
        if cls.__settings__.command_name is None:
            cls.__settings__.command_name = camel_case_to_kebab_case(cls.__name__)

    @classmethod
    def __post_dataclass_wrap__(cls, wrapped_cls: Type[Self]) -> None:
        subcommand = None
        names = set()
        for fld in fields(wrapped_cls):  # type: ignore[arg-type]
            if not fld.metadata.get('subcommand', False):
                continue
            if subcommand is None:
                # check field type is ArgparseDataclass or Union thereof
                subcommand = fld.name
                tp = cast(type, fld.type)
                if issubclass_safe(tp, ArgparseDataclass):
                    continue
                err = TypeError(f'invalid subcommand field {fld.name!r}, type must be an ArgparseDataclass or Union thereof')
                if get_origin(tp) == Union:
                    tp_args = [arg for arg in get_args(tp) if (arg is not type(None))]
                    for arg in tp_args:
                        if not issubclass_safe(arg, ArgparseDataclass):
                            raise err
                        name = arg.__settings__.command_name
                        if name in names:
                            raise TypeError(f'duplicate command name {name!r} in subcommand field {subcommand!r}')
                        names.add(name)
                    continue
                raise err
            raise TypeError(f'multiple fields ({subcommand} and {fld.name}) are registered as subcommands, at most one is allowed')
        # store the name of the subcommand field as a class attribute
        cls.subcommand_field_name = subcommand

    @property
    def subcommand_name(self) -> Optional[str]:
        """Gets the name of the chosen subcommand associated with the type of the object's subcommand field.

        Returns:
            Name of the subcommand, if a subcommand field exists, and `None` otherwise"""
        if self.subcommand_field_name is not None:
            tp: Type[ArgparseDataclass] = type(getattr(self, self.subcommand_field_name))
            return tp.__settings__.command_name
        return None

    @classmethod
    def _parser_description(cls) -> Optional[str]:
        if (descr := cls.__settings__.help_descr) is None:
            return cls.__doc__
        return descr

    @classmethod
    def _parser_description_brief(cls) -> Optional[str]:
        if (brief := cls.__settings__.help_descr_brief) is None:
            brief = cls._parser_description()
            if brief:
                brief = brief[0].lower() + brief[1:]
                if brief.endswith('.'):
                    brief = brief[:-1]
        return brief

    @classmethod
    def parser_kwargs(cls) -> Dict[str, Any]:
        """Gets keyword arguments that will be passed to the top-level argument parser.

        Returns:
            Keyword arguments passed upon construction of the `ArgumentParser`"""
        kwargs: Dict[str, Any] = {'description': cls._parser_description()}
        if (fmt_cls := cls.__settings__.formatter_class) is not None:
            kwargs['formatter_class'] = fmt_cls
        return kwargs

    @classmethod
    def _parser_argument_kwarg_names(cls) -> List[str]:
        """Gets keyword argument names that will be passed when adding arguments to the argument parser.

        Returns:
            Keyword argument names passed when adding arguments to the parser"""
        return ['action', 'nargs', 'const', 'choices', 'help', 'metavar']

    @classmethod
    def new_parser(cls) -> ArgumentParser:
        """Constructs a new top-level argument parser..

        Returns:
            New top-level parser derived from the class's fields"""
        return cls.__settings__.parser_class(**cls.parser_kwargs())

    @classmethod
    def configure_argument(cls, parser: ArgParser, name: str) -> None:
        """Given an argument parser and a field name, configures the parser with an argument of that name.

        Attempts to provide reasonable default behavior based on the dataclass field name, type, default, and metadata.

        Subclasses may override this method to implement custom behavior.

        Args:
            parser: parser object to update with a new argument
            name: Name of the argument to configure"""
        def is_nested(tp: type) -> TypeGuard[ArgparseDataclass]:
            return issubclass_safe(tp, ArgparseDataclass)
        kwargs: Dict[str, Any] = {}
        fld = cls.__dataclass_fields__[name]  # type: ignore[attr-defined]
        settings = cls._field_settings(fld).adapt_to(ArgparseDataclassFieldSettings)
        if settings.parse_exclude:  # exclude the argument from the parser
            return
        # determine the type of the parser argument for the field
        tp: type = settings.type or fld.type  # type: ignore[assignment]
        if isinstance(tp, str):  # resolve type
            tp = get_type_hints(cls)[name]
        action = settings.action or 'store'
        origin_type = get_origin(tp)
        if origin_type is not None:  # compound type
            if type_is_optional(tp):
                kwargs['default'] = None
            if origin_type == ClassVar:  # by default, exclude ClassVars from the parser
                return
            tp_args = get_args(tp)
            if tp_args:  # Union/List/Optional
                if origin_type == Union:
                    tp_args = tuple(arg for arg in tp_args if (arg is not type(None)))
                    if (len(tp_args) > 1) and (not settings.subcommand):
                        raise ValueError(f'union type {tp} not allowed as ArgparseDataclass field except as subcommand')
                elif issubclass_safe(origin_type, list) or issubclass_safe(origin_type, tuple):
                    for arg in tp_args:
                        if is_nested(arg):
                            name = f'list of {arg.__name__}' if issubclass_safe(origin_type, list) else f'tuple with {arg}'  # type: ignore[attr-defined]
                            raise ValueError(f'{name} not allowed in ArgparseDataclass parser')
                tp = tp_args[0]
                if get_origin(tp) == Literal:  # Optional[Literal[...]]: unwrap to Literal[...]
                    tp_args = get_args(tp)
                    origin_type = Literal
                if origin_type == Literal:  # literal options will become choices
                    arg_types = {type(arg) for arg in tp_args}
                    if len(arg_types) == 1:
                        tp = arg_types.pop()
                    else:
                        tp = None  # type: ignore[assignment]
                    kwargs['choices'] = tp_args
            else:  # type cannot be inferred
                raise ValueError(f'cannot infer type of items in field {name!r}')
            if issubclass_safe(origin_type, list) and (action == 'store'):
                kwargs['nargs'] = '*'  # allow multiple arguments by default
        if issubclass_safe(tp, IntEnum):
            # use a bare int type
            tp = int
        if issubclass_safe(get_origin(tp), list):  # type: ignore[arg-type]
            tp = get_args(tp)[0]
        kwargs['type'] = tp
        # determine the default value
        if fld.default == MISSING:
            if fld.default_factory != MISSING:
                kwargs['default'] = fld.default_factory()
        else:
            kwargs['default'] = fld.default
        # get the names of the arguments associated with the field
        args = settings.args
        if args is not None:
            if isinstance(args, str):
                args = [args]
            # argument is positional if it is explicitly given without a leading dash
            positional = not args[0].startswith('-')
            if (not positional) and ('default' not in kwargs):
                # no default available, so make the field a required option
                kwargs['required'] = True
        else:
            positional = (tp is not bool) and ('default' not in kwargs)
            if positional:
                args = [fld.name]
            else:
                # use a single dash for 1-letter names
                prefix = '-' if (len(fld.name) == 1) else '--'
                argname = fld.name.replace('_', '-')
                args = [prefix + argname]
        if args and (not positional):
            # store the argument based on the name of the field, and not whatever flag name was provided
            kwargs['dest'] = fld.name
        if settings.required is not None:
            kwargs['required'] = settings.required
        has_default = 'default' in kwargs
        default = kwargs.get('default')
        if fld.type is bool:  # use boolean flag instead of an argument
            action = settings.action or 'store_true'
            kwargs['action'] = action
            if isinstance(action, str) and (action not in ['store_true', 'store_false']):
                raise ValueError(f'invalid action {action!r} for boolean flag field {name!r}')
            if default is not None:
                if (action != 'store_false') == default:
                    raise ValueError(f'cannot use default value of {default} for action {action!r} with boolean flag field {name!r}')
            for key in ('type', 'required'):
                with suppress(KeyError):
                    kwargs.pop(key)
        # extract additional items from metadata
        for key in cls._parser_argument_kwarg_names():
            if key in fld.metadata:
                kwargs[key] = fld.metadata[key]
        if kwargs.get('action') in ['count', 'store_const']:
            del kwargs['type']
        # determine if the field show its default in the help string
        if cls.__settings__.default_help:
            # include default if there is one, and the flag is not overridden to False at the field level
            default_help = has_default and (settings.default_help is not False)
        else:
            # include default if the field-level flag is set to True
            default_help = bool(settings.default_help)
        if default_help:
            if not has_default:
                raise ValueError(f'cannot use default_help=True for field {name!r} since it has no default')
            help_str = kwargs.get('help', None)
            # append the default value to the help string
            help_str = ((help_str + ' ') if help_str else '') + f'(default: {default})'
            kwargs['help'] = help_str
        if (result := _get_parser_group_name(settings, fld.name)) is not None:
            # add argument to the group instead of the main parser
            (group_name, is_exclusive) = result
            if is_exclusive:
                group: Optional[Union[_ArgumentGroup, _MutuallyExclusiveGroup]] = _get_parser_exclusive_group(parser, group_name)
            else:
                group = _get_parser_group(parser, group_name)
            if not group:  # group not found, so create it
                if is_exclusive:
                    group = _add_exclusive_group(parser, group_name, kwargs.get('required', False))
                else:
                    # get kwargs from nested ArgparseDataclass
                    group_kwargs = tp.parser_kwargs() if is_nested(tp) else {}
                    group = _add_group(parser, group_name, **group_kwargs)
            parser = group
        if settings.subcommand:
            # create subparsers for each variant
            assert isinstance(parser, ArgumentParser)
            dest = cls.subcommand_dest_name
            required = kwargs.get('required', not has_default)
            if (not required) and (not has_default):
                raise ValueError(f'{name!r} field cannot set required=False with no default value')
            subparsers = parser.add_subparsers(dest=dest, required=required, help=settings.help, metavar='subcommand')
            tp_args = (tp,) if (origin_type is None) else tp_args
            for arg in tp_args:
                assert issubclass_safe(arg, ArgparseDataclass)
                descr_brief = arg._parser_description_brief()
                subparser_kwargs = arg.parser_kwargs()
                if 'formatter_class' not in subparser_kwargs:
                    # inherit formatter_class from the parent
                    subparser_kwargs['formatter_class'] = parser.formatter_class
                subparser = subparsers.add_parser(arg.__settings__.command_name, help=descr_brief, **subparser_kwargs)
                arg.configure_parser(subparser)
            return
        if is_nested(tp):  # recursively configure a nested ArgparseDataclass field
            tp.configure_parser(parser)
        else:
            # prevent duplicate positional args
            if not hasattr(parser, '_pos_args'):
                parser._pos_args = set()  # type: ignore[union-attr]
            if positional:
                pos_args = parser._pos_args  # type: ignore[union-attr]
                if args[0] in pos_args:
                    raise ValueError(f'duplicate positional argument {args[0]!r}')
                pos_args.add(args[0])
            parser.add_argument(*args, **kwargs)

    @classmethod
    def configure_parser(cls, parser: Union[ArgumentParser, _ArgumentGroup]) -> None:
        """Configures an argument parser by adding the appropriate arguments.

        By default, this will simply call [`configure_argument`][fancy_dataclass.cli.ArgparseDataclass.configure_argument] for each dataclass field.

        Args:
            parser: `ArgumentParser` to configure"""
        check_dataclass(cls)
        if (version := cls.__settings__.version):
            parser.add_argument('--version', action='version', version=version)
        subcommand = None
        for fld in fields(cls):  # type: ignore[arg-type]
            if fld.metadata.get('subcommand', False):
                # TODO: check field type is ArgparseDataclass or Union thereof
                # TODO: move this to __init_dataclass__
                if subcommand is None:
                    subcommand = fld.name
                else:
                    raise ValueError(f'multiple fields ({subcommand!r} and {fld.name!r}) registered as subcommands, at most one is allowed')
            cls.configure_argument(parser, fld.name)

    @classmethod
    def make_parser(cls) -> ArgumentParser:
        """Constructs an argument parser and configures it with arguments corresponding to the dataclass's fields.

        Returns:
            The configured `ArgumentParser`"""
        parser = cls.new_parser()
        cls.configure_parser(parser)
        return parser

    @classmethod
    def args_to_dict(cls, args: Namespace) -> Dict[str, Any]:
        """Converts a [`Namespace`](https://docs.python.org/3/library/argparse.html#argparse.Namespace) object to a dict that can be converted to the dataclass type.

        Override this to enable custom behavior.

        Args:
            args: `Namespace` object storing parsed arguments

        Returns:
            A dict mapping from field names to values"""
        check_dataclass(cls)
        d = {}
        for field in fields(cls):  # type: ignore[arg-type]
            nested_field = False
            tp = cast(type, field.type)
            if issubclass_safe(tp, ArgparseDataclass):
                # recursively gather arguments for nested ArgparseDataclass
                val = tp.args_to_dict(args)  # type: ignore[attr-defined]
                nested_field = True
            elif hasattr(args, field.name):  # extract arg from the namespace
                val = getattr(args, field.name)
                # check that Literal value matches one of the allowed values
                # TODO: let general-purpose validation mixin handle this post-init?
                if get_origin(tp) == Literal:
                    if not any(val == arg for arg in get_args(tp)):
                        raise ValueError(f'invalid value {val!r} for type {tp}')
            else:  # argument not present
                continue
            if nested_field:  # merge in nested ArgparseDataclass
                d.update(val)
            else:
                d[field.name] = val
        return d

    @classmethod
    def from_args(cls, args: Namespace) -> Self:
        """Constructs an [`ArgparseDataclass`][fancy_dataclass.cli.ArgparseDataclass] from a `Namespace` object.

        Args:
            args: `Namespace` object storing parsed arguments

        Returns:
            An instance of this class derived from the parsed arguments"""
        d = cls.args_to_dict(args)
        kwargs = {}
        for fld in fields(cls):  # type: ignore[arg-type]
            name = fld.name
            tp: Optional[type] = cast(type, fld.type)
            is_subcommand = fld.metadata.get('subcommand', False)
            origin_type = get_origin(tp)
            if origin_type == Union:
                tp_args = [arg for arg in get_args(tp) if (arg is not type(None))]
                subcommand = getattr(args, cls.subcommand_dest_name, None)
                if is_subcommand and subcommand:
                    tp_args = [arg for arg in tp_args if (arg.__settings__.command_name == subcommand)]
                    assert len(tp_args) == 1, f'exactly one type within {tp} should have command name {subcommand}'
                    assert issubclass_safe(tp_args[0], ArgparseDataclass)
                tp = tp_args[0] if (subcommand or (not is_subcommand)) else None
            if tp and issubclass_safe(tp, ArgparseDataclass):
                # handle nested ArgparseDataclass
                kwargs[name] = tp.from_args(args)  # type: ignore[attr-defined]
            elif name in d:
                if (origin_type is tuple) and isinstance(d.get(name), list):
                    kwargs[name] = tuple(d[name])
                else:
                    kwargs[name] = d[name]
            elif type_is_optional(cast(type, fld.type)) and (fld.default == MISSING) and (fld.default_factory == MISSING):
                # positional optional argument with no default: fill in None
                kwargs[name] = None
        return cls(**kwargs)

    @classmethod
    def process_args(cls, parser: ArgumentParser, args: Namespace) -> None:
        """Processes arguments from an ArgumentParser, after they are parsed.

        Override this to enable custom behavior.

        Args:
            parser: `ArgumentParser` used to parse arguments
            args: `Namespace` containing parsed arguments"""
        pass

    @classmethod
    def from_cli_args(cls, arg_list: Optional[List[str]] = None) -> Self:
        """Constructs and configures an argument parser, then parses the given command-line arguments and uses them to construct an instance of the class.

        Args:
            arg_list: List of arguments as strings (if `None`, uses `sys.argv`)

        Returns:
            An instance of this class derived from the parsed arguments"""
        parser = cls.make_parser()  # create and configure parser
        args = parser.parse_args(args=arg_list)  # parse arguments (uses sys.argv if None)
        cls.process_args(parser, args)  # process arguments
        return cls.from_args(args)

subcommand_name: Optional[str] property

Gets the name of the chosen subcommand associated with the type of the object's subcommand field.

Returns:

Type Description
Optional[str]

Name of the subcommand, if a subcommand field exists, and None otherwise

args_to_dict(args) classmethod

Converts a Namespace object to a dict that can be converted to the dataclass type.

Override this to enable custom behavior.

Parameters:

Name Type Description Default
args Namespace

Namespace object storing parsed arguments

required

Returns:

Type Description
Dict[str, Any]

A dict mapping from field names to values

Source code in fancy_dataclass/cli.py
@classmethod
def args_to_dict(cls, args: Namespace) -> Dict[str, Any]:
    """Converts a [`Namespace`](https://docs.python.org/3/library/argparse.html#argparse.Namespace) object to a dict that can be converted to the dataclass type.

    Override this to enable custom behavior.

    Args:
        args: `Namespace` object storing parsed arguments

    Returns:
        A dict mapping from field names to values"""
    check_dataclass(cls)
    d = {}
    for field in fields(cls):  # type: ignore[arg-type]
        nested_field = False
        tp = cast(type, field.type)
        if issubclass_safe(tp, ArgparseDataclass):
            # recursively gather arguments for nested ArgparseDataclass
            val = tp.args_to_dict(args)  # type: ignore[attr-defined]
            nested_field = True
        elif hasattr(args, field.name):  # extract arg from the namespace
            val = getattr(args, field.name)
            # check that Literal value matches one of the allowed values
            # TODO: let general-purpose validation mixin handle this post-init?
            if get_origin(tp) == Literal:
                if not any(val == arg for arg in get_args(tp)):
                    raise ValueError(f'invalid value {val!r} for type {tp}')
        else:  # argument not present
            continue
        if nested_field:  # merge in nested ArgparseDataclass
            d.update(val)
        else:
            d[field.name] = val
    return d

configure_argument(parser, name) classmethod

Given an argument parser and a field name, configures the parser with an argument of that name.

Attempts to provide reasonable default behavior based on the dataclass field name, type, default, and metadata.

Subclasses may override this method to implement custom behavior.

Parameters:

Name Type Description Default
parser ArgParser

parser object to update with a new argument

required
name str

Name of the argument to configure

required
Source code in fancy_dataclass/cli.py
@classmethod
def configure_argument(cls, parser: ArgParser, name: str) -> None:
    """Given an argument parser and a field name, configures the parser with an argument of that name.

    Attempts to provide reasonable default behavior based on the dataclass field name, type, default, and metadata.

    Subclasses may override this method to implement custom behavior.

    Args:
        parser: parser object to update with a new argument
        name: Name of the argument to configure"""
    def is_nested(tp: type) -> TypeGuard[ArgparseDataclass]:
        return issubclass_safe(tp, ArgparseDataclass)
    kwargs: Dict[str, Any] = {}
    fld = cls.__dataclass_fields__[name]  # type: ignore[attr-defined]
    settings = cls._field_settings(fld).adapt_to(ArgparseDataclassFieldSettings)
    if settings.parse_exclude:  # exclude the argument from the parser
        return
    # determine the type of the parser argument for the field
    tp: type = settings.type or fld.type  # type: ignore[assignment]
    if isinstance(tp, str):  # resolve type
        tp = get_type_hints(cls)[name]
    action = settings.action or 'store'
    origin_type = get_origin(tp)
    if origin_type is not None:  # compound type
        if type_is_optional(tp):
            kwargs['default'] = None
        if origin_type == ClassVar:  # by default, exclude ClassVars from the parser
            return
        tp_args = get_args(tp)
        if tp_args:  # Union/List/Optional
            if origin_type == Union:
                tp_args = tuple(arg for arg in tp_args if (arg is not type(None)))
                if (len(tp_args) > 1) and (not settings.subcommand):
                    raise ValueError(f'union type {tp} not allowed as ArgparseDataclass field except as subcommand')
            elif issubclass_safe(origin_type, list) or issubclass_safe(origin_type, tuple):
                for arg in tp_args:
                    if is_nested(arg):
                        name = f'list of {arg.__name__}' if issubclass_safe(origin_type, list) else f'tuple with {arg}'  # type: ignore[attr-defined]
                        raise ValueError(f'{name} not allowed in ArgparseDataclass parser')
            tp = tp_args[0]
            if get_origin(tp) == Literal:  # Optional[Literal[...]]: unwrap to Literal[...]
                tp_args = get_args(tp)
                origin_type = Literal
            if origin_type == Literal:  # literal options will become choices
                arg_types = {type(arg) for arg in tp_args}
                if len(arg_types) == 1:
                    tp = arg_types.pop()
                else:
                    tp = None  # type: ignore[assignment]
                kwargs['choices'] = tp_args
        else:  # type cannot be inferred
            raise ValueError(f'cannot infer type of items in field {name!r}')
        if issubclass_safe(origin_type, list) and (action == 'store'):
            kwargs['nargs'] = '*'  # allow multiple arguments by default
    if issubclass_safe(tp, IntEnum):
        # use a bare int type
        tp = int
    if issubclass_safe(get_origin(tp), list):  # type: ignore[arg-type]
        tp = get_args(tp)[0]
    kwargs['type'] = tp
    # determine the default value
    if fld.default == MISSING:
        if fld.default_factory != MISSING:
            kwargs['default'] = fld.default_factory()
    else:
        kwargs['default'] = fld.default
    # get the names of the arguments associated with the field
    args = settings.args
    if args is not None:
        if isinstance(args, str):
            args = [args]
        # argument is positional if it is explicitly given without a leading dash
        positional = not args[0].startswith('-')
        if (not positional) and ('default' not in kwargs):
            # no default available, so make the field a required option
            kwargs['required'] = True
    else:
        positional = (tp is not bool) and ('default' not in kwargs)
        if positional:
            args = [fld.name]
        else:
            # use a single dash for 1-letter names
            prefix = '-' if (len(fld.name) == 1) else '--'
            argname = fld.name.replace('_', '-')
            args = [prefix + argname]
    if args and (not positional):
        # store the argument based on the name of the field, and not whatever flag name was provided
        kwargs['dest'] = fld.name
    if settings.required is not None:
        kwargs['required'] = settings.required
    has_default = 'default' in kwargs
    default = kwargs.get('default')
    if fld.type is bool:  # use boolean flag instead of an argument
        action = settings.action or 'store_true'
        kwargs['action'] = action
        if isinstance(action, str) and (action not in ['store_true', 'store_false']):
            raise ValueError(f'invalid action {action!r} for boolean flag field {name!r}')
        if default is not None:
            if (action != 'store_false') == default:
                raise ValueError(f'cannot use default value of {default} for action {action!r} with boolean flag field {name!r}')
        for key in ('type', 'required'):
            with suppress(KeyError):
                kwargs.pop(key)
    # extract additional items from metadata
    for key in cls._parser_argument_kwarg_names():
        if key in fld.metadata:
            kwargs[key] = fld.metadata[key]
    if kwargs.get('action') in ['count', 'store_const']:
        del kwargs['type']
    # determine if the field show its default in the help string
    if cls.__settings__.default_help:
        # include default if there is one, and the flag is not overridden to False at the field level
        default_help = has_default and (settings.default_help is not False)
    else:
        # include default if the field-level flag is set to True
        default_help = bool(settings.default_help)
    if default_help:
        if not has_default:
            raise ValueError(f'cannot use default_help=True for field {name!r} since it has no default')
        help_str = kwargs.get('help', None)
        # append the default value to the help string
        help_str = ((help_str + ' ') if help_str else '') + f'(default: {default})'
        kwargs['help'] = help_str
    if (result := _get_parser_group_name(settings, fld.name)) is not None:
        # add argument to the group instead of the main parser
        (group_name, is_exclusive) = result
        if is_exclusive:
            group: Optional[Union[_ArgumentGroup, _MutuallyExclusiveGroup]] = _get_parser_exclusive_group(parser, group_name)
        else:
            group = _get_parser_group(parser, group_name)
        if not group:  # group not found, so create it
            if is_exclusive:
                group = _add_exclusive_group(parser, group_name, kwargs.get('required', False))
            else:
                # get kwargs from nested ArgparseDataclass
                group_kwargs = tp.parser_kwargs() if is_nested(tp) else {}
                group = _add_group(parser, group_name, **group_kwargs)
        parser = group
    if settings.subcommand:
        # create subparsers for each variant
        assert isinstance(parser, ArgumentParser)
        dest = cls.subcommand_dest_name
        required = kwargs.get('required', not has_default)
        if (not required) and (not has_default):
            raise ValueError(f'{name!r} field cannot set required=False with no default value')
        subparsers = parser.add_subparsers(dest=dest, required=required, help=settings.help, metavar='subcommand')
        tp_args = (tp,) if (origin_type is None) else tp_args
        for arg in tp_args:
            assert issubclass_safe(arg, ArgparseDataclass)
            descr_brief = arg._parser_description_brief()
            subparser_kwargs = arg.parser_kwargs()
            if 'formatter_class' not in subparser_kwargs:
                # inherit formatter_class from the parent
                subparser_kwargs['formatter_class'] = parser.formatter_class
            subparser = subparsers.add_parser(arg.__settings__.command_name, help=descr_brief, **subparser_kwargs)
            arg.configure_parser(subparser)
        return
    if is_nested(tp):  # recursively configure a nested ArgparseDataclass field
        tp.configure_parser(parser)
    else:
        # prevent duplicate positional args
        if not hasattr(parser, '_pos_args'):
            parser._pos_args = set()  # type: ignore[union-attr]
        if positional:
            pos_args = parser._pos_args  # type: ignore[union-attr]
            if args[0] in pos_args:
                raise ValueError(f'duplicate positional argument {args[0]!r}')
            pos_args.add(args[0])
        parser.add_argument(*args, **kwargs)

configure_parser(parser) classmethod

Configures an argument parser by adding the appropriate arguments.

By default, this will simply call configure_argument for each dataclass field.

Parameters:

Name Type Description Default
parser Union[ArgumentParser, _ArgumentGroup]

ArgumentParser to configure

required
Source code in fancy_dataclass/cli.py
@classmethod
def configure_parser(cls, parser: Union[ArgumentParser, _ArgumentGroup]) -> None:
    """Configures an argument parser by adding the appropriate arguments.

    By default, this will simply call [`configure_argument`][fancy_dataclass.cli.ArgparseDataclass.configure_argument] for each dataclass field.

    Args:
        parser: `ArgumentParser` to configure"""
    check_dataclass(cls)
    if (version := cls.__settings__.version):
        parser.add_argument('--version', action='version', version=version)
    subcommand = None
    for fld in fields(cls):  # type: ignore[arg-type]
        if fld.metadata.get('subcommand', False):
            # TODO: check field type is ArgparseDataclass or Union thereof
            # TODO: move this to __init_dataclass__
            if subcommand is None:
                subcommand = fld.name
            else:
                raise ValueError(f'multiple fields ({subcommand!r} and {fld.name!r}) registered as subcommands, at most one is allowed')
        cls.configure_argument(parser, fld.name)

from_args(args) classmethod

Constructs an ArgparseDataclass from a Namespace object.

Parameters:

Name Type Description Default
args Namespace

Namespace object storing parsed arguments

required

Returns:

Type Description
Self

An instance of this class derived from the parsed arguments

Source code in fancy_dataclass/cli.py
@classmethod
def from_args(cls, args: Namespace) -> Self:
    """Constructs an [`ArgparseDataclass`][fancy_dataclass.cli.ArgparseDataclass] from a `Namespace` object.

    Args:
        args: `Namespace` object storing parsed arguments

    Returns:
        An instance of this class derived from the parsed arguments"""
    d = cls.args_to_dict(args)
    kwargs = {}
    for fld in fields(cls):  # type: ignore[arg-type]
        name = fld.name
        tp: Optional[type] = cast(type, fld.type)
        is_subcommand = fld.metadata.get('subcommand', False)
        origin_type = get_origin(tp)
        if origin_type == Union:
            tp_args = [arg for arg in get_args(tp) if (arg is not type(None))]
            subcommand = getattr(args, cls.subcommand_dest_name, None)
            if is_subcommand and subcommand:
                tp_args = [arg for arg in tp_args if (arg.__settings__.command_name == subcommand)]
                assert len(tp_args) == 1, f'exactly one type within {tp} should have command name {subcommand}'
                assert issubclass_safe(tp_args[0], ArgparseDataclass)
            tp = tp_args[0] if (subcommand or (not is_subcommand)) else None
        if tp and issubclass_safe(tp, ArgparseDataclass):
            # handle nested ArgparseDataclass
            kwargs[name] = tp.from_args(args)  # type: ignore[attr-defined]
        elif name in d:
            if (origin_type is tuple) and isinstance(d.get(name), list):
                kwargs[name] = tuple(d[name])
            else:
                kwargs[name] = d[name]
        elif type_is_optional(cast(type, fld.type)) and (fld.default == MISSING) and (fld.default_factory == MISSING):
            # positional optional argument with no default: fill in None
            kwargs[name] = None
    return cls(**kwargs)

from_cli_args(arg_list=None) classmethod

Constructs and configures an argument parser, then parses the given command-line arguments and uses them to construct an instance of the class.

Parameters:

Name Type Description Default
arg_list Optional[List[str]]

List of arguments as strings (if None, uses sys.argv)

None

Returns:

Type Description
Self

An instance of this class derived from the parsed arguments

Source code in fancy_dataclass/cli.py
@classmethod
def from_cli_args(cls, arg_list: Optional[List[str]] = None) -> Self:
    """Constructs and configures an argument parser, then parses the given command-line arguments and uses them to construct an instance of the class.

    Args:
        arg_list: List of arguments as strings (if `None`, uses `sys.argv`)

    Returns:
        An instance of this class derived from the parsed arguments"""
    parser = cls.make_parser()  # create and configure parser
    args = parser.parse_args(args=arg_list)  # parse arguments (uses sys.argv if None)
    cls.process_args(parser, args)  # process arguments
    return cls.from_args(args)

make_parser() classmethod

Constructs an argument parser and configures it with arguments corresponding to the dataclass's fields.

Returns:

Type Description
ArgumentParser

The configured ArgumentParser

Source code in fancy_dataclass/cli.py
@classmethod
def make_parser(cls) -> ArgumentParser:
    """Constructs an argument parser and configures it with arguments corresponding to the dataclass's fields.

    Returns:
        The configured `ArgumentParser`"""
    parser = cls.new_parser()
    cls.configure_parser(parser)
    return parser

new_parser() classmethod

Constructs a new top-level argument parser..

Returns:

Type Description
ArgumentParser

New top-level parser derived from the class's fields

Source code in fancy_dataclass/cli.py
@classmethod
def new_parser(cls) -> ArgumentParser:
    """Constructs a new top-level argument parser..

    Returns:
        New top-level parser derived from the class's fields"""
    return cls.__settings__.parser_class(**cls.parser_kwargs())

parser_kwargs() classmethod

Gets keyword arguments that will be passed to the top-level argument parser.

Returns:

Type Description
Dict[str, Any]

Keyword arguments passed upon construction of the ArgumentParser

Source code in fancy_dataclass/cli.py
@classmethod
def parser_kwargs(cls) -> Dict[str, Any]:
    """Gets keyword arguments that will be passed to the top-level argument parser.

    Returns:
        Keyword arguments passed upon construction of the `ArgumentParser`"""
    kwargs: Dict[str, Any] = {'description': cls._parser_description()}
    if (fmt_cls := cls.__settings__.formatter_class) is not None:
        kwargs['formatter_class'] = fmt_cls
    return kwargs

process_args(parser, args) classmethod

Processes arguments from an ArgumentParser, after they are parsed.

Override this to enable custom behavior.

Parameters:

Name Type Description Default
parser ArgumentParser

ArgumentParser used to parse arguments

required
args Namespace

Namespace containing parsed arguments

required
Source code in fancy_dataclass/cli.py
@classmethod
def process_args(cls, parser: ArgumentParser, args: Namespace) -> None:
    """Processes arguments from an ArgumentParser, after they are parsed.

    Override this to enable custom behavior.

    Args:
        parser: `ArgumentParser` used to parse arguments
        args: `Namespace` containing parsed arguments"""
    pass

ArgparseDataclassFieldSettings

Bases: FieldSettings

Settings for ArgparseDataclass fields.

Each field may define a metadata dict containing any of the following entries:

  • type: override the dataclass field type with a different type
  • args: lists the command-line arguments explicitly
  • action: type of action taken when the argument is encountered
  • nargs: number of command-line arguments (use * for lists, + for non-empty lists)
  • const: constant value required by some action/nargs combinations
  • choices: list of possible inputs allowed
  • help: help string
  • metavar: name for the argument in usage messages
  • required: whether the option is required
  • group: name of the argument group in which to put the argument
    • The group will be created if it does not already exist in the parser
  • exclusive_group: name of the mutually exclusive argument group in which to put the argument
    • The group will be created if it does not already exist in the parser
  • subcommand: boolean flag marking this field as a subcommand
  • parse_exclude: boolean flag indicating that the field should not be included in the parser
  • default_help: boolean flag indicating the field's default value (if present) should be shown in the help
    • If None, falls back on the class-level default_help flag

Note that these line up closely with the usual options that can be passed to ArgumentParser.add_argument.

Positional arguments vs. options:

  • If a field explicitly lists arguments in the args metadata field, the argument will be an option if the first listed argument starts with a dash; otherwise it will be a positional argument.
    • If it is an option but specifies no default value, it will be a required option.
  • If args are absent, the field will be:
    • A boolean flag if its type is bool
      • Can set action in metadata as either "store_true" (default) or "store_false"
    • An option if it specifies a default value
    • Otherwise, a positional argument
  • If required is specified in the metadata, this will take precedence over the default behavior above.
Source code in fancy_dataclass/cli.py
@dataclass_kw_only()
class ArgparseDataclassFieldSettings(FieldSettings):
    """Settings for [`ArgparseDataclass`][fancy_dataclass.cli.ArgparseDataclass] fields.

    Each field may define a `metadata` dict containing any of the following entries:

    - `type`: override the dataclass field type with a different type
    - `args`: lists the command-line arguments explicitly
    - `action`: type of action taken when the argument is encountered
    - `nargs`: number of command-line arguments (use `*` for lists, `+` for non-empty lists)
    - `const`: constant value required by some action/nargs combinations
    - `choices`: list of possible inputs allowed
    - `help`: help string
    - `metavar`: name for the argument in usage messages
    - `required`: whether the option is required
    - `group`: name of the [argument group](https://docs.python.org/3/library/argparse.html#argument-groups) in which to put the argument
        - The group will be created if it does not already exist in the parser
    - `exclusive_group`: name of the [mutually exclusive](https://docs.python.org/3/library/argparse.html#mutual-exclusion) argument group in which to put the argument
        - The group will be created if it does not already exist in the parser
    - `subcommand`: boolean flag marking this field as a [subcommand](https://docs.python.org/3/library/argparse.html#sub-commands)
    - `parse_exclude`: boolean flag indicating that the field should not be included in the parser
    - `default_help`: boolean flag indicating the field's default value (if present) should be shown in the help
        - If `None`, falls back on the class-level `default_help` flag

    Note that these line up closely with the usual options that can be passed to [`ArgumentParser.add_argument`](https://docs.python.org/3/library/argparse.html#argparse.ArgumentParser.add_argument).

    **Positional arguments vs. options**:

    - If a field explicitly lists arguments in the `args` metadata field, the argument will be an option if the first listed argument starts with a dash; otherwise it will be a positional argument.
        - If it is an option but specifies no default value, it will be a required option.
    - If `args` are absent, the field will be:
        - A boolean flag if its type is `bool`
            - Can set `action` in metadata as either `"store_true"` (default) or `"store_false"`
        - An option if it specifies a default value
        - Otherwise, a positional argument
    - If `required` is specified in the metadata, this will take precedence over the default behavior above."""
    type: Optional[Union[type, Callable[[Any], Any]]] = None  # can be used to define custom constructor
    args: Optional[Union[str, Sequence[str]]] = None
    action: Optional[Union[str, Type[Action]]] = None
    nargs: Optional[Union[str, int]] = None
    const: Optional[Any] = None
    choices: Optional[Sequence[Any]] = None
    help: Optional[str] = None
    metavar: Optional[Union[str, Sequence[str]]] = None
    required: Optional[bool] = None
    version: Optional[str] = None
    group: Optional[str] = None
    exclusive_group: Optional[str] = None
    subcommand: bool = False
    parse_exclude: bool = False
    default_help: Optional[bool] = None

ArgparseDataclassSettings

Bases: MixinSettings

Class-level settings for the ArgparseDataclass mixin.

Subclasses of ArgparseDataclass may set the following fields as keyword arguments during inheritance:

  • parser_class: subclass of argparse.ArgumentParser to use for argument parsing
  • formatter_class: subclass of argparse.HelpFormatter to use for customizing the help output
  • help_descr: string to use for the help description, which is displayed when --help is passed to the parser
    • If None, the class's docstring will be used by default.
  • help_descr_brief: string to use for the brief help description, which is used when the class is used as a subcommand entry. This is the text that appears in the menu of subcommands, which is often briefer than the main description.
    • If None, the class's docstring will be used by default (lowercased).
  • command_name: when this class is used to define a subcommand, the name of that subcommand
  • version: if set to a string, expose a --version argument displaying the version automatically (see argparse docs)
  • default_help: if set to True, includes each field's default value in its help string (this can be overridden by the field-level default_help flag)
Source code in fancy_dataclass/cli.py
@dataclass_kw_only()
class ArgparseDataclassSettings(MixinSettings):
    """Class-level settings for the [`ArgparseDataclass`][fancy_dataclass.cli.ArgparseDataclass] mixin.

    Subclasses of `ArgparseDataclass` may set the following fields as keyword arguments during inheritance:

    - `parser_class`: subclass of [`argparse.ArgumentParser`](https://docs.python.org/3/library/argparse.html#argparse.ArgumentParser) to use for argument parsing
    - `formatter_class`: subclass of [`argparse.HelpFormatter`](https://docs.python.org/3/library/argparse.html#formatter-class) to use for customizing the help output
    - `help_descr`: string to use for the help description, which is displayed when `--help` is passed to the parser
        - If `None`, the class's docstring will be used by default.
    - `help_descr_brief`: string to use for the *brief* help description, which is used when the class is used as a *subcommand* entry. This is the text that appears in the menu of subcommands, which is often briefer than the main description.
        - If `None`, the class's docstring will be used by default (lowercased).
    - `command_name`: when this class is used to define a subcommand, the name of that subcommand
    - `version`: if set to a string, expose a `--version` argument displaying the version automatically (see [`argparse`](https://docs.python.org/3/library/argparse.html#action) docs)
    - `default_help`: if set to `True`, includes each field's default value in its help string (this can be overridden by the field-level `default_help` flag)"""
    parser_class: Type[ArgumentParser] = ArgumentParser
    formatter_class: Optional[Type[HelpFormatter]] = None
    help_descr: Optional[str] = None
    help_descr_brief: Optional[str] = None
    command_name: Optional[str] = None
    version: Optional[str] = None
    default_help: bool = False

CLIDataclass

Bases: ArgparseDataclass

This subclass of ArgparseDataclass allows the user to execute arbitrary program logic using the parsed arguments as input.

Subclasses should override the run method to implement custom behavior.

Source code in fancy_dataclass/cli.py
class CLIDataclass(ArgparseDataclass):
    """This subclass of [`ArgparseDataclass`][fancy_dataclass.cli.ArgparseDataclass] allows the user to execute arbitrary program logic using the parsed arguments as input.

    Subclasses should override the `run` method to implement custom behavior."""

    def run(self) -> None:
        """Runs the main body of the program.

        Subclasses should implement this to provide custom behavior.

        If the class has a subcommand defined, and it is an instance of `CLIDataclass`, the default implementation of `run` will be to call the subcommand's own implementation."""
        # delegate to the subcommand's `run` method, if it exists
        if self.subcommand_field_name:
            val = getattr(self, self.subcommand_field_name)
            if isinstance(val, CLIDataclass):
                return val.run()
        raise NotImplementedError

    @classmethod
    def main(cls, arg_list: Optional[List[str]] = None) -> None:
        """Executes the following procedures in sequence:

        1. Constructs a new argument parser.
        2. Configures the parser with appropriate arguments.
        3. Parses command-line arguments.
        4. Post-processes the arguments.
        5. Constructs a dataclass instance from the parsed arguments.
        6. Runs the main body of the program, using the parsed arguments.

        Args:
            arg_list: List of arguments as strings (if `None`, uses `sys.argv`)"""
        obj = cls.from_cli_args(arg_list)  # steps 1-5
        obj.run()  # step 6

main(arg_list=None) classmethod

Executes the following procedures in sequence:

  1. Constructs a new argument parser.
  2. Configures the parser with appropriate arguments.
  3. Parses command-line arguments.
  4. Post-processes the arguments.
  5. Constructs a dataclass instance from the parsed arguments.
  6. Runs the main body of the program, using the parsed arguments.

Parameters:

Name Type Description Default
arg_list Optional[List[str]]

List of arguments as strings (if None, uses sys.argv)

None
Source code in fancy_dataclass/cli.py
@classmethod
def main(cls, arg_list: Optional[List[str]] = None) -> None:
    """Executes the following procedures in sequence:

    1. Constructs a new argument parser.
    2. Configures the parser with appropriate arguments.
    3. Parses command-line arguments.
    4. Post-processes the arguments.
    5. Constructs a dataclass instance from the parsed arguments.
    6. Runs the main body of the program, using the parsed arguments.

    Args:
        arg_list: List of arguments as strings (if `None`, uses `sys.argv`)"""
    obj = cls.from_cli_args(arg_list)  # steps 1-5
    obj.run()  # step 6

run()

Runs the main body of the program.

Subclasses should implement this to provide custom behavior.

If the class has a subcommand defined, and it is an instance of CLIDataclass, the default implementation of run will be to call the subcommand's own implementation.

Source code in fancy_dataclass/cli.py
def run(self) -> None:
    """Runs the main body of the program.

    Subclasses should implement this to provide custom behavior.

    If the class has a subcommand defined, and it is an instance of `CLIDataclass`, the default implementation of `run` will be to call the subcommand's own implementation."""
    # delegate to the subcommand's `run` method, if it exists
    if self.subcommand_field_name:
        val = getattr(self, self.subcommand_field_name)
        if isinstance(val, CLIDataclass):
            return val.run()
    raise NotImplementedError