audit_report: New tool.
This commit is contained in:
parent
0e52f11a58
commit
7281cf0f01
2 changed files with 273 additions and 1 deletions
271
conservancy_beancount/tools/audit_report.py
Normal file
271
conservancy_beancount/tools/audit_report.py
Normal file
|
@ -0,0 +1,271 @@
|
|||
"""audit_report.py - Utility to run all reports for an audit"""
|
||||
# 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 logging
|
||||
import multiprocessing
|
||||
import os
|
||||
import runpy
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from typing import (
|
||||
Callable,
|
||||
Iterator,
|
||||
List,
|
||||
Optional,
|
||||
Sequence,
|
||||
Set,
|
||||
TextIO,
|
||||
Tuple,
|
||||
)
|
||||
from types import (
|
||||
ModuleType,
|
||||
)
|
||||
|
||||
from . import extract_odf_links
|
||||
from .. import cliutil
|
||||
from .. import config as configmod
|
||||
from ..reports import accrual
|
||||
from ..reports import balance_sheet
|
||||
from ..reports import fund
|
||||
from ..reports import ledger
|
||||
|
||||
from beancount.scripts import check as bc_check
|
||||
|
||||
ArgList = List[str]
|
||||
ReportFunc = Callable[[ArgList, TextIO, TextIO, configmod.Config], int]
|
||||
|
||||
CPU_COUNT = len(os.sched_getaffinity(0))
|
||||
PROGNAME = 'audit-report'
|
||||
logger = logging.getLogger('conservancy_beancount.tools.audit_report')
|
||||
|
||||
def jobs_arg(arg: str) -> int:
|
||||
if arg.endswith('%'):
|
||||
arg_n = round(CPU_COUNT * 100 / int(arg[:-1]))
|
||||
else:
|
||||
arg_n = int(arg)
|
||||
return max(1, arg_n)
|
||||
|
||||
def parse_arguments(arglist: Optional[Sequence[str]]=None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(prog=PROGNAME)
|
||||
cliutil.add_version_argument(parser)
|
||||
cliutil.add_loglevel_argument(parser)
|
||||
parser.add_argument(
|
||||
'--verbose', '-v',
|
||||
action='store_true',
|
||||
help="""Display progress information
|
||||
""")
|
||||
parser.add_argument(
|
||||
'--jobs', '-j',
|
||||
metavar='NUM',
|
||||
type=jobs_arg,
|
||||
default=CPU_COUNT,
|
||||
help="""Maximum number of processes to run concurrently.
|
||||
Can specify a positive integer or a percentage of CPU cores. Default all cores.
|
||||
""")
|
||||
parser.add_argument(
|
||||
'--output-directory', '-O',
|
||||
metavar='DIR',
|
||||
type=Path,
|
||||
help="""Write all reports to this directory.
|
||||
Default is a newly-created directory under your repository.
|
||||
""")
|
||||
parser.add_argument(
|
||||
'--force',
|
||||
action='store_true',
|
||||
help="""Run reports even if bean-check reports errors.
|
||||
""")
|
||||
parser.add_argument(
|
||||
'--rewrite-rules', '--rewrite', '-r',
|
||||
metavar='PATH',
|
||||
action='append',
|
||||
type=Path,
|
||||
default=[],
|
||||
help="""Path to rewrite rules for the balance sheet.
|
||||
Passed to `balance-sheet-report -r`.
|
||||
""")
|
||||
parser.add_argument(
|
||||
'--delimiter', '-d',
|
||||
metavar='TEXT',
|
||||
default='\\0',
|
||||
help="""Delimiter for ODF links in the manifest file.
|
||||
Passed to `extract-odf-links --delimiter`. Default `%(default)s`.
|
||||
""")
|
||||
parser.add_argument(
|
||||
'audit_year',
|
||||
metavar='YEAR',
|
||||
nargs='?',
|
||||
type=cliutil.year_or_date_arg,
|
||||
help="""Main fiscal year to generate reports for.
|
||||
Defaults to the last complete fiscal year.
|
||||
""")
|
||||
parser.add_argument(
|
||||
'end_date',
|
||||
metavar='END',
|
||||
nargs='?',
|
||||
type=cliutil.date_arg,
|
||||
help="""End date for reports for the following fiscal year.
|
||||
The default is automatically calculated from today's date.
|
||||
""")
|
||||
args = parser.parse_args(arglist)
|
||||
args.arg_error = parser.error
|
||||
return args
|
||||
|
||||
def now_s() -> str:
|
||||
return datetime.datetime.now().isoformat(sep=' ', timespec='seconds')
|
||||
|
||||
def bean_check(books_path: Path) -> int:
|
||||
sys.argv = ['bean-check', str(books_path)]
|
||||
logger.debug("running %r", sys.argv)
|
||||
# bean-check logs timing information to the root logger at INFO level.
|
||||
# Suppress that.
|
||||
logging.getLogger().setLevel(logging.WARNING)
|
||||
return bc_check.main() # type:ignore[no-any-return]
|
||||
|
||||
def main(arglist: Optional[Sequence[str]]=None,
|
||||
stdout: TextIO=sys.stdout,
|
||||
stderr: TextIO=sys.stderr,
|
||||
config: Optional[configmod.Config]=None,
|
||||
) -> int:
|
||||
args = parse_arguments(arglist)
|
||||
if config is None:
|
||||
config = configmod.Config()
|
||||
config.load_file()
|
||||
cliutil.set_loglevel(logger, args.loglevel)
|
||||
if args.verbose:
|
||||
logger.setLevel(logging.DEBUG)
|
||||
logging.getLogger('conservancy_beancount.reports').setLevel(logging.DEBUG)
|
||||
|
||||
fy = config.fiscal_year_begin()
|
||||
today = datetime.date.today()
|
||||
if args.audit_year is None:
|
||||
args.audit_year = fy.for_date(today) - 1
|
||||
audit_begin = fy.first_date(args.audit_year)
|
||||
audit_end = fy.next_fy_date(args.audit_year)
|
||||
if args.end_date is None:
|
||||
days_diff = (today - audit_end).days
|
||||
if days_diff < (28 * 2):
|
||||
args.end_date = today
|
||||
elif days_diff >= 365:
|
||||
args.end_date = fy.next_fy_date(args.audit_year + 1)
|
||||
else:
|
||||
end_date = today - datetime.timedelta(days=today.day + 1)
|
||||
args.end_date = end_date.replace(day=1)
|
||||
if args.end_date < audit_end:
|
||||
args.arg_error("end date is within audited fiscal year")
|
||||
next_year = fy.for_date(args.end_date)
|
||||
repo_path = config.repository_path()
|
||||
|
||||
if args.output_directory is None:
|
||||
args.output_directory = Path(tempfile.mkdtemp(
|
||||
prefix=f'FY{args.audit_year}AuditReports.', dir=repo_path,
|
||||
))
|
||||
logger.info("writing reports to %s", args.output_directory)
|
||||
else:
|
||||
args.output_directory.mkdir(exist_ok=True)
|
||||
output_reports: List[Path] = []
|
||||
def common_args(out_name: str, year: Optional[int]=None, *arglist: str) -> Iterator[str]:
|
||||
if year is not None:
|
||||
out_name = f'FY{year}{out_name}.ods'
|
||||
if year == args.audit_year:
|
||||
yield f'--begin={audit_begin.isoformat()}'
|
||||
yield f'--end={audit_end.isoformat()}'
|
||||
elif year == next_year:
|
||||
yield f'--begin={audit_end.isoformat()}'
|
||||
yield f'--end={args.end_date.isoformat()}'
|
||||
elif year is not None:
|
||||
raise ValueError(f"unknown year {year!r}")
|
||||
out_path = args.output_directory / out_name
|
||||
output_reports.append(out_path)
|
||||
yield f'--output-file={out_path}'
|
||||
yield from arglist
|
||||
reports: List[Tuple[ReportFunc, ArgList]] = [
|
||||
# Reports are sorted roughly in descending order of how long each takes
|
||||
# to generate.
|
||||
(ledger.main, list(common_args('GeneralLedger', args.audit_year))),
|
||||
(ledger.main, list(common_args('GeneralLedger', next_year))),
|
||||
(ledger.main, list(common_args('Disbursements', args.audit_year, '--disbursements'))),
|
||||
(ledger.main, list(common_args('Receipts', args.audit_year, '--receipts'))),
|
||||
(ledger.main, list(common_args('Disbursements', next_year, '--disbursements'))),
|
||||
(ledger.main, list(common_args('Receipts', next_year, '--receipts'))),
|
||||
(accrual.main, list(common_args('AgingReport.ods'))),
|
||||
(balance_sheet.main, list(common_args(
|
||||
'Summary', args.audit_year,
|
||||
*(f'--rewrite-rules={path}' for path in args.rewrite_rules),
|
||||
))),
|
||||
(fund.main, list(common_args('FundReport', args.audit_year))),
|
||||
(fund.main, list(common_args('FundReport', next_year))),
|
||||
]
|
||||
|
||||
returncode = 0
|
||||
books = config.books_loader()
|
||||
if books is None:
|
||||
logger.critical("no books available to load")
|
||||
return os.EX_NOINPUT
|
||||
|
||||
with multiprocessing.Pool(args.jobs, maxtasksperchild=1) as pool:
|
||||
logger.debug("%s: process pool ready with %s workers", now_s(), args.jobs)
|
||||
fy_paths = books._iter_fy_books(fy.range(args.audit_year - 1, args.end_date))
|
||||
check_results = pool.imap_unordered(bean_check, fy_paths)
|
||||
if all(exitcode == 0 for exitcode in check_results):
|
||||
logger.debug("%s: bean-check passed", now_s())
|
||||
else:
|
||||
logger.log(
|
||||
logging.WARNING if args.force else logging.ERROR,
|
||||
"%s: bean-check failed",
|
||||
now_s(),
|
||||
)
|
||||
if not args.force:
|
||||
return os.EX_DATAERR
|
||||
|
||||
report_results = [
|
||||
pool.apply_async(report_func, (arglist,), {'config': config})
|
||||
for report_func, arglist in reports
|
||||
]
|
||||
report_errors = [res.get() for res in report_results if res.get() != 0]
|
||||
if not report_errors:
|
||||
logger.debug("%s: all reports generated", now_s())
|
||||
else:
|
||||
logger.error("%s: %s reports generated errors", now_s(), len(report_errors))
|
||||
if not args.force:
|
||||
return max(report_errors)
|
||||
|
||||
missing_reports = frozenset(
|
||||
path for path in output_reports if not path.exists()
|
||||
)
|
||||
if missing_reports:
|
||||
logger.error("missing expected reports: %s",
|
||||
', '.join(path.name for path in missing_reports))
|
||||
if not args.force or len(missing_reports) == len(output_reports):
|
||||
return os.EX_UNAVAILABLE
|
||||
|
||||
arglist = [f'--delimiter={args.delimiter}']
|
||||
if repo_path is not None:
|
||||
arglist.append(f'--relative-to={repo_path}')
|
||||
arglist.extend(str(p) for p in output_reports if p not in missing_reports)
|
||||
with (args.output_directory / 'MANIFEST.AUTO').open('w') as manifest_file:
|
||||
returncode = extract_odf_links.main(arglist, manifest_file, stderr)
|
||||
logger.debug("%s: manifest generated", now_s())
|
||||
return returncode
|
||||
|
||||
entry_point = cliutil.make_entry_point(__name__, PROGNAME)
|
||||
|
||||
if __name__ == '__main__':
|
||||
exit(entry_point())
|
3
setup.py
3
setup.py
|
@ -5,7 +5,7 @@ from setuptools import setup
|
|||
setup(
|
||||
name='conservancy_beancount',
|
||||
description="Plugin, library, and reports for reading Conservancy's books",
|
||||
version='1.8.8',
|
||||
version='1.9.0',
|
||||
author='Software Freedom Conservancy',
|
||||
author_email='info@sfconservancy.org',
|
||||
license='GNU AGPLv3+',
|
||||
|
@ -37,6 +37,7 @@ setup(
|
|||
entry_points={
|
||||
'console_scripts': [
|
||||
'accrual-report = conservancy_beancount.reports.accrual:entry_point',
|
||||
'assemble-audit-reports = conservancy_beancount.tools.audit_report:entry_point',
|
||||
'balance-sheet-report = conservancy_beancount.reports.balance_sheet:entry_point',
|
||||
'extract-odf-links = conservancy_beancount.tools.extract_odf_links:entry_point',
|
||||
'fund-report = conservancy_beancount.reports.fund:entry_point',
|
||||
|
|
Loading…
Reference in a new issue