diff options
author | Andrew Shadura <andrewsh@debian.org> | 2016-10-19 17:41:10 +0200 |
---|---|---|
committer | Andrew Shadura <andrewsh@debian.org> | 2016-10-19 17:41:10 +0200 |
commit | 7fe4ae7c48cfd06ebee0e7a02077c998b83b1c38 (patch) | |
tree | bcc5de4d6b4a1ac0efb0429815b3f79fdf8771ab /ofxclient |
Imported Upstream version 1.3.8
Diffstat (limited to 'ofxclient')
-rw-r--r-- | ofxclient/__init__.py | 4 | ||||
-rw-r--r-- | ofxclient/account.py | 303 | ||||
-rw-r--r-- | ofxclient/cli.py | 221 | ||||
-rw-r--r-- | ofxclient/client.py | 203 | ||||
-rw-r--r-- | ofxclient/config.py | 315 | ||||
-rw-r--r-- | ofxclient/institution.py | 199 | ||||
-rw-r--r-- | ofxclient/util.py | 24 | ||||
-rw-r--r-- | ofxclient/version.py | 1 |
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' |