[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): | class TransactionNotFound(AccountingException): | ||||||
|     pass |     pass | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class LedgerNotBalanced(AccountingException): | ||||||
|  |     pass | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class TransactionIDCollision(AccountingException): | ||||||
|  |     pass | ||||||
|  |  | ||||||
|  | @ -2,17 +2,20 @@ | ||||||
| # https://gitorious.org/conservancy/accounting-api | # https://gitorious.org/conservancy/accounting-api | ||||||
| # License: AGPLv3-or-later | # License: AGPLv3-or-later | ||||||
| 
 | 
 | ||||||
|  | import os | ||||||
| import sys | import sys | ||||||
| import subprocess | import subprocess | ||||||
| import logging | import logging | ||||||
| import time | import time | ||||||
| import re | import re | ||||||
|  | import pygit2 | ||||||
| 
 | 
 | ||||||
| from datetime import datetime | from datetime import datetime | ||||||
| from xml.etree import ElementTree | from xml.etree import ElementTree | ||||||
| from contextlib import contextmanager | 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.models import Account, Transaction, Posting, Amount | ||||||
| from accounting.storage import Storage | from accounting.storage import Storage | ||||||
| 
 | 
 | ||||||
|  | @ -31,141 +34,93 @@ class Ledger(Storage): | ||||||
|         self.ledger_file = ledger_file |         self.ledger_file = ledger_file | ||||||
|         _log.info('ledger file: %s', ledger_file) |         _log.info('ledger file: %s', ledger_file) | ||||||
| 
 | 
 | ||||||
|         self.locked = False |         try: | ||||||
|         self.ledger_process = None |             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 |         # The signature used as author and committer in git commits | ||||||
|     def locked_process(self): |         self.signature = pygit2.Signature( | ||||||
|         r''' |             name='accounting-api', | ||||||
|         Context manager that checks that the ledger process is not already |             email='accounting-api@accounting.example') | ||||||
|         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) |  | ||||||
| 
 | 
 | ||||||
|  |     def commit_changes(self, message): | ||||||
|         ''' |         ''' | ||||||
|         if self.locked: |         Commits any changes to :attr:`self.ledger_file` to the git repository | ||||||
|             raise RuntimeError('The process has already been locked,' |         ''' | ||||||
|                                ' something\'s out of order.') |         if self.repository is None: | ||||||
|  |             return | ||||||
| 
 | 
 | ||||||
|             # XXX: This code has no purpose in a single-threaded process |         # Add the ledger file | ||||||
|             timeout = 5  # Seconds |         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): |         parents = [] | ||||||
|                 if i > timeout: |         try: | ||||||
|                     raise RuntimeError('Ledger process is already locked') |             parents.append(self.repository.head.target) | ||||||
|  |         except pygit2.GitError: | ||||||
|  |             _log.info('Repository has no head, creating initial commit') | ||||||
| 
 | 
 | ||||||
|                 if not self.locked: |         commit_id = self.repository.create_commit( | ||||||
|                     break |             'HEAD', | ||||||
|                 else: |             self.signature, | ||||||
|                     _log.info('Waiting for one second... %d/%d', i, timeout) |             self.signature, | ||||||
|                     time.sleep(1) |             message, | ||||||
|  |             tree_id, | ||||||
|  |             parents) | ||||||
| 
 | 
 | ||||||
|         process = self.get_process() |     def assemble_arguments(self, command=None): | ||||||
| 
 |  | ||||||
|         self.locked = True |  | ||||||
|         _log.debug('Lock enabled') |  | ||||||
| 
 |  | ||||||
|         yield process |  | ||||||
| 
 |  | ||||||
|         self.locked = False |  | ||||||
|         _log.debug('Lock disabled') |  | ||||||
| 
 |  | ||||||
|     def assemble_arguments(self): |  | ||||||
|         ''' |         ''' | ||||||
|         Returns a list of arguments suitable for :class:`subprocess.Popen` |         Returns a list of arguments suitable for :class:`subprocess.Popen` | ||||||
|         based on :attr:`self.ledger_bin` and :attr:`self.ledger_file`. |         based on :attr:`self.ledger_bin` and :attr:`self.ledger_file`. | ||||||
|         ''' |         ''' | ||||||
|         return [ |         args = [ | ||||||
|             self.ledger_bin, |             self.ledger_bin, | ||||||
|             '-f', |             '-f', | ||||||
|             self.ledger_file, |             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 |         Creates a new ledger process with the specified :data:`command` and | ||||||
|         :meth:`Ledger.assemble_arguments()` and then runs |         returns the output. | ||||||
|         :meth:`Ledger.read_until_prompt()` once (which should return the banner | 
 | ||||||
|         text) and discards the output. |         Raises an :class:`~accounting.exceptions.AccountingException`-based | ||||||
|  |         Exception based on the ledger-cli stderr. | ||||||
|         ''' |         ''' | ||||||
|         _log.debug('Starting ledger process...') |         _log.debug('Sending command: %r', command) | ||||||
|         self.ledger_process = subprocess.Popen( |         _log.debug('Starting ledger...') | ||||||
|             self.assemble_arguments(), |         p = subprocess.Popen( | ||||||
|  |             self.assemble_arguments(command=command), | ||||||
|             stdout=subprocess.PIPE, |             stdout=subprocess.PIPE, | ||||||
|             stdin=subprocess.PIPE, |             stdin=subprocess.PIPE, | ||||||
|             stderr=subprocess.PIPE) |             stderr=subprocess.PIPE) | ||||||
| 
 | 
 | ||||||
|         # Swallow the banner |         output = p.stdout.read() | ||||||
|         with self.locked_process() as p: |         stderr = p.stderr.read().decode('utf8') | ||||||
|             self.read_until_prompt(p) |  | ||||||
| 
 | 
 | ||||||
|         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): |             raise AccountingException(stderr) | ||||||
|         ''' |  | ||||||
|         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() |  | ||||||
| 
 | 
 | ||||||
|     def read_until_prompt(self, process): |         p.send_signal(subprocess.signal.SIGTERM) | ||||||
|         r''' |         _log.debug('Waiting for ledger to shut down') | ||||||
|         Reads from the subprocess instance :data:`process` until it finds a |         p.wait() | ||||||
|         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 |         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) |  | ||||||
|             _log.debug('Waiting for ledger to shut down') |  | ||||||
|             self.ledger_process.wait() |  | ||||||
|             self.ledger_process = None |  | ||||||
| 
 |  | ||||||
|             return output |  | ||||||
| 
 |  | ||||||
|     def add_transaction(self, transaction): |     def add_transaction(self, transaction): | ||||||
|         ''' |         ''' | ||||||
|         Writes a transaction to the ledger file by opening it in 'ab' mode and |         Writes a transaction to the ledger file by opening it in 'ab' mode and | ||||||
|  | @ -177,6 +132,13 @@ class Ledger(Storage): | ||||||
|             _log.debug('No ID found. Generating an ID.') |             _log.debug('No ID found. Generating an ID.') | ||||||
|             transaction.generate_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.metadata.update({'Id': transaction.id}) | ||||||
| 
 | 
 | ||||||
|         transaction_template = ('\n{date} {t.payee}\n' |         transaction_template = ('\n{date} {t.payee}\n' | ||||||
|  | @ -211,6 +173,8 @@ class Ledger(Storage): | ||||||
|         with open(self.ledger_file, 'ab') as f: |         with open(self.ledger_file, 'ab') as f: | ||||||
|             f.write(output) |             f.write(output) | ||||||
| 
 | 
 | ||||||
|  |         self.commit_changes('Added transaction %s' % transaction.id) | ||||||
|  | 
 | ||||||
|         _log.info('Added transaction %s', transaction.id) |         _log.info('Added transaction %s', transaction.id) | ||||||
| 
 | 
 | ||||||
|         _log.debug('written to file: %s', output) |         _log.debug('written to file: %s', output) | ||||||
|  | @ -271,9 +235,6 @@ class Ledger(Storage): | ||||||
|             if transaction.id == transaction_id: |             if transaction.id == transaction_id: | ||||||
|                 return transaction |                 return transaction | ||||||
| 
 | 
 | ||||||
|         raise TransactionNotFound( |  | ||||||
|             'No transaction with id {0} found'.format(transaction_id)) |  | ||||||
| 
 |  | ||||||
|     def reg(self): |     def reg(self): | ||||||
|         output = self.send_command('xml') |         output = self.send_command('xml') | ||||||
| 
 | 
 | ||||||
|  | @ -432,6 +393,8 @@ class Ledger(Storage): | ||||||
|             for line in lines: |             for line in lines: | ||||||
|                 f.write(line) |                 f.write(line) | ||||||
| 
 | 
 | ||||||
|  |         self.commit_changes('Removed transaction %s' % transaction_id) | ||||||
|  | 
 | ||||||
|     def update_transaction(self, transaction): |     def update_transaction(self, transaction): | ||||||
|         ''' |         ''' | ||||||
|         Update a transaction in the ledger file. |         Update a transaction in the ledger file. | ||||||
|  |  | ||||||
|  | @ -63,21 +63,26 @@ def transaction_by_id_options(transaction_id=None): | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @app.route('/transaction', methods=['GET']) | @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']) | @app.route('/transaction/<string:transaction_id>', methods=['GET']) | ||||||
| @cors() | @cors() | ||||||
| @jsonify_exceptions | @jsonify_exceptions | ||||||
| def transaction_get(transaction_id=None): | def transaction_get(transaction_id=None): | ||||||
|     ''' |     transaction = app.ledger.get_transaction(transaction_id) | ||||||
|     Returns the JSON-serialized output of :meth:`accounting.Ledger.reg` |  | ||||||
|     ''' |  | ||||||
|     if transaction_id is None: |  | ||||||
|         return jsonify(transactions=app.ledger.get_transactions()) |  | ||||||
| 
 | 
 | ||||||
|     try: |     if transaction is None: | ||||||
|         return jsonify(transaction=app.ledger.get_transaction(transaction_id)) |  | ||||||
|     except TransactionNotFound: |  | ||||||
|         abort(404) |         abort(404) | ||||||
| 
 | 
 | ||||||
|  |     return jsonify(transaction=transaction) | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
| @app.route('/transaction/<string:transaction_id>', methods=['POST']) | @app.route('/transaction/<string:transaction_id>', methods=['POST']) | ||||||
| @cors() | @cors() | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		
		Reference in a new issue
	
	 Joar Wandborg
						Joar Wandborg