"""Tools for metamodern text interfaces."""
# TODO pager w/ hjkl, [shift+]space, ctrl+[b|f], tab through links (web/mail),
# mouse scroll & click on links
import argparse
import inspect
import logging
import os
import sys
# TODO import argcomplete
__all__ = ["application", "get_output_width", "get_dimensions"]
ESCAPE = "\x1b[{}m"
RESET = ESCAPE.format(0)
colors = "ergybmcwt" # grey red green yellow blue magenta cyan white & reset
attributes = "ldiuborcs" # lght drk itlc under blnk over rev conceal & strike
def get_output_width():
"""Return the best of env var `TTY_WIDTH`, current terminal width or 80."""
return int(os.environ.get("TTY_WIDTH", get_dimensions()[1]))
def get_dimensions():
"""Return a 2-tuple of terminal's height and width."""
from fcntl import ioctl
from struct import pack, unpack
from termios import TIOCGWINSZ
return unpack("HHHH", ioctl(0, TIOCGWINSZ, pack("HHHH", 0, 0, 0, 0)))[:2]
class Application:
"""
A command-line application framework.
Clean and simple command line interfaces.
>>> main = application("Hello", "says hello")
>>> @main.register()
... class Main:
... def setup(self, add_arg):
... add_arg("-t", "--tz", help="timezone e.g. PST")
... def run(self, stdin, log):
... print(f"Hello from {self.tz}")
>>> try:
... main("-t", "CET")
... except SystemExit:
... print("exited cleanly")
Hello from CET
exited cleanly
"""
handlers: dict
subparsers: argparse._SubParsersAction
_add_argument: None
_root: None
def __init__(self, name, description):
self.name = name
desc, _, epilog = description.partition("\n\n")
# version, people, license = self.get_metadata()
# epilog += (
# "- width may be forced by setting `TTY_WIDTH`\n"
# "- colors may be forced by setting `COLORS_OFF` "
# "or `COLORS_ON`\n\n"
# "Released under the {} by:\n".format(license)
# )
# for person, roles in sorted(people.items()):
# epilog += " {}: ".format(person)
# epilog += ", ".join(
# "<{}> ({})".format(address, role)
# for role, address in sorted(roles.items())
# )
parser = argparse.ArgumentParser(
prog=name,
description=desc,
epilog=epilog,
add_help=False,
formatter_class=HelpFormatter,
)
add_arg = parser.add_argument
add_arg("--help", action="store_true", help="print this help message and exit")
# add_arg(
# "--version",
# action="version",
# version=version,
# help="print version number and exit",
# )
add_arg(
"--color",
action="store_true",
default=bool(os.environ.get("COLORS_ON")),
help="force color output to a suspected non-tty",
)
add_arg(
"--page",
action="store_true",
default=bool(os.environ.get("PAGING_ON")),
help="force color output to a suspected non-tty",
)
add_arg(
"-m",
"--machine",
action="store_true", # TODO move to `cli`
help="machine listing for piping to grep, cut, etc.",
)
self.parser = parser
# def get_metadata(self):
# """
# return a three-tuple of app's package's version, people and license
# """
# dist = None
# for dist_name in listing.get_distributions(dependencies=True):
# dist = listing.get_distribution(dist_name)
# if self.name in dist.details.get("entry-points", {}).get(
# "console_scripts", []
# ):
# break
# dist = dist.details
# return dist["version"], dist["people"], dist["license"]
def register(self, alias=None):
"""
Register a [sub-]command.
Register a single class named `Main` for single-parser comand
mode. Register any other class name(s) for multi-parser,
sub-command mode.
"""
# TODO investigate metaclass alternative
def handler(cls):
"""Prepare decorated class by providing a modified."""
name = cls.__name__.lower()
desc = (inspect.getdoc(cls) or "").strip()
if name == "main":
parser = self.parser
else:
try:
self.handlers
self.subparsers
except AttributeError:
self.handlers = {}
self.subparsers = self.parser.add_subparsers(help="cmds")
sub_add = self.subparsers.add_parser
epilog = "see also `{} --help`".format(self.name)
aliases = [name[:1]] # FIXME handle names w/ same first letter
if alias:
aliases.append(alias)
parser = sub_add(
name,
aliases=aliases,
add_help=False,
formatter_class=HelpFormatter,
help=desc,
epilog=epilog,
)
parser.add_argument(
"--help", action="help", help="print this help message and exit"
)
parser.set_defaults(_cmd=name)
_cls = cls()
# TODO def add_completed_argument(*args, **kwargs):
# TODO """Add `completer` kwarg to proxied `add_argument()`."""
# TODO completer = kwargs.pop("completer", None)
# TODO arg = _add_argument(*args, **kwargs)
# TODO if completer:
# TODO arg.completer = completer
# TODO return arg
# TODO _add_argument = parser.add_argument
# TODO parser.add_argument = add_completed_argument
try:
setup_handler = _cls.setup
except AttributeError:
pass
else:
setup_handler(parser.add_argument)
if name == "main":
self._root = _cls.run
else:
self.handlers[name] = _cls.run
return cls
return handler
def __call__(self, *command):
"""
Provide dynamic auto-complete and run command or print help.
Use of `__call__` to keep consistent with historical practice of
calling a function named `main`. Instead, best practice is to name
this global instance as such and call it as usual:
main = Application()
...
if __name__ == "__main__":
main()
"""
# TODO argcomplete.autocomplete(self.parser)
if command:
args = self.parser.parse_args(command)
else:
args = self.parser.parse_args()
try:
handler = self._root
except AttributeError:
try:
handler = self.handlers[getattr(args, "_cmd", "")]
except KeyError: # multi-command, none given (better way to trap?)
self.parser.print_help()
return self.parser.exit(0)
for arg, value in args.__dict__.items():
setattr(handler.__self__, arg, value)
log = logging.getLogger(self.name)
log.setLevel(logging.INFO)
log.addHandler(logging.StreamHandler(sys.stdout))
try:
status = handler(sys.stdin, log)
except KeyboardInterrupt:
status = 0
self.parser.exit(status)
application = Application
class HelpFormatter(argparse.HelpFormatter):
"""Help message formatter which adds various details to argument help."""
def _fill_text(self, text, width, indent):
"""Retain formatting of description & epilog."""
return "".join(indent + line for line in text.splitlines(keepends=True))
def _split_lines(self, text, width):
"""Retain formatting of help text."""
return text.splitlines()
def _format_action_invocation(self, action):
if not action.option_strings:
default = self._get_default_metavar_for_positional(action)
(metavar,) = self._metavar_formatter(action, default)(1)
return metavar
else:
output = ", ".join(action.option_strings)
if action.nargs != 0:
# TODO move logic to self._format_args(..) for use in `usage`
# default = self._get_default_metavar_for_optional(action)
# output += " " + self._format_args(action, default)
if isinstance(action.default, list):
default = "{0} [..]".format(action.metavar)
else:
default = action.metavar
output += " " + str(default)
return output
def _get_help_string(self, action):
"""Support repeatability (`action="count"`) & default values."""
help = action.help
if isinstance(action, argparse._CountAction):
help += " (e.g. -{})".format(3 * action.dest[0])
if action.default is not argparse.SUPPRESS:
default_nargs = [argparse.OPTIONAL, argparse.ZERO_OR_MORE]
if action.default and (
action.option_strings or action.nargs in default_nargs
):
help_str = "%(default)s"
if isinstance(action.default, (tuple, list, set)):
help_str = ", ".join(map(str, action.default))
help += " {{{}}}".format(help_str)
return help