diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index eb8ff64..b17bd79 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -56,7 +56,7 @@ repos: hooks: - id: check-github-workflows - repo: https://github.com/pre-commit/mirrors-mypy - rev: "v1.3.0" + rev: "v1.5.1" hooks: - id: mypy args: ["--strict"] diff --git a/README.md b/README.md index 5883099..cb4ba48 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,7 @@ error. >>> EUR("2.001") Traceback (most recent call last): ... -immoney.errors.MoneyParseError: Cannot interpret value as Money of currency EUR ... +immoney.errors.ParseError: Cannot interpret value as Money of currency EUR ... ``` #### Instance cache diff --git a/src/immoney/_base.py b/src/immoney/_base.py index ea8bbfb..ae4c1de 100644 --- a/src/immoney/_base.py +++ b/src/immoney/_base.py @@ -31,8 +31,9 @@ from ._cache import InstanceCache from ._frozen import Frozen from .errors import DivisionByZero +from .errors import InvalidOverdraftValue from .errors import InvalidSubunit -from .errors import MoneyParseError +from .errors import ParseError if TYPE_CHECKING: from pydantic_core.core_schema import CoreSchema @@ -66,11 +67,11 @@ def __str__(self) -> str: def __repr__(self) -> str: return f"Currency(code={self.code}, subunit={self.subunit})" - def __call__(self, value: Decimal | int | str) -> Money[Self]: + def __call__(self, value: ParsableMoneyValue) -> Money[Self]: return Money(value, self) def __hash__(self) -> int: - return hash((self.code, self.subunit)) + return hash((type(self), self.code, self.subunit)) @cached_property def decimal_exponent(self) -> Decimal: @@ -86,21 +87,21 @@ def normalize_value(self, value: Decimal | int | str) -> PositiveDecimal: try: value = Decimal(value) except decimal.InvalidOperation: - raise MoneyParseError("Failed parsing Decimal") + raise ParseError("Failed parsing Decimal") if value.is_nan(): - raise MoneyParseError("Cannot parse from NaN") + raise ParseError("Cannot parse from NaN") if not value.is_finite(): - raise MoneyParseError("Cannot parse from non-finite") + raise ParseError("Cannot parse from non-finite") if value < 0: - raise MoneyParseError("Cannot parse from negative value") + raise ParseError("Cannot parse from negative value") quantized = value.quantize(self.decimal_exponent) if value != quantized: - raise MoneyParseError( + 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." @@ -111,6 +112,9 @@ def normalize_value(self, value: Decimal | int | str) -> PositiveDecimal: def from_subunit(self, value: int) -> Money[Self]: return Money.from_subunit(value, self) + def overdraft_from_subunit(self, value: int) -> Overdraft[Self]: + return Overdraft.from_subunit(value, self) + @cached_property def one_subunit(self) -> Money[Self]: return self.from_subunit(1) @@ -121,11 +125,8 @@ def fraction( ) -> SubunitFraction[Self]: return SubunitFraction(subunit_value, self) - def overdraft( - self: Self, - value: Decimal | int | str, - ) -> Overdraft[Self]: - return Overdraft(Money(value, self)) + def overdraft(self: Self, value: ParsableMoneyValue) -> Overdraft[Self]: + return Overdraft(value, self) @classmethod def get_default_registry(cls) -> CurrencyRegistry[Currency]: @@ -144,19 +145,46 @@ def __get_pydantic_core_schema__( return build_currency_schema(cls) +def _validate_currency_arg( + cls: type, + value: object, + arg_name: str = "currency", +) -> None: + 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}" + ) + + +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) + + C_co = TypeVar("C_co", bound=Currency, covariant=True) -C_inv = TypeVar("C_inv", bound=Currency, covariant=False, contravariant=False) -@final -class Money(Frozen, Generic[C_co], metaclass=InstanceCache): +class _ValueCurrencyPair(Frozen, Generic[C_co], metaclass=InstanceCache): __slots__ = ("value", "currency") def __init__(self, value: ParsableMoneyValue, currency: C_co, /) -> None: - # Type ignore is safe because metaclass handles normalization. + # Type ignore is safe because metaclass delegates normalization to _normalize(). self.value: Final[Decimal] = value # type: ignore[assignment] self.currency: Final = currency + def __repr__(self) -> str: + return f"{type(self).__qualname__}({str(self.value)!r}, {self.currency})" + + @property + def subunits(self) -> int: + return int(self.currency.subunit * self.value) + + +C_inv = TypeVar("C_inv", bound=Currency, covariant=False, contravariant=False) + + +@final +class Money(_ValueCurrencyPair[C_co], Generic[C_co]): @classmethod def _normalize( cls, @@ -164,18 +192,11 @@ def _normalize( currency: C_inv, /, ) -> tuple[PositiveDecimal, C_inv]: - if not isinstance(currency, Currency): - raise TypeError( - f"Argument 'currency' of {cls.__qualname__!r} must be a Currency, " - f"got object of type {type(currency)!r}" - ) + _validate_currency_arg(cls, currency) return currency.normalize_value(value), currency - def __repr__(self) -> str: - return f"{type(self).__qualname__}({str(self.value)!r}, {self.currency})" - def __hash__(self) -> int: - return hash((self.currency, self.value)) + return hash((type(self), self.currency, self.value)) def __eq__(self, other: object) -> bool: if isinstance(other, int) and other == 0: @@ -226,18 +247,14 @@ def __sub__(self: Money[C_co], other: Money[C_co]) -> Money[C_co] | Overdraft[C_ """ if isinstance(other, Money) and self.currency == other.currency: value = self.value - other.value - return ( - Money(value, self.currency) - if value >= 0 - else Overdraft(Money(-value, self.currency)) - ) + return _dispatch_type(value, self.currency) return NotImplemented def __pos__(self) -> Self: return self - def __neg__(self: Money[C_co]) -> Overdraft[C_co]: - return Overdraft(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) # TODO: Support precision-lossy multiplication with floats? @overload @@ -253,14 +270,10 @@ def __mul__( other: object, ) -> Money[C_co] | SubunitFraction[C_co] | Overdraft[C_co]: if isinstance(other, int): - return ( - Money(self.value * other, self.currency) - if other >= 0 - else Overdraft(Money(-self.value * other, self.currency)) - ) + return _dispatch_type(self.value * other, self.currency) if isinstance(other, Decimal): return SubunitFraction( - Fraction(self.as_subunit()) * Fraction(other), + Fraction(self.subunits) * Fraction(other), self.currency, ) return NotImplemented @@ -301,8 +314,8 @@ def __truediv__(self: Money[C_co], other: object) -> tuple[Money[C_co], ...]: except decimal.DivisionByZero as e: raise DivisionByZero from e - under_subunit = under.as_subunit() - remainder = self.as_subunit() - under_subunit * other + under_subunit = under.subunits + remainder = self.subunits - under_subunit * other over = Money.from_subunit(under_subunit + 1, self.currency) return ( @@ -328,9 +341,6 @@ def __floordiv__(self, other: object) -> SubunitFraction[C_co]: def __abs__(self) -> Self: return self - def as_subunit(self) -> int: - return int(self.currency.subunit * self.value) - @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]: @@ -411,7 +421,7 @@ def __eq__(self, other: object) -> bool: if isinstance(other, SubunitFraction) and self.currency == other.currency: return self.value == other.value if isinstance(other, Money) and self.currency == other.currency: - return self.value == other.as_subunit() + return self.value == other.subunits return NotImplemented @classmethod @@ -420,17 +430,23 @@ def from_money( money: Money[C_co], denominator: int | Fraction = 1, ) -> SubunitFraction[C_co]: - return SubunitFraction( - Fraction(money.as_subunit(), denominator), money.currency - ) + return SubunitFraction(Fraction(money.subunits, denominator), money.currency) - def round_money(self, rounding: Round) -> Money[C_co]: + def _round_value(self, rounding: Round) -> Decimal: main_unit = Decimal(float(self.value / self.currency.subunit)) - quantized = main_unit.quantize( + return main_unit.quantize( exp=self.currency.decimal_exponent, rounding=rounding.value, ) - return Money(quantized, self.currency) + + def round_either(self, rounding: Round) -> Money[C_co] | Overdraft[C_co]: + return _dispatch_type(self._round_value(rounding), self.currency) + + def round_money(self, rounding: Round) -> Money[C_co]: + return Money(self._round_value(rounding), self.currency) + + def round_overdraft(self, rounding: Round) -> Overdraft[C_co]: + return Overdraft(-self._round_value(rounding), self.currency) @classmethod def __get_pydantic_core_schema__( @@ -450,35 +466,29 @@ def __get_pydantic_core_schema__( @final -class Overdraft(Frozen, Generic[C_co], metaclass=InstanceCache): - __slots__ = ("money",) - - def __init__(self, money: Money[C_co]) -> None: - self.money: Final = money - +class Overdraft(_ValueCurrencyPair[C_co], Generic[C_co]): @classmethod - def _normalize(cls, money: Money[C_co]) -> tuple[Money[C_co]]: - if not isinstance(money, Money): - raise TypeError( - f"Argument 'money' of {cls.__qualname__!r} must be a Money instance, " - f"got object of type {type(money)!r}" + 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: + raise InvalidOverdraftValue( + f"{cls.__qualname__} cannot be instantiated with a value of zero, " + f"the {Money.__qualname__} class should be used instead." ) - return (money,) - - def __repr__(self) -> str: - return ( - f"{type(self).__qualname__}" - f"({str(self.money.value)!r}, {self.money.currency})" - ) + return currency.normalize_value(value), currency def __hash__(self) -> int: - return hash((type(self), self.money)) + return hash((type(self), self.currency, self.value)) def __eq__(self, other: object) -> bool: - if isinstance(other, int) and other == 0: - return self.money.value == other - if isinstance(other, Overdraft) and other.money.currency == self.money.currency: - return self.money.value == other.money.value + if isinstance(other, Overdraft): + return self.currency == other.currency and self.value == other.value return NotImplemented @overload @@ -493,10 +503,10 @@ 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, Money): - return other - self.money - if isinstance(other, Overdraft): - return Overdraft(self.money + other.money) + if isinstance(other, Overdraft) and self.currency == other.currency: + return Overdraft(self.value + other.value, self.currency) + if isinstance(other, Money) and self.currency == other.currency: + return _dispatch_type(other.value - self.value, self.currency) return NotImplemented def __radd__( @@ -516,29 +526,47 @@ def __sub__( ) -> Money[C_co] | Overdraft[C_co]: ... - def __sub__(self: Overdraft[C_co], other: object) -> Money[C_co] | Overdraft[C_co]: - match other: - case Money(currency=self.money.currency) as other: - return Overdraft(self.money + other) - case Overdraft(money=Money(currency=self.money.currency)) as other: - return other.money - self.money - return NotImplemented + def __sub__( + self: Overdraft[C_co], + other: Money[C_co] | Overdraft[C_co], + ) -> Money[C_co] | Overdraft[C_co]: + if not isinstance(other, Money | Overdraft) or self.currency != other.currency: + return NotImplemented + + value = ( + self.value - other.value + if isinstance(other, Overdraft) + else -(self.value + other.value) + ) + + return _dispatch_type(value, self.currency) def __rsub__(self: Overdraft[C_co], other: Money[C_co]) -> Money[C_co]: - match other: - case Money(currency=self.money.currency) as other: - return self.money + other + if isinstance(other, Money) and self.currency == other.currency: + # 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 NotImplemented def __abs__(self: Overdraft[C_co]) -> Money[C_co]: - return self.money + return Money(self.value, self.currency) def __neg__(self: Overdraft[C_co]) -> Money[C_co]: - return self.money + return Money(self.value, self.currency) def __pos__(self: Overdraft[C_co]) -> Overdraft[C_co]: return self + @classmethod + # 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] + ) + @classmethod def __get_pydantic_core_schema__( cls, diff --git a/src/immoney/_pydantic.py b/src/immoney/_pydantic.py index 90312ba..0a2a002 100644 --- a/src/immoney/_pydantic.py +++ b/src/immoney/_pydantic.py @@ -99,7 +99,7 @@ class MoneyAdapter(GenericCurrencyAdapter[Money[Currency], MoneyDict]): @staticmethod def serialize(value: Money[Currency], *args: object) -> MoneyDict: return { - "subunits": value.as_subunit(), + "subunits": value.subunits, "currency": str(value.currency), } @@ -243,8 +243,8 @@ class OverdraftAdapter(GenericCurrencyAdapter[Overdraft[Currency], OverdraftDict @staticmethod def serialize(value: Overdraft[Currency], *args: object) -> OverdraftDict: return { - "overdraft_subunits": value.money.as_subunit(), - "currency": str(value.money.currency), + "overdraft_subunits": value.subunits, + "currency": str(value.currency), } @staticmethod @@ -254,7 +254,7 @@ def schema(currency_schema: core_schema.CoreSchema) -> core_schema.CoreSchema: wrapped=core_schema.typed_dict_schema( { "overdraft_subunits": core_schema.typed_dict_field( - core_schema.int_schema(ge=0), + core_schema.int_schema(gt=0), required=True, ), "currency": core_schema.typed_dict_field( @@ -275,12 +275,11 @@ def validate_overdraft( _registry: CurrencyRegistry[C] = registry, ) -> Overdraft[Currency]: if isinstance(value, Overdraft): - if value.money.currency.code not in _registry: + if value.currency.code not in _registry: raise ValueError("Currency is not registered.") return value currency = _registry[value["currency"]] - money_value = currency.from_subunit(value["overdraft_subunits"]) - return Overdraft(money_value) + return currency.overdraft_from_subunit(value["overdraft_subunits"]) return validate_overdraft @@ -292,17 +291,16 @@ def validate_overdraft( _currency: Currency = currency, ) -> Overdraft[Currency]: if isinstance(value, Overdraft): - if value.money.currency is not _currency: + if value.currency is not _currency: raise ValueError( - f"Invalid currency, got {value.money.currency!r}, expected " + f"Invalid currency, got {value.currency!r}, expected " f"{_currency!r}." ) return value # We ignore coverage here as this is enforced by schema. if value["currency"] != _currency.code: # pragma: no cover raise ValueError(f"Invalid currency, expected {_currency!s}.") - money_value = _currency.from_subunit(value["overdraft_subunits"]) - return Overdraft(money_value) + return _currency.overdraft_from_subunit(value["overdraft_subunits"]) return validate_overdraft diff --git a/src/immoney/errors.py b/src/immoney/errors.py index bd04c71..5b0d09e 100644 --- a/src/immoney/errors.py +++ b/src/immoney/errors.py @@ -2,7 +2,11 @@ class ImmoneyError(Exception): ... -class MoneyParseError(ImmoneyError, ValueError): +class ParseError(ImmoneyError, ValueError): + ... + + +class InvalidOverdraftValue(ParseError): ... diff --git a/tests/strategies.py b/tests/strategies.py new file mode 100644 index 0000000..c194def --- /dev/null +++ b/tests/strategies.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from typing import Final + +from hypothesis.strategies import decimals + +max_valid_sek: Final = 10_000_000_000_000_000_000_000_000 - 1 +valid_sek: Final = decimals( + min_value=0, + max_value=max_valid_sek, + places=2, + allow_nan=False, + allow_infinity=False, +) diff --git a/tests/test_arithmetic.py b/tests/test_arithmetic.py new file mode 100644 index 0000000..030dade --- /dev/null +++ b/tests/test_arithmetic.py @@ -0,0 +1,33 @@ +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 + + +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], + ) + int_sum = sum(values) + assert int_sum == _to_integer_subunit(monetary_sum) diff --git a/tests/test_base.py b/tests/test_base.py index bb4aedd..3e8ba0e 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -8,6 +8,7 @@ import pytest from abcattrs import UndefinedAbstractAttribute +from hypothesis import assume from hypothesis import example from hypothesis import given from hypothesis.strategies import composite @@ -20,23 +21,20 @@ from immoney import Overdraft from immoney import Round from immoney import SubunitFraction +from immoney._base import ParsableMoneyValue from immoney._base import valid_subunit from immoney.currencies import NOK from immoney.currencies import SEK from immoney.currencies import SEKType from immoney.errors import DivisionByZero from immoney.errors import FrozenInstanceError +from immoney.errors import InvalidOverdraftValue from immoney.errors import InvalidSubunit -from immoney.errors import MoneyParseError +from immoney.errors import ParseError + +from .strategies import max_valid_sek +from .strategies import valid_sek -max_valid_sek = 10_000_000_000_000_000_000_000_000 - 1 -valid_sek = decimals( - min_value=0, - max_value=max_valid_sek, - places=2, - allow_nan=False, - allow_infinity=False, -) very_small_decimal = Decimal("0.0000000000000000000000000001") @@ -57,6 +55,26 @@ def sums_to_valid_sek( ) +@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, @@ -139,7 +157,7 @@ def test_normalize_value_raises_for_precision_loss( currency: Currency, value: Decimal, ) -> None: - with pytest.raises((MoneyParseError, InvalidOperation)): + with pytest.raises((ParseError, InvalidOperation)): currency.normalize_value(value) currency.normalize_value(value + very_small_decimal) @@ -147,21 +165,33 @@ def test_normalize_value_raises_for_precision_loss( 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(MoneyParseError): + with pytest.raises(ParseError): SEK.normalize_value(value) # type: ignore[arg-type] def test_normalize_value_raises_for_invalid_str(self) -> None: - with pytest.raises(MoneyParseError): + with pytest.raises(ParseError): SEK.normalize_value("foo") def test_normalize_value_raises_for_nan(self) -> None: - with pytest.raises(MoneyParseError): + with pytest.raises(ParseError): SEK.normalize_value(Decimal("nan")) def test_normalize_value_raises_for_non_finite(self) -> None: - with pytest.raises(MoneyParseError): + with pytest.raises(ParseError): SEK.normalize_value(float("inf")) # type: ignore[arg-type] + 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.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.currency is SEK + valid_values = decimals( min_value=0, @@ -176,10 +206,24 @@ def monies( draw, currencies=currencies(), values=valid_values, -): - return SubunitFraction(Fraction(draw(values)), draw(currencies)).round_money( - Round.DOWN - ) +) -> Money[Currency]: + fraction = SubunitFraction(Fraction(draw(values)), draw(currencies)) + return fraction.round_money(Round.DOWN) + + +@composite +def overdrafts( + draw, + currencies=currencies(), + values=valid_values, +) -> Overdraft[Currency]: + value = draw(values) + fraction = SubunitFraction(Fraction(-value), draw(currencies)) + try: + return fraction.round_overdraft(Round.DOWN) + except InvalidOverdraftValue: + assume(False) + raise NotImplementedError class TestMoney: @@ -197,7 +241,7 @@ def test_instantiation_caches_instance(self): assert SEK(1) is SEK(1) def test_cannot_instantiate_subunit_fraction(self): - with pytest.raises(MoneyParseError): + with pytest.raises(ParseError): SEK(Decimal("1.001")) def test_raises_type_error_when_instantiated_with_non_currency(self): @@ -256,7 +300,7 @@ def test_cannot_check_equality_with_non_zero(self, value: Money[Any], number: in def test_can_check_equality_with_instance(self, value: Decimal): instance = SEK(value) assert instance == SEK(value) - next_plus = SEK.from_subunit(instance.as_subunit() + 1) + next_plus = SEK.from_subunit(instance.subunits + 1) assert next_plus != value assert value != next_plus @@ -363,9 +407,14 @@ def test_pos_returns_self(self, a: Money[Any]): def test_abs_returns_self(self, value: Money[Any]): assert value is abs(value) - @given(monies()) - def test_neg_returns_overdraft(self, value: Money[Any]): - assert -value is Overdraft(value) + @given(overdrafts()) + def test_neg_returns_overdraft(self, overdraft: Overdraft[Any]): + value = Money(overdraft.value, 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))) @@ -386,7 +435,7 @@ def test_sub(self, xy: tuple[Decimal, Decimal]): neg = b - a assert isinstance(neg, Overdraft) - assert neg.money.value == expected_sum + assert neg.value == expected_sum @given(a=monies(), b=monies()) @example(NOK(0), SEK(0)) @@ -403,10 +452,13 @@ def test_raises_type_error_for_subtraction_across_currencies( @given(monies()) def test_neg(self, a: Money[Any]): + assume(a.value != 0) negged = -a assert isinstance(negged, Overdraft) - assert negged.money == a - assert +a == a + assert negged.value == a.value + assert negged.currency == a.currency + assert -negged is a + assert +a is a @given(monies(), integers(min_value=0)) def test_returns_instance_when_multiplied_with_positive_integer( @@ -434,6 +486,8 @@ def test_returns_overdraft_when_multiplied_with_negative_integer( a: Money[Any], b: int, ): + assume(a.value != 0) + expected_product = -a.value * b try: product = a * b @@ -441,12 +495,24 @@ def test_returns_overdraft_when_multiplied_with_negative_integer( assert expected_product * a.currency.subunit > max_valid_sek return assert isinstance(product, Overdraft) - assert product.money.currency is a.currency - assert product.money.value == expected_product + assert product.currency is a.currency + assert product.value == expected_product reverse_applied = b * a assert isinstance(reverse_applied, Overdraft) - assert reverse_applied.money.currency is a.currency - assert reverse_applied.money.value == expected_product + assert reverse_applied.currency is a.currency + assert reverse_applied.value == expected_product + + @given(integers(), currencies()) + def test_multiplying_with_zero_returns_money_zero(self, a: int, currency: Currency): + zero = currency(0) + result = a * zero + + assert isinstance(result, Money) + assert result.value == 0 + assert result.currency == currency + + # Test commutative property. + assert zero * a == result @given(monies(), decimals(allow_infinity=False, allow_nan=False)) def test_returns_subunit_fraction_when_multiplied_with_decimal( @@ -525,7 +591,7 @@ def test_returns_subunit_fraction_on_floordiv( quotient = dividend // divisor assert isinstance(quotient, SubunitFraction) - assert quotient.value == Fraction(dividend.as_subunit(), divisor) + assert quotient.value == Fraction(dividend.subunits, divisor) assert quotient.currency == dividend.currency @given(monies()) @@ -544,10 +610,10 @@ class FooType(Currency): Foo = FooType() one_subunit = Foo("0.0001") - assert one_subunit.as_subunit() == 1 + assert one_subunit.subunits == 1 one_main_unit = Foo(1) - assert one_main_unit.as_subunit() == Foo.subunit + assert one_main_unit.subunits == Foo.subunit def test_from_subunit_returns_instance(self): class FooType(Currency): @@ -564,7 +630,7 @@ class FooType(Currency): @given(currencies(), integers(max_value=max_valid_sek, min_value=0)) def test_subunit_roundtrip(self, currency: Currency, value: int): - assert value == Money.from_subunit(value, currency).as_subunit() + assert value == Money.from_subunit(value, currency).subunits def test_floored_returns_closest_currency_value(self): assert Money.floored(Decimal("0.001"), SEK) == SEK(0) @@ -574,7 +640,7 @@ def test_floored_returns_closest_currency_value(self): assert Money.floored(Decimal("-0.001"), SEK) == SEK(0) def test_floored_raises_for_invalid_value(self): - with pytest.raises(MoneyParseError): + with pytest.raises(ParseError): Money.floored(Decimal("-0.0101"), SEK) @@ -685,22 +751,87 @@ def test_round_money_returns_money(self): assert SEK("3.32") == fraction.round_money(Round.HALF_DOWN) assert SEK("3.32") == fraction.round_money(Round.ZERO_FIVE_UP) - def test_round_money_raises__shoosh__for_negative_fraction(self): - with pytest.raises(MoneyParseError): + def test_round_money_raises_parser_error_for_negative_fraction(self): + with pytest.raises(ParseError): SubunitFraction(-1, SEK).round_money(Round.DOWN) - with pytest.raises(MoneyParseError): + with pytest.raises(ParseError): SubunitFraction(-100, NOK).round_money(Round.DOWN) + 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.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) + + def test_round_overdraft_raises_parse_error_for_positive_fraction(self): + with pytest.raises(ParseError): + SubunitFraction(1, SEK).round_overdraft(Round.DOWN) + + 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.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) + + def test_round_either_returns_money_for_positive_fraction(self): + fraction = SubunitFraction(Fraction(997, 3), SEK) + assert SEK("3.32") == fraction.round_either(Round.DOWN) + assert SEK("3.33") == fraction.round_either(Round.UP) + assert SEK("3.32") == fraction.round_either(Round.HALF_UP) + assert SEK("3.32") == fraction.round_either(Round.HALF_EVEN) + assert SEK("3.32") == fraction.round_either(Round.HALF_DOWN) + assert SEK("3.32") == fraction.round_either(Round.ZERO_FIVE_UP) + class TestOverdraft: - @pytest.mark.parametrize( - "value", - (object(), "foo", 123, 123.25, Decimal("123.25"), SubunitFraction(1, SEK)), - ) - def test_init_raises_type_error_for_non_money_value(self, value: object): + @given(valid_sek) + @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 + + @pytest.mark.parametrize("value", (0, "0.00", Decimal(0), Decimal("0.0"))) + def test_raises_type_error_for_value_zero(self, value: ParsableMoneyValue): + with pytest.raises( + InvalidOverdraftValue, + match=( + r"^Overdraft cannot be instantiated with a value of zero, the Money " + r"class should be used instead\.$" + ), + ): + SEK.overdraft(value) + + def test_instantiation_caches_instance(self): + assert SEK.overdraft("1.01") is SEK.overdraft("1.010") + assert SEK.overdraft(1) is SEK.overdraft(1) + + def test_cannot_instantiate_subunit_fraction(self): + with pytest.raises(ParseError): + SEK.overdraft(Decimal("1.001")) + + def test_raises_type_error_when_instantiated_with_non_currency(self): with pytest.raises(TypeError): - Overdraft(value) # type: ignore[arg-type] + Overdraft("2.00", "SEK") # type: ignore[type-var] + + @given(money=monies(), name=text(), value=valid_sek | 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): + initial = getattr(money, name, None) + with pytest.raises(FrozenInstanceError): + setattr(money, name, value) + assert getattr(money, name, None) == initial @pytest.mark.parametrize( ("value", "expected"), @@ -708,7 +839,7 @@ def test_init_raises_type_error_for_non_money_value(self, value: object): (SEK.overdraft(Decimal("523.12")), "Overdraft('523.12', SEK)"), (SEK.overdraft(52), "Overdraft('52.00', SEK)"), (SEK.overdraft(Decimal("52.13")), "Overdraft('52.13', SEK)"), - (SEK.overdraft(0), "Overdraft('0.00', SEK)"), + (SEK.overdraft("0.01"), "Overdraft('0.01', SEK)"), (NOK.overdraft(8000), "Overdraft('8000.00', NOK)"), ), ) @@ -725,54 +856,61 @@ def test_hash(self): assert {a, a, b} == {a, b, b} assert hash(SEK.overdraft(13)) == hash(SEK.overdraft(13)) - @given(monies()) - def test_abs_returns_money(self, value: Money[Any]): - assert value is abs(Overdraft(value)) + @given(overdrafts()) + def test_abs_returns_money(self, value: Overdraft[Any]): + assert abs(value) is Money(value.value, value.currency) - @given(monies()) - def test_neg_returns_money(self, value: Money[Any]): - assert value is -Overdraft(value) + @given(overdrafts()) + def test_neg_returns_money(self, value: Overdraft[Any]): + assert -value is Money(value.value, value.currency) - @given(monies()) - def test_pos_returns_self(self, value: Money[Any]): - assert Overdraft(value) is +Overdraft(value) + @given(overdrafts()) + def test_pos_returns_self(self, value: Overdraft[Any]): + assert value is +value def test_can_check_equality_with_zero(self): - assert SEK.overdraft(0) == 0 - assert 0 == SEK.overdraft(0) assert SEK.overdraft("0.01") != 0 assert 0 != SEK.overdraft("0.01") - assert NOK.overdraft(0) == 0 - assert 0 == NOK.overdraft(0) assert NOK.overdraft("0.01") != 0 assert 0 != NOK.overdraft("0.01") - @given(value=monies(), number=integers(min_value=1)) + @given(value=overdrafts(), number=integers(min_value=1)) @example(SEK(1), 1) @example(SEK("0.1"), 1) @example(SEK("0.01"), 1) - def test_cannot_check_equality_with_non_zero(self, value: Money[Any], number: int): - assert Overdraft(value) != number + def test_equality_with_non_zero_is_always_false( + self, value: Overdraft[Any], number: int + ): + assert value != number - @given(money_value=monies(), overdraft_value=monies()) - @example(SEK(1), SEK(1)) - def test_can_check_equality_with_money( + @given(money_value=monies(), overdraft_value=overdrafts()) + @example(SEK(1), SEK.overdraft(1)) + def test_equality_with_money_is_always_false( self, money_value: Money[Any], - overdraft_value: Money[Any], + overdraft_value: Overdraft[Any], ): - assert money_value != Overdraft(overdraft_value) + assert money_value != overdraft_value + assert overdraft_value != money_value - @given(sums_to_valid_sek()) - @example((Decimal(0), Decimal(0))) + @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) - assert (b + a).money.value == (a + b).money.value == x + y - @given(sums_to_valid_sek()) - @example((Decimal(0), Decimal(0))) + # Test commutative property. + c = b + a + assert isinstance(c, Overdraft) + d = a + b + assert isinstance(d, Overdraft) + assert c == d + assert c.value == d.value == x + y + + @given(non_zero_sums_to_valid_sek()) + @example((Decimal("0.01"), Decimal("0.01"))) def test_adding_instances_of_different_currency_raises_type_error( self, xy: tuple[Decimal, Decimal] ): @@ -785,9 +923,10 @@ def test_adding_instances_of_different_currency_raises_type_error( b + a # type: ignore[operator] @given(sums_to_valid_sek()) - @example((Decimal(0), Decimal(0))) + @example((Decimal("0.01"), Decimal(0))) def test_adding_money_equals_subtraction(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) @@ -804,16 +943,17 @@ def test_can_add_money(self): d = SEK.overdraft(1000) negative_sum = c + d assert isinstance(negative_sum, Overdraft) - assert negative_sum.money.value == Decimal("400") + assert negative_sum.value == Decimal("400") assert negative_sum == d + c @given(sums_to_valid_sek()) - @example((Decimal(0), Decimal(0))) + @example((Decimal(0), Decimal("0.01"))) def test_adding_money_of_different_currency_raises_type_error( self, xy: tuple[Decimal, Decimal] ): x, y = xy a = SEK(x) + assume(y != 0) b = NOK.overdraft(y) with pytest.raises(TypeError): a + b # type: ignore[operator] @@ -830,16 +970,16 @@ def test_cannot_add_arbitrary_object(self, value: object): with pytest.raises(TypeError): value + SEK.overdraft(1) # type: ignore[operator] - @given(sums_to_valid_sek()) - @example((Decimal(0), Decimal(0))) + @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(sums_to_valid_sek()) - @example((Decimal(0), Decimal(0))) + @given(non_zero_sums_to_valid_sek()) + @example((Decimal("0.01"), Decimal("0.01"))) def test_subtracting_instances_of_different_currency_raises_type_error( self, xy: tuple[Decimal, Decimal] ): @@ -855,6 +995,7 @@ def test_subtracting_instances_of_different_currency_raises_type_error( @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) @@ -883,6 +1024,7 @@ def test_subtracting_money_of_different_currency_raises_type_error( ): x, y = xy a = SEK(x) + assume(y != 0) b = NOK.overdraft(y) with pytest.raises(TypeError): a - b # type: ignore[operator] diff --git a/tests/test_initial.py b/tests/test_initial.py index b58273b..bf10b2d 100644 --- a/tests/test_initial.py +++ b/tests/test_initial.py @@ -1,3 +1,9 @@ +""" +These tests originate from initial experimentation, and were useful to have as +regression tests during early development. They have probably played out their role +since then, and can be removed. Meaning that they are now duplicating other better +written tests in the suite. +""" from decimal import Decimal from fractions import Fraction @@ -53,14 +59,14 @@ def test_subtraction(): # When the result of subtraction exceeds zero, the resulting value is an instance of # Overdraft. overdraft = m - Money("100.01", SEK) - assert overdraft == Overdraft(Money(".01", SEK)) + assert overdraft == Overdraft(".01", SEK) redeemed = overdraft + SEK("0.01") assert redeemed == SEK(0) - larger_overdraft = Overdraft(SEK(1000)) + larger_overdraft = SEK.overdraft(1000) not_yet_redeemed = larger_overdraft + SEK(501) - assert not_yet_redeemed == Overdraft(SEK(499)) + assert not_yet_redeemed == SEK.overdraft(499) - growing_overdraft = larger_overdraft + Overdraft(SEK(10000)) - assert growing_overdraft == Overdraft(SEK(11000)) + growing_overdraft = larger_overdraft + SEK.overdraft(10000) + assert growing_overdraft == SEK.overdraft(11000) diff --git a/tests/test_pydantic.py b/tests/test_pydantic.py index f54bca4..2fbedc8 100644 --- a/tests/test_pydantic.py +++ b/tests/test_pydantic.py @@ -681,14 +681,14 @@ def test_can_roundtrip_valid_data( assert instance.overdraft == expected assert json.loads(instance.model_dump_json()) == data - @pytest.mark.parametrize("value", (-1, -1024)) - def test_parsing_raises_validation_error_for_negative_value( + @pytest.mark.parametrize("value", (0, -1, -1024)) + def test_parsing_raises_validation_error_for_non_positive_value( self, value: int, ) -> None: with pytest.raises( ValidationError, - match=r"Input should be greater than or equal to 0", + match=r"Input should be greater than 0", ): DefaultOverdraftModel.model_validate( { @@ -736,7 +736,7 @@ def test_can_generate_schema(self) -> None: "overdraft_subunits": { "title": "Overdraft Subunits", "type": "integer", - "minimum": 0, + "exclusiveMinimum": 0, }, }, "required": sorted_items_equal(["overdraft_subunits", "currency"]), @@ -767,7 +767,7 @@ def test_can_roundtrip_valid_data(self) -> None: assert instance.overdraft == MCN.overdraft("89.999") assert json.loads(instance.model_dump_json()) == data assert_type(instance.overdraft, Overdraft[CustomCurrency]) - assert_type(instance.overdraft.money.currency, CustomCurrency) + assert_type(instance.overdraft.currency, CustomCurrency) def test_parsing_raises_validation_error_for_invalid_currency(self) -> None: data = { @@ -806,7 +806,7 @@ def test_can_generate_schema(self) -> None: "overdraft_subunits": { "title": "Overdraft Subunits", "type": "integer", - "minimum": 0, + "exclusiveMinimum": 0, }, }, "required": sorted_items_equal(["overdraft_subunits", "currency"]), @@ -837,7 +837,7 @@ def test_can_roundtrip_valid_data(self) -> None: assert instance.overdraft == CUP.overdraft("899.99") assert json.loads(instance.model_dump_json()) == data assert_type(instance.overdraft, Overdraft[CUPType]) - assert_type(instance.overdraft.money.currency, CUPType) + assert_type(instance.overdraft.currency, CUPType) def test_parsing_raises_validation_error_for_invalid_currency(self) -> None: data = { @@ -872,7 +872,7 @@ def test_can_generate_schema(self) -> None: "overdraft_subunits": { "title": "Overdraft Subunits", "type": "integer", - "minimum": 0, + "exclusiveMinimum": 0, }, }, "required": sorted_items_equal(["overdraft_subunits", "currency"]),