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