From 7f3a26b5557d548aa7d9b852538f4a098d67c246 Mon Sep 17 00:00:00 2001 From: Brett Smith Date: Thu, 18 Jun 2020 14:07:44 -0400 Subject: [PATCH] reports: Balance.format() accepts zero argument. This change has the same motivation as the recent change to BaseODS.balance_cell(): try to preserve currency information when it's available. --- conservancy_beancount/reports/accrual.py | 3 +- conservancy_beancount/reports/core.py | 27 +++++++++------ tests/test_reports_balance.py | 44 +++++++++++++++--------- 3 files changed, 47 insertions(+), 27 deletions(-) diff --git a/conservancy_beancount/reports/accrual.py b/conservancy_beancount/reports/accrual.py index 59359c8..f8f7958 100644 --- a/conservancy_beancount/reports/accrual.py +++ b/conservancy_beancount/reports/accrual.py @@ -463,10 +463,11 @@ class BalanceReport(BaseReport): def _report(self, posts: AccrualPostings, index: int) -> Iterable[str]: posts = posts.since_last_nonzero() date_s = posts[0].meta.date.strftime('%Y-%m-%d') + balance_s = posts.balance_at_cost().format(zero="Zero balance") if index: yield "" yield f"{posts.invoice}:" - yield f" {posts.balance_at_cost()} outstanding since {date_s}" + yield f" {balance_s} outstanding since {date_s}" class OutgoingReport(BaseReport): diff --git a/conservancy_beancount/reports/core.py b/conservancy_beancount/reports/core.py index 7f93368..3c3952e 100644 --- a/conservancy_beancount/reports/core.py +++ b/conservancy_beancount/reports/core.py @@ -202,28 +202,35 @@ class Balance(Mapping[str, data.Amount]): return self._all_amounts(op_func, self.tolerance) def format(self, - fmt: Optional[str]='#,#00.00 ¤¤', + fmt: Optional[str]='#,##0.00 ¤¤', sep: str=', ', empty: str="Zero balance", + zero: Optional[str]=None, tolerance: Optional[Decimal]=None, ) -> str: """Formats the balance as a string with the given parameters - If the balance is zero (within tolerance), returns ``empty``. - Otherwise, returns a string with each amount in the balance formatted + If the balance is completely empty, return ``empty``. + If the balance is zero (within tolerance) and ``zero`` is specified, + return ``zero``. + Otherwise, return a string with each amount in the balance formatted as ``fmt``, separated by ``sep``. If you set ``fmt`` to None, amounts will be formatted according to the user's locale. The default format is Beancount's input format. """ - amounts = list(self.clean_copy(tolerance).values()) - if not amounts: + balance = self.clean_copy(tolerance) or self.copy(tolerance) + if not balance: return empty - amounts.sort(key=lambda amt: abs(amt.number), reverse=True) - return sep.join( - babel.numbers.format_currency(amt.number, amt.currency, fmt) - for amt in amounts - ) + elif zero is not None and balance.is_zero(): + return zero + else: + amounts = list(balance.values()) + amounts.sort(key=lambda amt: (-abs(amt.number), amt.currency)) + return sep.join( + babel.numbers.format_currency(amt.number, amt.currency, fmt) + for amt in amounts + ) class MutableBalance(Balance): diff --git a/tests/test_reports_balance.py b/tests/test_reports_balance.py index 24c435e..ce81dce 100644 --- a/tests/test_reports_balance.py +++ b/tests/test_reports_balance.py @@ -28,12 +28,12 @@ from conservancy_beancount.reports import core DEFAULT_STRINGS = [ ({}, "Zero balance"), - ({'JPY': 0, 'BRL': 0}, "Zero balance"), + ({'JPY': 0, 'BRL': 0}, "0.00 BRL, 0 JPY"), ({'USD': '20.00'}, "20.00 USD"), ({'EUR': '50.00', 'GBP': '80.00'}, "80.00 GBP, 50.00 EUR"), ({'JPY': '-5500.00', 'BRL': '-8500.00'}, "-8,500.00 BRL, -5,500 JPY"), ({'USD': 10, 'EUR': '.00015'}, "10.00 USD"), - ({'JPY': '-.00015'}, "Zero balance"), + ({'JPY': '-.00015'}, "-0 JPY"), ] TOLERANCES = [Decimal(n) for n in ['.1', '.01', '.001', 0]] @@ -385,9 +385,9 @@ def test_format_defaults(mapping, expected): @pytest.mark.parametrize('fmt,expected', [ ('¤##0.0', '¥5000, -€1500.00'), - ('#,#00.0¤¤', '5,000JPY, -1,500.00EUR'), + ('#,##0.0¤¤', '5,000JPY, -1,500.00EUR'), ('¤+##0.0;¤-##0.0', '¥+5000, €-1500.00'), - ('#,#00.0 ¤¤;(#,#00.0 ¤¤)', '5,000 JPY, (1,500.00 EUR)'), + ('#,##0.0 ¤¤;(#,##0.0 ¤¤)', '5,000 JPY, (1,500.00 EUR)'), ]) def test_format_fmt(fmt, expected): amounts = [testutil.Amount(5000, 'JPY'), testutil.Amount(-1500, 'EUR')] @@ -420,20 +420,32 @@ def test_format_empty(empty): balance = core.Balance() assert balance.format(empty=empty) == empty -@pytest.mark.parametrize('tolerance', TOLERANCES) -def test_str_tolerance(tolerance): - chf = testutil.Amount('.005', 'CHF') - actual = str(core.Balance([chf], tolerance)) - if tolerance > chf.number: - assert actual == "Zero balance" - else: - assert actual == "00.00 CHF" +@pytest.mark.parametrize('currency,fmt', itertools.product( + ['USD', 'JPY', 'BRL'], + [None, '¤#,##0.00', '###0.00 ¤¤'], +)) +def test_format_zero_balance_fmt(currency, fmt): + zero_amt = testutil.Amount(0, currency) + nonzero_amt = testutil.Amount(9, currency) + zero_bal = core.Balance([zero_amt]) + nonzero_bal = core.Balance([nonzero_amt]) + expected = nonzero_bal.format(fmt).replace('9', '0') + assert zero_bal.format(fmt) == expected + +@pytest.mark.parametrize('currency,fmt', testutil.combine_values( + ['USD', 'JPY', 'BRL'], + ["N/A", "Zero", "ø"], +)) +def test_format_zero_balance_zero_str(currency, fmt): + zero_amt = testutil.Amount(0, currency) + zero_bal = core.Balance([zero_amt]) + assert zero_bal.format(zero=fmt) == fmt @pytest.mark.parametrize('tolerance', TOLERANCES) -def test_format_tolerance(tolerance): +def test_format_zero_balance_with_tolerance(tolerance): chf = testutil.Amount('.005', 'CHF') - actual = core.Balance([chf]).format(tolerance=tolerance) + actual = core.Balance([chf]).format(zero="ø", tolerance=tolerance) if tolerance > chf.number: - assert actual == "Zero balance" + assert actual == "ø" else: - assert actual == "00.00 CHF" + assert actual == "0.00 CHF"