conservancy_beancount/conservancy_beancount/reports/budget.py
Brett Smith ca12496880 typing: Updates to pass type checking under mypy>=0.800.
Most of these account for the fact that mypy now reports that Hashable is
not an allowed return type for sort key functions.

That, plus the new ignore for the regression in config.py.
2021-02-26 16:13:02 -05:00

247 lines
8.2 KiB
Python

"""budget.py - Budget variance report skeleton
This report sums income and expenses based on postings' ``budget-line``
metadata. Usually this metadata is set by rewrite rules, rather than entered in
the books directly. If there is no ``budget-line`` metadata, it falls back to
using account classifications in the account definitions.
"""
# Copyright © 2020 Brett Smith
# License: AGPLv3-or-later WITH Beancount-Plugin-Additional-Permission-1.0
#
# Full copyright and licensing details can be found at toplevel file
# LICENSE.txt in the repository.
import argparse
import collections
import datetime
import enum
import logging
import operator
import sys
from decimal import Decimal
from pathlib import Path
from typing import (
Any,
Callable,
Collection,
Dict,
Iterable,
Iterator,
List,
Mapping,
NamedTuple,
Optional,
Sequence,
TextIO,
Tuple,
Union,
)
import odf.style # type:ignore[import]
import odf.table # type:ignore[import]
from beancount.parser import printer as bc_printer
from . import core
from . import rewrite
from .. import books
from .. import cliutil
from .. import config as configmod
from .. import data
from .. import ranges
PROGNAME = 'budget-report'
logger = logging.getLogger('conservancy_beancount.reports.budget')
Fund = core.Fund
KWArgs = Mapping[str, Any]
Period = core.Period
class Balances(core.Balances):
def _get_classification(self, post: data.Posting) -> data.Account:
try:
return self._get_meta_account(post.meta, 'budget-line')
except (KeyError, TypeError):
return super()._get_classification(post)
class Report(core.BaseODS[Sequence[None], None]):
def __init__(self, balances: core.Balances) -> None:
super().__init__()
self.balances = balances
self.last_totals_row = odf.table.TableRow()
def section_key(self, row: Sequence[None]) -> None:
raise NotImplementedError("balance_sheet.Report.section_key")
def init_styles(self) -> None:
super().init_styles()
self.style_header = self.merge_styles(self.style_bold, self.style_centertext)
self.style_huline = self.merge_styles(
self.style_header,
self.border_style(core.Border.BOTTOM, '1pt'),
)
def write_all(self) -> None:
headers = [self.string_cell(text, stylename=self.style_huline) for text in [
"",
"Budgeted",
"Actual",
"% Above/Below Budget",
]]
for header_count, cell in enumerate(headers):
col_style = self.column_style(2 if header_count else 4)
self.sheet.addElement(odf.table.TableColumn(stylename=col_style))
header_count += 1
self.add_row(*headers)
self.lock_first_row()
self.add_row()
date_range = self.balances.period_range
self.add_row(self.multiline_cell(
["DRAFT Budget Variance Report",
f"from {date_range.start} to {date_range.stop}"],
stylename=self.style_header,
numbercolumnsspanned=header_count,
))
self.write_section("Support", 'Income', 'Equity', 'Liabilities:UnearnedIncome')
self.write_section("Program Activity", 'Expenses', post_meta='program')
self.write_section("Fundraising Activity", 'Expenses', post_meta='fundraising')
self.write_section("Management & Administration", 'Expenses', post_meta='management')
def write_section(self,
name: str,
*accounts: str,
post_meta: Optional[str]=None,
) -> None:
self.add_row(self.string_cell(name, stylename=self.style_bold))
self.add_row()
norm_func = core.normalize_amount_func(f'{accounts[0]}:RootsOK')
for classification in self.balances.classifications(accounts[0]):
balance = norm_func(self.balances.total(
accounts,
classification,
period=Period.PERIOD,
post_meta=post_meta,
))
self.add_row(
self.string_cell(classification),
odf.table.TableCell(), # TODO: Budgeted amount
self.balance_cell(balance),
odf.table.TableCell(), # TODO: Variance formula
)
self.add_row()
balance = norm_func(self.balances.total(
accounts,
period=Period.PERIOD,
post_meta=post_meta,
))
self.add_row(
self.string_cell("Totals", stylename=self.style_bold),
odf.table.TableCell(stylename=self.style_endtotal), # TODO: Budgeted amount
self.balance_cell(balance, stylename=self.style_endtotal),
odf.table.TableCell(stylename=self.style_endtotal), # TODO: Variance formula
)
self.add_row()
def parse_arguments(arglist: Optional[Sequence[str]]=None) -> argparse.Namespace:
parser = argparse.ArgumentParser(prog=PROGNAME)
cliutil.add_version_argument(parser)
parser.add_argument(
'--begin', '--start', '-b',
dest='start_date',
metavar='DATE',
required=True,
type=cliutil.date_arg,
help="""Date to start reporting entries, inclusive, in YYYY-MM-DD format.
""")
parser.add_argument(
'--end', '--stop', '-e',
dest='stop_date',
metavar='DATE',
required=True,
type=cliutil.date_arg,
help="""Date to stop reporting entries, exclusive, in YYYY-MM-DD format.
""")
cliutil.add_rewrite_rules_argument(parser)
parser.add_argument(
'--fund-metadata-key', '-m',
metavar='KEY',
default='project',
help="""Name of the fund metadata key. Default %(default)s.
""")
parser.add_argument(
'--unrestricted-fund', '-u',
metavar='PROJECT',
default='Conservancy',
help="""Name of the unrestricted fund. Default %(default)s.
""")
parser.add_argument(
'--output-file', '-O',
metavar='PATH',
type=Path,
help="""Write the report to this file, or stdout when PATH is `-`.
""")
cliutil.add_loglevel_argument(parser)
parser.add_argument(
'search_terms',
metavar='FILTER',
type=cliutil.SearchTerm.arg_parser('project', 'rt-id'),
nargs=argparse.ZERO_OR_MORE,
help="""Report on postings that match this criteria. The format is
NAME=TERM. TERM is a link or word that must exist in a posting's NAME
metadata to match. A single ticket number is a shortcut for
`rt-id=rt:NUMBER`. Any other word is a shortcut for `project=TERM`.
If you specify no search terms, defaults to generating a budget for the
unrestricted fund.
""")
args = parser.parse_args(arglist)
if not args.search_terms:
args.search_terms = [cliutil.SearchTerm(args.fund_metadata_key, args.unrestricted_fund)]
return args
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)
cliutil.set_loglevel(logger, args.loglevel)
if config is None:
config = configmod.Config()
config.load_file()
books_load = books.Loader.dispatch(
config.books_loader(), args.start_date, args.stop_date,
)
books_load.print_errors(stderr)
books_load.load_account_metadata()
returncode = books_load.returncode()
balances = Balances(
books_load.iter_postings(args.rewrite_rules, args.search_terms),
args.start_date,
args.stop_date,
'expense-type',
args.fund_metadata_key,
args.unrestricted_fund,
)
report = Report(balances)
report.set_common_properties(config.books_repo())
report.write_all()
if args.output_file is None:
out_dir_path = config.repository_path() or Path()
args.output_file = out_dir_path / 'BudgetReport_{}_{}.ods'.format(
args.start_date.isoformat(), args.stop_date.isoformat(),
)
logger.info("Writing report to %s", args.output_file)
ods_file = cliutil.bytes_output(args.output_file, stdout)
report.save_file(ods_file)
return returncode
entry_point = cliutil.make_entry_point(__name__, PROGNAME)
if __name__ == '__main__':
exit(entry_point())