diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bd083ef..eb8ff64 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -64,7 +64,6 @@ repos: additional_dependencies: - pytest==7.1.3 - types-setuptools==65.3.0 - - phantom-types==0.17.1 - abcattrs==0.3.2 - typing-extensions==4.6.3 - hypothesis==6.54.4 diff --git a/setup.cfg b/setup.cfg index d2a777f..506a9b4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -27,7 +27,6 @@ package_dir = packages = find: python_requires = >=3.10 install_requires = - phantom-types>=2.1.0 typing-extensions>=4.6.3 abcattrs>=0.3.2 diff --git a/src/immoney/__init__.py b/src/immoney/__init__.py index c47d1a7..686153f 100644 --- a/src/immoney/__init__.py +++ b/src/immoney/__init__.py @@ -1,8 +1,16 @@ from ._base import Currency from ._base import Money from ._base import Overdraft +from ._base import ParsableMoneyValue from ._base import Round from ._base import SubunitFraction -__all__ = ("Money", "Currency", "SubunitFraction", "Round", "Overdraft") +__all__ = ( + "Money", + "Currency", + "SubunitFraction", + "Round", + "Overdraft", + "ParsableMoneyValue", +) __version__ = "0.2.0" diff --git a/src/immoney/_base.py b/src/immoney/_base.py index 6f5211c..51fbf80 100644 --- a/src/immoney/_base.py +++ b/src/immoney/_base.py @@ -18,8 +18,9 @@ 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 cast from typing import final from typing import overload @@ -32,14 +33,15 @@ from .errors import DivisionByZero from .errors import InvalidSubunit from .errors import MoneyParseError -from .types import ParsableMoneyValue -from .types import PositiveDecimal if TYPE_CHECKING: from pydantic_core.core_schema import CoreSchema from .registry import CurrencyRegistry +ParsableMoneyValue: TypeAlias = int | str | Decimal +PositiveDecimal = NewType("PositiveDecimal", Decimal) + valid_subunit: Final = frozenset({10**i for i in range(20)}) @@ -80,23 +82,31 @@ def zero(self) -> Money[Self]: return Money(0, self) def normalize_value(self, value: Decimal | int | str) -> PositiveDecimal: - try: - positive = PositiveDecimal.parse(value) - except TypeError as e: - raise MoneyParseError( - "Failed to interpret value as non-negative decimal" - ) from e + if not isinstance(value, Decimal): + try: + value = Decimal(value) + except decimal.InvalidOperation: + raise MoneyParseError("Failed parsing Decimal") + + if value.is_nan(): + raise MoneyParseError("Cannot parse from NaN") + + if not value.is_finite(): + raise MoneyParseError("Cannot parse from non-finite") + + if value < 0: + raise MoneyParseError("Cannot parse from negative value") - quantized = cast(PositiveDecimal, positive.quantize(self.decimal_exponent)) + quantized = value.quantize(self.decimal_exponent) - if positive != quantized: + if value != quantized: raise MoneyParseError( f"Cannot interpret value as Money of currency {self.code} without loss " f"of precision. Explicitly round the value or consider using " f"SubunitFraction." ) - return quantized + return PositiveDecimal(quantized) def from_subunit(self, value: int) -> Money[Self]: return Money.from_subunit(value, self) @@ -153,7 +163,7 @@ def _normalize( value: ParsableMoneyValue, currency: C_inv, /, - ) -> tuple[Decimal, C_inv]: + ) -> tuple[PositiveDecimal, C_inv]: if not isinstance(currency, Currency): raise TypeError( f"Argument 'currency' of {cls.__qualname__!r} must be a Currency, " diff --git a/src/immoney/types.py b/src/immoney/types.py deleted file mode 100644 index 87f22ea..0000000 --- a/src/immoney/types.py +++ /dev/null @@ -1,34 +0,0 @@ -from __future__ import annotations - -import decimal -from decimal import Decimal -from typing import TypeAlias - -from phantom import Phantom -from phantom.predicates.boolean import all_of -from phantom.predicates.boolean import negate -from phantom.predicates.numeric import non_negative - - -class PositiveDecimal( - Decimal, - Phantom[Decimal], - predicate=all_of( - ( - negate(Decimal.is_nan), - Decimal.is_finite, - non_negative, - ) - ), -): - @classmethod - def parse(cls, instance: object) -> PositiveDecimal: - if isinstance(instance, (str, int)): - try: - return super().parse(Decimal(instance)) - except decimal.InvalidOperation: - pass - return super().parse(instance) - - -ParsableMoneyValue: TypeAlias = int | str | Decimal diff --git a/tests/test_base.py b/tests/test_base.py index 3001434..bb4aedd 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -120,7 +120,7 @@ class Subclass(Currency): "0." + len(str(subunit_value)) * "0" ) - def test_zero_returns_cached_instance_of_money_zero(self): + 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.currency is SEK @@ -138,30 +138,29 @@ def test_normalize_value_raises_for_precision_loss( self, currency: Currency, value: Decimal, - ): + ) -> None: with pytest.raises((MoneyParseError, InvalidOperation)): currency.normalize_value(value) currency.normalize_value(value + very_small_decimal) @given( - currency=currencies(), value=integers(max_value=-1) | decimals(max_value=Decimal("-0.000001")), ) - def test_normalize_value_raises_for_negative_value( - self, currency: Currency, value: object - ): + def test_normalize_value_raises_for_negative_value(self, value: object) -> None: + with pytest.raises(MoneyParseError): + SEK.normalize_value(value) # type: ignore[arg-type] + + def test_normalize_value_raises_for_invalid_str(self) -> None: with pytest.raises(MoneyParseError): - currency.normalize_value(value) # type: ignore[arg-type] + SEK.normalize_value("foo") - @given(currencies()) - def test_normalize_value_raises_for_invalid_str(self, currency: Currency): + def test_normalize_value_raises_for_nan(self) -> None: with pytest.raises(MoneyParseError): - currency.normalize_value("foo") + SEK.normalize_value(Decimal("nan")) - @given(currencies()) - def test_normalize_value_raises_for_nan(self, currency: Currency): + def test_normalize_value_raises_for_non_finite(self) -> None: with pytest.raises(MoneyParseError): - currency.normalize_value(Decimal("nan")) + SEK.normalize_value(float("inf")) # type: ignore[arg-type] valid_values = decimals(