creates new project
This commit is contained in:
parent
9542f9b26d
commit
06903c89e4
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
__pycache__/
|
75
bookeeper/__main__.py
Normal file
75
bookeeper/__main__.py
Normal file
@ -0,0 +1,75 @@
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
from . import args
|
||||
from . import calc
|
||||
from . import db
|
||||
from . import parser
|
||||
from . import util
|
||||
|
||||
|
||||
try:
|
||||
try: # parsing args
|
||||
params = args.parse(sys.argv[1:])
|
||||
except Exception as e:
|
||||
args.print_help()
|
||||
raise Exception("failure while parsing args", e)
|
||||
|
||||
try: # initializing DB
|
||||
dbconn = sqlite3.connect(params.dbpath)
|
||||
dbcur = dbconn.cursor()
|
||||
db.try_init(dbcur)
|
||||
dbcur.execute("BEGIN;")
|
||||
except Exception as e:
|
||||
raise Exception("failure while initializing DB", e)
|
||||
|
||||
if not sys.stdin.isatty():
|
||||
try: # insert new records
|
||||
for tx in parser.parse(sys.stdin):
|
||||
db.apply(dbcur, tx)
|
||||
except Exception as e:
|
||||
raise Exception("failure while inserting new records", e)
|
||||
|
||||
try: # writing outputs
|
||||
calc.call(
|
||||
calc.balancesheet,
|
||||
params.balancesheet,
|
||||
dbcur,
|
||||
)
|
||||
calc.call(
|
||||
calc.plstatement,
|
||||
util.get_or(params.plstatement, 0),
|
||||
dbcur,
|
||||
begin=util.get_or(params.plstatement, 1),
|
||||
end =util.get_or(params.plstatement, 2),
|
||||
)
|
||||
calc.call(
|
||||
calc.balancetransition,
|
||||
params.balancetransition,
|
||||
dbcur,
|
||||
)
|
||||
calc.call(
|
||||
calc.pltransition,
|
||||
util.get_or(params.pltransition, 0),
|
||||
dbcur,
|
||||
datefmt=util.get_or(params.pltransition, 1),
|
||||
)
|
||||
except Exception as e:
|
||||
raise Exception("failure while calculating output", e)
|
||||
|
||||
except Exception as e:
|
||||
print(f"bookeeper: error: {e}", file=sys.stderr)
|
||||
dbcur and dbcur.execute("ROLLBACK;")
|
||||
else:
|
||||
if params.permanentize:
|
||||
dbcur.execute("COMMIT;")
|
||||
else:
|
||||
dbcur.execute("ROLLBACK;")
|
||||
|
||||
finally:
|
||||
if dbcur:
|
||||
dbcur.close()
|
||||
if dbconn:
|
||||
dbconn.commit();
|
||||
dbconn.close()
|
55
bookeeper/args.py
Normal file
55
bookeeper/args.py
Normal file
@ -0,0 +1,55 @@
|
||||
import argparse
|
||||
|
||||
_parser = argparse.ArgumentParser(
|
||||
prog="bookeeper",
|
||||
description="CLI app for bookkeeping",
|
||||
epilog="developed by falsycat <me@falsy.cat>",
|
||||
)
|
||||
|
||||
_parser.add_argument(
|
||||
"dbpath",
|
||||
help="path to sqlite database file",
|
||||
nargs="?",
|
||||
default=":memory:",
|
||||
)
|
||||
|
||||
_parser.add_argument(
|
||||
"-p", "--permanentize",
|
||||
help="apply changes",
|
||||
action="store_true",
|
||||
dest="permanentize",
|
||||
)
|
||||
|
||||
_parser.add_argument(
|
||||
"-bs", "--balancesheet",
|
||||
help="emit balancesheet",
|
||||
action="store",
|
||||
dest="balancesheet",
|
||||
)
|
||||
_parser.add_argument(
|
||||
"-bt", "--balance-transition",
|
||||
help="emit daily transition of balance",
|
||||
action="store",
|
||||
dest="balancetransition",
|
||||
)
|
||||
_parser.add_argument(
|
||||
"-pl", "--pl-statement",
|
||||
help="emit P/L statement (filepath, begin, end)",
|
||||
action="store",
|
||||
nargs="+",
|
||||
dest="plstatement",
|
||||
)
|
||||
_parser.add_argument(
|
||||
"-plt", "--pl-transition",
|
||||
help="emit transition of P/L",
|
||||
action="store",
|
||||
nargs="+",
|
||||
dest="pltransition",
|
||||
)
|
||||
|
||||
def print_help():
|
||||
_parser.print_help()
|
||||
|
||||
def parse(args):
|
||||
ret = _parser.parse_args(args)
|
||||
return ret
|
101
bookeeper/calc.py
Normal file
101
bookeeper/calc.py
Normal file
@ -0,0 +1,101 @@
|
||||
import sys
|
||||
|
||||
def call(func, path, *args, **kwargs):
|
||||
if path == "-":
|
||||
func(sys.stdout, *args, **kwargs)
|
||||
elif path is None:
|
||||
pass
|
||||
else:
|
||||
with open(path, "w") as f:
|
||||
func(f, *args, **kwargs)
|
||||
|
||||
def balancesheet(stream, cur):
|
||||
cur.execute("""
|
||||
SELECT
|
||||
entry.type,
|
||||
entry.name,
|
||||
SUM(journal.amount)
|
||||
FROM journal RIGHT JOIN entry ON journal.entry=entry.id
|
||||
WHERE entry.type in ("A","D","N")
|
||||
GROUP BY entry.name;
|
||||
""")
|
||||
for row in cur:
|
||||
print(_recover_entry_name(row[0], row[1]), row[2], file=stream, sep=",")
|
||||
|
||||
def plstatement(stream, cur, begin=None, end=None):
|
||||
if begin is None:
|
||||
begin = "0000-00-00";
|
||||
if end is None:
|
||||
end = "9999-12-31";
|
||||
|
||||
cur.execute("""
|
||||
SELECT
|
||||
entry.type,
|
||||
entry.name,
|
||||
SUM(journal.amount)
|
||||
FROM journal
|
||||
RIGHT JOIN entry ON journal.entry=entry.id
|
||||
RIGHT JOIN tx ON journal.tx =tx.id
|
||||
WHERE
|
||||
entry.type in ("E","R") AND tx.date BETWEEN ? AND ?
|
||||
GROUP BY entry.name;
|
||||
""", (begin, end))
|
||||
for row in cur:
|
||||
print(_recover_entry_name(row[0], row[1]), row[2], file=stream, sep=",")
|
||||
|
||||
def balancetransition(stream, cur):
|
||||
cur.execute("""
|
||||
SELECT
|
||||
date,
|
||||
SUM(sum)
|
||||
OVER(ORDER BY date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
|
||||
FROM (
|
||||
SELECT
|
||||
tx.date AS date,
|
||||
SUM(
|
||||
CASE WHEN entry.type="A" THEN journal.amount ELSE -journal.amount END
|
||||
) AS sum
|
||||
FROM journal
|
||||
RIGHT JOIN entry ON journal.entry=entry.id
|
||||
RIGHT JOIN tx ON journal.tx =tx.id
|
||||
WHERE entry.type in ("A","D","N")
|
||||
GROUP BY tx.date
|
||||
);
|
||||
""")
|
||||
for row in cur:
|
||||
print(*row, file=stream, sep=",")
|
||||
|
||||
def pltransition(stream, cur, datefmt=None):
|
||||
if datefmt is None:
|
||||
datefmt = "%Y-%m-%d"
|
||||
cur.execute("""
|
||||
WITH temp AS (
|
||||
SELECT
|
||||
STRFTIME(?, tx.date) AS period,
|
||||
entry.type AS type,
|
||||
SUM(amount) AS sum
|
||||
FROM journal
|
||||
RIGHT JOIN entry ON journal.entry=entry.id
|
||||
RIGHT JOIN tx ON journal.tx =tx.id
|
||||
WHERE entry.type in ("E","R")
|
||||
GROUP BY period, entry.type
|
||||
)
|
||||
SELECT
|
||||
a.period AS period,
|
||||
a.sum AS expense,
|
||||
IFNULL(b.sum, 0) AS revenue
|
||||
FROM temp AS a LEFT JOIN temp AS b ON a.period=b.period AND b.type="R"
|
||||
WHERE a.type="E";
|
||||
""", [datefmt])
|
||||
for row in cur:
|
||||
print(*row, file=stream, sep=",")
|
||||
|
||||
def _recover_entry_name(t: str, name: str):
|
||||
m = {
|
||||
"A": "asset",
|
||||
"D": "debt",
|
||||
"N": "net",
|
||||
"E": "expense",
|
||||
"R": "revenue",
|
||||
}
|
||||
return f"{m[t]}/{name}"
|
49
bookeeper/db.py
Normal file
49
bookeeper/db.py
Normal file
@ -0,0 +1,49 @@
|
||||
import sqlite3
|
||||
|
||||
def try_init(cur):
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS entry (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
type VARCHAR(1) NOT NULL CHECK(type IN ("A","D","N","E","R")),
|
||||
name TEXT NOT NULL,
|
||||
|
||||
UNIQUE (type, name)
|
||||
);
|
||||
""")
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS tx (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
summary TEXT,
|
||||
date DATE NOT NULL,
|
||||
time TIME
|
||||
);
|
||||
""")
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS journal (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
tx INTEGER NOT NULL,
|
||||
entry INTEGER NOT NULL,
|
||||
amount INTEGER NOT NULL,
|
||||
|
||||
FOREIGN KEY(tx) REFERENCES tx(id),
|
||||
FOREIGN KEY(entry) REFERENCES entry(name)
|
||||
);
|
||||
""")
|
||||
|
||||
def apply(cur, tx):
|
||||
cur.execute("""
|
||||
INSERT INTO tx(summary, date, time) VALUES (?, ?, ?) RETURNING id
|
||||
""", (tx.summary, tx.date, tx.time))
|
||||
txid = next(cur)[0]
|
||||
|
||||
for j in tx.journals:
|
||||
cur.execute("""
|
||||
INSERT INTO entry(type, name) VALUES (?, ?)
|
||||
ON CONFLICT DO UPDATE SET id=id
|
||||
RETURNING id
|
||||
""", j[0:2])
|
||||
eid = next(cur)[0]
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO journal(tx, entry, amount) VALUES (?, ?, ?)
|
||||
""", (txid, eid, j[2]))
|
80
bookeeper/parser.py
Normal file
80
bookeeper/parser.py
Normal file
@ -0,0 +1,80 @@
|
||||
import re
|
||||
import string
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
_RE_DATETIME = re.compile(r"(\d{4}-\d{2}-\d{2})(?:(@\d{2}:\d{2}))?")
|
||||
|
||||
@dataclass
|
||||
class Tx:
|
||||
date : str = ""
|
||||
time : str|None = None
|
||||
summary : str = ""
|
||||
journals: list[tuple[str, int]] = field(default_factory=lambda: [])
|
||||
|
||||
def parse(stream):
|
||||
parts = []
|
||||
end = False
|
||||
line_num = 0
|
||||
while not end:
|
||||
line_num += 1
|
||||
|
||||
line = stream.readline()
|
||||
end = ("" == line)
|
||||
|
||||
if end or not line[0].isspace():
|
||||
if tx := _make_tx(parts):
|
||||
yield tx
|
||||
parts = []
|
||||
|
||||
comment = line.find("#")
|
||||
if comment >= 0:
|
||||
line = line[0:comment]
|
||||
|
||||
line = line.strip()
|
||||
if line != "":
|
||||
parts.append((line_num, line))
|
||||
|
||||
def _make_tx(lines: list[tuple[int, str]]) -> dict:
|
||||
if len(lines) < 2:
|
||||
return None
|
||||
|
||||
tx = Tx()
|
||||
|
||||
line1 = lines[0][1].split(maxsplit=1)
|
||||
if m := re.fullmatch(_RE_DATETIME, line1[0]):
|
||||
tx.date = m.group(1)
|
||||
tx.time = m.group(2)
|
||||
else:
|
||||
raise Exception(f"invalid datetime format: '{line1[0]}' ({lines[0][1]})")
|
||||
|
||||
if len(line1) == 2:
|
||||
tx.summary = line1[1]
|
||||
|
||||
for line in lines[1:]:
|
||||
tokens = line[1].split()
|
||||
if len(tokens) != 2:
|
||||
raise Exception("invalid journal: '{line[1]}' (line[0])")
|
||||
t, name = _parse_entry_name(tokens[0])
|
||||
tx.journals.append((t, name, int(tokens[1])))
|
||||
return tx
|
||||
|
||||
def _parse_entry_name(name: str) -> tuple[str, str]:
|
||||
tokens = name.split("/", maxsplit=1)
|
||||
if len(tokens) != 2:
|
||||
raise Exception(f"invalid entry name: {name}")
|
||||
|
||||
if "asset" == tokens[0]:
|
||||
t = "A"
|
||||
elif "debt" == tokens[0]:
|
||||
t = "D"
|
||||
elif "net" == tokens[0]:
|
||||
t = "N"
|
||||
elif "expense" == tokens[0]:
|
||||
t = "E"
|
||||
elif "revenue" == tokens[0]:
|
||||
t = "R"
|
||||
else:
|
||||
raise Exception(f"invalid entry type: {tokens[0]}")
|
||||
|
||||
return (t, tokens[1])
|
5
bookeeper/util.py
Normal file
5
bookeeper/util.py
Normal file
@ -0,0 +1,5 @@
|
||||
def get_or(a: list, idx: int, default = None):
|
||||
try:
|
||||
return a[idx]
|
||||
except:
|
||||
return default
|
Loading…
Reference in New Issue
Block a user