From b8fed2fa26eef5a05442d0580f1ab6755a330638 Mon Sep 17 00:00:00 2001 From: Anton Agestam Date: Mon, 9 Oct 2023 00:05:11 +0200 Subject: [PATCH] feature: Replace Decimal with int representation This commit changes to use plain int as internal value type for Money and Overdraft. --- setup.cfg | 1 + src/immoney/_base.py | 246 ++++++++++++++++----------- src/immoney/_cache.py | 4 +- src/immoney/_parsers.py | 30 ++++ tests/strategies.py | 9 +- tests/test_arithmetic.py | 36 ++-- tests/test_base.py | 350 ++++++++++++++++++--------------------- 7 files changed, 373 insertions(+), 303 deletions(-) create mode 100644 src/immoney/_parsers.py diff --git a/setup.cfg b/setup.cfg index 3eb84ba..f276cef 100644 --- a/setup.cfg +++ b/setup.cfg @@ -104,3 +104,4 @@ exclude_lines = # ignore non-implementations ^\s*\.\.\. ^\s*if TYPE_CHECKING:$ + ^\s*assert_never\( diff --git a/src/immoney/_base.py b/src/immoney/_base.py index ae4c1de..79016ed 100644 --- a/src/immoney/_base.py +++ b/src/immoney/_base.py @@ -1,7 +1,6 @@ from __future__ import annotations import abc -import decimal import enum import math from decimal import ROUND_05UP @@ -18,7 +17,6 @@ from typing import ClassVar from typing import Final from typing import Generic -from typing import NewType from typing import TypeAlias from typing import TypeVar from typing import final @@ -27,9 +25,13 @@ from abcattrs import Abstract from abcattrs import abstractattrs from typing_extensions import Self +from typing_extensions import assert_never from ._cache import InstanceCache from ._frozen import Frozen +from ._parsers import Nat +from ._parsers import approximate_decimal_subunits +from ._parsers import parse_nat from .errors import DivisionByZero from .errors import InvalidOverdraftValue from .errors import InvalidSubunit @@ -40,8 +42,8 @@ from .registry import CurrencyRegistry + ParsableMoneyValue: TypeAlias = int | str | Decimal -PositiveDecimal = NewType("PositiveDecimal", Decimal) valid_subunit: Final = frozenset({10**i for i in range(20)}) @@ -82,32 +84,29 @@ def decimal_exponent(self) -> Decimal: def zero(self) -> Money[Self]: return Money(0, self) - def normalize_value(self, value: Decimal | int | str) -> PositiveDecimal: - if not isinstance(value, Decimal): - try: - value = Decimal(value) - except decimal.InvalidOperation: - raise ParseError("Failed parsing Decimal") - - if value.is_nan(): - raise ParseError("Cannot parse from NaN") - - if not value.is_finite(): - raise ParseError("Cannot parse from non-finite") - - if value < 0: - raise ParseError("Cannot parse from negative value") + def normalize_to_subunits(self, main_unit: object) -> Nat: + """ + Takes a raw money value as Decimal, int, or str, and parses it into a valid + subunit value. + """ + if isinstance(main_unit, int): + return parse_nat(main_unit * self.subunit) - quantized = value.quantize(self.decimal_exponent) + if not isinstance(main_unit, str | Decimal): + raise NotImplementedError( + f"Cannot parse money from value of type {type(main_unit)!r}." + ) - if value != quantized: + approximated = approximate_decimal_subunits(main_unit, self.subunit) + exact = int(approximated) + if approximated != exact: raise ParseError( - f"Cannot interpret value as Money of currency {self.code} without loss " - f"of precision. Explicitly round the value or consider using " - f"SubunitFraction." + f"Cannot interpret value as Money of currency {self.code!r} " + f"without loss of precision. Explicitly round the value or " + f"consider using SubunitFraction." ) - return PositiveDecimal(quantized) + return parse_nat(exact) def from_subunit(self, value: int) -> Money[Self]: return Money.from_subunit(value, self) @@ -145,39 +144,73 @@ def __get_pydantic_core_schema__( return build_currency_schema(cls) -def _validate_currency_arg( +def _parse_currency_from_arg( cls: type, value: object, arg_name: str = "currency", -) -> None: +) -> Currency: if not isinstance(value, Currency): raise TypeError( f"Argument {arg_name!r} of {cls.__qualname__!r} must be a Currency, " f"got object of type {type(value)!r}" ) + return value -def _dispatch_type(value: Decimal, currency: C_inv) -> Money[C_inv] | Overdraft[C_inv]: - return Money(value, currency) if value >= 0 else Overdraft(-value, currency) +def _dispatch_type(subunits: int, currency: C_inv) -> Money[C_inv] | Overdraft[C_inv]: + return ( + Money.from_subunit(subunits, currency) + if subunits >= 0 + else Overdraft.from_subunit(-subunits, currency) + ) C_co = TypeVar("C_co", bound=Currency, covariant=True) class _ValueCurrencyPair(Frozen, Generic[C_co], metaclass=InstanceCache): - __slots__ = ("value", "currency") + __slots__ = ("subunits", "currency") + + @overload + def __init__(self, *, subunits: int, currency: C_co) -> None: + ... + @overload def __init__(self, value: ParsableMoneyValue, currency: C_co, /) -> None: + ... + + def __init__( # type: ignore[misc] + self, + value: ParsableMoneyValue, + currency: C_co, + ) -> None: # Type ignore is safe because metaclass delegates normalization to _normalize(). - self.value: Final[Decimal] = value # type: ignore[assignment] + self.subunits: Final[Nat] = value # type: ignore[assignment] self.currency: Final = currency + @classmethod + def _normalize( + cls, + *args: object, + **kwargs: object, + ) -> tuple[Nat, Currency]: + match args, kwargs: + case ((value, currency_arg), {}): + currency = _parse_currency_from_arg(cls, currency_arg) + return currency.normalize_to_subunits(value), currency + case ((), {"subunits": int(subunits), "currency": currency_arg}): + currency = _parse_currency_from_arg(cls, currency_arg) + return parse_nat(subunits), currency + case _: + raise TypeError(f"Invalid call signature for {cls.__qualname__}") + def __repr__(self) -> str: - return f"{type(self).__qualname__}({str(self.value)!r}, {self.currency})" + return f"{type(self).__qualname__}({str(self.decimal)!r}, {self.currency})" @property - def subunits(self) -> int: - return int(self.currency.subunit * self.value) + def decimal(self) -> Decimal: + value = Decimal(self.subunits) / self.currency.subunit + return value.quantize(self.currency.decimal_exponent) C_inv = TypeVar("C_inv", bound=Currency, covariant=False, contravariant=False) @@ -185,54 +218,42 @@ def subunits(self) -> int: @final class Money(_ValueCurrencyPair[C_co], Generic[C_co]): - @classmethod - def _normalize( - cls, - value: ParsableMoneyValue, - currency: C_inv, - /, - ) -> tuple[PositiveDecimal, C_inv]: - _validate_currency_arg(cls, currency) - return currency.normalize_value(value), currency - def __hash__(self) -> int: - return hash((type(self), self.currency, self.value)) + return hash((type(self), self.currency, self.subunits)) def __eq__(self, other: object) -> bool: if isinstance(other, int) and other == 0: - return self.value == other + return self.subunits == other if isinstance(other, Money): - return self.currency == other.currency and self.value == other.value + return self.currency == other.currency and self.subunits == other.subunits return NotImplemented def __gt__(self: Money[C_co], other: Money[C_co]) -> bool: if isinstance(other, Money) and self.currency == other.currency: - return self.value > other.value + return self.subunits > other.subunits return NotImplemented def __ge__(self: Money[C_co], other: Money[C_co]) -> bool: if isinstance(other, Money) and self.currency == other.currency: - return self.value >= other.value + return self.subunits >= other.subunits return NotImplemented def __lt__(self: Money[C_co], other: Money[C_co]) -> bool: if isinstance(other, Money) and self.currency == other.currency: - return self.value < other.value + return self.subunits < other.subunits return NotImplemented def __le__(self: Money[C_co], other: Money[C_co]) -> bool: if isinstance(other, Money) and self.currency == other.currency: - return self.value <= other.value + return self.subunits <= other.subunits return NotImplemented def __iadd__(self: Money[C_co], other: Money[C_co]) -> Money[C_co]: - if isinstance(other, Money) and self.currency == other.currency: - return Money(self.value + other.value, self.currency) - return NotImplemented + return self.__add__(other) def __add__(self: Money[C_co], other: Money[C_co]) -> Money[C_co]: if isinstance(other, Money) and self.currency == other.currency: - return Money(self.value + other.value, self.currency) + return self.currency.from_subunit(self.subunits + other.subunits) return NotImplemented def __sub__(self: Money[C_co], other: Money[C_co]) -> Money[C_co] | Overdraft[C_co]: @@ -246,15 +267,18 @@ def __sub__(self: Money[C_co], other: Money[C_co]) -> Money[C_co] | Overdraft[C_ Overdraft('1.00', SEK) """ if isinstance(other, Money) and self.currency == other.currency: - value = self.value - other.value - return _dispatch_type(value, self.currency) + return _dispatch_type(self.subunits - other.subunits, self.currency) return NotImplemented def __pos__(self) -> Self: return self def __neg__(self: Money[C_co]) -> Overdraft[C_co] | Money[C_co]: - return self if self.value == 0 else Overdraft(self.value, self.currency) + return ( + self + if self.subunits == 0 + else self.currency.overdraft_from_subunit(self.subunits) + ) # TODO: Support precision-lossy multiplication with floats? @overload @@ -270,7 +294,7 @@ def __mul__( other: object, ) -> Money[C_co] | SubunitFraction[C_co] | Overdraft[C_co]: if isinstance(other, int): - return _dispatch_type(self.value * other, self.currency) + return _dispatch_type(self.subunits * other, self.currency) if isinstance(other, Decimal): return SubunitFraction( Fraction(self.subunits) * Fraction(other), @@ -310,13 +334,13 @@ def __truediv__(self: Money[C_co], other: object) -> tuple[Money[C_co], ...]: return NotImplemented try: - under = self.floored(self.value / other, self.currency) - except decimal.DivisionByZero as e: + under = self.currency.from_subunit(self.subunits // other) + except ZeroDivisionError as e: raise DivisionByZero from e under_subunit = under.subunits remainder = self.subunits - under_subunit * other - over = Money.from_subunit(under_subunit + 1, self.currency) + over = self.currency.from_subunit(under_subunit + 1) return ( *(over for _ in range(remainder)), @@ -344,17 +368,17 @@ def __abs__(self) -> Self: @classmethod # This needs HKT to allow typing to work properly for subclasses of Money. def from_subunit(cls, value: int, currency: C_inv) -> Money[C_inv]: - return cls( # type: ignore[return-value] - Decimal(value) / currency.subunit, - currency, # type: ignore[arg-type] + return cls( + subunits=value, + currency=currency, ) @classmethod # This needs HKT to allow typing to work properly for subclasses of Money. def floored(cls, value: Decimal, currency: C_inv) -> Money[C_inv]: - return cls( # type: ignore[return-value] + return cls( value.quantize(currency.decimal_exponent, rounding=ROUND_DOWN), - currency, # type: ignore[arg-type] + currency, ) @classmethod @@ -388,6 +412,9 @@ class Round(enum.Enum): ZERO_FIVE_UP = ROUND_05UP +HALF: Final = Fraction(1, 2) + + @final class SubunitFraction(Frozen, Generic[C_co], metaclass=InstanceCache): __slots__ = ("value", "currency") @@ -422,8 +449,13 @@ def __eq__(self, other: object) -> bool: return self.value == other.value if isinstance(other, Money) and self.currency == other.currency: return self.value == other.subunits + if isinstance(other, Overdraft) and self.currency == other.currency: + return self.value == -other.subunits return NotImplemented + def __neg__(self) -> SubunitFraction[C_co]: + return SubunitFraction(-self.value, self.currency) + @classmethod def from_money( cls, @@ -432,21 +464,43 @@ def from_money( ) -> SubunitFraction[C_co]: return SubunitFraction(Fraction(money.subunits, denominator), money.currency) - def _round_value(self, rounding: Round) -> Decimal: - main_unit = Decimal(float(self.value / self.currency.subunit)) - return main_unit.quantize( - exp=self.currency.decimal_exponent, - rounding=rounding.value, - ) + def _round_subunit(self, rounding: Round) -> int: + remainder = self.value % 1 + + match rounding: + case Round.DOWN: + return math.floor(self.value) + case Round.UP: + return math.ceil(self.value) + case Round.HALF_UP: + if remainder >= HALF: + return math.ceil(self.value) + else: + return math.floor(self.value) + case Round.HALF_EVEN: + return round(self.value) + case Round.HALF_DOWN: + if remainder > HALF: + return math.ceil(self.value) + else: + return math.floor(self.value) + case Round.ZERO_FIVE_UP: + floored = math.floor(self.value) + if str(floored)[-1] in "05": + return math.ceil(self.value) + else: + return floored + case no_match: + assert_never(no_match) def round_either(self, rounding: Round) -> Money[C_co] | Overdraft[C_co]: - return _dispatch_type(self._round_value(rounding), self.currency) + return _dispatch_type(self._round_subunit(rounding), self.currency) def round_money(self, rounding: Round) -> Money[C_co]: - return Money(self._round_value(rounding), self.currency) + return self.currency.from_subunit(self._round_subunit(rounding)) def round_overdraft(self, rounding: Round) -> Overdraft[C_co]: - return Overdraft(-self._round_value(rounding), self.currency) + return self.currency.overdraft_from_subunit(-self._round_subunit(rounding)) @classmethod def __get_pydantic_core_schema__( @@ -470,25 +524,23 @@ class Overdraft(_ValueCurrencyPair[C_co], Generic[C_co]): @classmethod def _normalize( cls, - value: ParsableMoneyValue, - currency: C_inv, - /, - ) -> tuple[PositiveDecimal, C_inv]: - _validate_currency_arg(cls, currency) - normalized_value = currency.normalize_value(value) - if normalized_value == 0: + *args: object, + **kwargs: object, + ) -> tuple[Nat, Currency]: + subunits, currency = super()._normalize(*args, **kwargs) + if subunits == 0: raise InvalidOverdraftValue( f"{cls.__qualname__} cannot be instantiated with a value of zero, " f"the {Money.__qualname__} class should be used instead." ) - return currency.normalize_value(value), currency + return subunits, currency def __hash__(self) -> int: - return hash((type(self), self.currency, self.value)) + return hash((type(self), self.currency, self.subunits)) def __eq__(self, other: object) -> bool: if isinstance(other, Overdraft): - return self.currency == other.currency and self.value == other.value + return self.currency == other.currency and self.subunits == other.subunits return NotImplemented @overload @@ -504,9 +556,9 @@ def __add__(self: Overdraft[C_co], other: Overdraft[C_co]) -> Overdraft[C_co]: def __add__(self: Overdraft[C_co], other: object) -> Money[C_co] | Overdraft[C_co]: if isinstance(other, Overdraft) and self.currency == other.currency: - return Overdraft(self.value + other.value, self.currency) + return self.currency.overdraft_from_subunit(self.subunits + other.subunits) if isinstance(other, Money) and self.currency == other.currency: - return _dispatch_type(other.value - self.value, self.currency) + return _dispatch_type(other.subunits - self.subunits, self.currency) return NotImplemented def __radd__( @@ -534,9 +586,13 @@ def __sub__( return NotImplemented value = ( - self.value - other.value + # This rewrite is equivalent to + # (-x) - (-y) == (-x) + y == y - x + other.subunits - self.subunits if isinstance(other, Overdraft) - else -(self.value + other.value) + # This rewrite is equivalent to + # (-x) - y == -x - y == -(x + y) + else -(self.subunits + other.subunits) ) return _dispatch_type(value, self.currency) @@ -546,14 +602,14 @@ def __rsub__(self: Overdraft[C_co], other: Money[C_co]) -> Money[C_co]: # In the interpretation that an overdraft is a negative value, this is # equivalent to subtracting a negative value, which can be equivalently # rewritten as an addition (x - (-y) == x + y). - return Money(self.value + other.value, self.currency) + return self.currency.from_subunit(self.subunits + other.subunits) return NotImplemented def __abs__(self: Overdraft[C_co]) -> Money[C_co]: - return Money(self.value, self.currency) + return self.currency.from_subunit(self.subunits) def __neg__(self: Overdraft[C_co]) -> Money[C_co]: - return Money(self.value, self.currency) + return self.currency.from_subunit(self.subunits) def __pos__(self: Overdraft[C_co]) -> Overdraft[C_co]: return self @@ -562,9 +618,9 @@ def __pos__(self: Overdraft[C_co]) -> Overdraft[C_co]: # This needs HKT to allow typing to work properly for subclasses of Overdraft, that # would also allow moving the implementation to the shared super-class. def from_subunit(cls, value: int, currency: C_inv) -> Overdraft[C_inv]: - return cls( # type: ignore[return-value] - Decimal(value) / currency.subunit, - currency, # type: ignore[arg-type] + return cls( + subunits=value, + currency=currency, ) @classmethod diff --git a/src/immoney/_cache.py b/src/immoney/_cache.py index ad25619..dcc67e2 100644 --- a/src/immoney/_cache.py +++ b/src/immoney/_cache.py @@ -19,5 +19,5 @@ class InstanceCache(type): def __instantiate(cls, *args: object) -> object: return super().__call__(*args) - def __call__(cls, *args: object) -> Any: - return cls.__instantiate(*cls._normalize(*args)) + def __call__(cls, *args: object, **kwargs: object) -> Any: + return cls.__instantiate(*cls._normalize(*args, **kwargs)) diff --git a/src/immoney/_parsers.py b/src/immoney/_parsers.py new file mode 100644 index 0000000..f7793cb --- /dev/null +++ b/src/immoney/_parsers.py @@ -0,0 +1,30 @@ +from decimal import Decimal +from decimal import InvalidOperation +from typing import NewType + +from .errors import ParseError + +Nat = NewType("Nat", int) + + +def parse_nat(value: int) -> Nat: + if value < 0: + raise ParseError("Cannot parse from negative value") + return Nat(value) + + +def approximate_decimal_subunits( + main_unit: Decimal | str, + subunit_per_main: int, +) -> Decimal: + if isinstance(main_unit, str): + try: + main_unit = Decimal(main_unit) + except InvalidOperation as exception: + raise ParseError("Could not parse Money from the given str") from exception + + if main_unit.is_nan(): + raise ParseError("Cannot parse from NaN") + if not main_unit.is_finite(): + raise ParseError("Cannot parse from non-finite") + return main_unit * subunit_per_main diff --git a/tests/strategies.py b/tests/strategies.py index c194def..cc5edd7 100644 --- a/tests/strategies.py +++ b/tests/strategies.py @@ -3,12 +3,15 @@ from typing import Final from hypothesis.strategies import decimals +from hypothesis.strategies import integers -max_valid_sek: Final = 10_000_000_000_000_000_000_000_000 - 1 -valid_sek: Final = decimals( +valid_sek_decimals: Final = decimals( min_value=0, - max_value=max_valid_sek, + max_value=10_000_000_000_000_000_000_000_000 - 1, places=2, allow_nan=False, allow_infinity=False, ) + +valid_money_subunits: Final = integers(min_value=0) +valid_overdraft_subunits: Final = integers(min_value=1) diff --git a/tests/test_arithmetic.py b/tests/test_arithmetic.py index 030dade..5c2e030 100644 --- a/tests/test_arithmetic.py +++ b/tests/test_arithmetic.py @@ -1,15 +1,16 @@ +import operator +from functools import reduce + +from hypothesis import example from hypothesis import given from hypothesis.strategies import integers from hypothesis.strategies import lists -from typing_extensions import assert_type from immoney import Money from immoney import Overdraft from immoney.currencies import SEK from immoney.currencies import SEKType -from .strategies import max_valid_sek - def _to_integer_subunit(value: Money[SEKType] | Overdraft[SEKType]) -> int: return value.subunits if isinstance(value, Money) else -value.subunits @@ -19,15 +20,24 @@ def _from_integer_subunit(value: int) -> Money[SEKType] | Overdraft[SEKType]: return SEK.from_subunit(value) if value >= 0 else SEK.overdraft_from_subunit(-value) -@given(lists(integers(max_value=max_valid_sek, min_value=-max_valid_sek), min_size=1)) -def test_arithmetics(values: list[int]): - monetary_sum: Money[SEKType] | Overdraft[SEKType] = sum( - (_from_integer_subunit(value) for value in values), - SEK(0), - ) - assert_type( - monetary_sum, - Money[SEKType] | Overdraft[SEKType], - ) +@given(lists(integers(), min_size=1)) +@example([1]) +@example([0, -1]) +@example([10000000000000000000000000001]) +def test_sequence_of_additions(values: list[int]): + monetary_sum = sum((_from_integer_subunit(value) for value in values), SEK(0)) int_sum = sum(values) assert int_sum == _to_integer_subunit(monetary_sum) + + +@given(lists(integers(), min_size=1)) +@example([1]) +@example([0, -1]) +@example([10000000000000000000000000001]) +def test_sequence_of_subtractions(values: list[int]): + monetary_delta = reduce( + operator.sub, + (_from_integer_subunit(value) for value in values), + ) + int_delta = reduce(operator.sub, values) + assert int_delta == _to_integer_subunit(monetary_delta) diff --git a/tests/test_base.py b/tests/test_base.py index 3e8ba0e..c1af01f 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -32,49 +32,13 @@ from immoney.errors import InvalidSubunit from immoney.errors import ParseError -from .strategies import max_valid_sek -from .strategies import valid_sek +from .strategies import valid_money_subunits +from .strategies import valid_overdraft_subunits +from .strategies import valid_sek_decimals very_small_decimal = Decimal("0.0000000000000000000000000001") -@composite -def sums_to_valid_sek( - draw, - first_pick=valid_sek, -): - a = draw(first_pick) - return a, draw( - decimals( - min_value=0, - max_value=max_valid_sek - a, - places=2, - allow_nan=False, - allow_infinity=False, - ) - ) - - -@composite -def non_zero_sums_to_valid_sek( - draw, - first_pick=valid_sek, -): - a = draw(first_pick) - assume(a != 0) - b = draw( - decimals( - min_value=0, - max_value=max_valid_sek - a, - places=2, - allow_nan=False, - allow_infinity=False, - ) - ) - assume(b != 0) - return a, b - - @composite def currencies( draw, @@ -109,11 +73,11 @@ class Subclass(Currency): assert str(instance) == test_code - @given(valid_sek) - def test_call_instantiates_money(self, value: Decimal): + @given(valid_sek_decimals) + def test_call_instantiates_money(self, value: int): assert SEK(value) == Money(value, SEK) - @given(name=text(), value=valid_sek | text()) + @given(name=text(), value=valid_money_subunits | text()) @example(name="code", value="USD") @example(name="subunit", value=1) def test_raises_on_assignment(self, name: str, value: object): @@ -140,7 +104,7 @@ class Subclass(Currency): def test_zero_returns_cached_instance_of_money_zero(self) -> None: assert SEK.zero is SEK.zero - assert SEK.zero.value == 0 + assert SEK.zero.subunits == 0 assert SEK.zero.currency is SEK @given( @@ -158,38 +122,44 @@ def test_normalize_value_raises_for_precision_loss( value: Decimal, ) -> None: with pytest.raises((ParseError, InvalidOperation)): - currency.normalize_value(value) - currency.normalize_value(value + very_small_decimal) + currency.normalize_to_subunits(value) + currency.normalize_to_subunits(value + very_small_decimal) @given( value=integers(max_value=-1) | decimals(max_value=Decimal("-0.000001")), ) def test_normalize_value_raises_for_negative_value(self, value: object) -> None: with pytest.raises(ParseError): - SEK.normalize_value(value) # type: ignore[arg-type] + SEK.normalize_to_subunits(value) def test_normalize_value_raises_for_invalid_str(self) -> None: with pytest.raises(ParseError): - SEK.normalize_value("foo") + SEK.normalize_to_subunits("foo") def test_normalize_value_raises_for_nan(self) -> None: with pytest.raises(ParseError): - SEK.normalize_value(Decimal("nan")) + SEK.normalize_to_subunits(Decimal("nan")) def test_normalize_value_raises_for_non_finite(self) -> None: with pytest.raises(ParseError): - SEK.normalize_value(float("inf")) # type: ignore[arg-type] + SEK.normalize_to_subunits(Decimal("inf")) + + def test_normalize_value_raises_for_invalid_type(self) -> None: + with pytest.raises(NotImplementedError): + SEK.normalize_to_subunits(float("inf")) def test_from_subunit_returns_money_instance(self) -> None: instance = SEK.from_subunit(100) assert isinstance(instance, Money) - assert instance.value == Decimal("1.00") + assert instance.subunits == 100 + assert instance.decimal == Decimal("1.00") assert instance.currency is SEK def test_overdraft_from_subunit_returns_overdraft_instance(self) -> None: instance = SEK.overdraft_from_subunit(100) assert isinstance(instance, Overdraft) - assert instance.value == Decimal("1.00") + assert instance.subunits == 100 + assert instance.decimal == Decimal("1.00") assert instance.currency is SEK @@ -205,36 +175,32 @@ def test_overdraft_from_subunit_returns_overdraft_instance(self) -> None: def monies( draw, currencies=currencies(), - values=valid_values, + subunits=integers(min_value=0), ) -> Money[Currency]: - fraction = SubunitFraction(Fraction(draw(values)), draw(currencies)) - return fraction.round_money(Round.DOWN) + return Money.from_subunit(draw(subunits), draw(currencies)) @composite def overdrafts( draw, currencies=currencies(), - values=valid_values, + subunits=integers(min_value=1), ) -> Overdraft[Currency]: - value = draw(values) - fraction = SubunitFraction(Fraction(-value), draw(currencies)) - try: - return fraction.round_overdraft(Round.DOWN) - except InvalidOverdraftValue: - assume(False) - raise NotImplementedError + return Overdraft.from_subunit(draw(subunits), draw(currencies)) class TestMoney: - @given(valid_sek) + @given(valid_sek_decimals) @example(Decimal("1")) @example(Decimal("1.01")) @example(Decimal("1.010000")) - def test_instantiation_normalizes_value(self, value: Decimal): + # This value identifies a case where improperly using floats to represent + # intermediate values, will lead to precision loss in the .decimal property. + @example(Decimal("132293239054008.35")) + def test_instantiation_normalizes_decimal(self, value: Decimal): instantiated = SEK(value) - assert instantiated.value == value - assert instantiated.value.as_tuple().exponent == -2 + assert instantiated.decimal == value + assert instantiated.decimal.as_tuple().exponent == -2 def test_instantiation_caches_instance(self): assert SEK("1.01") is SEK("1.010") @@ -246,9 +212,13 @@ def test_cannot_instantiate_subunit_fraction(self): def test_raises_type_error_when_instantiated_with_non_currency(self): with pytest.raises(TypeError): - Money("2.00", "SEK") # type: ignore[type-var] + Money("2.00", "SEK") # type: ignore[call-overload] + + def test_raises_type_error_when_instantiated_with_invalid_args(self): + with pytest.raises(TypeError): + Money(foo=1, bar=2) # type: ignore[call-overload] - @given(money=monies(), name=text(), value=valid_sek | text()) + @given(money=monies(), name=text(), value=valid_sek_decimals | text()) @example(SEK(23), "value", Decimal("123")) @example(NOK(23), "currency", SEK) def test_raises_on_assignment(self, money: Money[Any], name: str, value: object): @@ -296,7 +266,7 @@ def test_can_check_equality_with_zero(self): def test_cannot_check_equality_with_non_zero(self, value: Money[Any], number: int): assert value != number - @given(value=valid_sek) + @given(value=valid_sek_decimals) def test_can_check_equality_with_instance(self, value: Decimal): instance = SEK(value) assert instance == SEK(value) @@ -317,14 +287,14 @@ def test_can_check_equality_with_instance(self, value: Decimal): def test_never_equal_across_currencies(self, a: Money[Any], b: Money[Any]): assert a != b - @given(valid_sek, valid_sek) + @given(valid_money_subunits, valid_money_subunits) @example(0, 0) @example(1, 1) @example(1, 0) @example(0, 1) - def test_total_ordering_within_currency(self, x: Decimal | int, y: Decimal | int): - a = SEK(x) - b = SEK(y) + def test_total_ordering_within_currency(self, x: int, y: int): + a = SEK.from_subunit(x) + b = SEK.from_subunit(y) assert (a > b and b < a) or (a < b and b > a) or (a == b and b == a) assert (a >= b and b <= a) or (a <= b and b >= a) @@ -353,13 +323,12 @@ def test_raises_type_error_for_ordering_across_currencies( with pytest.raises(TypeError): b <= a # noqa: B015 - @given(sums_to_valid_sek()) - @example((Decimal(0), Decimal(0))) - def test_add(self, xy: tuple[Decimal, Decimal]): - x, y = xy - a = SEK(x) - b = SEK(y) - assert (b + a).value == (a + b).value == (x + y) + @given(integers(min_value=0), integers(min_value=0)) + @example(0, 0) + def test_add(self, x: int, y: int): + a = SEK.from_subunit(x) + b = SEK.from_subunit(y) + assert (b + a).subunits == (a + b).subunits == (x + y) @given(a=monies(), b=monies()) @example(NOK(0), SEK(0)) @@ -374,17 +343,15 @@ def test_raises_type_error_for_addition_across_currencies( with pytest.raises(TypeError): b + a - @given(sums_to_valid_sek()) - @example((Decimal(0), Decimal(0))) - def test_iadd(self, xy: tuple[Decimal, Decimal]): - x, y = xy - a = SEK(x) - b = SEK(y) + @given(valid_money_subunits, valid_money_subunits) + def test_iadd(self, x: int, y: int): + a = SEK.from_subunit(x) + b = SEK.from_subunit(y) c = a c += b d = b d += a - assert c.value == d.value == (x + y) + assert c.subunits == d.subunits == (x + y) @given(a=monies(), b=monies()) @example(NOK(0), SEK(0)) @@ -409,19 +376,18 @@ def test_abs_returns_self(self, value: Money[Any]): @given(overdrafts()) def test_neg_returns_overdraft(self, overdraft: Overdraft[Any]): - value = Money(overdraft.value, overdraft.currency) + value = Money.from_subunit(overdraft.subunits, overdraft.currency) assert -value is overdraft def test_neg_zero_returns_self(self): value = SEK(0) assert -value is value - @given(sums_to_valid_sek()) - @example((Decimal(0), Decimal(0))) - def test_sub(self, xy: tuple[Decimal, Decimal]): - x, y = sorted(xy, reverse=True) - a = SEK(x) - b = SEK(y) + @given(valid_money_subunits, valid_money_subunits) + def test_sub(self, x: int, y: int): + x, y = sorted((x, y), reverse=True) + a = SEK.from_subunit(x) + b = SEK.from_subunit(y) if a == b: assert a - b == b - a == 0 @@ -431,11 +397,11 @@ def test_sub(self, xy: tuple[Decimal, Decimal]): pos = a - b assert isinstance(pos, Money) - assert pos.value == expected_sum + assert pos.subunits == expected_sum neg = b - a assert isinstance(neg, Overdraft) - assert neg.value == expected_sum + assert neg.subunits == expected_sum @given(a=monies(), b=monies()) @example(NOK(0), SEK(0)) @@ -452,10 +418,10 @@ def test_raises_type_error_for_subtraction_across_currencies( @given(monies()) def test_neg(self, a: Money[Any]): - assume(a.value != 0) + assume(a.subunits != 0) negged = -a assert isinstance(negged, Overdraft) - assert negged.value == a.value + assert negged.subunits == a.subunits assert negged.currency == a.currency assert -negged is a assert +a is a @@ -466,19 +432,15 @@ def test_returns_instance_when_multiplied_with_positive_integer( a: Money[Any], b: int, ): - expected_product = a.value * b - try: - product = a * b - except InvalidOperation: - assert expected_product * a.currency.subunit > max_valid_sek - return + expected_product = a.subunits * b + product = a * b assert isinstance(product, Money) assert product.currency is a.currency - assert product.value == expected_product + assert product.subunits == expected_product reverse_applied = b * a assert isinstance(reverse_applied, Money) assert reverse_applied.currency is a.currency - assert reverse_applied.value == expected_product + assert reverse_applied.subunits == expected_product @given(monies(), integers(max_value=-1)) def test_returns_overdraft_when_multiplied_with_negative_integer( @@ -486,21 +448,17 @@ def test_returns_overdraft_when_multiplied_with_negative_integer( a: Money[Any], b: int, ): - assume(a.value != 0) + assume(a.subunits != 0) - expected_product = -a.value * b - try: - product = a * b - except InvalidOperation: - assert expected_product * a.currency.subunit > max_valid_sek - return + expected_product = -a.subunits * b + product = a * b assert isinstance(product, Overdraft) assert product.currency is a.currency - assert product.value == expected_product + assert product.subunits == expected_product reverse_applied = b * a assert isinstance(reverse_applied, Overdraft) assert reverse_applied.currency is a.currency - assert reverse_applied.value == expected_product + assert reverse_applied.subunits == expected_product @given(integers(), currencies()) def test_multiplying_with_zero_returns_money_zero(self, a: int, currency: Currency): @@ -508,7 +466,7 @@ def test_multiplying_with_zero_returns_money_zero(self, a: int, currency: Curren result = a * zero assert isinstance(result, Money) - assert result.value == 0 + assert result.subunits == 0 assert result.currency == currency # Test commutative property. @@ -526,17 +484,18 @@ def test_returns_subunit_fraction_when_multiplied_with_decimal( return assert isinstance(product, SubunitFraction) assert product.currency is a.currency - assert product.value == Fraction(a.value) * Fraction(b) * Fraction( - a.currency.subunit - ) + assert product.value == Fraction(a.subunits) * Fraction(b) reverse_applied = b * a assert isinstance(reverse_applied, SubunitFraction) assert reverse_applied.currency is a.currency assert reverse_applied.value == product.value - @given(valid_sek, valid_sek) + @given(valid_money_subunits, valid_money_subunits) + @example(0, 0) def test_raises_type_error_for_multiplication_between_instances( - self, x: Decimal, y: Decimal + self, + x: int, + y: int, ): a = SEK(x) b = SEK(y) @@ -628,7 +587,8 @@ class FooType(Currency): one_main_unit = Money.from_subunit(Foo.subunit, Foo) assert one_main_unit == Foo(1) - @given(currencies(), integers(max_value=max_valid_sek, min_value=0)) + @given(currencies(), integers(min_value=0)) + @example(SEK, 1) def test_subunit_roundtrip(self, currency: Currency, value: int): assert value == Money.from_subunit(value, currency).subunits @@ -729,6 +689,14 @@ def test_equality(self): assert money_one != different_one assert different_one != money_one + overdraft_one = SEK.overdraft(1) + assert overdraft_one == -one + assert one == -overdraft_one + assert overdraft_one != zero + assert zero != overdraft_one + assert overdraft_one != -different_one + assert different_one != -overdraft_one + def test_from_money_returns_instance(self): class FooType(Currency): code = "foo" @@ -760,12 +728,12 @@ def test_round_money_raises_parser_error_for_negative_fraction(self): def test_round_overdraft_returns_overdraft(self): fraction = SubunitFraction(Fraction(-997, 3), SEK) - assert SEK.overdraft("3.32") == fraction.round_overdraft(Round.DOWN) - assert SEK.overdraft("3.33") == fraction.round_overdraft(Round.UP) + assert SEK.overdraft("3.33") == fraction.round_overdraft(Round.DOWN) + assert SEK.overdraft("3.32") == fraction.round_overdraft(Round.UP) assert SEK.overdraft("3.32") == fraction.round_overdraft(Round.HALF_UP) assert SEK.overdraft("3.32") == fraction.round_overdraft(Round.HALF_EVEN) assert SEK.overdraft("3.32") == fraction.round_overdraft(Round.HALF_DOWN) - assert SEK.overdraft("3.32") == fraction.round_overdraft(Round.ZERO_FIVE_UP) + assert SEK.overdraft("3.33") == fraction.round_overdraft(Round.ZERO_FIVE_UP) def test_round_overdraft_raises_parse_error_for_positive_fraction(self): with pytest.raises(ParseError): @@ -773,12 +741,12 @@ def test_round_overdraft_raises_parse_error_for_positive_fraction(self): def test_round_either_returns_overdraft_for_negative_fraction(self): fraction = SubunitFraction(Fraction(-997, 3), SEK) - assert SEK.overdraft("3.32") == fraction.round_either(Round.DOWN) - assert SEK.overdraft("3.33") == fraction.round_either(Round.UP) + assert SEK.overdraft("3.33") == fraction.round_either(Round.DOWN) + assert SEK.overdraft("3.32") == fraction.round_either(Round.UP) assert SEK.overdraft("3.32") == fraction.round_either(Round.HALF_UP) assert SEK.overdraft("3.32") == fraction.round_either(Round.HALF_EVEN) assert SEK.overdraft("3.32") == fraction.round_either(Round.HALF_DOWN) - assert SEK.overdraft("3.32") == fraction.round_either(Round.ZERO_FIVE_UP) + assert SEK.overdraft("3.33") == fraction.round_either(Round.ZERO_FIVE_UP) def test_round_either_returns_money_for_positive_fraction(self): fraction = SubunitFraction(Fraction(997, 3), SEK) @@ -791,15 +759,15 @@ def test_round_either_returns_money_for_positive_fraction(self): class TestOverdraft: - @given(valid_sek) + @given(valid_sek_decimals) @example(Decimal("1")) @example(Decimal("1.01")) @example(Decimal("1.010000")) def test_instantiation_normalizes_value(self, value: Decimal): assume(value != 0) instantiated = SEK.overdraft(value) - assert instantiated.value == value - assert instantiated.value.as_tuple().exponent == -2 + assert instantiated.decimal == value + assert instantiated.decimal.as_tuple().exponent == -2 @pytest.mark.parametrize("value", (0, "0.00", Decimal(0), Decimal("0.0"))) def test_raises_type_error_for_value_zero(self, value: ParsableMoneyValue): @@ -822,9 +790,9 @@ def test_cannot_instantiate_subunit_fraction(self): def test_raises_type_error_when_instantiated_with_non_currency(self): with pytest.raises(TypeError): - Overdraft("2.00", "SEK") # type: ignore[type-var] + Overdraft("2.00", "SEK") # type: ignore[call-overload] - @given(money=monies(), name=text(), value=valid_sek | text()) + @given(money=monies(), name=text(), value=valid_sek_decimals | text()) @example(SEK(23), "value", Decimal("123")) @example(NOK(23), "currency", SEK) def test_raises_on_assignment(self, money: Money[Any], name: str, value: object): @@ -858,11 +826,11 @@ def test_hash(self): @given(overdrafts()) def test_abs_returns_money(self, value: Overdraft[Any]): - assert abs(value) is Money(value.value, value.currency) + assert abs(value) is value.currency.from_subunit(value.subunits) @given(overdrafts()) def test_neg_returns_money(self, value: Overdraft[Any]): - assert -value is Money(value.value, value.currency) + assert -value is value.currency.from_subunit(value.subunits) @given(overdrafts()) def test_pos_returns_self(self, value: Overdraft[Any]): @@ -893,13 +861,11 @@ def test_equality_with_money_is_always_false( assert money_value != overdraft_value assert overdraft_value != money_value - @given(non_zero_sums_to_valid_sek()) - @example((Decimal("0.01"), Decimal("0.01"))) - def test_can_add_instances(self, xy: tuple[Decimal, Decimal]): - x, y = xy - - a = SEK.overdraft(x) - b = SEK.overdraft(y) + @given(valid_overdraft_subunits, valid_overdraft_subunits) + @example(1, 1) + def test_can_add_instances(self, x: int, y: int): + a = SEK.overdraft_from_subunit(x) + b = SEK.overdraft_from_subunit(y) # Test commutative property. c = b + a @@ -907,14 +873,15 @@ def test_can_add_instances(self, xy: tuple[Decimal, Decimal]): d = a + b assert isinstance(d, Overdraft) assert c == d - assert c.value == d.value == x + y + assert c.subunits == d.subunits == x + y - @given(non_zero_sums_to_valid_sek()) - @example((Decimal("0.01"), Decimal("0.01"))) + @given(valid_overdraft_subunits, valid_overdraft_subunits) + @example(1, 1) def test_adding_instances_of_different_currency_raises_type_error( - self, xy: tuple[Decimal, Decimal] + self, + x: int, + y: int, ): - x, y = xy a = SEK.overdraft(x) b = NOK.overdraft(y) with pytest.raises(TypeError): @@ -922,36 +889,37 @@ def test_adding_instances_of_different_currency_raises_type_error( with pytest.raises(TypeError): b + a # type: ignore[operator] - @given(sums_to_valid_sek()) - @example((Decimal("0.01"), Decimal(0))) - def test_adding_money_equals_subtraction(self, xy: tuple[Decimal, Decimal]): - x, y = xy + @given(valid_overdraft_subunits, valid_money_subunits) + def test_adding_money_equals_subtraction(self, x: int, y: int): assume(x != 0) - a = SEK.overdraft(x) - b = SEK(y) - assert abs(a + b).value == abs(b + a).value == abs(x - y) + a = SEK.overdraft_from_subunit(x) + b = SEK.from_subunit(y) + assert abs(a + b).subunits == abs(b + a).subunits == abs(x - y) def test_can_add_money(self): - a = SEK(1000) - b = SEK.overdraft(600) + a = SEK.from_subunit(1000) + b = SEK.overdraft_from_subunit(600) positive_sum = a + b assert isinstance(positive_sum, Money) - assert positive_sum.value == Decimal("400") + assert positive_sum.decimal == Decimal("4.00") + assert positive_sum.subunits == 400 assert positive_sum == b + a - c = SEK(600) - d = SEK.overdraft(1000) + c = SEK.from_subunit(600) + d = SEK.overdraft_from_subunit(1000) negative_sum = c + d assert isinstance(negative_sum, Overdraft) - assert negative_sum.value == Decimal("400") + assert negative_sum.decimal == Decimal("4.00") + assert negative_sum.subunits == 400 assert negative_sum == d + c - @given(sums_to_valid_sek()) - @example((Decimal(0), Decimal("0.01"))) + @given(valid_money_subunits, valid_overdraft_subunits) + @example(0, 1) def test_adding_money_of_different_currency_raises_type_error( - self, xy: tuple[Decimal, Decimal] + self, + x: int, + y: int, ): - x, y = xy a = SEK(x) assume(y != 0) b = NOK.overdraft(y) @@ -970,35 +938,33 @@ def test_cannot_add_arbitrary_object(self, value: object): with pytest.raises(TypeError): value + SEK.overdraft(1) # type: ignore[operator] - @given(non_zero_sums_to_valid_sek()) - @example((Decimal("0.01"), Decimal("0.01"))) - def test_can_subtract_instances(self, xy: tuple[Decimal, Decimal]): - x, y = xy - a = SEK.overdraft(x) - b = SEK.overdraft(y) - assert abs(b - a).value == abs(a - b).value == abs(x - y) + @given(valid_overdraft_subunits, valid_overdraft_subunits) + @example(1, 1) + def test_can_subtract_instances(self, x: int, y: int): + a = SEK.overdraft_from_subunit(x) + b = SEK.overdraft_from_subunit(y) + assert abs(b - a).subunits == abs(a - b).subunits == abs(x - y) - @given(non_zero_sums_to_valid_sek()) - @example((Decimal("0.01"), Decimal("0.01"))) + @given(valid_overdraft_subunits, valid_overdraft_subunits) + @example(1, 1) def test_subtracting_instances_of_different_currency_raises_type_error( - self, xy: tuple[Decimal, Decimal] + self, + x: int, + y: int, ): - x, y = xy - a = SEK.overdraft(x) - b = NOK.overdraft(y) + a = SEK.overdraft_from_subunit(x) + b = NOK.overdraft_from_subunit(y) with pytest.raises(TypeError): a - b # type: ignore[operator] with pytest.raises(TypeError): b - a # type: ignore[operator] - @given(sums_to_valid_sek()) - @example((Decimal(0), Decimal(0))) - def test_subtracting_money_equals_addition(self, xy: tuple[Decimal, Decimal]): - x, y = xy - assume(x != 0) - a = SEK.overdraft(x) - b = SEK(y) - assert abs(a - b).value == abs(b - a).value == abs(x + y) + @given(valid_overdraft_subunits, valid_money_subunits) + @example(1, 0) + def test_subtracting_money_equals_addition(self, x: int, y: int): + a = SEK.overdraft_from_subunit(x) + b = SEK.from_subunit(y) + assert abs(a - b).subunits == abs(b - a).subunits == abs(x + y) @pytest.mark.parametrize( "a, b, expected_difference", @@ -1017,14 +983,13 @@ def test_can_subtract_money( ): assert a - b == expected_difference - @given(sums_to_valid_sek()) - @example((Decimal(0), Decimal(0))) + @given(valid_money_subunits, valid_overdraft_subunits) def test_subtracting_money_of_different_currency_raises_type_error( - self, xy: tuple[Decimal, Decimal] + self, + x: int, + y: int, ): - x, y = xy a = SEK(x) - assume(y != 0) b = NOK.overdraft(y) with pytest.raises(TypeError): a - b # type: ignore[operator] @@ -1040,3 +1005,8 @@ def test_cannot_subtract_arbitrary_object(self, value: object): with pytest.raises(TypeError): value - SEK.overdraft(1) # type: ignore[operator] + + @given(currencies(), integers(min_value=1)) + @example(SEK, 1) + def test_subunit_roundtrip(self, currency: Currency, value: int): + assert value == Overdraft.from_subunit(value, currency).subunits