summaryrefslogtreecommitdiff
path: root/ofxparse
diff options
context:
space:
mode:
authorAndrew Shadura <andrewsh@debian.org>2016-10-19 18:02:05 +0200
committerAndrew Shadura <andrewsh@debian.org>2016-10-19 18:02:05 +0200
commitd8216aeb9c12ea81d9941edc6eff39be32c24aca (patch)
tree1ad1a1dd693ba915dcda076adf037964c9d6132f /ofxparse
parentbdbd753fe4ab2de979939bae4caf93b16f6b5efc (diff)
Imported Upstream version 0.15
Diffstat (limited to 'ofxparse')
-rw-r--r--ofxparse/__init__.py3
-rw-r--r--ofxparse/ofxparse.py337
-rw-r--r--ofxparse/ofxprinter.py194
-rw-r--r--ofxparse/ofxutil.py49
4 files changed, 475 insertions, 108 deletions
diff --git a/ofxparse/__init__.py b/ofxparse/__init__.py
index f1fb25b..a08ba77 100644
--- a/ofxparse/__init__.py
+++ b/ofxparse/__init__.py
@@ -1,5 +1,6 @@
from __future__ import absolute_import
from .ofxparse import OfxParser, AccountType, Account, Statement, Transaction
+from .ofxprinter import OfxPrinter
-__version__ = '0.14'
+__version__ = '0.15'
diff --git a/ofxparse/ofxparse.py b/ofxparse/ofxparse.py
index a66df88..5686030 100644
--- a/ofxparse/ofxparse.py
+++ b/ofxparse/ofxparse.py
@@ -22,13 +22,37 @@ else:
from . import mcc
+
+def skip_headers(fh):
+ '''
+ Prepare `fh` for parsing by BeautifulSoup by skipping its OFX
+ headers.
+ '''
+ if fh is None or isinstance(fh, six.string_types):
+ return
+ fh.seek(0)
+ header_re = re.compile(r"^\s*\w+:\s*\w+\s*$")
+ while True:
+ pos = fh.tell()
+ line = fh.readline()
+ if not line:
+ break
+ if header_re.search(line) is None:
+ fh.seek(pos)
+ return
+
+
def soup_maker(fh):
+ skip_headers(fh)
try:
from bs4 import BeautifulSoup
- return BeautifulSoup(fh)
+ soup = BeautifulSoup(fh, "xml")
+ for tag in soup.findAll():
+ tag.name = tag.name.lower()
except ImportError:
from BeautifulSoup import BeautifulStoneSoup
- return BeautifulStoneSoup(fh)
+ soup = BeautifulStoneSoup(fh)
+ return soup
def try_decode(string, encoding):
@@ -36,11 +60,13 @@ def try_decode(string, encoding):
string = string.decode(encoding)
return string
+
def is_iterable(candidate):
- if sys.version_info < (2,6):
+ if sys.version_info < (2, 6):
return hasattr(candidate, 'next')
return isinstance(candidate, collections.Iterable)
+
@contextlib.contextmanager
def save_pos(fh):
"""
@@ -54,6 +80,7 @@ def save_pos(fh):
finally:
fh.seek(orig_pos)
+
class OfxFile(object):
def __init__(self, fh):
"""
@@ -76,7 +103,7 @@ class OfxFile(object):
head_data = self.fh.read(1024 * 10)
head_data = head_data[:head_data.find(six.b('<'))]
- for line in re.split(six.b('\r?\n?'), head_data):
+ for line in head_data.splitlines():
# Newline?
if line.strip() == six.b(""):
break
@@ -138,7 +165,7 @@ class OfxFile(object):
class OfxPreprocessedFile(OfxFile):
def __init__(self, fh):
- super(OfxPreprocessedFile,self).__init__(fh)
+ super(OfxPreprocessedFile, self).__init__(fh)
if self.fh is None:
return
@@ -146,19 +173,21 @@ class OfxPreprocessedFile(OfxFile):
ofx_string = self.fh.read()
# find all closing tags as hints
- closing_tags = [ t.upper() for t in re.findall(r'(?i)</([a-z0-9_\.]+)>', ofx_string) ]
+ closing_tags = [t.upper() for t in re.findall(r'(?i)</([a-z0-9_\.]+)>',
+ ofx_string)]
# close all tags that don't have closing tags and
# leave all other data intact
last_open_tag = None
- tokens = re.split(r'(?i)(</?[a-z0-9_\.]+>)', ofx_string)
- new_fh = StringIO()
- for idx,token in enumerate(tokens):
+ tokens = re.split(r'(?i)(</?[a-z0-9_\.]+>)', ofx_string)
+ new_fh = StringIO()
+ for idx, token in enumerate(tokens):
is_closing_tag = token.startswith('</')
is_processing_tag = token.startswith('<?')
is_cdata = token.startswith('<!')
is_tag = token.startswith('<') and not is_cdata
- is_open_tag = is_tag and not is_closing_tag and not is_processing_tag
+ is_open_tag = is_tag and not is_closing_tag \
+ and not is_processing_tag
if is_tag:
if last_open_tag is not None:
new_fh.write("</%s>" % last_open_tag)
@@ -175,7 +204,8 @@ class OfxPreprocessedFile(OfxFile):
class Ofx(object):
def __str__(self):
return ""
-# headers = "\r\n".join(":".join(el if el else "NONE" for el in item) for item in six.iteritems(self.headers))
+# headers = "\r\n".join(":".join(el if el else "NONE" for el in item)
+# for item in six.iteritems(self.headers))
# headers += "\r\n\r\n"
#
# return headers + str(self.signon)
@@ -187,6 +217,7 @@ class AccountType(object):
class Account(object):
def __init__(self):
+ self.curdef = None
self.statement = None
self.account_id = ''
self.routing_number = ''
@@ -216,25 +247,52 @@ class Security:
self.ticker = ticker
self.memo = memo
+
class Signon:
- def __init__(self, code, severity, message):
- self.code = code
- self.severity = severity
- self.message = message
- if int(code) == 0:
+ def __init__(self, keys):
+ self.code = keys['code']
+ self.severity = keys['severity']
+ self.message = keys['message']
+ self.dtserver = keys['dtserver']
+ self.language = keys['language']
+ self.dtprofup = keys['dtprofup']
+ self.fi_org = keys['org']
+ self.fi_fid = keys['fid']
+ self.intu_bid = keys['intu.bid']
+
+ if int(self.code) == 0:
self.success = True
else:
self.success = False
def __str__(self):
- ret = "\t<SIGNONMSGSRSV1>\r\n" + "\t\t<SONRS>\r\n" + "\t\t\t<STATUS>\r\n"
+ ret = "\t<SIGNONMSGSRSV1>\r\n" + "\t\t<SONRS>\r\n" + \
+ "\t\t\t<STATUS>\r\n"
ret += "\t\t\t\t<CODE>%s\r\n" % self.code
ret += "\t\t\t\t<SEVERITY>%s\r\n" % self.severity
if self.message:
ret += "\t\t\t\t<MESSAGE>%s\r\n" % self.message
- ret += "\t\t\t</STATUS>\r\n" + "\t\t</SONRS>\r\n" + "\t</SIGNONMSGSRSV1>\r\n"
+ ret += "\t\t\t</STATUS>\r\n"
+ if self.dtserver is not None:
+ ret += "\t\t\t<DTSERVER>" + self.dtserver + "\r\n"
+ if self.language is not None:
+ ret += "\t\t\t<LANGUAGE>" + self.language + "\r\n"
+ if self.dtprofup is not None:
+ ret += "\t\t\t<DTPROFUP>" + self.dtprofup + "\r\n"
+ if (self.fi_org is not None) or (self.fi_fid is not None):
+ ret += "\t\t\t<FI>\r\n"
+ if self.fi_org is not None:
+ ret += "\t\t\t\t<ORG>" + self.fi_org + "\r\n"
+ if self.fi_fid is not None:
+ ret += "\t\t\t\t<FID>" + self.fi_fid + "\r\n"
+ ret += "\t\t\t</FI>\r\n"
+ if self.intu_bid is not None:
+ ret += "\t\t\t<INTU.BID>" + self.intu_bid + "\r\n"
+ ret += "\t\t</SONRS>\r\n"
+ ret += "\t</SIGNONMSGSRSV1>\r\n"
return ret
+
class Statement(object):
def __init__(self):
self.start_date = ''
@@ -272,20 +330,29 @@ class Transaction(object):
class InvestmentTransaction(object):
- (Unknown, BuyMF, SellMF, Reinvest, BuyStock, SellStock) = [x for x in range(-1, 5)]
+ AGGREGATE_TYPES = ['buydebt', 'buymf', 'buyopt', 'buyother',
+ 'buystock', 'closureopt', 'income',
+ 'invexpense', 'jrnlfund', 'jrnlsec',
+ 'margininterest', 'reinvest', 'retofcap',
+ 'selldebt', 'sellmf', 'sellopt', 'sellother',
+ 'sellstock', 'split', 'transfer']
+
def __init__(self, type):
- try:
- self.type = ['buymf', 'sellmf', 'reinvest', 'buystock', 'sellstock'].index(type.lower())
- except ValueError:
- self.type = InvestmentTransaction.Unknown
+ self.type = type.lower()
self.tradeDate = None
self.settleDate = None
+ self.memo = ''
self.security = ''
+ self.income_type = ''
self.units = decimal.Decimal(0)
self.unit_price = decimal.Decimal(0)
+ self.commission = decimal.Decimal(0)
+ self.fees = decimal.Decimal(0)
+ self.total = decimal.Decimal(0)
def __repr__(self):
- return "<InvestmentTransaction type=" + str(self.type) + ", units=" + str(self.units) + ">"
+ return "<InvestmentTransaction type=" + str(self.type) + ", \
+ units=" + str(self.units) + ">"
class Position(object):
@@ -321,8 +388,9 @@ class OfxParser(object):
'''
cls_.fail_fast = fail_fast
- if isinstance(file_handle, type('')):
- raise RuntimeError(six.u("parse() takes in a file handle, not a string"))
+ if not hasattr(file_handle, 'seek'):
+ raise TypeError(six.u('parse() accepts a seek-able file handle\
+ , not %s' % type(file_handle).__name__))
ofx_obj = Ofx()
@@ -332,14 +400,30 @@ class OfxParser(object):
ofx_obj.accounts = []
ofx_obj.signon = None
+ skip_headers(ofx_file.fh)
ofx = soup_maker(ofx_file.fh)
- if len(ofx.contents) == 0:
+ if ofx.find('ofx') is None:
raise OfxParserException('The ofx file is empty!')
sonrs_ofx = ofx.find('sonrs')
if sonrs_ofx:
ofx_obj.signon = cls_.parseSonrs(sonrs_ofx)
+ stmttrnrs = ofx.find('stmttrnrs')
+ if stmttrnrs:
+ stmttrnrs_trnuid = stmttrnrs.find('trnuid')
+ if stmttrnrs_trnuid:
+ ofx_obj.trnuid = stmttrnrs_trnuid.contents[0].strip()
+
+ stmttrnrs_status = stmttrnrs.find('status')
+ if stmttrnrs_status:
+ ofx_obj.status = {}
+ ofx_obj.status['code'] = int(
+ stmttrnrs_status.find('code').contents[0].strip()
+ )
+ ofx_obj.status['severity'] = \
+ stmttrnrs_status.find('severity').contents[0].strip()
+
stmtrs_ofx = ofx.findAll('stmtrs')
if stmtrs_ofx:
ofx_obj.accounts += cls_.parseStmtrs(stmtrs_ofx, AccountType.Bank)
@@ -386,14 +470,23 @@ class OfxParser(object):
timeZoneOffset = datetime.timedelta(hours=tz)
+ res = re.search("^[0-9]*\.([0-9]{0,5})", ofxDateTime)
+ if res:
+ msec = datetime.timedelta(seconds=float("0." + res.group(1)))
+ else:
+ msec = datetime.timedelta(seconds=0)
+
try:
local_date = datetime.datetime.strptime(
ofxDateTime[:14], '%Y%m%d%H%M%S'
)
- return local_date - timeZoneOffset
+ return local_date - timeZoneOffset + msec
except:
+ if ofxDateTime[:8] == "00000000":
+ return None
+
return datetime.datetime.strptime(
- ofxDateTime[:8], '%Y%m%d') - timeZoneOffset
+ ofxDateTime[:8], '%Y%m%d') - timeZoneOffset + msec
@classmethod
def parseAcctinfors(cls_, acctinfors_ofx, ofx):
@@ -462,7 +555,12 @@ class OfxParser(object):
name_tag = secinfo_ofx.find('secname')
ticker_tag = secinfo_ofx.find('ticker')
memo_tag = secinfo_ofx.find('memo')
- if uniqueid_tag and name_tag and ticker_tag:
+ if uniqueid_tag and name_tag:
+ try:
+ ticker = ticker_tag.contents[0].strip()
+ except AttributeError:
+ # ticker can be empty
+ ticker = None
try:
memo = memo_tag.contents[0].strip()
except AttributeError:
@@ -471,7 +569,7 @@ class OfxParser(object):
securityList.append(
Security(uniqueid_tag.contents[0].strip(),
name_tag.contents[0].strip(),
- ticker_tag.contents[0].strip(),
+ ticker,
memo))
return securityList
@@ -483,10 +581,10 @@ class OfxParser(object):
position.security = tag.contents[0].strip()
tag = ofx.find('units')
if (hasattr(tag, 'contents')):
- position.units = decimal.Decimal(tag.contents[0].strip())
+ position.units = cls_.toDecimal(tag)
tag = ofx.find('unitprice')
if (hasattr(tag, 'contents')):
- position.unit_price = decimal.Decimal(tag.contents[0].strip())
+ position.unit_price = cls_.toDecimal(tag)
tag = ofx.find('dtpriceasof')
if (hasattr(tag, 'contents')):
try:
@@ -521,12 +619,27 @@ class OfxParser(object):
tag = ofx.find('uniqueid')
if (hasattr(tag, 'contents')):
transaction.security = tag.contents[0].strip()
+ tag = ofx.find('incometype')
+ if (hasattr(tag, 'contents')):
+ transaction.income_type = tag.contents[0].strip()
tag = ofx.find('units')
if (hasattr(tag, 'contents')):
- transaction.units = decimal.Decimal(tag.contents[0].strip())
+ transaction.units = cls_.toDecimal(tag)
tag = ofx.find('unitprice')
if (hasattr(tag, 'contents')):
- transaction.unit_price = decimal.Decimal(tag.contents[0].strip())
+ transaction.unit_price = cls_.toDecimal(tag)
+ tag = ofx.find('commission')
+ if (hasattr(tag, 'contents')):
+ transaction.commission = cls_.toDecimal(tag)
+ tag = ofx.find('fees')
+ if (hasattr(tag, 'contents')):
+ transaction.fees = cls_.toDecimal(tag)
+ tag = ofx.find('total')
+ if (hasattr(tag, 'contents')):
+ transaction.total = cls_.toDecimal(tag)
+ tag = ofx.find('inv401ksource')
+ if (hasattr(tag, 'contents')):
+ transaction.inv401ksource = tag.contents[0].strip()
return transaction
@classmethod
@@ -548,7 +661,8 @@ class OfxParser(object):
raise
except ValueError:
e = sys.exc_info()[1]
- statement.warnings.append(six.u('Invalid start date: %s') % e)
+ statement.warnings.append(six.u('Invalid start date:\
+ %s') % e)
if cls_.fail_fast:
raise
@@ -561,7 +675,8 @@ class OfxParser(object):
statement.warnings.append(six.u('Empty end date.'))
except ValueError:
e = sys.exc_info()[1]
- statement.warnings.append(six.u('Invalid end date: %s') % e)
+ statement.warnings.append(six.u('Invalid end date: \
+ %s') % e)
if cls_.fail_fast:
raise
@@ -576,12 +691,11 @@ class OfxParser(object):
if cls_.fail_fast:
raise
statement.discarded_entries.append(
- {six.u('error'): six.u("Error parsing positions: ") + str(e),
- six.u('content'): investment_ofx}
+ {six.u('error'): six.u("Error parsing positions: \
+ ") + str(e), six.u('content'): investment_ofx}
)
- for transaction_type in ['buymf', 'sellmf', 'reinvest', 'buystock',
- 'sellstock', 'buyopt', 'sellopt']:
+ for transaction_type in InvestmentTransaction.AGGREGATE_TYPES:
try:
for investment_ofx in invstmtrs_ofx.findAll(transaction_type):
statement.transactions.append(
@@ -613,14 +727,28 @@ class OfxParser(object):
@classmethod
def parseSonrs(cls_, sonrs):
- code = int(sonrs.find('code').contents[0].strip())
- severity = sonrs.find('severity').contents[0].strip()
- try:
- message = sonrs.find('message').contents[0].strip()
- except:
- message = ''
+ items = [
+ 'code',
+ 'severity',
+ 'dtserver',
+ 'language',
+ 'dtprofup',
+ 'org',
+ 'fid',
+ 'intu.bid',
+ 'message'
+ ]
+ idict = {}
+ for i in items:
+ try:
+ idict[i] = sonrs.find(i).contents[0].strip()
+ except:
+ idict[i] = None
+ idict['code'] = int(idict['code'])
+ if idict['message'] is None:
+ idict['message'] = ''
- return Signon(code,severity,message)
+ return Signon(idict)
@classmethod
def parseStmtrs(cls_, stmtrs_list, accountType):
@@ -628,6 +756,9 @@ class OfxParser(object):
ret = []
for stmtrs_ofx in stmtrs_list:
account = Account()
+ act_curdef = stmtrs_ofx.find('curdef')
+ if act_curdef:
+ account.curdef = act_curdef.contents[0].strip()
acctid_tag = stmtrs_ofx.find('acctid')
if hasattr(acctid_tag, 'contents'):
account.account_id = acctid_tag.contents[0].strip()
@@ -648,6 +779,41 @@ class OfxParser(object):
return ret
@classmethod
+ def parseBalance(cls_, statement, stmt_ofx, bal_tag_name, bal_attr,
+ bal_date_attr, bal_type_string):
+ bal_tag = stmt_ofx.find(bal_tag_name)
+ if hasattr(bal_tag, "contents"):
+ balamt_tag = bal_tag.find('balamt')
+ dtasof_tag = bal_tag.find('dtasof')
+ if hasattr(balamt_tag, "contents"):
+ try:
+ setattr(statement, bal_attr, cls_.toDecimal(balamt_tag))
+ except (IndexError, decimal.InvalidOperation):
+ ex = sys.exc_info()[1]
+ statement.warnings.append(
+ six.u("%s balance amount was empty for \
+ %s") % (bal_type_string, stmt_ofx))
+ if cls_.fail_fast:
+ raise OfxParserException("Empty %s balance\
+ " % bal_type_string)
+ if hasattr(dtasof_tag, "contents"):
+ try:
+ setattr(statement, bal_date_attr, cls_.parseOfxDateTime(
+ dtasof_tag.contents[0].strip()))
+ except IndexError:
+ statement.warnings.append(
+ six.u("%s balance date was empty for %s\
+ ") % (bal_type_string, stmt_ofx))
+ if cls_.fail_fast:
+ raise
+ except ValueError:
+ statement.warnings.append(
+ six.u("%s balance date was not allowed for \
+ %s") % (bal_type_string, stmt_ofx))
+ if cls_.fail_fast:
+ raise
+
+ @classmethod
def parseStatement(cls_, stmt_ofx):
'''
Parse a statement in ofx-land and return a Statement object.
@@ -665,7 +831,8 @@ class OfxParser(object):
raise
except ValueError:
statement.warnings.append(
- six.u("Statement start date was not allowed for %s") % stmt_ofx)
+ six.u("Statement start date was not allowed for \
+ %s") % stmt_ofx)
if cls_.fail_fast:
raise
@@ -682,13 +849,14 @@ class OfxParser(object):
except ValueError:
ve = sys.exc_info()[1]
msg = six.u("Statement start date was not formatted "
- "correctly for %s")
+ "correctly for %s")
statement.warnings.append(msg % stmt_ofx)
if cls_.fail_fast:
raise
except TypeError:
statement.warnings.append(
- six.u("Statement start date was not allowed for %s") % stmt_ofx)
+ six.u("Statement start date was not allowed for \
+ %s") % stmt_ofx)
if cls_.fail_fast:
raise
@@ -702,33 +870,11 @@ class OfxParser(object):
if cls_.fail_fast:
raise
- ledger_bal_tag = stmt_ofx.find('ledgerbal')
- if hasattr(ledger_bal_tag, "contents"):
- balamt_tag = ledger_bal_tag.find('balamt')
- if hasattr(balamt_tag, "contents"):
- try:
- statement.balance = decimal.Decimal(
- balamt_tag.contents[0].strip())
- except (IndexError, decimal.InvalidOperation):
- ex = sys.exc_info()[1]
- statement.warnings.append(
- six.u("Ledger balance amount was empty for %s") % stmt_ofx)
- if cls_.fail_fast:
- raise OfxParserException("Empty ledger balance")
+ cls_.parseBalance(statement, stmt_ofx, 'ledgerbal',
+ 'balance', 'balance_date', 'ledger')
- avail_bal_tag = stmt_ofx.find('availbal')
- if hasattr(avail_bal_tag, "contents"):
- balamt_tag = avail_bal_tag.find('balamt')
- if hasattr(balamt_tag, "contents"):
- try:
- statement.available_balance = decimal.Decimal(
- balamt_tag.contents[0].strip())
- except (IndexError, decimal.InvalidOperation):
- ex = sys.exc_info()[1]
- msg = six.u("Available balance amount was empty for %s")
- statement.warnings.append(msg % stmt_ofx)
- if cls_.fail_fast:
- raise OfxParserException("Empty available balance")
+ cls_.parseBalance(statement, stmt_ofx, 'availbal', 'available_balance',
+ 'available_balance_date', 'ledger')
for transaction_ofx in stmt_ofx.findAll('stmttrn'):
try:
@@ -783,13 +929,17 @@ class OfxParser(object):
amt_tag = txn_ofx.find('trnamt')
if hasattr(amt_tag, "contents"):
try:
- transaction.amount = decimal.Decimal(
- amt_tag.contents[0].strip())
+ transaction.amount = cls_.toDecimal(amt_tag)
except IndexError:
raise OfxParserException("Invalid Transaction Date")
except decimal.InvalidOperation:
- raise OfxParserException(
- six.u("Invalid Transaction Amount: '%s'") % amt_tag.contents[0])
+ # Some banks use a null transaction for including interest
+ # rate changes on your statement.
+ if amt_tag.contents[0].strip() in ('null', '-null'):
+ transaction.amount = 0
+ else:
+ raise OfxParserException(
+ six.u("Invalid Transaction Amount: '%s'") % amt_tag.contents[0])
except TypeError:
raise OfxParserException(
six.u("No Transaction Amount (a required field)"))
@@ -819,24 +969,29 @@ class OfxParser(object):
try:
transaction.id = id_tag.contents[0].strip()
except IndexError:
- raise OfxParserException(six.u("Empty FIT id (a required field)"))
+ raise OfxParserException(six.u("Empty FIT id (a required \
+ field)"))
except TypeError:
raise OfxParserException(six.u("No FIT id (a required field)"))
else:
- raise OfxParserException(six.u("Missing FIT id (a required field)"))
+ raise OfxParserException(six.u("Missing FIT id (a required \
+ field)"))
sic_tag = txn_ofx.find('sic')
if hasattr(sic_tag, 'contents'):
try:
transaction.sic = sic_tag.contents[0].strip()
except IndexError:
- raise OfxParserException(six.u("Empty transaction Standard Industry Code (SIC)"))
+ raise OfxParserException(six.u("Empty transaction Standard \
+ Industry Code (SIC)"))
if transaction.sic is not None and transaction.sic in mcc.codes:
try:
- transaction.mcc = mcc.codes.get(transaction.sic, '').get('combined description')
+ transaction.mcc = mcc.codes.get(transaction.sic, '').get('combined \
+ description')
except IndexError:
- raise OfxParserException(six.u("Empty transaction Merchant Category Code (MCC)"))
+ raise OfxParserException(six.u("Empty transaction Merchant Category \
+ Code (MCC)"))
except AttributeError:
if cls._fail_fast:
raise
@@ -846,6 +1001,14 @@ class OfxParser(object):
try:
transaction.checknum = checknum_tag.contents[0].strip()
except IndexError:
- raise OfxParserException(six.u("Empty Check (or other reference) number"))
+ raise OfxParserException(six.u("Empty Check (or other reference) \
+ number"))
return transaction
+
+ @classmethod
+ def toDecimal(cls_, tag):
+ d = tag.contents[0].strip()
+ if '.' not in d and ',' in d:
+ d = d.replace(',', '.')
+ return decimal.Decimal(d)
diff --git a/ofxparse/ofxprinter.py b/ofxparse/ofxprinter.py
new file mode 100644
index 0000000..4c0a13d
--- /dev/null
+++ b/ofxparse/ofxprinter.py
@@ -0,0 +1,194 @@
+class OfxPrinter():
+ ofx = None
+ out_filename = None
+ out_handle = None
+ term = "\r\n"
+
+ def __init__(self, ofx, filename, term="\r\n"):
+ self.ofx = ofx
+ self.out_filename = filename
+ self.term = term
+
+ def writeLine(self, data, tabs=0, term=None):
+ if term is None:
+ term = self.term
+
+ tabbing = (tabs * "\t") if (tabs > 0) else ''
+
+ return self.out_handle.write(
+ "{0}{1}{2}".format(
+ tabbing,
+ data,
+ term
+ )
+ )
+
+ def writeHeaders(self):
+ for k, v in self.ofx.headers.iteritems():
+ if v is None:
+ self.writeLine("{0}:NONE".format(k))
+ else:
+ self.writeLine("{0}:{1}".format(k, v))
+ self.writeLine("")
+
+ def writeSignOn(self, tabs=0):
+ # signon already has newlines and tabs in it
+ # TODO: reimplement signon printing with tabs
+ self.writeLine(self.ofx.signon.__str__(), term="")
+
+ def printDate(self, dt, msec_digs=3):
+ strdt = dt.strftime('%Y%m%d%H%M%S')
+ strdt_msec = dt.strftime('%f')
+ if len(strdt_msec) < msec_digs:
+ strdt_msec += ('0' * (msec_digs - len(strdt_msec)))
+ elif len(strdt_msec) > msec_digs:
+ strdt_msec = strdt_msec[:msec_digs]
+ return strdt + '.' + strdt_msec
+
+ def writeTrn(self, trn, tabs=5):
+ self.writeLine("<STMTTRN>", tabs=tabs)
+ tabs += 1
+
+ self.writeLine("<TRNTYPE>{}".format(trn.type.upper()), tabs=tabs)
+ self.writeLine("<DTPOSTED>{}".format(
+ self.printDate(trn.date)
+ ), tabs=tabs)
+ self.writeLine("<TRNAMT>{0:.2f}".format(float(trn.amount)), tabs=tabs)
+
+ self.writeLine("<FITID>{}".format(trn.id), tabs=tabs)
+
+ if len(str(trn.checknum)) > 0:
+ self.writeLine("<CHECKNUM>{}".format(
+ trn.checknum
+ ), tabs=tabs)
+
+ self.writeLine("<NAME>{}".format(trn.payee), tabs=tabs)
+
+ if len(trn.memo.strip()) > 0:
+ self.writeLine("<MEMO>{}".format(trn.memo), tabs=tabs)
+
+ tabs -= 1
+ self.writeLine("</STMTTRN>", tabs=tabs)
+
+ def writeLedgerBal(self, statement, tabs=4):
+ bal = getattr(statement, 'balance')
+ baldt = getattr(statement, 'balance_date')
+
+ if bal and baldt:
+ self.writeLine("<LEDGERBAL>", tabs=tabs)
+ self.writeLine("<BALAMT>{0:.2f}".format(float(bal)), tabs=tabs+1)
+ self.writeLine("<DTASOF>{0}".format(
+ self.printDate(baldt)
+ ), tabs=tabs+1)
+ self.writeLine("</LEDGERBAL>", tabs=tabs)
+
+ def writeAvailBal(self, statement, tabs=4):
+ bal = getattr(statement, 'available_balance')
+ baldt = getattr(statement, 'available_balance_date')
+
+ if bal and baldt:
+ self.writeLine("<AVAILBAL>", tabs=tabs)
+ self.writeLine("<BALAMT>{0:.2f}".format(float(bal)), tabs=tabs+1)
+ self.writeLine("<DTASOF>{0}".format(
+ self.printDate(baldt)
+ ), tabs=tabs+1)
+ self.writeLine("</AVAILBAL>", tabs=tabs)
+
+ def writeStmTrs(self, tabs=3):
+ for acct in self.ofx.accounts:
+ self.writeLine("<STMTRS>", tabs=tabs)
+ tabs += 1
+
+ if acct.curdef:
+ self.writeLine("<CURDEF>{0}".format(
+ acct.curdef
+ ), tabs=tabs)
+
+ if acct.routing_number or acct.account_id or acct.account_type:
+ self.writeLine("<BANKACCTFROM>", tabs=tabs)
+ if acct.routing_number:
+ self.writeLine("<BANKID>{0}".format(
+ acct.routing_number
+ ), tabs=tabs+1)
+ if acct.account_id:
+ self.writeLine("<ACCTID>{0}".format(
+ acct.account_id
+ ), tabs=tabs+1)
+ if acct.account_type:
+ self.writeLine("<ACCTTYPE>{0}".format(
+ acct.account_type
+ ), tabs=tabs+1)
+ self.writeLine("</BANKACCTFROM>", tabs=tabs)
+
+ self.writeLine("<BANKTRANLIST>", tabs=tabs)
+ tabs += 1
+ self.writeLine("<DTSTART>{0}".format(
+ self.printDate(acct.statement.start_date)
+ ), tabs=tabs)
+ self.writeLine("<DTEND>{0}".format(
+ self.printDate(acct.statement.end_date)
+ ), tabs=tabs)
+
+ for trn in acct.statement.transactions:
+ self.writeTrn(trn, tabs=tabs)
+
+ tabs -= 1
+
+ self.writeLine("</BANKTRANLIST>", tabs=tabs)
+
+ self.writeLedgerBal(acct.statement, tabs=tabs)
+ self.writeAvailBal(acct.statement, tabs=tabs)
+
+ tabs -= 1
+
+ self.writeLine("</STMTRS>", tabs=tabs)
+
+ def writeBankMsgsRsv1(self, tabs=1):
+ self.writeLine("<BANKMSGSRSV1>", tabs=tabs)
+ tabs += 1
+ self.writeLine("<STMTTRNRS>", tabs=tabs)
+ tabs += 1
+ if self.ofx.trnuid is not None:
+ self.writeLine("<TRNUID>{0}".format(
+ self.ofx.trnuid
+ ), tabs=tabs)
+ if self.ofx.status:
+ self.writeLine("<STATUS>", tabs=tabs)
+ self.writeLine("<CODE>{0}".format(
+ self.ofx.status['code']
+ ), tabs=tabs+1)
+ self.writeLine("<SEVERITY>{0}".format(
+ self.ofx.status['severity']
+ ), tabs=tabs+1)
+ self.writeLine("</STATUS>", tabs=tabs)
+ self.writeStmTrs(tabs=tabs)
+ tabs -= 1
+ self.writeLine("</STMTTRNRS>", tabs=tabs)
+ tabs -= 1
+ self.writeLine("</BANKMSGSRSV1>", tabs=tabs)
+
+ def writeOfx(self, tabs=0):
+ self.writeLine("<OFX>", tabs=tabs)
+ tabs += 1
+ self.writeSignOn(tabs=tabs)
+ self.writeBankMsgsRsv1(tabs=tabs)
+ tabs -= 1
+ # No newline at end of file
+ self.writeLine("</OFX>", tabs=tabs, term="")
+
+ def write(self, filename=None, tabs=0):
+ if self.out_handle:
+ raise Exception("Already writing file")
+
+ if filename is None:
+ filename = self.out_filename
+
+ self.out_handle = open(filename, 'wb')
+
+ self.writeHeaders()
+
+ self.writeOfx(tabs=tabs)
+
+ self.out_handle.flush()
+ self.out_handle.close()
+ self.out_handle = None
diff --git a/ofxparse/ofxutil.py b/ofxparse/ofxutil.py
index 17c2f36..0002ee1 100644
--- a/ofxparse/ofxutil.py
+++ b/ofxparse/ofxutil.py
@@ -12,9 +12,11 @@ else:
import six
+
class InvalidOFXStructureException(Exception):
pass
+
class OfxData(object):
def __init__(self, tag):
self.nodes = odict.OrderedDict()
@@ -38,7 +40,8 @@ class OfxData(object):
del self.nodes[name]
def __setattr__(self, name, value):
- if name in self.__dict__ or name in ['nodes', 'tag', 'data', 'headers', 'xml']:
+ if name in self.__dict__ or name in ['nodes', 'tag', 'data', '\
+ headers', 'xml']:
self.__dict__[name] = value
else:
self.del_tag(name)
@@ -100,12 +103,14 @@ class OfxData(object):
return len(self.nodes)
def __str__(self):
- return os.linesep.join("\t" * line[1] + line[0] for line in self.format())
+ return os.linesep.join("\t" * line[1] + line[0] for line \
+ in self.format())
def format(self):
if self.data or not self.nodes:
if self.tag.upper() == "OFX":
- return [["<%s>%s</%s>" % (self.tag, self.data if self.data else "", self.tag), 0]]
+ return [["<%s>%s</%s>" % (self.tag, self.data \
+ if self.data else "", self.tag), 0]]
return [["<%s>%s" % (self.tag, self.data), 0]]
else:
ret = [["<%s>" % self.tag, -1]]
@@ -129,10 +134,12 @@ class OfxUtil(OfxData):
self.headers = odict.OrderedDict()
self.xml = ""
if ofx_data:
- if isinstance(ofx_data, six.string_types) and not ofx_data.lower().endswith('.ofx'):
+ if isinstance(ofx_data, six.string_types) and not \
+ ofx_data.lower().endswith('.ofx'):
self.parse(ofx_data)
else:
- self.parse(open(ofx_data).read() if isinstance(ofx_data, six.string_types) else ofx_data.read())
+ self.parse(open(ofx_data).read() if isinstance(\
+ ofx_data, six.string_types) else ofx_data.read())
def parse(self, ofx):
try:
@@ -168,24 +175,27 @@ class OfxUtil(OfxData):
for i, tag in enumerate(tags):
gt = tag.index(">")
if tag[1] != "/":
- #Is an opening tag
+ # Is an opening tag
if not can_open:
- tags[i - 1] = tags[i - 1] + "</" + heirarchy.pop() + ">"
+ tags[i - 1] = tags[i - 1] + "</" + \
+ heirarchy.pop() + ">"
can_open = True
tag_name = tag[1:gt].split()[0]
heirarchy.append(tag_name)
if len(tag) > gt + 1:
can_open = False
else:
- #Is a closing tag
+ # Is a closing tag
tag_name = tag[2:gt].split()[0]
if tag_name not in heirarchy:
- #Close tag with no matching open, so delete it
+ # Close tag with no matching open, so delete it
tags[i] = tag[gt + 1:]
else:
- #Close tag with matching open, but other open tags that need to be closed first
+ # Close tag with matching open, but other open
+ # tags that need to be closed first
while(tag_name != heirarchy[-1]):
- tags[i - 1] = tags[i - 1] + "</" + heirarchy.pop() + ">"
+ tags[i - 1] = tags[i - 1] + "</" + \
+ heirarchy.pop() + ">"
can_open = True
heirarchy.pop()
@@ -209,7 +219,8 @@ class OfxUtil(OfxData):
f.write(str(self))
def __str__(self):
- ret = os.linesep.join(":".join(line) for line in six.iteritems(self.headers)) + os.linesep * 2
+ ret = os.linesep.join(":".join(line) for line in \
+ six.iteritems(self.headers)) + os.linesep * 2
ret += super(OfxUtil, self).__str__()
return ret
@@ -219,22 +230,20 @@ if __name__ == "__main__":
ofx = OfxUtil(fixtures + 'checking.ofx')
# ofx = OfxUtil(fixtures + 'fidelity.ofx')
-
- #Manipulate OFX file via XML library
+ # Manipulate OFX file via XML library
# for transaction in ofx.xml.iter('STMTTRN'):
# transaction.find('NAME').text = transaction.find('MEMO').text
# transaction.remove(transaction.find('MEMO'))
# ofx.reload_xml()
-
- #Manipulate OFX file via object tree built from XML
+ # Manipulate OFX file via object tree built from XML
# for transaction in ofx.bankmsgsrsv1.stmttrnrs.stmtrs.banktranlist.stmttrn:
# transaction.name = transaction.memo
# del transaction.memo
# transaction.notes = "Acknowledged"
-
- #Modified sytnax for object tree data manipulation
- #I'm using the __getitem__ method like the xml.iter method from ElementTree, as a recursive search
+ # Modified sytnax for object tree data manipulation
+ # I'm using the __getitem__ method like the xml.iter method from
+ # ElementTree, as a recursive search
for transaction in ofx['stmttrn']:
transaction.name = transaction.memo
del transaction.memo
@@ -248,7 +257,7 @@ if __name__ == "__main__":
#
print(ofx)
- #Write OFX data to output file
+ # Write OFX data to output file
# ofx.write('out.ofx')
# for file_name in os.listdir(fixtures):