summaryrefslogtreecommitdiff
path: root/ofxclient
diff options
context:
space:
mode:
authorAndrew Shadura <andrewsh@debian.org>2016-10-19 17:41:10 +0200
committerAndrew Shadura <andrewsh@debian.org>2016-10-19 17:41:10 +0200
commit7fe4ae7c48cfd06ebee0e7a02077c998b83b1c38 (patch)
treebcc5de4d6b4a1ac0efb0429815b3f79fdf8771ab /ofxclient
Imported Upstream version 1.3.8
Diffstat (limited to 'ofxclient')
-rw-r--r--ofxclient/__init__.py4
-rw-r--r--ofxclient/account.py303
-rw-r--r--ofxclient/cli.py221
-rw-r--r--ofxclient/client.py203
-rw-r--r--ofxclient/config.py315
-rw-r--r--ofxclient/institution.py199
-rw-r--r--ofxclient/util.py24
-rw-r--r--ofxclient/version.py1
8 files changed, 1270 insertions, 0 deletions
diff --git a/ofxclient/__init__.py b/ofxclient/__init__.py
new file mode 100644
index 0000000..8742d5b
--- /dev/null
+++ b/ofxclient/__init__.py
@@ -0,0 +1,4 @@
+from institution import Institution
+from account import Account, BrokerageAccount, CreditCardAccount, BankAccount
+from client import Client
+from version import __version__
diff --git a/ofxclient/account.py b/ofxclient/account.py
new file mode 100644
index 0000000..a1d705a
--- /dev/null
+++ b/ofxclient/account.py
@@ -0,0 +1,303 @@
+from ofxparse import OfxParser, AccountType
+import datetime
+import StringIO
+import time
+import hashlib
+
+
+class Account(object):
+ """Base class for accounts at an institution
+
+ :param number: The account number
+ :type number: string
+ :param institution: The bank this belongs to
+ :type institution: :py:class:`ofxclient.Institution` object
+ :param description: optional account description
+ :type description: string or None
+
+ This class is almost never never instantiated on it's own. Instead,
+ sub-classes are instantiated.
+
+ In most cases these subclasses are either being deserialized from a
+ config file entry, a serialization hash, or returned by the
+ :py:meth:`ofxclient.Institution.accounts` method.
+
+ Example from a saved config entry::
+
+ from ofxclient.config import OfxConfig
+ account = OfxConfig().account('local_id() string')
+
+ Example of deserialization::
+
+ from ofxclient import BankAccount
+ # assume 'inst' is an Institution()
+ a1 = BankAccount(number='asdf',institution=inst)
+ data1 = a1.serialize()
+ a2 = Account.deserialize(data1)
+
+ Example by querying the bank directly::
+
+ from ofxclient import Institution
+ # assume an Institution() is configured with
+ # a username/password etc
+ accounts = institution.accounts()
+
+ .. seealso::
+
+ :py:class:`ofxclient.BankAccount`
+ :py:class:`ofxclient.BrokerageAccount`
+ :py:class:`ofxclient.CreditCardAccount`
+
+ """
+ def __init__(self, number, institution, description=None):
+ self.institution = institution
+ self.number = number
+ self.description = description or self._default_description()
+
+ def local_id(self):
+ """Locally generated unique account identifier.
+
+ :rtype: string
+ """
+ return hashlib.sha256("%s%s" % (
+ self.institution.local_id(),
+ self.number)).hexdigest()
+
+ def number_masked(self):
+ """Masked version of the account number for privacy.
+
+ :rtype: string
+ """
+ return "***%s" % self.number[-4:]
+
+ def long_description(self):
+ """Long description of the account (includes institution description).
+
+ :rtype: string
+ """
+ return "%s: %s" % (self.institution.description, self.description)
+
+ def _default_description(self):
+ return self.number_masked()
+
+ def download(self, days=60):
+ """Downloaded OFX response for the given time range
+
+ :param days: Number of days to look back at
+ :type days: integer
+ :rtype: :py:class:`StringIO.StringIO`
+
+ """
+ days_ago = datetime.datetime.now() - datetime.timedelta(days=days)
+ as_of = time.strftime("%Y%m%d", days_ago.timetuple())
+ query = self._download_query(as_of=as_of)
+ response = self.institution.client().post(query)
+ return StringIO.StringIO(response)
+
+ def download_parsed(self, days=60):
+ """Downloaded OFX response parsed by :py:meth:`OfxParser.parse`
+
+ :param days: Number of days to look back at
+ :type days: integer
+ :rtype: :py:class:`ofxparser.Ofx`
+ """
+ return OfxParser.parse(self.download(days=days))
+
+ def statement(self, days=60):
+ """Download the :py:class:`ofxparse.Statement` given the time range
+
+ :param days: Number of days to look back at
+ :type days: integer
+ :rtype: :py:class:`ofxparser.Statement`
+ """
+ parsed = self.download_parsed(days=days)
+ return parsed.account.statement
+
+ def transactions(self, days=60):
+ """Download a a list of :py:class:`ofxparse.Transaction` objects
+
+ :param days: Number of days to look back at
+ :type days: integer
+ :rtype: list of :py:class:`ofxparser.Transaction` objects
+ """
+ return self.statement(days=days).transactions
+
+ def serialize(self):
+ """Serialize predictably for use in configuration storage.
+
+ Output look like this::
+
+ {
+ 'local_id': 'string',
+ 'number': 'account num',
+ 'description': 'descr',
+ 'broker_id': 'may be missing - type dependent',
+ 'routing_number': 'may be missing - type dependent,
+ 'account_type': 'may be missing - type dependent,
+ 'institution': {
+ # ... see :py:meth:`ofxclient.Institution.serialize`
+ }
+ }
+
+ :rtype: nested dictionary
+ """
+ data = {
+ 'local_id': self.local_id(),
+ 'institution': self.institution.serialize(),
+ 'number': self.number,
+ 'description': self.description
+ }
+ if hasattr(self, 'broker_id'):
+ data['broker_id'] = self.broker_id
+ elif hasattr(self, 'routing_number'):
+ data['routing_number'] = self.routing_number
+ data['account_type'] = self.account_type
+
+ return data
+
+ @staticmethod
+ def deserialize(raw):
+ """Instantiate :py:class:`ofxclient.Account` subclass from dictionary
+
+ :param raw: serilized Account
+ :param type: dict as given by :py:meth:`~ofxclient.Account.serialize`
+ :rtype: subclass of :py:class:`ofxclient.Account`
+ """
+ from ofxclient.institution import Institution
+ institution = Institution.deserialize(raw['institution'])
+
+ del raw['institution']
+ del raw['local_id']
+
+ if 'broker_id' in raw:
+ a = BrokerageAccount(institution=institution, **raw)
+ elif 'routing_number' in raw:
+ a = BankAccount(institution=institution, **raw)
+ else:
+ a = CreditCardAccount(institution=institution, **raw)
+ return a
+
+ @staticmethod
+ def from_ofxparse(data, institution):
+ """Instantiate :py:class:`ofxclient.Account` subclass from ofxparse
+ module
+
+ :param data: an ofxparse account
+ :type data: An :py:class:`ofxparse.Account` object
+ :param institution: The parent institution of the account
+ :type institution: :py:class:`ofxclient.Institution` object
+ """
+
+ description = data.desc if hasattr(data, 'desc') else None
+ if data.type == AccountType.Bank:
+ return BankAccount(
+ institution=institution,
+ number=data.account_id,
+ routing_number=data.routing_number,
+ account_type=data.account_type,
+ description=description)
+ elif data.type == AccountType.CreditCard:
+ return CreditCardAccount(
+ institution=institution,
+ number=data.account_id,
+ description=description)
+ elif data.type == AccountType.Investment:
+ return BrokerageAccount(
+ institution=institution,
+ number=data.account_id,
+ broker_id=data.brokerid,
+ description=description)
+ raise ValueError("unknown account type: %s" % data.type)
+
+
+class BrokerageAccount(Account):
+ """:py:class:`ofxclient.Account` subclass for brokerage/investment accounts
+
+ In addition to the parameters it's superclass requires, the following
+ parameters are needed.
+
+ :param broker_id: Broker ID of the account
+ :type broker_id: string
+
+ .. seealso::
+
+ :py:class:`ofxclient.Account`
+ """
+ def __init__(self, broker_id, **kwargs):
+ super(BrokerageAccount, self).__init__(**kwargs)
+ self.broker_id = broker_id
+
+ def _download_query(self, as_of):
+ """Formulate the specific query needed for download
+
+ Not intended to be called by developers directly.
+
+ :param as_of: Date in 'YYYYMMDD' format
+ :type as_of: string
+ """
+ c = self.institution.client()
+ q = c.brokerage_account_query(
+ number=self.number, date=as_of, broker_id=self.broker_id)
+ return q
+
+
+class BankAccount(Account):
+ """:py:class:`ofxclient.Account` subclass for a checking/savings account
+
+ In addition to the parameters it's superclass requires, the following
+ parameters are needed.
+
+ :param routing_number: Routing number or account number of the account
+ :type routing_number: string
+ :param account_type: Account type per OFX spec can be empty but not None
+ :type account_type: string
+
+ .. seealso::
+
+ :py:class:`ofxclient.Account`
+ """
+ def __init__(self, routing_number, account_type, **kwargs):
+ super(BankAccount, self).__init__(**kwargs)
+ self.routing_number = routing_number
+ self.account_type = account_type
+
+ def _download_query(self, as_of):
+ """Formulate the specific query needed for download
+
+ Not intended to be called by developers directly.
+
+ :param as_of: Date in 'YYYYMMDD' format
+ :type as_of: string
+ """
+ c = self.institution.client()
+ q = c.bank_account_query(
+ number=self.number,
+ date=as_of,
+ account_type=self.account_type,
+ bank_id=self.routing_number)
+ return q
+
+
+class CreditCardAccount(Account):
+ """:py:class:`ofxclient.Account` subclass for a credit card account
+
+ No additional parameters to the constructor are needed.
+
+ .. seealso::
+
+ :py:class:`ofxclient.Account`
+ """
+ def __init__(self, **kwargs):
+ super(CreditCardAccount, self).__init__(**kwargs)
+
+ def _download_query(self, as_of):
+ """Formulate the specific query needed for download
+
+ Not intended to be called by developers directly.
+
+ :param as_of: Date in 'YYYYMMDD' format
+ :type as_of: string
+ """
+ c = self.institution.client()
+ q = c.credit_card_account_query(number=self.number, date=as_of)
+ return q
diff --git a/ofxclient/cli.py b/ofxclient/cli.py
new file mode 100644
index 0000000..ebf9bd7
--- /dev/null
+++ b/ofxclient/cli.py
@@ -0,0 +1,221 @@
+from ofxclient.account import BankAccount, BrokerageAccount, CreditCardAccount
+from ofxclient.institution import Institution
+from ofxclient.util import combined_download
+from ofxhome import OFXHome
+import argparse
+import config
+import getpass
+import os
+import os.path
+import client
+import sys
+
+AUTO_OPEN_DOWNLOADS = 1
+DOWNLOAD_DAYS = 30
+
+GlobalConfig = config.OfxConfig()
+
+
+def run():
+ accounts = GlobalConfig.accounts()
+ account_ids = [a.local_id() for a in accounts]
+
+ parser = argparse.ArgumentParser(prog='ofxclient')
+ parser.add_argument('-a', '--account', choices=account_ids)
+ parser.add_argument('-d', '--download', type=argparse.FileType('wb', 0))
+ parser.add_argument('-o', '--open', action='store_true')
+ args = parser.parse_args()
+
+ if args.download:
+ if accounts:
+ if args.account:
+ a = GlobalConfig.account(args.account)
+ ofxdata = a.download(days=DOWNLOAD_DAYS)
+ else:
+ ofxdata = combined_download(accounts, days=DOWNLOAD_DAYS)
+ args.download.write(ofxdata.read())
+ if args.open:
+ open_with_ofx_handler(args.download.name)
+ sys.exit(0)
+ else:
+ print "no accounts configured"
+
+ main_menu()
+
+
+def main_menu():
+ while 1:
+ menu_title("Main\nEdit %s to\nchange descriptions or ofx options" %
+ GlobalConfig.file_name)
+
+ accounts = GlobalConfig.accounts()
+ for idx, account in enumerate(accounts):
+ menu_item(idx, account.long_description())
+
+ menu_item('A', 'Add an account')
+ if accounts:
+ menu_item('D', 'Download all combined')
+
+ menu_item('Q', 'Quit')
+
+ choice = prompt().lower()
+ if choice == 'a':
+ add_account_menu()
+ elif choice == 'd':
+ if not accounts:
+ print "no accounts on file"
+ else:
+ ofxdata = combined_download(accounts, days=DOWNLOAD_DAYS)
+ wrote = write_and_handle_download(
+ ofxdata,
+ 'combined_download.ofx'
+ )
+ print "wrote: %s" % wrote
+ elif choice in ['q', '']:
+ return
+ elif int(choice) < len(accounts):
+ account = accounts[int(choice)]
+ view_account_menu(account)
+
+
+def add_account_menu():
+ menu_title("Add account")
+ while 1:
+ query = prompt('enter part of a bank name eg. express> ')
+ if query.lower() in ['']:
+ return
+
+ found = OFXHome.search(query)
+ if not found:
+ error("No banks found")
+ continue
+
+ while 1:
+ for idx, bank in enumerate(found):
+ menu_item(idx, bank['name'])
+ choice = prompt().lower()
+ if choice in ['q', '']:
+ return
+ elif int(choice) < len(found):
+ bank = OFXHome.lookup(found[int(choice)]['id'])
+ if login_check_menu(bank):
+ return
+
+
+def view_account_menu(account):
+ while 1:
+ menu_title(account.long_description())
+
+ institution = account.institution
+ client = institution.client()
+
+ print "Overview:"
+ print " Name: %s" % account.description
+ print " Account Number: %s" % account.number_masked()
+ print " Institution: %s" % institution.description
+ print " Main Type: %s" % str(type(account))
+ if hasattr(account, 'routing_number'):
+ print " Routing Number: %s" % account.routing_number
+ print " Sub Type: %s" % account.account_type
+ if hasattr(account, 'broker_id'):
+ print " Broker ID: %s" % account.broker_id
+
+ print "Nerdy Info:"
+ print " Download Up To: %s days" % DOWNLOAD_DAYS
+ print " Username: %s" % institution.username
+ print " Local Account ID: %s" % account.local_id()
+ print " Local Institution ID: %s" % institution.local_id()
+ print " FI Id: %s" % institution.id
+ print " FI Org: %s" % institution.org
+ print " FI Url: %s" % institution.url
+ if institution.broker_id:
+ print " FI Broker Id: %s" % institution.broker_id
+ print " Client Id: %s" % client.id
+ print " App Ver: %s" % client.app_version
+ print " App Id: %s" % client.app_id
+ print " OFX Ver: %s" % client.ofx_version
+ print " Config File: %s" % GlobalConfig.file_name
+
+ menu_item('D', 'Download')
+ choice = prompt().lower()
+ if choice == 'd':
+ out = account.download(days=DOWNLOAD_DAYS)
+ wrote = write_and_handle_download(out,
+ "%s.ofx" % account.local_id())
+ print "wrote: %s" % wrote
+ return
+
+
+def login_check_menu(bank_info):
+ while 1:
+ username = ''
+ while not username:
+ username = prompt('username> ')
+
+ password = ''
+ while not password:
+ password = getpass.getpass('password> ')
+
+ i = Institution(
+ id=bank_info['fid'],
+ org=bank_info['org'],
+ url=bank_info['url'],
+ broker_id=bank_info['brokerid'],
+ description=bank_info['name'],
+ username=username,
+ password=password
+ )
+ try:
+ i.authenticate()
+ except Exception, e:
+ print "authentication failed: %s" % e
+ continue
+
+ accounts = i.accounts()
+ for a in accounts:
+ GlobalConfig.add_account(a)
+ GlobalConfig.save()
+ return 1
+
+
+def write_and_handle_download(ofx_data, name):
+ outfile = open(name, 'w')
+ outfile.write(ofx_data.read())
+ outfile.close()
+ if AUTO_OPEN_DOWNLOADS:
+ open_with_ofx_handler(name)
+ return os.path.abspath(name)
+
+
+def prompt(text='choice> '):
+ got = raw_input(text)
+ return got
+
+
+def error(text=''):
+ print "!! %s" % text
+
+
+def menu_item(key, description):
+ print "(%s) %s" % (key, description)
+
+
+def menu_title(name):
+ print "+----------------------------------"
+ print "%s" % name
+ print "+----------------------------------"
+
+
+def open_with_ofx_handler(filename):
+ import platform
+ sysname = platform.system()
+ if sysname == 'Darwin':
+ os.system("/usr/bin/open '%s'" % filename)
+ elif sysname == 'Windows':
+ os.startfile(filename)
+ else:
+ # linux
+ os.system("xdg-open '%s'" % filename)
+
+if __name__ == '__main__':
+ run()
diff --git a/ofxclient/client.py b/ofxclient/client.py
new file mode 100644
index 0000000..943218c
--- /dev/null
+++ b/ofxclient/client.py
@@ -0,0 +1,203 @@
+import httplib
+import time
+import urllib2
+
+DEFAULT_APP_ID = 'QWIN'
+DEFAULT_APP_VERSION = '2200'
+DEFAULT_OFX_VERSION = '102'
+
+LINE_ENDING = "\r\n"
+
+
+def ofx_uid():
+ import uuid
+ return str(uuid.uuid4().hex)
+
+
+class Client:
+ """This communicates with the banks via the OFX protocol
+
+ :param institution: institution to connect to
+ :type institution: :py:class:`ofxclient.Institution`
+ :param id: client id (optional need for OFX version >= 103)
+ :type id: string
+ :param app_id: OFX app id
+ :type app_id: string
+ :param app_version: OFX app version
+ :type app_version: string
+ :param ofx_version: OFX spec version
+ :type ofx_version: string
+ """
+
+ def __init__(
+ self,
+ institution,
+ id=ofx_uid(),
+ app_id=DEFAULT_APP_ID,
+ app_version=DEFAULT_APP_VERSION,
+ ofx_version=DEFAULT_OFX_VERSION
+ ):
+ self.institution = institution
+ self.id = id
+ self.app_id = app_id
+ self.app_version = app_version
+ self.ofx_version = ofx_version
+ self.cookie = 3
+
+ def authenticated_query(
+ self,
+ with_message=None,
+ username=None,
+ password=None
+ ):
+ """Authenticated query
+
+ If you pass a 'with_messages' array those queries will be passed along
+ otherwise this will just be an authentication probe query only.
+ """
+ u = username or self.institution.username
+ p = password or self.institution.password
+
+ contents = ['OFX', self._signOn(username=u, password=p)]
+ if with_message:
+ contents.append(with_message)
+ return str.join(LINE_ENDING, [
+ self.header(),
+ _tag(*contents)
+ ])
+
+ def bank_account_query(self, number, date, account_type, bank_id):
+ """Bank account statement request"""
+ return self.authenticated_query(
+ self._bareq(number, date, account_type, bank_id)
+ )
+
+ def credit_card_account_query(self, number, date):
+ """CC Statement request"""
+ return self.authenticated_query(self._ccreq(number, date))
+
+ def brokerage_account_query(self, number, date, broker_id):
+ return self.authenticated_query(
+ self._invstreq(broker_id, number, date))
+
+ def account_list_query(self, date='19700101000000'):
+ return self.authenticated_query(self._acctreq(date))
+
+ def post(self, query):
+ # N.B. urllib doesn't honor user Content-type, use urllib2
+ i = self.institution
+ garbage, path = urllib2.splittype(i.url)
+ host, selector = urllib2.splithost(path)
+ h = httplib.HTTPSConnection(host)
+ h.request('POST', selector, query,
+ {
+ "Content-type": "application/x-ofx",
+ "Accept": "*/*, application/x-ofx"
+ })
+ res = h.getresponse()
+ response = res.read()
+ res.close()
+
+ return response
+
+ def next_cookie(self):
+ self.cookie += 1
+ return str(self.cookie)
+
+ def header(self):
+ parts = [
+ "OFXHEADER:100",
+ "DATA:OFXSGML",
+ "VERSION:%d" % int(self.ofx_version),
+ "SECURITY:NONE",
+ "ENCODING:USASCII",
+ "CHARSET:1252",
+ "COMPRESSION:NONE",
+ "OLDFILEUID:NONE",
+ "NEWFILEUID:"+ofx_uid(),
+ ""
+ ]
+ return str.join(LINE_ENDING, parts)
+
+ """Generate signon message"""
+ def _signOn(self, username=None, password=None):
+ i = self.institution
+ u = username or i.username
+ p = password or i.password
+ fidata = [_field("ORG", i.org)]
+ if i.id:
+ fidata.append(_field("FID", i.id))
+
+ client_uid = ''
+ if str(self.ofx_version) == '103':
+ client_uid = _field('CLIENTUID', self.id)
+
+ return _tag("SIGNONMSGSRQV1",
+ _tag("SONRQ",
+ _field("DTCLIENT", now()),
+ _field("USERID", u),
+ _field("USERPASS", p),
+ _field("LANGUAGE", "ENG"),
+ _tag("FI", *fidata),
+ _field("APPID", self.app_id),
+ _field("APPVER", self.app_version),
+ client_uid
+ ))
+
+ def _acctreq(self, dtstart):
+ req = _tag("ACCTINFORQ", _field("DTACCTUP", dtstart))
+ return self._message("SIGNUP", "ACCTINFO", req)
+
+# this is from _ccreq below and reading page 176 of the latest OFX doc.
+ def _bareq(self, acctid, dtstart, accttype, bankid):
+ req = _tag("STMTRQ",
+ _tag("BANKACCTFROM",
+ _field("BANKID", bankid),
+ _field("ACCTID", acctid),
+ _field("ACCTTYPE", accttype)),
+ _tag("INCTRAN",
+ _field("DTSTART", dtstart),
+ _field("INCLUDE", "Y")))
+ return self._message("BANK", "STMT", req)
+
+ def _ccreq(self, acctid, dtstart):
+ req = _tag("CCSTMTRQ",
+ _tag("CCACCTFROM", _field("ACCTID", acctid)),
+ _tag("INCTRAN",
+ _field("DTSTART", dtstart),
+ _field("INCLUDE", "Y")))
+ return self._message("CREDITCARD", "CCSTMT", req)
+
+ def _invstreq(self, brokerid, acctid, dtstart):
+ req = _tag("INVSTMTRQ",
+ _tag("INVACCTFROM",
+ _field("BROKERID", brokerid),
+ _field("ACCTID", acctid)),
+ _tag("INCTRAN",
+ _field("DTSTART", dtstart),
+ _field("INCLUDE", "Y")),
+ _field("INCOO", "Y"),
+ _tag("INCPOS",
+ _field("DTASOF", now()),
+ _field("INCLUDE", "Y")),
+ _field("INCBAL", "Y"))
+ return self._message("INVSTMT", "INVSTMT", req)
+
+ def _message(self, msgType, trnType, request):
+ return _tag(msgType+"MSGSRQV1",
+ _tag(trnType+"TRNRQ",
+ _field("TRNUID", ofx_uid()),
+ _field("CLTCOOKIE", self.next_cookie()),
+ request))
+
+
+def _field(tag, value):
+ return "<"+tag+">"+value
+
+
+def _tag(tag, *contents):
+ return str.join(LINE_ENDING, ["<"+tag+">"]+list(contents)+["</"+tag+">"])
+
+
+def now():
+ return time.strftime("%Y%m%d%H%M%S", time.localtime())
diff --git a/ofxclient/config.py b/ofxclient/config.py
new file mode 100644
index 0000000..21a5198
--- /dev/null
+++ b/ofxclient/config.py
@@ -0,0 +1,315 @@
+from __future__ import with_statement
+from ofxclient.account import Account
+from ConfigParser import ConfigParser
+import os
+import os.path
+
+try:
+ import keyring
+ KEYRING_AVAILABLE = True
+except:
+ KEYRING_AVAILABLE = False
+
+try:
+ DEFAULT_CONFIG = os.path.expanduser(os.path.join('~', 'ofxclient.ini'))
+except:
+ DEFAULT_CONFIG = None
+
+
+class SecurableConfigParser(ConfigParser):
+ """:py:class:`ConfigParser.ConfigParser` subclass that knows how to store
+ options marked as secure into the OS specific
+ keyring/keychain.
+
+ To mark an option as secure, the caller must call
+ 'set_secure' at least one time for the particular
+ option and from then on it will be seen as secure
+ and will be stored / retrieved from the keychain.
+
+ Example::
+
+ from ofxclient.config import SecurableConfigParser
+
+ # password will not be saved in the config file
+
+ c = SecurableConfigParser()
+ c.add_section('Info')
+ c.set('Info','username','bill')
+ c.set_secure('Info','password','s3cre7')
+ with open('config.ini','w') as fp:
+ c.write(fp)
+ """
+
+ _secure_placeholder = '%{secured}'
+
+ def __init__(self, keyring_name='ofxclient',
+ keyring_available=KEYRING_AVAILABLE, **kwargs):
+ ConfigParser.__init__(self)
+ self.keyring_name = keyring_name
+ self.keyring_available = keyring_available
+ self._unsaved = {}
+ self.keyring_name = keyring_name
+
+ def is_secure_option(self, section, option):
+ """Test an option to see if it is secured or not.
+
+ :param section: section id
+ :type section: string
+ :param option: option name
+ :type option: string
+ :rtype: boolean
+ otherwise.
+ """
+ if not self.has_section(section):
+ return False
+ if not self.has_option(section, option):
+ return False
+ if ConfigParser.get(self, section, option) == self._secure_placeholder:
+ return True
+ return False
+
+ def has_secure_option(self, section, option):
+ """See is_secure_option"""
+ return self.is_secure_option(section, option)
+
+ def items(self, section):
+ """Get all items for a section. Subclassed, to ensure secure
+ items come back with the unencrypted data.
+
+ :param section: section id
+ :type section: string
+ """
+ items = []
+ for k, v in ConfigParser.items(self, section):
+ if self.is_secure_option(section, k):
+ v = self.get(section, k)
+ items.append((k, v))
+ return items
+
+ def secure_items(self, section):
+ """Like items() but only return secure items.
+
+ :param section: section id
+ :type section: string
+ """
+ return [x
+ for x in self.items(section)
+ if self.is_secure_option(section, x[0])]
+
+ def set(self, section, option, value):
+ """Set an option value. Knows how to set options properly marked
+ as secure."""
+ if self.is_secure_option(section, option):
+ self.set_secure(section, option, value)
+ else:
+ ConfigParser.set(self, section, option, value)
+
+ def set_secure(self, section, option, value):
+ """Set an option and mark it as secure.
+
+ Any subsequent uses of 'set' or 'get' will also
+ now know that this option is secure as well.
+ """
+ if self.keyring_available:
+ s_option = "%s%s" % (section, option)
+ self._unsaved[s_option] = ('set', value)
+ value = self._secure_placeholder
+ ConfigParser.set(self, section, option, value)
+
+ def get(self, section, option, *args):
+ """Get option value from section. If an option is secure,
+ populates the plain text."""
+ if self.is_secure_option(section, option) and self.keyring_available:
+ s_option = "%s%s" % (section, option)
+ if self._unsaved.get(s_option, [''])[0] == 'set':
+ return self._unsaved[s_option][1]
+ else:
+ return keyring.get_password(self.keyring_name, s_option)
+ return ConfigParser.get(self, section, option, *args)
+
+ def remove_option(self, section, option):
+ """Removes the option from ConfigParser as well as
+ the secure storage backend
+ """
+ if self.is_secure_option(section, option) and self.keyring_available:
+ s_option = "%s%s" % (section, option)
+ self._unsaved[s_option] = ('delete', None)
+ ConfigParser.remove_option(self, section, option)
+
+ def write(self, *args):
+ """See ConfigParser.write(). Also writes secure items to keystore."""
+ ConfigParser.write(self, *args)
+ if self.keyring_available:
+ for key, thing in self._unsaved.items():
+ action = thing[0]
+ value = thing[1]
+ if action == 'set':
+ keyring.set_password(self.keyring_name, key, value)
+ elif action == 'delete':
+ try:
+ keyring.delete_password(self.keyring_name, key)
+ except:
+ pass
+ self._unsaved = {}
+
+
+class OfxConfig(object):
+ """Default config file handler for other tools to use.
+
+ This can read and write from the default config which is
+ $USERS_HOME/ofxclient.ini
+
+ :param file_name: absolute path to a config file (optional)
+ :type file_name: string or None
+
+ Example usage::
+
+ from ofxclient.config import OfxConfig
+ from ofxclient import Account
+
+ a = Account()
+
+ c = OfxConfig(file_name='/tmp/new.ini')
+ c.add_account(a)
+ c.save()
+
+ account_list = c.accounts()
+ one_account = c.account( a.local_id() )
+ """
+
+ def __init__(self, file_name=None):
+
+ self.secured_field_names = [
+ 'institution.username',
+ 'institution.password'
+ ]
+
+ f = file_name or DEFAULT_CONFIG
+ if f is None:
+ raise ValueError('file_name is required')
+ self._load(f)
+
+ def reload(self):
+ """Reload the config file from disk"""
+ return self._load()
+
+ def accounts(self):
+ """List of confgured :py:class:`ofxclient.Account` objects"""
+ return [self._section_to_account(s)
+ for s in self.parser.sections()]
+
+ def encrypted_accounts(self):
+ return [a
+ for a in self.accounts()
+ if self.is_encrypted_account(a.local_id())]
+
+ def unencrypted_accounts(self):
+ return [a
+ for a in self.accounts()
+ if not self.is_encrypted_account(a.local_id())]
+
+ def account(self, id):
+ """Get :py:class:`ofxclient.Account` by section id"""
+ if self.parser.has_section(id):
+ return self._section_to_account(id)
+ return None
+
+ def add_account(self, account):
+ """Add Account to config (does not save)"""
+ serialized = account.serialize()
+ section_items = flatten_dict(serialized)
+ section_id = section_items['local_id']
+
+ if not self.parser.has_section(section_id):
+ self.parser.add_section(section_id)
+
+ for key in sorted(section_items):
+ self.parser.set(section_id, key, section_items[key])
+
+ self.encrypt_account(id=section_id)
+
+ return self
+
+ def encrypt_account(self, id):
+ """Make sure that certain fields are encrypted."""
+ for key in self.secured_field_names:
+ value = self.parser.get(id, key)
+ self.parser.set_secure(id, key, value)
+ return self
+
+ def is_encrypted_account(self, id):
+ """Are all fields for the account id encrypted?"""
+ for key in self.secured_field_names:
+ if not self.parser.is_secure_option(id, key):
+ return False
+ return True
+
+ def remove_account(self, id):
+ """Add Account from config (does not save)"""
+ if self.parser.has_section(id):
+ self.parser.remove_section(id)
+ return True
+ return False
+
+ def save(self):
+ """Save changes to config file"""
+ with open(self.file_name, 'w') as fp:
+ self.parser.write(fp)
+ return self
+
+ def _load(self, file_name=None):
+ self.parser = None
+
+ file_name = file_name or self.file_name
+
+ if not os.path.exists(file_name):
+ with open(file_name, 'a'):
+ os.utime(file_name, None)
+
+ self.file_name = file_name
+
+ conf = SecurableConfigParser()
+ conf.readfp(open(self.file_name))
+ self.parser = conf
+
+ return self
+
+ def _section_to_account(self, section):
+ section_items = dict(self.parser.items(section))
+ serialized = unflatten_dict(section_items)
+ return Account.deserialize(serialized)
+
+
+def unflatten_dict(dict, prefix=None, separator='.'):
+ ret = {}
+ for k, v in dict.items():
+ key_parts = k.split(separator)
+
+ if len(key_parts) == 1:
+ ret[k] = v
+ else:
+ first = key_parts[0]
+ rest = key_parts[1:]
+ temp = ret.setdefault(first, {})
+ for idx, part in enumerate(rest):
+ if (idx+1) == len(rest):
+ temp[part] = v
+ else:
+ temp = temp.setdefault(part, {})
+ return ret
+
+
+def flatten_dict(dict_, prefix=None, separator='.'):
+ ret = {}
+ for k, v in dict_.items():
+ if prefix:
+ flat_key = separator.join([prefix, k])
+ else:
+ flat_key = k
+ if isinstance(v, dict):
+ deflated = flatten_dict(v, prefix=flat_key)
+ for dk, dv in deflated.items():
+ ret[dk] = dv
+ else:
+ ret[flat_key] = v
+ return ret
diff --git a/ofxclient/institution.py b/ofxclient/institution.py
new file mode 100644
index 0000000..b3da2b9
--- /dev/null
+++ b/ofxclient/institution.py
@@ -0,0 +1,199 @@
+import StringIO
+import hashlib
+from ofxclient.client import Client
+from ofxparse import OfxParser
+from BeautifulSoup import BeautifulStoneSoup
+
+
+class Institution(object):
+ """Represents an institution or bank
+
+ :param id: FI Id
+ :type id: string
+ :param org: FI Org
+ :type org: string
+ :param url: FI Url
+ :type url: string
+ :param username: Customer username or member id
+ :type username: string
+ :param password: Customer password or PIN
+ :type password: string
+ :param broker_id: FI Broker ID (optional)
+ :type broker_id: string
+ :param description: Description of the bank (optional)
+ :type description: string or None
+ :param client_args: :py:class:`ofxclient.Client` kwargs (optional)
+ :type client_args: dict
+
+ Values for many of the parameters need to come from some sort of
+ OFX registry which knows about each banks particular setup.
+
+ For help obtaining this sort of information; please see the
+ :py:mod:`ofxhome` python module and/or the `OFX Home <http://ofxhome.com>`_
+ website.
+
+ Example::
+
+ from ofxclient import Institution
+
+ inst = Institution(
+ id = '3101',
+ org = 'AMEX',
+ url = 'https://online.americanexpress.com/myca\
+ /ofxdl/desktop/desktop Download.do?\
+ request_type=nl_ofxdownload',
+ username = 'gene',
+ password = 'wilder'
+ )
+
+ for a in inst.accounts():
+ print a.statement(days=5).balance
+
+
+ """
+ def __init__(self, id, org, url, username, password,
+ broker_id='', description=None, client_args={}):
+ self.id = id
+ self.org = org
+ self.url = url
+ self.broker_id = broker_id
+ self.username = username
+ self.password = password
+ self.description = description or self._default_description()
+ self.client_args = client_args
+
+ def client(self):
+ """Build a :py:class:`ofxclient.Client` for talking with the bank
+
+ It implicitly passes in the ``client_args`` that were passed
+ when instantiating this ``Institution``.
+
+ :rtype: :py:class:`ofxclient.Client`
+ """
+ return Client(institution=self, **self.client_args)
+
+ def local_id(self):
+ """Locally generated unique account identifier.
+
+ :rtype: string
+ """
+ return hashlib.sha256("%s%s" % (
+ self.id,
+ self.username)).hexdigest()
+
+ def _default_description(self):
+ return self.org
+
+ def authenticate(self, username=None, password=None):
+ """Test the authentication credentials
+
+ Raises a ``ValueError`` if there is a problem authenticating
+ with the human readable reason given by the institution.
+
+ :param username: optional username (use self.username by default)
+ :type username: string or None
+ :param password: optional password (use self.password by default)
+ :type password: string or None
+ """
+
+ u = self.username
+ p = self.password
+ if username and password:
+ u = username
+ p = password
+
+ client = self.client()
+ query = client.authenticated_query(username=u, password=p)
+ res = client.post(query)
+ ofx = BeautifulStoneSoup(res)
+
+ sonrs = ofx.find('sonrs')
+ code = int(sonrs.find('code').contents[0].strip())
+
+ try:
+ status = sonrs.find('message').contents[0].strip()
+ except Exception:
+ status = ''
+
+ if code == 0:
+ return 1
+
+ raise ValueError(status)
+
+ def accounts(self):
+ """Ask the bank for the known :py:class:`ofxclient.Account` list.
+
+ :rtype: list of :py:class:`ofxclient.Account` objects
+ """
+ from ofxclient.account import Account
+ client = self.client()
+ query = client.account_list_query()
+ resp = client.post(query)
+ resp_handle = StringIO.StringIO(resp)
+
+ parsed = OfxParser.parse(resp_handle)
+
+ return [Account.from_ofxparse(a, institution=self)
+ for a in parsed.accounts]
+
+ def serialize(self):
+ """Serialize predictably for use in configuration storage.
+
+ Output looks like this::
+
+ {
+ 'local_id': 'unique local identifier',
+ 'id': 'FI Id',
+ 'org': 'FI Org',
+ 'url': 'FI OFX Endpoint Url',
+ 'broker_id': 'FI Broker Id',
+ 'username': 'Customer username',
+ 'password': 'Customer password',
+ 'description': 'descr',
+ 'client_args': {
+ 'id': 'random client id - see Client() for default',
+ 'app_id': 'app name - see Client() for default',
+ 'app_version': 'app version - see Client() for default',
+ 'ofx_version': 'ofx version - see Client() for default',
+ }
+ }
+
+ :rtype: nested dictionary
+ """
+ client = self.client()
+ client_args = {
+ 'id': client.id,
+ 'app_id': client.app_id,
+ 'app_version': client.app_version,
+ 'ofx_version': client.ofx_version,
+ }
+ return {
+ 'id': self.id,
+ 'org': self.org,
+ 'url': self.url,
+ 'broker_id': self.broker_id,
+ 'username': self.username,
+ 'password': self.password,
+ 'description': self.description,
+ 'client_args': client_args,
+ 'local_id': self.local_id()
+ }
+
+ @staticmethod
+ def deserialize(raw):
+ """Instantiate :py:class:`ofxclient.Institution` from dictionary
+
+ :param raw: serialized ``Institution``
+ :param type: dict per :py:method:`~Institution.serialize`
+ :rtype: subclass of :py:class:`ofxclient.Institution`
+ """
+ return Institution(
+ id=raw['id'],
+ org=raw['org'],
+ url=raw['url'],
+ broker_id=raw.get('broker_id', ''),
+ username=raw['username'],
+ password=raw['password'],
+ description=raw.get('description', None),
+ client_args=raw.get('client_args', {})
+ )
diff --git a/ofxclient/util.py b/ofxclient/util.py
new file mode 100644
index 0000000..76405bb
--- /dev/null
+++ b/ofxclient/util.py
@@ -0,0 +1,24 @@
+from ofxclient.client import Client
+from StringIO import StringIO
+
+
+def combined_download(accounts, days=60):
+ """Download OFX files and combine them into one
+
+ It expects an 'accounts' list of ofxclient.Account objects
+ as well as an optional 'days' specifier which defaults to 60
+ """
+
+ client = Client(institution=None)
+
+ out_file = StringIO()
+ out_file.write(client.header())
+ for a in accounts:
+ ofx = a.download(days=days).read()
+ stripped = ofx.partition('<OFX>')[2].partition('</OFX>')[0]
+ out_file.write(stripped)
+
+ out_file.write("<OFX>")
+ out_file.seek(0)
+
+ return out_file
diff --git a/ofxclient/version.py b/ofxclient/version.py
new file mode 100644
index 0000000..bb5a33c
--- /dev/null
+++ b/ofxclient/version.py
@@ -0,0 +1 @@
+__version__ = '1.3.8'