"""Utility classes to write command line help.
:see: :class:`.UsageActionOptionParser`
"""
from __future__ import annotations
__author__ = 'Paul Landes'
from typing import (
Tuple, Iterable, List, Union, Optional, Sequence, Set, ClassVar
)
from dataclasses import dataclass, field
import logging
import os
import sys
import re
from itertools import chain
from pathlib import Path
from io import TextIOBase
from optparse import OptionParser
from zensols.util import APIError
from zensols.introspect import IntegerSelection
from zensols.config import Writable, Dictable
from zensols.persist import persisted
from . import OptionMetaData, ActionMetaData, PositionalMetaData
logger = logging.getLogger(__name__)
[docs]
@dataclass
class UsageConfig(Dictable):
"""Configuraiton information for the command line help.
"""
width: int = field(default=None)
"""The max width to print help."""
max_first_col: Union[float, int] = field(default=0.4)
"""Maximum width of the first column. If this is a float, then it is
computed as a percentage of the terminal width.
"""
max_metavar_len: Union[float, int] = field(default=0.15)
"""Max length of the option type."""
max_default_len: Union[float, int] = field(default=0.1)
"""Max length in characters of the default value."""
left_indent: int = field(default=2)
"""The number of left spaces for the option and positional arguments."""
inter_col_space: int = field(default=2)
"""The number of spaces between all three columns."""
sort_actions: bool = field(default=False)
"""If ``True`` sort mnemonic output."""
doc: str = field(default=None)
"""Overrides the application help documentation."""
def __post_init__(self):
if self.width is None:
try:
self.width = os.get_terminal_size()[0]
except OSError as e:
if logger.isEnabledFor(logging.DEBUG):
logger.debug(f'can not get terminal size: {e}')
self.width = 0
if self.width == 0:
self.width = 80
if self.max_first_col is None:
self.max_first_col = 0.4
if isinstance(self.max_first_col, float):
self.max_first_col = int(self.width * self.max_first_col)
if isinstance(self.max_metavar_len, float):
self.max_metavar_len = int(self.width * self.max_metavar_len)
if isinstance(self.max_default_len, float):
self.max_default_len = int(self.width * self.max_default_len)
[docs]
class UsageActionOptionParser(OptionParser):
"""Implements a human readable implementation of :meth:`print_help` for
action based command line handlers.
Each action is described with the full documentation with ``--help``.
However, the short option version (``-h``) creates a much (GNU style)
summarization of the command and actions.
If an action or several actions are given with either help flag, only that
action usage and documentation is printed.
**Implementation note**: we have to extend :class:`~optparser.OptionParser`
since the ``-h`` option invokes the print help behavior and then exists
printing the second pass action options. Instead, we look for the help
option in the first pass, print help with the correction options, then
exit.
"""
[docs]
def __init__(self, actions: Tuple[ActionMetaData, ...],
options: Tuple[OptionMetaData, ...], usage_config: UsageConfig,
doc: str = None, default_action: str = None, *args, **kwargs):
super().__init__(*args, add_help_option=False, **kwargs)
help_op = OptionMetaData(
'help', 'h',
metavar='[actions]',
dtype=bool,
doc='show this help message and exit')
version_op = OptionMetaData(
'version', None, dtype=bool,
doc='show the program version and exit')
options = [help_op, version_op] + list(options)
if usage_config.doc is not None:
doc = usage_config.doc
self._usage_writer = _UsageWriter(
parser=self,
actions=actions,
global_options=options,
doc=doc,
usage_config=usage_config,
default_action=default_action)
self.add_option(help_op.create_option())
[docs]
def print_help(self, file: TextIOBase = sys.stdout,
include_actions: bool = True,
action_metas: Sequence[ActionMetaData] = None,
action_format: str = False):
"""Write the usage information and help text.
:param include_actions: if ``True`` write each actions' usage as well
:param actions: the list of actions to output, or ``None`` for all
:param action_format: the action format, either ``short`` or ``long``
"""
self._usage_writer.write(
writer=file,
include_actions=include_actions,
action_metas=action_metas,
action_format=action_format)
@dataclass
class _Formatter(Writable):
"""A formattingn base class that has utility methods.
"""
_BACKTICKS_REGEX = re.compile(r"``([^`]+)``")
def _format_doc(self, doc: str = None) -> str:
doc = '' if doc is None else doc
doc = re.sub(self._BACKTICKS_REGEX, r'"\1"', doc)
return doc
def _write_one_col(self, text: str, depth: int, writer: TextIOBase):
text = self._trunc(text)
self._write_line(text, depth, writer)
def _write_three_col(self, a: str, b: str, c: str, depth: int,
writer: TextIOBase):
a = '' if a is None else a
b = '' if b is None else b
c = '' if c is None else c
w1 = self.usage_formatter.two_col_width
w2 = self.usage_formatter.three_col_width
a = self._trunc(a, self.usage_formatter.max_first_col)
fmt = '{:<' + str(w1) + '}{:<' + str(w2) + '}{}'
s = fmt.format(a, b, c)
sp = self._get_str_space(w1 + w2)
self._write_wrap(s, depth, writer, subsequent_indent=sp)
@dataclass
class _OptionFormatter(_Formatter):
"""Write the option, which includes the option name and documenation.
"""
usage_formatter: _UsageWriter
opt: OptionMetaData
usage_config: UsageConfig
def __post_init__(self):
self.WRITABLE_MAX_COL = self.usage_config.width
opt = self.opt
self.doc = self._format_doc(self.opt.doc)
left_indent: str = ' ' * self.usage_config.left_indent
max_olen: int = self.usage_config.max_metavar_len
sep: str = '' if opt.short_name is None else ', '
long_opt: str = opt.long_option
short_opt: str = '' if opt.short_option is None else opt.short_option
metavar: str = '' if opt.metavar is None else opt.metavar
mlen, over = self._get_min_default_len()
self._opt_str = f'{left_indent}{short_opt}{sep}{long_opt}'
if not issubclass(opt.dtype, IntegerSelection) and \
len(metavar) > max_olen:
if metavar.find('|') > -1:
metavar = metavar[1:-1]
if len(self.doc) > 0:
self.doc += ', '
self.doc += f"X is one of: {', '.join(metavar.split('|'))}"
self._opt_str += ' X'
else:
if len(self.doc) > 0:
self.doc += ', of '
self.doc += f'type {metavar}'
else:
self._opt_str += f' {metavar}'
if over:
self.doc += f' with default {self.opt.default_str}'
def _get_min_default_len(self) -> Tuple[Optional[int], bool]:
mdlen: int = None
over: bool = False
if self.opt.default is not None and self.opt.dtype != bool:
mdlen: int = self.usage_config.max_default_len
over = (len(self.opt.default_str) + 3) > mdlen
return mdlen, over
@property
def default(self) -> str:
mlen, over = self._get_min_default_len()
if mlen is not None:
s: str = self.opt.default_str
if over:
s = s[:mlen] + '...'
return s
else:
return ''
def add_first_col_width(self, widths: List[int]):
widths.append(len(self._opt_str))
def write(self, depth: int = 0, writer: TextIOBase = sys.stdout):
self._write_three_col(
self._opt_str, self.default, self.doc, depth, writer)
@dataclass
class _PositionalFormatter(_Formatter):
usage_formatter: _UsageFormatter
pos: PositionalMetaData
def __post_init__(self):
self.WRITABLE_MAX_COL = self.usage_formatter.usage_config.width
spl = self.usage_formatter.writer.usage_config.left_indent
sp = self._get_str_space(spl)
mv = ''
if self.pos.metavar is not None:
mv = f' {self.pos.metavar}'
self.name = f'{sp}{self.pos.name}{mv}'
self.doc = self._format_doc(self.pos.doc)
def add_first_col_width(self, widths: List[int]):
widths.append(len(self.name))
def write(self, depth: int = 0, writer: TextIOBase = sys.stdout):
self._write_three_col(self.name, '', self.doc, depth, writer)
@dataclass
class _ActionFormatter(_Formatter):
"""Write the action, which includes the name, positional arguments, and
documentation in one line, then the options afterward.
"""
usage_formatter: _UsageFormatter
action: ActionMetaData
usage_config: UsageConfig = field()
action_name: str = field(default=None)
opts: Tuple[_OptionFormatter] = field(default=None)
pos: Tuple[_PositionalFormatter] = field(default=None)
def __post_init__(self):
self.WRITABLE_MAX_COL = self.usage_config.width
self.opts = tuple(map(
lambda of: _OptionFormatter(
self.usage_formatter, of, self.usage_config),
self.action.options))
self.pos = tuple(map(
lambda pos: _PositionalFormatter(self.usage_formatter, pos),
self.action.positional))
self.doc = self._format_doc(self.action.doc)
@property
@persisted('_position_args_str')
def position_args_str(self) -> Optional[str]:
if len(self.action.positional) > 0:
pargs = ' '.join(map(lambda p: p.name, self.action.positional))
return f'<{pargs}>'
@property
@persisted('_action_desc')
def action_desc(self) -> str:
is_def: bool = self.usage_formatter.default_action == self.action.name
action_name: str = self.action.name
pos_args: Optional[str] = self.position_args_str
if pos_args is not None:
action_name = f'{action_name} {pos_args}'
if is_def:
action_name = f'{action_name} (default)'
return action_name
def add_first_col_width(self, widths: List[int]):
widths.append(len(self.action_desc))
for of in self.opts:
of.add_first_col_width(widths)
for pos in self.pos:
pos.add_first_col_width(widths)
def write(self, depth: int = 0, writer: TextIOBase = sys.stdout,
format: str = 'long'):
if format == 'long':
self._write_three_col(
self.action_desc, '', self.doc, depth, writer)
for pos in self.pos:
self._write_object(pos, depth, writer)
for opt in self.opts:
self._write_object(opt, depth, writer)
elif format == 'short':
self._write_one_col(self.action_desc, depth, writer)
else:
raise APIError(f'No such action format: {format}')
@dataclass
class _UsageFormatter(_Formatter):
"""Write the global options and all actions.
"""
writer: _UsageWriter
actions: Tuple[ActionMetaData, ...]
usage_config: UsageConfig
global_options: Tuple[OptionMetaData, ...]
glob_option_formatters: List[_OptionFormatter] = field(default=None)
action_formatters: List[_ActionFormatter] = field(default=None)
pos_formatters: List[_PositionalFormatter] = field(default=None)
def __post_init__(self):
self.WRITABLE_MAX_COL = self.usage_config.width
self.glob_option_formatters = list(
map(lambda o: _OptionFormatter(self, o, self.usage_config),
self.global_options))
self.action_formatters = list(
map(lambda a: _ActionFormatter(self, a, self.usage_config),
self.actions))
self.pos_formatters = []
if self.is_singleton_action:
for af in self.action_formatters:
self.glob_option_formatters.extend(af.opts)
self.pos_formatters.extend(af.pos)
self.action_formatters.clear()
@property
def is_singleton_action(self) -> bool:
return len(self.visible_actions) == 1
@property
def default_action(self) -> str:
return self.writer.default_action
@property
def max_first_col(self) -> int:
return self.writer.usage_config.max_first_col
def _get_opt_formatters(self) -> Iterable[_OptionFormatter]:
return chain.from_iterable(
[chain.from_iterable(
map(lambda f: f.opts, self.action_formatters)),
self.glob_option_formatters])
@property
@persisted('_two_col_width_pw')
def two_col_width(self) -> int:
widths = []
for af in self.action_formatters:
af.add_first_col_width(widths)
for go in self.glob_option_formatters:
go.add_first_col_width(widths)
for po in self.pos_formatters:
po.add_first_col_width(widths)
return max(widths) + self.usage_config.inter_col_space
@property
@persisted('_three_col_width_pw')
def three_col_width(self) -> int:
return max(len(a.default) for a in self._get_opt_formatters()) + \
self.usage_config.inter_col_space
@property
@persisted('_visible_actions')
def visible_actions(self) -> Tuple[ActionMetaData]:
return tuple(filter(lambda a: a.is_usage_visible, self.actions))
def get_option_usage_names(self, expand: bool = True) -> str:
actions: Tuple[ActionMetaData] = self.visible_actions
action_names: Tuple[str, ...] = tuple(map(lambda a: a.name, actions))
if len(action_names) > 1:
if expand:
names = '|'.join(action_names)
else:
names = 'actions'
if self.default_action is None:
opts = f"<{names}> "
else:
opts = f"[{names}] "
elif len(action_names) > 0:
if logger.isEnabledFor(logging.DEBUG):
logger.debug(f'action: {self.actions[0]}')
opts = ', '.join(map(lambda p: p.name, actions[0].positional))
if len(opts) > 0:
opts = f'<{opts}> '
else:
opts = ''
return opts
@property
def has_opts(self) -> bool:
return len(self.glob_option_formatters) > 0
def _write_options(self, depth: int, writer: TextIOBase) -> bool:
has_opts: bool = self.has_opts
if self.has_opts:
self._write_line('Options:', depth, writer)
for i, of in enumerate(self.glob_option_formatters):
of.write(depth, writer)
return has_opts
def _write_actions(self, depth: int, writer: TextIOBase,
action_metas: Sequence[ActionMetaData],
action_format: str):
def filter_action(f: _ActionFormatter) -> bool:
return f.action.is_usage_visible and \
(am_set is None or f.action.name in am_set)
is_short: bool = action_format == 'short'
am_set: Set[str] = None
if action_metas is not None:
am_set = set(map(lambda a: a.name, action_metas))
# get only visible actions
fmts: Tuple[_ActionFormatter] = tuple(filter(
filter_action, self.action_formatters))
n_fmt: int = len(fmts)
lead_str: str = ''
if is_short:
fmt: _ActionFormatter
for fmt in fmts:
blocks: List[str] = [fmt.action.name]
pos_args: Optional[str] = fmt.position_args_str
if pos_args is not None:
blocks.append(pos_args)
self.writer.write_short_usage(
depth=depth,
writer=writer,
opts=tuple(map(lambda f: f.opt, fmt.opts)),
usage=self._get_str_space(len(self.writer.USAGE_STR)),
start_blocks=blocks)
else:
if n_fmt > 0 and (action_metas is None or len(action_metas) == 0):
self._write_line('Actions:', depth, writer)
i: int
fmt: _ActionFormatter
for i, fmt in enumerate(fmts):
writer.write(lead_str)
fmt.write(depth, writer, format=action_format)
if i < n_fmt - 1:
self._write_empty(writer)
def write(self, depth: int = 0, writer: TextIOBase = sys.stdout,
include_singleton_positional: bool = True,
include_global_options: bool = True,
include_actions: bool = True,
action_metas: Sequence[ActionMetaData] = None,
action_format: str = 'long'):
if self.is_singleton_action and include_singleton_positional and \
len(self.pos_formatters) > 0:
self._write_line('Positional:', depth, writer)
for po in self.pos_formatters:
self._write_object(po, depth, writer)
if include_global_options or include_actions:
self._write_empty(writer)
if include_global_options:
if self._write_options(depth, writer) and include_actions and \
len(self.action_formatters) > 0:
self._write_empty(writer)
if include_actions:
self._write_actions(depth, writer, action_metas, action_format)
@dataclass
class _UsageWriter(_Formatter):
"""Generates the usage and help messages for an
:class:`optparse.OptionParser`.
"""
USAGE_STR: ClassVar[str] = 'Usage: '
parser: OptionParser = field()
"""Parses the command line in to primitive Python data structures."""
actions: Tuple[ActionMetaData, ...] = field()
"""The set of actions to document as a usage."""
global_options: Tuple[OptionMetaData, ...] = field()
"""Application level options (i.e. level, config, verbose etc)."""
doc: str = field()
"""The application document string."""
usage_config: UsageConfig = field(default_factory=UsageConfig)
"""Configuraiton information for the command line help."""
default_action: str = field(default=None)
"""The default mnemonic use when the user does not supply one."""
usage_formatter: _UsageFormatter = field(default=None)
"""The usage formatter used to generate the documentation."""
def __post_init__(self):
self.WRITABLE_MAX_COL = self.usage_config.width
if self.usage_config.sort_actions:
actions = sorted(self.actions, key=lambda a: a.name)
else:
actions = self.actions
self.usage_formatter = _UsageFormatter(
self, actions, self.usage_config, self.global_options)
@property
def program_name(self) -> str:
prog: str = '<python>'
if len(sys.argv) > 0:
prog_path: Path = Path(sys.argv[0])
prog = prog_path.name
return prog
def _get_short_option_str(self, opts: Tuple[OptionMetaData, ...]) -> str:
def filter_short(o: OptionMetaData) -> bool:
return o.dtype == bool and o.short_name is not None
def fmt_long(o: OptionMetaData) -> str:
if o.dtype == bool:
return f'[{o.shortest_option}]'
return f'[{o.shortest_option} {o.metavar}]'
shorts: str = '|'.join(map(lambda o: o.short_option,
filter(filter_short, opts)))
longs: str = ' '.join(map(fmt_long,
filter(lambda o: not filter_short(o), opts)))
if len(shorts) > 0:
shorts = f'[{shorts}]'
sp: str = ' ' if len(shorts) > 0 and len(longs) > 0 else ''
return shorts + sp + longs
def write_short_usage(self, depth: int, writer: TextIOBase,
opts: Tuple[OptionMetaData, ...],
usage: str = None,
start_blocks: Sequence[str] = None,
end_blocks: Sequence[str] = None):
def filter_short(o: OptionMetaData) -> bool:
return o.dtype == bool and o.short_name is not None
def fmt_long(o: OptionMetaData) -> str:
if o.dtype == bool:
return f'[{o.shortest_option}]'
return f'[{o.shortest_option} {o.metavar}]'
usage = self.USAGE_STR if usage is None else usage
usage_ind: int = len(usage)
sp: str = self._sp(depth)
option_sp: str = self._get_str_space(
usage_ind + len(self.program_name) + 1)
option_ind: int = len(option_sp)
shorts: str = '|'.join(map(
lambda o: o.short_option, filter(filter_short, opts)))
blocks: List[str] = [(usage + self.program_name)]
ind: int = 0
if start_blocks is not None:
blocks.extend(start_blocks)
if len(shorts) > 0:
blocks.append(f'[{shorts}]')
opt: OptionMetaData
for opt in filter(lambda o: not filter_short(o), opts):
blocks.append(fmt_long(opt))
if end_blocks is not None:
blocks.extend(end_blocks)
writer.write(sp)
block: str
for i, block in enumerate(blocks):
write_sp: bool = (i > 0)
if i > 0:
if i < len(blocks):
next_width: int = ind + len(blocks[i]) + 1
if next_width > self.WRITABLE_MAX_COL:
writer.write('\n')
writer.write(option_sp)
ind = option_ind
write_sp = False
if write_sp:
writer.write(' ')
ind += 1
writer.write(block)
ind += len(block)
writer.write('\n')
def _write_long_usage(self, depth: int, writer: TextIOBase):
prog: str = self.program_name
opt_usage: str = '[options]:'
opts = self.usage_formatter.get_option_usage_names()
usage = f'{self.USAGE_STR}{prog} {opts}{opt_usage}'
if len(usage) > (self.usage_config.width - len(opt_usage)):
opts = self.usage_formatter.get_option_usage_names(expand=False)
usage = f'{prog} {opts}{opt_usage}'
writer.write(usage)
self._write_empty(writer)
self._write_empty(writer)
def write(self, depth: int = 0, writer: TextIOBase = sys.stdout,
include_singleton_positional: bool = True,
include_global_options: bool = True,
include_actions: bool = True,
action_metas: Sequence[ActionMetaData] = None,
action_format: str = 'long'):
is_short: bool = action_format == 'short'
# if user specified help action(s) on the command line, only print the
# action(s)
if action_metas is None:
if is_short:
self.write_short_usage(
depth=depth,
writer=writer,
opts=(),
start_blocks=('[-h|--help]', '[--version]'))
self.write_short_usage(
depth=depth,
writer=writer,
opts=tuple(filter(
lambda o: o.long_name not in {'help', 'version'},
self.global_options)),
usage=self._get_str_space(len(self.USAGE_STR)),
start_blocks=(('<action>' if self.default_action is None
else '[action]'),),
end_blocks=('[action options]',))
else:
self._write_long_usage(depth, writer)
if self.doc is not None and not is_short:
doc = self._format_doc(self.doc)
self._write_wrap(doc, depth, writer)
self._write_empty(writer)
else:
include_global_options = False
if action_format == 'short':
include_singleton_positional = False
include_global_options = False
self.usage_formatter.write(
depth, writer,
include_singleton_positional=include_singleton_positional,
include_global_options=include_global_options,
include_actions=include_actions,
action_metas=action_metas,
action_format=action_format)