reports: Add Balance.format() method.
This commit is contained in:
parent
3780c31c59
commit
2c44cc8f50
4 changed files with 73 additions and 14 deletions
|
@ -19,10 +19,13 @@ import operator
|
||||||
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import babel.numbers # type:ignore[import]
|
||||||
|
|
||||||
from .. import data
|
from .. import data
|
||||||
|
|
||||||
from typing import (
|
from typing import (
|
||||||
overload,
|
overload,
|
||||||
|
Any,
|
||||||
Callable,
|
Callable,
|
||||||
DefaultDict,
|
DefaultDict,
|
||||||
Dict,
|
Dict,
|
||||||
|
@ -65,11 +68,7 @@ class Balance(Mapping[str, data.Amount]):
|
||||||
return f"{type(self).__name__}({self._currency_map!r})"
|
return f"{type(self).__name__}({self._currency_map!r})"
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
amounts = [amount for amount in self.values() if amount.number]
|
return self.format()
|
||||||
if not amounts:
|
|
||||||
return "Zero balance"
|
|
||||||
amounts.sort(key=lambda amt: abs(amt.number), reverse=True)
|
|
||||||
return ', '.join(str(amount) for amount in amounts)
|
|
||||||
|
|
||||||
def __eq__(self, other: Any) -> bool:
|
def __eq__(self, other: Any) -> bool:
|
||||||
if (self.is_zero()
|
if (self.is_zero()
|
||||||
|
@ -113,6 +112,26 @@ class Balance(Mapping[str, data.Amount]):
|
||||||
"""Returns true if all amounts in the balance <= 0."""
|
"""Returns true if all amounts in the balance <= 0."""
|
||||||
return self._all_amounts(operator.le, 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):
|
class MutableBalance(Balance):
|
||||||
__slots__ = ()
|
__slots__ = ()
|
||||||
|
|
1
setup.py
1
setup.py
|
@ -11,6 +11,7 @@ setup(
|
||||||
license='GNU AGPLv3+',
|
license='GNU AGPLv3+',
|
||||||
|
|
||||||
install_requires=[
|
install_requires=[
|
||||||
|
'babel>=2.6', # Debian:python3-babel
|
||||||
'beancount>=2.2', # Debian:beancount
|
'beancount>=2.2', # Debian:beancount
|
||||||
'PyYAML>=3.0', # Debian:python3-yaml
|
'PyYAML>=3.0', # Debian:python3-yaml
|
||||||
'regex', # Debian:python3-regex
|
'regex', # Debian:python3-regex
|
||||||
|
|
|
@ -249,7 +249,7 @@ def check_output(output, expect_patterns):
|
||||||
('rt:505/5050', "Zero balance outstanding since 2020-05-05"),
|
('rt:505/5050', "Zero balance outstanding since 2020-05-05"),
|
||||||
('rt:510/5100', "Zero balance outstanding since 2020-05-10"),
|
('rt:510/5100', "Zero balance outstanding since 2020-05-10"),
|
||||||
('rt:510/6100', "-280.00 USD outstanding since 2020-06-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):
|
def test_balance_report(accrual_postings, invoice, expected):
|
||||||
related = core.RelatedPostings(
|
related = core.RelatedPostings(
|
||||||
|
@ -392,7 +392,7 @@ def test_main_balance_report(arglist):
|
||||||
assert retcode == 0
|
assert retcode == 0
|
||||||
check_output(output, [
|
check_output(output, [
|
||||||
r'\brt://ticket/515/attachments/5150:$',
|
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():
|
def test_main_no_books():
|
||||||
|
|
|
@ -24,6 +24,14 @@ from . import testutil
|
||||||
|
|
||||||
from conservancy_beancount.reports import core
|
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():
|
def test_empty_balance():
|
||||||
balance = core.Balance()
|
balance = core.Balance()
|
||||||
assert not balance
|
assert not balance
|
||||||
|
@ -150,13 +158,44 @@ def test_eq(kwargs1, kwargs2, expected):
|
||||||
actual = bal1 == bal2
|
actual = bal1 == bal2
|
||||||
assert actual == expected
|
assert actual == expected
|
||||||
|
|
||||||
@pytest.mark.parametrize('balance_map_kwargs,expected', [
|
@pytest.mark.parametrize('balance_map_kwargs,expected', 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': '-55.00', 'BRL': '-85.00'}, "-85.00 BRL, -55.00 JPY"),
|
|
||||||
])
|
|
||||||
def test_str(balance_map_kwargs, expected):
|
def test_str(balance_map_kwargs, expected):
|
||||||
amounts = testutil.balance_map(**balance_map_kwargs)
|
amounts = testutil.balance_map(**balance_map_kwargs)
|
||||||
assert str(core.Balance(amounts.items())) == expected
|
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
|
||||||
|
|
Loading…
Reference in a new issue