From afdefe273fe7d03d380083436337dff66ff8cc23 Mon Sep 17 00:00:00 2001 From: Atsushi Kanaya Date: Fri, 3 Jan 2014 01:22:19 +0900 Subject: [PATCH 1/4] #61 Python 3.3 support using six module --- recurly/__init__.py | 3 +-- recurly/errors.py | 19 ++++++++------- recurly/js.py | 10 ++++---- recurly/link_header.py | 7 +++--- recurly/resource.py | 52 ++++++++++++++++++++++++----------------- setup.py | 2 +- tests/recurlytests.py | 40 ++++++++++++++++++++++++------- tests/test_resources.py | 50 ++++++++++++++++++++++----------------- 8 files changed, 112 insertions(+), 71 deletions(-) diff --git a/recurly/__init__.py b/recurly/__init__.py index ca73b11b..cb7d3049 100644 --- a/recurly/__init__.py +++ b/recurly/__init__.py @@ -1,6 +1,5 @@ import logging -from urllib import urlencode -from urlparse import urljoin +from six.moves.urllib.parse import urljoin from xml.etree import ElementTree import recurly.js as js diff --git a/recurly/errors.py b/recurly/errors.py index 03af1f69..5f300e0a 100644 --- a/recurly/errors.py +++ b/recurly/errors.py @@ -1,5 +1,6 @@ -import httplib +from six.moves import http_client as httplib from xml.etree import ElementTree +import six class ResponseError(Exception): @@ -49,7 +50,7 @@ def error(self): return el.text def __str__(self): - return unicode(self).encode('utf8') + return six.text_type(self).encode('utf8') def __unicode__(self): symbol = self.symbol @@ -57,8 +58,8 @@ def __unicode__(self): return self.error details = self.details if details is not None: - return u'%s: %s %s' % (symbol, self.message, details) - return u'%s: %s' % (symbol, self.message) + return six.u('%s: %s %s') % (symbol, self.message, details) + return six.u('%s: %s') % (symbol, self.message) class ClientError(ResponseError): @@ -86,10 +87,10 @@ def __init__(self, response_xml): self.response_text = response_xml def __str__(self): - return unicode(self).encode('utf-8') + return six.text_type(self).encode('utf-8') def __unicode__(self): - return unicode(self.response_text) + return six.text_type(self.response_text) class PaymentRequiredError(ClientError): @@ -158,7 +159,7 @@ def __str__(self): return self.message.encode('utf8') def __unicode__(self): - return u'%s: %s %s' % (self.symbol, self.field, self.message) + return six.u('%s: %s %s') % (self.symbol, self.field, self.message) @property def errors(self): @@ -186,7 +187,7 @@ def errors(self): return suberrors def __unicode__(self): - return u'; '.join(unicode(error) for error in self.errors.itervalues()) + return six.u('; ').join(six.text_type(error) for error in self.errors.itervalues()) class ServerError(ResponseError): @@ -232,7 +233,7 @@ def __init__(self, status, response_xml): self.status = status def __unicode__(self): - return unicode(self.status) + return six.text_type(self.status) error_classes = { diff --git a/recurly/js.py b/recurly/js.py index 7d5d3b28..0e4b9937 100644 --- a/recurly/js.py +++ b/recurly/js.py @@ -4,8 +4,8 @@ import os import re import time -import urllib -from urlparse import urlsplit, urljoin +import six +from six.moves.urllib.parse import urljoin, quote_plus import recurly @@ -32,9 +32,9 @@ def sign(*records): if 'timestamp' not in data: data['timestamp'] = int(time.time()) if 'nonce' not in data: - data['nonce'] = re.sub('\W+', '', base64.b64encode(os.urandom(32))) + data['nonce'] = re.sub(six.b('\W+'), six.b(''), base64.b64encode(os.urandom(32))) unsigned = to_query(data) - signed = hmac.new(PRIVATE_KEY, unsigned, hashlib.sha1).hexdigest() + signed = hmac.new(six.b(PRIVATE_KEY), six.b(unsigned), hashlib.sha1).hexdigest() return '|'.join([signed, unsigned]) @@ -53,4 +53,4 @@ def to_query(object, key=None): elif object_type in (list, tuple): return '&'.join([to_query(o, '%s[]' % key) for o in object]) else: - return '%s=%s' % (urllib.quote_plus(str(key)), urllib.quote_plus(str(object))) + return '%s=%s' % (quote_plus(str(key)), quote_plus(str(object))) diff --git a/recurly/link_header.py b/recurly/link_header.py index ce4acb7b..26924784 100644 --- a/recurly/link_header.py +++ b/recurly/link_header.py @@ -1,5 +1,6 @@ #!/usr/bin/env python # source: https://gist.github.com/1103172 +from __future__ import print_function """ HTTP Link Header Parsing @@ -47,7 +48,7 @@ def _unquotestring(instr): def _splitstring(instr, item, split): - if not instr: + if not instr: return [] return [h.strip() for h in re.findall(r'%s(?=%s|\s*$)' % (item, split), instr)] @@ -72,7 +73,7 @@ def parse_link_value(instr): """ out = {} - if not instr: + if not instr: return out for link in [h.strip() for h in link_splitter.findall(instr)]: url, params = link.split(">", 1) @@ -91,4 +92,4 @@ def parse_link_value(instr): if __name__ == '__main__': import sys if len(sys.argv) > 1: - print parse_link_value(sys.argv[1]) \ No newline at end of file + print(parse_link_value(sys.argv[1])) \ No newline at end of file diff --git a/recurly/resource.py b/recurly/resource.py index 506245ae..18ad3e05 100644 --- a/recurly/resource.py +++ b/recurly/resource.py @@ -1,20 +1,25 @@ import base64 from datetime import datetime -import httplib import logging import socket import ssl import sys -from urllib import urlencode -from urlparse import urlsplit, urljoin from xml.etree import ElementTree import iso8601 -import backports.ssl_match_hostname +import six import recurly import recurly.errors from recurly.link_header import parse_link_value +from six.moves import http_client +from six.moves.urllib.parse import urlencode, urljoin, urlsplit + + +if six.PY3: + from ssl import match_hostname +else: + from backports.ssl_match_hostname import match_hostname class Money(object): @@ -46,7 +51,7 @@ def add_to_element(self, elem): for currency, amount in self.currencies.items(): currency_el = ElementTree.Element(currency) currency_el.attrib['type'] = 'integer' - currency_el.text = unicode(amount) + currency_el.text = six.text_type(amount) elem.append(currency_el) def __getitem__(self, name): @@ -158,7 +163,7 @@ def page_for_value(cls, resp, value): page = cls(value) page.record_size = resp.getheader('X-Records') links = parse_link_value(resp.getheader('Link')) - for url, data in links.iteritems(): + for url, data in six.iteritems(links): if data.get('rel') == 'start': page.start_url = url if data.get('rel') == 'next': @@ -167,9 +172,9 @@ def page_for_value(cls, resp, value): return page -class _ValidatedHTTPSConnection(httplib.HTTPSConnection): +class _ValidatedHTTPSConnection(http_client.HTTPSConnection): - """An `httplib.HTTPSConnection` that validates the SSL connection by + """An `http_client.HTTPSConnection` that validates the SSL connection by requiring certificate validation and checking the connection's intended hostname again the validated certificate's possible hosts.""" @@ -190,7 +195,7 @@ def connect(self): ca_certs=recurly.CA_CERTS_FILE) # Let the CertificateError for failure be raised to the caller. - backports.ssl_match_hostname.match_hostname(ssl_sock.getpeercert(), self.host) + match_hostname(ssl_sock.getpeercert(), self.host) self.sock = ssl_sock @@ -230,13 +235,13 @@ def __init__(self, **kwargs): except AttributeError: self.currency = recurly.DEFAULT_CURRENCY - for key, value in kwargs.iteritems(): + for key, value in six.iteritems(kwargs): setattr(self, key, value) @classmethod def http_request(cls, url, method='GET', body=None, headers=None): """Make an HTTP request with the given method to the given URL, - returning the resulting `httplib.HTTPResponse` instance. + returning the resulting `http_client.HTTPResponse` instance. If the `body` argument is a `Resource` instance, it is serialized to XML by calling its `to_element()` method before submitting it. @@ -250,9 +255,9 @@ def http_request(cls, url, method='GET', body=None, headers=None): """ urlparts = urlsplit(url) if urlparts.scheme != 'https': - connection = httplib.HTTPConnection(urlparts.netloc) + connection = http_client.HTTPConnection(urlparts.netloc) elif recurly.CA_CERTS_FILE is None: - connection = httplib.HTTPSConnection(urlparts.netloc) + connection = http_client.HTTPSConnection(urlparts.netloc) else: connection = _ValidatedHTTPSConnection(urlparts.netloc) @@ -263,12 +268,12 @@ def http_request(cls, url, method='GET', body=None, headers=None): }) if recurly.API_KEY is None: raise recurly.UnauthorizedError('recurly.API_KEY not set') - headers['Authorization'] = 'Basic %s' % base64.b64encode('%s:' % recurly.API_KEY) + headers['Authorization'] = 'Basic %s' % base64.b64encode(six.b('%s:' % recurly.API_KEY)).decode() log = logging.getLogger('recurly.http.request') if log.isEnabledFor(logging.DEBUG): log.debug("%s %s HTTP/1.1", method, url) - for header, value in headers.iteritems(): + for header, value in six.iteritems(headers): if header == 'Authorization': value = '' log.debug("%s: %s", header, value) @@ -290,8 +295,11 @@ def http_request(cls, url, method='GET', body=None, headers=None): log = logging.getLogger('recurly.http.response') if log.isEnabledFor(logging.DEBUG): log.debug("HTTP/1.1 %d %s", resp.status, resp.reason) - for header in resp.msg.headers: - log.debug(header.rstrip('\n')) + if six.PY2: + for header in resp.msg.headers: + log.debug(header.rstrip('\n')) + else: + log.debug(resp.msg._headers) log.debug('') return resp @@ -306,7 +314,7 @@ def as_log_output(self): """ elem = self.to_element() for attrname in self.sensitive_attributes: - for sensitive_el in elem.getiterator(attrname): + for sensitive_el in elem.iter(attrname): sensitive_el.text = 'XXXXXXXXXXXXXXXX' return ElementTree.tostring(elem, encoding='UTF-8') @@ -341,7 +349,7 @@ def get(cls, uuid): @classmethod def element_for_url(cls, url): """Return the resource at the given URL, as a - (`httplib.HTTPResponse`, `xml.etree.ElementTree.Element`) tuple + (`http_client.HTTPResponse`, `xml.etree.ElementTree.Element`) tuple resulting from a ``GET`` request to that URL.""" response = cls.http_request(url) if response.status != 200: @@ -462,7 +470,7 @@ def element_for_value(cls, attrname, value): elif isinstance(value, Money): value.add_to_element(el) else: - el.text = unicode(value) + el.text = six.text_type(value) return el @@ -641,7 +649,7 @@ def delete(self): @classmethod def raise_http_error(cls, response): """Raise a `ResponseError` of the appropriate subclass in - reaction to the given `httplib.HTTPResponse`.""" + reaction to the given `http_client.HTTPResponse`.""" response_xml = response.read() logging.getLogger('recurly.http.response').debug(response_xml) exc_class = recurly.errors.error_class_for_http_status(response.status) @@ -661,7 +669,7 @@ def to_element(self): continue if attrname in self.xml_attribute_attributes: - elem.attrib[attrname] = unicode(value) + elem.attrib[attrname] = six.text_type(value) else: sub_elem = self.element_for_value(attrname, value) elem.append(sub_elem) diff --git a/setup.py b/setup.py index 7a367c32..6796e707 100755 --- a/setup.py +++ b/setup.py @@ -37,7 +37,7 @@ packages=['recurly'], install_requires=['iso8601', 'backports.ssl_match_hostname'] + more_install_requires, tests_require=['mock', - 'unittest2'], + 'six'], test_suite='unittest2.collector', zip_safe=True, ) diff --git a/tests/recurlytests.py b/tests/recurlytests.py index 72679ddf..49e58d0d 100644 --- a/tests/recurlytests.py +++ b/tests/recurlytests.py @@ -1,6 +1,6 @@ from contextlib import contextmanager from datetime import datetime -import httplib +import email import logging import os from os.path import join, dirname @@ -9,6 +9,9 @@ from xml.etree import ElementTree import mock +import six + +from six.moves import http_client def xml(text): @@ -27,7 +30,7 @@ def __init__(self, fixture): self.fixture = fixture def __enter__(self): - self.request_context = mock.patch.object(httplib.HTTPConnection, 'request') + self.request_context = mock.patch.object(http_client.HTTPConnection, 'request') self.request_context.return_value = None self.request_mock = self.request_context.__enter__() @@ -37,11 +40,32 @@ def __enter__(self): preamble_line = self.fixture_file.readline().strip() try: self.method, self.uri, http_version = preamble_line.split(None, 2) + self.method = self.method.decode() + self.uri = self.uri.decode() except ValueError: raise ValueError("Couldn't parse preamble line from fixture file %r; does it have a fixture in it?" % self.fixture) - msg = httplib.HTTPMessage(self.fixture_file, 0) - self.headers = dict((k, v.strip()) for k, v in (header.split(':', 1) for header in msg.headers)) + + # Read request headers + def read_headers(fp): + while True: + try: + line = fp.readline() + except EOFError: + return + if not line or line == six.b('\n'): + return + yield line + + if six.PY2: + msg = http_client.HTTPMessage(self.fixture_file, 0) + self.headers = dict((k, v.strip()) for k, v in (header.split(':', 1) for header in msg.headers)) + else: + # http.client.HTTPMessage doesn't have importing headers from file + msg = http_client.HTTPMessage() + headers = email.message_from_bytes(six.b('').join(read_headers(self.fixture_file))) + self.headers = dict((k, v.strip()) for k, v in headers._headers) + # self.headers = {k: v for k, v in headers._headers} msg.fp = None # Read through to the vertical space. @@ -51,11 +75,11 @@ def nextline(fp): line = fp.readline() except EOFError: return - if not line or line.startswith('\x16'): + if not line or line.startswith(six.b('\x16')): return yield line - body = ''.join(nextline(self.fixture_file)) # exhaust the request either way + body = six.b('').join(nextline(self.fixture_file)) # exhaust the request either way self.body = None if self.method in ('PUT', 'POST'): if 'Content-Type' in self.headers: @@ -67,10 +91,10 @@ def nextline(fp): # Set up the response returner. sock = mock.Mock() sock.makefile = mock.Mock(return_value=self.fixture_file) - response = httplib.HTTPResponse(sock, method=self.method) + response = http_client.HTTPResponse(sock, method=self.method) response.begin() - self.response_context = mock.patch.object(httplib.HTTPConnection, 'getresponse', lambda self: response) + self.response_context = mock.patch.object(http_client.HTTPConnection, 'getresponse', lambda self: response) self.response_mock = self.response_context.__enter__() return self diff --git a/tests/test_resources.py b/tests/test_resources.py index 1c4f8ab3..7ecad520 100644 --- a/tests/test_resources.py +++ b/tests/test_resources.py @@ -1,11 +1,16 @@ -from cStringIO import StringIO import collections import logging import time -from urlparse import urljoin from xml.etree import ElementTree +import six import recurly + +from six import StringIO +from six.moves import urllib, http_client +from six.moves.urllib.parse import urljoin + + from recurly import Account, AddOn, Adjustment, BillingInfo, Coupon, Plan, Redemption, Subscription, SubscriptionAddOn, Transaction from recurly import Money, NotFoundError, ValidationError, BadRequestError, PageError from recurlytests import RecurlyTest, xml @@ -21,7 +26,7 @@ def test_authentication(self): account_code = 'test%s' % self.test_id try: Account.get(account_code) - except recurly.UnauthorizedError, exc: + except recurly.UnauthorizedError as exc: pass else: self.fail("Updating account with invalid email address did not raise a ValidationError") @@ -53,20 +58,20 @@ def test_account(self): account.username = 'shmohawk58' account.email = 'larry.david' - account.first_name = u'L\xe4rry' + account.first_name = six.u('L\xe4rry') account.last_name = 'David' account.company_name = 'Home Box Office' account.accept_language = 'en-US' with self.mock_request('account/update-bad-email.xml'): try: account.save() - except ValidationError, exc: + except ValidationError as exc: self.assertTrue(isinstance(exc.errors, collections.Mapping)) self.assertTrue('account.email' in exc.errors) suberror = exc.errors['account.email'] self.assertEqual(suberror.symbol, 'invalid_email') self.assertTrue(suberror.message) - self.assertEqual(suberror.message, str(suberror)) + self.assertEqual(suberror.message, suberror.message) else: self.fail("Updating account with invalid email address did not raise a ValidationError") @@ -147,11 +152,12 @@ def test_add_on(self): try: add_on = AddOn(add_on_code=add_on_code, name='Mock Add-On') + exc = None with self.mock_request('add-on/need-amount.xml'): try: plan.create_add_on(add_on) - except ValidationError, exc: - pass + except ValidationError as _exc: + exc = _exc else: self.fail("Creating a plan add-on without an amount did not raise a ValidationError") error = exc.errors['add_on.unit_amount_in_cents'] @@ -207,7 +213,7 @@ def test_billing_info(self): first_name='Verena', last_name='Example', address1='123 Main St', - city=u'San Jos\xe9', + city=six.u('San Jos\xe9'), state='CA', zip='94105', country='US', @@ -232,7 +238,7 @@ def test_billing_info(self): with self.mock_request('billing-info/exists.xml'): same_binfo = same_account.billing_info self.assertEqual(same_binfo.first_name, 'Verena') - self.assertEqual(same_binfo.city, u'San Jos\xe9') + self.assertEqual(same_binfo.city, six.u('San Jos\xe9')) with self.mock_request('billing-info/deleted.xml'): binfo.delete() @@ -249,7 +255,7 @@ def test_billing_info(self): first_name='Verena', last_name='Example', address1='123 Main St', - city=u'San Jos\xe9', + city=six.u('San Jos\xe9'), state='CA', zip='94105', country='US', @@ -465,7 +471,7 @@ def test_invoice(self): with self.mock_request('invoice/error-no-charges.xml'): try: account.invoice() - except ValidationError, exc: + except ValidationError as exc: error = exc else: self.fail("Invoicing an account with no charges did not raise a ValidationError") @@ -585,7 +591,7 @@ def test_subscribe(self): with self.mock_request('subscription/error-no-billing-info.xml'): try: account.subscribe(sub) - except BadRequestError, exc: + except BadRequestError as exc: error = exc else: self.fail("Subscribing with no billing info did not raise a BadRequestError") @@ -595,7 +601,7 @@ def test_subscribe(self): first_name='Verena', last_name='Example', address1='123 Main St', - city=u'San Jos\xe9', + city=six.u('San Jos\xe9'), state='CA', zip='94105', country='US', @@ -667,7 +673,7 @@ def test_subscribe(self): first_name='Verena', last_name='Example', address1='123 Main St', - city=u'San Jos\xe9', + city=six.u('San Jos\xe9'), state='CA', zip='94105', country='US', @@ -704,7 +710,7 @@ def test_subscribe(self): first_name='Verena', last_name='Example', address1='123 Main St', - city=u'San Jos\xe9', + city=six.u('San Jos\xe9'), state='CA', zip='94105', country='US', @@ -927,11 +933,12 @@ def test_transaction_with_balance(self): currency='USD', account=Account(), ) + error = None with self.mock_request('transaction-balance/transaction-no-account.xml'): try: transaction.save() - except ValidationError, error: - pass + except ValidationError as _error: + error = _error else: self.fail("Posting a transaction without an account code did not raise a ValidationError") # Make sure there really were errors. @@ -949,11 +956,12 @@ def test_transaction_with_balance(self): currency='USD', account=account, ) + error = None with self.mock_request('transaction-balance/transaction-no-billing-fails.xml'): try: transaction.save() - except ValidationError, error: - pass + except ValidationError as _error: + error = _error else: self.fail("Posting a transaction without billing info did not raise a ValidationError") # Make sure there really were errors. @@ -963,7 +971,7 @@ def test_transaction_with_balance(self): first_name='Verena', last_name='Example', address1='123 Main St', - city=u'San Jos\xe9', + city=six.u('San Jos\xe9'), state='CA', zip='94105', country='US', From 89d7b39a1701cbd80cd23ba9b5fc882356d6c465 Mon Sep 17 00:00:00 2001 From: Atsushi Kanaya Date: Fri, 3 Jan 2014 01:39:01 +0900 Subject: [PATCH 2/4] #61 added Python 3 classifier --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 6796e707..039d2513 100755 --- a/setup.py +++ b/setup.py @@ -32,6 +32,7 @@ 'Intended Audience :: Developers', 'License :: OSI Approved :: MIT License', 'Programming Language :: Python', + 'Programming Language :: Python :: 3', 'Topic :: Internet :: WWW/HTTP', ], packages=['recurly'], From 19b01a72e7d02f9e5a80717d7a9220eaefe37de4 Mon Sep 17 00:00:00 2001 From: Atsushi Kanaya Date: Sat, 4 Jan 2014 22:39:38 +0900 Subject: [PATCH 3/4] #61 added six to install_requires --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 039d2513..0f8b3106 100755 --- a/setup.py +++ b/setup.py @@ -36,7 +36,7 @@ 'Topic :: Internet :: WWW/HTTP', ], packages=['recurly'], - install_requires=['iso8601', 'backports.ssl_match_hostname'] + more_install_requires, + install_requires=['iso8601', 'backports.ssl_match_hostname', 'six'] + more_install_requires, tests_require=['mock', 'six'], test_suite='unittest2.collector', From eee39256061a0f1e1eea70f59aefb7a1347c932c Mon Sep 17 00:00:00 2001 From: Atsushi Kanaya Date: Sun, 5 Jan 2014 23:22:39 +0900 Subject: [PATCH 4/4] #61 added python 3.3 to .travis.yml target version. --- .travis.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 78c391ed..985ce7ae 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,9 @@ language: python python: - "2.7" + - "3.3" install: - - pip install mock iso8601 backports.ssl-match-hostname --use-mirrors + - pip install six mock iso8601 backports.ssl-match-hostname --use-mirrors - python setup.py install script: - python -m unittest discover -s tests