home · contact · privacy
9fa9f14a5ae7d1816175148d96b06de029d8346b
[misc] / ledger.py
1 from http.server import BaseHTTPRequestHandler, HTTPServer
2 import sys
3 import os
4 from urllib.parse import parse_qs
5 hostName = "localhost"
6 serverPort = 8082
7
8
9 class HandledException(Exception):
10     pass
11
12
13 def handled_error_exit(msg):
14     print(f"ERROR: {msg}")
15     sys.exit(1)
16
17
18 def apply_booking_to_account_balances(account_sums, account, currency, amount):
19     if not account in account_sums:
20         account_sums[account] = {currency: amount}
21     elif not currency in account_sums[account].keys():
22         account_sums[account][currency] = amount
23     else:
24         account_sums[account][currency] += amount
25
26
27 def parse_lines(lines):
28     import datetime
29     import decimal
30     inside_booking = False
31     date_string, description = None, None
32     booking_lines = []
33     start_line = 0
34     bookings = []
35     comments = []
36     lines += [''] # to ensure a booking-ending last line
37     for i, line in enumerate(lines):
38         prefix = f"line {i}"
39         # we start with the case of an utterly empty line
40         comments += [""]
41         stripped_line = line.rstrip()
42         if stripped_line == '':
43             if inside_booking:
44                 # assume we finished a booking, finalize, and commit to DB
45                 if len(booking_lines) < 2:
46                     raise HandledException(f"{prefix} booking ends to early")
47                 booking = Booking(date_string, description, booking_lines, start_line)
48                 bookings += [booking]
49             # expect new booking to follow so re-zeroall booking data
50             inside_booking = False
51             date_string, description = None, None
52             booking_lines = []
53             continue
54         # if non-empty line, first get comment if any, and commit to DB
55         split_by_comment = stripped_line.split(sep=";", maxsplit=1)
56         if len(split_by_comment) == 2:
57             comments[i] = split_by_comment[1]
58         # 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
59         non_comment = split_by_comment[0].rstrip()
60         if non_comment.rstrip() == '':
61              if inside_booking:
62                  booking_lines += ['']
63              continue
64         # if we're starting a booking, parse by first-line pattern
65         if not inside_booking:
66             start_line = i
67             toks = non_comment.split(maxsplit=1)
68             date_string = toks[0]
69             try:
70                 datetime.datetime.strptime(date_string, '%Y-%m-%d')
71             except ValueError:
72                 raise HandledException(f"{prefix} bad date string: {date_string}")
73             try:
74                 description = toks[1]
75             except IndexError:
76                 raise HandledException(f"{prefix} bad description: {description}")
77             inside_booking = True
78             continue
79         # otherwise, read as transfer data
80         toks = non_comment.split()  # ignore specification's allowance of single spaces in names
81         if len(toks) > 3:
82             raise HandledException(f"{prefix} too many booking line tokens: {toks}")
83         amount, currency = None, None
84         account_name = toks[0]
85         if account_name[0] == '[' and account_name[-1] == ']':
86             # ignore specification's differentiation of "virtual" accounts
87             account_name = account_name[1:-1]
88         decimal_chars = ".-0123456789"
89         if len(toks) == 3:
90             i_currency = 1
91             try:
92                 amount = decimal.Decimal(toks[1])
93                 i_currency = 2
94             except decimal.InvalidOperation:
95                 try:
96                     amount = decimal.Decimal(toks[2])
97                 except decimal.InvalidOperation:
98                     raise HandledException(f"{prefix} no decimal number in: {toks[1:]}")
99             currency = toks[i_currency]
100             if currency[0] in decimal_chars:
101                 raise HandledException(f"{prefix} currency starts with int, dot, or minus: {currency}")
102         elif len(toks) == 2:
103             value = toks[1]
104             inside_amount = False
105             inside_currency = False
106             amount_string = ""
107             currency = ""
108             dots_counted = 0
109             for i, c in enumerate(value):
110                 if i == 0:
111                     if c in decimal_chars:
112                         inside_amount = True
113                     else:
114                         inside_currency = True
115                 if inside_currency:
116                     if c in decimal_chars and len(amount_string) == 0:
117                         inside_currency = False
118                         inside_amount = True
119                     else:
120                         currency += c
121                         continue
122                 if inside_amount:
123                     if c not in decimal_chars:
124                         if len(currency) > 0:
125                             raise HandledException(f"{prefix} amount has non-decimal chars: {value}")
126                         inside_currency = True
127                         inside_amount = False
128                         currency += c
129                         continue
130                     if c == '-' and len(amount_string) > 1:
131                         raise HandledException(f"{prefix} amount has non-start '-': {value}")
132                     if c == '.':
133                         if dots_counted > 1:
134                             raise HandledException(f"{prefix} amount has multiple dots: {value}")
135                         dots_counted += 1
136                     amount_string += c
137             if len(amount_string) == 0:
138                 raise HandledException(f"{prefix} amount missing: {value}")
139             if len(currency) == 0:
140                 raise HandledException(f"{prefix} currency missing: {value}")
141             amount = decimal.Decimal(amount_string)
142         booking_lines += [(account_name, amount, currency)]
143     if inside_booking:
144         raise HandledException(f"{prefix} last booking unfinished")
145     return bookings, comments
146
147 class Booking:
148
149     def __init__(self, date_string, description, booking_lines, start_line):
150         self.date_string = date_string
151         self.description = description
152         self.lines = booking_lines
153         self.start_line = start_line
154         self.validate_booking_lines()
155         self.account_changes = self.parse_booking_lines_to_account_changes()
156
157     def validate_booking_lines(self):
158         prefix = f"booking at line {self.start_line}"
159         sums = {}
160         empty_values = 0
161         for line in self.lines:
162             if line == '':
163                 continue
164             _, amount, currency = line
165             if amount is None:
166                 if empty_values > 0:
167                     raise HandledException(f"{prefix} relates more than one empty value of same currency {currency}")
168                 empty_values += 1
169                 continue
170             if currency not in sums:
171                 sums[currency] = 0
172             sums[currency] += amount
173         if empty_values == 0:
174             for k, v in sums.items():
175                 if v != 0:
176                     raise HandledException(f"{prefix} does not sum up to zero")
177         else:
178             sinkable = False
179             for k, v in sums.items():
180                 if v != 0:
181                     sinkable = True
182             if not sinkable:
183                 raise HandledException(f"{prefix} has empty value that cannot be filled")
184
185     def parse_booking_lines_to_account_changes(self):
186         account_changes = {}
187         debt = {}
188         sink_account = None
189         for line in self.lines:
190             if line == '':
191                 continue
192             account, amount, currency = line
193             if amount is None:
194                 sink_account = account
195                 continue
196             apply_booking_to_account_balances(account_changes, account, currency, amount)
197             if currency not in debt:
198                 debt[currency] = amount
199             else:
200                 debt[currency] += amount
201         if sink_account:
202             for currency, amount in debt.items():
203                 apply_booking_to_account_balances(account_changes, sink_account, currency, -amount)
204         return account_changes
205
206
207
208 class Database:
209
210     def __init__(self):
211         db_name = "_ledger"
212         self.db_file = db_name + ".json"
213         self.lock_file = db_name+ ".lock"
214         self.bookings = []
215         self.comments = []
216         self.real_lines = []
217         if os.path.exists(self.db_file):
218             with open(self.db_file, "r") as f:
219                 self.real_lines += f.readlines()
220         ret = parse_lines(self.real_lines)
221         self.bookings += ret[0]
222         self.comments += ret[1]
223
224     def replace(self, start, end, lines):
225         import shutil
226         if os.path.exists(self.lock_file):
227             raise HandledException('Sorry, lock file!')
228         if os.path.exists(self.db_file):
229             shutil.copy(self.db_file, self.db_file + ".bak")
230         f = open(self.lock_file, 'w+')
231         f.close()
232         text = ''.join(self.real_lines[:start]) + '\n'.join(lines) + ''.join(self.real_lines[end:])
233         with open(self.db_file, 'w') as f:
234             f.write(text);
235         os.remove(self.lock_file)
236
237     def append(self, lines):
238         import shutil
239         if os.path.exists(self.lock_file):
240             raise HandledException('Sorry, lock file!')
241         if os.path.exists(self.db_file):
242             shutil.copy(self.db_file, self.db_file + ".bak")
243         f = open(self.lock_file, 'w+')
244         f.close()
245         with open(self.db_file, 'a') as f:
246             f.write('\n' + '\n'.join(lines) + '\n');
247         os.remove(self.lock_file)
248
249
250 class MyServer(BaseHTTPRequestHandler):
251     header = '<html><meta charset="UTF-8"><body><a href="/ledger">ledger</a> <a href="/balance">balance</a> <a href="/add">add</a><hr />'
252     footer = '</body><html>'
253
254     def do_POST(self):
255         length = int(self.headers['content-length'])
256         postvars = parse_qs(self.rfile.read(length).decode(), keep_blank_values=1)
257         lines = postvars['booking'][0].splitlines()
258         start = int(postvars['start'][0])
259         end = int(postvars['end'][0])
260         try:
261             _, _ = parse_lines(lines)
262             if start == end == 0:
263                 db.append(lines)
264             else:
265                 # raise HandledException(f'would write from {start} to {end}')
266                 db.replace(start, end, lines)
267             self.send_response(200)
268             self.end_headers()
269             page = f"{self.header}Success!{self.footer}"
270             self.wfile.write(bytes(page, "utf-8"))
271         except HandledException as e:
272             self.send_response(400)
273             self.end_headers()
274             page = f"{self.header}{e}{self.footer}"
275             self.wfile.write(bytes(page, "utf-8"))
276
277     def do_GET(self):
278         from urllib.parse import urlparse
279         self.send_response(200)
280         self.send_header("Content-type", "text/html")
281         self.end_headers()
282         db = Database()
283         page = self.header + ''
284         parsed_url = urlparse(self.path)
285         if parsed_url.path == '/balance':
286             page += self.balance_as_html(db)
287         elif parsed_url.path == '/add':
288             params = parse_qs(parsed_url.query)
289             start = int(params.get('start', ['0'])[0])
290             end = int(params.get('end', ['0'])[0])
291             page += self.add(db, start, end)
292         else:
293             page += self.ledger_as_html(db)
294         page += self.footer
295         self.wfile.write(bytes(page, "utf-8"))
296
297     def balance_as_html(self, db):
298         account_sums = {}
299         for booking in db.bookings:
300             for account, changes in booking.account_changes.items():
301                 for currency, amount in changes.items():
302                     apply_booking_to_account_balances(account_sums, account, currency, amount)
303         account_tree = {}
304         def collect_branches(account_name, path):
305             node = account_tree
306             path_copy = path[:]
307             while len(path_copy) > 0:
308                 step = path_copy.pop(0)
309                 node = node[step]
310             toks = account_name.split(":", maxsplit=1)
311             parent = toks[0]
312             if parent in node.keys():
313                 child = node[parent]
314             else:
315                 child = {}
316                 node[parent] = child
317             if len(toks) == 2:
318                 k, v = collect_branches(toks[1], path + [parent])
319                 if k not in child.keys():
320                     child[k] = v
321                 else:
322                     child[k].update(v)
323             return parent, child
324         for account_name in sorted(account_sums.keys()):
325             k, v = collect_branches(account_name, [])
326             if k not in account_tree.keys():
327                 account_tree[k] = v
328             else:
329                 account_tree[k].update(v)
330         def collect_totals(parent_path, tree_node):
331             for k, v in tree_node.items():
332                 child_path = parent_path + ":" + k
333                 for currency, amount in collect_totals(child_path, v).items():
334                     apply_booking_to_account_balances(account_sums, parent_path, currency, amount)
335             return account_sums[parent_path]
336         for account_name in account_tree.keys():
337             account_sums[account_name] = collect_totals(account_name, account_tree[account_name])
338         lines = []
339         def print_subtree(lines, indent, node, subtree, path):
340             line = f"{indent}{node}"
341             n_tabs = 5 - (len(line) // 8)
342             line += n_tabs * "\t"
343             if "€" in account_sums[path + node].keys():
344                 amount = account_sums[path + node]["€"]
345                 line += f"{amount:9.2f} €\t"
346             else:
347                 line += f"\t\t"
348             for currency, amount in account_sums[path + node].items():
349                 if currency != '€' and amount > 0:
350                     line += f"{amount:5.2f} {currency}\t"
351             lines += [line]
352             indent += "  "
353             for k, v in sorted(subtree.items()):
354                 print_subtree(lines, indent, k, v, path + node + ":")
355         for k, v in sorted(account_tree.items()):
356             print_subtree(lines, "", k, v, "")
357         content = "\n".join(lines)
358         return f"<pre>{content}</pre>"
359
360     def ledger_as_html(self, db):
361         lines = []
362         line_sep = '<br />'
363         for comment in db.comments:
364             line = f'; {comment}' if comment != '' else ''
365             lines += [line + line_sep]
366         for booking in db.bookings:
367             i = booking.start_line
368             suffix = lines[i] # f" {lines[i]}" if len(lines[i]) > 0 else ""
369             lines[i] = f'<p>{booking.date_string} {booking.description}{suffix}'
370             for booking_line in booking.lines:
371                 i += 1
372                 if booking_line == '':
373                     continue
374                 suffix = f' {lines[i]}' if len(lines[i]) > 0 else ''
375                 value = f' {booking_line[1]} {booking_line[2]}' if booking_line[1] else ''
376                 lines[i] = f'{booking_line[0]}{value}{suffix}'
377             lines[i] = lines[i][:-len(line_sep)] + f'</p><a href="/add?start={booking.start_line}&end={i+1}">edit</a><br />'
378         return '\n'.join(lines)
379
380     def add(self, db, start=0, end=0):
381         if start == end == 0:
382             content = ''
383         else:
384             content = ''.join(db.real_lines[start:end])
385         return f'<form method="POST" action="/"><textarea name="booking" rows="8" cols="80">{content}</textarea><input type="hidden" name="start" value={start} /><input type="hidden" name="end" value={end} /><input type="submit"></form>'
386
387
388 db = Database()
389 if __name__ == "__main__":     
390     webServer = HTTPServer((hostName, serverPort), MyServer)
391     print(f"Server started http://{hostName}:{serverPort}")
392     try:
393         webServer.serve_forever()
394     except KeyboardInterrupt:
395         pass
396     webServer.server_close()
397     print("Server stopped.")