Changeset - 6f2c875c7b76
[Not reviewed]
0 3 2
Joar Wandborg - 11 years ago 2013-12-10 23:25:16
joar@wandborg.se
Added /transaction endpoint
5 files changed with 125 insertions and 2 deletions:
0 comments (0 inline, 0 general)
accounting/__init__.py
Show inline comments
...
 
@@ -18,192 +18,216 @@ class 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:
 
            raise RuntimeError('The process has already been locked,'
 
                               ' something\'s out of order.')
 

	
 
            # XXX: This code has no purpose in a single-threaded process
 
            timeout = 5  # Seconds
 

	
 
            for i in range(1, timeout + 2):
 
                if i > timeout:
 
                    raise RuntimeError('Ledger process is already locked')
 

	
 
                if not self.locked:
 
                    break
 
                else:
 
                    _log.info('Waiting for one second... %d/%d', i, timeout)
 
                    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:
 
            line = p.stdout.read(1)  # XXX: This is a hack
 

	
 
            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, command):
 
        output = None
 

	
 
        with self.locked_process() as p:
 
            if isinstance(command, str):
 
                command = command.encode('utf8')
 

	
 
            p.stdin.write(command + b'\n')
 
            p.stdin.flush()
 

	
 
            output = self.read_until_prompt(p)
 

	
 
            self.ledger_process.send_signal(subprocess.signal.SIGTERM)
 
            _log.debug('Waiting for ledger to shut down')
 
            self.ledger_process.wait()
 
            self.ledger_process = None
 

	
 
            return output
 

	
 
    def add_transaction(self, transaction):
 
        transaction_template = ('\n{date} {t.payee}\n'
 
                                '{postings}')
 

	
 
        posting_template = ('  {account} {p.amount.symbol}'
 
                            ' {p.amount.amount}\n')
 

	
 
        output  = b''
 

	
 
        output += transaction_template.format(
 
            date=transaction.date.strftime('%Y-%m-%d'),
 
            t=transaction,
 
            postings=''.join([posting_template.format(
 
                p=p,
 
                account=p.account + ' ' * (
 
                    80 - (len(p.account) + len(p.amount.symbol) +
 
                    len(p.amount.amount) + 1 + 2)
 
                )) for p in transaction.postings])).encode('utf8')
 

	
 
        with open(self.ledger_file, 'ab') as f:
 
            f.write(output)
 

	
 
        _log.debug('written to file: %s', output)
 

	
 
    def bal(self):
 
        output = self.send_command('xml')
 

	
 
        if output is None:
 
            raise RuntimeError('bal call returned no output')
 

	
 
        accounts = []
 

	
 
        xml = ElementTree.fromstring(output.decode('utf8'))
 

	
 
        accounts = self._recurse_accounts(xml.find('./accounts'))
 

	
 
        return accounts
 

	
 
    def _recurse_accounts(self, root):
 
        accounts = []
 

	
 
        for account in root.findall('./account'):
 
            name = account.find('./fullname').text
 

	
 
            amounts = []
 

	
 
            # Try to find an account total value, then try to find the account
 
            # balance
 
            account_amounts = account.findall(
 
                './account-total/balance/amount') or \
 
                    account.findall('./account-amount/amount') or \
 
                    account.findall('./account-total/amount')
 

	
 
            if account_amounts:
 
                for amount in account_amounts:
 
                    quantity = amount.find('./quantity').text
 
                    symbol = amount.find('./commodity/symbol').text
 

	
 
                    amounts.append(Amount(amount=quantity, symbol=symbol))
 
            else:
 
                _log.warning('Account %s does not have any amounts', name)
 

	
 
            accounts.append(Account(name=name,
 
                                    amounts=amounts,
 
                                    accounts=self._recurse_accounts(account)))
 

	
 
        return accounts
 

	
 
    def reg(self):
 
        output = self.send_command('xml')
 

	
 
        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(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.strftime('%Y-%m-%d'))
 

	
 

	
 
class Posting:
 
    def __init__(self, account=None, amount=None):
 
        self.account = account
 
        self.amount = amount
 

	
 
    def __repr__(self):
accounting/decorators.py
Show inline comments
 
new file 100644
 
from functools import wraps
 

	
 
from flask import jsonify
 

	
 
from accounting.exceptions import AccountingException
 

	
 

	
 
def jsonify_exceptions(func):
 
    '''
 
    Wraps a Flask endpoint and catches any AccountingException-based
 
    exceptions which are returned to the client as JSON.
 
    '''
 
    @wraps(func)
 
    def wrapper(*args, **kw):
 
        try:
 
            return func(*args, **kw)
 
        except AccountingException as exc:
 
            return jsonify(error=exc)
 

	
 
    return wrapper
accounting/exceptions.py
Show inline comments
 
new file 100644
 
class AccountingException(Exception):
 
    '''
 
    Used as a base for exceptions that are returned to the caller via the
 
    jsonify_exceptions decorator
 
    '''
 
    pass
accounting/transport.py
Show inline comments
 
from datetime import datetime
 

	
 
from flask import json
 

	
 
from accounting import Amount, Transaction, Posting, Account
 

	
 
class AccountingEncoder(json.JSONEncoder):
 
    def default(self, o):
 
        if isinstance(o, Account):
 
            return dict(
 
                __type__=o.__class__.__name__,
 
                name=o.name,
 
                amounts=o.amounts,
 
                accounts=o.accounts
 
            )
 
        elif isinstance(o, Transaction):
 
            return dict(
 
                __type__=o.__class__.__name__,
 
                date=o.date.strftime('%Y-%m-%d'),
 
                payee=o.payee,
 
                postings=o.postings
 
            )
 
        elif isinstance(o, Posting):
 
            return dict(
 
                __type__=o.__class__.__name__,
 
                account=o.account,
 
                amount=o.amount,
 
            )
 
        elif isinstance(o, Amount):
 
            return dict(
 
                __type__=o.__class__.__name__,
 
                amount=o.amount,
 
                symbol=o.symbol
 
            )
 
        elif isinstance(o, Exception):
 
            return dict(
 
                __type__=o.__class__.__name__,
 
                args=o.args
 
            )
 

	
 
        return json.JSONEncoder.default(self, o)
 

	
 
class AccountingDecoder(json.JSONDecoder):
 
    def __init__(self):
 
        json.JSONDecoder.__init__(self, object_hook=self.dict_to_object)
 

	
 
    def dict_to_object(self, d):
 
        if '__type__' not in d:
 
            return d
 

	
 
        types = {c.__name__ : c for c in [Amount, Transaction, Posting,
 
                                          Account]}
 

	
 
        _type = d.pop('__type__')
 

	
 
        if _type == 'Transaction':
 
            d['date'] = datetime.strptime(d['date'], '%Y-%m-%d')
 

	
 
        return types[_type](**d)
accounting/web.py
Show inline comments
 
import sys
 
import logging
 
import argparse
 

	
 
from flask import Flask, g, jsonify, json, request
 
from flask import Flask, jsonify, request
 

	
 
from accounting import Ledger, Account, Posting, Transaction, Amount
 
from accounting import Ledger
 
from accounting.transport import AccountingEncoder, AccountingDecoder
 
from accounting.exceptions import AccountingException
 
from accounting.decorators import jsonify_exceptions
 

	
 

	
 
app = Flask('accounting')
 
app.config.from_pyfile('config.py')
 

	
 
ledger = Ledger(ledger_file=app.config['LEDGER_FILE'])
 

	
 

	
 
# These will convert output from our internal classes to JSON and back
 
app.json_encoder = AccountingEncoder
 
app.json_decoder = AccountingDecoder
 

	
 

	
 
@app.route('/')
 
def index():
 
    return 'Hello World!'
 

	
 

	
 
@app.route('/balance')
 
def balance_report():
 
    ''' Returns the balance report from ledger '''
 
    report_data = ledger.bal()
 

	
 
    return jsonify(balance_report=report_data)
 

	
 

	
 
@app.route('/transaction', methods=['POST'])
 
@jsonify_exceptions
 
def transaction():
 
    '''
 
    REST/JSON endpoint for transactions.
 

	
 
    Current state:
 

	
 
    Takes a POST request with a ``transactions`` JSON payload and writes it to
 
    the ledger file.
 

	
 
    Requires the ``transactions`` payload to be __type__-annotated:
 

	
 
    .. code-block:: json
 

	
 
        {
 
          "transactions": [
 
            {
 
              "__type__": "Transaction",
 
              "date": "2013-01-01",
 
              "payee": "Kindly T. Donor",
 
              "postings": [
 
                {
 
                  "__type__": "Posting",
 
                  "account": "Income:Foo:Donation",
 
                  "amount": {
 
                    "__type__": "Amount",
 
                    "amount": "-100",
 
                    "symbol": "$"
 
                  }
 
                },
 
                {
 
                  "__type__": "Posting",
 
                  "account": "Assets:Checking",
 
                  "amount": {
 
                    "__type__": "Amount",
 
                    "amount": "100",
 
                    "symbol": "$"
 
                  }
 
                }
 
              ]
 
            }
 
        }
 

	
 
    becomes::
 

	
 
        2013-01-01 Kindly T. Donor
 
          Income:Foo:Donation                                         $ -100
 
          Assets:Checking                                              $ 100
 
    '''
 
    transactions = request.json.get('transactions')
 

	
 
    if not transactions:
 
        raise AccountingException('No transaction data provided')
 

	
 
    for transaction in transactions:
 
        ledger.add_transaction(transaction)
 

	
 
    return jsonify(foo='bar')
 

	
 

	
 
@app.route('/parse-json', methods=['POST'])
 
def parse_json():
 
    r'''
 
    Parses a __type__-annotated JSON payload and debug-logs the decoded version
 
    of it.
 

	
 
    Example:
 

	
 
        wget http://127.0.0.1:5000/balance -O balance.json
 
        curl -X POST -H 'Content-Type: application/json' -d @balance.json \
 
            http://127.0.0.1/parse-json
 
        # Logging output (linebreaks added for clarity)
 
        DEBUG:accounting:json data: {'balance_report':
 
            [<Account "None" [
 
                <Amount $ 0>, <Amount KARMA 0>]
 
                [<Account "Assets" [
 
                    <Amount $ 50>, <Amount KARMA 10>]
 
                    [<Account "Assets:Checking" [
 
                        <Amount $ 50>] []>,
 
                     <Account "Assets:Karma Account" [
 
                        <Amount KARMA 10>] []>]>,
 
                 <Account "Expenses" [
 
                    <Amount $ 500>]
 
                    [<Account "Expenses:Blah" [
 
                        <Amount $ 250>]
 
                        [<Account "Expenses:Blah:Hosting" [
 
                            <Amount $ 250>] []>]>,
 
                     <Account "Expenses:Foo" [
 
                        <Amount $ 250>] [
 
                        <Account "Expenses:Foo:Hosting" [
 
                            <Amount $ 250>] []>]>]>,
 
                 <Account "Income" [
 
                    <Amount $ -550>,
 
                    <Amount KARMA -10>]
 
                    [<Account "Income:Donation" [
 
                        <Amount $ -50>] []>,
 
                     <Account "Income:Foo" [
 
                        <Amount $ -500>]
 
                        [<Account "Income:Foo:Donation" [
 
                            <Amount $ -500>] []>]>,
 
                     <Account "Income:Karma" [
 
                     <Amount KARMA -10>] []>]>]>]}
 
    '''
 
    app.logger.debug('json data: %s', request.json)
 
    return jsonify(foo='bar')
 

	
 

	
 
@app.route('/register')
 
def register_report():
 
    ''' Returns the register report from ledger '''
 
    report_data = ledger.reg()
 

	
 
    return jsonify(register_report=report_data)
 

	
 

	
 
def main(argv=None):
 
    prog = __name__
 
    if argv is None:
 
        prog = sys.argv[0]
 
        argv = sys.argv[1:]
 

	
 
    parser = argparse.ArgumentParser(prog=prog)
 
    parser.add_argument('-v', '--verbosity',
 
                        default='INFO',
 
                        help=('Filter logging output. Possible values:' +
 
                        ' CRITICAL, ERROR, WARNING, INFO, DEBUG'))
 

	
 
    args = parser.parse_args(argv)
 

	
 
    logging.basicConfig(level=getattr(logging, args.verbosity, 'INFO'))
 

	
 
    app.run(host=app.config['HOST'], port=app.config['PORT'])
 

	
 
if __name__ == '__main__':
 
    main()
0 comments (0 inline, 0 general)