diff --git a/.travis.yml b/.travis.yml index f00ebfe..d642cc1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,10 +1,9 @@ language: python python: - - "2.7" - - "3.4" + - "3.6" # command to install dependencies install: - python setup.py install - pip install nose # command to run tests -script: nosetests \ No newline at end of file +script: nosetests diff --git a/examples/iphone6.py b/examples/iphone6.py new file mode 100644 index 0000000..c2941b4 --- /dev/null +++ b/examples/iphone6.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python +""" +This example creates one order containing 4 items. In this case a couple +different iPhone 6 cases. + +To run this code just edit your CUSTOMER_CODE and API_KEY +for Spoke Custom below and type: + + python iphone6.py + +""" + + +import datetime +import pprint + +import spoke + + +CUSTOMER_CODE = ... +API_KEY = ... + + +api = spoke.Spoke( + production=True, + Customer=CUSTOMER_CODE, + Key=API_KEY, + Logo={ + 'ImageType' : 'svg', + 'Url' : "https://d1s82l1atzspzx.cloudfront.net/threadless-media/static/imgs/global/threadless-logo.svg", + }, +) + +order_id = 2 +shipment_id = 2 +shipping_address = { + 'FirstName' : 'Mister', + 'LastName' : 'Mittens', + 'Address1' : '1260 W Madison St', + 'City' : 'Chicago', + 'State' : 'IL', + 'PostalCode' : '60607', + 'CountryCode' : 'US', + 'OrderDate' : datetime.datetime.now().strftime("%m/%d/%Y"), + 'PhoneNumber' : '555-555-5555', + 'PurchaseOrderNumber' : shipment_id, + 'GiftMessage' : '', + 'Prices':dict( + DisplayOnPackingSlip="No", + CurrencySymbol="$", + TaxCents='0', + ShippingCents='0', + DiscountCents='0' + ), +} + + +def get_image(filename): + return "https://d1s82l1atzspzx.cloudfront.net/threadless-media/{}?rot=270&q=95".format(filename) + + +items = [ + dict(stock_id=1, + quantity=1, + print_image=get_image('artist_designs/1680000-1760000/1713600-1715200/1714912-1714944/1714928/1714928-3938-star_iphone2.jpg'), + case_type="iph6tough" + ), + dict(stock_id=2, + quantity=1, + print_image=get_image('artist_designs/1520000-1600000/1547200-1548800/1547424-1547456/1547453/1547453-5376-attack_iphone.JPG'), + case_type="iph6tough" + ), + dict(stock_id=3, + quantity=1, + print_image=get_image('artist_designs/1840000-1920000/1883200-1884800/1884544-1884576/1884551/1884551-4709-iphonemythunderstood.jpg'), + case_type="iph6bt" + ), + dict(stock_id=4, + quantity=1, + print_image=get_image('artist_designs/1680000-1760000/1729600-1731200/1730048-1730080/1730078/1730078-5430-iphone.jpg'), + case_type="iph6bt" + ), +] + + +data = dict( + OrderId=order_id, + ShippingMethod='Overnight', + OrderInfo=shipping_address, + Cases=[ + { + 'CaseId': item['stock_id'], + 'CaseType': item["case_type"], + 'Quantity': item['quantity'], + 'PrintImage': { + 'ImageType': 'jpg', + 'Url': item['print_image'], + }, + } for item in items + ], +) + + + +pprint.pprint(data) + +api.new(**data) diff --git a/setup.py b/setup.py index 4ada749..f0ae929 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ setup( name = 'Python-Spoke', - version = '1.0.0', + version = '1.0.31', packages = find_packages(), description = 'API bindings for Spoke API', long_description = open(os.path.join(os.path.dirname(__file__), 'README.md'), 'r').read(), @@ -12,6 +12,6 @@ author_email = 'rob.hoelz@skinnycorp.com', url = 'https://github.com/Threadless/python-spoke', keywords = 'spoke', - install_requires = ['lxml==3.3.5', 'requests==2.2.1'], - tests_require = ['nose==1.3.1', 'python-termstyle==0.1.10', 'rednose==0.4.1'], + install_requires = ['lxml==4.9.3', 'requests==2.27.0'], + tests_require = ['nose==1.3.7', 'rednose==1.3.0'], ) diff --git a/spoke/__init__.py b/spoke/__init__.py index b6bd12c..68fe70e 100644 --- a/spoke/__init__.py +++ b/spoke/__init__.py @@ -7,8 +7,9 @@ from lxml import etree import requests +import six -__version__ = '1.0.0' +__version__ = '1.0.31' __all__ = ['Case', 'Comment', 'Image', 'OrderInfo', 'PackSlipCustomInfo', 'Spoke', 'ValidationError', 'SpokeError'] @@ -27,6 +28,7 @@ def passthrough(v): class Validator(object): is_required = True + is_conditional = False def __init__(self, inner=None): if inner is None: @@ -51,6 +53,35 @@ def __call__(self, value): class Required(Validator): pass +class RequiredOnlyIfNot(Required): + """ This validator will require the key ONLY IF other keys are NOT present in + the payload. + + This validator was added because threadless.com payloads use "ShippingMethod" whereas + Artist Shops payloads use "ShippingAccount" and "ShippingMethodId" + + An example would be that SomeKey is only required if SomeOtherKey is not present in the payload: + "SomeKey" = RequiredOnlyIfNot(['SomeOtherKey']) + + """ + is_required = True + is_conditional = True + other_keys = [] + + def __init__(self, other_keys=[], inner=None): + if not isinstance(other_keys, (tuple, list)): + other_keys = [other_keys] + self.other_keys = other_keys + + super(RequiredOnlyIfNot, self).__init__(inner) + + def __call__(self, value, d): + # if all of other_keys are present in the payload, + # then require don't require this field + if all([key in d.keys() for key in self.other_keys]): + self.is_required = False + + return super(RequiredOnlyIfNot, self).__call__(value) class Optional(Validator): is_required = False @@ -81,7 +112,17 @@ def _validate(d, **validation_spec): validator = validation_spec.pop(k, None) if validator is None: raise ValidationError('parameter "%s" not allowed' % k) - d[k] = validator(v) + if validator.is_conditional: # conditional validators need the whole dictionary to look at other keys + d[k] = validator(v, d) + else: + d[k] = validator(v) + + # it's possible that there's some conditional validators still in the validation_spec + # because their corresponding key isn't in the payload, so look over them and if all + # of their other_keys are present in the payload, then this conditional validator isn't required + for k, v in validation_spec.items(): + if v.is_conditional and all([key in d.keys() for key in v.other_keys]): + v.is_required = False validation_spec = dict((k, v) for k, v in validation_spec.items() if v.is_required) if validation_spec: @@ -256,10 +297,72 @@ def __init__(self, **kwargs): ''' _validate(kwargs, CaseId = Required(), - CaseType = Required(Enum('iph4bt', 'iph4tough', 'iph4vibe', 'iph3bt', - 'iph3tough', 'ipt4gbt', 'bb9900bt', 'kindlefirebt', 'ssgs3vibe', - 'iph5bt', 'iph5vibe', 'iph5cbt', 'iph5xtreme', 'ipad4bt', 'ipadminitough', - 'ipt5gbt', 'ssgn2tough', 'bbz10tough', 'ssgs4bt', 'ssgs4vibe')), + CaseType = Required(Enum( + 'bb9900bt', 'bbz10tough', 'kindlefirebt', + # apple / iphone + 'iph3bt', 'iph3tough', 'iph4bt', 'iph4tough', 'iph4tough2', + 'ipt4gbt', 'iph5bt', 'iph5vibe', 'iph5cbt', 'ipt5gbt', + 'iph5xtreme', 'iph6bt', 'iph6tough', 'iph655bt', 'iph655tough', + 'ipad4bt', 'ipadminitough', 'iph6sbtpresale', + 'iph6stoughpresale', 'iph6splusbtpresale', + 'iph6splustoughpresale', 'iph7bt', 'iph7tough', 'iph7plusbt', + 'iph7plustough', 'iph8bt', 'iph8tough', 'iph10bt', + 'iph10tough', 'iphxsmaxbt', 'iphxsmaxtough', 'iphxrbt', + 'iphxrtough', 'iph11bt', 'iph11tough', 'iph11probt', + 'iph11protough', 'iph11promaxbt', 'iph11promaxtough', + 'iph12minibt', 'iph12minitough', 'iph12probt', + 'iph12protough', 'iph12promaxbt', 'iph12promaxtough', + 'iph13bt', 'iph13tough', 'iph13minibt', 'iph13minitough', + 'iph13probt', 'iph13protough', 'iph13promaxbt', 'iph13promaxtough', + 'iph14snapps', 'iph14prosnapps', 'iph14plussnapps', 'iph14promaxsnapps', + 'iph14toughps', 'iph14protoughps', 'iph14plustoughps', 'iph14promaxtoughps', + 'SP10599', # iphone 15 slim + 'SP10603', # iphone 15 tough + 'SP10601', # iphone 15 plus slim + 'SP10605', # iphone 15 plus tough + 'SP10600', # iphone 15 pro slim + 'SP10604', # iphone 15 pro tough + 'SP10602', # iphone 15 pro max slim + 'SP10606', # iphone 15 pro max tough + 'SP10625', # iphone 16 slim + 'SP10629', # iphone 16 tough + 'SP10627', # iphone 16 plus slim + 'SP10631', # iphone 16 plus tough + 'SP10626', # iphone 16 pro slim + 'SP10630', # iphone 16 pro tough + 'SP10628', # iphone 16 pro max slim + 'SP10632', # iphone 16 pro max tough + 'SP10803', # iphone 17 slim + 'SP10815', # iphone 17 tough + 'SP10812', # iphone 17 pro slim + 'SP10824', # iphone 17 pro tough + 'SP10809', # iphone 17 pro max slim + 'SP10821', # iphone 17 pro max tough + 'SP10806', # iphone 17 air slim + 'SP10818', # iphone 17 air tough + # buttons + 'button-round-125', 'button-round-225', + # samsung / galaxy + 'ssgn2tough', 'ssgs3vibe', 'ssgs4bt', 'ssgs4vibe', + 'ssgs5bt', 'ssgn4bt', 'ssgs6vibe', 'ssgs6bt', 'ssgs7bt', 'ssgs8bt', + # magnets + '3x3-magnet', '4x4-magnet', '6x6-magnet', + # mugs + 'mug11oz', 'mug15oz', 'mug12ozlatte', 'mug15oztravel', + # notebooks + 'journal5x7blank', 'journal5x7ruled', 'spiral6x8ruled', + # stickers + '2x2-white', '3x3-white', '4x4-white', '6x6-white', + '2x2-clear', '3x3-clear', '4x4-clear', '6x6-clear', + # socks + 'sock-small', 'sock-medium', 'sock-large', + # face masks + 'facemasksmall', 'facemasklarge', + # puzzles + '8x10-puzzle', '11x14-puzzle', '16x20-puzzle', + # mouse pad / desk mat + '9x7mousepad', 'smallmat', 'largemat', 'xlargemat', + )), Quantity = Required(), PrintImage = Required(Image), QcImage = Optional(Image), @@ -363,7 +466,7 @@ def _generate_tree(self, tag_name, serializers, node): else: element = etree.Element(tag_name) - if not isinstance(node, basestring): + if not isinstance(node, str): node = str(node) element.text = node @@ -388,11 +491,11 @@ def serialize_it(tag_name, value): Key = self.Key, Order = Order, )) - return etree.tostring(request, pretty_print=True) + return etree.tostring(request, encoding='utf-8', pretty_print=True) def _send_request(self, request): res = self.transport.send(request) - tree = etree.fromstring(res) + tree = etree.fromstring(res.decode('utf-8')) result = tree.xpath('//result')[0].text if result == 'Success': @@ -436,14 +539,17 @@ def new(self, **kwargs): Overnight = 'ON', ) _validate(kwargs, - OrderId = Required(), # XXX number - ShippingMethod = Required(Enum('FirstClass', 'PriorityMail', 'TrackedDelivery', 'SecondDay', 'Overnight')), - PackSlip = Optional(Image), - Comments = Optional(Array(Comment)), - OrderInfo = Required(OrderInfo), - Cases = Required(Array(Case)), + OrderId = Required(), # XXX number + ShippingMethod = RequiredOnlyIfNot(['ShippingAccount', 'ShippingMethodId'], Enum('FirstClass', 'PriorityMail', 'TrackedDelivery', 'SecondDay', 'Overnight')), + ShippingMethodId = RequiredOnlyIfNot(['ShippingMethod']), + ShippingAccount = RequiredOnlyIfNot(['ShippingMethod']), + PackSlip = Optional(Image), + Comments = Optional(Array(Comment)), + OrderInfo = Required(OrderInfo), + Cases = Required(Array(Case)), ) - kwargs['ShippingMethod'] = shipping_method_map[ kwargs['ShippingMethod'] ] + if "ShippingMethod" in kwargs: + kwargs['ShippingMethod'] = shipping_method_map[ kwargs['ShippingMethod'] ] # XXX OrderDate (date or datetime?) request = self._generate_request( diff --git a/tests/spoke-tests.py b/tests/spoke-tests.py index 718db09..8f3d5fc 100644 --- a/tests/spoke-tests.py +++ b/tests/spoke-tests.py @@ -103,10 +103,54 @@ def test_new_required_fields(self): for k in params.keys(): copy = params.copy() del copy[k] + self.assertRaises(spoke.ValidationError, sp.new, **copy) - self.assertRaises(spoke.ValidationError, spoke.Spoke, **copy) + def test_conditionally_required_fields(self): + sp = spoke.Spoke( + Customer = CUSTOMER_NAME, + Key = CUSTOMER_KEY, + production = False, + transport = FauxTransport(), + ) + + params = dict( + Cases = [dict( + CaseId = 1234, + CaseType = 'iph4tough', + PrintImage = dict( + ImageType = 'jpg', + Url = 'http://threadless.com/nothing.jpg', + ), + Quantity = 1, + )], + OrderId = 2, + OrderInfo = dict( + Address1 = FAUX_ADDRESS, + City = FAUX_CITY, + CountryCode = 'US', + FirstName = FAUX_FIRST_NAME, + LastName = FAUX_LAST_NAME, + OrderDate = datetime.now(), + PhoneNumber = FAUXN_NUMBER, + PostalCode = FAUX_ZIP, + State = FAUX_STATE, + ), + ShippingAccount = '5110896', + ShippingMethodId = 66, + ) + + for k in params.keys(): + copy = params.copy() + del copy[k] + self.assertRaises(spoke.ValidationError, sp.new, **copy) + + del params['ShippingAccount'] + del params['ShippingMethodId'] + self.assertRaises(spoke.ValidationError, sp.new, **params) + + def test_new_optional_fields(self): sp = spoke.Spoke( Customer = CUSTOMER_NAME, @@ -480,3 +524,74 @@ def test_unicode_roundtrip(self): result = sp.cancel(order_id) self.assertTrue('immc_id' in result) + + + @unittest.skipUnless('AS_SPOKE_CUSTOMER' in os.environ and 'AS_SPOKE_KEY' in os.environ, 'Please set AS_SPOKE_CUSTOMER and AS_SPOKE_KEY for live testing') + def test_roundtrip_artist_shop(self): + customer = os.getenv('AS_SPOKE_CUSTOMER') + key = os.getenv('AS_SPOKE_KEY') + + sp = spoke.Spoke( + Customer = customer, + Key = key, + production = False, + ) + + order_id = random.randint(1000000, 2000000) + + result = sp.new( + Cases = [dict( + CaseId = 1234, + CaseType = 'iph4tough', + PrintImage = dict( + ImageType = 'jpg', + Url = 'http://threadless.com/nothing.jpg', + ), + Quantity = 1, + )], + OrderId = order_id, + OrderInfo = dict( + Address1 = FAUX_ADDRESS, + City = FAUX_CITY, + CountryCode = 'US', + FirstName = FAUX_FIRST_NAME, + LastName = FAUX_LAST_NAME, + OrderDate = datetime.now(), + PhoneNumber = FAUXN_NUMBER, + PostalCode = FAUX_ZIP, + State = FAUX_STATE, + ), + ShippingMethodId = 66, + ShippingAccount = '5110896', + PackSlip = spoke.Image( + ImageType = 'jpg', + Url = 'file:///tmp/nothing.jpg', + ), + Comments = [dict( + Type = 'Printer', + CommentText = 'testing', + )] + ) + + self.assertTrue('immc_id' in result) + + result = sp.update( + OrderId = order_id, + OrderInfo = dict( + Address1 = FAUX_ADDRESS, + City = FAUX_CITY, + CountryCode = 'US', + FirstName = FAUX_FIRST_NAME, + LastName = FAUX_LAST_NAME, + OrderDate = datetime.now(), + PhoneNumber = FAUXN_NUMBER, + PostalCode = FAUX_ZIP, + State = FAUX_STATE, + ), + ) + + self.assertTrue('immc_id' in result) + + result = sp.cancel(order_id) + + self.assertTrue('immc_id' in result) \ No newline at end of file