gnunet-svn
[Top][All Lists]
Advanced

[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.



reply via email to

[Prev in Thread] Current Thread [Next in Thread]