Skip to content

Commit 9b001d1

Browse files
committed
Added Counter backport for Python 2.6. Replaced backend with requests oauth library. Added verify_ssl kwarg for python 2.6 issues with Intuit SSL cert
1 parent 30652eb commit 9b001d1

File tree

10 files changed

+260
-62
lines changed

10 files changed

+260
-62
lines changed

README.md

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,7 @@ pip install python-aggcat
1010

1111
## Release Notes
1212

13-
**0.2**
14-
* Cleanup
15-
* Made end_date an optional parameter in `get_account_transactions` to reflect intuit
16-
* Added `requirements.pip` file do that docs build correctly on readthedocs.org
17-
18-
**0.1**
19-
* Initial Release
13+
[https://aggcat.readthedocs.org/en/latest/#release-notes](https://aggcat.readthedocs.org/en/latest/#release-notes)
2014

2115
## Full documentation
2216

aggcat/client.py

Lines changed: 47 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
from __future__ import absolute_import
22

33
import urlparse
4-
from urllib import urlencode
54

65
import requests
7-
import oauth2
6+
from requests_oauthlib import OAuth1Session
87
from lxml import etree
98

109
from .saml import SAML
@@ -15,14 +14,14 @@
1514

1615

1716
class AggCatResponse(object):
18-
"""General response object that contains oAuth2 response object
19-
and the content"""
20-
def __init__(self, headers, content):
21-
self.headers = headers
17+
"""General response object that contains the HTTP status code
18+
and response text"""
19+
def __init__(self, status_code, content):
20+
self.status_code = status_code
2221
self.content = content
2322

2423
def __repr__(self):
25-
return u'<AggCatResponse %s>' % self.headers.status
24+
return u'<AggCatResponse %s>' % self.status_code
2625

2726

2827
class AggcatClient(object):
@@ -36,6 +35,7 @@ class AggcatClient(object):
3635
testing you can use whatever integer you want.
3736
:param string private_key: The absolute path to the generated x509 private key
3837
:param boolean objectify: (optional) Convert XML into pythonic object on every API call. Default: ``True``
38+
:param boolean verify_ssl: (optional) Verify SSL Certificate. See :ref:`known_issues`. Default: ``True``
3939
4040
:returns: :class:`AggcatClient`
4141
@@ -61,7 +61,7 @@ class AggcatClient(object):
6161
``objectify`` (Boolean) This is a BETA functionality. It will objectify the XML returned from
6262
intuit into standard python objects so you don't have to mess with XML. Default: ``True``
6363
"""
64-
def __init__(self, consumer_key, consumer_secret, saml_identity_provider_id, customer_id, private_key, objectify=True):
64+
def __init__(self, consumer_key, consumer_secret, saml_identity_provider_id, customer_id, private_key, objectify=True, verify_ssl=True):
6565
# base API url
6666
self.base_url = 'https://financialdatafeed.platform.intuit.com/rest-war/v1'
6767

@@ -72,6 +72,9 @@ def __init__(self, consumer_key, consumer_secret, saml_identity_provider_id, cus
7272
self.customer_id = customer_id
7373
self.private_key = private_key
7474

75+
# verify ssl
76+
self.verify_ssl = verify_ssl
77+
7578
# SAML object to help create SAML assertion message
7679
self.saml = SAML(private_key, saml_identity_provider_id, customer_id)
7780

@@ -88,17 +91,15 @@ def __init__(self, consumer_key, consumer_secret, saml_identity_provider_id, cus
8891
self.client = self._client()
8992

9093
def _client(self):
91-
"""Build an oAuth2 client from consumer tokens, and oauth tokens"""
92-
consumer = oauth2.Consumer(
93-
key=self.consumer_key,
94-
secret=self.consumer_secret
95-
)
96-
token = oauth2.Token(
97-
key=self._oauth_tokens['oauth_token'][0],
98-
secret=self._oauth_tokens['oauth_token_secret'][0]
94+
"""Build an oAuth client from consumer tokens, and oauth tokens"""
95+
# initialze the oauth session for requests
96+
session = OAuth1Session(
97+
self.consumer_key,
98+
self.consumer_secret,
99+
self._oauth_tokens['oauth_token'][0],
100+
self._oauth_tokens['oauth_token_secret'][0]
99101
)
100-
101-
return oauth2.Client(consumer, token)
102+
return session
102103

103104
def _get_oauth_tokens(self):
104105
"""Get an oauth token by sending over the SAML assertion"""
@@ -123,48 +124,51 @@ def _refresh_client(self):
123124
# get a new client
124125
self.client = self._client()
125126

126-
def _build_url(self, path, query):
127-
"""Build a url from a string path and dict query"""
128-
if query:
129-
return '%s/%s?%s' % (self.base_url, path, urlencode(query))
130-
else:
131-
return '%s/%s' % (self.base_url, path)
127+
def _build_url(self, path):
128+
"""Build a url from a string path"""
129+
return '%s/%s' % (self.base_url, path)
132130

133131
def _make_request(self, path, method='GET', body=None, query={}, headers={}):
134132
"""Make the signed request to the API"""
135-
url = self._build_url(path, query)
133+
# build the query url
134+
url = self._build_url(path)
136135

137136
# check for plain object request
138137
return_obj = self.objectify
139138

140-
if method == 'GET' or method == 'DELETE':
141-
response, content = self.client.request(url, method)
142-
else:
139+
if method == 'GET':
140+
response = self.client.get(url, params=query, verify=self.verify_ssl)
141+
142+
if method == 'PUT':
143143
headers.update({'Content-Type': 'application/xml'})
144-
response, content = self.client.request(
145-
url,
146-
method=method,
147-
body=body,
148-
headers=headers,
149-
realm=None
150-
)
144+
response = self.client.put(url, data=body, headers=headers, verify=self.verify_ssl)
145+
146+
if method == 'DELETE':
147+
response = self.client.delete(url, verify=self.verify_ssl)
148+
149+
if method == 'POST':
150+
headers.update({'Content-Type': 'application/xml'})
151+
response = self.client.post(url, data=body, headers=headers, verify=self.verify_ssl)
151152

152153
# refresh the token if token expires and retry the query
153-
if 'www-authenticate' in response and response['www-authenticate'] == \
154-
'OAuth oauth_problem="token_rejected"':
155-
self._refresh_client()
156-
self._make_request(path, method, body, query, headers)
154+
if 'www-authenticate' in response.headers:
155+
if response.headers['www-authenticate'] == 'OAuth oauth_problem="token_rejected"':
156+
self._refresh_client()
157+
self._make_request(path, method, body, query, headers)
157158

158-
if response.status not in [200, 201, 401]:
159-
raise HTTPError('Status Code: %s, Response %s' % (response.status, content,))
159+
if response.status_code not in [200, 201, 401]:
160+
raise HTTPError('Status Code: %s, Response %s' % (response.status_code, response.text,))
160161

161162
if return_obj:
162163
try:
163-
return AggCatResponse(response, Objectify(content).get_object())
164+
return AggCatResponse(
165+
response.status_code,
166+
Objectify(response.content).get_object()
167+
)
164168
except etree.XMLSyntaxError:
165169
return None
166170

167-
return AggCatResponse(response, content)
171+
return AggCatResponse(response.status_code, response.content)
168172

169173
def _remove_namespaces(self, tree):
170174
"""Remove the namspaces from the Intuit XML for easier parsing"""

aggcat/counter.py

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
# http://code.activestate.com/recipes/576611-counter-class/
2+
from operator import itemgetter
3+
from heapq import nlargest
4+
from itertools import repeat, ifilter
5+
6+
7+
class Counter(dict):
8+
'''Dict subclass for counting hashable objects. Sometimes called a bag
9+
or multiset. Elements are stored as dictionary keys and their counts
10+
are stored as dictionary values.
11+
12+
>>> Counter('zyzygy')
13+
Counter({'y': 3, 'z': 2, 'g': 1})
14+
15+
'''
16+
17+
def __init__(self, iterable=None, **kwds):
18+
'''Create a new, empty Counter object. And if given, count elements
19+
from an input iterable. Or, initialize the count from another mapping
20+
of elements to their counts.
21+
22+
>>> c = Counter() # a new, empty counter
23+
>>> c = Counter('gallahad') # a new counter from an iterable
24+
>>> c = Counter({'a': 4, 'b': 2}) # a new counter from a mapping
25+
>>> c = Counter(a=4, b=2) # a new counter from keyword args
26+
27+
'''
28+
self.update(iterable, **kwds)
29+
30+
def __missing__(self, key):
31+
return 0
32+
33+
def most_common(self, n=None):
34+
'''List the n most common elements and their counts from the most
35+
common to the least. If n is None, then list all element counts.
36+
37+
>>> Counter('abracadabra').most_common(3)
38+
[('a', 5), ('r', 2), ('b', 2)]
39+
40+
'''
41+
if n is None:
42+
return sorted(self.iteritems(), key=itemgetter(1), reverse=True)
43+
return nlargest(n, self.iteritems(), key=itemgetter(1))
44+
45+
def elements(self):
46+
'''Iterator over elements repeating each as many times as its count.
47+
48+
>>> c = Counter('ABCABC')
49+
>>> sorted(c.elements())
50+
['A', 'A', 'B', 'B', 'C', 'C']
51+
52+
If an element's count has been set to zero or is a negative number,
53+
elements() will ignore it.
54+
55+
'''
56+
for elem, count in self.iteritems():
57+
for _ in repeat(None, count):
58+
yield elem
59+
60+
# Override dict methods where the meaning changes for Counter objects.
61+
62+
@classmethod
63+
def fromkeys(cls, iterable, v=None):
64+
raise NotImplementedError(
65+
'Counter.fromkeys() is undefined. Use Counter(iterable) instead.')
66+
67+
def update(self, iterable=None, **kwds):
68+
'''Like dict.update() but add counts instead of replacing them.
69+
70+
Source can be an iterable, a dictionary, or another Counter instance.
71+
72+
>>> c = Counter('which')
73+
>>> c.update('witch') # add elements from another iterable
74+
>>> d = Counter('watch')
75+
>>> c.update(d) # add elements from another counter
76+
>>> c['h'] # four 'h' in which, witch, and watch
77+
4
78+
79+
'''
80+
if iterable is not None:
81+
if hasattr(iterable, 'iteritems'):
82+
if self:
83+
self_get = self.get
84+
for elem, count in iterable.iteritems():
85+
self[elem] = self_get(elem, 0) + count
86+
else:
87+
dict.update(self, iterable) # fast path when counter is empty
88+
else:
89+
self_get = self.get
90+
for elem in iterable:
91+
self[elem] = self_get(elem, 0) + 1
92+
if kwds:
93+
self.update(kwds)
94+
95+
def copy(self):
96+
'Like dict.copy() but returns a Counter instance instead of a dict.'
97+
return Counter(self)
98+
99+
def __delitem__(self, elem):
100+
'Like dict.__delitem__() but does not raise KeyError for missing values.'
101+
if elem in self:
102+
dict.__delitem__(self, elem)
103+
104+
def __repr__(self):
105+
if not self:
106+
return '%s()' % self.__class__.__name__
107+
items = ', '.join(map('%r: %r'.__mod__, self.most_common()))
108+
return '%s({%s})' % (self.__class__.__name__, items)
109+
110+
# Multiset-style mathematical operations discussed in:
111+
# Knuth TAOCP Volume II section 4.6.3 exercise 19
112+
# and at http://en.wikipedia.org/wiki/Multiset
113+
#
114+
# Outputs guaranteed to only include positive counts.
115+
#
116+
# To strip negative and zero counts, add-in an empty counter:
117+
# c += Counter()
118+
119+
def __add__(self, other):
120+
'''Add counts from two counters.
121+
122+
>>> Counter('abbb') + Counter('bcc')
123+
Counter({'b': 4, 'c': 2, 'a': 1})
124+
125+
126+
'''
127+
if not isinstance(other, Counter):
128+
return NotImplemented
129+
result = Counter()
130+
for elem in set(self) | set(other):
131+
newcount = self[elem] + other[elem]
132+
if newcount > 0:
133+
result[elem] = newcount
134+
return result
135+
136+
def __sub__(self, other):
137+
''' Subtract count, but keep only results with positive counts.
138+
139+
>>> Counter('abbbc') - Counter('bccd')
140+
Counter({'b': 2, 'a': 1})
141+
142+
'''
143+
if not isinstance(other, Counter):
144+
return NotImplemented
145+
result = Counter()
146+
for elem in set(self) | set(other):
147+
newcount = self[elem] - other[elem]
148+
if newcount > 0:
149+
result[elem] = newcount
150+
return result
151+
152+
def __or__(self, other):
153+
'''Union is the maximum of value in either of the input counters.
154+
155+
>>> Counter('abbb') | Counter('bcc')
156+
Counter({'b': 3, 'c': 2, 'a': 1})
157+
158+
'''
159+
if not isinstance(other, Counter):
160+
return NotImplemented
161+
_max = max
162+
result = Counter()
163+
for elem in set(self) | set(other):
164+
newcount = _max(self[elem], other[elem])
165+
if newcount > 0:
166+
result[elem] = newcount
167+
return result
168+
169+
def __and__(self, other):
170+
''' Intersection is the minimum of corresponding counts.
171+
172+
>>> Counter('abbb') & Counter('bcc')
173+
Counter({'b': 1})
174+
175+
'''
176+
if not isinstance(other, Counter):
177+
return NotImplemented
178+
_min = min
179+
result = Counter()
180+
if len(self) < len(other):
181+
self, other = other, self
182+
for elem in ifilter(self.__contains__, other):
183+
newcount = _min(self[elem], other[elem])
184+
if newcount > 0:
185+
result[elem] = newcount
186+
return result

aggcat/docs/requirements.pip

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
lxml==3.2.1
22
M2Crypto==0.21.1
33
requests==1.2.0
4-
git+https://github.com/glenbot/python-oauth2.git
4+
requests-oauthlib==0.3.3

aggcat/docs/source/conf.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,9 @@
4949
# built documents.
5050
#
5151
# The short X.Y version.
52-
version = '0.2'
52+
version = '0.3'
5353
# The full version, including alpha/beta/rc tags.
54-
release = '0.2'
54+
release = '0.3'
5555

5656
# The language for content autogenerated by Sphinx. Refer to documentation
5757
# for a list of supported languages.

aggcat/docs/source/index.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@ Installation
2626
pip install python-aggcat
2727

2828

29+
.. _known_issues:
30+
31+
Known Issues
32+
------------
33+
34+
The SSL library in Python 2.6 and below has a bug and will not parse the ``AlternativeNames`` out of the Intuit SSL cert causing a name mismatch during cetificate validation. For now, please pass ``verify_ssl = False`` to the :class:`AggcatClient` when initializing it. While less secure, I wanted the verification to be turned off explictly so you are aware. If possible, upgrade to Python 2.7+.
35+
2936
Initializing the API Client
3037
---------------------------
3138

0 commit comments

Comments
 (0)