More work to improve documentation generated by sphinx. Also fixes
[pyutils.git] / src / pyutils / typez / centcount.py
1 #!/usr/bin/env python3
2
3 # © Copyright 2021-2022, 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 if you
74                 compare or aggregate it with non-CentCount objects; that is,
75                 strict_mode disallows comparison with literal numbers or
76                 aggregation with literal numbers.
77         """
78         self.strict_mode = strict_mode
79         if isinstance(centcount, str):
80             ret = CentCount._parse(centcount)
81             if ret is None:
82                 raise Exception(f'Unable to parse money string "{centcount}"')
83             centcount = ret[0]
84             currency = ret[1]
85         if isinstance(centcount, float):
86             centcount = int(centcount * 100.0)
87         if not isinstance(centcount, int):
88             centcount = int(centcount)
89         self.centcount = centcount
90         if not currency:
91             self.currency: Optional[str] = None
92         else:
93             self.currency = currency
94
95     def __repr__(self):
96         w = self.centcount // 100
97         p = self.centcount % 100
98         s = f'{w}.{p:02d}'
99         if self.currency is not None:
100             return f'{s} {self.currency}'
101         else:
102             return f'${s}'
103
104     def __pos__(self):
105         return CentCount(centcount=self.centcount, currency=self.currency)
106
107     def __neg__(self):
108         return CentCount(centcount=-self.centcount, currency=self.currency)
109
110     def __add__(self, other):
111         if isinstance(other, CentCount):
112             if self.currency == other.currency:
113                 return CentCount(
114                     centcount=self.centcount + other.centcount,
115                     currency=self.currency,
116                 )
117             else:
118                 raise TypeError('Incompatible currencies in add expression')
119         else:
120             if self.strict_mode:
121                 raise TypeError('In strict_mode only two moneys can be added')
122             else:
123                 return self.__add__(CentCount(other, self.currency))
124
125     def __sub__(self, other):
126         if isinstance(other, CentCount):
127             if self.currency == other.currency:
128                 return CentCount(
129                     centcount=self.centcount - other.centcount,
130                     currency=self.currency,
131                 )
132             else:
133                 raise TypeError('Incompatible currencies in add expression')
134         else:
135             if self.strict_mode:
136                 raise TypeError('In strict_mode only two moneys can be added')
137             else:
138                 return self.__sub__(CentCount(other, self.currency))
139
140     def __mul__(self, other):
141         """
142         .. note::
143
144             Multiplication and division are performed by converting the
145             CentCount into a float and operating on two floating point
146             numbers.  But the result is then cast back to an int which
147             loses precision beyond the 1-cent granularity in order to
148             avoid floating point representation artifacts.
149
150             This can cause "problems" such as the one illustrated
151             below::
152
153                 >>> c = CentCount(100.00)
154                 >>> c = c * 2
155                 >>> c = c / 3
156                 >>> c
157                 66.66 USD
158
159             Two-thirds of $100.00 is $66.66666... which might be
160             expected to round upwards to $66.67 but it does not
161             because the int cast truncates the result.  Be aware
162             of this and decide whether it's suitable for your
163             application.
164         """
165         if isinstance(other, CentCount):
166             raise TypeError('can not multiply monetary quantities')
167         else:
168             return CentCount(
169                 centcount=int(self.centcount * float(other)),
170                 currency=self.currency,
171             )
172
173     def __truediv__(self, other):
174         """
175         .. note::
176
177             Multiplication and division are performed by converting the
178             CentCount into a float and operating on two floating point
179             numbers.  But the result is then cast back to an int which
180             loses precision beyond the 1-cent granularity in order to
181             avoid floating point representation artifacts.
182
183             This can cause "problems" such as the one illustrated
184             below::
185
186                 >>> c = CentCount(100.00)
187                 >>> c = c * 2
188                 >>> c = c / 3
189                 >>> c
190                 66.66 USD
191
192             Two-thirds of $100.00 is $66.66666... which might be
193             expected to round upwards to $66.67 but it does not
194             because the int cast truncates the result.  Be aware
195             of this and decide whether it's suitable for your
196             application.
197         """
198         if isinstance(other, CentCount):
199             raise TypeError('can not divide monetary quantities')
200         else:
201             return CentCount(
202                 centcount=int(float(self.centcount) / float(other)),
203                 currency=self.currency,
204             )
205
206     def __int__(self):
207         return self.centcount.__int__()
208
209     def __float__(self):
210         return self.centcount.__float__() / 100.0
211
212     __radd__ = __add__
213
214     def __rsub__(self, other):
215         if isinstance(other, CentCount):
216             if self.currency == other.currency:
217                 return CentCount(
218                     centcount=other.centcount - self.centcount,
219                     currency=self.currency,
220                 )
221             else:
222                 raise TypeError('Incompatible currencies in sub expression')
223         else:
224             if self.strict_mode:
225                 raise TypeError('In strict_mode only two moneys can be added')
226             else:
227                 return CentCount(
228                     centcount=int(other) - self.centcount,
229                     currency=self.currency,
230                 )
231
232     __rmul__ = __mul__
233
234     #
235     # Override comparison operators to also compare currency.
236     #
237     def __eq__(self, other):
238         if other is None:
239             return False
240         if isinstance(other, CentCount):
241             return self.centcount == other.centcount and self.currency == other.currency
242         if self.strict_mode:
243             raise TypeError("In strict mode only two CentCounts can be compared")
244         else:
245             return self.centcount == int(other)
246
247     def __ne__(self, other):
248         result = self.__eq__(other)
249         if result is NotImplemented:
250             return result
251         return not result
252
253     def __lt__(self, other):
254         if isinstance(other, CentCount):
255             if self.currency == other.currency:
256                 return self.centcount < other.centcount
257             else:
258                 raise TypeError('can not directly compare different currencies')
259         else:
260             if self.strict_mode:
261                 raise TypeError('In strict mode, only two CentCounts can be compated')
262             else:
263                 return self.centcount < int(other)
264
265     def __gt__(self, other):
266         if isinstance(other, CentCount):
267             if self.currency == other.currency:
268                 return self.centcount > other.centcount
269             else:
270                 raise TypeError('can not directly compare different currencies')
271         else:
272             if self.strict_mode:
273                 raise TypeError('In strict mode, only two CentCounts can be compated')
274             else:
275                 return self.centcount > int(other)
276
277     def __le__(self, other):
278         return self < other or self == other
279
280     def __ge__(self, other):
281         return self > other or self == other
282
283     def __hash__(self) -> int:
284         return hash(self.__repr__)
285
286     CENTCOUNT_RE = re.compile(r"^([+|-]?)(\d+)(\.\d+)$")
287     CURRENCY_RE = re.compile(r"^[A-Z][A-Z][A-Z]$")
288
289     @classmethod
290     def _parse(cls, s: str) -> Optional[Tuple[int, str]]:
291         centcount = None
292         currency = None
293         s = s.strip()
294         chunks = s.split(' ')
295         try:
296             for chunk in chunks:
297                 if CentCount.CENTCOUNT_RE.match(chunk) is not None:
298                     centcount = int(float(chunk) * 100.0)
299                 elif CentCount.CURRENCY_RE.match(chunk) is not None:
300                     currency = chunk
301         except Exception:
302             pass
303         if centcount is not None and currency is not None:
304             return (centcount, currency)
305         elif centcount is not None:
306             return (centcount, 'USD')
307         return None
308
309     @classmethod
310     def parse(cls, s: str) -> 'CentCount':
311         """Parses a string format monetary amount and returns a CentCount
312         if possible.
313
314         Args:
315             s: the string to be parsed
316         """
317         chunks = CentCount._parse(s)
318         if chunks is not None:
319             return CentCount(chunks[0], chunks[1])
320         raise Exception(f'Unable to parse money string "{s}"')
321
322
323 if __name__ == '__main__':
324     import doctest
325
326     doctest.testmod()