Initial commit
This commit is contained in:
commit
222ef8b421
2 changed files with 233 additions and 0 deletions
201
accounting/__init__.py
Normal file
201
accounting/__init__.py
Normal file
|
@ -0,0 +1,201 @@
|
|||
import subprocess
|
||||
import logging
|
||||
import time
|
||||
|
||||
from datetime import datetime
|
||||
from xml.etree import ElementTree
|
||||
from contextlib import contextmanager
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
class Ledger:
|
||||
def __init__(self, ledger_file=None, ledger_bin=None):
|
||||
if ledger_file is None:
|
||||
raise ValueError('ledger_file cannot be None')
|
||||
|
||||
self.ledger_bin = ledger_bin or 'ledger'
|
||||
self.ledger_file = ledger_file
|
||||
_log.info('ledger file: %s', ledger_file)
|
||||
|
||||
self.locked = False
|
||||
self.ledger_process = None
|
||||
|
||||
@contextmanager
|
||||
def locked_process(self):
|
||||
if self.locked:
|
||||
_log.warning('Process is already locked')
|
||||
for i in range(1, 5):
|
||||
if i > 4:
|
||||
raise RuntimeError('Ledger process is already locked')
|
||||
|
||||
if not self.locked:
|
||||
break
|
||||
else:
|
||||
_log.info('Waiting for one second... %d/%d', i, 5)
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
process = self.get_process()
|
||||
|
||||
self.locked = True
|
||||
_log.debug('lock enabled')
|
||||
|
||||
yield process
|
||||
|
||||
self.locked = False
|
||||
_log.debug('lock disabled')
|
||||
|
||||
def assemble_arguments(self):
|
||||
return [
|
||||
self.ledger_bin,
|
||||
'-f',
|
||||
self.ledger_file,
|
||||
]
|
||||
|
||||
def init_process(self):
|
||||
_log.debug('starting ledger process')
|
||||
self.ledger_process = subprocess.Popen(
|
||||
self.assemble_arguments(),
|
||||
stdout=subprocess.PIPE,
|
||||
stdin=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE)
|
||||
|
||||
# Swallow the banner
|
||||
with self.locked_process() as p:
|
||||
self.read_until_prompt(p)
|
||||
|
||||
return self.ledger_process
|
||||
|
||||
def get_process(self):
|
||||
return self.ledger_process or self.init_process()
|
||||
|
||||
def read_until_prompt(self, p):
|
||||
output = b''
|
||||
|
||||
while True:
|
||||
# _log.debug('reading data')
|
||||
|
||||
line = p.stdout.read(1) # XXX: This is a hack
|
||||
# _log.debug('line: %s', line)
|
||||
|
||||
output += line
|
||||
|
||||
if b'\n] ' in output:
|
||||
_log.debug('found prompt!')
|
||||
break
|
||||
|
||||
output = output[:-3] # Cut away the prompt
|
||||
|
||||
_log.debug('output: %s', output)
|
||||
|
||||
return output
|
||||
|
||||
def send_command(self, p, command):
|
||||
# TODO: Should be extended to handle the locking and return the output
|
||||
_bytes = p.stdin.write(command + b'\n')
|
||||
p.stdin.flush()
|
||||
|
||||
return _bytes
|
||||
|
||||
def bal(self):
|
||||
output = None
|
||||
|
||||
with self.locked_process() as p:
|
||||
_log.debug('aquired process lock')
|
||||
self.send_command(p, b'bal --format "%A|%t\\\\n"')
|
||||
_log.debug('sent command')
|
||||
|
||||
output = self.read_until_prompt(p)
|
||||
|
||||
if output is None:
|
||||
raise RuntimeError('bal call returned no output')
|
||||
|
||||
accounts = []
|
||||
|
||||
for line in output.split(b'\n'):
|
||||
name, balance = line.decode('utf8').split('|')
|
||||
|
||||
accounts.append(Account(name=name, balance=balance))
|
||||
|
||||
return accounts
|
||||
|
||||
def reg(self):
|
||||
output = None
|
||||
|
||||
with self.locked_process() as p:
|
||||
_log.debug('aquired process lock')
|
||||
self.send_command(p, b'xml')
|
||||
|
||||
output = self.read_until_prompt(p)
|
||||
|
||||
if output is None:
|
||||
raise RuntimeError('reg call returned no output')
|
||||
|
||||
entries = []
|
||||
|
||||
reg_xml = ElementTree.fromstring(output.decode('utf8'))
|
||||
|
||||
for transaction in reg_xml.findall('./transactions/transaction'):
|
||||
date = datetime.strptime(transaction.find('./date').text,
|
||||
'%Y/%m/%d')
|
||||
payee = transaction.find('./payee').text
|
||||
|
||||
postings = []
|
||||
|
||||
for posting in transaction.findall('./postings/posting'):
|
||||
account = posting.find('./account/name').text
|
||||
amount = posting.find('./post-amount/amount/quantity').text
|
||||
symbol = posting.find(
|
||||
'./post-amount/amount/commodity/symbol').text
|
||||
|
||||
postings.append(
|
||||
Posting(account=account, amount=amount, symbol=symbol))
|
||||
|
||||
entries.append(
|
||||
Transaction(date=date, payee=payee, postings=postings))
|
||||
|
||||
return entries
|
||||
|
||||
|
||||
class Transaction:
|
||||
def __init__(self, date=None, payee=None, postings=None):
|
||||
self.date = date
|
||||
self.payee = payee
|
||||
self.postings = postings
|
||||
|
||||
def __repr__(self):
|
||||
return ('<{self.__class__.__name__} {date}' +
|
||||
' {self.payee} {self.postings}').format(
|
||||
self=self,
|
||||
date=self.date.isoformat())
|
||||
|
||||
class Posting:
|
||||
def __init__(self, account=None, amount=None, symbol=None):
|
||||
self.account = account
|
||||
self.amount = amount
|
||||
self.symbol = symbol
|
||||
|
||||
def __repr__(self):
|
||||
return ('<{self.__class__.__name__} "{self.account}"' +
|
||||
' {self.symbol} {self.amount}>').format(self=self)
|
||||
|
||||
|
||||
class Account:
|
||||
def __init__(self, name=None, balance=None):
|
||||
self.name = name
|
||||
self.balance = balance
|
||||
|
||||
def __repr__(self):
|
||||
return '<{self.__class__.__name__}: "{self.name}" {self.balance} >'.format(
|
||||
self=self)
|
||||
|
||||
|
||||
def main():
|
||||
ledger = Ledger(ledger_file='non-profit-test-data.ledger')
|
||||
print(ledger.bal())
|
||||
print(ledger.reg())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
main()
|
32
non-profit-test-data.ledger
Normal file
32
non-profit-test-data.ledger
Normal file
|
@ -0,0 +1,32 @@
|
|||
|
||||
2010/01/01 Kindly T. Donor
|
||||
Income:Foo:Donation $-100.00
|
||||
;Invoice: Projects/Foo/Invoices/Invoice20100101.pdf
|
||||
Assets:Checking $100.00
|
||||
|
||||
|
||||
2011/03/15 Another J. Donor
|
||||
Income:Foo:Donation $-400.00
|
||||
;Approval: Projects/Foo/earmark-record.txt
|
||||
Assets:Checking $400.00
|
||||
|
||||
2011/04/20 (1) Baz Hosting Services, LLC
|
||||
Expenses:Foo:Hosting $250.00
|
||||
;Receipt: Projects/Foo/Expenses/hosting/AprilHostingReceipt.pdf
|
||||
Assets:Checking $-250.00
|
||||
|
||||
2011/05/10 Donation to General Fund
|
||||
Income:Donation $-50.00
|
||||
;Invoice: Financial/Invoices/Invoice20110510.pdf
|
||||
Assets:Checking $50.00
|
||||
|
||||
2011/04/20 (2) Baz Hosting Services, LLC
|
||||
Expenses:Blah:Hosting $250.00
|
||||
;Receipt: Projects/Blah/Expenses/hosting/AprilHostingReceipt.pdf
|
||||
;Invoice: Projects/Blah/Expenses/hosting/april-invoice.pdf
|
||||
Assets:Checking $-250.00
|
||||
;Statement: Financial/BankStuff/bank-statement.pdf
|
||||
|
||||
2011-04-25 A transaction with ISO date
|
||||
Income:Karma KARMA-10
|
||||
Assets:Karma Account KARMA 10
|
Loading…
Reference in a new issue