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
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

    _subcommand_field_name: ClassVar[Optional[str]] = None

    @classmethod
    def __init_subclass__(cls, **kwargs: Any) -> None:
        super().__init_subclass__(**kwargs)
        # 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 = 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 private class attribute
        cls._subcommand_field_name = subcommand

    @property
    def subcommand(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_class(cls) -> Type[ArgumentParser]:
        """Gets the type of the top-level argument parser.

        Returns:
            Type (subclass of `argparse.ArgumentParser`) to be constructed by this class"""
        return ArgumentParser

    @classmethod
    def parser_description(cls) -> Optional[str]:
        """Gets a description string for the top-level argument parser, which will be displayed by default when `--help` is passed to the parser.

        By default, uses the class's own docstring.

        Returns:
            String to be used as the program's description"""
        return cls.__doc__

    @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`"""
        return {'description' : cls.parser_description()}

    @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.parser_class()(**cls.parser_kwargs())

    @classmethod
    def configure_argument(cls, parser: ArgumentParser, 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]
        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 origin_type == Literal:  # literal options will become choices
                    tp = type(tp)
                    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
        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:
            argname = fld.name.replace('_', '-')
            positional = (tp is not bool) and ('default' not in kwargs)
            if positional:
                args = [argname]
            else:
                # use a single dash for 1-letter names
                prefix = '-' if (len(fld.name) == 1) else '--'
                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 fld.type is bool:  # use boolean flag instead of an argument
            action = settings.action or 'store_true'
            kwargs['action'] = action
            if action not in ['store_true', 'store_false']:
                raise ValueError(f'invalid action {action!r} for boolean flag field {name!r}')
            if (default := kwargs.get('default')) is not None:
                if (action == 'store_true') == 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') == 'store_const'):
            del kwargs['type']
        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: Dict[str, Any] = tp.parser_kwargs() if is_nested(tp) else {}
                    group = _add_group(parser, group_name, **group_kwargs)
            parser = group  # type: ignore[assignment]
        if settings.subcommand:
            # create subparsers for each variant
            subparsers = parser.add_subparsers(dest='_subcommand', required=True, 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)
                subparser = subparsers.add_parser(arg.__settings__.command_name, help=arg.parser_description())
                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[attr-defined]
            if positional:
                pos_args = parser._pos_args  # type: ignore[attr-defined]
                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: ArgumentParser) -> 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)
        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
            if issubclass_safe(field.type, ArgparseDataclass):
                # recursively gather arguments for nested ArgparseDataclass
                val = field.type.args_to_dict(args)
                nested_field = True
            elif hasattr(args, field.name):  # extract arg from the namespace
                val = getattr(args, field.name)
            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 = fld.type
            if type_is_optional(tp):
                tp = next(arg for arg in get_args(fld.type) if (arg is not type(None)))
            origin_type = get_origin(tp)
            if origin_type == Union:
                tp_args = [arg for arg in get_args(tp) if (arg.__settings__.command_name == args._subcommand)]
                assert len(tp_args) == 1, f'exactly one type within {tp} should have command name {args._subcommand}'
                tp = tp_args[0]
                assert issubclass_safe(tp, ArgparseDataclass)
            if issubclass_safe(tp, ArgparseDataclass):
                # handle nested ArgparseDataclass
                kwargs[name] = tp.from_args(args)
            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]
        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: 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
        if issubclass_safe(field.type, ArgparseDataclass):
            # recursively gather arguments for nested ArgparseDataclass
            val = field.type.args_to_dict(args)
            nested_field = True
        elif hasattr(args, field.name):  # extract arg from the namespace
            val = getattr(args, field.name)
        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 ArgumentParser

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: ArgumentParser, 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]
    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 origin_type == Literal:  # literal options will become choices
                tp = type(tp)
                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
    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:
        argname = fld.name.replace('_', '-')
        positional = (tp is not bool) and ('default' not in kwargs)
        if positional:
            args = [argname]
        else:
            # use a single dash for 1-letter names
            prefix = '-' if (len(fld.name) == 1) else '--'
            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 fld.type is bool:  # use boolean flag instead of an argument
        action = settings.action or 'store_true'
        kwargs['action'] = action
        if action not in ['store_true', 'store_false']:
            raise ValueError(f'invalid action {action!r} for boolean flag field {name!r}')
        if (default := kwargs.get('default')) is not None:
            if (action == 'store_true') == 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') == 'store_const'):
        del kwargs['type']
    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: Dict[str, Any] = tp.parser_kwargs() if is_nested(tp) else {}
                group = _add_group(parser, group_name, **group_kwargs)
        parser = group  # type: ignore[assignment]
    if settings.subcommand:
        # create subparsers for each variant
        subparsers = parser.add_subparsers(dest='_subcommand', required=True, 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)
            subparser = subparsers.add_parser(arg.__settings__.command_name, help=arg.parser_description())
            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[attr-defined]
        if positional:
            pos_args = parser._pos_args  # type: ignore[attr-defined]
            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 ArgumentParser

ArgumentParser to configure

required
Source code in fancy_dataclass/cli.py
@classmethod
def configure_parser(cls, parser: ArgumentParser) -> 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)
    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 = fld.type
        if type_is_optional(tp):
            tp = next(arg for arg in get_args(fld.type) if (arg is not type(None)))
        origin_type = get_origin(tp)
        if origin_type == Union:
            tp_args = [arg for arg in get_args(tp) if (arg.__settings__.command_name == args._subcommand)]
            assert len(tp_args) == 1, f'exactly one type within {tp} should have command name {args._subcommand}'
            tp = tp_args[0]
            assert issubclass_safe(tp, ArgparseDataclass)
        if issubclass_safe(tp, ArgparseDataclass):
            # handle nested ArgparseDataclass
            kwargs[name] = tp.from_args(args)
        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]
    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.parser_class()(**cls.parser_kwargs())

parser_argument_kwarg_names() classmethod

Gets keyword argument names that will be passed when adding arguments to the argument parser.

Returns:

Type Description
List[str]

Keyword argument names passed when adding arguments to the parser

Source code in fancy_dataclass/cli.py
@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']

parser_class() classmethod

Gets the type of the top-level argument parser.

Returns:

Type Description
Type[ArgumentParser]

Type (subclass of argparse.ArgumentParser) to be constructed by this class

Source code in fancy_dataclass/cli.py
@classmethod
def parser_class(cls) -> Type[ArgumentParser]:
    """Gets the type of the top-level argument parser.

    Returns:
        Type (subclass of `argparse.ArgumentParser`) to be constructed by this class"""
    return ArgumentParser

parser_description() classmethod

Gets a description string for the top-level argument parser, which will be displayed by default when --help is passed to the parser.

By default, uses the class's own docstring.

Returns:

Type Description
Optional[str]

String to be used as the program's description

Source code in fancy_dataclass/cli.py
@classmethod
def parser_description(cls) -> Optional[str]:
    """Gets a description string for the top-level argument parser, which will be displayed by default when `--help` is passed to the parser.

    By default, uses the class's own docstring.

    Returns:
        String to be used as the program's description"""
    return cls.__doc__

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`"""
    return {'description' : cls.parser_description()}

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 dataclass

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
  • 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

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
Source code in fancy_dataclass/cli.py
@dataclass
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
    - `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

    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"""
    type: Optional[Union[type, Callable[[Any], Any]]] = None  # can be used to define custom constructor
    args: Optional[Union[str, Sequence[str]]] = None
    action: Optional[str] = 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
    group: Optional[str] = None
    exclusive_group: Optional[str] = None
    subcommand: bool = False
    parse_exclude: bool = False

ArgparseDataclassSettings dataclass

Bases: DataclassMixinSettings

Class-level settings for the ArgparseDataclass mixin.

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

  • command_name: when this class is used to define a subcommand, the name of that subcommand
Source code in fancy_dataclass/cli.py
@dataclass
class ArgparseDataclassSettings(DataclassMixinSettings):
    """Class-level settings for the [`ArgparseDataclass`][fancy_dataclass.cli.ArgparseDataclass] mixin.

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

    - `command_name`: when this class is used to define a subcommand, the name of that subcommand"""
    command_name: Optional[str] = None

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