From 61a43543c1ebae884a4f0a72c87008977749c438 Mon Sep 17 00:00:00 2001 From: Greg Brockman Date: Thu, 26 May 2011 11:13:14 -0700 Subject: [PATCH] 1.5.0 release --- .gitignore | 3 + LICENSE | 21 ++ README.rdoc | 15 ++ example.py | 5 + setup.py | 15 ++ stripe/__init__.py | 484 ++++++++++++++++++++++++++++++++++++++++++++ test/test_stripe.py | 72 +++++++ 7 files changed, 615 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.rdoc create mode 100644 example.py create mode 100644 setup.py create mode 100644 stripe/__init__.py create mode 100644 test/test_stripe.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..0d8650fed --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/build +/dist +MANIFEST diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..767e125c9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License + +Copyright (c) 2010-2011 Stripe (http://stripe.com) + +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. diff --git a/README.rdoc b/README.rdoc new file mode 100644 index 000000000..77f94196c --- /dev/null +++ b/README.rdoc @@ -0,0 +1,15 @@ += Stripe Python bindings + +== Installation + +You don't need this source code unless you want to modify the package. If you just want to use the Stripe Python bindings, you should run: + + sudo easy_install stripe + +To install from this source code, run: + + python setup.py install + += Documentation + +Please see http://stripe.com/api for the most up-to-date documentation. diff --git a/example.py b/example.py new file mode 100644 index 000000000..451f4d400 --- /dev/null +++ b/example.py @@ -0,0 +1,5 @@ +import stripe +stripe.api_key = '26OjYWkUnwDegILl9ZNVbefRjRboRSio' +print "Attempting charge..." +resp = stripe.Charge.execute(amount=200, currency='usd', card={'number' : '4242424242424242', 'exp_month' : 10, 'exp_year' : 2014}, mnemonic='customer@gmail.com') +print 'Success: %r' % (resp, ) diff --git a/setup.py b/setup.py new file mode 100644 index 000000000..bf255ec33 --- /dev/null +++ b/setup.py @@ -0,0 +1,15 @@ +from distutils.core import setup +import os, sys + +path, script = os.path.split(sys.argv[0]) +os.chdir(os.path.abspath(path)) + +setup(name='stripe', + version='1.5.0', + description='Stripe python bindings', + author='Stripe', + author_email='support@stripe.com', + url='https://stripe.com/', + packages=['stripe'], + requires=['json', 'pycurl'] +) diff --git a/stripe/__init__.py b/stripe/__init__.py new file mode 100644 index 000000000..f79545b5a --- /dev/null +++ b/stripe/__init__.py @@ -0,0 +1,484 @@ +# Stripe Python bindings +# API spec at http://stripe.com/api/spec +# Authors: Patrick Collison and Greg Brockman + +import cStringIO as StringIO +import logging +import platform +import pycurl +import sys +import urllib # need urlencode +import textwrap + +# Python 2.5 and below do not ship with json +__loaded = None +try: + import json + __loaded = hasattr(json, 'loads') +except ImportError: + pass + +if not __loaded: + try: + import simplejson as json + except ImportError: + if __loaded is None: + raise ImportError("Stripe requires a JSON library, which you do not appear to have. Please install the simplejson library. HINT: Try installing the python simplejson library via 'easy_install simplejson', or contact support@stripe.com with questions.") + else: + raise ImportError("Stripe requires a JSON library with the same interface as the Python 2.6 'json' library. You appear to have a 'json' library with a different interface. Please install the simplejson library. HINT: Try installing the python simplejson library via 'easy_install simplejson', or contact support@stripe.com with questions.") + +## Configuration variables +VERSION = '1.5.0' +logger = logging.getLogger('stripe') +logger.addHandler(logging.StreamHandler(sys.stderr)) +logger.setLevel(logging.ERROR) + +api_key = None +api_base = 'https://api.stripe.com/v1' + +## Exceptions +class StripeError(Exception): + pass + +class APIError(StripeError): + pass + +class APIConnectionError(StripeError): + pass + +class CardError(StripeError): + def __init__(self, message, param, code): + super(CardError, self).__init__(message) + self.param = param + self.code = code + +class InvalidRequestError(StripeError): + def __init__(self, message, param): + super(InvalidRequestError, self).__init__(message) + self.param = param + +class AuthenticationError(StripeError): + pass + + +def convertToStripeObject(resp, api_key): + types = { 'charge' : Charge, 'customer' : Customer, + 'invoice' : Invoice, 'invoice_item' : InvoiceItem } + if isinstance(resp, list): + return [convertToStripeObject(i, api_key) for i in resp] + elif isinstance(resp, dict): + resp = resp.copy() + klass_name = resp.get('object') + klass = types.get(klass_name, StripeObject) + return klass.constructFrom(resp, api_key) + else: + return resp + +class APIRequestor(object): + def __init__(self, key=None): + self.api_key = key + + @classmethod + def apiUrl(cls, url=''): + return '%s%s' % (api_base, url) + + @classmethod + def _utf8(cls, value): + if isinstance(value, unicode): + return value.encode('utf-8') + else: + return value + + @classmethod + def _encodeInner(cls, d): + """ + We want post vars of form: + {'foo': 'bar', 'nested': {'a': 'b', 'c': 'd'}} + to become: + foo=bar&nested[a]=b&nested[c]=d + """ + stk = [] + for key, value in d.iteritems(): + key = cls._utf8(key) + if value is None: + stk.append((key, '')) + elif isinstance(value, dict): + n = {} + for k, v in value.iteritems(): + k = cls._utf8(k) + v = cls._utf8(v) + n["%s[%s]" % (key, k)] = v + stk.extend(cls._encodeInner(n)) + else: + value = cls._utf8(value) + stk.append((key, value)) + return stk + + @classmethod + def _objects_to_ids(cls, d): + if isinstance(d, APIResource): + return d.id + elif isinstance(d, dict): + res = {} + for k, v in d.iteritems(): + res[k] = cls._objects_to_ids(v) + return res + else: + return d + + @classmethod + def encode(cls, d): + """ + Internal: encode a string for url representation + """ + return urllib.urlencode(cls._encodeInner(d)) + + def handleCurlError(self, e): + if e[0] in [pycurl.E_COULDNT_CONNECT, + pycurl.E_COULDNT_RESOLVE_HOST, + pycurl.E_OPERATION_TIMEOUTED]: + msg = "Could not connect to Stripe (%s). Please check your internet connection and try again. If this problem persists, you should check Stripe's service status at https://twitter.com/stripe, or let us know at support@stripe.com." % api_base + elif e[0] == pycurl.E_SSL_CACERT: + msg = "Could not verify Stripe's SSL certificate. Please make sure that your network is not intercepting certificates. (Try going to https://api.stripe.com in your browser.) If this problem persists, let us know at support@stripe.com." + else: + msg = "Unexpected error communicating with Stripe. If this problem persists, let us know at support@stripe.com." + msg = textwrap.fill(msg) + "\n\n(Network error: " + e[1] + ")" + raise APIConnectionError(msg) + + def handleApiError(self, rcode, resp): + try: + error = resp['error'] + except (KeyError, TypeError): + raise APIError("Invalid response object from API: %r (HTTP response code was %d)" % (rcode, resp)) + + if rcode in [400, 404]: + raise InvalidRequestError(error.get('message'), error.get('param')) + elif rcode == 401: + raise AuthenticationError(error.get('message')) + elif rcode == 402: + raise CardError(error.get('message'), error.get('param'), error.get('code')) + else: + raise APIError(error.get('message')) + + def request(self, meth, url, params={}): + """ + Mechanism for issuing an API call + """ + my_api_key = self.api_key or api_key + if my_api_key is None: + raise AuthenticationError('No API key provided. (HINT: set your API key using "stripe.api_key = ". You can generate API keys from the Stripe web interface. See https://stripe.com/api for details, or email support@stripe.com if you have any questions.') + + abs_url = self.apiUrl(url) + params = params.copy() + self._objects_to_ids(params) + + s = StringIO.StringIO() + curl = pycurl.Curl() + ua = { + 'bindings_version' : VERSION, + 'lang' : 'python', + 'lang_version' : platform.python_version(), + 'platform' : platform.platform(), + 'publisher' : 'stripe', + 'uname' : ' '.join(platform.uname()) + } + headers = ['X-Stripe-Client-User-Agent: %s' % (json.dumps(ua), ), + 'User-Agent: Stripe/v1 PythonBindings/%s' % (VERSION, )] + + meth = meth.lower() + if meth == 'get': + curl.setopt(pycurl.HTTPGET, 1) + # TODO: maybe be a bit less manual here + abs_url = '%s?%s' % (abs_url, self.encode(params)) + elif meth == 'post': + curl.setopt(pycurl.POST, 1) + curl.setopt(pycurl.POSTFIELDS, self.encode(params)) + elif meth == 'delete': + curl.setopt(pycurl.CUSTOMREQUEST, 'DELETE') + else: + raise APIError('Unrecognized method %r' % (meth, )) + + # pycurl doesn't like unicode URLs + abs_url = self._utf8(abs_url) + curl.setopt(pycurl.URL, abs_url) + curl.setopt(pycurl.WRITEFUNCTION, s.write) + curl.setopt(pycurl.NOSIGNAL, 1) + curl.setopt(pycurl.HTTPAUTH, pycurl.HTTPAUTH_BASIC) + curl.setopt(pycurl.USERPWD, '%s:' % (my_api_key, )) + curl.setopt(pycurl.CONNECTTIMEOUT, 30) + curl.setopt(pycurl.TIMEOUT, 80) + curl.setopt(pycurl.HTTPHEADER, headers) + + try: + curl.perform() + except pycurl.error, e: + self.handleCurlError(e) + rcode = curl.getinfo(pycurl.RESPONSE_CODE) + rbody = s.getvalue() + + try: + resp = json.loads(rbody) + except Exception: + raise APIError("Invalid response body from API: %s (HTTP response code was %d)" % (rbody, rcode)) + + if not (200 <= rcode < 300): + self.handleApiError(rcode, resp) + + logger.info('API request to %s returned (response code, response object) of (%d, %r)' % (abs_url, rcode, resp)) + return resp, my_api_key + +class StripeObject(object): + _permanent_attributes = set(['api_key']) + _ignored_attributes = set(['id', 'api_key', 'object']) + + def __init__(self, id=None, api_key=None): + self.__dict__['_values'] = set() + self.__dict__['_unsaved_values'] = set() + self.__dict__['_transient_values'] = set() + self.__dict__['api_key'] = api_key + + if id: + self.id = id + + def __setattr__(self, k, v): + # TODO: may want to make ignored attributes immutable + self.__dict__[k] = v + self._values.add(k) + if k not in self._ignored_attributes: + self._unsaved_values.add(k) + + def __getattr__(self, k): + try: + return self.__dict__[k] + except KeyError: + pass + if k in self._transient_values: + raise AttributeError("%r object has no attribute %r. HINT: The %r attribute was set in the past, however. It was then wiped when refreshing the object with the result returned by Stripe's API, probably as a result of a save(). The attributes currently available on this object are: %s" % + (type(self).__name__, k, k, ', '.join(self._values))) + else: + raise AttributeError("%r object has no attribute %r" % (type(self).__name__, k)) + + def __getitem__(self, k): + if k in self._values: + return self.__dict__[k] + elif k in self._transient_values: + raise KeyError("%r. HINT: The %r attribute was set in the past, however. It was then wiped when refreshing the object with the result returned by Stripe's API, probably as a result of a save(). The attributes currently available on this object are: %s" % (k, k, ', '.join(self._values))) + else: + raise KeyError(k) + + def get(self, k, default=None): + try: + return self[k] + except KeyError: + return default + + def setdefault(self, k, default=None): + try: + return self[k] + except KeyError: + self[k] = default + return default + + def __setitem__(self, k, v): + setattr(self, k, v) + + def keys(self): + return self._values.keys() + + def values(self): + return self._values.keys() + + @classmethod + def constructFrom(cls, values, api_key): + instance = cls(values.get('id'), api_key) + instance.refreshFrom(values, api_key) + return instance + + def refreshFrom(self, values, api_key, partial=False): + self.api_key = api_key + + # Wipe old state before setting new. This is useful for e.g. updating a + # customer, where there is no persistent card parameter. Mark those values + # which don't persist as transient + if partial: + removed = set() + else: + removed = self._values - set(values) + + for k in removed: + if k in self._permanent_attributes: + continue + del self.__dict__[k] + self._values.discard(k) + self._transient_values.add(k) + self._unsaved_values.discard(k) + + for k, v in values.iteritems(): + if k in self._permanent_attributes: + continue + self.__dict__[k] = convertToStripeObject(v, api_key) + self._values.add(k) + self._transient_values.discard(k) + self._unsaved_values.discard(k) + + def _ident(self): + return [self.get('object'), self.get('id')] + + def __repr__(self, nested=False): + ident = [i for i in self._ident() if i] + if ident: + ident = '[%s]' % (', '.join(ident), ) + else: + ident = '' + + if nested: + return '' % (type(self).__name__, ident) + + values_str = [] + for k in sorted(self._values): + if k in self._ignored_attributes: + continue + v = getattr(self, k) + if isinstance(v, StripeObject): + v = v.__repr__(True) + elif isinstance(v, unicode): + v = repr(v) + if v[0] == 'u': + v = v[1:] + else: + v = repr(v) + if k in self._unsaved_values: + values_str.append('%s=%s (unsaved)' % (k, v)) + else: + values_str.append('%s=%s' % (k, v)) + if not values_str: + values_str.append('(no attributes)') + return '' % (type(self).__name__, ident, ', '.join(values_str)) + + def __str__(self): + return repr(self) + +class APIResource(StripeObject): + def _ident(self): + return [self.get('id')] + + @classmethod + def retrieve(cls, id, api_key=None): + instance = cls(id, api_key) + instance.refresh() + return instance + + def refresh(self): + requestor = APIRequestor(self.api_key) + url = self.instanceUrl() + response, api_key = requestor.request('get', url) + self.refreshFrom(response, api_key) + return self + + @classmethod + def classUrl(cls): + if cls == APIResource: + raise NotImplementedError('APIResource is an abstract class. You should perform actions on its subclasses (Charge, Customer, etc.)') + return "/%ss" % urllib.quote_plus(cls.__name__.lower()) + + def instanceUrl(self): + id = APIRequestor._utf8(self.id) + base = type(self).classUrl() + extn = urllib.quote_plus(id) + return "%s/%s" % (base, extn) + +# Classes of API operations +class ListableAPIResource(APIResource): + @classmethod + def all(cls, api_key=None, **params): + requestor = APIRequestor(api_key) + url = cls.classUrl() + response, api_key = requestor.request('get', url, params) + return convertToStripeObject(response, api_key) + +class CreateableAPIResource(APIResource): + @classmethod + def create(cls, api_key=None, **params): + requestor = APIRequestor(api_key) + url = cls.classUrl() + response, api_key = requestor.request('post', url, params) + return convertToStripeObject(response, api_key) + +class UpdateableAPIResource(APIResource): + def save(self): + if self._unsaved_values: + requestor = APIRequestor(self.api_key) + params = {} + for k in self._unsaved_values: + params[k] = getattr(self, k) + url = self.instanceUrl() + response, api_key = requestor.request('post', url, params) + self.refreshFrom(response, api_key) + else: + logger.debug("Trying to save already saved object %r" % (self, )) + return self + +class DeletableAPIResource(APIResource): + def delete(self): + requestor = APIRequestor(self.api_key) + url = self.instanceUrl() + response, api_key = requestor.request('delete', url) + self.refreshFrom(response, api_key) + return self + +# API objects +class Charge(CreateableAPIResource, ListableAPIResource): + def refund(self): + requestor = APIRequestor(self.api_key) + url = self.instanceUrl() + '/refund' + response, api_key = requestor.request('post', url) + self.refreshFrom(response, api_key) + return self + +class Customer(CreateableAPIResource, UpdateableAPIResource, + ListableAPIResource, DeletableAPIResource): + def add_invoice_item(self, **params): + params['customer_id'] = self.id + ii = InvoiceItem.create(self.api_key, **params) + return ii + + def invoices(self, **params): + params['customer_id'] = self.id + invoices = Invoice.all(self.api_key, **params) + return invoices + + def invoice_items(self, **params): + params['customer_id'] = self.id + iis = InvoiceItem.all(self.api_key, **params) + return iis + + def charges(self, **params): + params['customer_id'] = self.id + charges = Charge.all(self.api_key, **params) + return charges + + def update_subscription(self, **params): + requestor = APIRequestor(self.api_key) + url = self.instanceUrl() + '/subscription' + response, api_key = requestor.request('post', url, params) + self.refreshFrom({ 'subscription' : response }, api_key, True) + return self.subscription + + def cancel_subscription(self): + requestor = APIRequestor(self.api_key) + url = self.instanceUrl() + '/subscription' + response, api_key = requestor.request('delete', url) + self.refreshFrom({ 'subscription' : response }, api_key, True) + return self.subscription + +class Invoice(ListableAPIResource): + @classmethod + def upcoming(cls, **params): + requestor = APIRequestor(self.api_key) + url = self.classUrl() + '/upcoming' + response, api_key = requestor.request('get', url, params) + return convertToStripeObject(response, api_key) + +class InvoiceItem(CreateableAPIResource, UpdateableAPIResource, + ListableAPIResource, DeletableAPIResource): + pass diff --git a/test/test_stripe.py b/test/test_stripe.py new file mode 100644 index 000000000..b2947e0c0 --- /dev/null +++ b/test/test_stripe.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +import os +import sys +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) +import stripe + +class FunctionalTests(unittest.TestCase): + def setUp(self): + api_base = os.environ.get('STRIPE_API_BASE') + if api_base: + stripe.api_base = api_base + api_key = os.environ['STRIPE_API_KEY'] + if api_key: + stripe.api_key = api_key + + def test_dns_failure(self): + api_base = stripe.api_base + try: + stripe.api_base = 'https://my-invalid-domain.ireallywontresolve/v1' + self.assertRaises(stripe.APIConnectionError, stripe.Customer.create) + finally: + stripe.api_base = api_base + + def test_run(self): + c = stripe.Charge.create(amount=100, currency='usd', card={ 'number' : '4242424242424242', 'exp_month' : 03, 'exp_year' : 2015 }) + self.assertFalse(c.refunded) + c.refund() + self.assertTrue(c.refunded) + + def test_refresh(self): + c = stripe.Charge.create(amount=100, currency='usd', card={ 'number' : '4242424242424242', 'exp_month' : 03, 'exp_year' : 2015 }) + d = stripe.Charge.retrieve(c.id) + self.assertEqual(d.created, c.created) + + d.junk = 'junk' + d.refresh() + self.assertRaises(AttributeError, lambda: d.junk) + + def test_create_customer(self): + self.assertRaises(stripe.InvalidRequestError, stripe.Customer.create, plan='gold') + c = stripe.Customer.create(plan='gold', card={ 'number' : '4242424242424242', 'exp_month' : 03, 'exp_year' : 2015 }) + self.assertTrue(hasattr(c, 'subscription')) + self.assertFalse(hasattr(c, 'plan')) + c.delete() + self.assertFalse(hasattr(c, 'subscription')) + self.assertFalse(hasattr(c, 'plan')) + self.assertTrue(c.deleted) + + def test_list_customers(self): + cs = stripe.Customer.all() + self.assertTrue(isinstance(cs, list)) + + def test_list_accessors(self): + c = stripe.Customer.create(plan='gold', card={ 'number' : '4242424242424242', 'exp_month' : 03, 'exp_year' : 2015 }) + self.assertEqual(c['created'], c.created) + c['foo'] = 'bar' + self.assertEqual(c.foo, 'bar') + + def test_raise(self): + self.assertRaises(stripe.CardError, stripe.Charge.create, amount=100, currency='usd', card={ 'number' : '4242424242424241', 'exp_month' : 03, 'exp_year' : 2015 }) + + def test_unicode(self): + # Make sure unicode requests can be sent + self.assertRaises(stripe.InvalidRequestError, stripe.Charge.retrieve, id=u'☃') + + def test_none_values(self): + self.assertRaises(stripe.InvalidRequestError, stripe.Customer.create, plan=None) + +if __name__ == '__main__': + unittest.main()