reports: Add sort_and_filter_accounts() function.
Extracted from the ledger report.
This commit is contained in:
parent
5b68312924
commit
7a9bc2da50
3 changed files with 106 additions and 19 deletions
|
@ -1069,3 +1069,46 @@ def normalize_amount_func(account_name: str) -> Callable[[T], T]:
|
||||||
return operator.neg
|
return operator.neg
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"unrecognized account name {account_name!r}")
|
raise ValueError(f"unrecognized account name {account_name!r}")
|
||||||
|
|
||||||
|
def sort_and_filter_accounts(
|
||||||
|
accounts: Iterable[data.Account],
|
||||||
|
order: Sequence[str],
|
||||||
|
) -> Iterator[Tuple[int, data.Account]]:
|
||||||
|
"""Reorganize accounts based on an ordered set of names
|
||||||
|
|
||||||
|
This function takes a iterable of Account objects, and a sequence of
|
||||||
|
account names. Usually the account names are higher parts of the account
|
||||||
|
hierarchy like Income, Equity, or Assets:Receivable.
|
||||||
|
|
||||||
|
It returns an iterator of 2-tuples, ``(index, account)`` where ``index`` is
|
||||||
|
an index into the ordering sequence, and ``account`` is one of the input
|
||||||
|
Account objects that's under the account name ``order[index]``. Tuples are
|
||||||
|
sorted, so ``index`` increases monotonically, and Account objects using the
|
||||||
|
same index are yielded sorted by name.
|
||||||
|
|
||||||
|
For example, if your order is
|
||||||
|
``['Liabilities:Payable', 'Assets:Receivable']``, the return value will
|
||||||
|
first yield zero or more results with index 0 and an account under
|
||||||
|
Liabilities:Payable, then zero or more results with index 1 and an account
|
||||||
|
under Accounts:Receivable.
|
||||||
|
|
||||||
|
Input Accounts that are not under any of the account names in ``order`` do
|
||||||
|
not appear in the output iterator. That's the filtering part.
|
||||||
|
|
||||||
|
Note that if none of the input Accounts are under one of the ordering
|
||||||
|
sequence accounts, its index will never appear in the results. This is why
|
||||||
|
the 2-tuples include an index rather than the original account name string,
|
||||||
|
to make it easier for callers to know when this happens and do something
|
||||||
|
with unused ordering accounts.
|
||||||
|
"""
|
||||||
|
index_map = {s: ii for ii, s in enumerate(order)}
|
||||||
|
retval: Mapping[int, List[data.Account]] = collections.defaultdict(list)
|
||||||
|
for account in accounts:
|
||||||
|
acct_key = account.is_under(*order)
|
||||||
|
if acct_key is not None:
|
||||||
|
retval[index_map[acct_key]].append(account)
|
||||||
|
return (
|
||||||
|
(key, account)
|
||||||
|
for key in sorted(retval)
|
||||||
|
for account in sorted(retval[key])
|
||||||
|
)
|
||||||
|
|
|
@ -240,23 +240,6 @@ class LedgerODS(core.BaseODS[data.Posting, data.Account]):
|
||||||
for sheet_name in cls._split_sheet(split_tally[key], sheet_size, key)
|
for sheet_name in cls._split_sheet(split_tally[key], sheet_size, key)
|
||||||
]
|
]
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _sort_and_filter_accounts(
|
|
||||||
accounts: Iterable[data.Account],
|
|
||||||
order: Sequence[str],
|
|
||||||
) -> Iterator[Tuple[int, data.Account]]:
|
|
||||||
index_map = {s: ii for ii, s in enumerate(order)}
|
|
||||||
retval: Mapping[int, List[data.Account]] = collections.defaultdict(list)
|
|
||||||
for account in accounts:
|
|
||||||
acct_key = account.is_under(*order)
|
|
||||||
if acct_key is not None:
|
|
||||||
retval[index_map[acct_key]].append(account)
|
|
||||||
for key in sorted(retval):
|
|
||||||
acct_list = retval[key]
|
|
||||||
acct_list.sort()
|
|
||||||
for account in acct_list:
|
|
||||||
yield key, account
|
|
||||||
|
|
||||||
def section_key(self, row: data.Posting) -> data.Account:
|
def section_key(self, row: data.Posting) -> data.Account:
|
||||||
return row.account
|
return row.account
|
||||||
|
|
||||||
|
@ -383,7 +366,7 @@ class LedgerODS(core.BaseODS[data.Posting, data.Account]):
|
||||||
))
|
))
|
||||||
self.add_row()
|
self.add_row()
|
||||||
self._combined_balance_row(balance_accounts, 'start')
|
self._combined_balance_row(balance_accounts, 'start')
|
||||||
for _, account in self._sort_and_filter_accounts(
|
for _, account in core.sort_and_filter_accounts(
|
||||||
self.account_groups, balance_accounts,
|
self.account_groups, balance_accounts,
|
||||||
):
|
):
|
||||||
balance = self.account_groups[account].period_bal
|
balance = self.account_groups[account].period_bal
|
||||||
|
@ -413,7 +396,7 @@ class LedgerODS(core.BaseODS[data.Posting, data.Account]):
|
||||||
tally_by_account, self.required_sheet_names, self.sheet_size,
|
tally_by_account, self.required_sheet_names, self.sheet_size,
|
||||||
)
|
)
|
||||||
using_sheet_index = -1
|
using_sheet_index = -1
|
||||||
for sheet_index, account in self._sort_and_filter_accounts(
|
for sheet_index, account in core.sort_and_filter_accounts(
|
||||||
tally_by_account, sheet_names,
|
tally_by_account, sheet_names,
|
||||||
):
|
):
|
||||||
while using_sheet_index < sheet_index:
|
while using_sheet_index < sheet_index:
|
||||||
|
|
|
@ -22,6 +22,8 @@ from . import testutil
|
||||||
|
|
||||||
from conservancy_beancount.reports import core
|
from conservancy_beancount.reports import core
|
||||||
|
|
||||||
|
from conservancy_beancount.data import Account
|
||||||
|
|
||||||
AMOUNTS = [
|
AMOUNTS = [
|
||||||
2,
|
2,
|
||||||
Decimal('4.40'),
|
Decimal('4.40'),
|
||||||
|
@ -64,3 +66,62 @@ def test_normalize_amount_func_neg(acct_name):
|
||||||
def test_normalize_amount_func_bad_acct_name(acct_name):
|
def test_normalize_amount_func_bad_acct_name(acct_name):
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
core.normalize_amount_func(acct_name)
|
core.normalize_amount_func(acct_name)
|
||||||
|
|
||||||
|
def test_sort_and_filter_accounts():
|
||||||
|
accounts = (Account(s) for s in [
|
||||||
|
'Expenses:Services',
|
||||||
|
'Assets:Receivable',
|
||||||
|
'Income:Other',
|
||||||
|
'Liabilities:Payable',
|
||||||
|
'Equity:Funds:Unrestricted',
|
||||||
|
'Income:Donations',
|
||||||
|
'Expenses:Other',
|
||||||
|
])
|
||||||
|
actual = core.sort_and_filter_accounts(accounts, ['Equity', 'Income', 'Expenses'])
|
||||||
|
assert list(actual) == [
|
||||||
|
(0, 'Equity:Funds:Unrestricted'),
|
||||||
|
(1, 'Income:Donations'),
|
||||||
|
(1, 'Income:Other'),
|
||||||
|
(2, 'Expenses:Other'),
|
||||||
|
(2, 'Expenses:Services'),
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_sort_and_filter_accounts_unused_name():
|
||||||
|
accounts = (Account(s) for s in [
|
||||||
|
'Liabilities:CreditCard',
|
||||||
|
'Assets:Cash',
|
||||||
|
'Assets:Receivable:Accounts',
|
||||||
|
])
|
||||||
|
actual = core.sort_and_filter_accounts(
|
||||||
|
accounts, ['Assets:Receivable', 'Liabilities:Payable', 'Assets', 'Liabilities'],
|
||||||
|
)
|
||||||
|
assert list(actual) == [
|
||||||
|
(0, 'Assets:Receivable:Accounts'),
|
||||||
|
(2, 'Assets:Cash'),
|
||||||
|
(3, 'Liabilities:CreditCard'),
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_sort_and_filter_accounts_with_subaccounts():
|
||||||
|
accounts = (Account(s) for s in [
|
||||||
|
'Assets:Checking',
|
||||||
|
'Assets:Receivable:Fraud',
|
||||||
|
'Assets:Cash',
|
||||||
|
'Assets:Receivable:Accounts',
|
||||||
|
])
|
||||||
|
actual = core.sort_and_filter_accounts(accounts, ['Assets:Receivable', 'Assets'])
|
||||||
|
assert list(actual) == [
|
||||||
|
(0, 'Assets:Receivable:Accounts'),
|
||||||
|
(0, 'Assets:Receivable:Fraud'),
|
||||||
|
(1, 'Assets:Cash'),
|
||||||
|
(1, 'Assets:Checking'),
|
||||||
|
]
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('empty_arg', ['accounts', 'order'])
|
||||||
|
def test_sort_and_filter_accounts_empty_accounts(empty_arg):
|
||||||
|
accounts = [Account(s) for s in ['Expenses:Other', 'Income:Other']]
|
||||||
|
if empty_arg == 'accounts':
|
||||||
|
args = ([], accounts)
|
||||||
|
else:
|
||||||
|
args = (accounts, [])
|
||||||
|
actual = core.sort_and_filter_accounts(*args)
|
||||||
|
assert next(actual, None) is None
|
||||||
|
|
Loading…
Reference in a new issue