-
-
Save zertrin/ce9f32499c626f0c721869ad31855ec6 to your computer and use it in GitHub Desktop.
A simple script to convert an (uncompressed) gnucash XML file to the ledger-cli format
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
import os | |
import sys | |
import dateutil.parser | |
import xml.etree.ElementTree | |
nss = {'gnc': 'http://www.gnucash.org/XML/gnc', | |
'act': 'http://www.gnucash.org/XML/act', | |
'book': 'http://www.gnucash.org/XML/book', | |
'cd': 'http://www.gnucash.org/XML/cd', | |
'cmdty': 'http://www.gnucash.org/XML/cmdty', | |
'price': 'http://www.gnucash.org/XML/price', | |
'slot': 'http://www.gnucash.org/XML/slot', | |
'split': 'http://www.gnucash.org/XML/split', | |
'sx': 'http://www.gnucash.org/XML/sx', | |
'trn': 'http://www.gnucash.org/XML/trn', | |
'ts': 'http://www.gnucash.org/XML/ts', | |
'fs': 'http://www.gnucash.org/XML/fs', | |
'bgt': 'http://www.gnucash.org/XML/bgt', | |
'recurrence': 'http://www.gnucash.org/XML/recurrence', | |
'lot': 'http://www.gnucash.org/XML/lot', | |
'addr': 'http://www.gnucash.org/XML/addr', | |
'owner': 'http://www.gnucash.org/XML/owner', | |
'billterm': 'http://www.gnucash.org/XML/billterm', | |
'bt-days': 'http://www.gnucash.org/XML/bt-days', | |
'bt-prox': 'http://www.gnucash.org/XML/bt-prox', | |
'cust': 'http://www.gnucash.org/XML/cust', | |
'employee': 'http://www.gnucash.org/XML/employee', | |
'entry': 'http://www.gnucash.org/XML/entry', | |
'invoice': 'http://www.gnucash.org/XML/invoice', | |
'job': 'http://www.gnucash.org/XML/job', | |
'order': 'http://www.gnucash.org/XML/order', | |
'taxtable': 'http://www.gnucash.org/XML/taxtable', | |
'tte': 'http://www.gnucash.org/XML/tte', | |
'vendor': 'http://www.gnucash.org/XML/vendor', } | |
class DefaultAttributeProducer: | |
def __init__(self, defaultValue): | |
self.__defaultValue = defaultValue | |
def __getattr__(self, value): | |
return self.__defaultValue | |
def orElse(var, default=''): | |
if var is None: | |
return DefaultAttributeProducer(default) | |
else: | |
return var | |
class Commodity: | |
def __init__(self, e): | |
"""From a XML e representing a commodity, generates a representation of | |
the commodity | |
""" | |
self.space = orElse(e.find('cmdty:space', nss)).text | |
self.id = orElse(e.find('cmdty:id', nss)).text | |
self.name = orElse(e.find('cmdty:name', nss)).text | |
def toLedgerFormat(self, indent=0): | |
"""Format the commodity in a way good to be interpreted by ledger. | |
If provided, `indent` will be the indentation (in spaces) of the entry. | |
""" | |
outPattern = ('{spaces}commodity {id}\n' | |
'{spaces} note {name} ({space}:{id})\n') | |
return outPattern.format(spaces=' '*indent, **self.__dict__) | |
class Account: | |
def __init__(self, accountDb, e): | |
self.accountDb = accountDb | |
self.name = e.find('act:name', nss).text | |
self.id = e.find('act:id', nss).text | |
self.accountDb[self.id] = self | |
self.description = orElse(e.find('act:description', nss)).text | |
self.type = e.find('act:type', nss).text | |
self.parent = orElse(e.find('act:parent', nss), None).text | |
self.used = False # Mark accounts that were in a transaction | |
self.commodity = orElse(e.find('act:commodity/cmdty:id', nss), None).text | |
def getParent(self): | |
return self.accountDb[self.parent] | |
def fullName(self): | |
if self.parent is not None and self.getParent().type != 'ROOT': | |
prefix = self.getParent().fullName() + ':' | |
else: | |
prefix = '' # ROOT will not be displayed | |
return prefix + self.name | |
def toLedgerFormat(self, indent=0): | |
outPattern = ('{spaces}account {fullName}\n' | |
'{spaces} note {description} (type: {type})\n') | |
return outPattern.format(spaces=' '*indent, fullName=self.fullName(), | |
**self.__dict__) | |
class Split: | |
"""Represents a single split in a transaction""" | |
def __init__(self, accountDb, e): | |
self.accountDb = accountDb | |
self.reconciled = e.find('split:reconciled-state', nss).text == 'y' | |
self.accountId = e.find('split:account', nss).text | |
accountDb[self.accountId].used = True | |
# Some special treatment for value and quantity | |
rawValue = e.find('split:value', nss).text | |
self.value = self.convertValue(rawValue) | |
# Quantity is the amount on the commodity of the account | |
rawQuantity = e.find('split:quantity', nss).text | |
self.quantity = self.convertValue(rawQuantity) | |
def getAccount(self): | |
return self.accountDb[self.accountId] | |
def toLedgerFormat(self, commodity='$', indent=0): | |
outPattern = '{spaces} {flag}{accountName} {value}' | |
# Check if commodity conversion will be needed | |
conversion = '' | |
if commodity == self.getAccount().commodity: | |
value = '{value} {commodity}'.format(commodity=commodity, | |
value=self.value) | |
else: | |
conversion = ' {destValue} "{destCmdty}" @@ {value} {commodity}' | |
realValue = self.value[1:] if self.value.startswith('-') else self.value | |
value = conversion.format(destValue=self.quantity, | |
destCmdty=self.getAccount().commodity, | |
value=realValue, | |
commodity=commodity) | |
return outPattern.format(flag='* ' if self.reconciled else '', | |
spaces=indent*' ', | |
accountName=self.getAccount().fullName(), | |
conversion=conversion, | |
value=value) | |
def convertValue(self, rawValue): | |
(intValue, decPoint) = rawValue.split('/') | |
n = len(decPoint) - 1 | |
signFlag = intValue.startswith('-') | |
if signFlag: | |
intValue = intValue[1:] | |
if len(intValue) < n+1: | |
intValue = '0'*(n+1-len(intValue)) + intValue | |
if signFlag: | |
intValue = '-' + intValue | |
return intValue[:-n] + '.' + intValue[-n:] | |
class Transaction: | |
def __init__(self, accountDb, e): | |
self.accountDb = accountDb | |
self.date = dateutil.parser.parse(e.find('trn:date-posted/ts:date', | |
nss).text) | |
self.commodity = e.find('trn:currency/cmdty:id', nss).text | |
self.description = e.find('trn:description', nss).text | |
self.splits = [Split(accountDb, s) | |
for s in e.findall('trn:splits/trn:split', nss)] | |
def toLedgerFormat(self, indent=0): | |
outPattern = ('{spaces}{date} {description}\n' | |
'{splits}\n') | |
splits = '\n'.join(s.toLedgerFormat(self.commodity, indent) | |
for s in self.splits) | |
return outPattern.format( | |
spaces=' '*indent, | |
date=self.date.strftime('%Y/%m/%d'), | |
description=self.description, | |
splits=splits) | |
def convert2Ledger(inputFile): | |
"""Reads a XML file and converts it to a ledger file.""" | |
e = xml.etree.ElementTree.parse(inputFile).getroot() | |
b = e.find('gnc:book', nss) | |
# Find all commodities | |
commodities = [] | |
for cmdty in b.findall('gnc:commodity', nss): | |
commodities.append(Commodity(cmdty)) | |
# Find all accounts | |
accountDb = {} | |
for acc in b.findall('gnc:account', nss): | |
Account(accountDb, acc) | |
# Finally, find all transactions | |
transactions = [] | |
for xact in b.findall('gnc:transaction', nss): | |
transactions.append(Transaction(accountDb, xact)) | |
# Generate output | |
output = '' | |
# First, add the commodities definition | |
output = '\n'.join(c.toLedgerFormat() for c in commodities) | |
output += '\n\n' | |
# Then, output all accounts | |
output += '\n'.join(a.toLedgerFormat() | |
for a in accountDb.values() if a.used) | |
output += '\n\n' | |
# And finally, output all transactions | |
output += '\n'.join(t.toLedgerFormat() | |
for t in sorted(transactions, key=lambda x: x.date)) | |
return (output, commodities, accountDb, transactions) | |
if __name__ == '__main__': | |
if len(sys.argv) not in (2, 3): | |
print('Usage: gcash2ledger.py inputXMLFile [outputLedgedFile]\n') | |
print('If output is not provided, output to stdout') | |
print('If output exists, it will not be overwritten.') | |
exit(1) | |
if len(sys.argv) == 3 and os.path.exists(sys.argv[2]): | |
print('Output file exists. It will not be overwritten.') | |
exit(2) | |
(data, commodities, accountDb, transactions) = convert2Ledger(sys.argv[1]) | |
if len(sys.argv) == 3: | |
with open(sys.argv[2], 'w') as fh: | |
fh.write(data) | |
else: | |
print(data) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment