1 from http.server import BaseHTTPRequestHandler, HTTPServer
8 class HandledException(Exception):
12 def handled_error_exit(msg):
13 print(f"ERROR: {msg}")
17 def apply_booking_to_account_balances(account_sums, account, currency, amount):
18 if not account in account_sums:
19 account_sums[account] = {currency: amount}
20 elif not currency in account_sums[account].keys():
21 account_sums[account][currency] = amount
23 account_sums[account][currency] += amount
26 def parse_lines(lines):
29 inside_booking = False
30 date_string, description = None, None
35 for i, line in enumerate(lines):
37 # we start with the case of an utterly empty line
39 stripped_line = line.rstrip()
40 if stripped_line == '':
42 # assume we finished a booking, finalize, and commit to DB
43 if len(booking_lines) < 2:
44 raise HandledException(f"{prefix} booking ends to early")
45 booking = Booking(date_string, description, booking_lines, start_line)
47 # expect new booking to follow so re-zeroall booking data
48 inside_booking = False
49 date_string, description = None, None
52 # if non-empty line, first get comment if any, and commit to DB
53 split_by_comment = stripped_line.split(sep=";", maxsplit=1)
54 if len(split_by_comment) == 2:
55 comments[i] = split_by_comment[1]
56 # 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
57 non_comment = split_by_comment[0].rstrip()
58 if non_comment.rstrip() == '':
62 # if we're starting a booking, parse by first-line pattern
63 if not inside_booking:
65 toks = non_comment.split(maxsplit=1)
68 datetime.datetime.strptime(date_string, '%Y-%m-%d')
70 raise HandledException(f"{prefix} bad date string: {date_string}")
74 raise HandledException(f"{prefix} bad description: {description}")
77 # otherwise, read as transfer data
78 toks = non_comment.split() # ignore specification's allowance of single spaces in names
80 raise HandledException(f"{prefix} too many booking line tokens: {toks}")
81 amount, currency = None, None
82 account_name = toks[0]
83 if account_name[0] == '[' and account_name[-1] == ']':
84 # ignore specification's differentiation of "virtual" accounts
85 account_name = account_name[1:-1]
86 decimal_chars = ".-0123456789"
90 amount = decimal.Decimal(toks[1])
92 except decimal.InvalidOperation:
94 amount = decimal.Decimal(toks[2])
95 except decimal.InvalidOperation:
96 raise HandledException(f"{prefix} no decimal number in: {toks[1:]}")
97 currency = toks[i_currency]
98 if currency[0] in decimal_chars:
99 raise HandledException(f"{prefix} currency starts with int, dot, or minus: {currency}")
102 inside_amount = False
103 inside_currency = False
107 for i, c in enumerate(value):
109 if c in decimal_chars:
112 inside_currency = True
114 if c in decimal_chars and len(amount_string) == 0:
115 inside_currency = False
121 if c not in decimal_chars:
122 if len(currency) > 0:
123 raise HandledException(f"{prefix} amount has non-decimal chars: {value}")
124 inside_currency = True
125 inside_amount = False
128 if c == '-' and len(amount_string) > 1:
129 raise HandledException(f"{prefix} amount has non-start '-': {value}")
132 raise HandledException(f"{prefix} amount has multiple dots: {value}")
135 if len(amount_string) == 0:
136 raise HandledException(f"{prefix} amount missing: {value}")
137 if len(currency) == 0:
138 raise HandledException(f"{prefix} currency missing: {value}")
139 amount = decimal.Decimal(amount_string)
140 booking_lines += [(account_name, amount, currency)]
142 raise HandledException(f"{prefix} last booking unfinished")
143 return bookings, comments
147 def __init__(self, date_string, description, booking_lines, start_line):
148 self.date_string = date_string
149 self.description = description
150 self.lines = booking_lines
151 self.start_line = start_line
152 self.validate_booking_lines()
153 self.account_changes = self.parse_booking_lines_to_account_changes()
155 def validate_booking_lines(self):
156 prefix = f"booking at line {self.start_line}"
159 for line in self.lines:
162 _, amount, currency = line
165 raise HandledException(f"{prefix} relates more than one empty value of same currency {currency}")
168 if currency not in sums:
170 sums[currency] += amount
171 if empty_values == 0:
172 for k, v in sums.items():
174 raise HandledException(f"{prefix} does not sum up to zero")
177 for k, v in sums.items():
181 raise HandledException(f"{prefix} has empty value that cannot be filled")
183 def parse_booking_lines_to_account_changes(self):
187 for line in self.lines:
190 account, amount, currency = line
192 sink_account = account
194 apply_booking_to_account_balances(account_changes, account, currency, amount)
195 if currency not in debt:
196 debt[currency] = amount
198 debt[currency] += amount
200 for currency, amount in debt.items():
201 apply_booking_to_account_balances(account_changes, sink_account, currency, -amount)
202 return account_changes
208 def __init__(self, load_from_file=True):
210 self.db_file = db_name + ".json"
211 self.lock_file = db_name+ ".lock"
214 if load_from_file and os.path.exists(self.db_file):
215 with open(self.db_file, "r") as f:
216 ret = parse_lines(f.readlines())
217 self.bookings += ret[0]
218 self.comments += ret[1]
220 def append(self, lines):
222 if os.path.exists(self.lock_file):
223 raise HandledException('Sorry, lock file!')
224 if os.path.exists(self.db_file):
225 shutil.copy(self.db_file, self.db_file + ".bak")
226 f = open(self.lock_file, 'w+')
228 with open(self.db_file, 'a') as f:
229 f.write('\n' + '\n'.join(lines) + '\n');
230 os.remove(self.lock_file)
233 class MyServer(BaseHTTPRequestHandler):
234 header = '<html><meta charset="UTF-8"><body><a href="/ledger">ledger</a> <a href="/balance">balance</a> <a href="/add">add</a><hr />'
235 footer = '</body><html>'
238 from urllib.parse import parse_qs
239 length = int(self.headers['content-length'])
240 postvars = parse_qs(self.rfile.read(length).decode(), keep_blank_values=1)
241 lines = postvars['booking'][0].splitlines()
243 bookings, comments = parse_lines(lines)
245 self.send_response(200)
247 page = f"{self.header}Success!{self.footer}"
248 self.wfile.write(bytes(page, "utf-8"))
249 except HandledException as e:
250 self.send_response(400)
252 page = f"{self.header}{e}{self.footer}"
253 self.wfile.write(bytes(page, "utf-8"))
256 self.send_response(200)
257 self.send_header("Content-type", "text/html")
260 page = self.header + ''
261 if self.path == '/balance':
262 page += self.balance_as_html(db)
263 elif self.path == '/add':
266 page += self.ledger_as_html(db)
268 self.wfile.write(bytes(page, "utf-8"))
270 def balance_as_html(self, db):
272 for booking in db.bookings:
273 for account, changes in booking.account_changes.items():
274 for currency, amount in changes.items():
275 apply_booking_to_account_balances(account_sums, account, currency, amount)
277 def collect_branches(account_name, path):
280 while len(path_copy) > 0:
281 step = path_copy.pop(0)
283 toks = account_name.split(":", maxsplit=1)
285 if parent in node.keys():
291 k, v = collect_branches(toks[1], path + [parent])
292 if k not in child.keys():
297 for account_name in sorted(account_sums.keys()):
298 k, v = collect_branches(account_name, [])
299 if k not in account_tree.keys():
302 account_tree[k].update(v)
303 def collect_totals(parent_path, tree_node):
304 for k, v in tree_node.items():
305 child_path = parent_path + ":" + k
306 for currency, amount in collect_totals(child_path, v).items():
307 apply_booking_to_account_balances(account_sums, parent_path, currency, amount)
308 return account_sums[parent_path]
309 for account_name in account_tree.keys():
310 account_sums[account_name] = collect_totals(account_name, account_tree[account_name])
312 def print_subtree(lines, indent, node, subtree, path):
313 line = f"{indent}{node}"
314 n_tabs = 5 - (len(line) // 8)
315 line += n_tabs * "\t"
316 if "€" in account_sums[path + node].keys():
317 amount = account_sums[path + node]["€"]
318 line += f"{amount:9.2f} €\t"
321 for currency, amount in account_sums[path + node].items():
322 if currency != '€' and amount > 0:
323 line += f"{amount:5.2f} {currency}\t"
326 for k, v in sorted(subtree.items()):
327 print_subtree(lines, indent, k, v, path + node + ":")
328 for k, v in sorted(account_tree.items()):
329 print_subtree(lines, "", k, v, "")
330 content = "\n".join(lines)
331 return f"<pre>{content}</pre>"
333 def ledger_as_html(self, db):
335 for comment in db.comments:
336 lines += [f"; {comment}" if comment != '' else '']
337 for booking in db.bookings:
338 i = booking.start_line
339 suffix = f" {lines[i]}" if len(lines[i]) > 0 else ""
340 lines[i] = f"{booking.date_string} {booking.description}{suffix}"
341 for booking_line in booking.lines:
343 if booking_line == '':
345 suffix = f" {lines[i]}" if len(lines[i]) > 0 else ""
346 value = f" {booking_line[1]} {booking_line[2]}" if booking_line[1] else ""
347 lines[i] = f"{booking_line[0]}{value}{suffix}"
348 content = "\n".join(lines)
349 return f"<pre>{content}</pre>"
352 return '<form method="POST" action="/"><textarea name="booking" rows="8" cols="80"></textarea><input type="submit"></form>'
356 if __name__ == "__main__":
357 webServer = HTTPServer((hostName, serverPort), MyServer)
358 print(f"Server started http://{hostName}:{serverPort}")
360 webServer.serve_forever()
361 except KeyboardInterrupt:
363 webServer.server_close()
364 print("Server stopped.")