[ledgercli] Versioning, error handling
- Switched to passing the command via argv instead of stdin to ledger. We might as well as we don't use ledger's long-running mode in an effective manner. - Added version control of the ledger file using pygit2.A - Added error handling in the case of an unbalanced ledger, and cruder error handling in the case of any stderr output from ledger. - [web] Separated transaction_get into transaction_get and transaction_get_all.
This commit is contained in:
parent
4fce6b4fea
commit
fcec13c548
3 changed files with 95 additions and 119 deletions
|
@ -15,3 +15,11 @@ class AccountingException(Exception):
|
|||
|
||||
class TransactionNotFound(AccountingException):
|
||||
pass
|
||||
|
||||
|
||||
class LedgerNotBalanced(AccountingException):
|
||||
pass
|
||||
|
||||
|
||||
class TransactionIDCollision(AccountingException):
|
||||
pass
|
||||
|
|
|
@ -2,17 +2,20 @@
|
|||
# https://gitorious.org/conservancy/accounting-api
|
||||
# License: AGPLv3-or-later
|
||||
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import logging
|
||||
import time
|
||||
import re
|
||||
import pygit2
|
||||
|
||||
from datetime import datetime
|
||||
from xml.etree import ElementTree
|
||||
from contextlib import contextmanager
|
||||
|
||||
from accounting.exceptions import AccountingException, TransactionNotFound
|
||||
from accounting.exceptions import AccountingException, TransactionNotFound, \
|
||||
LedgerNotBalanced, TransactionIDCollision
|
||||
from accounting.models import Account, Transaction, Posting, Amount
|
||||
from accounting.storage import Storage
|
||||
|
||||
|
@ -31,138 +34,90 @@ class Ledger(Storage):
|
|||
self.ledger_file = ledger_file
|
||||
_log.info('ledger file: %s', ledger_file)
|
||||
|
||||
self.locked = False
|
||||
self.ledger_process = None
|
||||
try:
|
||||
self.repository = pygit2.Repository(
|
||||
os.path.join(os.path.dirname(self.ledger_file), '.git'))
|
||||
except KeyError:
|
||||
self.repository = None
|
||||
_log.warning('ledger_file directory does not contain a .git'
|
||||
' directory, will not track changes.')
|
||||
|
||||
@contextmanager
|
||||
def locked_process(self):
|
||||
r'''
|
||||
Context manager that checks that the ledger process is not already
|
||||
locked, then "locks" the process and yields the process handle and
|
||||
unlocks the process when execution is returned.
|
||||
|
||||
Since this decorated as a :func:`contextlib.contextmanager` the
|
||||
recommended use is with the ``with``-statement.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
with self.locked_process() as p:
|
||||
p.stdin.write(b'bal\n')
|
||||
|
||||
output = self.read_until_prompt(p)
|
||||
# The signature used as author and committer in git commits
|
||||
self.signature = pygit2.Signature(
|
||||
name='accounting-api',
|
||||
email='accounting-api@accounting.example')
|
||||
|
||||
def commit_changes(self, message):
|
||||
'''
|
||||
if self.locked:
|
||||
raise RuntimeError('The process has already been locked,'
|
||||
' something\'s out of order.')
|
||||
Commits any changes to :attr:`self.ledger_file` to the git repository
|
||||
'''
|
||||
if self.repository is None:
|
||||
return
|
||||
|
||||
# XXX: This code has no purpose in a single-threaded process
|
||||
timeout = 5 # Seconds
|
||||
# Add the ledger file
|
||||
self.repository.index.read()
|
||||
self.repository.index.add(os.path.basename(self.ledger_file))
|
||||
tree_id = self.repository.index.write_tree()
|
||||
self.repository.index.write()
|
||||
|
||||
for i in range(1, timeout + 2):
|
||||
if i > timeout:
|
||||
raise RuntimeError('Ledger process is already locked')
|
||||
parents = []
|
||||
try:
|
||||
parents.append(self.repository.head.target)
|
||||
except pygit2.GitError:
|
||||
_log.info('Repository has no head, creating initial commit')
|
||||
|
||||
if not self.locked:
|
||||
break
|
||||
else:
|
||||
_log.info('Waiting for one second... %d/%d', i, timeout)
|
||||
time.sleep(1)
|
||||
commit_id = self.repository.create_commit(
|
||||
'HEAD',
|
||||
self.signature,
|
||||
self.signature,
|
||||
message,
|
||||
tree_id,
|
||||
parents)
|
||||
|
||||
process = self.get_process()
|
||||
|
||||
self.locked = True
|
||||
_log.debug('Lock enabled')
|
||||
|
||||
yield process
|
||||
|
||||
self.locked = False
|
||||
_log.debug('Lock disabled')
|
||||
|
||||
def assemble_arguments(self):
|
||||
def assemble_arguments(self, command=None):
|
||||
'''
|
||||
Returns a list of arguments suitable for :class:`subprocess.Popen`
|
||||
based on :attr:`self.ledger_bin` and :attr:`self.ledger_file`.
|
||||
'''
|
||||
return [
|
||||
args = [
|
||||
self.ledger_bin,
|
||||
'-f',
|
||||
self.ledger_file,
|
||||
]
|
||||
if command is not None:
|
||||
args.append(command)
|
||||
|
||||
def init_process(self):
|
||||
return args
|
||||
|
||||
def send_command(self, command):
|
||||
'''
|
||||
Creates a new (presumably) ledger subprocess based on the args from
|
||||
:meth:`Ledger.assemble_arguments()` and then runs
|
||||
:meth:`Ledger.read_until_prompt()` once (which should return the banner
|
||||
text) and discards the output.
|
||||
Creates a new ledger process with the specified :data:`command` and
|
||||
returns the output.
|
||||
|
||||
Raises an :class:`~accounting.exceptions.AccountingException`-based
|
||||
Exception based on the ledger-cli stderr.
|
||||
'''
|
||||
_log.debug('Starting ledger process...')
|
||||
self.ledger_process = subprocess.Popen(
|
||||
self.assemble_arguments(),
|
||||
_log.debug('Sending command: %r', command)
|
||||
_log.debug('Starting ledger...')
|
||||
p = subprocess.Popen(
|
||||
self.assemble_arguments(command=command),
|
||||
stdout=subprocess.PIPE,
|
||||
stdin=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE)
|
||||
|
||||
# Swallow the banner
|
||||
with self.locked_process() as p:
|
||||
self.read_until_prompt(p)
|
||||
output = p.stdout.read()
|
||||
stderr = p.stderr.read().decode('utf8')
|
||||
|
||||
return self.ledger_process
|
||||
if stderr:
|
||||
lines = stderr.split('\n')
|
||||
if 'While balancing transaction from' in lines[1]:
|
||||
raise LedgerNotBalanced('\n'.join(lines[2:]))
|
||||
|
||||
def get_process(self):
|
||||
'''
|
||||
Returns :attr:`self.ledger_process` if it evaluates to ``True``. If
|
||||
:attr:`self.ledger_process` is not set the result of
|
||||
:meth:`self.init_process() <Ledger.init_process>` is returned.
|
||||
'''
|
||||
return self.ledger_process or self.init_process()
|
||||
raise AccountingException(stderr)
|
||||
|
||||
def read_until_prompt(self, process):
|
||||
r'''
|
||||
Reads from the subprocess instance :data:`process` until it finds a
|
||||
combination of ``\n]\x20`` (the prompt), then returns the output
|
||||
without the prompt.
|
||||
'''
|
||||
output = b''
|
||||
|
||||
while True:
|
||||
line = process.stdout.read(1) # XXX: This is a hack
|
||||
|
||||
if len(line) > 0:
|
||||
pass
|
||||
#_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, command):
|
||||
output = None
|
||||
|
||||
with self.locked_process() as p:
|
||||
if isinstance(command, str):
|
||||
command = command.encode('utf8')
|
||||
|
||||
_log.debug('Sending command: %r', command)
|
||||
|
||||
p.stdin.write(command + b'\n')
|
||||
p.stdin.flush()
|
||||
|
||||
output = self.read_until_prompt(p)
|
||||
|
||||
self.ledger_process.send_signal(subprocess.signal.SIGTERM)
|
||||
p.send_signal(subprocess.signal.SIGTERM)
|
||||
_log.debug('Waiting for ledger to shut down')
|
||||
self.ledger_process.wait()
|
||||
self.ledger_process = None
|
||||
p.wait()
|
||||
|
||||
return output
|
||||
|
||||
|
@ -177,6 +132,13 @@ class Ledger(Storage):
|
|||
_log.debug('No ID found. Generating an ID.')
|
||||
transaction.generate_id()
|
||||
|
||||
exists = self.get_transaction(transaction.id)
|
||||
|
||||
if exists is not None:
|
||||
raise TransactionIDCollision(
|
||||
'A transaction with the id %s already exists: %s' %
|
||||
(transaction.id, exists))
|
||||
|
||||
transaction.metadata.update({'Id': transaction.id})
|
||||
|
||||
transaction_template = ('\n{date} {t.payee}\n'
|
||||
|
@ -211,6 +173,8 @@ class Ledger(Storage):
|
|||
with open(self.ledger_file, 'ab') as f:
|
||||
f.write(output)
|
||||
|
||||
self.commit_changes('Added transaction %s' % transaction.id)
|
||||
|
||||
_log.info('Added transaction %s', transaction.id)
|
||||
|
||||
_log.debug('written to file: %s', output)
|
||||
|
@ -271,9 +235,6 @@ class Ledger(Storage):
|
|||
if transaction.id == transaction_id:
|
||||
return transaction
|
||||
|
||||
raise TransactionNotFound(
|
||||
'No transaction with id {0} found'.format(transaction_id))
|
||||
|
||||
def reg(self):
|
||||
output = self.send_command('xml')
|
||||
|
||||
|
@ -432,6 +393,8 @@ class Ledger(Storage):
|
|||
for line in lines:
|
||||
f.write(line)
|
||||
|
||||
self.commit_changes('Removed transaction %s' % transaction_id)
|
||||
|
||||
def update_transaction(self, transaction):
|
||||
'''
|
||||
Update a transaction in the ledger file.
|
||||
|
|
|
@ -63,21 +63,26 @@ def transaction_by_id_options(transaction_id=None):
|
|||
|
||||
|
||||
@app.route('/transaction', methods=['GET'])
|
||||
@cors()
|
||||
@jsonify_exceptions
|
||||
def transaction_get_all(transaction_id=None):
|
||||
'''
|
||||
Returns the JSON-serialized output of :meth:`accounting.Ledger.reg`
|
||||
'''
|
||||
return jsonify(transactions=app.ledger.get_transactions())
|
||||
|
||||
|
||||
@app.route('/transaction/<string:transaction_id>', methods=['GET'])
|
||||
@cors()
|
||||
@jsonify_exceptions
|
||||
def transaction_get(transaction_id=None):
|
||||
'''
|
||||
Returns the JSON-serialized output of :meth:`accounting.Ledger.reg`
|
||||
'''
|
||||
if transaction_id is None:
|
||||
return jsonify(transactions=app.ledger.get_transactions())
|
||||
transaction = app.ledger.get_transaction(transaction_id)
|
||||
|
||||
try:
|
||||
return jsonify(transaction=app.ledger.get_transaction(transaction_id))
|
||||
except TransactionNotFound:
|
||||
if transaction is None:
|
||||
abort(404)
|
||||
|
||||
return jsonify(transaction=transaction)
|
||||
|
||||
|
||||
@app.route('/transaction/<string:transaction_id>', methods=['POST'])
|
||||
@cors()
|
||||
|
|
Loading…
Reference in a new issue