diff --git a/conservancy_beancount/reports/ledger.py b/conservancy_beancount/reports/ledger.py index 3dfe589d801f16da576a6c0bd93d544872c68d41..10409f10b174442caeed9b536667c666d464c57c 100644 --- a/conservancy_beancount/reports/ledger.py +++ b/conservancy_beancount/reports/ledger.py @@ -60,6 +60,7 @@ from typing import ( Mapping, Optional, Sequence, + Set, TextIO, Tuple, Union, @@ -401,12 +402,13 @@ date was also not specified. """) parser.add_argument( '--account', '-a', - dest='sheet_names', + dest='accounts', metavar='ACCOUNT', action='append', help="""Show this account in the report. You can specify this option -multiple times. If not specified, the default set adapts to your search -criteria. +multiple times. You can specify a part of the account hierarchy, or an account +classification from metadata. If not specified, the default set adapts to your +search criteria. """) parser.add_argument( '--sheet-size', '--size', @@ -436,18 +438,18 @@ metadata to match. A single ticket number is a shortcut for `rt-id=rt:NUMBER`. Any other word is a shortcut for `project=TERM`. """) args = parser.parse_args(arglist) - if args.sheet_names is None: + if args.accounts is None: if any(term.meta_key == 'project' for term in args.search_terms): - args.sheet_names = [ + args.accounts = [ 'Income', 'Expenses', 'Assets:Receivable', + 'Liabilities:Payable', 'Assets:Prepaid', 'Liabilities:UnearnedIncome', - 'Liabilities:Payable', ] else: - args.sheet_names = list(LedgerODS.ACCOUNT_COLUMNS) + args.accounts = list(LedgerODS.ACCOUNT_COLUMNS) return args def diff_year(date: datetime.date, diff: int) -> datetime.date: @@ -483,14 +485,26 @@ def main(arglist: Optional[Sequence[str]]=None, returncode = 0 books_loader = config.books_loader() if books_loader is None: - entries, load_errors, _ = books.Loader.load_none(config.config_file_path()) + entries, load_errors, options = books.Loader.load_none(config.config_file_path()) else: - entries, load_errors, _ = 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) for error in load_errors: bc_printer.print_error(error, file=stderr) returncode |= ReturnFlag.LOAD_ERRORS - postings = data.Posting.from_entries(entries) + data.Account.load_from_books(entries, options) + accounts: Set[data.Account] = set() + sheet_names: Dict[str, None] = collections.OrderedDict() + for acct_arg in args.accounts: + for account in data.Account.iter_accounts(acct_arg): + accounts.add(account) + if not account.is_under(*sheet_names): + new_sheet = account.is_under(*LedgerODS.ACCOUNT_COLUMNS) + assert new_sheet is not None + sheet_names[new_sheet] = None + + postings = (post for post in data.Posting.from_entries(entries) + if post.account in accounts) for search_term in args.search_terms: postings = search_term.filter_postings(postings) @@ -500,7 +514,7 @@ def main(arglist: Optional[Sequence[str]]=None, report = LedgerODS( args.start_date, args.stop_date, - args.sheet_names, + list(sheet_names), rt_wrapper, args.sheet_size, ) diff --git a/tests/books/ledger.beancount b/tests/books/ledger.beancount index a9413796364a91345dd32aa25490c0f5c6649698..eba4c5419900fa19c80dffb67323e19fae948625 100644 --- a/tests/books/ledger.beancount +++ b/tests/books/ledger.beancount @@ -1,10 +1,16 @@ 2018-01-01 open Equity:OpeningBalance 2018-01-01 open Assets:Checking + classification: "Cash" 2018-01-01 open Assets:Receivable:Accounts + classification: "Accounts receivable" 2018-01-01 open Expenses:Other + classification: "Other expenses" 2018-01-01 open Income:Other + classification: "Other income" 2018-01-01 open Liabilities:CreditCard + classification: "Accounts payable" 2018-01-01 open Liabilities:Payable:Accounts + classification: "Accounts payable" 2018-02-28 * "Opening balance" Equity:OpeningBalance -10,000 USD diff --git a/tests/test_reports_ledger.py b/tests/test_reports_ledger.py index 1613424ba7bc0c6b49c9a1fc125fd60841a863e5..34559dab2552d352d2d2dcd909523264b7600a97 100644 --- a/tests/test_reports_ledger.py +++ b/tests/test_reports_ledger.py @@ -15,6 +15,7 @@ # along with this program. If not, see . import collections +import contextlib import copy import datetime import io @@ -33,6 +34,8 @@ from conservancy_beancount import data from conservancy_beancount.reports import core from conservancy_beancount.reports import ledger +clean_account_meta = contextlib.contextmanager(testutil.clean_account_meta) + Acct = data.Account _ledger_load = bc_loader.load_file(testutil.test_path('books/ledger.beancount')) @@ -43,19 +46,11 @@ DEFAULT_REPORT_SHEETS = [ 'Equity', 'Assets:Receivable', 'Liabilities:Payable', - 'Assets:PayPal', 'Assets', 'Liabilities', ] -PROJECT_REPORT_SHEETS = [ - 'Balance', - 'Income', - 'Expenses', - 'Assets:Receivable', - 'Assets:Prepaid', - 'Liabilities:UnearnedIncome', - 'Liabilities:Payable', -] +PROJECT_REPORT_SHEETS = DEFAULT_REPORT_SHEETS[:6] +del PROJECT_REPORT_SHEETS[3] OVERSIZE_RE = re.compile( r'^([A-Za-z0-9:]+) has ([0-9,]+) rows, over size ([0-9,]+)$' ) @@ -275,7 +270,8 @@ def run_main(arglist, config=None): arglist.insert(0, '--output-file=-') output = io.BytesIO() errors = io.StringIO() - retcode = ledger.main(arglist, output, errors, config) + with clean_account_meta(): + retcode = ledger.main(arglist, output, errors, config) output.seek(0) return retcode, output, errors @@ -292,6 +288,30 @@ def test_main(ledger_entries): for _, expected in ExpectedPostings.group_by_account(postings): expected.check_report(ods, START_DATE, STOP_DATE) +@pytest.mark.parametrize('acct_arg', [ + 'Liabilities', + 'Accounts payable', +]) +def test_main_account_limit(ledger_entries, acct_arg): + retcode, output, errors = run_main([ + '-a', acct_arg, + '-b', START_DATE.isoformat(), + '-e', STOP_DATE.isoformat(), + ]) + assert not errors.getvalue() + assert retcode == 0 + ods = odf.opendocument.load(output) + assert get_sheet_names(ods) == ['Balance', 'Liabilities'] + postings = data.Posting.from_entries(ledger_entries) + for account, expected in ExpectedPostings.group_by_account(postings): + should_find = account.startswith('Liabilities') + try: + expected.check_report(ods, START_DATE, STOP_DATE) + except NotFound: + assert not should_find + else: + assert should_find + @pytest.mark.parametrize('project,start_date,stop_date', [ ('eighteen', START_DATE, MID_DATE.replace(day=30)), ('nineteen', MID_DATE, STOP_DATE), diff --git a/tests/testutil.py b/tests/testutil.py index f46b1fcadd55f8562039bbdbc9641758f5d028d9..9534bff4382ead304b93a66256879a0ad1905e69 100644 --- a/tests/testutil.py +++ b/tests/testutil.py @@ -44,9 +44,11 @@ TESTS_DIR = Path(__file__).parent # it with different scopes. Typical usage looks like: # clean_account_meta = pytest.fixture([options])(testutil.clean_account_meta) def clean_account_meta(): - yield - data.Account.load_options_map(bc_options.OPTIONS_DEFAULTS) - data.Account._meta_map.clear() + try: + yield + finally: + data.Account.load_options_map(bc_options.OPTIONS_DEFAULTS) + data.Account._meta_map.clear() def _ods_cell_value_type(cell): assert cell.tagName == 'table:table-cell'