Klasa Money i testy jednostkowe do niej w Pythonie

0

Witam

Co sądzicie o takiej implementacji klasy Money?

import functools
from decimal import Decimal
from dataclasses import dataclass
 
 
@dataclass
class Currency:
    name: str
    symbol: str
    exchange_rate: Decimal
 
    def __str__(self) -> str:
        return self.name
 
    def __repr__(self) -> str:
        return f"{self.__class__.__name__}({self.name!r}, {self.symbol!r}, {self.exchange_rate!r})"
 
 
@functools.total_ordering
class Money:
    def __init__(self, amount: Decimal, currency: Currency, precision: int = 2):
        self._amount = amount
        self.precision = precision
        self.currency = currency
 
    @property
    def amount(self) -> Decimal:
        return self._amount.quantize(Decimal("10") ** -self.precision)
 
    @amount.setter
    def amount(self, value: Decimal) -> None:
        self._amount = value
 
    def __str__(self) -> str:
        return f"{self.amount} {self.currency.symbol}"
 
    def __repr__(self) -> str:
        return f"{self.__class__.__name__}({self.amount!r}, {self.currency!r})"
 
    def __add__(self, other):
        if self.currency != other.currency:
            raise ValueError(f"cannot add two {self.__class__.__name__} instances: "
                             "different currency property")
        return Money(self.amount + other.amount, self.currency)
 
    def __sub__(self, other):
        if self.currency != other.currency:
            raise ValueError(f"cannot subtract two {self.__class__.__name__} instances: "
                             "different currency property")
        return Money(self.amount - other.amount, self.currency)
 
    def __mul__(self, other):
        if self.currency != other.currency:
            raise ValueError(f"cannot multiply two {self.__class__.__name__} instances: "
                             "different currency property")
        return Money(self.amount * other.amount, self.currency, self.precision)
 
    def __truediv__(self, other):
        if self.currency != other.currency:
            raise ValueError(f"cannot divide two {self.__class__.__name__} instances: "
                             "different currency property")
        return Money(self.amount / other.amount, self.currency, self.precision)
 
    def __iadd__(self, other):
        if self.currency != other.currency:
            raise ValueError(f"cannot add two {self.__class__.__name__} instances: "
                             "different currency property")
        self.amount += other.amount
        return self
 
    def __isub__(self, other):
        if self.currency != other.currency:
            raise ValueError(f"cannot subtract two {self.__class__.__name__} instances: "
                             "different currency property")
        self.amount -= other.amount
        return self
 
    def __imul__(self, other):
        if self.currency != other.currency:
            raise ValueError(f"cannot multiply two {self.__class__.__name__} instances: "
                             "different currency property")
        self.amount *= other.amount
        return self
 
    def __idiv__(self, other):
        if self.currency != other.currency:
            raise ValueError(f"cannot divide two {self.__class__.__name__} instances: "
                             "different currency property")
        self.amount /= other.amount
        return self
 
    def __lt__(self, other):
        if self.currency != other.currency:
            raise ValueError(f"cannot compare two {self.__class__.__name__} instances: "
                             "different currency property")
        return self.amount < other.amount
 
    def __eq__(self, other):
        if self.currency != other.currency:
            raise ValueError(f"cannot compare two {self.__class__.__name__} instances: "
                             "different currency property")
        return self.amount == other.amount

Testy:

import pytest
from example_shop.shop.money import Currency, Money
from decimal import Decimal
from typing import Optional
 
 
def money_euro(amount: str, precision: Optional[int] = None) -> Money:
    if precision is None:
        return Money(Decimal(amount), Currency("Euro", "EUR", Decimal("4.52")))
    return Money(Decimal(amount), Currency("Euro", "EUR", Decimal("4.52")), precision)
 
 
def money_usd(amount: str, precision: Optional[int] = None) -> Money:
    if precision is None:
        return Money(Decimal(amount), Currency("American dollar", "USD", Decimal("4.17")))
    return Money(Decimal(amount), Currency("American dollar", "USD", Decimal("4.17")), precision)
 
 
@pytest.mark.parametrize("price1,price2,expected",
                         [(money_usd("1.5"), money_usd("1.2"), money_usd("2.7")),
                          (money_usd("2.7"), money_usd("1.3"), money_usd("4.0")),
                          (money_usd("2.700"), money_usd("1.3"), money_usd("4.00000")),
                          (money_usd("1.5", 4), money_usd("1.5", 3), money_usd("3", 4)),
                          (money_usd("-1.5", 4), money_usd("3", 5), money_usd("1.5", 5))])
def test_money_add_the_same_currency(price1, price2, expected):
    assert price1 + price2 == expected
 
 
@pytest.mark.parametrize("price1,price2,expected",
                         [(money_usd("1.5"), money_usd("1.2"), money_usd("0.3")),
                          (money_usd("2.7"), money_usd("1.3"), money_usd("1.4")),
                          (money_usd("2.700"), money_usd("1.3"), money_usd("1.40000")),
                          (money_usd("1.5", 4), money_usd("1.5", 3), money_usd("0", 4)),
                          (money_usd("1.5", 4), money_usd("3", 5), money_usd("-1.5", 5))])
def test_money_subtract_the_same_currency(price1, price2, expected):
    assert price1 - price2 == expected
 
 
@pytest.mark.parametrize("price1,price2,expected",
                         [(money_usd("1.5"), money_usd("1.2"), money_usd("1.8")),
                          (money_usd("2.7"), money_usd("1.3"), money_usd("3.51")),
                          (money_usd("2.700"), money_usd("1.3"), money_usd("3.51000")),
                          (money_usd("0", 4), money_usd("1.5", 3), money_usd("0", 4)),
                          (money_usd("1.5", 4), money_usd("-3", 5), money_usd("-4.5", 5))])
def test_money_multiply_the_same_currency(price1, price2, expected):
    assert price1 * price2 == expected
 
 
@pytest.mark.parametrize("price1,price2,expected",
                         [(money_usd("1.5"), money_usd("1.2"), money_usd("1.25")),
                          (money_usd("2.7"), money_usd("1.3"), money_usd("2.08")),
                          (money_usd("2.700"), money_usd("1.3"), money_usd("2.08000")),
                          (money_usd("0", 4), money_usd("1.5", 3), money_usd("0", 4)),
                          (money_usd("1.5", 4), money_usd("-3", 5), money_usd("-0.5", 5))])
def test_money_divide_the_same_currency(price1, price2, expected):
    assert price1 / price2 == expected
 
 
@pytest.mark.parametrize("price1,price2",
                         [(money_usd("1.5"), money_euro("1.2")),
                          (money_euro("1.2"), money_usd("1.5")),
                          (money_usd("1.5", 4), money_euro("1.2", 5)),
                          (money_euro("1.2", 4), money_usd("1.5", 5))])
def test_money_add_the_different_currency(price1, price2):
    with pytest.raises(ValueError):
        assert price1 + price2
 
 
@pytest.mark.parametrize("price1,price2",
                         [(money_usd("1.5"), money_euro("1.2")),
                          (money_euro("1.2"), money_usd("1.5")),
                          (money_usd("1.5", 4), money_euro("1.2", 5)),
                          (money_euro("1.2", 4), money_usd("1.5", 5))])
def test_money_subtract_different_currency(price1, price2):
    with pytest.raises(ValueError):
        assert price1 - price2
 
 
@pytest.mark.parametrize("price1,price2",
                         [(money_usd("1.5"), money_euro("1.2")),
                          (money_euro("1.2"), money_usd("1.5")),
                          (money_usd("1.5", 4), money_euro("1.2", 5)),
                          (money_euro("1.2", 4), money_usd("1.5", 5))])
def test_money_multiply_different_currency(price1, price2):
    with pytest.raises(ValueError):
        assert price1 * price2
 
 
@pytest.mark.parametrize("price1,price2",
                         [(money_usd("1.5"), money_euro("1.2")),
                          (money_euro("1.2"), money_usd("1.5")),
                          (money_usd("1.5", 4), money_euro("1.2", 5)),
                          (money_euro("1.2", 4), money_usd("1.5", 5))])
def test_money_divide_different_currency(price1, price2):
    with pytest.raises(ValueError):
        assert price1 / price2
 
 
@pytest.mark.parametrize("price1,price2,expected",
                         [(money_usd("1.5"), money_usd("1.2"), money_usd("2.7")),
                          (money_usd("2.7"), money_usd("1.3"), money_usd("4.0")),
                          (money_usd("2.700"), money_usd("1.3"), money_usd("4.00000")),
                          (money_usd("1.5", 4), money_usd("1.5", 3), money_usd("3", 4)),
                          (money_usd("-1.5", 4), money_usd("3", 5), money_usd("1.5", 5))])
def test_money_add_in_place_the_same_currency(price1, price2, expected):
    result = price1
    result += price2
    assert result == expected
 
 
@pytest.mark.parametrize("price1,price2,expected",
                         [(money_usd("1.5"), money_usd("1.2"), money_usd("0.3")),
                          (money_usd("2.7"), money_usd("1.3"), money_usd("1.4")),
                          (money_usd("2.700"), money_usd("1.3"), money_usd("1.40000")),
                          (money_usd("1.5", 4), money_usd("1.5", 3), money_usd("0", 4)),
                          (money_usd("1.5", 4), money_usd("3", 5), money_usd("-1.5", 5))])
def test_money_subtract_in_place_the_same_currency(price1, price2, expected):
    result = price1
    result -= price2
    assert result == expected
 
 
@pytest.mark.parametrize("price1,price2,expected",
                         [(money_usd("1.5"), money_usd("1.2"), money_usd("1.8")),
                          (money_usd("2.7"), money_usd("1.3"), money_usd("3.51")),
                          (money_usd("2.700"), money_usd("1.3"), money_usd("3.51000")),
                          (money_usd("0", 4), money_usd("1.5", 3), money_usd("0", 4)),
                          (money_usd("1.5", 4), money_usd("-3", 5), money_usd("-4.5", 5))])
def test_money_multiply_in_place_the_same_currency(price1, price2, expected):
    result = price1
    result *= price2
    assert result == expected
 
 
@pytest.mark.parametrize("price1,price2,expected",
                         [(money_usd("1.5"), money_usd("1.2"), money_usd("1.25")),
                          (money_usd("2.7"), money_usd("1.3"), money_usd("2.08")),
                          (money_usd("2.700"), money_usd("1.3"), money_usd("2.08000")),
                          (money_usd("0", 4), money_usd("1.5", 3), money_usd("0", 4)),
                          (money_usd("1.5", 4), money_usd("-3", 5), money_usd("-0.5", 5))])
def test_money_divide_in_place_the_same_currency(price1, price2, expected):
    result = price1
    result /= price2
    assert result == expected
 
 
@pytest.mark.parametrize("price1,price2",
                         [(money_usd("1.5"), money_euro("1.2")),
                          (money_euro("1.2"), money_usd("1.5")),
                          (money_usd("1.5", 4), money_euro("1.2", 5)),
                          (money_euro("1.2", 4), money_usd("1.5", 5))])
def test_money_add_in_place_the_different_currency(price1, price2):
    with pytest.raises(ValueError):
        result = price1
        result += price2
        assert result
 
 
@pytest.mark.parametrize("price1,price2",
                         [(money_usd("1.5"), money_euro("1.2")),
                          (money_euro("1.2"), money_usd("1.5")),
                          (money_usd("1.5", 4), money_euro("1.2", 5)),
                          (money_euro("1.2", 4), money_usd("1.5", 5))])
def test_money_subtract_in_place_different_currency(price1, price2):
    with pytest.raises(ValueError):
        result = price1
        result -= price2
        assert result
 
 
@pytest.mark.parametrize("price1,price2",
                         [(money_usd("1.5"), money_euro("1.2")),
                          (money_euro("1.2"), money_usd("1.5")),
                          (money_usd("1.5", 4), money_euro("1.2", 5)),
                          (money_euro("1.2", 4), money_usd("1.5", 5))])
def test_money_multiply_in_place_different_currency(price1, price2):
    with pytest.raises(ValueError):
        result = price1
        result *= price2
        assert result
 
 
@pytest.mark.parametrize("price1,price2",
                         [(money_usd("1.5"), money_euro("1.2")),
                          (money_euro("1.2"), money_usd("1.5")),
                          (money_usd("1.5", 4), money_euro("1.2", 5)),
                          (money_euro("1.2", 4), money_usd("1.5", 5))])
def test_money_divide_in_place_different_currency(price1, price2):
    with pytest.raises(ValueError):
        result = price1
        result /= price2
        assert result
 
@pytest.mark.parametrize("price1,price2,expected",
                         [(money_euro("1.23"), money_euro("4.56"), True),
                          (money_euro("1.5"), money_euro("1"), False),
                          (money_usd("-2"), money_usd("0"), True),
                          (money_euro("0"), money_euro("0"), False)])
def test_less_than_the_same_currency(price1, price2, expected):
    assert (price1 < price2) == expected
 
 
@pytest.mark.parametrize("price1,price2,expected",
                         [(money_euro("1.23"), money_euro("4.56"), False),
                          (money_euro("1.5"), money_euro("1"), False),
                          (money_usd("-2"), money_usd("0"), False),
                          (money_euro("0"), money_euro("0"), True)])
def test_equal_the_same_currency(price1, price2, expected):
    assert (price1 == price2) == expected
 
 
@pytest.mark.parametrize("price1,price2",
                         [(money_usd("1.23"), money_euro("4.56")),
                          (money_euro("1.5"), money_usd("1"))])
def test_less_than_different_currency(price1, price2):
    with pytest.raises(ValueError):
        assert price1 < price2
 
 
@pytest.mark.parametrize("price1,price2",
                         [(money_usd("1.23"), money_euro("4.56")),
                          (money_euro("1.5"), money_usd("1"))])
def test_equal_different_currency(price1, price2):
    with pytest.raises(ValueError):
        assert price1 == price2

Czy uzależnianie od siebie kolejności przypisań w konstruktorze jest ok gdy np. dana właściwość wykorzystuje wcześniejszą zmeinną?

Z góry dziękuję i pozdrawiam :-)

0

zastanawia mnie jaka jest uzytecznosc klasy Money. zamiast rzucac wyjatki powinna ona po prostu przeliczac miedzy walutami, w innym wypadku to troche przerost formy nad trescia i po co w ogole taki wrapper... testowanie typu czy mnozenie dziala to tez troche przesada, no chyba ze to taki kod na potrzeby prezentacji.

co do kolejnosci przypisan w konstruktorze to bez znaczenia, wazne zeby nie bylo side effectow.
swoja droga to lepiej w tym wypadku uzyc NamedTuple zamiast data classes

0

Money też powinienem zrobić jako NamedTuple i zamiast implementować przeciążanie dodawania, odejmowania itd. po prostu zrobić funkcję exchange_currency?

Co z testami? Są one w ogóle potrzebne?

Czyto, że będę pobierał dane na temat kursów walut z internetu oznacza, że lepiej użyć dataclasses zamiast namedtuple?

0

postaraj sie zeby twoje struktury byly jak najbardziej niemutowalne, uzycie namedtuple zamiast dataclass temu sprzyja. co do przeciazania operatorow to moze byc to troche przesada jesli po prostu chcesz przemieniac waluty :) w systemach finansowych zazwyczaj sie trzyma/przetwarza wszystko w jednym wspolnym standardzie (convention over configuration :)) a jedynie zmienia formatting w razie potrzeby gdy juz to wyswietlasz

0

Jest sens implementować dodawanie i odejmowanie dla Money czy tylko skupić się na porównaniach? Czy powinienem konwertować w trakcie porównań obie instancje Money na wspólną walutę czy mam porównać po prostu self.amount == other,amount and self.currency == other.currency and self.precision == other.precision?

1

ale to zalezy od przypadkow uzycia. przy czym jak sobie z Money zrobisz namedtuple to porownania dostajesz za free wiec nie ma co tu implementowac. ogolnie nie widze zbyt duzego sensu na robienie equals miedzy roznymi walutami, chocby czesc ulamkowa bedzie czesto problematyczna. sprowadzanie do wspolnej waluty powinno byc explicite, niejawne konwersje custom typow to proszenie sie o problemy. wez najpierw rozwiaz problemy biznesowe a potem refactor :)

0

Taka implementacja jest ok?

import functools
from decimal import Decimal
from typing import NamedTuple


class Currency(NamedTuple):
    name: str
    symbol: str

    def __str__(self) -> str:
        return self.name

    def __repr__(self) -> str:
        return f"{self.__class__.__name__}(name={self.name!r}, symbol={self.symbol!r})"


@functools.total_ordering
class Money:
    def __init__(self, amount: Decimal, currency: Currency, precision: int):
        if isinstance(amount, float):
            raise TypeError("amount should not be float")
        self.amount = Decimal(amount)
        self.currency = currency
        self.precision = precision

    @property
    def precision(self) -> int:
        return self._precision

    @precision.setter
    def precision(self, value: int) -> None:
        if value < 0:
            raise ValueError("precision should be a non-negative number")
        self._precision = value

    def __str__(self) -> str:
        return f"{self.amount:.{self.precision}f} {self.currency.symbol}"

    def __repr__(self) -> str:
        return f"{self.__class__.__name__}(amount={self.amount!r}, currency={self.currency!r}, " \
               f"precision={self.precision!r})"

    def __lt__(self, other) -> bool:
        if self.currency != other.currency:
            raise ValueError("comparison between different currencies is not supported")
        return self.amount < other.amount

    def __eq__(self, other) -> bool:
        if self.currency != other.currency:
            raise ValueError("comparison between different currencies is not supported")
        return self.amount == other.amount


EUR = Currency("Euro", "EUR")
USD = Currency("American dollar", "USD")


Testy:

import pytest
from example_shop.shop.money import Money, EUR, USD
from decimal import Decimal


@pytest.mark.parametrize("price1,price2,expected",
                         [(Money(Decimal("1.23"), EUR, 2), Money(Decimal("4.56"), EUR, 2), True),
                          (Money(Decimal("1.5"), EUR, 2), Money(Decimal("1"), EUR, 4), False),
                          (Money(Decimal("-2"), USD, 4), Money(Decimal("0"), USD, 2), True),
                          (Money(Decimal("0"), EUR, 4), Money(Decimal("0"), EUR, 4), False)])
def test_less_than_the_same_currency(price1, price2, expected):
    assert (price1 < price2) == expected


@pytest.mark.parametrize("price1,price2,expected",
                         [(Money(Decimal("1.23"), USD, 2), Money(Decimal("4.56"), USD, 2), False),
                          (Money(Decimal("1.5"), EUR, 2), Money(Decimal("1"), EUR, 4), False),
                          (Money(Decimal("-2"), EUR, 4), Money(Decimal("0"), EUR, 2), False),
                          (Money(Decimal("0"), USD, 4), Money(Decimal("0"), USD, 4), True)])
def test_equal_the_same_currency(price1, price2, expected):
    assert (price1 == price2) == expected


@pytest.mark.parametrize("price1,price2",
                         [(Money(Decimal("1.23"), EUR, 2), Money(Decimal("4.56"), USD, 4)),
                          (Money(Decimal("1.5"), USD, 4), Money(Decimal("1"), EUR, 2))])
def test_less_than_different_currency(price1, price2):
    with pytest.raises(ValueError):
        assert price1 < price2


@pytest.mark.parametrize("price1,price2",
                         [(Money(Decimal("1.23"), EUR, 2), Money(Decimal("4.56"), USD, 4)),
                          (Money(Decimal("1.5"), USD, 4), Money(Decimal("1"), EUR, 2))])
def test_equal_different_currency(price1, price2):
    with pytest.raises(ValueError):
        assert price1 == price2


4

Jaki jest sens mnożenia waluty przez walutę? Wychodzi waluta kwadratu ;) Powinieneś mieć operację mnożenia / dzielenia przez wartość bezwymiarową. Btw: Operacje dodawania i odejmowania jak najbardziej mają sens, bo zachowują typ tj. rodzaj waluty.

4

U mnie na studiach jednej pani taki wzór wyszedł ze złotówkami kwadratowymi :-). To było na jakimś przedmiocie humanistyczno menadżerskim, wielu ludziom mózg się zagotował i w zasadzie już do końca semestru staraliśmy się na wykład nie chodzić (tylko delegować kogoś do podpisywania listy).
Z drugiej strony - nie zdziwiłbym się jakby jacyś magicy coś takiego robili przy jakiejś analizie technicznej - tam fizyka i logika nie działa.

0
jarekr000000 napisał(a):

U mnie na studiach jednej pani taki wzór wyszedł ze złotówkami kwadratowymi :-). To było na jakimś przedmiocie humanistyczno menadżerskim, wielu ludziom mózg się zagotował i w zasadzie już do końca semestru staraliśmy się na wykład nie chodzić (tylko delegować kogoś do podpisywania listy).
Z drugiej strony - nie zdziwiłbym się jakby jacyś magicy coś takiego robili przy jakiejś analizie technicznej - tam fizyka i logika nie działa.

No ale @Krolik ma rację. Jeśli mnoższy dwie waluty przez siebie to wychodzi Ci jednostka waluta do kwadratu. Podobnie jak podzielisz dwie te same waluty przez siebie to Ci wyjdzie wartość bez jednostki.

@lester29: Co do samego kodu:

  • Widać że testy nie napisane w TDD
  • Ale jak na takie testy to są nawet dobre
  • Pole exchange_rate jest nieużyte?
  • Test pod dzielenie przez 0?
  • Zawsze robisz Money(Decimal(x)) - można by przerobić tą klasę tak żeby dało się robić Money(x) (a wewnątrz by się robiło Decimal())
  • Co do uwagi odnośnie tego czy klasa powinna być niemutowalna czy mutowalna - odpowiedź brzmi: zależy:
    • Jeśli masz przykłady użycia gdzie mutowanie money jest Ci potrzebne, to zaimplementuj to
    • Jeśli nie masz, i wszystko Ci jedno to faktycznie lepiej zrobić immutable

1 użytkowników online, w tym zalogowanych: 0, gości: 1