Source code for zensols.cli.meta
"""Domain classes for parsing the command line.
"""
__author__ = 'Paul Landes'
from typing import Tuple, Dict, Any, Type, Callable
from dataclasses import dataclass, field
from enum import Enum
import logging
import sys
from io import TextIOBase
from pathlib import Path
import optparse
from frozendict import frozendict
from zensols.util import Failure
from zensols.introspect import TypeMapper, IntegerSelection
from zensols.persist import persisted, PersistableContainer
from zensols.config import Dictable
from . import ActionCliError
logger = logging.getLogger(__name__)
[docs]
class ApplicationError(ActionCliError):
"""Thrown for any application error that should result in a user error
rather than produce a full stack trace.
"""
pass
[docs]
@dataclass
class ApplicationFailure(Failure, Dictable):
"""Contains information for application invocation failures used by
programatic methods.
"""
[docs]
def raise_exception(self):
"""Raise the contained exception. The exception will include
:obj:`message` if available.
:throws ApplicationError: every time for the contained exception
"""
raise ApplicationError(str(self)) from self.exception
def __str__(self):
return self.message if self.message is not None else str(self.exception)
[docs]
def apperror(method: Callable = None, *,
exception: Type[Exception] = Exception):
"""A decorator that rethrows any method's exception as an
:class:`.ApplicationError`. This is helpful for application classes that
should print a usage rather than an exception stack trace.
An optional exception parameter can be provided so the exception is rethrown
for only certain caught exceptions.
"""
def no_args(*args, **kwargs):
if method is not None:
ref = args[0]
try:
return method(ref, *args[1:], **kwargs)
except exception as e:
raise ApplicationError(str(e)) from e
else:
def with_args(*wargs, **wkwargs):
try:
return args[0](wargs[0], *wargs[1:], **wkwargs)
except exception as e:
raise ApplicationError(str(e)) from e
return with_args
return no_args
@dataclass
class _MetavarFormatter(object):
"""Formats the meta variable string for options. This is the data type or
example printed next to the argument or option in the usage help.
"""
def __post_init__(self):
is_enum: bool
try:
is_enum = issubclass(self.dtype, Enum) and self.choices is None
except Exception:
is_enum = False
if is_enum:
self.choices = tuple(sorted(self.dtype.__members__.keys()))
else:
self.choices = ()
if self.metavar is None:
self._set_metvar()
@property
def is_choice(self) -> bool:
"""Whether or not this option represents string combinations that map to
a :class:`enum.Enum` Python class.
"""
return len(self.choices) > 0
def _set_metvar(self) -> str:
if self.is_choice:
metavar = f"<{'|'.join(self.choices)}>"
elif self.dtype == Path:
metavar = 'FILE'
elif self.dtype == IntegerSelection:
metavar = f'INT[,INT|{IntegerSelection.INTERVAL_DELIM}INT]'
elif self.dtype == bool:
metavar = None
elif self.dtype == str:
metavar = 'STRING'
else:
metavar = self.dtype.__name__.upper()
if logger.isEnabledFor(logging.DEBUG):
logger.debug(f'metavar recompute using {self.dtype}: ' +
f'{metavar}, {self.choices}')
self.metavar = metavar
[docs]
@dataclass(eq=True, order=True, unsafe_hash=True)
class OptionMetaData(PersistableContainer, Dictable, _MetavarFormatter):
"""A command line option."""
DATA_TYPES = frozenset(TypeMapper.DEFAULT_DATA_TYPES.values())
"""Supported data types."""
long_name: str = field()
"""The long name of the option (i.e. ``--config``)."""
short_name: str = field(default=None)
"""The short name of the option (i.e. ``-c``)."""
dest: str = field(default=None, repr=False)
"""The the field/parameter name used to on the target class."""
dtype: type = field(default=str)
"""The data type of the option (i.e. :class:`str`).
Other types include: :class:`int`, :class`float`, :class:`bool`,
:class:`list` (for choice), or :class:`patlib.Path` for files and
directories.
"""
choices: Tuple[str, ...] = field(default=None)
"""The constant list of choices when :obj:`dtype` is :class:`list`. Note
that this class is a tuple so instances are hashable in :class:`.ActionCli`.
"""
default: str = field(default=None)
"""The default value of the option."""
doc: str = field(default=None)
"""The document string used in the command line help."""
metavar: str = field(default=None, repr=False)
"""Used in the command line help for the type of the option."""
def __post_init__(self):
if self.dest is None:
self.dest = self.long_name
_MetavarFormatter.__post_init__(self)
def _str_vals(self) -> Tuple[str, str, str]:
default = self.default
choices = None
tpe = {str: 'string',
int: 'int',
float: 'float',
bool: None,
Path: None,
list: 'choice'}.get(self.dtype)
if tpe is None and self.is_choice:
tpe = 'choice'
choices = self.choices
# use the string value of the default if set from the enum
if isinstance(default, Enum):
default = default.name
elif (default is not None) and (self.dtype != bool):
default = str(default)
return tpe, default, choices
@property
def default_str(self) -> str:
"""Get the default as a string usable in printing help and as a default
using the :class:`optparse.OptionParser` class.
"""
return self._str_vals()[1]
@property
def long_option(self) -> str:
"""The long option string with dashes."""
return f'--{self.long_name}'
@property
def short_option(self) -> str:
"""The short option string with dash."""
return None if self.short_name is None else f'-{self.short_name}'
@property
def shortest_option(self) -> str:
"""The shortest option (or long if no short option) with dash."""
opt: str = self.short_option
return self.long_option if opt is None else opt
[docs]
def create_option(self) -> optparse.Option:
"""Add the option to an option parser.
:param parser: the parser to populate
"""
params = {}
tpe, default, choices = self._str_vals()
if choices is not None:
params['choices'] = choices
# only set the default if given, as default=None is not the same as a
# missing default when adding the option
if default is not None:
params['default'] = default
if tpe is not None:
params['type'] = tpe
if self.dtype == list:
params['choices'] = self.choices
if self.doc is not None:
params['help'] = self.doc
for att in 'metavar dest'.split():
v = getattr(self, att)
if v is not None:
params[att] = v
if self.dtype == bool:
if self.default is True:
params['action'] = 'store_false'
else:
params['action'] = 'store_true'
if logger.isEnabledFor(logging.DEBUG):
logger.debug(f'params: {params}')
return optparse.Option(self.long_option, self.short_option, **params)
def _from_dictable(self, *args, **kwargs) -> Dict[str, Any]:
dct = super()._from_dictable(*args, **kwargs)
dct['dtype'] = self.dtype
if not self.is_choice:
del dct['choices']
else:
if self.default is not None:
dct['default'] = self.default.name
dct = {k: dct[k] for k in
filter(lambda x: dct[x] is not None, dct.keys())}
return dct
[docs]
def write(self, depth: int = 0, writer: TextIOBase = sys.stdout):
dct = self.asdict()
del dct['long_name']
self._write_line(self.long_name, depth, writer)
self._write_object(dct, depth + 1, writer)
[docs]
@dataclass
class PositionalMetaData(Dictable, _MetavarFormatter):
"""A command line required argument that has no option switches.
"""
name: str = field()
"""The name of the positional argument. Used in the documentation and when
parsing the type.
"""
dtype: Type = field(default=str)
"""The type of the positional argument.
:see: :obj:`.Option.dtype`
"""
doc: str = field(default=None)
"""The documentation of the positional metadata or ``None`` if missing.
"""
choices: Tuple[str, ...] = field(default=None)
"""The constant list of choices when :obj:`dtype` is :class:`list`. Note
that this class is a tuple so instances are hashable in :class:`.ActionCli`.
"""
metavar: str = field(default=None, repr=False)
"""Used in the command line help for the type of the option."""
def __post_init__(self):
_MetavarFormatter.__post_init__(self)
[docs]
class OptionFactory(object):
"""Creates commonly used options.
"""
[docs]
@classmethod
def dry_run(cls: type, **kwargs) -> OptionMetaData:
"""A boolean dry run option."""
return OptionMetaData('dry_run', 'd', dtype=bool,
doc="don't do anything; just act like it",
**kwargs)
[docs]
@classmethod
def file(cls: type, name: str, short_name: str, **kwargs):
"""A file :class:`~pathlib.Path` option."""
return OptionMetaData(name, short_name, dtype=Path,
doc=f'the path to the {name} file',
**kwargs)
[docs]
@classmethod
def directory(cls: type, name: str, short_name: str, **kwargs):
"""A directory :class:`~pathlib.Path` option."""
return OptionMetaData(name, short_name, dtype=Path,
doc=f'the path to the {name} directory',
**kwargs)
[docs]
@classmethod
def config_file(cls: type, **kwargs) -> OptionMetaData:
"""A subordinate file based configuration option."""
return cls.file('config', 'c', **kwargs)
[docs]
@dataclass
class ActionMetaData(PersistableContainer, Dictable):
"""An action represents a link between a command line mnemonic *action* and
a method on a class to invoke.
"""
_DICTABLE_WRITABLE_DESCENDANTS = True
name: str = field(default=None)
"""The name of the action, which is also the mnemonic used on the command
line.
"""
doc: str = field(default=None)
"""A short human readable documentation string used in the usage."""
options: Tuple[OptionMetaData, ...] = field(default_factory=lambda: ())
"""The command line options for the action."""
positional: Tuple[PositionalMetaData, ...] = \
field(default_factory=lambda: ())
"""The positional arguments expected for the action."""
first_pass: bool = field(default=False)
"""If ``True`` this is a first pass action that is used with no mnemonic.
Examples include the ``-w``/``--whine`` logging level, which applies to the
entire application and can be configured in a separate class/process from
the main single action given as a mnemonic on the command line.
"""
is_usage_visible: bool = field(default=True)
"""Whether to display the action in the help usage. Applications such as
:class:`.ConfigFactoryAccessor`, which is only useful with a programatic
usage, is an example of where this is used.
"""
def __post_init__(self):
if self.first_pass and len(self.positional) > 0:
raise ActionCliError(
'A first pass action can not have positional arguments, ' +
f'but got {self.positional} for action: {self.name}')
@property
@persisted('_options_by_dest')
def options_by_dest(self) -> Dict[str, OptionMetaData]:
return frozendict({m.dest: m for m in self.options})
def _from_dictable(self, *args, **kwargs) -> Dict[str, Any]:
dct = super()._from_dictable(*args, **kwargs)
if len(dct['positional']) == 0:
del dct['positional']
return dct