Source code for argclz.core

from __future__ import annotations

import abc
import argparse
import collections
import sys
from collections.abc import Sequence, Iterable, Callable
from typing import TYPE_CHECKING, Type, TypeVar, Literal, overload, Any, get_type_hints, TextIO, cast

from typing_extensions import Self

if TYPE_CHECKING:
    from .validator import Validator

__all__ = [
    # parser
    'AbstractParser',
    'new_parser',
    'parse_args',
    # argument
    'Argument',
    'argument',
    'pos_argument',
    'var_argument',
    'aliased_argument',
    'as_argument',
    # utilities
    'foreach_arguments',
    'with_defaults',
    'set_options',
    'copy_argument',
    'print_help',
    'as_dict'
]

T = TypeVar('T')
Nargs = Literal[
    '*', '+', '?', '...'
]
Actions = Literal[
    'store',
    'store_const',
    'store_true',
    'store_false',
    'append',
    'append_const',
    'extend',
    'count',
    'help',
    'version',
        #
    'boolean'
]


class ArgumentParserInterrupt(RuntimeError):
    """(internal) Error raised when any error occurs during command-line parsing."""

    def __init__(self, status: int, message: str | None):
        """
        :param status: error code
        :param message: error message
        """
        if message is None:
            super().__init__(f'exit {status}')
        else:
            super().__init__(f'exit {status}: {message}')

        self.status = status
        self.message = message


class ArgumentParser(argparse.ArgumentParser):
    """(internal) override ``argparse.ArgumentParser``."""

    def exit(self, status: int = 0, message: str | None = None):
        raise ArgumentParserInterrupt(status, message)

    def error(self, message: str):
        raise ArgumentParserInterrupt(2, message)


[docs] class AbstractParser(metaclass=abc.ABCMeta): """Commandline parser.""" USAGE: str | list[str] | None = None """parser usage.""" DESCRIPTION: str | None = None """parser description. Could be override as a method if its content is dynamic-generated.""" EPILOG: str | None = None """parser epilog. Could be override as a method if its content is dynamic-generated.""" def __new__(cls, *args, **kwargs): obj = object.__new__(cls) with_defaults(obj) return obj
[docs] @classmethod def new_parser(cls, **kwargs) -> ArgumentParser: """create an :class:`~argclz.core.ArgumentParser`. :param kwargs: keyword parameters to ``argparse.ArgumentParser`` :return: an ArgumentParser. """ return new_parser(cls, **kwargs)
[docs] def main(self, args: list[str] | None = None, *, parse_only=False, system_exit: Type[BaseException] | bool = SystemExit) -> AbstractParser: """parsing the commandline input *args* and call :meth:`~argclz.core.ArgumentParser.run()`. :param args: command-line arguments. If omitted, use ``sys.args``. :param parse_only: parse command-line arguments only, do not raise error and invoke :meth:`~argclz.core.ArgumentParser.run()` :param system_exit: error raised when commandline parsed fail. :return: parser itself. If it has sub command, return sub parser when used. """ parser = self.new_parser(reset=True) try: result = parser.parse_args(args) except ArgumentParserInterrupt as e: exit_status = e.status exit_message = e.message result = None else: exit_status = None exit_message = None set_options(self, result) from .commands import init_sub_command pp = init_sub_command(self) if pp is not self and result is not None: set_options(pp, result) if parse_only: return pp if exit_status is not None: if system_exit is SystemExit: if exit_status != 0: parser.print_usage(sys.stderr) print(exit_message, file=sys.stderr) sys.exit(exit_status) elif isinstance(system_exit, type) and issubclass(system_exit, BaseException): raise system_exit(exit_status) else: sys.exit(exit_status) pp.run() return pp
[docs] def run(self): """called after :meth:`~argclz.core.ArgumentParser.main()`. Used for runs the main execution logic of the object""" pass
def __str__(self): return type(self).__name__ def __repr__(self): """print key value pair content""" self_type = type(self) ret = [] for a_name in dir(self_type): a_value = getattr(self_type, a_name) if isinstance(a_value, Argument) and not a_name.startswith('_'): try: ret.append(f'{a_name} = {a_value.__get__(self, self_type)}') except: ret.append(f'{a_name} = <error>') elif isinstance(a_value, property): try: ret.append(f'{a_name} = {a_value.__get__(self, self_type)}') except: ret.append(f'{a_name} = <error>') return '\n'.join(ret)
[docs] class Argument(object): """ (internal) Do not use class directly. Commandline argument descriptor (https://docs.python.org/3/glossary.html#term-descriptor). Carried the arguments pass to ``argparse.ArgumentParser.add_argument``. """
[docs] def __init__(self, *options, validator: Callable[[T], bool] | None = None, group: str | None = None, ex_group: str | None = None, hidden: bool = False, **kwargs): """ :param options: options :param validator: argument validator. :param group: argument group. :param ex_group: mutually exclusive group. :param hidden: hide this argument from help document :param kwargs: """ from .validator import Validator if len(options) > 0 and isinstance(options[-1], Validator): if validator is not None: raise RuntimeError() validator = options[-1] options = options[:-1] if not all([it.startswith('-') for it in options]): raise RuntimeError(f'options should startswith "-". {options}') if isinstance(validator, Validator): validator = validator.freeze() self.attr = None self.attr_type = Any self.group = group self.ex_group = ex_group self.validator = validator self.options = options self.hidden = hidden self._kwargs = kwargs # original kwargs self.kwargs = dict(kwargs)
@property def default(self): try: return self.kwargs['default'] except KeyError: pass if self.attr_type == bool: return self.kwargs.get('action', 'store_true') != 'store_true' if self.kwargs.get('action', None) in ('append', 'extend', 'append_const'): return [] raise ValueError @property def const(self): try: return self.kwargs['const'] except KeyError: pass if self.attr_type == bool: return self.kwargs.get('action', 'store_true') == 'store_true' raise ValueError @property def metavar(self) -> str | None: return self.kwargs.get('metavar', None) @property def choices(self) -> tuple[Any, ...] | None: return self.kwargs.get('choices', None) @property def required(self) -> bool: return self.kwargs.get('required', False) @property def type(self) -> type | Callable[[str], Any] | None: try: return self.kwargs['type'] except KeyError: pass attr_type = self.attr_type if attr_type == bool: from .types import bool_type return bool_type elif attr_type in (str, int, float): return attr_type else: from ._types import caster_by_annotation assert self.attr is not None return caster_by_annotation(self.attr, attr_type) @property def help(self) -> str | None: return self.kwargs.get('help', None) def __set_name__(self, owner: Type, name: str): if self.attr is not None: raise RuntimeError('reuse Argument') self.attr = name self.attr_type = get_type_hints(owner).get(name, Any) from ._types import complete_arg_kwargs complete_arg_kwargs(self) if self.hidden: self.kwargs['help'] = argparse.SUPPRESS def __get__(self, instance, owner=None): if instance is None: if owner is not None: # ad-hoc for the document building self.__doc__ = self.help return self try: return instance.__dict__[f'__{self.attr}'] except KeyError: pass raise AttributeError(self.attr) def __set__(self, instance, value): if (validator := self.validator) is not None: from .validator import Validator, ValidatorFailError try: fail = not validator(value) except ValidatorFailError: raise except BaseException as e: raise ValueError('validator fail') from e else: if fail: raise ValueError('validator fail') instance.__dict__[f'__{self.attr}'] = value def __delete__(self, instance): try: del instance.__dict__[f'__{self.attr}'] except KeyError: pass
[docs] def add_argument(self, ap: argparse._ActionsContainer, instance): """Add this into `argparse.ArgumentParser`. :param ap: :param instance: :return: """ try: return ap.add_argument(*self.options, **self.kwargs, dest=self.attr) except TypeError as e: if isinstance(instance, type): name = instance.__name__ else: name = type(instance).__name__ raise RuntimeError(f'{name}.{self.attr} : ' + repr(e)) from e
[docs] def with_options(self, *options, **kwargs) -> Self: """Modify or update keyword parameter and return a new argument. option flags update rule: 1. ``()``, ``(...)`` : do not update options 2. ``('-a', '-b')`` : replace options 3. ``(..., '-c')`` : additional options 4. ``({'-a': '-A'})`` : rename options 5. ``({'-a': ...})`` : remove options 6. ``({'-a': '-A', '-b': ...}, '-c')`` : rename '-a', remove '-b', add '-c' general form: ``() | (dict?, ...?, *str)`` :param options: change option flags :param kwargs: change keyword parameters, use `...` to unset parameter :return: """ kw = dict(self._kwargs) # use original kwargs kw['group'] = self.group kw['ex_group'] = self.ex_group kw['validator'] = self.validator kw['hidden'] = self.hidden kw.update(kwargs) for k in list(kw.keys()): if kw[k] is ...: del kw[k] cls = type(self) if len(self.options) > 0: match options: case (): return cls(*self.options, **kw) case (e, *o) if e is ...: return cls(*self.options, *o, **kw) case (dict(d), ): return cls(*self._map_options(d), **kw) case (dict(d), *o): return cls(*self._map_options(d), *o, **kw) case _: return cls(*options, **kw) else: if len(options) > 0: raise RuntimeError('cannot change positional argument to optional') return cls(**kw)
def _map_options(self, mapping: dict[str, str]) -> list[str]: ret = [] for old_opt in self.options: try: new_opt = mapping[old_opt] except KeyError: ret.append(old_opt) else: if new_opt is not ...: ret.append(new_opt) return ret
@overload def argument(*options: str | Validator, action: Actions = ..., nargs: int | Nargs = ..., const: T = ..., default: T = ..., type: Type | Callable[[str], T] = ..., validator: Callable[[T], bool] = ..., choices: Sequence[T] = ..., required: bool = False, hidden: bool = False, help: str = ..., group: str | None = None, ex_group: str | None = None, metavar: str = ...) -> T: ...
[docs] def argument(*options: str | Validator, **kwargs) -> Any: r"""create an argument attribute **Usage** >>> class Example: ... a: str = argument('-a') **Type (caster)** The parameter ``type`` usually can be infered via the annotation of target attribute, like the `a: str` in above example. There are some special rules to treat common type: - **Literal types**: ``Literal[...]`` annotations create a caster that restricts values to the specified literals. - **Boolean flags**: annotating with ``bool`` automatically sets up a `store_true` or `store_false` action, making the option a flag. - **Container types**: ``list[T]`` annotations infer a repeated option with `append` (or `extend`) action, converting each input to type ``T``. - **Tuple types**: tuple annotations (e.g. ``tuple[int, ...]``) use a comma-separated parser to split and convert values into a tuple of ``T``. If the default type infered does not fit your application, you can give it directly. **Validator** Although parameter ``type`` can handle some validation works, it is ignored when user assign value to the attribute directly. The parameter ``validator`` is used to perform the assignment validation work and raise a ``ValueError`` (normal case) if any improper assignments. The parameter ``validator`` has been treated specially when use :attr:`~argclz.validator`, which it can put at lastest position of ``options`` >>> from argclz import argument, validator >>> class Example: ... a: str = argument('-a', validator.str.match(r'\d+')) :param options: options strings :param action: argument action. Please see ``argparse.ArgumentParser.add_argument(action)`` for detailed. :param nargs: number of following values. Please see ``argparse.ArgumentParser.add_argument(nargs)`` for detailed. :param const: Please see ``argparse.ArgumentParser.add_argument(const)`` for detailed. :param default: default value of argument. Please see ``argparse.ArgumentParser.add_argument(default)`` for detailed. :param type: type caster with signature ``(str) -> T``. Please see ``argparse.ArgumentParser.add_argument(type)`` for detailed. :param validator: value validator with signature ``(T) -> bool``. :param choices: Please see ``argparse.ArgumentParser.add_argument(choices)`` for detailed. :param required: Please see ``argparse.ArgumentParser.add_argument(required)`` for detailed. :param hidden: hide this argument from help document. :param help: help document for this argument. :param group: group name of this argument. :param ex_group: the mutually exclusive group name of this argument. :param metavar: name of argument value. Please see ``argparse.ArgumentParser.add_argument(metavar)`` for detailed. """ return Argument(*options, **kwargs)
@overload def pos_argument(option: str, validator: Callable[[T], bool] = ..., *, nargs: Nargs | None = None, action: Actions = ..., const: T = ..., default: T = ..., type: Type | Callable[[str], T] = ..., choices: Sequence[str] = ..., required: bool = False, help: str = ...) -> T: ...
[docs] def pos_argument(option: str, validator: Callable[[T], bool] | None = None, *, nargs=None, **kwargs) -> Any: """create a positional (non-flag) command-line argument attribute. **Usage** >>> class Example: ... a: str = pos_argument('A') shorten for ``argument(metavar=option, nargs=nargs, validator=validator, **kwargs)`` :param option: The name for the positional argument shown in usage messages :param validator: value validator with signature ``(T) -> bool``. :param nargs: number of following values. Please see ``argparse.ArgumentParser.add_argument(nargs)`` for detailed. :param action: argument action. Please see ``argparse.ArgumentParser.add_argument(action)`` for detailed. :param const: Please see ``argparse.ArgumentParser.add_argument(const)`` for detailed. :param default: default value of argument. Please see ``argparse.ArgumentParser.add_argument(default)`` for detailed. :param type: type caster with signature ``(str) -> T``. Please see ``argparse.ArgumentParser.add_argument(type)`` for detailed. :param choices: Please see ``argparse.ArgumentParser.add_argument(choices)`` for detailed. :param required: Please see ``argparse.ArgumentParser.add_argument(required)`` for detailed. :param help: help document for this argument. """ if validator is not None: kwargs['validator'] = validator return Argument(metavar=option, nargs=nargs, **kwargs)
@overload def var_argument(option: str, validator: Callable[[T], bool] = ..., *, nargs: Nargs = ..., action: Actions = ..., type: Type | Callable[[str], T] = ..., help: str = ...) -> list[T]: ...
[docs] def var_argument(option: str, validator: Callable[[T], bool] | None = None, *, nargs='*', action='extend', **kwargs) -> Any: """ create a variable-length positional argument, suitable for capturing multiple values into a list. **Usage** >>> class Example: ... a: list[str] = var_argument('A') shorten for ``argument(metavar=option, nargs='*', action='extend', validator=validator, **kwargs)`` by default. :param option: The name for the vary-length positional argument shown in usage messages :param validator: value validator with signature ``(T) -> bool``. :param nargs: number of following values. Please see ``argparse.ArgumentParser.add_argument(nargs)`` for detailed. :param action: argument action. Please see ``argparse.ArgumentParser.add_argument(action)`` for detailed. :param type: type caster with signature ``(str) -> T``. Please see ``argparse.ArgumentParser.add_argument(type)`` for detailed. :param help: help document for this argument. """ if validator is not None: kwargs['validator'] = validator return Argument(metavar=option, nargs=nargs, action=action, **kwargs)
class AliasArgument(Argument): """ (internal) Do not use class directly. """ def __init__(self, *options, aliases: dict[str, Any], **kwargs): super().__init__(*options, **kwargs) self.aliases = aliases def add_argument(self, ap: argparse._ActionsContainer, instance): gp = ap.add_mutually_exclusive_group(required=self.required) result = super().add_argument(gp, instance) assert len(self.options) > 0 primary = self.options[0] for name, values in self.aliases.items(): kw = dict(self.kwargs) kw.pop('metavar', None) kw.pop('type', None) kw['action'] = 'store_const' kw['const'] = values kw['help'] = f'short for {primary}={values}.' gp.add_argument(name, **kw, dest=self.attr) return result @overload def aliased_argument(options: str, *, aliases: dict[str, T], nargs: Nargs = ..., action: Actions = ..., const: T = ..., default: T = ..., type: Type | Callable[[str], T] = ..., validator: Callable[[T], bool] = ..., choices: Sequence[str] = ..., help: str = ..., group: str | None = None, ex_group: str | None = None, metavar: str = ...) -> T: ...
[docs] def aliased_argument(*options: str, aliases: dict[str, T], **kwargs) -> Any: """ create an argument that supports shorthand aliases for specific constant values. **Usage** >>> class Example: ... level: str = aliased_argument( ... '--level', ... aliases={ ... '--low': 'low', ... '--mid': 'middle', ... '--high': 'high', ... }, ... choices=('low', 'middle', 'high') ... ) :param options: options strings :param aliases: a dictionary maps options to value. :param action: argument action. Please see ``argparse.ArgumentParser.add_argument(action)`` for detailed. :param nargs: number of following values. Please see ``argparse.ArgumentParser.add_argument(nargs)`` for detailed. :param const: Please see ``argparse.ArgumentParser.add_argument(const)`` for detailed. :param default: default value of argument. Please see ``argparse.ArgumentParser.add_argument(default)`` for detailed. :param type: type caster with signature ``(str) -> T``. Please see ``argparse.ArgumentParser.add_argument(type)`` for detailed. :param validator: value validator with signature ``(T) -> bool``. :param choices: Please see ``argparse.ArgumentParser.add_argument(choices)`` for detailed. :param required: Please see ``argparse.ArgumentParser.add_argument(required)`` for detailed. :param hidden: hide this argument from help document. :param help: help document for this argument. :param group: group name of this argument. :param ex_group: the mutually exclusive group name of this argument. :param metavar: name of argument value. Please see ``argparse.ArgumentParser.add_argument(metavar)`` for detailed. """ return AliasArgument(*options, aliases=aliases, **kwargs)
[docs] def as_argument(a) -> Argument: """cast argument attribute as an :class:`~argclz.core.Argument` for type checking framework/IDE.""" if isinstance(a, Argument): return a raise TypeError
[docs] def foreach_arguments(instance: T | Type[T]) -> Iterable[Argument]: """iterating all argument attributes in instance. :param instance: any instance that contains ``argument``. :return: """ if isinstance(instance, type): clazz = instance else: clazz = type(instance) arg_set = set() for clz in reversed(clazz.mro()): if (ann := getattr(clz, '__annotations__', None)) is not None: for attr in ann: if isinstance((arg := getattr(clazz, attr, None)), Argument) and attr not in arg_set: arg_set.add(attr) yield arg
[docs] def new_parser(instance: T | Type[T], reset=False, **kwargs) -> ArgumentParser: """Create ``ArgumentParser`` for instance. :param instance: any instance that contains ``argument``. :param reset: reset argument attributes. do nothing if *instance* isn't an instance. :param kwargs: Please see ``argparse.ArgumentParser(**kwargs)`` for detailed. :return: """ if isinstance(instance, AbstractParser) or (isinstance(instance, type) and issubclass(instance, AbstractParser)): usage = instance.USAGE if isinstance(usage, list): usage = '\n '.join(usage) kwargs.setdefault('usage', usage) description = instance.DESCRIPTION if callable(description): description = description() kwargs.setdefault('description', description) epilog = instance.EPILOG if callable(epilog): epilog = epilog() kwargs.setdefault('epilog', epilog) kwargs.setdefault('formatter_class', argparse.RawTextHelpFormatter) ap = ArgumentParser(**kwargs) groups: dict[str, list[Argument]] = collections.defaultdict(list) from .commands import get_sub_command_group if (sub := get_sub_command_group(instance)) is not None: sub.add_parser(ap) # setup non-grouped arguments mu_ex_groups: dict[str, argparse._MutuallyExclusiveGroup] = {} for arg in foreach_arguments(instance): if instance is not None and not isinstance(instance, type) and reset: arg.__delete__(instance) if arg.group is not None: groups[arg.group].append(arg) continue elif arg.ex_group is not None: try: tp: argparse._ActionsContainer = mu_ex_groups[arg.ex_group] except KeyError: # XXX current Python does not support add title and description into mutually exclusive group # so the message in ex_group is dropped. mu_ex_groups[arg.ex_group] = ap.add_mutually_exclusive_group() tp = mu_ex_groups[arg.ex_group] if arg.required: mu_ex_groups[arg.ex_group].required = True else: tp = ap arg.add_argument(tp, instance) # setup grouped arguments for group, args in groups.items(): pp = ap.add_argument_group(group) mu_ex_groups_grp: dict[str, argparse._MutuallyExclusiveGroup] = {} for arg in args: if arg.ex_group is not None: try: tp = mu_ex_groups_grp[arg.ex_group] except KeyError: mu_ex_groups_grp[arg.ex_group] = pp.add_mutually_exclusive_group() tp = mu_ex_groups_grp[arg.ex_group] if arg.required: mu_ex_groups_grp[arg.ex_group].required = True else: tp = pp arg.add_argument(tp, instance) return ap
[docs] def set_options(instance: T, result: argparse.Namespace) -> T: """set argument to ``instance``'s attributes from ``argparse.Namespace`` . :param instance: any instance that contains ``argument``. :param result: :return: *instance* itself. """ for arg in foreach_arguments(instance): assert arg.attr is not None try: value = getattr(result, arg.attr) except AttributeError: pass else: arg.__set__(instance, value) from .commands import get_sub_command_group if (sub := get_sub_command_group(instance)) is not None: assert sub.attr is not None try: value = getattr(result, sub.attr) except AttributeError: sub.__set__(instance, None) else: sub.__set__(instance, value) return instance
[docs] def parse_args(instance: T, args: list[str] | None = None) -> T: """Parse the command-list arguments and apply the parsed values to the given instance :param instance: any instance that contains ``argument``. :param args: A list of strings representing command-line arguments. If ``None``, uses ``sys.argv`` :return: ``instance`` itself, with attributes populated """ ap = new_parser(instance, reset=True) ot = ap.parse_args(args) return set_options(instance, ot)
@overload def print_help(instance, file: TextIO = sys.stdout, prog: str | None = None) -> None: pass @overload def print_help(instance, file: Literal[None], prog: str | None = None) -> str: pass
[docs] def with_defaults(instance: T) -> T: """Initialize all argument attributes by assign the default value if provided. :param instance: any instance that contains ``argument``. :return: *instance* itself, with attributes initialized with proper default. """ for arg in foreach_arguments(instance): try: value = arg.default except ValueError: arg.__delete__(instance) else: arg.__set__(instance, value) from .commands import get_sub_command_group if (sub := get_sub_command_group(instance)) is not None: sub.__set__(instance, None) return instance
[docs] def as_dict(instance: list[T] | T) -> list[dict[str, Any]] | dict[str, Any]: """ Collect all argument attributes into a dictionary with attribute name to its value. It *instance* is a list, it works like ``list(map(as_dict, instance))``. :param instance: any instance that contains ``argument``. :return: A dictionary mapping argument attribute names to their values """ if isinstance(instance, list): if len(instance) == 0: return [] return cast(list[dict[str, Any]], list(map(as_dict, instance))) ret = {} for arg in foreach_arguments(instance): assert arg.attr is not None try: value = arg.__get__(instance) except AttributeError: pass else: ret[arg.attr] = value from .commands import get_sub_command_group if (sub := get_sub_command_group(instance)) is not None: assert sub.attr is not None try: value = sub.__get__(instance) except AttributeError: pass else: ret[sub.attr] = value return ret
[docs] def copy_argument(opt: T, ref, **kwargs) -> T: """copy argument from ref to opt :param opt: any instance that contains ``argument``. :param ref: any instance that contains ``argument``. :param kwargs: overwrite argument value mapping. :return: ``opt`` itself. """ shadow = ShadowOption(ref, **kwargs) for arg in foreach_arguments(opt): assert arg.attr is not None try: value = getattr(shadow, arg.attr) except AttributeError: pass else: # print('set', arg.attr, value) arg.__set__(opt, value) from .commands import get_sub_command_group if (sub := get_sub_command_group(opt)) is not None: assert sub.attr is not None try: value = getattr(shadow, sub.attr) except AttributeError: pass else: sub.__set__(opt, value) return opt
class ShadowOption: """ (internal) Do not use class directly. Shadow options, used to pass wrapped :class:`AbstractOptions` """ def __init__(self, ref, **kwargs): self.__ref = ref self.__kwargs = kwargs def __getattr__(self, attr: str): if attr in self.__kwargs: return self.__kwargs[attr] if attr.startswith('_') and attr[1:] in self.__kwargs: return self.__kwargs[attr[1:]] if hasattr(self.__ref, attr): return getattr(self.__ref, attr) raise AttributeError(attr)