Source code for typed_settings.cli_argparse

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

.. versionadded:: 2.0.0
"""

import argparse
import itertools
from datetime import datetime
from enum import Enum
from functools import wraps
from pathlib import Path
from typing import (
    TYPE_CHECKING,
    Any,
    Callable,
    Collection,
    Dict,
    Iterable,
    Mapping,
    Optional,
    Sequence,
    Tuple,
    Type,
    Union,
)


if TYPE_CHECKING:
    from argparse import FileType

from . import _core
from .cli_utils import (
    DEFAULT_SENTINEL,
    DEFAULT_SENTINEL_NAME,
    NO_DEFAULT,
    Default,
    StrDict,
    TypeArgsMaker,
    TypeHandlerFunc,
    get_default,
)
from .constants import ARGPARSE_METADATA_KEY as METADATA_KEY
from .converters import Converter, default_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": datetime.fromisoformat, "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
[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, 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 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 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 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 ( callable(kwargs["default"]) and kwargs["default"].__name__ == DEFAULT_SENTINEL_NAME ): help_extra = "[default: (dynamic)]" else: help_extra = f" [default: {default_repr}]" if kwargs["default"] not in (None, (), [], {}): kwargs["default"] = DEFAULT_SENTINEL 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 value is not DEFAULT_SENTINEL: 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)