Source code for typed_settings.cli_argparse

"""
Utilities for generating an :mod:`argparse` based CLI.

.. versionadded:: 2.0.0
"""

import argparse
import itertools
from collections.abc import Collection, Iterable, Mapping, Sequence
from datetime import date, datetime, timedelta
from enum import Enum
from functools import partial, wraps
from pathlib import Path
from typing import (
    TYPE_CHECKING,
    Any,
    Callable,
    Optional,
    Union,
)


if TYPE_CHECKING:
    from argparse import FileType

from . import _core, converters
from .cli_utils import (
    NO_DEFAULT,
    Default,
    DefaultFactorySentinel,
    StrDict,
    TypeArgsMaker,
    TypeHandlerFunc,
    get_default,
)
from .constants import ARGPARSE_METADATA_KEY as METADATA_KEY
from .converters import Converter
from .loaders import Loader
from .processors import Processor
from .types import (
    SECRET_REPR,
    ST,
    LoadedValue,
    LoaderMeta,
    MergedSettings,
    OptionInfo,
)


__all__ = [
    "cli",
    "make_parser",
    "namespace2settings",
    "handle_datetime",
    "handle_enum",
    "handle_path",
    "DEFAULT_TYPES",
    "ArgparseHandler",
    "BooleanOptionalAction",
    "ListAction",
    "DictItemAction",
]


WrapppedFunc = Callable[[ST], Any]
CliFn = Callable[[ST], Any]
DecoratedCliFn = Callable[[], Optional[int]]


[docs] def handle_datetime(type: type, default: Default, is_optional: bool) -> StrDict: """ Handle isoformatted datetimes. """ kwargs: StrDict = { "type": partial(converters.to_datetime, cls=datetime), "metavar": "YYYY-MM-DD[Thh:mm:ss[+xx:yy]]", } if isinstance(default, datetime): kwargs["default"] = default.isoformat() elif is_optional: kwargs["default"] = None return kwargs
def handle_date(type: type, default: Default, is_optional: bool) -> StrDict: """ Handle isoformatted datetimes. """ kwargs: StrDict = { "type": partial(converters.to_date, cls=date), "metavar": "YYYY-MM-DD", } if isinstance(default, date): kwargs["default"] = default.isoformat() elif is_optional: kwargs["default"] = None return kwargs def handle_timedelta(type: type, default: Default, is_optional: bool) -> StrDict: """ Handle isoformatted datetimes. """ kwargs: StrDict = { "type": partial(converters.to_timedelta, cls=timedelta), "metavar": "[-][Dd][HHh][MMm][SS[.ffffff]s]", } if isinstance(default, timedelta): kwargs["default"] = converters.timedelta_to_str(default) elif is_optional: kwargs["default"] = None return kwargs
[docs] def handle_enum(type: type[Enum], default: Default, is_optional: bool) -> StrDict: """ Use *choices* as option type and use the enum value's name as default. """ kwargs: StrDict = {"choices": list(type.__members__)} if isinstance(default, type): # Convert Enum instance to string kwargs["default"] = default.name elif is_optional: kwargs["default"] = None return kwargs
[docs] def handle_path(type: type[Path], default: Default, is_optional: bool) -> StrDict: """ Handle :class:`pathlib.Path` and also use proper metavar. """ kwargs: StrDict = {"type": Path, "metavar": "PATH"} if isinstance(default, (Path, str)): kwargs["default"] = str(default) elif is_optional: kwargs["default"] = None return kwargs
#: Default handlers for argparse option types. DEFAULT_TYPES: dict[type, TypeHandlerFunc] = { datetime: handle_datetime, date: handle_date, timedelta: handle_timedelta, Enum: handle_enum, Path: handle_path, }
[docs] class ArgparseHandler: """ Implementation of the :class:`~typed_settings.cli_utils.TypeHandler` protocol for Click. Args: extra_types: A dict mapping types to specialized handler functions. Use :data:`DEFAULT_TYPES` by default. """ def __init__( self, extra_types: Optional[dict[type, TypeHandlerFunc]] = None ) -> None: self.extra_types = extra_types or DEFAULT_TYPES def get_scalar_handlers(self) -> dict[type, TypeHandlerFunc]: return self.extra_types def handle_scalar( self, type: Optional[type], default: Default, is_optional: bool, ) -> StrDict: kwargs: StrDict = {"type": type} if type is not None: if issubclass(type, str): kwargs["metavar"] = "TEXT" else: kwargs["metavar"] = str(type.__name__).upper() # if default is not None or is_optional: if default not in (None, NO_DEFAULT): kwargs["default"] = default elif is_optional: kwargs["default"] = None if type and issubclass(type, bool): kwargs["action"] = BooleanOptionalAction return kwargs def handle_tuple( self, type_args_maker: TypeArgsMaker, types: tuple[Any, ...], default: Optional[tuple], is_optional: bool, ) -> StrDict: metavar = tuple( "TEXT" if issubclass(t, str) else str(t.__name__).upper() for t in types ) kwargs = { "metavar": metavar, "nargs": len(types), "default": default, } return kwargs def handle_collection( self, type_args_maker: TypeArgsMaker, types: tuple[Any, ...], default: Optional[Collection[Any]], is_optional: bool, ) -> StrDict: kwargs = type_args_maker.get_kwargs(types[0], NO_DEFAULT) kwargs["default"] = default or [] # Don't use None as default kwargs["action"] = ListAction return kwargs def handle_mapping( self, type_args_maker: TypeArgsMaker, types: tuple[Any, ...], default: Default, is_optional: bool, ) -> StrDict: kwargs = { "metavar": "KEY=VALUE", "action": DictItemAction, } if not isinstance(default, Mapping): default = {} kwargs["default"] = default kwargs["default_repr"] = ", ".join(f"{k}={v}" for k, v in default.items()) return kwargs
[docs] def cli( settings_cls: type[ST], loaders: Union[str, Sequence[Loader]], *, processors: Sequence[Processor] = (), converter: Optional[Converter] = None, base_dir: Path = Path(), type_args_maker: Optional[TypeArgsMaker] = None, **parser_kwargs: Any, ) -> Callable[[CliFn[ST]], DecoratedCliFn]: r""" **Decorator:** Generate an argument parser for the options of the given settings class and pass an instance of that class to the decorated function. Args: settings_cls: The settings class to generate options for. loaders: Either a string with your app name or a list of :class:`.Loader`\ s. If it's a string, use it with :func:`~typed_settings.default_loaders()` to get the default loaders. processors: A list of settings :class:`.Processor`'s. converter: An optional :class:`.Converter` used for converting option values to the required type. By default, :data:`typed_settings.default_converter()` is used. base_dir: Base directory for resolving relative paths in default option values. type_args_maker: The type args maker that is used to generate keyword arguments for :func:`click.option()`. By default, use :class:`.TypeArgsMaker` with :class:`ArgparseHandler`. **parser_kwargs: Additional keyword arguments to pass to the :class:`argparse.ArgumentParser`. Return: A decorator for an argparse CLI function. Raise: InvalidSettingsError: If an instance of *cls* cannot be created for the given settings. Example: .. code-block:: python import typed_settings as ts @ts.settings class Settings: ... @ts.cli(Settings, "example") def cli(settings: Settings) -> None: print(settings) .. versionchanged:: 23.0.0 Made *converter* and *type_args_maker* a keyword-only argument .. versionchanged:: 23.0.0 Added the *processors* argument .. versionchanged:: 23.1.0 Added the *base_dir* argument """ if isinstance(loaders, str): loaders = _core.default_loaders(loaders) converter = converter or converters.default_converter() state = _core.SettingsState(settings_cls, loaders, processors, converter, base_dir) type_args_maker = type_args_maker or TypeArgsMaker(ArgparseHandler()) decorator = _get_decorator(state, type_args_maker, **parser_kwargs) return decorator
[docs] def make_parser( settings_cls: type[ST], loaders: Union[str, Sequence[Loader]], *, processors: Sequence[Processor] = (), converter: Optional[Converter] = None, base_dir: Path = Path(), type_args_maker: Optional[TypeArgsMaker] = None, **parser_kwargs: Any, ) -> tuple[argparse.ArgumentParser, MergedSettings]: r""" Return an argument parser for the options of the given settings class. Use :func:`namespace2settings()` to convert the parser's namespace to an instance of the settings class. Args: settings_cls: The settings class to generate options for. loaders: Either a string with your app name or a list of :class:`.Loader`\ s. If it's a string, use it with :func:`~typed_settings.default_loaders()` to get the default loaders. processors: A list of settings :class:`.Processor`'s. converter: An optional :class:`.Converter` used for converting option values to the required type. By default, :data:`typed_settings.default_converter()` is used. base_dir: Base directory for resolving relative paths in default option values. type_args_maker: The type args maker that is used to generate keyword arguments for :func:`click.option()`. By default, use :class:`.TypeArgsMaker` with :class:`ArgparseHandler`. **parser_kwargs: Additional keyword arguments to pass to the :class:`argparse.ArgumentParser`. Return: An argument parser configured with with an argument for each option of *settings_cls*. Raise: InvalidSettingsError: If an instance of *cls* cannot be created for the given settings. .. versionchanged:: 23.0.0 Made *converter* and *type_args_maker* a keyword-only argument .. versionchanged:: 23.0.0 Added the *processors* argument .. versionchanged:: 23.1.0 Added the *base_dir* argument """ if isinstance(loaders, str): loaders = _core.default_loaders(loaders) converter = converter or converters.default_converter() state = _core.SettingsState(settings_cls, loaders, processors, converter, base_dir) type_args_maker = type_args_maker or TypeArgsMaker(ArgparseHandler()) return _mk_parser(state, type_args_maker, **parser_kwargs)
[docs] def namespace2settings( settings_cls: type[ST], namespace: argparse.Namespace, *, merged_settings: MergedSettings, converter: Optional[Converter] = None, base_dir: Path = Path(), ) -> ST: """ Create a settings instance from an argparse namespace. To be used together with :func:`make_parser()`. Args: settings_cls: The settings class to instantiate. namespace: The namespace returned by the argument parser. merged_settings: The loaded and merged settings by settings name. converter: An optional :class:`.Converter` used for converting option values to the required type. By default, :data:`typed_settings.default_converter()` is used. base_dir: Base directory for resolving relative paths in default option values. Raise: InvalidSettingsError: If an instance of *cls* cannot be created for the given settings. Return: An instance of *settings_cls*. .. versionchanged:: 23.1.0 Added the *base_dir* argument """ converter = converter or converters.default_converter() state = _core.SettingsState(settings_cls, [], [], converter, base_dir) return _ns2settings(namespace, state, merged_settings)
def _get_decorator( state: _core.SettingsState[ST], type_args_maker: TypeArgsMaker, **parser_kwargs: Any, ) -> Callable[[CliFn], DecoratedCliFn]: """ Build the CLI decorator based on the user's config. """ def decorator(func: CliFn) -> DecoratedCliFn: """ Create an argument parsing wrapper for *func*. The wrapper - loads settings as default option values - creates an argument parser with an option for each setting - parses the command line options - passes the updated settings instance to the decorated function """ @wraps(func) def cli_wrapper() -> Optional[int]: if "description" not in parser_kwargs and func.__doc__: parser_kwargs["description"] = func.__doc__.strip() parser, merged_settings = _mk_parser( state, type_args_maker, **parser_kwargs ) args = parser.parse_args() settings = _ns2settings(args, state, merged_settings) return func(settings) return cli_wrapper return decorator def _mk_parser( state: _core.SettingsState[ST], type_args_maker: TypeArgsMaker, **parser_kwargs: Any, ) -> tuple[argparse.ArgumentParser, MergedSettings]: """ Create an :class:`argparse.ArgumentParser` for all options. """ merged_settings = _core._load_settings(state) grouped_options = [ (g_cls, list(g_opts)) for g_cls, g_opts in itertools.groupby( state.options, key=lambda o: o.parent_cls ) ] parser = argparse.ArgumentParser(**parser_kwargs) for g_cls, g_opts in grouped_options: group = parser.add_argument_group(g_cls.__name__, f"{g_cls.__name__} options") for oinfo in g_opts: default = get_default(oinfo, merged_settings, state.converter) flags, cfg = _mk_argument(oinfo, default, type_args_maker) group.add_argument(*flags, **cfg) return (parser, merged_settings) def _mk_argument( oinfo: OptionInfo, default: Default, type_args_maker: TypeArgsMaker, ) -> tuple[tuple[str, ...], dict[str, Any]]: user_config = dict(oinfo.metadata.get(METADATA_KEY, {})) # The option type specifies the default option kwargs kwargs = type_args_maker.get_kwargs(oinfo.cls, default) param_decls: tuple[str, ...] user_param_decls: Union[str, Sequence[str]] user_param_decls = user_config.pop("param_decls", ()) if not user_param_decls: option_name = oinfo.path.replace(".", "-").replace("_", "-") param_decls = (f"--{option_name}",) elif isinstance(user_param_decls, str): param_decls = (user_param_decls,) else: param_decls = tuple(user_param_decls) # Get "help" from the user_config *now*, because we may need to update it # below. Also replace "None" with "". kwargs["help"] = user_config.pop("help", None) or "" if "default" in kwargs and kwargs["default"] is not NO_DEFAULT: default_repr = kwargs.pop("default_repr", kwargs["default"]) if kwargs["default"] is None: help_extra = "" elif oinfo.is_secret: help_extra = f" [default: ({SECRET_REPR})]" elif isinstance(callable(kwargs["default"]), DefaultFactorySentinel): help_extra = "[default: (dynamic)]" else: help_extra = f" [default: {default_repr}]" if kwargs["default"] not in (None, (), [], {}): kwargs["default"] = DefaultFactorySentinel() else: kwargs["required"] = True help_extra = " [required]" kwargs["help"] = f"{kwargs['help']}{help_extra}" # The user has the last word, though. kwargs.update(user_config) return (param_decls, kwargs) def _ns2settings( namespace: argparse.Namespace, state: _core.SettingsState[ST], merged_settings: MergedSettings, ) -> ST: """ Convert the :class:`argparse.Namespace` to an instance of the settings class and return it. """ meta = LoaderMeta("Command line args") for option_info in state.options: path = option_info.path attr = path.replace(".", "_") if hasattr(namespace, attr): # pragma: no cover # "path" *should* always be in "cli_options", b/c we *currently* # generate CLI options for all options. But let's stay safe here # in case the behavior changes in the future. value = getattr(namespace, attr) if not isinstance(value, DefaultFactorySentinel): merged_settings[path] = LoadedValue(value, meta) settings = _core.convert(merged_settings, state) return settings class BooleanOptionalAction(argparse.Action): """ An argparse action for handling boolean flags. """ def __init__( self, option_strings: Sequence[str], dest: str, default: Default = None, type: Union[Callable[[str], Any], "FileType", None] = None, choices: Optional[Iterable[Any]] = None, required: bool = False, help: Optional[str] = None, metavar: Union[str, tuple[str, ...], None] = None, ) -> None: _option_strings = [] for option_string in option_strings: _option_strings.append(option_string) if not option_string.startswith("--"): raise ValueError( f"Only boolean flags starting with '--' are supported: " f"{option_string}" ) option_string = "--no-" + option_string[2:] _option_strings.append(option_string) super().__init__( option_strings=_option_strings, dest=dest, nargs=0, default=default, type=type, choices=choices, required=required, help=help, metavar=metavar, ) def __call__( self, parser: argparse.ArgumentParser, namespace: argparse.Namespace, values: Union[str, Sequence[Any], None], option_string: Optional[str] = None, ) -> None: if option_string and option_string in self.option_strings: # pragma: no cover setattr(namespace, self.dest, not option_string.startswith("--no-")) def format_usage(self) -> str: return " | ".join(self.option_strings) class ListAction(argparse.Action): """ An argparse action for handling lists. """ def __init__( self, option_strings: Sequence[str], dest: str, nargs: Union[int, str, None] = None, default: Default = None, type: Union[Callable[[str], Any], "FileType", None] = None, choices: Optional[Iterable[Any]] = None, required: bool = False, help: Optional[str] = None, metavar: Union[str, tuple[str, ...], None] = None, ) -> None: if nargs == 0: # pragma: no cover raise ValueError(f"nargs for append actions must be != 0: {nargs}") super().__init__( option_strings=option_strings, dest=dest, nargs=nargs, default=default, type=type, choices=choices, required=required, help=help, metavar=metavar, ) def __call__( self, parser: argparse.ArgumentParser, namespace: argparse.Namespace, values: Union[str, Sequence[Any], None], option_string: Optional[str] = None, ) -> None: if values is None: return # pragma: no cover items = getattr(namespace, self.dest, []) # Do not append to the defaults but create a new list! if items is self.default: items = [] items.append(values) setattr(namespace, self.dest, items) class DictItemAction(argparse.Action): """ An argparse action for handling dicts. """ def __init__( self, option_strings: Sequence[str], dest: str, default: Default = None, type: Union[Callable[[str], Any], "FileType", None] = None, choices: Optional[Iterable[Any]] = None, required: bool = False, help: Optional[str] = None, metavar: Union[str, tuple[str, ...], None] = None, ) -> None: super().__init__( option_strings=option_strings, dest=dest, nargs=1, default=default, type=type, choices=choices, required=required, help=help, metavar=metavar, ) def __call__( self, parser: argparse.ArgumentParser, namespace: argparse.Namespace, values: Union[str, Sequence[Any], None], option_string: Optional[str] = None, ) -> None: if values is None: return # pragma: no cover if isinstance(values, str): values = [values] # pragma: no cover items = getattr(namespace, self.dest, {}) # Do not append to the defaults but create a new list! if items is self.default: items = {} for value in values: k, _, v = value.partition("=") items[k] = v setattr(namespace, self.dest, items)