diff --git a/conservancy_beancount/reports/core.py b/conservancy_beancount/reports/core.py index e2f474ed1332c948d985b6dd3be77cc010b6da57..458f16faa920d633dc5d76b21f6bf8654d402351 100644 --- a/conservancy_beancount/reports/core.py +++ b/conservancy_beancount/reports/core.py @@ -19,10 +19,13 @@ import operator from decimal import Decimal +import babel.numbers # type:ignore[import] + from .. import data from typing import ( overload, + Any, Callable, DefaultDict, Dict, @@ -65,11 +68,7 @@ class Balance(Mapping[str, data.Amount]): return f"{type(self).__name__}({self._currency_map!r})" def __str__(self) -> str: - amounts = [amount for amount in self.values() if amount.number] - if not amounts: - return "Zero balance" - amounts.sort(key=lambda amt: abs(amt.number), reverse=True) - return ', '.join(str(amount) for amount in amounts) + return self.format() def __eq__(self, other: Any) -> bool: if (self.is_zero() @@ -113,6 +112,26 @@ class Balance(Mapping[str, data.Amount]): """Returns true if all amounts in the balance <= 0.""" return self._all_amounts(operator.le, 0) + def format(self, + fmt: str='#,#00.00 ¤¤', + sep: str=', ', + empty: str="Zero balance", + ) -> str: + """Formats the balance as a string with the given parameters + + If the balance is zero, returns ``empty``. Otherwise, returns a string + with each amount in the balance formatted as ``fmt``, separated by + ``sep``. + """ + amounts = [amount for amount in self.values() if amount.number] + if not amounts: + 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 + ) + class MutableBalance(Balance): __slots__ = () diff --git a/setup.py b/setup.py index d772e5b5659acaf497b3ce27130984adae9321ad..f7a92f163dc01c5199c97e0d9afd93d3ef1330d0 100755 --- a/setup.py +++ b/setup.py @@ -11,6 +11,7 @@ setup( license='GNU AGPLv3+', install_requires=[ + 'babel>=2.6', # Debian:python3-babel 'beancount>=2.2', # Debian:beancount 'PyYAML>=3.0', # Debian:python3-yaml 'regex', # Debian:python3-regex diff --git a/tests/test_reports_accrual.py b/tests/test_reports_accrual.py index 85b5e3ca6644717f4e086d504cd928f59ea2a0cb..5356285a09b2e71aa0b7d148fa79032bd2604a70 100644 --- a/tests/test_reports_accrual.py +++ b/tests/test_reports_accrual.py @@ -249,7 +249,7 @@ def check_output(output, expect_patterns): ('rt:505/5050', "Zero balance outstanding since 2020-05-05"), ('rt:510/5100', "Zero balance outstanding since 2020-05-10"), ('rt:510/6100', "-280.00 USD outstanding since 2020-06-10"), - ('rt://ticket/515/attachments/5150', "1500.00 USD outstanding since 2020-05-15",), + ('rt://ticket/515/attachments/5150', "1,500.00 USD outstanding since 2020-05-15",), ]) def test_balance_report(accrual_postings, invoice, expected): related = core.RelatedPostings( @@ -392,7 +392,7 @@ def test_main_balance_report(arglist): assert retcode == 0 check_output(output, [ r'\brt://ticket/515/attachments/5150:$', - r'^\s+1500\.00 USD outstanding since 2020-05-15$', + r'^\s+1,500\.00 USD outstanding since 2020-05-15$', ]) def test_main_no_books(): diff --git a/tests/test_reports_balance.py b/tests/test_reports_balance.py index 07e238662870c712fc76a39a84f210ac8aa691c1..7913156940639c97d059d69c585b24c8fbfa7d4d 100644 --- a/tests/test_reports_balance.py +++ b/tests/test_reports_balance.py @@ -24,6 +24,14 @@ from . import testutil from conservancy_beancount.reports import core +DEFAULT_STRINGS = [ + ({}, "Zero balance"), + ({'JPY': 0, 'BRL': 0}, "Zero balance"), + ({'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"), +] + def test_empty_balance(): balance = core.Balance() assert not balance @@ -150,13 +158,44 @@ def test_eq(kwargs1, kwargs2, expected): actual = bal1 == bal2 assert actual == expected -@pytest.mark.parametrize('balance_map_kwargs,expected', [ - ({}, "Zero balance"), - ({'JPY': 0, 'BRL': 0}, "Zero balance"), - ({'USD': '20.00'}, "20.00 USD"), - ({'EUR': '50.00', 'GBP': '80.00'}, "80.00 GBP, 50.00 EUR"), - ({'JPY': '-55.00', 'BRL': '-85.00'}, "-85.00 BRL, -55.00 JPY"), -]) +@pytest.mark.parametrize('balance_map_kwargs,expected', DEFAULT_STRINGS) def test_str(balance_map_kwargs, expected): amounts = testutil.balance_map(**balance_map_kwargs) assert str(core.Balance(amounts.items())) == expected + +@pytest.mark.parametrize('bal_kwargs,expected', DEFAULT_STRINGS) +def test_format_defaults(bal_kwargs, expected): + amounts = testutil.balance_map(**bal_kwargs) + assert core.Balance(amounts).format() == expected + +@pytest.mark.parametrize('fmt,expected', [ + ('¤##0.0', '¥5000, -€1500.00'), + ('#,#00.0¤¤', '5,000JPY, -1,500.00EUR'), + ('¤+##0.0;¤-##0.0', '¥+5000, €-1500.00'), + ('#,#00.0 ¤¤;(#,#00.0 ¤¤)', '5,000 JPY, (1,500.00 EUR)'), +]) +def test_format_fmt(fmt, expected): + amounts = testutil.balance_map(JPY=5000, EUR=-1500) + balance = core.Balance(amounts) + assert balance.format(fmt) == expected + +@pytest.mark.parametrize('sep', [ + '; ', + '—', + '\0', +]) +def test_format_sep(sep): + bal_kwargs, expected = DEFAULT_STRINGS[-1] + expected = expected.replace(', ', sep) + amounts = testutil.balance_map(**bal_kwargs) + balance = core.Balance(amounts) + assert balance.format(sep=sep) == expected + +@pytest.mark.parametrize('empty', [ + "N/A", + "Zero", + "ø", +]) +def test_format_empty(empty): + balance = core.Balance() + assert balance.format(empty=empty) == empty