from datetime import date, datetime
import re
import os
VALID_DATES_FORMAT = r'\d{4}[-./]\d{1,2}[-./]\d{1,2}'
VALID_DATES_SEP = '[-./]'
class entry:
def __init__(self, date: str, comment: str, transactions: list) -> None:
self.date = self.__date_from_str(date)
self.comment = comment
self.transactions = transactions
def __date_from_str(self, date_str: str):
"""
Searches for a valid date on a string and transforms it into ISO
format. (YYYY-MM-DD)
"""
my_date = re.findall(VALID_DATES_FORMAT, date_str)[0]
year, month, day = re.split(VALID_DATES_SEP, my_date)
return datetime.fromisoformat(f'{int(year)}-{int(month):02}-{int(day):02}')
def __str_to_price_format(self, price: str):
# Find the first instance of a number. A string of digits that may have
# a dot and more digits after.
price_nu = re.findall(r'\d+(?:\.\d*)?', price)[0]
# For the currency symbol, we get rid of the number.
price_sy = price.replace(price_nu, '')
if '-' in price:
# If there was a minus (-), add it to the number and delete it from
# the currency symbol.
price_nu = f"-{price_nu}"
price_sy = price_sy.replace('-', '')
# Remove the whitespace around the currency symbol.
price_sy = price_sy.strip()
# If the symbol is 1 character long, write it on the left.
if len(price_sy) == 1:
return f"{price_sy}{float(price_nu):.02f}"
# If it is longer than 1 character, write it on the right.
else:
return f"{float(price_nu):.02f} {price_sy}"
def __str__(self) -> str:
result = self.date.strftime('%Y/%m/%d')
result += " " + self.comment + "\n"
for trans in self.transactions:
if len(trans) == 2:
account, price = trans
price = self.__str_to_price_format(price)
else:
account = trans[0]
price = ""
result += f" {account:<35} {price:>12}\n"
return result
def give_me_file_contents(path: str):
line_comments = ";#%|*"
try:
with open(path, 'r', encoding='utf8') as fp:
result = fp.readlines()
for line in result:
# If line is just empty spaces or empty, ignore it.
if len(line.lstrip()) == 0:
continue
# If first character of line is a line comment, ignore it.
first_char = line.lstrip()[0]
if first_char in line_comments:
continue
yield line.strip()
except:
raise Exception(f"Error while trying to read {path} file.")
def is_new_entry(line: str):
"""
Returns `True` if the line contains at least one valid date. This means
we're looking at a new transaction.
"""
return re.search(VALID_DATES_FORMAT, line) is not None
def read_ledger(path: str):
files_to_read = [path]
date = None
comment = None
transactions = None
results = []
while files_to_read:
current_file = files_to_read.pop()
for line in give_me_file_contents(current_file):
if line.startswith('!include'):
file_path = line.split()[-1]
base_dir = os.path.dirname(current_file)
files_to_read.insert(0,
os.path.join(base_dir, file_path)
)
continue
if is_new_entry(line) and date is not None:
results.append(
entry(date, comment, transactions)
)
if is_new_entry(line):
date, comment = line.split(maxsplit=1)
transactions = []
else:
# The line is a new transaction
tabs_to_spaces = line.replace('\t', ' ')
transactions.append(
re.split(r'\s{2,}', tabs_to_spaces)
)
if date is not None:
results.append(
entry(date, comment, transactions)
)
date = None
comment = None
transactions = None
return results