pax_global_header00006660000000000000000000000064134301465260014516gustar00rootroot0000000000000052 comment=4da2719f0ecbbf5eee62fb82c1b3b34ec955ee5e ofxclient-2.0.4/000077500000000000000000000000001343014652600135145ustar00rootroot00000000000000ofxclient-2.0.4/.atom-build.yml000066400000000000000000000003621343014652600163530ustar00rootroot00000000000000cmd: "python" name: "build" args: - "setup.py" - "sdist" targets: test: cmd: "python" name: "test" args: - "setup.py" - "test" errorMatch: - ".*File \"(?[\\/0-9a-zA-Z\\._\\-]+)\", line (?\\d+).*" ofxclient-2.0.4/.gitignore000066400000000000000000000005361343014652600155100ustar00rootroot00000000000000.buildinfo .doctrees venv *.py[co] # Packages *.egg *.egg-info .eggs dist build eggs parts bin var sdist develop-eggs .installed.cfg # Installer logs pip-log.txt # Unit test / coverage reports .coverage .tox tests/python_keyring/keyring_pass.cfg # ofx files in root folder /*.ofx #Translations *.mo #Vim *.swp #Mr Developer .mr.developer.cfg ofxclient-2.0.4/.travis.yml000066400000000000000000000002321343014652600156220ustar00rootroot00000000000000language: python python: - "2.7" - "3.4" - "3.5" - "3.6" install: - pip install -r requirements-test.txt script: nosetests --verbose tests/*.py ofxclient-2.0.4/AUTHORS000066400000000000000000000011061343014652600145620ustar00rootroot00000000000000Maintainers: David Bartle Thanks: Jeremy Jongsma - The first ofx-ba.py script Jeffrey Paul - Encouraging my to improve this module past homebrew status @jbms - Jumpstarting the python 3 support Rudd-O - Encouraging security review and communication @tgoetze - encoding and windows bug fixes lsowen - Additional CLI options mattprompt - python 3 fixes ridler77 - bug fixes gboudreau - TD Bank fix fanqiuwen - Discover card fix jantman - Additional Discover fix, Vanguard cookie fix Everyone who uses this - I'm honored that you find this hobby project useful ofxclient-2.0.4/CHANGELOG.md000066400000000000000000000020351343014652600153250ustar00rootroot00000000000000# Change Log All notable changes to this project will be documented in this file. ## [2.0.4] - 2019-02-10 - fix a number of python3 encoding issues - fix bug with ConfigParser - Vanguard and Citibank bug fixes ## [2.0.3] - 2017-04-27 - Update quicken client version to 2500 - CLI option to set OFX version - CLI option to support changing how far back to download - Python 3 bug fixes - Fix Discover card header requirements - Fix TD Bank header requirements ## [2.0.2] - 2015-05-30 - Bug: fix get password on windows - Bug: ignore unexpected chars when decoding - Dockerfile - Lock versions of dependencies in requirements.txt - Force parsing backend to 'lxml' HTML ## [2.0.1] - 2016-02-11 ### Added - Use unicode instead of byte string wherever possible - Dropping Python 2.5 support - Adding Python 3 support ### Fixed - Merging multiple OFX documents into one now closes tag properly. - LICENSE file included in dist - More runtime testing of keychain configuration ## [1.3.8] - 2013-03-26 - Allow this to work on non-unix or Google App Engine ofxclient-2.0.4/Dockerfile000066400000000000000000000006441343014652600155120ustar00rootroot00000000000000FROM python:2.7-onbuild MAINTAINER David Bartle RUN mkdir -p /root/.local/share/python_keyring RUN mkdir -p /export COPY keyringrc.cfg /root/.local/share/python_keyring RUN python setup.py install VOLUME /export VOLUME /root/.local/share/python_keyring # so that ofxclient will download to the shared volume by default WORKDIR /export ENTRYPOINT ["ofxclient", "-c", "/export/ofxconfig.ini"] ofxclient-2.0.4/LICENSE000066400000000000000000000020401343014652600145150ustar00rootroot00000000000000Copyright (c) 2012 David Bartle Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ofxclient-2.0.4/MANIFEST.in000066400000000000000000000000601343014652600152460ustar00rootroot00000000000000include *.md include LICENSE include tests/*.py ofxclient-2.0.4/README000077700000000000000000000000001343014652600156462README.mdustar00rootroot00000000000000ofxclient-2.0.4/README.md000066400000000000000000000027741343014652600150050ustar00rootroot00000000000000Overview ========= Simple ofxclient command line utility and OFX client libraries for development. Full Documentation ================== http://captin411.github.com/ofxclient/ Quick Start =========== > $ ofxclient --download combined.ofx --open --verbose Security Notes ============== Initial Setup ------------- When using the command line tool, initial account setup uses a third party website. The website http://ofxhome.com will be consulted in order to determine information about banks. For example, the API url that your bank wants to use for their OFX communication. You will be transmitting the name of your bank to a third party over an insecure channel. Your password and username are not transmitted during the "search" phase. The username and password will be transmitted to the URL for your bank as provided by ofxhome.com. You will be shown the URL that will be used. If the URL does not look appropriate, or otherwise appears to be tampered with, then do not submit your username and password during the setup phase. Bank Information Storage ------------------------ A configuration file `$HOME/ofxclient.ini` is used to store the information about the banks that you want to download transactions from. Your username and password are stored encrypted however your account number, routing number, bank name, and account type are not encrypted and are visible to anyone with access to this file on your local computer. For full details on the config file, see: http://captin411.github.com/ofxclient/ ofxclient-2.0.4/example/000077500000000000000000000000001343014652600151475ustar00rootroot00000000000000ofxclient-2.0.4/example/amex.py000066400000000000000000000010431343014652600164510ustar00rootroot00000000000000from ofxclient import Institution inst = Institution( id = '3101', org = 'AMEX', url = 'https://online.americanexpress.com/myca/ofxdl/desktop/desktopDownload.do?request_type=nl_ofxdownload', username = 'genewilder', password = 'ihatecandy' ) accounts = inst.accounts() for a in accounts: # a StringIO wrapped string of the raw OFX download download = a.download(days=5) print download.read() # an ofxparse.Statement object statement = a.statement(days=5) print statement.balance ofxclient-2.0.4/keyringrc.cfg000066400000000000000000000000751343014652600161740ustar00rootroot00000000000000[backend] default-keyring=keyrings.alt.file.EncryptedKeyring ofxclient-2.0.4/ofxclient/000077500000000000000000000000001343014652600155075ustar00rootroot00000000000000ofxclient-2.0.4/ofxclient/__init__.py000066400000000000000000000004531343014652600176220ustar00rootroot00000000000000from __future__ import absolute_import from __future__ import unicode_literals from ofxclient.account import ( Account, BrokerageAccount, CreditCardAccount, BankAccount ) from ofxclient.client import Client from ofxclient.institution import Institution from ofxclient.version import __version__ ofxclient-2.0.4/ofxclient/account.py000066400000000000000000000240151343014652600175170ustar00rootroot00000000000000from __future__ import absolute_import from __future__ import unicode_literals import datetime import hashlib try: # python 3 from io import StringIO, BytesIO IS_PYTHON_2 = False except ImportError: # python 2 from StringIO import StringIO IS_PYTHON_2 = True import time from ofxparse import OfxParser, AccountType 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)).encode()).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` """ 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(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` """ if IS_PYTHON_2: return OfxParser.parse( self.download(days=days) ) else: return OfxParser.parse( BytesIO(self.download(days=days).read().encode()) ) 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 ofxclient-2.0.4/ofxclient/cli.py000066400000000000000000000234351343014652600166370ustar00rootroot00000000000000from __future__ import absolute_import from __future__ import unicode_literals import argparse import getpass import io import logging import os import os.path import sys from ofxhome import OFXHome from ofxclient.account import BankAccount, BrokerageAccount, CreditCardAccount from ofxclient.config import OfxConfig from ofxclient.institution import Institution from ofxclient.util import combined_download from ofxclient.client import DEFAULT_OFX_VERSION AUTO_OPEN_DOWNLOADS = 1 DOWNLOAD_DAYS = 30 GlobalConfig = None def run(): global GlobalConfig parser = argparse.ArgumentParser(prog='ofxclient') parser.add_argument('-a', '--account') parser.add_argument('-d', '--download', type=argparse.FileType('wb', 0)) parser.add_argument('-o', '--open', action='store_true') parser.add_argument('-v', '--verbose', action='store_true') parser.add_argument('-c', '--config', help='config file path') parser.add_argument('--download-days', default=DOWNLOAD_DAYS, type=int, help='number of days to download (default: %s)' % DOWNLOAD_DAYS) parser.add_argument('--ofx-version', default=DEFAULT_OFX_VERSION, type=int, help='ofx version to use for new accounts (default: %s)' % DEFAULT_OFX_VERSION) args = parser.parse_args() if args.config: GlobalConfig = OfxConfig(file_name=args.config) else: GlobalConfig = OfxConfig() accounts = GlobalConfig.accounts() account_ids = [a.local_id() for a in accounts] if args.verbose: logging.basicConfig(level=logging.DEBUG) if args.download: if accounts: if args.account: a = GlobalConfig.account(args.account) ofxdata = a.download(days=args.download_days) else: ofxdata = combined_download(accounts, days=args.download_days) args.download.write(ofxdata.read().encode()) if args.open: open_with_ofx_handler(args.download.name) sys.exit(0) else: print("no accounts configured") main_menu(args) def main_menu(args): 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(args) elif choice == 'd': if not accounts: print("no accounts on file") else: ofxdata = combined_download(accounts, days=args.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, args) def add_account_menu(args): menu_title("Add account") while 1: print('------') print('Notice') print('------') print('You are about to search for bank connection information') print('on a third party website. This means you are trusting') print('http://ofxhome.com and their security policies.') print('') print('You will be sending your bank name to this website.') print('------') query = prompt('bank name eg. "express" (enter to exit)> ') 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, args): return def view_account_menu(account, args): 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" % args.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(" User-Agent header: %s" % client.user_agent) print(" Accept header: %s" % client.accept) print(" Config File: %s" % GlobalConfig.file_name) menu_item('D', 'Download') choice = prompt().lower() if choice == 'd': out = account.download(days=args.download_days) wrote = write_and_handle_download(out, "%s.ofx" % account.local_id()) print("wrote: %s" % wrote) return def login_check_menu(bank_info, args): print('------') print('Notice') print('------') print('You are about to test to make sure your username and password') print('are correct. This means you will be sending it to the URL below.') print('If the URL does not appear to belong to your bank then you should') print('exit this program by hitting CTRL-C.') print(' bank name: %s' % (bank_info['name'])) print(' bank url: %s' % (bank_info['url'])) print('------') while 1: username = '' while not username: username = prompt('username> ') password = '' prompt_text = 'password> ' if os.name == 'nt' and sys.version_info < (3, 0): prompt_text = prompt_text.encode('utf8') while not password: password = getpass.getpass(prompt=prompt_text) 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, client_args=client_args_for_bank(bank_info, args.ofx_version) ) try: i.authenticate() except Exception as e: print("authentication failed: %s" % e) continue accounts = i.accounts() for a in accounts: GlobalConfig.add_account(a) GlobalConfig.save() return 1 def client_args_for_bank(bank_info, ofx_version): """ Return the client arguments to use for a particular Institution, as found from ofxhome. This provides us with an extension point to override or augment ofxhome data for specific institutions, such as those that require specific User-Agent headers (or no User-Agent header). :param bank_info: OFXHome bank information for the institution, as returned by ``OFXHome.lookup()`` :type bank_info: dict :param ofx_version: OFX Version argument specified on command line :type ofx_version: str :return: Client arguments for a specific institution :rtype: dict """ client_args = {'ofx_version': str(ofx_version)} if 'ofx.discovercard.com' in bank_info['url']: # Discover needs no User-Agent and no Accept headers client_args['user_agent'] = False client_args['accept'] = False if 'www.accountonline.com' in bank_info['url']: # Citi needs no User-Agent header client_args['user_agent'] = False return client_args def write_and_handle_download(ofx_data, name): outfile = io.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> '): try: # python 2 got = raw_input(text) except NameError: # python 3 got = 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() ofxclient-2.0.4/ofxclient/client.py000066400000000000000000000233171343014652600173450ustar00rootroot00000000000000from __future__ import absolute_import from __future__ import unicode_literals try: # python 3 from http.client import HTTPSConnection except ImportError: # python 2 from httplib import HTTPSConnection import logging import time try: # python 3 from urllib.parse import splittype, splithost except ImportError: # python 2 from urllib import splittype, splithost import uuid DEFAULT_APP_ID = 'QWIN' DEFAULT_APP_VERSION = '2500' DEFAULT_OFX_VERSION = '102' DEFAULT_USER_AGENT = 'httpclient' DEFAULT_ACCEPT = '*/*, application/x-ofx' LINE_ENDING = "\r\n" def ofx_uid(): 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 :param user_agent: Value to send for User-Agent HTTP header. Leave as None to send default. Set to False to not send User-Agent header. :type user_agent: str, None or False :param accept: Value to send for Accept HTTP header. Leave as None to send default. Set to False to not send User-Agent header. :type accept: str, None or False """ def __init__( self, institution, id=ofx_uid(), app_id=DEFAULT_APP_ID, app_version=DEFAULT_APP_VERSION, ofx_version=DEFAULT_OFX_VERSION, user_agent=DEFAULT_USER_AGENT, accept=DEFAULT_ACCEPT ): self.institution = institution self.id = id self.app_id = app_id self.app_version = app_version self.ofx_version = ofx_version self.user_agent = user_agent self.accept = accept # used when serializing Institutions self._init_args = { 'id': self.id, 'app_id': self.app_id, 'app_version': self.app_version, 'ofx_version': self.ofx_version, 'user_agent': self.user_agent, 'accept': self.accept } self.cookie = 3 @property def init_args(self): """ Return a dict of the arguments used to initialize this client, suitable for use when serializing an Institution. :return: constructor arguments :rtype: dict """ return self._init_args 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 LINE_ENDING.join([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): """ Wrapper around ``_do_post()`` to handle accounts that require sending back session cookies (``self.set_cookies`` True). """ res, response = self._do_post(query) cookies = res.getheader('Set-Cookie', None) if len(response) == 0 and cookies is not None and res.status == 200: logging.debug('Got 0-length 200 response with Set-Cookies header; ' 'retrying request with cookies') _, response = self._do_post(query, [('Cookie', cookies)]) return response def _do_post(self, query, extra_headers=[]): """ Do a POST to the Institution. :param query: Body content to POST (OFX Query) :type query: str :param extra_headers: Extra headers to send with the request, as a list of (Name, Value) header 2-tuples. :type extra_headers: list :return: 2-tuple of (HTTPResponse, str response body) :rtype: tuple """ i = self.institution logging.debug('posting data to %s' % i.url) garbage, path = splittype(i.url) host, selector = splithost(path) h = HTTPSConnection(host, timeout=60) # Discover requires a particular ordering of headers, so send the # request step by step. h.putrequest('POST', selector, skip_host=True, skip_accept_encoding=True) headers = [ ('Content-Type', 'application/x-ofx'), ('Host', host), ('Content-Length', len(query)), ('Connection', 'Keep-Alive') ] if self.accept: headers.append(('Accept', self.accept)) if self.user_agent: headers.append(('User-Agent', self.user_agent)) for ehname, ehval in extra_headers: headers.append((ehname, ehval)) logging.debug('---- request headers ----') for hname, hval in headers: logging.debug('%s: %s', hname, hval) h.putheader(hname, hval) logging.debug('---- request body (query) ----') logging.debug(query) h.endheaders(query.encode()) res = h.getresponse() response = res.read().decode('ascii', 'ignore') logging.debug('---- response ----') logging.debug(res.__dict__) logging.debug('Headers: %s', res.getheaders()) logging.debug(response) res.close() return res, 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 LINE_ENDING.join(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 LINE_ENDING.join(['<'+tag+'>']+list(contents)+['']) def now(): return time.strftime("%Y%m%d%H%M%S", time.localtime()) ofxclient-2.0.4/ofxclient/config.py000066400000000000000000000247511343014652600173370ustar00rootroot00000000000000from __future__ import absolute_import from __future__ import unicode_literals from __future__ import with_statement try: # python 3 from configparser import ConfigParser except ImportError: # python 2 from ConfigParser import ConfigParser try: import keyring keyring.get_password('is-backend', 'configured?') KEYRING_AVAILABLE = True except RuntimeError: # no keyring backend found KEYRING_AVAILABLE = False except ImportError: KEYRING_AVAILABLE = False import os import os.path import sys from ofxclient.account import Account try: DEFAULT_CONFIG = os.path.expanduser(os.path.join('~', 'ofxclient.ini')) except: DEFAULT_CONFIG = None class SecurableConfigParser(ConfigParser): """:py:class:`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','wb') as fp: c.write(fp) """ _secure_placeholder = '%{secured}' def __init__(self, keyring_name='ofxclient', keyring_available=KEYRING_AVAILABLE, **kwargs): if sys.version_info >= (3,): # python 3 ConfigParser.__init__(self, interpolation=None) else: # python 2 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) if v == '!!False!!': v = False 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 not value: value = '!!False!!' 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': res = self._unsaved[s_option][1] else: res = keyring.get_password(self.keyring_name, s_option) else: res = ConfigParser.get(self, section, option, *args) if res == '!!False!!': return False return res 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() with open(self.file_name) as f: if hasattr(conf, 'read_file'): # python 3 conf.read_file(f) else: # python 2 conf.readfp(f) 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 ofxclient-2.0.4/ofxclient/institution.py000066400000000000000000000143231343014652600204550ustar00rootroot00000000000000from __future__ import absolute_import from __future__ import unicode_literals import hashlib try: # python 3 from io import StringIO, BytesIO IS_PYTHON_2 = False except ImportError: # python 2 from StringIO import StringIO IS_PYTHON_2 = True from bs4 import BeautifulSoup from ofxparse import OfxParser from ofxclient.client import Client 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 `_ 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)).encode()).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 = BeautifulSoup(res, 'lxml') 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(resp) if IS_PYTHON_2: parsed = OfxParser.parse(resp_handle) else: parsed = OfxParser.parse(BytesIO(resp_handle.read().encode())) 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', '...': 'see Client() for other options' } } :rtype: nested dictionary """ 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': self.client().init_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', {}) ) ofxclient-2.0.4/ofxclient/util.py000066400000000000000000000015031343014652600170350ustar00rootroot00000000000000from __future__ import absolute_import from __future__ import unicode_literals try: # python 3 from io import StringIO except ImportError: # python 2 from StringIO import StringIO from ofxclient.client import Client 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()) out_file.write('') for a in accounts: ofx = a.download(days=days).read() stripped = ofx.partition('')[2].partition('')[0] out_file.write(stripped) out_file.write("") out_file.seek(0) return out_file ofxclient-2.0.4/ofxclient/version.py000066400000000000000000000000761343014652600175510ustar00rootroot00000000000000from __future__ import unicode_literals __version__ = '2.0.4' ofxclient-2.0.4/requirements-test.txt000066400000000000000000000000361343014652600177540ustar00rootroot00000000000000-r requirements.txt mock nose ofxclient-2.0.4/requirements.txt000066400000000000000000000002331343014652600167760ustar00rootroot00000000000000argparse==1.4.1; python_version < '2.7' beautifulsoup4==4.4.1 keyring==8.4.1 ofxhome==0.3.3 ofxparse==0.14 lxml>=3.5.0 keyrings.alt==1.1.1 pycrypto==2.6.1 ofxclient-2.0.4/setup.cfg000066400000000000000000000001311343014652600153300ustar00rootroot00000000000000[egg_info] tag_svn_revision = false [aliases] release = register sdist bdist_egg upload ofxclient-2.0.4/setup.py000066400000000000000000000031211343014652600152230ustar00rootroot00000000000000from setuptools import setup, find_packages import re VERSIONFILE = "ofxclient/version.py" verstrline = open(VERSIONFILE, "rt").read() VSRE = r"^__version__ = ['\"]([^'\"]*)['\"]" mo = re.search(VSRE, verstrline, re.M) if mo: verstr = mo.group(1) else: raise RuntimeError("Unable to find version string in %s." % (VERSIONFILE,)) setup(name='ofxclient', version=verstr, description="OFX client for dowloading transactions from banks", long_description=open("./README.md", "r").read(), classifiers=[ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Natural Language :: English", "Operating System :: OS Independent", "Programming Language :: Python", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Utilities", "License :: OSI Approved :: MIT License", ], keywords='ofx, Open Financial Exchange, download transactions', author='David Bartle', author_email='captindave@gmail.com', url='https://github.com/captin411/ofxclient', license='MIT License', packages=find_packages(exclude=[ 'ez_setup', 'example', 'tests', 'external']), include_package_data=True, zip_safe=False, entry_points={ 'console_scripts': [ 'ofxclient = ofxclient.cli:run' ] }, install_requires=[ "argparse", "keyring", "lxml", "ofxhome", "ofxparse>0.8", "beautifulsoup4", ], test_suite='tests', ) ofxclient-2.0.4/tests/000077500000000000000000000000001343014652600146565ustar00rootroot00000000000000ofxclient-2.0.4/tests/__init__.py000066400000000000000000000000001343014652600167550ustar00rootroot00000000000000ofxclient-2.0.4/tests/account.py000066400000000000000000000052101343014652600166620ustar00rootroot00000000000000import unittest from ofxclient import Account from ofxclient import BankAccount from ofxclient import BrokerageAccount from ofxclient import CreditCardAccount from ofxclient import Institution class OfxAccountTests(unittest.TestCase): def setUp(self): institution = Institution( id='1', org='Example', url='http://example.com', username='username', password='password' ) self.institution = institution def testNumberRequired(self): a = {'institution': self.institution} self.assertRaises(TypeError, Account, **a) def testInstitutionRequired(self): a = {'number': '12345'} self.assertRaises(TypeError, Account, **a) def testMasked(self): account = Account( number='12345', institution=self.institution ) self.assertEqual(account.number_masked(), '***2345') account.number = '1234' self.assertEqual(account.number_masked(), '***1234') account.number = '123' self.assertEqual(account.number_masked(), '***123') account.number = '12' self.assertEqual(account.number_masked(), '***12') account.number = '1' self.assertEqual(account.number_masked(), '***1') def testDescription(self): account = Account( number='12345', institution=self.institution ) self.assertEqual( account.description, '***2345', 'kwarg is not required and defaults') account = Account( number='12345', institution=self.institution, description=None ) self.assertEqual(account.description, '***2345', 'None defaults') account = Account( number='12345', institution=self.institution, description='' ) self.assertEqual( account.description, '***2345', 'empty string desc defaults') account = Account( number='12345', institution=self.institution, description='0' ) self.assertEqual(account.description, '0', '0 string is preserved') account = Account( number='12345', institution=self.institution, description='passed' ) self.assertEqual(account.description, 'passed') def testNoInstitution(self): account = Account( number='12345', institution=None ) ofxclient-2.0.4/tests/institution.py000066400000000000000000000047751343014652600176360ustar00rootroot00000000000000import unittest from ofxclient import Client from ofxclient import Institution class OfxInstitutionTests(unittest.TestCase): def testClientDefaultsPreserved(self): i = Institution( id='1', org='org', url='http://example.com', username='username', password='password' ) c = Client(institution=i) ic = i.client() self.assertEqual(c.id, ic.id) self.assertEqual(c.app_id, ic.app_id) self.assertEqual(c.app_version, ic.app_version) self.assertEqual(c.ofx_version, ic.ofx_version) def testClientSomeOverride(self): i = Institution( id='1', org='org', url='http://example.com', username='username', password='password', client_args={ 'app_id': 'capp_id', } ) c = Client(institution=i) ic = i.client() self.assertEqual(ic.app_id, 'capp_id', 'overridden app_id') self.assertNotEqual(ic.app_id, c.app_id, 'overridden app_id') self.assertEqual(ic.id, c.id, 'default id') self.assertEqual(ic.app_version, c.app_version, 'default app version') self.assertEqual(ic.ofx_version, c.ofx_version, 'default ofx version') def testClientAllOverride(self): i = Institution( id='1', org='org', url='http://example.com', username='username', password='password', client_args={ 'id': 'cid', 'app_id': 'capp_id', 'app_version': 'capp_version', 'ofx_version': 'cofx_version' } ) c = i.client() self.assertEqual(c.id, 'cid') self.assertEqual(c.app_id, 'capp_id') self.assertEqual(c.app_version, 'capp_version') self.assertEqual(c.ofx_version, 'cofx_version') def testRequiredParams(self): self.assertRaises(TypeError, Institution.__init__) a = {'id': '1'} self.assertRaises(TypeError, Institution, **a) a = {'id': '1', 'org': 'org'} self.assertRaises(TypeError, Institution, **a) a = {'id': '1', 'org': 'org', 'url': 'url'} self.assertRaises(TypeError, Institution, **a) a = {'id': '1', 'org': 'org', 'url': 'url', 'username': 'username'} self.assertRaises(TypeError, Institution, **a) ofxclient-2.0.4/tests/ofxconfig.py000066400000000000000000000136131343014652600172160ustar00rootroot00000000000000import keyring from keyrings.alt.file import PlaintextKeyring import os import os.path import tempfile import unittest try: from test.support import EnvironmentVarGuard except ImportError: from test.test_support import EnvironmentVarGuard import ofxclient.config from ofxclient.config import OfxConfig from ofxclient import Institution, CreditCardAccount class OfxConfigTests(unittest.TestCase): def setUp(self): keyring.set_keyring(PlaintextKeyring()) self.env = EnvironmentVarGuard() self.temp_file = tempfile.NamedTemporaryFile() test_path = os.path.dirname(os.path.realpath(__file__)) self.env['XDG_DATA_HOME'] = test_path self.env['XDG_CONFIG_HOME'] = test_path def tearDown(self): self.temp_file.close() def testFileCreated(self): file_name = self.temp_file.name self.temp_file.close() self.assertFalse(os.path.exists(file_name)) c = OfxConfig(file_name=file_name) # noqa self.assertTrue(os.path.exists(file_name)) os.remove(file_name) def testAddAccount(self): c = OfxConfig(file_name=self.temp_file.name) i = Institution( id='1', org='org', url='url', username='user', password='pass' ) a = CreditCardAccount(institution=i, number='12345') c.add_account(a) self.assertEqual(len(c.accounts()), 1) self.assertEqual(c.account(a.local_id()).local_id(), a.local_id()) def testLoadFromFile(self): c = OfxConfig(file_name=self.temp_file.name) i = Institution( id='1', org='org', url='url', username='user', password='pass' ) a = CreditCardAccount(institution=i, number='12345') c.add_account(a) c.save() c = OfxConfig(file_name=self.temp_file.name) got = c.account(a.local_id()) self.assertEqual(len(c.accounts()), 1) self.assertEqual(got.local_id(), a.local_id()) self.assertEqual(got.number, a.number) self.assertEqual(got.institution.local_id(), a.institution.local_id()) self.assertEqual(got.institution.id, a.institution.id) self.assertEqual(got.institution.org, a.institution.org) self.assertEqual(got.institution.url, a.institution.url) self.assertEqual(got.institution.username, a.institution.username) self.assertEqual(got.institution.password, a.institution.password) def testFieldsSecured(self): if not ofxclient.config.KEYRING_AVAILABLE: return # always skip these for now return c = OfxConfig(file_name=self.temp_file.name) i = Institution( id='1', org='org', url='url', username='user', password='pass' ) a = CreditCardAccount(institution=i, number='12345') c.add_account(a) self.assertTrue( c.parser.is_secure_option(a.local_id(), 'institution.username') ) self.assertTrue( c.parser.is_secure_option(a.local_id(), 'institution.password') ) def testFieldsRemainUnsecure(self): if not ofxclient.config.KEYRING_AVAILABLE: return # always skip these for now return c = OfxConfig(file_name=self.temp_file.name) i = Institution( id='1', org='org', url='url', username='user', password='pass' ) a = CreditCardAccount(institution=i, number='12345') c.add_account(a) # pretend the user put their password in there in the clear on purpose c.parser.remove_option(a.local_id(), 'institution.password') c.parser.set(a.local_id(), 'institution.password', 'pass') c.save() c = OfxConfig(file_name=self.temp_file.name) self.assertTrue( c.parser.is_secure_option(a.local_id(), 'institution.username') ) self.assertFalse( c.parser.is_secure_option(a.local_id(), 'institution.password') ) def testResecuredAfterEncryptAccount(self): if not ofxclient.config.KEYRING_AVAILABLE: return # always skip these for now return c = OfxConfig(file_name=self.temp_file.name) i = Institution( id='1', org='org', url='url', username='user', password='pass' ) a1 = CreditCardAccount(institution=i, number='12345') c.add_account(a1) a2 = CreditCardAccount(institution=i, number='67890') c.add_account(a2) # pretend the user put their password in there in the clear on purpose # to fix something... and then wants it to be resecured later on c.parser.remove_option(a1.local_id(), 'institution.password') c.parser.set(a1.local_id(), 'institution.password', 'pass') c.save() c = OfxConfig(file_name=self.temp_file.name) self.assertEqual(len(c.accounts()), 2) self.assertEqual(len(c.encrypted_accounts()), 1) self.assertEqual(len(c.unencrypted_accounts()), 1) self.assertTrue( c.parser.is_secure_option(a1.local_id(), 'institution.username') ) self.assertFalse( c.parser.is_secure_option(a1.local_id(), 'institution.password') ) c.encrypt_account(a1.local_id()) self.assertEqual(len(c.accounts()), 2) self.assertEqual(len(c.encrypted_accounts()), 2) self.assertEqual(len(c.unencrypted_accounts()), 0) self.assertTrue( c.parser.is_secure_option(a1.local_id(), 'institution.username') ) self.assertTrue( c.parser.is_secure_option(a1.local_id(), 'institution.password') ) ofxclient-2.0.4/tests/secure_config.py000066400000000000000000000104251343014652600200450ustar00rootroot00000000000000try: from configparser import ConfigParser, NoOptionError except ImportError: from ConfigParser import ConfigParser, NoOptionError import unittest from ofxclient.config import SecurableConfigParser def makeConfig(keyring_available=True, **kwargs): conf = None conf = SecurableConfigParser(keyring_available=keyring_available, **kwargs) conf.add_section('section1') conf.add_section('section2') conf.set('section1', 'username', 'USERNAME') conf.set_secure('section1', 'password', 'PASSWORD') conf.set('section2', 'question', 'answer') conf.set_secure('section2', 'ssn', '111-11-1111') return conf class IdentifySecureOptionTests(unittest.TestCase): def testIsSecureOption(self): c = makeConfig() self.assertTrue(c.is_secure_option('section1', 'password')) self.assertTrue(c.is_secure_option('section2', 'ssn')) self.assertFalse(c.is_secure_option('section1', 'username')) self.assertFalse(c.is_secure_option('section2', 'question')) def testStaysSecure(self): c = makeConfig() self.assertTrue(c.is_secure_option('section1', 'password')) c.set('section1', 'password', 'MYPASS') self.assertTrue(c.is_secure_option('section1', 'password')) def testStaysUnsecure(self): c = makeConfig() self.assertFalse(c.is_secure_option('section1', 'username')) c.set('section1', 'username', 'MYUSER') self.assertFalse(c.is_secure_option('section1', 'username')) def testSetThenSetSecureTurnsSecure(self): c = makeConfig() c.set('section1', 'foo', 'bar') self.assertFalse(c.is_secure_option('section1', 'foo')) c.set_secure('section1', 'foo', 'bar') self.assertTrue(c.is_secure_option('section1', 'foo')) c.set('section1', 'foo', 'bar') self.assertTrue(c.is_secure_option('section1', 'foo')) def testItemsHavePasswords(self): c = makeConfig() items = sorted(c.items('section1')) self.assertEqual( items, [('password', 'PASSWORD'), ('username', 'USERNAME')] ) self.assertEqual(len(items), 2) def testSecureItems(self): c = makeConfig() items = sorted(c.secure_items('section1')) self.assertEqual(items, [('password', 'PASSWORD')]) self.assertEqual(len(items), 1) c.remove_option('section1', 'password') items = sorted(c.secure_items('section1')) self.assertEqual(len(items), 0) def testGet(self): c = makeConfig() self.assertEqual(c.get('section1', 'password'), 'PASSWORD') self.assertNotEqual( ConfigParser.get(c, 'section1', 'password'), 'PASSWORD' ) self.assertEqual(c.get('section1', 'username'), 'USERNAME') c.remove_option('section1', 'password') self.assertRaises(NoOptionError, c.get, 'section1', 'password') def testUnsavedOptions(self): c = makeConfig() s_option = "%s%s" % ('section1', 'foo2') c.set('section1', 'foo2', 'bar2') self.assertFalse(s_option in c._unsaved) c.remove_option('section1', 'foo2') self.assertFalse(s_option in c._unsaved) c.set_secure('section1', 'foo2', 'bar2') self.assertTrue(s_option in c._unsaved) self.assertTrue(c._unsaved[s_option][0] == 'set') self.assertTrue(c._unsaved[s_option][1] == 'bar2') c.remove_option('section1', 'foo2') self.assertTrue(s_option in c._unsaved) self.assertTrue(c._unsaved[s_option][0] == 'delete') self.assertTrue(c._unsaved[s_option][1] is None) def testKeyringOffSet(self): c = makeConfig(keyring_available=False) self.assertFalse(c.is_secure_option('section1', 'username')) self.assertFalse(c.is_secure_option('section1', 'password')) self.assertEqual(c._unsaved, {}) c.set_secure('section1', 'password', 'PASSWORD') self.assertFalse(c.is_secure_option('section1', 'password')) self.assertEqual(c.get('section1', 'password'), 'PASSWORD') self.assertEqual(c.get('section1', 'username'), 'USERNAME') c.remove_option('section1', 'password') self.assertFalse(c.is_secure_option('section1', 'password')) self.assertEqual(c._unsaved, {}) pass