Make subdirs type clean too.
[python_utils.git] / type / centcount.py
1 #!/usr/bin/env python3
2
3 import re
4 from typing import Optional, Tuple, TypeVar
5
6 import math_utils
7
8
9 class CentCount(object):
10     """A class for representing monetary amounts potentially with
11     different currencies meant to avoid floating point rounding
12     issues by treating amount as a simple integral count of cents.
13     """
14
15     def __init__(self, centcount, currency: str = 'USD', *, strict_mode=False):
16         self.strict_mode = strict_mode
17         if isinstance(centcount, str):
18             ret = CentCount._parse(centcount)
19             if ret is None:
20                 raise Exception(f'Unable to parse money string "{centcount}"')
21             centcount = ret[0]
22             currency = ret[1]
23         if isinstance(centcount, float):
24             centcount = int(centcount * 100.0)
25         if not isinstance(centcount, int):
26             centcount = int(centcount)
27         self.centcount = centcount
28         if not currency:
29             self.currency: Optional[str] = None
30         else:
31             self.currency = currency
32
33     def __repr__(self):
34         a = float(self.centcount)
35         a /= 100
36         a = round(a, 2)
37         s = f'{a:,.2f}'
38         if self.currency is not None:
39             return '%s %s' % (s, self.currency)
40         else:
41             return '$%s' % s
42
43     def __pos__(self):
44         return CentCount(centcount=self.centcount, currency=self.currency)
45
46     def __neg__(self):
47         return CentCount(centcount=-self.centcount, currency=self.currency)
48
49     def __add__(self, other):
50         if isinstance(other, CentCount):
51             if self.currency == other.currency:
52                 return CentCount(
53                     centcount=self.centcount + other.centcount, currency=self.currency
54                 )
55             else:
56                 raise TypeError('Incompatible currencies in add expression')
57         else:
58             if self.strict_mode:
59                 raise TypeError('In strict_mode only two moneys can be added')
60             else:
61                 return self.__add__(CentCount(other, self.currency))
62
63     def __sub__(self, other):
64         if isinstance(other, CentCount):
65             if self.currency == other.currency:
66                 return CentCount(
67                     centcount=self.centcount - other.centcount, currency=self.currency
68                 )
69             else:
70                 raise TypeError('Incompatible currencies in add expression')
71         else:
72             if self.strict_mode:
73                 raise TypeError('In strict_mode only two moneys can be added')
74             else:
75                 return self.__sub__(CentCount(other, self.currency))
76
77     def __mul__(self, other):
78         if isinstance(other, CentCount):
79             raise TypeError('can not multiply monetary quantities')
80         else:
81             return CentCount(
82                 centcount=int(self.centcount * float(other)), currency=self.currency
83             )
84
85     def __truediv__(self, other):
86         if isinstance(other, CentCount):
87             raise TypeError('can not divide monetary quantities')
88         else:
89             return CentCount(
90                 centcount=int(float(self.centcount) / float(other)),
91                 currency=self.currency,
92             )
93
94     def __int__(self):
95         return self.centcount.__int__()
96
97     def __float__(self):
98         return self.centcount.__float__() / 100.0
99
100     def truncate_fractional_cents(self):
101         x = int(self)
102         self.centcount = int(math_utils.truncate_float(x))
103         return self.centcount
104
105     def round_fractional_cents(self):
106         x = int(self)
107         self.centcount = int(round(x, 2))
108         return self.centcount
109
110     __radd__ = __add__
111
112     def __rsub__(self, other):
113         if isinstance(other, CentCount):
114             if self.currency == other.currency:
115                 return CentCount(
116                     centcount=other.centcount - self.centcount, currency=self.currency
117                 )
118             else:
119                 raise TypeError('Incompatible currencies in sub expression')
120         else:
121             if self.strict_mode:
122                 raise TypeError('In strict_mode only two moneys can be added')
123             else:
124                 return CentCount(
125                     centcount=int(other) - self.centcount, currency=self.currency
126                 )
127
128     __rmul__ = __mul__
129
130     #
131     # Override comparison operators to also compare currency.
132     #
133     def __eq__(self, other):
134         if other is None:
135             return False
136         if isinstance(other, CentCount):
137             return self.centcount == other.centcount and self.currency == other.currency
138         if self.strict_mode:
139             raise TypeError("In strict mode only two CentCounts can be compared")
140         else:
141             return self.centcount == int(other)
142
143     def __ne__(self, other):
144         result = self.__eq__(other)
145         if result is NotImplemented:
146             return result
147         return not result
148
149     def __lt__(self, other):
150         if isinstance(other, CentCount):
151             if self.currency == other.currency:
152                 return self.centcount < other.centcount
153             else:
154                 raise TypeError('can not directly compare different currencies')
155         else:
156             if self.strict_mode:
157                 raise TypeError('In strict mode, only two CentCounts can be compated')
158             else:
159                 return self.centcount < int(other)
160
161     def __gt__(self, other):
162         if isinstance(other, CentCount):
163             if self.currency == other.currency:
164                 return self.centcount > other.centcount
165             else:
166                 raise TypeError('can not directly compare different currencies')
167         else:
168             if self.strict_mode:
169                 raise TypeError('In strict mode, only two CentCounts can be compated')
170             else:
171                 return self.centcount > int(other)
172
173     def __le__(self, other):
174         return self < other or self == other
175
176     def __ge__(self, other):
177         return self > other or self == other
178
179     def __hash__(self):
180         return self.__repr__
181
182     CENTCOUNT_RE = re.compile(r"^([+|-]?)(\d+)(\.\d+)$")
183     CURRENCY_RE = re.compile(r"^[A-Z][A-Z][A-Z]$")
184
185     @classmethod
186     def _parse(cls, s: str) -> Optional[Tuple[int, str]]:
187         centcount = None
188         currency = None
189         s = s.strip()
190         chunks = s.split(' ')
191         try:
192             for chunk in chunks:
193                 if CentCount.CENTCOUNT_RE.match(chunk) is not None:
194                     centcount = int(float(chunk) * 100.0)
195                 elif CentCount.CURRENCY_RE.match(chunk) is not None:
196                     currency = chunk
197         except Exception:
198             pass
199         if centcount is not None and currency is not None:
200             return (centcount, currency)
201         elif centcount is not None:
202             return (centcount, 'USD')
203         return None
204
205     @classmethod
206     def parse(cls, s: str) -> 'CentCount':
207         chunks = CentCount._parse(s)
208         if chunks is not None:
209             return CentCount(chunks[0], chunks[1])
210         raise Exception(f'Unable to parse money string "{s}"')