Source code for sr.tools.spending

"""Library for accessing the spending files."""
from __future__ import print_function

import datetime
from decimal import Decimal as D
import errno
import os
from subprocess import check_output, check_call, CalledProcessError
import sys

import sr.tools.budget as budget


[docs]class Transaction(object): """ A spending transaction. :param str name: The name of the transaction. :param date: The date of the transaction. :param str fname: The filename of the transation. """ def __init__(self, name, date, fname): """Create a new transaction.""" self.name = "TODO" self.date = None # TODO self.summary = "TODO" self.description = "TODO" self.budget = "TODO" self.cost = D(0) # TODO self.trac = 0 # TODO self.cheque = None # TODO self.payee = None # TODO self.ackdate = None # TODO self.bank_transfer = False # TODO # Strip the '.yaml' off the end of the budget field if it's present if self.budget[-5:] == ".yaml": self.budget = self.budget[:-5]
[docs]def load_transactions(root): """ Load transactions from a directory. :param str root: The path to the root of the spending directory. """ root = os.path.abspath(root) transactions = [] for dirpath, dirnames, filenames in os.walk(root): try: dirnames.remove(".git") dirnames.remove("budget") except ValueError: # those directories will not always be there pass for fname in filenames: fullp = os.path.abspath(os.path.join(dirpath, fname)) if fname[-5:] != ".yaml": continue # The name of a transaction is not unique as multiple transactions # with the same file name can exist in the spending repository. name = fname[:-5] # The date of the transaction if it has been reconciled. None if # it's still pending. topdir = fullp[len(root) + 1:fullp.find('/', len(root) + 1)] repopath = fullp[len(root) + 1:-(len(fname) + 1)] if topdir == "pending": date = None else: try: tmp = repopath.split("/") date = datetime.date(int(tmp[0]), int(tmp[1]), int(tmp[2])) except: print("Unable to determine the date of the transaction " "%s." % fullp, file=sys.stderr) sys.exit(1) transactions.append(Transaction(name, date, fullp)) return transactions
[docs]def group_trans_by_budget_line(trans): """ Group transactions by the budget line. :param trans: The transactions to group. :returns: A dictionary mapping budget line to a list of transactions. """ transgrp = {} for t in trans: transgrp.setdefault(t.budget, []).append(t) return transgrp
[docs]def budget_line_to_account(line): """ Convert a budget line to an account name. :param str line: The line to convert. :returns: The account. """ if line[0] == "/": line = line[1:] line = line.replace("/", ":") return "Expenses:{0}".format(line)
[docs]def account_to_budget_line(account): """ Convert an account name to a budget line name. :param str account: The account to convert. :returns: The budget line. """ line = account.replace(":", "/") return line[len("Expenses/"):]
[docs]class LedgerNotFound(Exception): """An exception that occurs if 'ledger' cannot be found.""" def __init__(self): super(LedgerNotFound, self).__init__("Unable to find 'ledger' which " "is required to operate the " "spending repo.")
[docs]def load_budget_spends(root): """ Load budget spending data. :param str root: The root of the spending data. :returns: A list of budget lines. """ p = os.path.join(root, "spending.dat") try: cmd = ["ledger", "--file", p, "bal", "--format", "%A,%(display_total)\n", "^Expenses:"] balances = check_output(cmd, universal_newlines=True).strip().decode('utf-8') except OSError as oe: if oe.errno == errno.ENOENT: # a nicer error for the most likely case raise LedgerNotFound() else: # re-raise the underlying exception raise lines = {} for line in balances.splitlines(): account, total = line.split(",") if len(account) == 0: continue total = D(total[1:]) line = account_to_budget_line(account) lines[line] = total return lines
[docs]def load_budget_with_spending(root): """ Load the budget with spending data. :param str root: The root path of the spending data. :returns: The budget with spending data. :rtype: budget.Budget """ bud = budget.load_budget(os.path.join(root, "budget/")) lines = load_budget_spends(root) for b in bud.walk(): if b.name not in lines: b.spent = D(0) else: b.spent = lines[b.name] return bud
[docs]class NotSpendingRepo(Exception): """An exception that occurs if the repo is not a spending clone.""" pass
[docs]def find_root(path=None): """ Find the root directory of the spending repository. Checks that the repository is spending.git too. :param path: if provided, is a path within the spending.git repository (defaults to working directory) """ if path is None: path = os.getcwd() try: # check that we're in spending.git with open("/dev/null", "w") as n: check_call(["git", "rev-list", # This is the commit that transitioned spending.git # over to ledger, which is required for this library "09d64df13422ac2fcf9bd17c00b1f66e9e78e912"], cwd=path, stdout=n, stderr=n) except CalledProcessError: # it's not the spending repository raise NotSpendingRepo() root = check_output(["git", "rev-parse", "--show-toplevel"], cwd=path) return root.strip().decode('UTF-8')