home · contact · privacy
Add basic ledger interpreter.
[misc] / ledger.py
1 from http.server import BaseHTTPRequestHandler, HTTPServer
2 import sys
3 hostName = "localhost"
4 serverPort = 8082
5
6
7 class HandledException(Exception):
8     pass
9
10
11 def handled_error_exit(msg):
12     print(f"ERROR: {msg}")
13     sys.exit(1)
14
15
16 def apply_booking_to_account_balances(account_sums, account, currency, amount):
17     if not account in account_sums:
18         account_sums[account] = {currency: amount} 
19     elif not currency in account_sums[account].keys():
20         account_sums[account][currency] = amount
21     else:
22         account_sums[account][currency] += amount
23
24
25 class Booking:
26
27     def __init__(self, date_string, description, booking_lines, start_line):
28         self.date_string = date_string
29         self.description = description 
30         self.lines = booking_lines 
31         self.start_line = start_line
32         self.validate_booking_lines()
33         self.account_changes = self.parse_booking_lines_to_account_changes()
34
35     def validate_booking_lines(self):
36         prefix = f"booking at line {self.start_line}"
37         sums = {}
38         empty_values = 0
39         for line in self.lines:
40             if line == '': 
41                 continue
42             _, amount, currency = line
43             if amount is None:
44                 if empty_values > 0:
45                     raise HandledException(f"{prefix} relates more than one empty value of same currency {currency}")
46                 empty_values += 1
47                 continue
48             if currency not in sums:
49                 sums[currency] = 0
50             sums[currency] += amount
51         if empty_values == 0:
52             for k, v in sums.items():
53                 if v != 0:
54                     raise HandledException(f"{prefix} does not sum up to zero")
55         else:
56             sinkable = False
57             for k, v in sums.items():
58                 if v != 0:
59                     sinkable = True 
60             if not sinkable:
61                 raise HandledException(f"{prefix} has empty value that cannot be filled")
62
63     def parse_booking_lines_to_account_changes(self):
64         account_changes = {}
65         debt = {}
66         sink_account = None
67         for line in self.lines:
68             if line == '': 
69                 continue
70             account, amount, currency = line
71             if amount is None:
72                 sink_account = account
73                 continue
74             apply_booking_to_account_balances(account_changes, account, currency, amount)
75             if currency not in debt:
76                 debt[currency] = amount
77             else:
78                 debt[currency] += amount
79         if sink_account:
80             for currency, amount in debt.items():
81                 apply_booking_to_account_balances(account_changes, sink_account, currency, -amount)
82         return account_changes
83
84
85
86 class Database:
87
88     def __init__(self, load_from_file=True):
89         import os
90         db_name = "_ledger"
91         self.db_file = db_name + ".json"
92         self.lock_file = db_name+ ".lock"
93         self.bookings = []
94         self.comments = []
95         if load_from_file and os.path.exists(self.db_file):
96             with open(self.db_file, "r") as f:
97                 self.parse_lines(f.readlines())
98
99     def parse_lines(self, lines): 
100         import datetime
101         import decimal 
102         inside_booking = False
103         date_string, description = None, None
104         booking_lines = []
105         start_line = 0
106         for i, line in enumerate(lines):
107             prefix = f"line {i}"
108             # we start with the case of an utterly empty line
109             self.comments += [""] 
110             stripped_line = line.rstrip()
111             if stripped_line == '':
112                 if inside_booking:
113                     # assume we finished a booking, finalize, and commit to DB 
114                     if len(booking_lines) < 2:
115                         raise HandledException(f"{prefix} booking ends to early")
116                     booking = Booking(date_string, description, booking_lines, start_line)
117                     self.bookings += [booking]
118                 # expect new booking to follow so re-zeroall booking data 
119                 inside_booking = False
120                 date_string, description = None, None
121                 booking_lines = []
122                 continue
123             # if non-empty line, first get comment if any, and commit to DB 
124             split_by_comment = stripped_line.split(sep=";", maxsplit=1)
125             if len(split_by_comment) == 2:
126                 self.comments[i] = split_by_comment[1] 
127             # if pre-comment empty: if inside_booking, this must be a comment-only line so we keep it for later ledger-output to capture those comments; otherwise, no more to process for this line 
128             non_comment = split_by_comment[0].rstrip()
129             if non_comment.rstrip() == '': 
130                  if inside_booking: 
131                      booking_lines += ['']
132                  continue
133             # if we're starting a booking, parse by first-line pattern 
134             if not inside_booking:
135                 start_line = i
136                 toks = non_comment.split(maxsplit=1)
137                 date_string = toks[0]
138                 try:
139                     datetime.datetime.strptime(date_string, '%Y-%m-%d')
140                 except ValueError:
141                     raise HandledException(f"{prefix} bad date string: {date_string}")
142                 try:
143                     description = toks[1]
144                 except IndexError:
145                     raise HandledException(f"{prefix} bad description: {description}")
146                 inside_booking = True
147                 continue
148             # otherwise, read as transfer data
149             toks = non_comment.split()  # ignore specification's allowance of single spaces in names
150             if len(toks) > 3:
151                 raise HandledException(f"{prefix} too many booking line tokens: {toks}")
152             amount, currency = None, None
153             account_name = toks[0]
154             if account_name[0] == '[' and account_name[-1] == ']':
155                 # ignore specification's differentiation of "virtual" accounts
156                 account_name = account_name[1:-1]
157             decimal_chars = ".-0123456789"
158             if len(toks) == 3:
159                 i_currency = 1 
160                 try:
161                     amount = decimal.Decimal(toks[1])
162                     i_currency = 2
163                 except decimal.InvalidOperation:
164                     try:
165                         amount = decimal.Decimal(toks[2])
166                     except decimal.InvalidOperation:
167                         raise HandledException(f"{prefix} no decimal number in: {toks[1:]}")
168                 currency = toks[i_currency] 
169                 if currency[0] in decimal_chars:
170                     raise HandledException(f"{prefix} currency starts with int, dot, or minus: {currency}")
171             elif len(toks) == 2:
172                 value = toks[1]
173                 inside_amount = False
174                 inside_currency = False
175                 amount_string = ""
176                 currency = ""
177                 dots_counted = 0
178                 for i, c in enumerate(value):
179                     if i == 0:
180                         if c in decimal_chars: 
181                             inside_amount = True 
182                         else:
183                             inside_currency = True 
184                     if inside_currency:
185                         if c in decimal_chars and len(amount_string) == 0:
186                             inside_currency = False
187                             inside_amount = True 
188                         else:
189                             currency += c
190                             continue
191                     if inside_amount:
192                         if c not in decimal_chars:
193                             if len(currency) > 0:
194                                 raise HandledException(f"{prefix} amount has non-decimal chars: {value}")
195                             inside_currency = True
196                             inside_amount = False 
197                             currency += c
198                             continue
199                         if c == '-' and len(amount_string) > 1:
200                             raise HandledException(f"{prefix} amount has non-start '-': {value}")
201                         if c == '.':
202                             if dots_counted > 1:
203                                 raise HandledException(f"{prefix} amount has multiple dots: {value}")
204                             dots_counted += 1
205                         amount_string += c
206                 if len(amount_string) == 0:
207                     raise HandledException(f"{prefix} amount missing: {value}")
208                 if len(currency) == 0:
209                     raise HandledException(f"{prefix} currency missing: {value}")
210                 amount = decimal.Decimal(amount_string)
211             booking_lines += [(account_name, amount, currency)]
212
213     def ledger_as_html(self):
214         lines = []
215         for comment in self.comments:
216             lines += [f"; {comment}" if comment != '' else '']
217         for booking in self.bookings:
218             i = booking.start_line
219             suffix = f" {lines[i]}" if len(lines[i]) > 0 else ""
220             lines[i] = f"{booking.date_string} {booking.description}{suffix}"
221             for booking_line in booking.lines:
222                 i += 1
223                 if booking_line == '':
224                     continue
225                 suffix = f" {lines[i]}" if len(lines[i]) > 0 else ""
226                 value = f" {booking_line[1]} {booking_line[2]}" if booking_line[1] else ""
227                 lines[i] = f"{booking_line[0]}{value}{suffix}"
228         content = "\n".join(lines)
229         return f"<html><meta charset=\"UTF-8\"><pre>{content}</pre></html>"
230
231     def balance_as_html(self):
232         account_sums = {}
233         for booking in self.bookings:
234             for account, changes in booking.account_changes.items():
235                 for currency, amount in changes.items():
236                     apply_booking_to_account_balances(account_sums, account, currency, amount)
237         account_tree = {}
238         def collect_branches(account_name, path):
239             node = account_tree 
240             path_copy = path[:]
241             while len(path_copy) > 0:
242                 step = path_copy.pop(0)
243                 node = node[step] 
244             toks = account_name.split(":", maxsplit=1)
245             parent = toks[0]
246             if parent in node.keys():
247                 child = node[parent]
248             else:
249                 child = {}
250                 node[parent] = child 
251             if len(toks) == 2:
252                 k, v = collect_branches(toks[1], path + [parent])
253                 if k not in child.keys():
254                     child[k] = v
255                 else:
256                     child[k].update(v)
257             return parent, child 
258         for account_name in sorted(account_sums.keys()):
259             k, v = collect_branches(account_name, [])
260             if k not in account_tree.keys():
261                 account_tree[k] = v
262             else:
263                 account_tree[k].update(v)
264         def collect_totals(parent_path, tree_node):
265             for k, v in tree_node.items():
266                 child_path = parent_path + ":" + k
267                 for currency, amount in collect_totals(child_path, v).items():
268                     apply_booking_to_account_balances(account_sums, parent_path, currency, amount) 
269             return account_sums[parent_path]
270         for account_name in account_tree.keys():
271             account_sums[account_name] = collect_totals(account_name, account_tree[account_name])
272         lines = []
273         def print_subtree(lines, indent, node, subtree, path):
274             line = f"{indent}{node}" 
275             n_tabs = 5 - (len(line) // 8)
276             line += n_tabs * "\t" 
277             if "€" in account_sums[path + node].keys():
278                 amount = account_sums[path + node]["€"]
279                 line += f"{amount:9.2f} €\t"
280             else:
281                 line += f"\t\t"
282             for currency, amount in account_sums[path + node].items(): 
283                 if currency != '€' and amount > 0:
284                     line += f"{amount:5.2f} {currency}\t"
285             lines += [line]
286             indent += "  "
287             for k, v in sorted(subtree.items()):
288                 print_subtree(lines, indent, k, v, path + node + ":")
289         for k, v in sorted(account_tree.items()):
290             print_subtree(lines, "", k, v, "")
291         content = "\n".join(lines)
292         return f"<html><meta charset=\"UTF-8\"><pre>{content}</pre></html>"
293
294
295 class MyServer(BaseHTTPRequestHandler):
296
297     def do_GET(self):
298         self.send_response(200)
299         self.send_header("Content-type", "text/html")
300         self.end_headers()
301         db = Database()
302         # page = db.ledger_as_html()
303         page = db.balance_as_html()
304         self.wfile.write(bytes(page, "utf-8"))
305
306
307 db = Database()
308 if __name__ == "__main__":        
309     webServer = HTTPServer((hostName, serverPort), MyServer)
310     print(f"Server started http://{hostName}:{serverPort}")
311     try:
312         webServer.serve_forever()
313     except KeyboardInterrupt:
314         pass
315     webServer.server_close()
316     print("Server stopped.")