Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Result refactor #23

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions chargebee/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,5 @@ def configure(api_key, site):
'api_key': api_key,
'site': site,
})

mock = ChargeBee.mock
8 changes: 4 additions & 4 deletions chargebee/compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,23 +15,23 @@

if py_major_v < 3:
from urllib import urlencode
from urlparse import urlparse
from urlparse import urlparse, urlsplit, parse_qs
from urllib2 import urlopen as _urlopen, Request
elif py_major_v >= 3:
from urllib.parse import urlencode, urlparse
from urllib.parse import urlencode, urlparse, urlsplit, parse_qs
from urllib.request import urlopen as _urlopen, Request



try:
SSLError = None
ssl = None

if Environment.chargebee_domain is None:
HTTPSConnection = object
else:
HTTPConnection = object

if py_major_v < 3:
from httplib import HTTPConnection, HTTPSConnection, HTTPException
else:
Expand Down
100 changes: 100 additions & 0 deletions chargebee/environment.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import re
from collections import namedtuple


class Environment(object):

chargebee_domain = None
Expand All @@ -15,3 +19,99 @@ def __init__(self, options):

def api_url(self, url):
return self.api_endpoint + url


class MockEnvironment(Environment):
Request = namedtuple('Request', ['method', 'url_path', 'payload'])

def __init__(self, **options):
params = {
'api_key': 'mock-api-key',
'site': 'mock-test',
}
params.update(options)
super(MockEnvironment, self).__init__(params)

self.clear()

def clear(self):
""" Clear any expected and previous requests """
self.expecting = []
self.requests = []

def add(self, url_path, response_body, response_status=200, request_method=None):
"""
Add a mock response.

url_path is the API path after the API version. eg. '/subscriptions/1mkVvvHQiQMbLBBf/cancel'
It can also be a regex pattern from re.compile()
response_body is the JSON response body to return.
It can be a string, a JSON-serializable dict, or a callable which
will be called with (url_path, payload, method) and should return a (json_body_string, http_status) tuple.
response_status is the HTTP response code to return.
This is ignored if response_body is a callable.
request_method optional HTTP method to check against the request

>>> with chargebee.mock() as mock:
>>> mock.add('/subscriptions/1mkVvvHQiQMbLBBf/cancel', {"subscription": {...}})
>>> result = chargebee.Subscription.cancel('1mkVvvHQiQMbLBBf')
>>> subscription = result.subscription
>>> mock.requests
[Request(method='POST', url_path='/subscriptions/1mkVvvHQiQMbLBBf/cancel', payload={})]
>>> chargebee.Subscription.list({})
AssertionError: No more mock requests left: /subscriptions
"""
self.expecting.append((url_path, request_method, response_body, response_status))

def request(self, method, url, payload):
"""
Called for each Chargebee request

Adds the request content to MockEnvironment.requests
Matches the next expected request and returns the associated content

Returns a (json_body_string, http_status) tuple
"""
from chargebee.compat import urlsplit, parse_qs, json

url_obj = urlsplit(url)
try:
url_path = url_obj.path.split('/api/%s' % self.API_VERSION)[1]
except IndexError:
raise ValueError("Request URL (%s) didn't match endpoint %s" % (url, self.api_endpoint))

# un-encode the body/querystring payload
payload = parse_qs(payload or url_obj.query, keep_blank_values=True)

# save to MockEnvironment.requests
self.requests.append(MockEnvironment.Request(method, url_path, payload))

# check the next expected request
try:
req_path, req_method, resp_body, resp_status = self.expecting.pop(0)
except IndexError:
raise AssertionError("No more mock requests left: %s" % url_path)

# Match the URL path
if isinstance(req_path, re._pattern_type):
# url_path is a regex
if not req_path.match(url_path):
raise AssertionError("Request URL %s does not match %s" % (url_path, req_path.pattern))
elif req_path != url_path:
# url_path is a string
raise AssertionError("Request URL %s != %s" % (url_path, req_path))

# Match any request method
if req_method and method != req_method.upper():
raise AssertionError("Request Method %s != %s (%s)" % (method, req_method, url_path))

# Return the response
if callable(resp_body):
# response_body is a callable, use it to generate the response
return resp_body(url_path, payload, method)
elif isinstance(resp_body, (dict, list, tuple)):
# response body is a dict/sequence, JSON-encode it
return (json.dumps(resp_body), resp_status)
else:
# string body
return (resp_body, resp_status)
14 changes: 10 additions & 4 deletions chargebee/http_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@
import platform
from chargebee import APIError,PaymentError,InvalidRequestError,OperationFailedError, compat
from chargebee.main import ChargeBee
from chargebee.main import Environment
from chargebee.main import Environment, MockEnvironment
from chargebee.version import VERSION


def _basic_auth_str(username):
return 'Basic ' + base64.b64encode(('%s:' % username).encode('latin1')).strip().decode('latin1')

Expand All @@ -23,6 +24,11 @@ def request(method, url, env, params=None, headers=None):
payload = compat.urlencode(params)
headers['Content-type'] = 'application/x-www-form-urlencoded'

if isinstance(env, MockEnvironment):
# we're running in a testing environment. It will provide responses
data, resp_status = env.request(method.upper(), url, payload)
return process_response(url, data, resp_status)

headers.update({
'User-Agent': 'ChargeBee-Python-Client v%s' % VERSION,
'Accept': 'application/json',
Expand All @@ -39,8 +45,8 @@ def request(method, url, env, params=None, headers=None):
if Environment.protocol == "https":
connection = compat.HTTPSConnection(meta.netloc)
else:
connection = compat.HTTPConnection(meta.netloc)
connection = compat.HTTPConnection(meta.netloc)

connection.request(method.upper(), meta.path + '?' + meta.query, payload, headers)
try:
response = connection.getresponse()
Expand All @@ -56,7 +62,7 @@ def request(method, url, env, params=None, headers=None):
def process_response(url,response, http_code):
try:
resp_json = compat.json.loads(response)
except Exception as ex:
except Exception as ex:
raise Exception("Response not in JSON format. Probably not a chargebee error. \n URL is " + url + "\n Content is \n" + response)
if http_code < 200 or http_code > 299:
handle_api_resp_error(url,http_code, resp_json)
Expand Down
19 changes: 18 additions & 1 deletion chargebee/main.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import os.path
from contextlib import contextmanager

from chargebee.environment import Environment
from chargebee.environment import Environment, MockEnvironment


class ChargeBee(object):
Expand All @@ -13,3 +14,19 @@ class ChargeBee(object):
@classmethod
def configure(cls, options):
cls.default_env = Environment(options)

@classmethod
@contextmanager
def mock(cls, **options):
"""
Context manager for unit testing environments.
This changes the default environment, so saves passing custom environments around.

See MockEnvironment for more details & example usage.
"""
prev_env = cls.default_env
try:
cls.default_env = MockEnvironment(**options)
yield cls.default_env
finally:
cls.default_env = prev_env
50 changes: 21 additions & 29 deletions chargebee/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,20 @@


class Model(object):

fields = []
fields = [] # field list
repr_field = None # field to use for repr(), default is fields[0]
sub_types = {} # mapping {attr: type}
dependant_types = {} # mapping {attr: type}. If type is a 1-tuple, indicates it's a list.

def __init__(self, values, sub_types=None, dependant_types=None):
if sub_types is None:
sub_types = {}
if dependant_types is None:
dependant_types = {}

def __init__(self, values):
self.values = values
self.sub_types = sub_types
self.dependant_types = dependant_types
for field in self.fields:
setattr(self, field, None)

def __repr__(self):
repr_field = self.repr_field or self.fields[0]
return "<chargebee.{}: {}={}>".format(self.__class__.__name__, repr_field, getattr(self, repr_field))

def __str__(self):
return json.dumps(self.values, indent=4)

Expand All @@ -40,27 +39,20 @@ def load(self, values):

# Returns null for any attribute that starts with cf_ to access the custom fields.
def __getattr__(self, name):
if( name[0:3] == "cf_"):
if( name[0:3] == "cf_"):
return None
raise AttributeError("Attribute %s not found " % name)
raise AttributeError("Attribute %s not found " % name)

@classmethod
def construct(cls, values, sub_types=None, dependant_types=None):
obj = cls(values, sub_types, dependant_types)
def construct(cls, values):
obj = cls(values)
obj.load(values)
for k, dependent_type in cls.dependant_types.items():
if values.get(k) is not None:
if isinstance(dependent_type, tuple):
# dependent type being a 1-tuple indicates a list
set_val = [dependent_type[0].construct(v) for v in values[k]]
else:
set_val = dependent_type.construct(values[k])
setattr(obj, k, set_val)
return obj

def init_dependant(self, obj, type, sub_types={}):
if obj.get(type) != None:
if isinstance(obj, dict) and type in self.dependant_types:
dependant_obj = self.dependant_types[type].construct(obj[type], sub_types)
setattr(self, type, dependant_obj)

def init_dependant_list(self, obj, type, sub_types={}):
if obj.get(type) != None:
if isinstance(obj[type],(list, tuple)) and type in self.dependant_types:
if(self.dependant_types != None):
set_val = [self.dependant_types[type].construct(dt, sub_types) for dt in obj[type]]
setattr(self, type, set_val)


3 changes: 3 additions & 0 deletions chargebee/models/addon.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ class Tier(Model):
"shipping_frequency_period_unit", "resource_version", "updated_at", "invoice_notes", "taxable", \
"tax_profile_id", "meta_data", "tiers"]

sub_types = {
'tiers' : Tier,
}

@staticmethod
def create(params, env=None, headers=None):
Expand Down
11 changes: 11 additions & 0 deletions chargebee/models/credit_note.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,17 @@ class Allocation(Model):
"round_off_amount", "fractional_correction", "line_items", "discounts", "line_item_discounts", \
"line_item_tiers", "taxes", "line_item_taxes", "linked_refunds", "allocations", "deleted"]

sub_types = {
'line_items': LineItem,
'discounts': Discount,
'line_item_discounts': LineItemDiscount,
'line_item_tiers' : LineItemTier,
'taxes': Tax,
'line_item_taxes': LineItemTax,
'linked_refunds': LinkedRefund,
'allocations': Allocation,
}


@staticmethod
def create(params, env=None, headers=None):
Expand Down
9 changes: 9 additions & 0 deletions chargebee/models/customer.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
class Customer(Model):
class BillingAddress(Model):
fields = ["first_name", "last_name", "email", "company", "phone", "line1", "line2", "line3", "city", "state_code", "state", "country", "zip", "validation_status"]
repr_field = "zip"
pass
class ReferralUrl(Model):
fields = ["external_customer_id", "referral_sharing_url", "created_at", "updated_at", "referral_campaign_id", "referral_account_id", "referral_external_campaign_id", "referral_system"]
Expand Down Expand Up @@ -34,6 +35,14 @@ class Relationship(Model):
"registered_for_gst", "business_customer_without_vat_number", "customer_type", "client_profile_id", \
"relationship"]

sub_types = {
'billing_address': BillingAddress,
'referral_urls': ReferralUrl,
'contacts': Contact,
'payment_method': PaymentMethod,
'balances': Balance,
'relationship': Relationship,
}

@staticmethod
def create(params=None, env=None, headers=None):
Expand Down
15 changes: 15 additions & 0 deletions chargebee/models/estimate.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,26 @@
from chargebee import request
from chargebee import APIError

from chargebee.models.credit_note_estimate import CreditNoteEstimate
from chargebee.models.invoice_estimate import InvoiceEstimate
from chargebee.models.subscription_estimate import SubscriptionEstimate
from chargebee.models.unbilled_charge import UnbilledCharge


class Estimate(Model):

fields = ["created_at", "subscription_estimate", "invoice_estimate", "invoice_estimates", \
"next_invoice_estimate", "credit_note_estimates", "unbilled_charge_estimates"]

dependant_types = {
'subscription_estimate': SubscriptionEstimate,
'invoice_estimate': InvoiceEstimate,
'next_invoice_estimate': InvoiceEstimate,
'invoice_estimates': (InvoiceEstimate,),
'credit_note_estimates': (CreditNoteEstimate,),
'unbilled_charge_estimates': (UnbilledCharge,),
}


@staticmethod
def create_subscription(params, env=None, headers=None):
Expand Down
11 changes: 8 additions & 3 deletions chargebee/models/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ class Webhook(Model):
fields = ["id", "occurred_at", "source", "user", "webhook_status", "webhook_failure_reason", \
"webhooks", "event_type", "api_version"]

sub_types = {
'webhooks': Webhook,
}


@property
def content(self):
from chargebee import Content
Expand All @@ -23,12 +28,12 @@ def deserialize(json_data):
webhook_data = json.loads(json_data)
except (TypeError, ValueError) as ex:
raise Exception("The passed json_data is not JSON formatted . " + ex.message)

api_version = webhook_data.get('api_version', None)
env_version = Environment.API_VERSION
if api_version != None and api_version.upper() != env_version.upper():
if api_version != None and api_version.upper() != env_version.upper():
raise Exception("API version [" + api_version.upper() + "] in response does not match "
+ "with client library API version [" + env_version.upper() + "]")
+ "with client library API version [" + env_version.upper() + "]")
return Event.construct(webhook_data)

@staticmethod
Expand Down
Loading