conservancy_beancount/conservancy_beancount/cliutil.py
2020-06-25 08:43:28 -04:00

345 lines
11 KiB
Python

"""cliutil - Utilities for CLI tools"""
PKGNAME = 'conservancy_beancount'
LICENSE = """
Copyright © 2020 Brett Smith
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>."""
import argparse
import datetime
import enum
import io
import logging
import operator
import os
import pkg_resources
import re
import signal
import sys
import traceback
import types
from pathlib import Path
from . import data
from . import filters
from . import rtutil
from typing import (
cast,
Any,
BinaryIO,
Callable,
IO,
Iterable,
NamedTuple,
NoReturn,
Optional,
Sequence,
TextIO,
Type,
Union,
)
from .beancount_types import (
MetaKey,
)
OutputFile = Union[int, IO]
STDSTREAM_PATH = Path('-')
VERSION = pkg_resources.require(PKGNAME)[0].version
class ExceptHook:
def __init__(self,
logger: Optional[logging.Logger]=None,
default_exitcode: int=3,
) -> None:
if logger is None:
logger = logging.getLogger()
self.logger = logger
self.default_exitcode = default_exitcode
def __call__(self,
exc_type: Type[BaseException],
exc_value: BaseException,
exc_tb: types.TracebackType,
) -> NoReturn:
exitcode = self.default_exitcode
if isinstance(exc_value, KeyboardInterrupt):
signal.signal(signal.SIGINT, signal.SIG_DFL)
os.kill(0, signal.SIGINT)
signal.pause()
elif isinstance(exc_value, OSError):
exitcode += 1
msg = "I/O error: {e.filename}: {e.strerror}".format(e=exc_value)
else:
parts = [type(exc_value).__name__, *exc_value.args]
msg = "internal " + ": ".join(parts)
self.logger.critical(msg)
self.logger.debug(
''.join(traceback.format_exception(exc_type, exc_value, exc_tb)),
)
raise SystemExit(exitcode)
class InfoAction(argparse.Action):
def __call__(self,
parser: argparse.ArgumentParser,
namespace: argparse.Namespace,
values: Union[Sequence[Any], str, None]=None,
option_string: Optional[str]=None,
) -> NoReturn:
if isinstance(self.const, str):
info = self.const
exitcode = 0
else:
info, exitcode = self.const
print(info)
raise SystemExit(exitcode)
class LogLevel(enum.IntEnum):
DEBUG = logging.DEBUG
INFO = logging.INFO
WARNING = logging.WARNING
ERROR = logging.ERROR
CRITICAL = logging.CRITICAL
WARN = WARNING
ERR = ERROR
CRIT = CRITICAL
@classmethod
def from_arg(cls, arg: str) -> int:
try:
return cls[arg.upper()].value
except KeyError:
raise ValueError(f"unknown loglevel {arg!r}") from None
@classmethod
def choices(cls) -> Iterable[str]:
for level in sorted(cls, key=operator.attrgetter('value')):
yield level.name.lower()
class SearchTerm(NamedTuple):
"""NamedTuple representing a user's metadata filter
SearchTerm knows how to parse and store posting metadata filters provided
by the user in `key=value` format. Reporting tools can use this to filter
postings that match the user's criteria, to report on subsets of the books.
Typical usage looks like::
argument_parser.add_argument(
'search_terms',
type=SearchTerm.arg_parser(),
…,
)
args = argument_parser.parse_args(…)
for query in args.search_terms:
postings = query.filter_postings(postings)
"""
meta_key: MetaKey
pattern: str
@classmethod
def arg_parser(cls,
default_key: Optional[str]=None,
ticket_default_key: Optional[str]=None,
) -> Callable[[str], 'SearchTerm']:
"""Build a SearchTerm parser
This method returns a function that can parse strings in ``key=value``
format and return a corresponding SearchTerm.
If you specify a default key, then strings that just specify a ``value``
will be parsed as if they said ``default_key=value``. Otherwise,
parsing strings without a metadata key will raise a ValueError.
If you specify a default key ticket links, then values in the format
``number``, ``rt:number``, or ``rt://ticket/number`` will be parsed as
if they said ``ticket_default_key=value``.
"""
if ticket_default_key is None:
ticket_default_key = default_key
def parse_search_term(arg: str) -> 'SearchTerm':
key: Optional[str] = None
if re.match(r'^[a-z][-\w]*=', arg):
key, _, raw_link = arg.partition('=')
else:
raw_link = arg
rt_ids = rtutil.RT.parse(raw_link)
if rt_ids is None:
rt_ids = rtutil.RT.parse('rt:' + raw_link)
if rt_ids is None:
if key is None:
key = default_key
pattern = r'(?:^|\s){}(?:\s|$)'.format(re.escape(raw_link))
else:
ticket_id, attachment_id = rt_ids
if key is None:
if attachment_id is None:
key = ticket_default_key
else:
key = default_key
pattern = rtutil.RT.metadata_regexp(
ticket_id,
attachment_id,
first_link_only=key == 'rt-id' and attachment_id is None,
)
if key is None:
raise ValueError(f"invalid search term {arg!r}: no metadata key")
return cls(key, pattern)
return parse_search_term
def filter_postings(self, postings: Iterable[data.Posting]) -> Iterable[data.Posting]:
return filters.filter_meta_match(
postings, self.meta_key, re.compile(self.pattern),
)
def add_loglevel_argument(parser: argparse.ArgumentParser,
default: LogLevel=LogLevel.INFO) -> argparse.Action:
return parser.add_argument(
'--loglevel',
metavar='LEVEL',
default=default.value,
type=LogLevel.from_arg,
help="Show logs at this level and above."
f" Specify one of {', '.join(LogLevel.choices())}."
f" Default {default.name.lower()}.",
)
def add_version_argument(parser: argparse.ArgumentParser) -> argparse.Action:
progname = parser.prog or sys.argv[0]
return parser.add_argument(
'--version', '--copyright', '--license',
action=InfoAction,
nargs=0,
const=f"{progname} version {VERSION}\n{LICENSE}",
help="Show program version and license information",
)
def date_arg(arg: str) -> datetime.date:
return datetime.datetime.strptime(arg, '%Y-%m-%d').date()
def year_or_date_arg(arg: str) -> Union[int, datetime.date]:
"""Get either a date or a year (int) from an argument string
This is a useful argument type for arguments that will be passed into
Books loader methods which can accept either a fiscal year or a full date.
"""
try:
year = int(arg, 10)
except ValueError:
ok = False
else:
ok = datetime.MINYEAR <= year <= datetime.MAXYEAR
if ok:
return year
else:
return date_arg(arg)
def make_entry_point(mod_name: str, prog_name: str=sys.argv[0]) -> Callable[[], int]:
"""Create an entry_point function for a tool
The returned function is suitable for use as an entry_point in setup.py.
It sets up the root logger and excepthook, then calls the module's main
function.
"""
def entry_point(): # type:ignore
prog_mod = sys.modules[mod_name]
setup_logger()
prog_mod.logger = logging.getLogger(prog_name)
sys.excepthook = ExceptHook(prog_mod.logger)
return prog_mod.main()
return entry_point
def setup_logger(logger: Union[str, logging.Logger]='',
stream: TextIO=sys.stderr,
fmt: str='%(name)s: %(levelname)s: %(message)s',
) -> logging.Logger:
"""Set up a logger with a StreamHandler with the given format"""
if isinstance(logger, str):
logger = logging.getLogger(logger)
formatter = logging.Formatter(fmt)
handler = logging.StreamHandler(stream)
handler.setFormatter(formatter)
logger.addHandler(handler)
return logger
def set_loglevel(logger: logging.Logger, loglevel: int=logging.INFO) -> None:
"""Set the loglevel for a tool or module
If the given logger is not under a hierarchy, this function sets the
loglevel for the root logger, along with some specific levels for libraries
used by reporting tools. Otherwise, it's the same as
``logger.setLevel(loglevel)``.
"""
if '.' not in logger.name:
logger = logging.getLogger()
if loglevel <= logging.DEBUG:
# At the debug level, the rt module logs the full body of every
# request and response. That's too much.
logging.getLogger('rt.rt').setLevel(logging.INFO)
logger.setLevel(loglevel)
def bytes_output(path: Optional[Path]=None,
default: OutputFile=sys.stdout,
mode: str='w',
) -> BinaryIO:
"""Get a file-like object suitable for binary output
If ``path`` is ``None`` or ``-``, returns a file-like object backed by
``default``. If ``default`` is a file descriptor or text IO object, this
method returns a file-like object that writes to the same place.
Otherwise, returns ``path.open(mode)``.
"""
mode = f'{mode}b'
if path is None or path == STDSTREAM_PATH:
if isinstance(default, int):
retval = open(default, mode)
elif isinstance(default, TextIO):
retval = default.buffer
else:
retval = default
else:
retval = path.open(mode)
return cast(BinaryIO, retval)
def text_output(path: Optional[Path]=None,
default: OutputFile=sys.stdout,
mode: str='w',
encoding: Optional[str]=None,
) -> TextIO:
"""Get a file-like object suitable for text output
If ``path`` is ``None`` or ``-``, returns a file-like object backed by
``default``. If ``default`` is a file descriptor or binary IO object, this
method returns a file-like object that writes to the same place.
Otherwise, returns ``path.open(mode)``.
"""
if path is None or path == STDSTREAM_PATH:
if isinstance(default, int):
retval = open(default, mode, encoding=encoding)
elif isinstance(default, BinaryIO):
retval = io.TextIOWrapper(default, encoding=encoding)
else:
retval = default
else:
retval = path.open(mode, encoding=encoding)
return cast(TextIO, retval)