1 from http.server import BaseHTTPRequestHandler, HTTPServer
4 from urllib.parse import parse_qs
9 class HandledException(Exception):
13 def handled_error_exit(msg):
14 print(f"ERROR: {msg}")
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
24 account_sums[account][currency] += amount
27 def parse_lines(lines):
30 inside_booking = False
31 date_string, description = None, None
36 lines += [''] # to ensure a booking-ending last line
37 for i, line in enumerate(lines):
39 # we start with the case of an utterly empty line
41 stripped_line = line.rstrip()
42 if stripped_line == '':
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)
49 # expect new booking to follow so re-zeroall booking data
50 inside_booking = False
51 date_string, description = None, None
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() == '':
64 # if we're starting a booking, parse by first-line pattern
65 if not inside_booking:
67 toks = non_comment.split(maxsplit=1)
70 datetime.datetime.strptime(date_string, '%Y-%m-%d')
72 raise HandledException(f"{prefix} bad date string: {date_string}")
76 raise HandledException(f"{prefix} bad description: {description}")
79 # otherwise, read as transfer data
80 toks = non_comment.split() # ignore specification's allowance of single spaces in names
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"
92 amount = decimal.Decimal(toks[1])
94 except decimal.InvalidOperation:
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}")
104 inside_amount = False
105 inside_currency = False
109 for i, c in enumerate(value):
111 if c in decimal_chars:
114 inside_currency = True
116 if c in decimal_chars and len(amount_string) == 0:
117 inside_currency = False
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
130 if c == '-' and len(amount_string) > 1:
131 raise HandledException(f"{prefix} amount has non-start '-': {value}")
134 raise HandledException(f"{prefix} amount has multiple dots: {value}")
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)]
144 raise HandledException(f"{prefix} last booking unfinished")
145 return bookings, comments
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()
157 def validate_booking_lines(self):
158 prefix = f"booking at line {self.start_line}"
161 for line in self.lines:
164 _, amount, currency = line
167 raise HandledException(f"{prefix} relates more than one empty value of same currency {currency}")
170 if currency not in sums:
172 sums[currency] += amount
173 if empty_values == 0:
174 for k, v in sums.items():
176 raise HandledException(f"{prefix} does not sum up to zero")
179 for k, v in sums.items():
183 raise HandledException(f"{prefix} has empty value that cannot be filled")
185 def parse_booking_lines_to_account_changes(self):
189 for line in self.lines:
192 account, amount, currency = line
194 sink_account = account
196 apply_booking_to_account_balances(account_changes, account, currency, amount)
197 if currency not in debt:
198 debt[currency] = amount
200 debt[currency] += amount
202 for currency, amount in debt.items():
203 apply_booking_to_account_balances(account_changes, sink_account, currency, -amount)
204 return account_changes
212 self.db_file = db_name + ".json"
213 self.lock_file = db_name+ ".lock"
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]
224 def replace(self, start, end, lines):
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+')
232 text = ''.join(self.real_lines[:start]) + '\n'.join(lines) + ''.join(self.real_lines[end:])
233 with open(self.db_file, 'w') as f:
235 os.remove(self.lock_file)
237 def append(self, lines):
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+')
245 with open(self.db_file, 'a') as f:
246 f.write('\n' + '\n'.join(lines) + '\n');
247 os.remove(self.lock_file)
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>'
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])
261 _, _ = parse_lines(lines)
262 if start == end == 0:
265 # raise HandledException(f'would write from {start} to {end}')
266 db.replace(start, end, lines)
267 self.send_response(200)
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)
274 page = f"{self.header}{e}{self.footer}"
275 self.wfile.write(bytes(page, "utf-8"))
278 from urllib.parse import urlparse
279 self.send_response(200)
280 self.send_header("Content-type", "text/html")
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)
293 page += self.ledger_as_html(db)
295 self.wfile.write(bytes(page, "utf-8"))
297 def balance_as_html(self, db):
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)
304 def collect_branches(account_name, path):
307 while len(path_copy) > 0:
308 step = path_copy.pop(0)
310 toks = account_name.split(":", maxsplit=1)
312 if parent in node.keys():
318 k, v = collect_branches(toks[1], path + [parent])
319 if k not in child.keys():
324 for account_name in sorted(account_sums.keys()):
325 k, v = collect_branches(account_name, [])
326 if k not in account_tree.keys():
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])
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"
348 for currency, amount in account_sums[path + node].items():
349 if currency != '€' and amount > 0:
350 line += f"{amount:5.2f} {currency}\t"
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>"
360 def ledger_as_html(self, db):
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:
372 if booking_line == '':
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)
380 def add(self, db, start=0, end=0):
381 if start == end == 0:
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>'
389 if __name__ == "__main__":
390 webServer = HTTPServer((hostName, serverPort), MyServer)
391 print(f"Server started http://{hostName}:{serverPort}")
393 webServer.serve_forever()
394 except KeyboardInterrupt:
396 webServer.server_close()
397 print("Server stopped.")