Changeset - 4cba2b2681ac
[Not reviewed]
0 2 0
Brett Smith - 4 years ago 2020-06-04 02:20:37
brettcsmith@brettcsmith.org
reports: Balance has built-in tolerance for zero comparisons.
2 files changed with 26 insertions and 8 deletions:
0 comments (0 inline, 0 general)
conservancy_beancount/reports/core.py
Show inline comments
...
 
@@ -71,28 +71,35 @@ BalanceType = TypeVar('BalanceType', bound='Balance')
 
ElementType = Callable[..., odf.element.Element]
 
LinkType = Union[str, Tuple[str, Optional[str]]]
 
RelatedType = TypeVar('RelatedType', bound='RelatedPostings')
 
RT = TypeVar('RT', bound=Sequence)
 
ST = TypeVar('ST')
 

	
 
class Balance(Mapping[str, data.Amount]):
 
    """A collection of amounts mapped by currency
 

	
 
    Each key is a Beancount currency string, and each value represents the
 
    balance in that currency.
 
    """
 
    __slots__ = ('_currency_map',)
 
    __slots__ = ('_currency_map', 'tolerance')
 
    TOLERANCE = Decimal('0.01')
 

	
 
    def __init__(self, source: Iterable[data.Amount]=()) -> None:
 
    def __init__(self,
 
                 source: Iterable[data.Amount]=(),
 
                 tolerance: Optional[Decimal]=None,
 
    ) -> None:
 
        if tolerance is None:
 
            tolerance = self.TOLERANCE
 
        self._currency_map = {amount.currency: amount for amount in source}
 
        self.tolerance = tolerance
 

	
 
    def _add_amount(self,
 
                    currency_map: MutableMapping[str, data.Amount],
 
                    amount: data.Amount,
 
    ) -> None:
 
        code = amount.currency
 
        try:
 
            current_number = currency_map[code].number
 
        except KeyError:
 
            current_number = Decimal(0)
 
        currency_map[code] = data.Amount(current_number + amount.number, code)
 

	
...
 
@@ -138,37 +145,42 @@ class Balance(Mapping[str, data.Amount]):
 
    def __iter__(self) -> Iterator[str]:
 
        return iter(self._currency_map)
 

	
 
    def __len__(self) -> int:
 
        return len(self._currency_map)
 

	
 
    def _all_amounts(self,
 
                     op_func: Callable[[DecimalCompat, DecimalCompat], bool],
 
                     operand: DecimalCompat,
 
    ) -> bool:
 
        return all(op_func(amt.number, operand) for amt in self.values())
 

	
 
    @staticmethod
 
    def within_tolerance(dec: DecimalCompat, tolerance: DecimalCompat) -> bool:
 
        dec = cast(Decimal, dec)
 
        return abs(dec) < tolerance
 

	
 
    def eq_zero(self) -> bool:
 
        """Returns true if all amounts in the balance == 0."""
 
        return self._all_amounts(operator.eq, 0)
 
        """Returns true if all amounts in the balance == 0, within tolerance."""
 
        return self._all_amounts(self.within_tolerance, self.tolerance)
 

	
 
    is_zero = eq_zero
 

	
 
    def ge_zero(self) -> bool:
 
        """Returns true if all amounts in the balance >= 0."""
 
        return self._all_amounts(operator.ge, 0)
 
        """Returns true if all amounts in the balance >= 0, within tolerance."""
 
        return self._all_amounts(operator.ge, -self.tolerance)
 

	
 
    def le_zero(self) -> bool:
 
        """Returns true if all amounts in the balance <= 0."""
 
        return self._all_amounts(operator.le, 0)
 
        """Returns true if all amounts in the balance <= 0, within tolerance."""
 
        return self._all_amounts(operator.le, self.tolerance)
 

	
 
    def format(self,
 
               fmt: Optional[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``.
 

	
tests/test_reports_balance.py
Show inline comments
...
 
@@ -83,55 +83,61 @@ def test_mixed_balance():
 

	
 
@pytest.mark.parametrize('mapping,expected', [
 
    ({}, True),
 
    ({'USD': 0}, True),
 
    ({'USD': 0, 'EUR': 0}, True),
 
    ({'USD': -10, 'EUR': 0}, False),
 
    ({'EUR': -10}, False),
 
    ({'USD': -10, 'EUR': -20}, False),
 
    ({'USD': 10, 'EUR': -20}, False),
 
    ({'JPY': 10}, False),
 
    ({'JPY': 10, 'BRL': 0}, False),
 
    ({'JPY': 10, 'BRL': 20}, False),
 
    ({'USD': '0.00015'}, True),
 
    ({'EUR': '-0.00052'}, True),
 
])
 
def test_eq_zero(mapping, expected):
 
    balance = core.Balance(amounts_from_map(mapping))
 
    assert balance.eq_zero() == expected
 
    assert balance.is_zero() == expected
 

	
 
@pytest.mark.parametrize('mapping,expected', [
 
    ({}, True),
 
    ({'USD': 0}, True),
 
    ({'USD': 0, 'EUR': 0}, True),
 
    ({'EUR': -10}, False),
 
    ({'USD': 10, 'EUR': -20}, False),
 
    ({'USD': -10, 'EUR': -20}, False),
 
    ({'JPY': 10}, True),
 
    ({'JPY': 10, 'BRL': 0}, True),
 
    ({'JPY': 10, 'BRL': 20}, True),
 
    ({'USD': '0.00015'}, True),
 
    ({'EUR': '-0.00052'}, True),
 
])
 
def test_ge_zero(mapping, expected):
 
    balance = core.Balance(amounts_from_map(mapping))
 
    assert balance.ge_zero() == expected
 

	
 
@pytest.mark.parametrize('mapping,expected', [
 
    ({}, True),
 
    ({'USD': 0}, True),
 
    ({'USD': 0, 'EUR': 0}, True),
 
    ({'EUR': -10}, True),
 
    ({'USD': 10, 'EUR': -20}, False),
 
    ({'USD': -10, 'EUR': -20}, True),
 
    ({'JPY': 10}, False),
 
    ({'JPY': 10, 'BRL': 0}, False),
 
    ({'JPY': 10, 'BRL': 20}, False),
 
    ({'USD': '0.00015'}, True),
 
    ({'EUR': '-0.00052'}, True),
 
])
 
def test_le_zero(mapping, expected):
 
    balance = core.Balance(amounts_from_map(mapping))
 
    assert balance.le_zero() == expected
 

	
 
@pytest.mark.parametrize('mapping', [
 
    {},
 
    {'USD': 0},
 
    {'EUR': 10},
 
    {'JPY': 20, 'BRL': 30},
 
    {'EUR': -15},
 
    {'JPY': -25, 'BRL': -35},
0 comments (0 inline, 0 general)