Adds graph subcommand

Moves all graph and matplotlib dependencies to graph.py.
Moves Transactions and Categories methods outside the class for better
visibility.
Variable expenses renamed to required.
transactions.py now has by_year, by_month, by_categories in global scope
and slightly improved perfomance.
This commit is contained in:
Luís Murta 2021-01-29 22:48:55 +00:00
parent 9971e6214f
commit fe840b622c
Signed by: satprog
GPG Key ID: DDF2EFC6179009DC
5 changed files with 178 additions and 174 deletions

132
main.py
View File

@ -1,12 +1,9 @@
from pathlib import Path from pathlib import Path
import argparse import argparse
import datetime as dt import datetime as dt
import matplotlib.pyplot as plt
import pickle from pfbudget.graph import monthly
import sys
from pfbudget.categories import Categories
from pfbudget.transactions import load_transactions, save_transactions from pfbudget.transactions import load_transactions, save_transactions
from pfbudget.parsers import Parser
import pfbudget.tools as tools import pfbudget.tools as tools
@ -21,11 +18,15 @@ class PfBudgetNotInitialized(Exception):
pass pass
class DataFileMissing(Exception):
pass
def init(state, args): def init(state, args):
"""init function """init function
Creates .pfbudget.pickle which stores the internal state of the program for later use. Parses all raw directory Creates state file which stores the internal state of the program for later use.
into data directory. Calls parse, that parses all raw directory into data directory.
args.raw -- raw dir args.raw -- raw dir
args.data -- data dir args.data -- data dir
@ -50,8 +51,8 @@ def init(state, args):
def restart(state, args): def restart(state, args):
"""restart function """restart function
Deletes .pfbudget.pickle and creates new one. Parses all raw directory into data directory. New dirs can be passed Deletes state and creates new one. Parses all raw directory into data directory.
as arguments, otherwise uses previous values New dirs can be passed as arguments, otherwise uses previous values.
args.raw -- raw dir args.raw -- raw dir
args.data -- data dir args.data -- data dir
@ -61,8 +62,7 @@ def restart(state, args):
try: try:
(Path(state.data_dir) / fn).unlink() (Path(state.data_dir) / fn).unlink()
except FileNotFoundError: except FileNotFoundError:
print("missing {}".format(Path(state.data_dir) / fn)) raise DataFileMissing("missing {}".format(Path(state.data_dir) / fn))
sys.exit(-1)
if args.raw: if args.raw:
state.raw_dir = args.raw state.raw_dir = args.raw
@ -91,8 +91,8 @@ def backup(state, args):
def parse(state, args): def parse(state, args):
"""parse function """parse function
Extracts from .pfbudget.pickle the already read files and parses the remaining. args will be None if called from Extracts from .pfbudget.pickle the already read files and parses the remaining.
command line and gathered from the pickle. args will be None if called from command line and gathered from the pickle.
args.raw -- raw dir args.raw -- raw dir
args.data -- data dir args.data -- data dir
@ -141,7 +141,10 @@ def vacation(state, args):
def status(state, args): def status(state, args):
print(state) print(state)
sys.exit(0)
def graph(state, args):
monthly(state, start=dt.date(2020, 1, 1), end=dt.date(2020, 12, 31))
if __name__ == "__main__": if __name__ == "__main__":
@ -197,105 +200,20 @@ if __name__ == "__main__":
p_backup.set_defaults(func=backup) p_backup.set_defaults(func=backup)
p_parse.set_defaults(func=parse) p_parse.set_defaults(func=parse)
p_vacation.set_defaults(func=vacation) p_vacation.set_defaults(func=vacation)
p_report.set_defaults(func=categorize)
p_status.set_defaults(func=status) p_status.set_defaults(func=status)
p_graph.set_defaults(func=graph)
state = tools.pfstate(p) state = tools.pfstate(p)
state.filename = p state.filename = p
args = parser.parse_args() args = parser.parse_args()
args.func(state, args) args.func(state, args)
transactions = load_transactions(state.data_dir) # income = [sum(t.value for cat, transactions in months.items() for t in transactions
# if cat in get_income_categories()) for months in monthly_transactions_by_cat]
# reprocess = [Education().name] # expenses = []
# for i, transaction in enumerate(transactions): # for category in expense_categories:
# for category in Categories.get_categories(): # expense_value = [-sum(t.value for t in month[category]) for month in monthly_transactions_by_cat]
# if transaction.category in reprocess: # expenses.extend(expense_value)
# transaction.category = ''
monthly_transactions = transactions.get_transactions_by_month( # print("Income: {}, Expenses: {}, Net = {}"".format(sum(income), sum(expenses), sum(income) - sum(expenses)))
start=dt.date(2020, 1, 1), end=dt.date(2020, 12, 31)
)
monthly_transactions_by_cat = []
for month_transactions in monthly_transactions.values():
cat = month_transactions.get_transactions_by_category()
monthly_transactions_by_cat.append(cat)
for month, month_transactions in zip(
monthly_transactions.keys(), monthly_transactions_by_cat
):
nulls = sum(t.value for t in month_transactions["Null"])
if nulls != 0:
print(f"{month} {nulls}")
expense_categories = [
*Categories.get_fixed_expenses(),
*Categories.get_variable_expenses(),
*Categories.get_discretionary_expenses(),
]
if True:
t = list(monthly_transactions.keys())
income = [
float(
sum(
t.value
for cat, transactions in months.items()
for t in transactions
if cat in Categories.get_income_categories()
)
)
for months in monthly_transactions_by_cat
]
# income = []
# for months in monthly_transactions_by_cat:
# for cat, transactions in months.items():
# if cat in Categories.get_income_categories():
# income.append(sum(transactions))
expenses = []
for category in expense_categories:
expense_value = [
-float(sum(t.value for t in month[category]))
for month in monthly_transactions_by_cat
]
expenses.append(expense_value)
# expenses = [transactions for months in monthly_transactions_by_cat for cat, transactions in months.items()
# if cat not in Categories.get_income_categories() and transactions]
for expense in expenses:
for i, month in reversed(list(enumerate(t))):
if expense[i] < 0:
if i - 1 < 0:
break
else:
expense[i - 1] += expense[i]
expense[i] = 0
plt.plot(t, income, label="Income")
plt.stackplot(t, expenses, labels=expense_categories)
plt.legend(bbox_to_anchor=(1, 1), loc="upper left")
plt.show()
income = [
sum(
t.value
for cat, transactions in months.items()
for t in transactions
if cat in Categories.get_income_categories()
)
for months in monthly_transactions_by_cat
]
expenses = []
for category in expense_categories:
expense_value = [
-sum(t.value for t in month[category])
for month in monthly_transactions_by_cat
]
expenses.extend(expense_value)
print(
"Income: {}, Expenses: {}, Net = {}".format(
sum(income), sum(expenses), sum(income) - sum(expenses)
)
)

View File

@ -38,39 +38,36 @@ class Categories:
def get_categories(cls): def get_categories(cls):
return cls.__subclasses__() return cls.__subclasses__()
@classmethod
def get_categories_names(cls):
return [cat.name for cat in cls.get_categories()]
@classmethod def get_categories():
def get_income_categories(cls): return [cat.name for cat in Categories.get_categories()]
return [cat.name for cat in cls.get_categories() if "Income" in cat.name]
@classmethod
def get_fixed_expenses(cls): def get_income_categories():
return [ return [cat for cat in get_categories() if "Income" in cat]
Utilities.name,
Commute.name,
] def get_fixed_expenses():
return [Utilities.name, Commute.name]
@classmethod
def get_variable_expenses(cls):
return [Groceries.name] def get_required_expenses():
return [Groceries.name]
@classmethod
def get_discretionary_expenses(cls):
return [ def get_discretionary_expenses():
cat.name return [
for cat in cls.get_categories() cat
if cat.name for cat in get_categories()
not in [ if cat
*cls.get_income_categories(), not in [
*cls.get_fixed_expenses(), *get_income_categories(),
*cls.get_variable_expenses(), *get_fixed_expenses(),
Investment.name, *get_required_expenses(),
Null.name, Investment.name,
] Null.name,
] ]
]
class Income1(Categories): class Income1(Categories):
@ -193,7 +190,7 @@ class Travel(Categories):
name = "Travel" name = "Travel"
regex = [c("ryanair"), c("easyjet"), c("airbnb")] regex = [c("ryanair"), c("easyjet"), c("airbnb")]
not_in_travel = [ not_in_travel = [
*Categories.get_income_categories(), *get_income_categories(),
Utilities.name, Utilities.name,
] ]

72
pfbudget/graph.py Normal file
View File

@ -0,0 +1,72 @@
from dateutil.rrule import MONTHLY, YEARLY
import matplotlib.pyplot as plt
from .categories import (
get_income_categories,
get_fixed_expenses,
get_required_expenses,
get_discretionary_expenses,
)
from .transactions import load_transactions, daterange, by_category, by_month
def monthly(state, start, end):
transactions = load_transactions(state.data_dir)
monthly_transactions = by_month(transactions, start, end)
monthly_transactions_by_categories = {}
income, fixed, required, discretionary = [], [], [], []
for month, transactions in monthly_transactions.items():
monthly_transactions_by_categories[month] = by_category(transactions)
income.append(
sum(
float(t.value)
for category, transactions in monthly_transactions_by_categories[
month
].items()
if transactions and category in get_income_categories()
for t in transactions
)
)
fixed.append(
sum(
-float(t.value)
for category, transactions in monthly_transactions_by_categories[
month
].items()
if transactions and category in get_fixed_expenses()
for t in transactions
)
)
required.append(
sum(
-float(t.value)
for category, transactions in monthly_transactions_by_categories[
month
].items()
if transactions and category in get_required_expenses()
for t in transactions
)
)
discretionary.append(
sum(
-float(t.value)
for category, transactions in monthly_transactions_by_categories[
month
].items()
if transactions and category in get_discretionary_expenses()
for t in transactions
)
)
plt.plot(daterange(start, end, "month"), income, label="Income")
plt.stackplot(
daterange(start, end, "month"),
fixed,
required,
discretionary,
labels=["Fixed", "Required", "Discretionary"],
)
plt.legend(loc="upper left")
plt.show()

View File

@ -4,7 +4,7 @@ import datetime as dt
import pickle import pickle
import shutil import shutil
from .categories import Categories, Null, Travel from .categories import Categories, Null, Travel, get_categories
from .transactions import ( from .transactions import (
Transaction, Transaction,
load_transactions, load_transactions,
@ -255,7 +255,7 @@ def manual_categorization(state: PFState, transactions: list):
category = input(f"{transaction.desc()} category: ") category = input(f"{transaction.desc()} category: ")
if category == "quit": if category == "quit":
return return
if category not in Categories.get_categories_names(): if category not in get_categories():
print("category doesn't exist") print("category doesn't exist")
continue continue
else: else:

View File

@ -1,9 +1,10 @@
from csv import reader, writer from csv import reader, writer
from datetime import date from datetime import date
from dateutil.rrule import rrule, MONTHLY, YEARLY
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from pathlib import Path from pathlib import Path
from .categories import Categories from .categories import get_categories
COMMENT_TOKEN = "#" COMMENT_TOKEN = "#"
@ -124,42 +125,58 @@ class Transactions(list):
return years return years
def get_transactions_by_month(self, start=None, end=None):
if not start:
start = self[0].date
if not end:
end = self[-1].date
months = dict() def daterange(start, end, period):
for year, year_transactions in self.get_transactions_by_year( if period == "year":
start, end r = [d.strftime("%Y") for d in rrule(YEARLY, dtstart=start, until=end)]
).items(): elif period == "month":
for month in range(1, 13): r = [d.strftime("%b %Y") for d in rrule(MONTHLY, dtstart=start, until=end)]
key = "_".join([str(year), str(month)]) else:
months[key] = Transactions( raise TransactionError("wrong time period")
t for t in year_transactions if t.date.month == month return r
)
# trims last unused months
trim = 1
for transactions in reversed(months.values()):
if transactions:
break
else:
trim += 1
while trim := trim - 1:
months.popitem()
return months def by_year(transactions, start=None, end=None) -> dict:
start = start if start else transactions[0].date
end = end if end else transactions[-1].date
def get_transactions_by_category(self): yearly_transactions = dict.fromkeys(daterange(start, end, "year"), None)
categories = {cat: [] for cat in Categories.get_categories_names()} for t in [t for t in transactions if t.date >= start and t.date <= end]:
for transaction in self: try:
try: yearly_transactions[t.date.strftime("%Y")].append(t)
categories[transaction.category].append(transaction) except AttributeError:
except AttributeError: yearly_transactions[t.date.strftime("%Y")] = [t]
categories[transaction.category] = [transaction] except KeyError:
return categories raise TransactionError("date invalid")
return yearly_transactions
def by_month(transactions, start=None, end=None) -> dict:
start = start if start else transactions[0].date
end = end if end else transactions[-1].date
monthly_transactions = dict.fromkeys(daterange(start, end, "month"), None)
for t in [t for t in transactions if t.date >= start and t.date <= end]:
try:
monthly_transactions[t.date.strftime("%b %Y")].append(t)
except AttributeError:
monthly_transactions[t.date.strftime("%b %Y")] = [t]
except KeyError:
raise TransactionError("date invalid")
return monthly_transactions
def by_category(transactions) -> dict:
transactions_by_category = dict.fromkeys(get_categories(), None)
for transaction in transactions:
try:
transactions_by_category[transaction.category].append(transaction)
except AttributeError:
transactions_by_category[transaction.category] = [transaction]
return transactions_by_category
def load_transactions(data_dir) -> Transactions: def load_transactions(data_dir) -> Transactions:
@ -184,7 +201,7 @@ def save_transactions(data_dir, transactions):
write_transactions(f, trs) write_transactions(f, trs)
def read_transactions(filename, encoding="utf-8"): def read_transactions(filename, encoding="utf-8") -> list:
try: try:
with open(filename, newline="", encoding=encoding) as f: with open(filename, newline="", encoding=encoding) as f:
r = reader(f, delimiter="\t") r = reader(f, delimiter="\t")