Adds a __repr__ to graph.
[pyutils.git] / src / pyutils / typez / centcount.py
1 #!/usr/bin/env python3
2
3 # © Copyright 2021-2023, Scott Gasch
4
5 """An amount of money represented as an integral count of cents so as
6 to avoid floating point artifacts.  Multiplication and division are
7 performed using floating point arithmetic but the quotient is cast
8 back to an integer number thus truncating the result and
9 avoiding floating point arithmetic artifacts.  See details below.
10
11 The type guards against inadvertent aggregation of instances with
12 non-matching currencies, the division of one CentCount by another, and
13 has a strict mode which disallows comparison or aggregation with
14 non-CentCount operands (i.e. no comparison or aggregation with literal
15 numbers).
16
17 .. note::
18
19     Multiplication and division are performed by converting the
20     `CentCount` into a float and operating on two floating point
21     numbers.  The result is then cast back to an int which loses
22     precision beyond the 1-cent granularity in order to avoid floating
23     point representation artifacts.
24
25     This can cause "problems" such as the one illustrated
26     below::
27
28         >>> c = CentCount(100.00)
29         >>> c
30         100.00 USD
31         >>> c = c * 2
32         >>> c
33         200.00 USD
34         >>> c = c / 3
35         >>> c
36         66.66 USD
37
38     Two-thirds of $100.00 is $66.66666... which might be
39     expected to round upwards to $66.67 but it does not
40     because the `int` cast truncates the result.  Be aware
41     of this and decide whether it's suitable for your
42     application.
43
44 See also the :class:`pyutils.typez.Money` class which uses Python
45 Decimals (see: https://docs.python.org/3/library/decimal.html) to
46 represent monetary amounts.
47 """
48
49 import re
50 from typing import Optional, Tuple, Union
51
52
53 class CentCount(object):
54     """A class for representing monetary amounts potentially with
55     different currencies meant to avoid floating point rounding
56     issues by treating amount as a simple integral count of cents.
57     """
58
59     def __init__(
60         self,
61         centcount: Union[int, float, str, "CentCount"] = 0,
62         currency: str = "USD",
63         *,
64         strict_mode=False,
65     ):
66         """
67         Args:
68             centcount: the amount of money being represented; this can be
69                 a float, int, CentCount or str.
70             currency: optionally declare the currency being represented by
71                 this instance.  If provided it will guard against operations
72                 such as attempting to add it to non-matching currencies.
73             strict_mode: if True, the instance created will object (raise) if
74                 compared or aggregated with non-CentCount objects; that is,
75                 strict_mode disallows comparison with literal numbers or
76                 aggregation with literal numbers.
77
78         Raises:
79             ValueError: invalid money string passed in
80         """
81         self.strict_mode = strict_mode
82         if isinstance(centcount, str):
83             ret = CentCount._parse(centcount)
84             if ret is None:
85                 raise ValueError(f'Unable to parse money string "{centcount}"')
86             centcount = ret[0]
87             currency = ret[1]
88         if isinstance(centcount, float):
89             centcount = int(centcount * 100.0)
90         if not isinstance(centcount, int):
91             centcount = int(centcount)
92         self.centcount = centcount
93         if not currency:
94             self.currency: Optional[str] = None
95         else:
96             self.currency = currency
97
98     def __repr__(self):
99         w = self.centcount // 100
100         p = self.centcount % 100
101         s = f"{w}.{p:02d}"
102         if self.currency is not None:
103             return f"{s} {self.currency}"
104         else:
105             return f"${s}"
106
107     def __pos__(self):
108         return CentCount(centcount=self.centcount, currency=self.currency)
109
110     def __neg__(self):
111         return CentCount(centcount=-self.centcount, currency=self.currency)
112
113     def __add__(self, other):
114         """
115         Raises:
116             TypeError: if addend is not compatible or the object is in strict
117                 mode and the addend is not another CentCount.
118         """
119         if isinstance(other, CentCount):
120             if self.currency == other.currency:
121                 return CentCount(
122                     centcount=self.centcount + other.centcount,
123                     currency=self.currency,
124                 )
125             else:
126                 raise TypeError("Incompatible currencies in add expression")
127         else:
128             if self.strict_mode:
129                 raise TypeError("In strict_mode only two moneys can be added")
130             else:
131                 return self.__add__(CentCount(other, self.currency))
132
133     def __sub__(self, other):
134         """
135         Raises:
136             TypeError: if amount is not compatible or the object is in strict
137                 mode and the amount is not another CentCount.
138         """
139         if isinstance(other, CentCount):
140             if self.currency == other.currency:
141                 return CentCount(
142                     centcount=self.centcount - other.centcount,
143                     currency=self.currency,
144                 )
145             else:
146                 raise TypeError("Incompatible currencies in add expression")
147         else:
148             if self.strict_mode:
149                 raise TypeError("In strict_mode only two moneys can be added")
150             else:
151                 return self.__sub__(CentCount(other, self.currency))
152
153     def __mul__(self, other):
154         """
155         Raises:
156             TypeError: if factor is not compatible.
157
158         .. note::
159
160             Multiplication and division are performed by converting the
161             CentCount into a float and operating on two floating point
162             numbers.  But the result is then cast back to an int which
163             loses precision beyond the 1-cent granularity in order to
164             avoid floating point representation artifacts.
165
166             This can cause "problems" such as the one illustrated
167             below::
168
169                 >>> c = CentCount(100.00)
170                 >>> c = c * 2
171                 >>> c = c / 3
172                 >>> c
173                 66.66 USD
174
175             Two-thirds of $100.00 is $66.66666... which might be
176             expected to round upwards to $66.67 but it does not
177             because the int cast truncates the result.  Be aware
178             of this and decide whether it's suitable for your
179             application.
180         """
181         if isinstance(other, CentCount):
182             raise TypeError("can not multiply monetary quantities")
183         else:
184             return CentCount(
185                 centcount=int(self.centcount * float(other)),
186                 currency=self.currency,
187             )
188
189     def __truediv__(self, other):
190         """
191         Raises:
192             TypeError: the divisor is not compatible
193
194         .. note::
195
196             Multiplication and division are performed by converting the
197             CentCount into a float and operating on two floating point
198             numbers.  But the result is then cast back to an int which
199             loses precision beyond the 1-cent granularity in order to
200             avoid floating point representation artifacts.
201
202             This can cause "problems" such as the one illustrated
203             below::
204
205                 >>> c = CentCount(100.00)
206                 >>> c = c * 2
207                 >>> c = c / 3
208                 >>> c
209                 66.66 USD
210
211             Two-thirds of $100.00 is $66.66666... which might be
212             expected to round upwards to $66.67 but it does not
213             because the int cast truncates the result.  Be aware
214             of this and decide whether it's suitable for your
215             application.
216         """
217         if isinstance(other, CentCount):
218             raise TypeError("can not divide monetary quantities")
219         else:
220             return CentCount(
221                 centcount=int(float(self.centcount) / float(other)),
222                 currency=self.currency,
223             )
224
225     def __int__(self):
226         return self.centcount.__int__()
227
228     def __float__(self):
229         return self.centcount.__float__() / 100.0
230
231     __radd__ = __add__
232
233     def __rsub__(self, other):
234         """
235         Raises:
236             TypeError: amount is not compatible or, if the object is in
237                 strict mode, the amount is not a CentCount.
238         """
239         if isinstance(other, CentCount):
240             if self.currency == other.currency:
241                 return CentCount(
242                     centcount=other.centcount - self.centcount,
243                     currency=self.currency,
244                 )
245             else:
246                 raise TypeError("Incompatible currencies in sub expression")
247         else:
248             if self.strict_mode:
249                 raise TypeError("In strict_mode only two moneys can be added")
250             else:
251                 return CentCount(
252                     centcount=int(other) - self.centcount,
253                     currency=self.currency,
254                 )
255
256     __rmul__ = __mul__
257
258     #
259     # Override comparison operators to also compare currency.
260     #
261     def __eq__(self, other):
262         """
263         Raises:
264             TypeError: In strict mode and the other object isn't a CentCount.
265         """
266         if other is None:
267             return False
268         if isinstance(other, CentCount):
269             return self.centcount == other.centcount and self.currency == other.currency
270         if self.strict_mode:
271             raise TypeError("In strict mode only two CentCounts can be compared")
272         else:
273             return self.centcount == int(other)
274
275     def __ne__(self, other):
276         result = self.__eq__(other)
277         if result is NotImplemented:
278             return result
279         return not result
280
281     def __lt__(self, other):
282         """
283         Raises:
284             TypeError: amounts have different currencies or, if this object
285                 is in strict mode, the amount must be a CentCount.
286         """
287         if isinstance(other, CentCount):
288             if self.currency == other.currency:
289                 return self.centcount < other.centcount
290             else:
291                 raise TypeError("can not directly compare different currencies")
292         else:
293             if self.strict_mode:
294                 raise TypeError("In strict mode, only two CentCounts can be compated")
295             else:
296                 return self.centcount < int(other)
297
298     def __gt__(self, other):
299         """
300         Raises:
301             TypeError: amounts have different currencies or, if this object
302                 is in strict mode, the amount must be a CentCount.
303         """
304         if isinstance(other, CentCount):
305             if self.currency == other.currency:
306                 return self.centcount > other.centcount
307             else:
308                 raise TypeError("can not directly compare different currencies")
309         else:
310             if self.strict_mode:
311                 raise TypeError("In strict mode, only two CentCounts can be compated")
312             else:
313                 return self.centcount > int(other)
314
315     def __le__(self, other):
316         return self < other or self == other
317
318     def __ge__(self, other):
319         return self > other or self == other
320
321     def __hash__(self) -> int:
322         return hash(self.__repr__)
323
324     CENTCOUNT_RE = re.compile(r"^([+|-]?)(\d+)(\.\d+)$")
325     CURRENCY_RE = re.compile(r"^[A-Z][A-Z][A-Z]$")
326
327     @classmethod
328     def _parse(cls, s: str) -> Optional[Tuple[int, str]]:
329         centcount = None
330         currency = None
331         s = s.strip()
332         chunks = s.split(" ")
333         try:
334             for chunk in chunks:
335                 if CentCount.CENTCOUNT_RE.match(chunk) is not None:
336                     centcount = int(float(chunk) * 100.0)
337                 elif CentCount.CURRENCY_RE.match(chunk) is not None:
338                     currency = chunk
339         except Exception:
340             pass
341         if centcount is not None and currency is not None:
342             return (centcount, currency)
343         elif centcount is not None:
344             return (centcount, "USD")
345         return None
346
347     @classmethod
348     def parse(cls, s: str) -> "CentCount":
349         """Parses a string format monetary amount and returns a CentCount
350         if possible.
351
352         Args:
353             s: the string to be parsed
354
355         Raises:
356             ValueError: input string cannot be parsed.
357         """
358         chunks = CentCount._parse(s)
359         if chunks is not None:
360             return CentCount(chunks[0], chunks[1])
361         raise ValueError(f'Unable to parse money string "{s}"')
362
363
364 if __name__ == "__main__":
365     import doctest
366
367     doctest.testmod()