A simple script to convert an (uncompressed) gnucash XML file to the ledger-cli format
#! /usr/bin/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('/') | |
# Decimal points are a little annoying, since I don't want to convert | |
# to numbers to avoid loosing precision | |
if decPoint == '100': | |
signFlag = intValue.startswith('-') | |
if signFlag: | |
intValue = intValue[1:] | |
if len(intValue) < 3: | |
intValue = '0'*(3-len(intValue)) + intValue | |
if signFlag: | |
intValue = '-' + intValue | |
return intValue[:-2] + '.' + intValue[-2:] | |
else: | |
raise Exception('Do not know how to deal with other fractional ' | |
'values') | |
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) |
This comment has been minimized.
This comment has been minimized.
Thanks @nonducor for the snippet, really helped. Thanks @bitcracker I applied this same patch above since also my commodities were reported with more decimal digits, such as 1 or 10000 |
This comment has been minimized.
This comment has been minimized.
Nice script, thanks - looks like it will be useful to allow me to automate reports from GnuCash. The following patch will add the GnuCash split 'Memo' field as a ledger diff --git a/gcash2ledger.py b/gcash2ledger.py
--- a/gcash2ledger.py
+++ b/gcash2ledger.py
@@ -107,6 +107,7 @@ class Split:
self.accountDb = accountDb
self.reconciled = e.find('split:reconciled-state', nss).text == 'y'
self.accountId = e.find('split:account', nss).text
+ self.memo = e.find('split:memo', nss)
accountDb[self.accountId].used = True
# Some special treatment for value and quantity
@@ -121,7 +122,7 @@ class Split:
return self.accountDb[self.accountId]
def toLedgerFormat(self, commodity='$', indent=0):
- outPattern = '{spaces} {flag}{accountName} {value}'
+ outPattern = '{spaces} {flag}{accountName} {value}{memo}'
# Check if commodity conversion will be needed
conversion = ''
@@ -136,11 +137,16 @@ class Split:
value=realValue,
commodity=commodity)
+ memo = ''
+ if self.memo is not None:
+ memo = ' ; Payee: ' + self.memo.text
+
return outPattern.format(flag='* ' if self.reconciled else '',
spaces=indent*' ',
accountName=self.getAccount().fullName(),
conversion=conversion,
- value=value)
+ value=value,
+ memo=memo)
def convertValue(self, rawValue):
(intValue, decPoint) = rawValue.split('/')
|
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
This comment has been minimized.
I found the following diff useful:
"#!/usr/bin/env python3" should get the right python3.
Allow arbitrary precision (my mutual funds required more than 2 decimal digits).