cliutil: Use semi-standardized BSD exit codes.
This commit is contained in:
parent
f56d89462a
commit
8597a526d7
10 changed files with 82 additions and 54 deletions
|
@ -32,6 +32,8 @@ import types
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import rt.exceptions as rt_error
|
||||||
|
|
||||||
from . import data
|
from . import data
|
||||||
from . import filters
|
from . import filters
|
||||||
from . import rtutil
|
from . import rtutil
|
||||||
|
@ -61,38 +63,67 @@ STDSTREAM_PATH = Path('-')
|
||||||
VERSION = pkg_resources.require(PKGNAME)[0].version
|
VERSION = pkg_resources.require(PKGNAME)[0].version
|
||||||
|
|
||||||
class ExceptHook:
|
class ExceptHook:
|
||||||
def __init__(self,
|
def __init__(self, logger: Optional[logging.Logger]=None) -> None:
|
||||||
logger: Optional[logging.Logger]=None,
|
|
||||||
default_exitcode: int=3,
|
|
||||||
) -> None:
|
|
||||||
if logger is None:
|
if logger is None:
|
||||||
logger = logging.getLogger()
|
logger = logging.getLogger()
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
self.default_exitcode = default_exitcode
|
|
||||||
|
|
||||||
def __call__(self,
|
def __call__(self,
|
||||||
exc_type: Type[BaseException],
|
exc_type: Type[BaseException],
|
||||||
exc_value: BaseException,
|
exc_value: BaseException,
|
||||||
exc_tb: types.TracebackType,
|
exc_tb: types.TracebackType,
|
||||||
) -> NoReturn:
|
) -> NoReturn:
|
||||||
exitcode = self.default_exitcode
|
error_type = type(exc_value).__name__
|
||||||
|
msg = ": ".join(str(arg) for arg in exc_value.args)
|
||||||
if isinstance(exc_value, KeyboardInterrupt):
|
if isinstance(exc_value, KeyboardInterrupt):
|
||||||
signal.signal(signal.SIGINT, signal.SIG_DFL)
|
signal.signal(signal.SIGINT, signal.SIG_DFL)
|
||||||
os.kill(0, signal.SIGINT)
|
os.kill(0, signal.SIGINT)
|
||||||
signal.pause()
|
signal.pause()
|
||||||
|
elif isinstance(exc_value, (
|
||||||
|
rt_error.AuthorizationError,
|
||||||
|
rt_error.NotAllowed,
|
||||||
|
)):
|
||||||
|
exitcode = os.EX_NOPERM
|
||||||
|
error_type = "RT access denied"
|
||||||
|
elif isinstance(exc_value, rt_error.ConnectionError):
|
||||||
|
exitcode = os.EX_TEMPFAIL
|
||||||
|
error_type = "RT connection error"
|
||||||
|
elif isinstance(exc_value, rt_error.RtError):
|
||||||
|
exitcode = os.EX_UNAVAILABLE
|
||||||
|
error_type = f"RT {error_type}"
|
||||||
elif isinstance(exc_value, OSError):
|
elif isinstance(exc_value, OSError):
|
||||||
exitcode += 1
|
if exc_value.filename is None:
|
||||||
msg = "I/O error: {e.filename}: {e.strerror}".format(e=exc_value)
|
exitcode = os.EX_OSERR
|
||||||
|
error_type = "OS error"
|
||||||
|
msg = exc_value.strerror
|
||||||
|
else:
|
||||||
|
# There are more specific exit codes for input problems vs.
|
||||||
|
# output problems, but without knowing how the file was
|
||||||
|
# intended to be used, we can't use them.
|
||||||
|
exitcode = os.EX_IOERR
|
||||||
|
error_type = "I/O error"
|
||||||
|
msg = f"{exc_value.filename}: {exc_value.strerror}"
|
||||||
else:
|
else:
|
||||||
parts = [type(exc_value).__name__, *exc_value.args]
|
exitcode = os.EX_SOFTWARE
|
||||||
msg = "internal " + ": ".join(parts)
|
error_type = f"internal {error_type}"
|
||||||
self.logger.critical(msg)
|
self.logger.critical("%s%s%s", error_type, ": " if msg else "", msg)
|
||||||
self.logger.debug(
|
self.logger.debug(
|
||||||
''.join(traceback.format_exception(exc_type, exc_value, exc_tb)),
|
''.join(traceback.format_exception(exc_type, exc_value, exc_tb)),
|
||||||
)
|
)
|
||||||
raise SystemExit(exitcode)
|
raise SystemExit(exitcode)
|
||||||
|
|
||||||
|
|
||||||
|
class ExitCode(enum.IntEnum):
|
||||||
|
# BSD exit codes commonly used
|
||||||
|
NoConfiguration = os.EX_CONFIG
|
||||||
|
NoConfig = NoConfiguration
|
||||||
|
NoDataFiltered = os.EX_DATAERR
|
||||||
|
NoDataLoaded = os.EX_NOINPUT
|
||||||
|
|
||||||
|
# Our own exit codes, working down from that range
|
||||||
|
BeancountErrors = 63
|
||||||
|
|
||||||
|
|
||||||
class InfoAction(argparse.Action):
|
class InfoAction(argparse.Action):
|
||||||
def __call__(self,
|
def __call__(self,
|
||||||
parser: argparse.ArgumentParser,
|
parser: argparse.ArgumentParser,
|
||||||
|
@ -132,26 +163,6 @@ class LogLevel(enum.IntEnum):
|
||||||
yield level.name.lower()
|
yield level.name.lower()
|
||||||
|
|
||||||
|
|
||||||
class ReturnFlag(enum.IntFlag):
|
|
||||||
"""Common return codes for tools
|
|
||||||
|
|
||||||
Tools should combine these flags to report different errors, and then use
|
|
||||||
ReturnFlag.returncode(flags) to report their final exit status code.
|
|
||||||
|
|
||||||
Values 1, 2, 4, and 8 should be reserved for this class to be shared across
|
|
||||||
all tools. Flags 16, 32, and 64 are available for tools to report their own
|
|
||||||
specific errors.
|
|
||||||
"""
|
|
||||||
LOAD_ERRORS = 1
|
|
||||||
NOTHING_TO_REPORT = 2
|
|
||||||
_RESERVED4 = 4
|
|
||||||
_RESERVED8 = 8
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def returncode(cls, flags: int) -> int:
|
|
||||||
return 0 if flags == 0 else 16 + flags
|
|
||||||
|
|
||||||
|
|
||||||
class SearchTerm(NamedTuple):
|
class SearchTerm(NamedTuple):
|
||||||
"""NamedTuple representing a user's metadata filter
|
"""NamedTuple representing a user's metadata filter
|
||||||
|
|
||||||
|
|
|
@ -709,20 +709,24 @@ def main(arglist: Optional[Sequence[str]]=None,
|
||||||
books_loader = config.books_loader()
|
books_loader = config.books_loader()
|
||||||
if books_loader is None:
|
if books_loader is None:
|
||||||
entries, load_errors, _ = books.Loader.load_none(config.config_file_path())
|
entries, load_errors, _ = books.Loader.load_none(config.config_file_path())
|
||||||
|
returncode = cliutil.ExitCode.NoConfiguration
|
||||||
else:
|
else:
|
||||||
load_since = None if args.report_type == ReportType.AGING else args.since
|
load_since = None if args.report_type == ReportType.AGING else args.since
|
||||||
entries, load_errors, _ = books_loader.load_all(load_since)
|
entries, load_errors, _ = books_loader.load_all(load_since)
|
||||||
|
if load_errors:
|
||||||
|
returncode = cliutil.ExitCode.BeancountErrors
|
||||||
|
elif not entries:
|
||||||
|
returncode = cliutil.ExitCode.NoDataLoaded
|
||||||
filters.remove_opening_balance_txn(entries)
|
filters.remove_opening_balance_txn(entries)
|
||||||
for error in load_errors:
|
for error in load_errors:
|
||||||
bc_printer.print_error(error, file=stderr)
|
bc_printer.print_error(error, file=stderr)
|
||||||
returncode |= cliutil.ReturnFlag.LOAD_ERRORS
|
|
||||||
|
|
||||||
postings = list(filter_search(
|
postings = list(filter_search(
|
||||||
data.Posting.from_entries(entries), args.search_terms,
|
data.Posting.from_entries(entries), args.search_terms,
|
||||||
))
|
))
|
||||||
if not postings:
|
if not postings:
|
||||||
logger.warning("no matching entries found to report")
|
logger.warning("no matching entries found to report")
|
||||||
returncode |= cliutil.ReturnFlag.NOTHING_TO_REPORT
|
returncode = returncode or cliutil.ExitCode.NoDataFiltered
|
||||||
# groups is a mapping of metadata value strings to AccrualPostings.
|
# groups is a mapping of metadata value strings to AccrualPostings.
|
||||||
# The keys are basically arbitrary, the report classes don't rely on them,
|
# The keys are basically arbitrary, the report classes don't rely on them,
|
||||||
# but they do help symbolize what's being grouped.
|
# but they do help symbolize what's being grouped.
|
||||||
|
@ -776,10 +780,10 @@ def main(arglist: Optional[Sequence[str]]=None,
|
||||||
report = BalanceReport(out_file)
|
report = BalanceReport(out_file)
|
||||||
|
|
||||||
if report is None:
|
if report is None:
|
||||||
returncode |= 16
|
returncode = cliutil.ExitCode.NoConfiguration
|
||||||
else:
|
else:
|
||||||
report.run(groups)
|
report.run(groups)
|
||||||
return cliutil.ReturnFlag.returncode(returncode)
|
return returncode
|
||||||
|
|
||||||
entry_point = cliutil.make_entry_point(__name__, PROGNAME)
|
entry_point = cliutil.make_entry_point(__name__, PROGNAME)
|
||||||
|
|
||||||
|
|
|
@ -362,11 +362,15 @@ def main(arglist: Optional[Sequence[str]]=None,
|
||||||
books_loader = config.books_loader()
|
books_loader = config.books_loader()
|
||||||
if books_loader is None:
|
if books_loader is None:
|
||||||
entries, load_errors, _ = books.Loader.load_none(config.config_file_path())
|
entries, load_errors, _ = books.Loader.load_none(config.config_file_path())
|
||||||
|
returncode = cliutil.ExitCode.NoConfiguration
|
||||||
else:
|
else:
|
||||||
entries, load_errors, _ = books_loader.load_fy_range(args.start_date, args.stop_date)
|
entries, load_errors, _ = books_loader.load_fy_range(args.start_date, args.stop_date)
|
||||||
|
if load_errors:
|
||||||
|
returncode = cliutil.ExitCode.BeancountErrors
|
||||||
|
elif not entries:
|
||||||
|
returncode = cliutil.ExitCode.NoDataLoaded
|
||||||
for error in load_errors:
|
for error in load_errors:
|
||||||
bc_printer.print_error(error, file=stderr)
|
bc_printer.print_error(error, file=stderr)
|
||||||
returncode |= cliutil.ReturnFlag.LOAD_ERRORS
|
|
||||||
|
|
||||||
postings = (
|
postings = (
|
||||||
post
|
post
|
||||||
|
@ -387,7 +391,7 @@ def main(arglist: Optional[Sequence[str]]=None,
|
||||||
)
|
)
|
||||||
if not fund_map:
|
if not fund_map:
|
||||||
logger.warning("no matching postings found to report")
|
logger.warning("no matching postings found to report")
|
||||||
returncode |= cliutil.ReturnFlag.NOTHING_TO_REPORT
|
returncode = returncode or cliutil.ExitCode.NoDataFiltered
|
||||||
elif args.report_type is ReportType.TEXT:
|
elif args.report_type is ReportType.TEXT:
|
||||||
out_file = cliutil.text_output(args.output_file, stdout)
|
out_file = cliutil.text_output(args.output_file, stdout)
|
||||||
report = TextReport(args.start_date, args.stop_date, out_file)
|
report = TextReport(args.start_date, args.stop_date, out_file)
|
||||||
|
@ -404,7 +408,7 @@ def main(arglist: Optional[Sequence[str]]=None,
|
||||||
logger.info("Writing report to %s", args.output_file)
|
logger.info("Writing report to %s", args.output_file)
|
||||||
ods_file = cliutil.bytes_output(args.output_file, stdout)
|
ods_file = cliutil.bytes_output(args.output_file, stdout)
|
||||||
ods_report.save_file(ods_file)
|
ods_report.save_file(ods_file)
|
||||||
return cliutil.ReturnFlag.returncode(returncode)
|
return returncode
|
||||||
|
|
||||||
entry_point = cliutil.make_entry_point(__name__, PROGNAME)
|
entry_point = cliutil.make_entry_point(__name__, PROGNAME)
|
||||||
|
|
||||||
|
|
|
@ -770,11 +770,15 @@ def main(arglist: Optional[Sequence[str]]=None,
|
||||||
books_loader = config.books_loader()
|
books_loader = config.books_loader()
|
||||||
if books_loader is None:
|
if books_loader is None:
|
||||||
entries, load_errors, options = books.Loader.load_none(config.config_file_path())
|
entries, load_errors, options = books.Loader.load_none(config.config_file_path())
|
||||||
|
returncode = cliutil.ExitCode.NoConfiguration
|
||||||
else:
|
else:
|
||||||
entries, load_errors, options = books_loader.load_fy_range(args.start_date, args.stop_date)
|
entries, load_errors, options = books_loader.load_fy_range(args.start_date, args.stop_date)
|
||||||
|
if load_errors:
|
||||||
|
returncode = cliutil.ExitCode.BeancountErrors
|
||||||
|
elif not entries:
|
||||||
|
returncode = cliutil.ExitCode.NoDataLoaded
|
||||||
for error in load_errors:
|
for error in load_errors:
|
||||||
bc_printer.print_error(error, file=stderr)
|
bc_printer.print_error(error, file=stderr)
|
||||||
returncode |= cliutil.ReturnFlag.LOAD_ERRORS
|
|
||||||
|
|
||||||
data.Account.load_from_books(entries, options)
|
data.Account.load_from_books(entries, options)
|
||||||
postings = data.Posting.from_entries(entries)
|
postings = data.Posting.from_entries(entries)
|
||||||
|
@ -813,7 +817,7 @@ def main(arglist: Optional[Sequence[str]]=None,
|
||||||
report.write(postings)
|
report.write(postings)
|
||||||
if not any(report.account_groups.values()):
|
if not any(report.account_groups.values()):
|
||||||
logger.warning("no matching postings found to report")
|
logger.warning("no matching postings found to report")
|
||||||
returncode |= cliutil.ReturnFlag.NOTHING_TO_REPORT
|
returncode = returncode or cliutil.ExitCode.NoDataFiltered
|
||||||
|
|
||||||
if args.output_file is None:
|
if args.output_file is None:
|
||||||
out_dir_path = config.repository_path() or Path()
|
out_dir_path = config.repository_path() or Path()
|
||||||
|
@ -825,7 +829,7 @@ def main(arglist: Optional[Sequence[str]]=None,
|
||||||
logger.info("Writing report to %s", args.output_file)
|
logger.info("Writing report to %s", args.output_file)
|
||||||
ods_file = cliutil.bytes_output(args.output_file, stdout)
|
ods_file = cliutil.bytes_output(args.output_file, stdout)
|
||||||
report.save_file(ods_file)
|
report.save_file(ods_file)
|
||||||
return cliutil.ReturnFlag.returncode(returncode)
|
return returncode
|
||||||
|
|
||||||
entry_point = cliutil.make_entry_point(__name__, PROGNAME)
|
entry_point = cliutil.make_entry_point(__name__, PROGNAME)
|
||||||
|
|
||||||
|
|
|
@ -191,11 +191,15 @@ def main(arglist: Optional[Sequence[str]]=None,
|
||||||
books_loader = config.books_loader()
|
books_loader = config.books_loader()
|
||||||
if books_loader is None:
|
if books_loader is None:
|
||||||
entries, load_errors, _ = books.Loader.load_none(config.config_file_path())
|
entries, load_errors, _ = books.Loader.load_none(config.config_file_path())
|
||||||
|
returncode = cliutil.ExitCode.NoConfiguration
|
||||||
else:
|
else:
|
||||||
entries, load_errors, _ = books_loader.load_fy_range(0, args.as_of_date)
|
entries, load_errors, _ = books_loader.load_fy_range(0, args.as_of_date)
|
||||||
|
if load_errors:
|
||||||
|
returncode = cliutil.ExitCode.BeancountErrors
|
||||||
|
elif not entries:
|
||||||
|
returncode = cliutil.ExitCode.NoDataLoaded
|
||||||
for error in load_errors:
|
for error in load_errors:
|
||||||
bc_printer.print_error(error, file=stderr)
|
bc_printer.print_error(error, file=stderr)
|
||||||
returncode |= cliutil.ReturnFlag.LOAD_ERRORS
|
|
||||||
|
|
||||||
inventories: Mapping[AccountWithFund, Inventory] = collections.defaultdict(Inventory)
|
inventories: Mapping[AccountWithFund, Inventory] = collections.defaultdict(Inventory)
|
||||||
for post in Posting.from_entries(entries):
|
for post in Posting.from_entries(entries):
|
||||||
|
@ -247,7 +251,7 @@ def main(arglist: Optional[Sequence[str]]=None,
|
||||||
dcontext = bc_dcontext.DisplayContext()
|
dcontext = bc_dcontext.DisplayContext()
|
||||||
dcontext.set_commas(True)
|
dcontext.set_commas(True)
|
||||||
bc_printer.print_entry(opening, dcontext, file=stdout)
|
bc_printer.print_entry(opening, dcontext, file=stdout)
|
||||||
return cliutil.ReturnFlag.returncode(returncode)
|
return returncode
|
||||||
|
|
||||||
entry_point = cliutil.make_entry_point(__name__, PROGNAME)
|
entry_point = cliutil.make_entry_point(__name__, PROGNAME)
|
||||||
|
|
||||||
|
|
2
setup.py
2
setup.py
|
@ -5,7 +5,7 @@ from setuptools import setup
|
||||||
setup(
|
setup(
|
||||||
name='conservancy_beancount',
|
name='conservancy_beancount',
|
||||||
description="Plugin, library, and reports for reading Conservancy's books",
|
description="Plugin, library, and reports for reading Conservancy's books",
|
||||||
version='1.6.3',
|
version='1.6.4',
|
||||||
author='Software Freedom Conservancy',
|
author='Software Freedom Conservancy',
|
||||||
author_email='info@sfconservancy.org',
|
author_email='info@sfconservancy.org',
|
||||||
license='GNU AGPLv3+',
|
license='GNU AGPLv3+',
|
||||||
|
|
|
@ -153,7 +153,7 @@ def test_excepthook_oserror(errnum, caplog):
|
||||||
error = OSError(errnum, os.strerror(errnum), 'TestFilename')
|
error = OSError(errnum, os.strerror(errnum), 'TestFilename')
|
||||||
with pytest.raises(SystemExit) as exc_check:
|
with pytest.raises(SystemExit) as exc_check:
|
||||||
cliutil.ExceptHook()(type(error), error, None)
|
cliutil.ExceptHook()(type(error), error, None)
|
||||||
assert exc_check.value.args[0] == 4
|
assert exc_check.value.args[0] == os.EX_IOERR
|
||||||
assert caplog.records
|
assert caplog.records
|
||||||
for log in caplog.records:
|
for log in caplog.records:
|
||||||
assert log.levelname == 'CRITICAL'
|
assert log.levelname == 'CRITICAL'
|
||||||
|
@ -168,7 +168,7 @@ def test_excepthook_bug(exc_type, caplog):
|
||||||
error = exc_type("test message")
|
error = exc_type("test message")
|
||||||
with pytest.raises(SystemExit) as exc_check:
|
with pytest.raises(SystemExit) as exc_check:
|
||||||
cliutil.ExceptHook()(exc_type, error, None)
|
cliutil.ExceptHook()(exc_type, error, None)
|
||||||
assert exc_check.value.args[0] == 3
|
assert exc_check.value.args[0] == os.EX_SOFTWARE
|
||||||
assert caplog.records
|
assert caplog.records
|
||||||
for log in caplog.records:
|
for log in caplog.records:
|
||||||
assert log.levelname == 'CRITICAL'
|
assert log.levelname == 'CRITICAL'
|
||||||
|
|
|
@ -684,10 +684,11 @@ def run_main(arglist, config=None, out_type=io.StringIO):
|
||||||
errors.seek(0)
|
errors.seek(0)
|
||||||
return retcode, output, errors
|
return retcode, output, errors
|
||||||
|
|
||||||
def check_main_fails(arglist, config, error_flags):
|
def check_main_fails(arglist, config, expect_retcode):
|
||||||
|
if not isinstance(expect_retcode, int):
|
||||||
|
expect_retcode = cliutil.ExitCode[expect_retcode]
|
||||||
retcode, output, errors = run_main(arglist, config)
|
retcode, output, errors = run_main(arglist, config)
|
||||||
assert retcode > 16
|
assert retcode == expect_retcode
|
||||||
assert (retcode - 16) & error_flags
|
|
||||||
assert not output.getvalue()
|
assert not output.getvalue()
|
||||||
return errors
|
return errors
|
||||||
|
|
||||||
|
@ -775,7 +776,7 @@ def test_main_aging_report(arglist):
|
||||||
check_aging_ods(output, datetime.date.today(), recv_rows, pay_rows)
|
check_aging_ods(output, datetime.date.today(), recv_rows, pay_rows)
|
||||||
|
|
||||||
def test_main_no_books():
|
def test_main_no_books():
|
||||||
errors = check_main_fails([], testutil.TestConfig(), 1 | 2)
|
errors = check_main_fails([], testutil.TestConfig(), 'NoConfiguration')
|
||||||
testutil.check_lines_match(iter(errors), [
|
testutil.check_lines_match(iter(errors), [
|
||||||
r':[01]: +no books to load in configuration\b',
|
r':[01]: +no books to load in configuration\b',
|
||||||
])
|
])
|
||||||
|
@ -786,7 +787,7 @@ def test_main_no_books():
|
||||||
['-t', 'balance', 'entity=NonExistent'],
|
['-t', 'balance', 'entity=NonExistent'],
|
||||||
])
|
])
|
||||||
def test_main_no_matches(arglist, caplog):
|
def test_main_no_matches(arglist, caplog):
|
||||||
check_main_fails(arglist, None, 2)
|
check_main_fails(arglist, None, 'NoDataFiltered')
|
||||||
testutil.check_logs_match(caplog, [
|
testutil.check_logs_match(caplog, [
|
||||||
('WARNING', 'no matching entries found to report'),
|
('WARNING', 'no matching entries found to report'),
|
||||||
])
|
])
|
||||||
|
@ -795,7 +796,7 @@ def test_main_no_rt(caplog):
|
||||||
config = testutil.TestConfig(
|
config = testutil.TestConfig(
|
||||||
books_path=testutil.test_path('books/accruals.beancount'),
|
books_path=testutil.test_path('books/accruals.beancount'),
|
||||||
)
|
)
|
||||||
check_main_fails(['-t', 'out'], config, 16)
|
check_main_fails(['-t', 'out'], config, 'NoConfiguration')
|
||||||
testutil.check_logs_match(caplog, [
|
testutil.check_logs_match(caplog, [
|
||||||
('ERROR', 'unable to generate outgoing report: RT client is required'),
|
('ERROR', 'unable to generate outgoing report: RT client is required'),
|
||||||
])
|
])
|
||||||
|
|
|
@ -280,5 +280,5 @@ def test_ods_report(start_date, stop_date):
|
||||||
|
|
||||||
def test_main_no_postings(caplog):
|
def test_main_no_postings(caplog):
|
||||||
retcode, output, errors = run_main(io.StringIO, ['NonexistentProject'])
|
retcode, output, errors = run_main(io.StringIO, ['NonexistentProject'])
|
||||||
assert retcode == 18
|
assert retcode == 65
|
||||||
assert any(log.levelname == 'WARNING' for log in caplog.records)
|
assert any(log.levelname == 'WARNING' for log in caplog.records)
|
||||||
|
|
|
@ -557,5 +557,5 @@ def test_main_invalid_account(caplog, arg):
|
||||||
|
|
||||||
def test_main_no_postings(caplog):
|
def test_main_no_postings(caplog):
|
||||||
retcode, output, errors = run_main(['NonexistentProject'])
|
retcode, output, errors = run_main(['NonexistentProject'])
|
||||||
assert retcode == 18
|
assert retcode == 65
|
||||||
assert any(log.levelname == 'WARNING' for log in caplog.records)
|
assert any(log.levelname == 'WARNING' for log in caplog.records)
|
||||||
|
|
Loading…
Reference in a new issue