246 lines
8.8 KiB
Python
246 lines
8.8 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from decimal import Decimal
|
|
from enum import StrEnum
|
|
|
|
|
|
class BaseCurrency(StrEnum):
|
|
USD = "USD"
|
|
EUR = "EUR"
|
|
CHF = "CHF"
|
|
|
|
|
|
class WeightUnit(StrEnum):
|
|
GRAM = "g"
|
|
KILOGRAM = "kg"
|
|
OUNCE_TROY = "ozt"
|
|
|
|
|
|
GRAMS_PER_KILOGRAM = Decimal("1000")
|
|
GRAMS_PER_TROY_OUNCE = Decimal("31.1034768")
|
|
|
|
|
|
DecimalLike = Decimal | int | str
|
|
|
|
|
|
def to_decimal(value: DecimalLike) -> Decimal:
|
|
if isinstance(value, bool):
|
|
raise TypeError("Boolean values are not valid Decimal inputs")
|
|
if isinstance(value, Decimal):
|
|
amount = value
|
|
elif isinstance(value, int):
|
|
amount = Decimal(value)
|
|
elif isinstance(value, str):
|
|
amount = Decimal(value)
|
|
else:
|
|
raise TypeError(f"Unsupported decimal input type: {type(value)!r}")
|
|
if not amount.is_finite():
|
|
raise ValueError("Decimal value must be finite")
|
|
return amount
|
|
|
|
|
|
def decimal_from_float(value: float) -> Decimal:
|
|
if not isinstance(value, float):
|
|
raise TypeError(f"Expected float, got {type(value)!r}")
|
|
amount = Decimal(str(value))
|
|
if not amount.is_finite():
|
|
raise ValueError("Decimal value must be finite")
|
|
return amount
|
|
|
|
|
|
def _coerce_currency(value: BaseCurrency | str) -> BaseCurrency:
|
|
if isinstance(value, BaseCurrency):
|
|
return value
|
|
try:
|
|
return BaseCurrency(value)
|
|
except ValueError as exc:
|
|
raise ValueError(f"Invalid currency: {value!r}") from exc
|
|
|
|
|
|
def _coerce_weight_unit(value: WeightUnit | str) -> WeightUnit:
|
|
if isinstance(value, WeightUnit):
|
|
return value
|
|
try:
|
|
return WeightUnit(value)
|
|
except ValueError as exc:
|
|
raise ValueError(f"Invalid weight unit: {value!r}") from exc
|
|
|
|
|
|
def weight_unit_factor(unit: WeightUnit) -> Decimal:
|
|
if unit is WeightUnit.GRAM:
|
|
return Decimal("1")
|
|
if unit is WeightUnit.KILOGRAM:
|
|
return GRAMS_PER_KILOGRAM
|
|
if unit is WeightUnit.OUNCE_TROY:
|
|
return GRAMS_PER_TROY_OUNCE
|
|
raise ValueError(f"Unsupported weight unit: {unit}")
|
|
|
|
|
|
def convert_weight(amount: Decimal, from_unit: WeightUnit, to_unit: WeightUnit) -> Decimal:
|
|
if from_unit is to_unit:
|
|
return amount
|
|
grams = amount * weight_unit_factor(from_unit)
|
|
return grams / weight_unit_factor(to_unit)
|
|
|
|
|
|
def convert_price_per_weight(amount: Decimal, from_unit: WeightUnit, to_unit: WeightUnit) -> Decimal:
|
|
if from_unit is to_unit:
|
|
return amount
|
|
return amount * weight_unit_factor(to_unit) / weight_unit_factor(from_unit)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Money:
|
|
amount: Decimal
|
|
currency: BaseCurrency | str
|
|
|
|
def __post_init__(self) -> None:
|
|
object.__setattr__(self, "amount", to_decimal(self.amount))
|
|
object.__setattr__(self, "currency", _coerce_currency(self.currency))
|
|
|
|
@classmethod
|
|
def zero(cls, currency: BaseCurrency) -> Money:
|
|
return cls(amount=Decimal("0"), currency=currency)
|
|
|
|
def assert_currency(self, currency: BaseCurrency) -> Money:
|
|
if self.currency is not currency:
|
|
raise ValueError(f"Currency mismatch: {self.currency} != {currency}")
|
|
return self
|
|
|
|
def __add__(self, other: object) -> Money:
|
|
if not isinstance(other, Money):
|
|
return NotImplemented
|
|
if self.currency is not other.currency:
|
|
raise ValueError(f"Currency mismatch: {self.currency} != {other.currency}")
|
|
return Money(amount=self.amount + other.amount, currency=self.currency)
|
|
|
|
def __sub__(self, other: object) -> Money:
|
|
if not isinstance(other, Money):
|
|
return NotImplemented
|
|
if self.currency is not other.currency:
|
|
raise ValueError(f"Currency mismatch: {self.currency} != {other.currency}")
|
|
return Money(amount=self.amount - other.amount, currency=self.currency)
|
|
|
|
def __mul__(self, other: object) -> Money:
|
|
if isinstance(other, bool):
|
|
return NotImplemented
|
|
if isinstance(other, Decimal):
|
|
return Money(amount=self.amount * other, currency=self.currency)
|
|
if isinstance(other, int):
|
|
return Money(amount=self.amount * Decimal(other), currency=self.currency)
|
|
return NotImplemented
|
|
|
|
def __rmul__(self, other: object) -> Money:
|
|
return self.__mul__(other)
|
|
|
|
def __truediv__(self, other: object) -> Money:
|
|
if isinstance(other, bool):
|
|
return NotImplemented
|
|
if isinstance(other, Decimal):
|
|
return Money(amount=self.amount / other, currency=self.currency)
|
|
if isinstance(other, int):
|
|
return Money(amount=self.amount / Decimal(other), currency=self.currency)
|
|
return NotImplemented
|
|
|
|
def __neg__(self) -> Money:
|
|
return Money(amount=-self.amount, currency=self.currency)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Weight:
|
|
amount: Decimal
|
|
unit: WeightUnit | str
|
|
|
|
def __post_init__(self) -> None:
|
|
object.__setattr__(self, "amount", to_decimal(self.amount))
|
|
object.__setattr__(self, "unit", _coerce_weight_unit(self.unit))
|
|
|
|
def to_unit(self, unit: WeightUnit) -> Weight:
|
|
return Weight(amount=convert_weight(self.amount, self.unit, unit), unit=unit)
|
|
|
|
def __add__(self, other: object) -> Weight:
|
|
if not isinstance(other, Weight):
|
|
return NotImplemented
|
|
other_converted = other.to_unit(self.unit)
|
|
return Weight(amount=self.amount + other_converted.amount, unit=self.unit)
|
|
|
|
def __sub__(self, other: object) -> Weight:
|
|
if not isinstance(other, Weight):
|
|
return NotImplemented
|
|
other_converted = other.to_unit(self.unit)
|
|
return Weight(amount=self.amount - other_converted.amount, unit=self.unit)
|
|
|
|
def __mul__(self, other: object) -> Weight | Money:
|
|
if isinstance(other, bool):
|
|
return NotImplemented
|
|
if isinstance(other, Decimal):
|
|
return Weight(amount=self.amount * other, unit=self.unit)
|
|
if isinstance(other, int):
|
|
return Weight(amount=self.amount * Decimal(other), unit=self.unit)
|
|
if isinstance(other, PricePerWeight):
|
|
adjusted_weight = self.to_unit(other.per_unit)
|
|
return Money(amount=adjusted_weight.amount * other.amount, currency=other.currency)
|
|
return NotImplemented
|
|
|
|
def __rmul__(self, other: object) -> Weight:
|
|
if isinstance(other, bool):
|
|
return NotImplemented
|
|
if isinstance(other, Decimal):
|
|
return Weight(amount=self.amount * other, unit=self.unit)
|
|
if isinstance(other, int):
|
|
return Weight(amount=self.amount * Decimal(other), unit=self.unit)
|
|
return NotImplemented
|
|
|
|
def __truediv__(self, other: object) -> Weight:
|
|
if isinstance(other, bool):
|
|
return NotImplemented
|
|
if isinstance(other, Decimal):
|
|
return Weight(amount=self.amount / other, unit=self.unit)
|
|
if isinstance(other, int):
|
|
return Weight(amount=self.amount / Decimal(other), unit=self.unit)
|
|
return NotImplemented
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class PricePerWeight:
|
|
amount: Decimal
|
|
currency: BaseCurrency | str
|
|
per_unit: WeightUnit | str
|
|
|
|
def __post_init__(self) -> None:
|
|
amount = to_decimal(self.amount)
|
|
if amount < 0:
|
|
raise ValueError("PricePerWeight amount must be non-negative")
|
|
object.__setattr__(self, "amount", amount)
|
|
object.__setattr__(self, "currency", _coerce_currency(self.currency))
|
|
object.__setattr__(self, "per_unit", _coerce_weight_unit(self.per_unit))
|
|
|
|
def to_unit(self, unit: WeightUnit) -> PricePerWeight:
|
|
return PricePerWeight(
|
|
amount=convert_price_per_weight(self.amount, self.per_unit, unit),
|
|
currency=self.currency,
|
|
per_unit=unit,
|
|
)
|
|
|
|
def __mul__(self, other: object) -> Money | PricePerWeight:
|
|
if isinstance(other, bool):
|
|
return NotImplemented
|
|
if isinstance(other, Weight):
|
|
adjusted_weight = other.to_unit(self.per_unit)
|
|
return Money(amount=adjusted_weight.amount * self.amount, currency=self.currency)
|
|
if isinstance(other, Decimal):
|
|
return PricePerWeight(amount=self.amount * other, currency=self.currency, per_unit=self.per_unit)
|
|
if isinstance(other, int):
|
|
return PricePerWeight(amount=self.amount * Decimal(other), currency=self.currency, per_unit=self.per_unit)
|
|
return NotImplemented
|
|
|
|
def __rmul__(self, other: object) -> PricePerWeight:
|
|
if isinstance(other, bool):
|
|
return NotImplemented
|
|
if isinstance(other, Decimal):
|
|
return PricePerWeight(amount=self.amount * other, currency=self.currency, per_unit=self.per_unit)
|
|
if isinstance(other, int):
|
|
return PricePerWeight(amount=self.amount * Decimal(other), currency=self.currency, per_unit=self.per_unit)
|
|
return NotImplemented
|