[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]
[taler-taler-util] branch master updated: make amounts immutable, implem
From: |
gnunet |
Subject: |
[taler-taler-util] branch master updated: make amounts immutable, implement signed amounts |
Date: |
Sun, 12 Jan 2020 19:01:32 +0100 |
This is an automated email from the git hooks/post-receive script.
dold pushed a commit to branch master
in repository taler-util.
The following commit(s) were added to refs/heads/master by this push:
new 872a2e6 make amounts immutable, implement signed amounts
872a2e6 is described below
commit 872a2e65b7be94d91ca969c4529b7e300a979071
Author: Florian Dold <address@hidden>
AuthorDate: Sun Jan 12 19:00:21 2020 +0100
make amounts immutable, implement signed amounts
---
taler/util/amount.py | 398 ++++++++++++++++++++++++---------------------------
tests/test_amount.py | 74 +++++-----
tox.ini | 2 +-
3 files changed, 221 insertions(+), 253 deletions(-)
diff --git a/taler/util/amount.py b/taler/util/amount.py
index 3891326..9096f7d 100644
--- a/taler/util/amount.py
+++ b/taler/util/amount.py
@@ -21,228 +21,103 @@
# @version 0.1
# @repository https://git.taler.net/taler-util.git/
-
-##
-# Exception class to raise when an operation between two
-# amounts of different currency is being attempted.
-class CurrencyMismatch(Exception):
- hint = "Client gave amount with unsupported currency."
- http_status_code = 406
- taler_error_code = 5104
-
- ##
- # Init constructor.
- #
- # @param self the object itself.
- # @param curr1 first currency involved in the operation.
- # @param curr2 second currency involved in the operation.
+from dataclasses import dataclass
+from functools import total_ordering
+
+class CurrencyMismatchError(Exception):
+ """
+ Exception class to raise when an operation between two
+ amounts of different currency is being attempted.
+ """
def __init__(self, curr1, curr2) -> None:
- super(CurrencyMismatch, self).__init__("%s vs %s" % (curr1, curr2))
-
-
-##
-# Exception class to raise when a amount string is not valid.
-class BadFormatAmount(Exception):
- hint = "Malformed amount string"
- http_status_code = 400
- taler_error_code = 5112
- hint = "Malformed amount string"
-
- ##
- # Init constructor.
- #
- # @param self the object itself.
- # @param faulty_str the invalid amount string.
- def __init__(self, faulty_str) -> None:
- super(BadFormatAmount,
- self).__init__("Bad format amount: " + faulty_str)
-
-
-##
-# Main Amount class.
-class NumberTooBig(Exception):
- hint = "Number given is too big"
- http_status_code = 400
- taler_error_code = 5108
-
- def __init__(self) -> None:
- super(NumberTooBig, self).__init__("Number given is too big")
-
-
-class NegativeNumber(Exception):
- hint = "Negative number given as value and/or fraction"
- taler_error_code = 5107
- http_status_code = 400
-
- def __init__(self) -> None:
- super(NegativeNumber,
- self).__init__("Negative number given as value and/or fraction")
-
-
-class Amount:
- ##
- # How many "fraction" units make one "value" unit of currency
- # (Taler requires 10^8). Do not change this 'constant'.
- @staticmethod
- def _fraction() -> int:
- return 10**8
-
- ##
- # Max value admitted: 2^53 - 1. This constant is dictated
- # by the wallet: JavaScript does not go beyond this value.
- @staticmethod
- def _max_value() -> int:
- return (2**53) - 1
-
- ##
- # Init constructor.
- #
- # @param self the object itself.
- # @param currency the amount's currency.
- # @param value integer part the amount
- # @param fraction fractional part of the amount
- def __init__(self, currency, value=0, fraction=0) -> None:
- if value < 0 or fraction < 0:
- raise NegativeNumber()
- self.value = value
- self.fraction = fraction
- self.currency = currency
- self.__normalize()
- if self.value > Amount._max_value():
- raise NumberTooBig()
-
- ##
- # Normalize amount. It means it makes sure that the
- # fractional part is less than one unit, and transfers
- # the overhead to the integer part.
- def __normalize(self) -> None:
- if self.fraction >= Amount._fraction():
- self.value += int(self.fraction / Amount._fraction())
- self.fraction = self.fraction % Amount._fraction()
-
- ##
- # Parse a string matching the format "A:B.C",
- # instantiating an amount object.
- #
- # @param cls unused.
- # @param amount_str the stringified amount to parse.
+ super(CurrencyMismatchError, self).__init__(f"mismatched currency:
{curr1} vs {curr2}")
+
+class AmountOverflowError(Exception):
+ pass
+
+MAX_AMOUNT_VALUE = 2 ** 52
+
+FRACTIONAL_LENGTH = 8
+
+FRACTIONAL_BASE = 1e8
+
+@total_ordering
+class Amount():
+ def __init__(self, currency, value, fraction):
+ if fraction >= FRACTIONAL_BASE:
+ raise AmountOverflowError("amount fraction too big")
+ if value > MAX_AMOUNT_VALUE:
+ raise AmountOverflowError("amount value too big")
+ self._currency = currency
+ self._value = value
+ self._fraction = fraction
+
+ @property
+ def currency(self):
+ return self._currency
+
+ @property
+ def value(self):
+ return self._value
+
+ @property
+ def fraction(self):
+ return self._fraction
+
+ def __repr__(self):
+ return f"Amount(currency={self.currency!r}, value={self.value!r},
fraction={self.fraction!r})"
+
+ def __str__(self):
+ return self.stringify()
+
@classmethod
- def parse(cls, amount_str: str):
+ def parse(cls, amount_str):
exp = r'^\s*([-_*A-Za-z0-9]+):([0-9]+)\.?([0-9]+)?\s*$'
import re
parsed = re.search(exp, amount_str)
if not parsed:
raise BadFormatAmount(amount_str)
- ##
- # Checks if the input overflows.
- #
- # @param arg the input number to check.
- # @return True if the overflow occurs, False otherwise.
- def check_overflow(arg):
- # Comes from 2^53 - 1
- JAVASCRIPT_MAX_INT = "9007199254740991"
- if len(JAVASCRIPT_MAX_INT) < len(arg):
- return True
- if len(JAVASCRIPT_MAX_INT) == len(arg):
- # Assume current system can afford to store
- # a number as big as JAVASCRIPT_MAX_INT.
- tmp = int(arg)
- tmp_js = int(JAVASCRIPT_MAX_INT)
-
- if tmp > tmp_js - 1: # - 1 leaves room for the fractional part
- return True
- return False
-
- if check_overflow(parsed.group(2)):
- raise AmountOverflow("integer part")
+ tail = ("." + (parsed.group(3) or "0"))
+ if len(tail) > FRACTIONAL_LENGTH + 1:
+ raise AmountOverflow()
value = int(parsed.group(2))
- fraction = 0
- for i, digit in enumerate(parsed.group(3) or "0"):
- fraction += int(int(digit) * (Amount._fraction() / 10**(i + 1)))
- if check_overflow(str(fraction)):
- raise AmountOverflow("fraction")
-
- return cls(parsed.group(1), value, fraction)
-
- ##
- # Compare two amounts.
- #
- # @param am1 first amount to compare.
- # @param am2 second amount to compare.
- # @return -1 if a < b
- # 0 if a == b
- # 1 if a > b
- @staticmethod
- def cmp(am1, am2) -> int:
- if am1.currency != am2.currency:
- raise CurrencyMismatch(am1.currency, am2.currency)
- if am1.value == am2.value:
- if am1.fraction < am2.fraction:
- return -1
- if am1.fraction > am2.fraction:
- return 1
- return 0
- if am1.value < am2.value:
- return -1
- return 1
+ fraction = round(FRACTIONAL_BASE * float(tail))
+ currency = parsed.group(1)
+ return Amount(currency, value, fraction)
+
+ def __add__(self, other):
+ if self.currency != other.currency:
+ raise CurrencyMismatchError(self.currency, other.currency)
+ v = int(self.value + other.value + (self.fraction + other.fraction) //
FRACTIONAL_BASE)
+ if v >= MAX_AMOUNT_VALUE:
+ raise AmountOverflowError()
+ f = int((self.fraction + other.fraction) % FRACTIONAL_BASE)
+ return Amount(self.currency, v, f)
+
+ def __sub__(self, other):
+ if self.currency != other.currency:
+ raise CurrencyMismatchError(self.currency, other.currency)
+ v = self.value
+ f = self.fraction
+ if self.fraction < other.fraction:
+ v -= 1
+ f += FRACTIONAL_BASE
+ f -= other.fraction
+ if v < other.value:
+ raise AmountOverflowError()
+ v -= other.value
+ return Amount(self.currency, v, f)
- ##
- # Setter method for the current object.
- #
- # @param self the object itself.
- # @param currency the currency to set.
- # @param value the value to set.
- # @param fraction the fraction to set.
- def set(self, currency: str, value=0, fraction=0) -> None:
- self.currency = currency
- self.value = value
- self.fraction = fraction
-
- ##
- # Add the given amount to this one.
- #
- # @param self the object itself.
- # @param amount the amount to add to this one.
- def add(self, amount) -> None:
- if self.currency != amount.currency:
- raise CurrencyMismatch(self.currency, amount.currency)
- self.value += amount.value
- self.fraction += amount.fraction
- self.__normalize()
-
- ##
- # Subtract amount from this one.
- #
- # @param self this object.
- # @param amount the amount to subtract to this one.
- def subtract(self, amount) -> None:
- if self.currency != amount.currency:
- raise CurrencyMismatch(self.currency, amount.currency)
- if self.fraction < amount.fraction:
- self.fraction += Amount._fraction()
- self.value -= 1
- if self.value < amount.value:
- raise ValueError('self is lesser than amount to be subtracted')
- self.value -= amount.value
- self.fraction -= amount.fraction
-
- ##
- # Convert the amount to a string.
- #
- # @param self this object.
- # @param ndigits minimum number of digits to display in the fractional part
- # @param pretty if True, put the currency in the last position and
- # omit the colon.
def stringify(self, ndigits=0, pretty=False) -> str:
s = str(self.value)
if self.fraction != 0:
s += "."
frac = self.fraction
while frac > 0 or (ndigits is not None and ndigits > 0):
- s += str(int(frac / (Amount._fraction() / 10)))
- frac = (frac * 10) % (Amount._fraction())
+ s += str(int(frac / (FRACTIONAL_BASE / 10)))
+ frac = (frac * 10) % FRACTIONAL_BASE
if ndigits > 0:
ndigits -= 1
elif ndigits != 0:
@@ -251,12 +126,107 @@ class Amount:
return f"{self.currency}:{s}"
return f"{s} {self.currency}"
- ##
- # Dump the Taler-compliant 'dict' amount from
- # this object.
- #
- # @param self this object.
- def dump(self) -> dict:
- return dict(
- value=self.value, fraction=self.fraction, currency=self.currency
- )
+ def cmp(self, am2) -> int:
+ if self.currency != am2.currency:
+ raise CurrencyMismatchError(self.currency, am2.currency)
+ if self.value == am2.value:
+ if self.fraction < am2.fraction:
+ return -1
+ if self.fraction > am2.fraction:
+ return 1
+ return 0
+ if self.value < am2.value:
+ return -1
+ return 1
+
+ def is_zero(self):
+ return self.fraction == 0 and self.value == 0
+
+ def __eq__(self, other):
+ return self.cmp(other) == 0
+
+ def __lt__(self, other):
+ return self.cmp(other) == -1
+
+
+@total_ordering
+class SignedAmount:
+ """
+ Amount with a sign.
+ """
+
+ def __init__(self, is_positive, amount):
+ self._is_positive = is_positive
+ self._amount = amount
+
+ @property
+ def is_positive(self):
+ return self._is_positive
+
+ @property
+ def amount(self):
+ return self._amount
+
+ def __eq__(self, other):
+ if self.is_zero() and other.is_zero():
+ return True
+ if self.is_positive == other.is_positive:
+ return self.amount == other.amount
+ return False
+
+ def __lt__(self, other):
+ if self.is_positive:
+ if other.is_positive:
+ return self.amount < other.amount
+ else:
+ return False
+ else:
+ if other.is_positive:
+ return True
+ else:
+ return self.amount > other.amount
+
+ def stringify(self, ndigits=0, pretty=False) -> str:
+ if self.is_positive:
+ sgn = "+"
+ else:
+ sgn = "-"
+ return sgn + self.amount.stringify(ndigits, pretty)
+
+ @classmethod
+ def parse(cls, amount_str):
+ c0 = amount_str[0:1]
+ if c0 == "+":
+ return SignedAmount(True, Amount.parse(amount_str[1:]))
+ if c0 == "-":
+ return SignedAmount(False, Amount.parse(amount_str[1:]))
+ return SignedAmount(True, Amount.parse(amount_str))
+
+ def __neg__(self):
+ return SignedAmount(not self.is_positive, self.amount)
+
+ def __add__(self, other):
+ if self.is_positive == other.is_positive:
+ return SignedAmount(self.is_positive, self.amount + other.amount)
+ if self.is_positive:
+ if self.amount >= other.amount:
+ return SignedAmount(True, self.amount - other.amount)
+ else:
+ return SignedAmount(False, other.amount - self.amount)
+ else:
+ if other.amount >= self.amount:
+ return SignedAmount(True, other.amount - self.amount)
+ else:
+ return SignedAmount(False, self.amount - other.amount)
+
+ def is_zero(self):
+ return self.amount.is_zero()
+
+ def __sub__(self, other):
+ return self + (-other)
+
+ def __repr__(self):
+ return f"SignedAmount(is_positive={self.is_positive!r},
amount={self.amount!r})"
+
+ def __str__(self):
+ return self.stringify()
diff --git a/tests/test_amount.py b/tests/test_amount.py
index a4043b7..3dd386a 100755
--- a/tests/test_amount.py
+++ b/tests/test_amount.py
@@ -19,53 +19,51 @@
# @version 0.0
# @repository https://git.taler.net/taler-util.git/
-from __future__ import unicode_literals
-from taler.util.amount import Amount, BadFormatAmount, NumberTooBig,
NegativeNumber
+from taler.util.amount import Amount, SignedAmount, AmountOverflowError,
MAX_AMOUNT_VALUE
from unittest import TestCase
import json
-from mock import MagicMock
class TestAmount(TestCase):
- def setUp(self):
- self.amount = Amount('TESTKUDOS')
-
def test_very_big_number(self):
- with self.assertRaises(NumberTooBig):
+ with self.assertRaises(AmountOverflowError):
self.Amount = Amount('TESTKUDOS',
-
value=99999999999999999999999999999999999999999999)
+
value=99999999999999999999999999999999999999999999,
+ fraction=0)
- def test_negative_value(self):
- with self.assertRaises(NegativeNumber):
- self.Amount = Amount('TESTKUDOS',
- value=-9)
+ def test_add_overflow(self):
+ a1 = Amount('TESTKUDOS',
+ value=MAX_AMOUNT_VALUE,
+ fraction=0)
+ with self.assertRaises(AmountOverflowError):
+ a2 = a1 + Amount.parse("TESTKUDOS:1")
+
+ def test_sub_overflow(self):
+ a1 = Amount('TESTKUDOS',
+ value=MAX_AMOUNT_VALUE,
+ fraction=0)
+ s1 = SignedAmount(False, a1)
+ with self.assertRaises(AmountOverflowError):
+ s2 = s1 - SignedAmount.parse("TESTKUDOS:1")
def test_parse_and_cmp(self):
- a = self.amount.parse('TESTKUDOS:0.0')
- self.assertEqual(Amount.cmp(self.amount, a), 0)
- b = self.amount.parse('TESTKUDOS:0.1')
- self.assertEqual(Amount.cmp(Amount('TESTKUDOS', fraction=10000000),
b), 0)
- c = self.amount.parse('TESTKUDOS:3.3')
- self.assertEqual(Amount.cmp(Amount('TESTKUDOS', 3, 30000000), c), 0)
- self.assertEqual(Amount.cmp(a, b), -1)
- self.assertEqual(Amount.cmp(c, b), 1)
- with self.assertRaises(BadFormatAmount):
- Amount.parse(':3')
+ self.assertTrue(Amount.parse("EUR:0.0") < Amount.parse("EUR:0.5"))
+
+ def test_amount(self):
+ self.assertEqual(Amount.parse("TESTKUDOS:0").stringify(3),
"TESTKUDOS:0.000")
+
+ def test_signed_amount(self):
+ self.assertEqual(SignedAmount.parse("TESTKUDOS:1.5").stringify(3),
"+TESTKUDOS:1.500")
+
+ def test_zero_crossing(self):
+ p1 = SignedAmount.parse("EUR:1")
+ p2 = SignedAmount.parse("EUR:2")
+ p3 = SignedAmount.parse("EUR:3")
+ p5 = SignedAmount.parse("EUR:5")
+ p8 = SignedAmount.parse("EUR:8")
- def test_add_and_dump(self):
- mocky = MagicMock()
- self.amount.add(Amount('TESTKUDOS', 9, 10**8))
- mocky(**self.amount.dump())
- mocky.assert_called_with(currency='TESTKUDOS', value=10, fraction=0)
+ self.assertEqual(p5 + p3, p8)
+ self.assertEqual(p5 - p3, p2)
+ self.assertEqual(p2 - p3, -p1)
- def test_subtraction(self):
- with self.assertRaises(ValueError):
- self.amount.subtract(Amount('TESTKUDOS', fraction=1))
- a = Amount('TESTKUDOS', 2)
- a.subtract(Amount('TESTKUDOS', 1, 99999999))
- self.assertEqual(Amount.cmp(a, Amount('TESTKUDOS', fraction=1)), 0)
+ self.assertEqual((-p2) + p3, p1)
- def test_stringify(self):
- self.assertEqual(self.amount.stringify(3), 'TESTKUDOS:0.000')
- self.amount.add(Amount('TESTKUDOS', 2, 100))
- self.assertEqual(self.amount.stringify(6), 'TESTKUDOS:2.000001')
- self.assertEqual(Amount("TESTKUDOS", value=5,
fraction=9000000).stringify(), 'TESTKUDOS:5.09')
diff --git a/tox.ini b/tox.ini
index 267f9f9..0866cb6 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,5 +1,5 @@
[tox]
-envlist = py37
+envlist = py37,py38
[testenv]
changedir = tests
deps = mock
--
To stop receiving notification emails like this one, please contact
address@hidden.
[Prev in Thread] |
Current Thread |
[Next in Thread] |
- [taler-taler-util] branch master updated: make amounts immutable, implement signed amounts,
gnunet <=