diff --git a/.gitignore b/.gitignore index 420be1b5..27d46ade 100644 --- a/.gitignore +++ b/.gitignore @@ -21,8 +21,14 @@ pip-log.txt .coverage .tox -#Translations +# Translations *.mo -#Mr Developer +# Mr Developer .mr.developer.cfg + +# VS Code Settings +.vscode/settings.json + +.travis.yml +.DS_Store diff --git a/.travis.yml b/.travis.yml index 0c43fb15..1f3ce469 100644 --- a/.travis.yml +++ b/.travis.yml @@ -19,19 +19,3 @@ script: after_success: - pip install codecov - codecov -deploy: - - provider: pypi - server: https://test.pypi.org/legacy/ - user: jameshiew - password: - secure: yzWwoA/uUkNbioPLAKZNEHK9UKJ39F8s81tER2XEfOOY1TNIAjOrT1jwdKuBDu6y0hDf5IySjIV63GcW2q3e0q2b3UyQTUTezknPyw1zLNY/jVA/dmaY7QemZBV2dmLpCc3tzjrhpLCNcZQB1Thai9rDxjJaq10KU3av8XpQEDO68+4OFh78lLWRK/6VYqjl2UJ+RPb1J0LxM02aMSwOyehLOaE+NWvqmzLBTEOEm97HJ4FqgLDpnJ0cU1pH0QqwAUTtkVSF0LEGr2lJjg9ag9Uf1JfZlO0eS8kexM5hb5NsW5YCzCgwxgtTWv4574Sq7ZYJgLAMSoUV5C8xQ5EQ0VL89Tjshxi/5svD2ssOL21SBiZi+/VtL8WTjyUWAxGviuFnvjGGiPPs/cCGQQiEU5SRoTW4WeO7JBvTeCxZRh9gixWkrKaACUsx1Md2q4XF/RrwjEOvlZWxSiwkB+jp35axK4hjsW0UspO7lf8vGpuLgywN38h/kvB05dMILpMiWGxIre9UYjBuRnudYI0U/Td4uNTtCYgApCeuVz9i+gd3dBHYoP09Vvz0DPrEolE4WvssR61e3bazFUoo4/U79gn6TyR8Dg+pgZe/wlRQ9MqDC6XKX8q6T/uuJAlNSWtTTv6Fnz5OWayQjwv0Fag7b238knFdX3qxmwiL9xzL2ig= - on: - branch: develop - python: 3.6 - - provider: pypi - user: jameshiew - password: - secure: znPqVWZrTzMHVyGMf2F38LCNPpnHw/DyCbTh1c6Lt2L4drgTdYN/mrPg8/EVwab2Fmdjud2lWkNNlREs5vdcTyQbsEZQbqAtiVenpFy2UFTwS8dlsKd7nnqblpgOB83CCKUs38Gv/7T4noM4zFSYQAC+1iAZvntsvNGmxfLqPXJfS+xnmDwZHW2PCbq2/YJ7MiNwFOin9c2fNNy7m9w3SB9G8rgpBori4xWLyv7nw0DAATvIpg1Ac2sQynqqCzSu9t2o+QnleSKa5KHqWGCl2m/pt1/UPXR0qdDiYNq7eYyIy9mQjmRCiSgSxix5duFV2dQsmVE0gjt0CIY+G9hVUWDUHy4yGuIPgKSy4mC3pVVQhhmJmhQ+dvBp6LFfjlj7KkZP83jeDWKfPuQxcUelD/apwZ2aWk+7b65MtCV8riTn5iP8jnonPHsf+337KnonLOcv4vddy2ROQYWeo3CdzaThkbw2iomchLRQjHHrl/aPMiQLqEUVAsYpl8ENEnPbKp1swsZPdigMRBA5gp/TBXCutaey775Og5L9G7mV49zngsTpR55EFXZdefTSJMJvTOep08WCot6xl+xvtGSZRSB429pZd+i7wKAqSqjh6gbhgadzKEx2dpMl60N6kowrVMkGcXo9SQzh2u84A0YqvbpZXxluKXCbF0HnKr0NwzE= - on: - branch: master - python: 3.6 diff --git a/AUTHORS b/AUTHORS index e915e938..ae1f870c 100644 --- a/AUTHORS +++ b/AUTHORS @@ -1,6 +1,7 @@ Current or previous core committers: * Paulo Alvarado +* Galen Rice Contributors (in alphabetical order): diff --git a/README.md b/README.md index f58cd787..d074b95b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,11 @@ # mws -[![Requirements Status](https://requires.io/github/jameshiew/mws/requirements.svg)](https://requires.io/github/jameshiew/mws/requirements/) [![PyPI version](https://badge.fury.io/py/mws.svg)](https://badge.fury.io/py/mws) [![Build Status](https://travis-ci.org/jameshiew/mws.svg)](https://travis-ci.org/jameshiew/mws) [![codecov](https://codecov.io/gh/jameshiew/mws/branch/develop/graph/badge.svg)](https://codecov.io/gh/jameshiew/mws) +[![PyPI version](https://badge.fury.io/py/mws.svg)](https://badge.fury.io/py/mws) + +master: +[![Requirements Status](https://requires.io/github/celery/celery/requirements.svg?branch=master)](https://requires.io/github/python-amazon-mws/python-amazon-mws/requirements/) [![Build Status](https://travis-ci.org/python-amazon-mws/python-amazon-mws.svg?branch=master)](https://travis-ci.org/python-amazon-mws/python-amazon-mws?branch=master) [![codecov](https://codecov.io/gh/python-amazon-mws/python-amazon-mws/branch/master/graph/badge.svg)](https://codecov.io/gh/python-amazon-mws/python-amazon-mws/branch/master) + +develop: +[![Requirements Status](https://requires.io/github/celery/celery/requirements.svg?branch=develop)](https://requires.io/github/python-amazon-mws/python-amazon-mws/requirements/) [![Build Status](https://travis-ci.org/python-amazon-mws/python-amazon-mws.svg?branch=develop)](https://travis-ci.org/python-amazon-mws/python-amazon-mws?branch=develop) [![codecov](https://codecov.io/gh/python-amazon-mws/python-amazon-mws/branch/develop/graph/badge.svg)](https://codecov.io/gh/python-amazon-mws/python-amazon-mws/branch/develop) This is a fork and continuation of https://github.com/czpython/python-amazon-mws with preliminary Python 2/3 support. diff --git a/mws/mws.py b/mws/mws.py index 550a3ef7..07dfdc90 100644 --- a/mws/mws.py +++ b/mws/mws.py @@ -1,12 +1,13 @@ # -*- coding: utf-8 -*- from __future__ import absolute_import +from time import gmtime, strftime import base64 import datetime import hashlib import hmac import re -from time import gmtime, strftime +import warnings from requests import request from requests.exceptions import HTTPError @@ -22,16 +23,20 @@ except ImportError: from xml.parsers.expat import ExpatError as XMLError +from zipfile import ZipFile +from io import BytesIO __all__ = [ 'Feeds', 'Inventory', + 'InboundShipments', 'MWSError', 'Reports', 'Orders', 'Products', 'Recommendations', 'Sellers', + 'Finances', ] # See https://images-na.ssl-images-amazon.com/images/G/01/mwsportal/doc/en_US/bde/MWSDeveloperGuide._V357736853_.pdf @@ -55,7 +60,7 @@ class MWSError(Exception): """ - Main MWS Exception class + Main MWS Exception class """ # Allows quick access to the response object. # Do not rely on this attribute, always check if its not None. @@ -63,26 +68,34 @@ class MWSError(Exception): def calc_md5(string): - """Calculates the MD5 encryption for the given string """ - md = hashlib.md5() - md.update(string) - return base64.b64encode(md.digest()).strip(b'\n') + Calculates the MD5 encryption for the given string + """ + md5_hash = hashlib.md5() + md5_hash.update(string) + return base64.b64encode(md5_hash.digest()).strip(b'\n') -def remove_empty(d): - """Helper function that removes all keys from a dictionary (d), that have an empty value. +def calc_request_description(params): + request_description = '' + for key in sorted(params): + encoded_value = quote(params[key], safe='-_.~') + request_description += '&{}={}'.format(key, encoded_value) + return request_description[1:] # don't include leading ampersand - Args: - d (dict) - Return: - dict +def remove_empty(dict_): """ - return {k: v for k, v in d.items() if v} + Returns dict_ with all empty values removed. + """ + return {k: v for k, v in dict_.items() if v} def remove_namespace(xml): + """ + Strips the namespace from XML document contained in a string. + Returns the stripped string. + """ regex = re.compile(' xmlns(:ns2)?="[^"]+"|(ns2:)|(xml:)') return regex.sub('', xml) @@ -90,38 +103,57 @@ def remove_namespace(xml): class DictWrapper(object): def __init__(self, xml, rootkey=None): self.original = xml + self.response = None self._rootkey = rootkey - self._mydict = utils.xml2dict().fromstring(remove_namespace(xml)) - self._response_dict = self._mydict.get(list(self._mydict.keys())[0], - self._mydict) + self._mydict = utils.XML2Dict().fromstring(remove_namespace(xml)) + self._response_dict = self._mydict.get(list(self._mydict.keys())[0], self._mydict) @property def parsed(self): if self._rootkey: return self._response_dict.get(self._rootkey) - else: - return self._response_dict + return self._response_dict class DataWrapper(object): """ - Text wrapper in charge of validating the hash sent by Amazon. + Text wrapper in charge of validating the hash sent by Amazon. """ - def __init__(self, data, header): + def __init__(self, data, headers): self.original = data - if 'content-md5' in header: + self.response = None + self.headers = headers + if 'content-md5' in headers: hash_ = calc_md5(self.original) - if header['content-md5'].encode() != hash_: + if headers['content-md5'].encode() != hash_: raise MWSError("Wrong Contentlength, maybe amazon error...") @property def parsed(self): return self.original + """ + To return an unzipped file object based on the content type" + """ + @property + def unzipped(self): + if self.headers['content-type'] == 'application/zip': + try: + with ZipFile(BytesIO(self.original)) as unzipped_fileobj: + # unzipped the zip file contents + unzipped_fileobj.extractall() + # return original zip file object to the user + return(unzipped_fileobj) + except Exception as e: + raise MWSError(str(e.response.text)) + else: + return('The response is not a zipped file.') -class MWS(object): - """ Base Amazon API class """ +class MWS(object): + """ + Base Amazon API class + """ # This is used to post/get to the different uris used by amazon per api # ie. /Orders/2011-01-01 # All subclasses must define their own URI only if needed @@ -132,9 +164,15 @@ class MWS(object): # There seem to be some xml namespace issues. therefore every api subclass # is recommended to define its namespace, so that it can be referenced - # like so AmazonAPISubclass.NS. + # like so AmazonAPISubclass.NAMESPACE. # For more information see http://stackoverflow.com/a/8719461/389453 - NS = '' + NAMESPACE = '' + + # In here we name each of the operations available to the subclass + # that have 'ByNextToken' operations associated with them. + # If the Operation is not listed here, self.action_by_next_token + # will raise an error. + NEXT_TOKEN_OPERATIONS = [] # Some APIs are available only to either a "Merchant" or "Seller" # the type of account needs to be sent in every call to the amazon MWS. @@ -146,7 +184,9 @@ class MWS(object): # Which is the name of the parameter for that specific account type. ACCOUNT_TYPE = "SellerId" - def __init__(self, access_key, secret_key, account_id, region='US', domain='', uri="", version="", auth_token=""): + def __init__(self, access_key, secret_key, account_id, + region='US', domain='', uri="", + version="", auth_token=""): self.access_key = access_key self.secret_key = secret_key self.account_id = account_id @@ -165,8 +205,25 @@ def __init__(self, access_key, secret_key, account_id, region='US', domain='', u } raise MWSError(error_msg) + def get_params(self): + """ + Get the parameters required in all MWS requests + """ + params = { + 'AWSAccessKeyId': self.access_key, + self.ACCOUNT_TYPE: self.account_id, + 'SignatureVersion': '2', + 'Timestamp': self.get_timestamp(), + 'Version': self.version, + 'SignatureMethod': 'HmacSHA256', + } + if self.auth_token: + params['MWSAuthToken'] = self.auth_token + return params + def make_request(self, extra_data, method="GET", **kwargs): - """Make request to Amazon MWS API with these parameters + """ + Make request to Amazon MWS API with these parameters """ # Remove all keys with an empty value because @@ -178,21 +235,17 @@ def make_request(self, extra_data, method="GET", **kwargs): if isinstance(value, (datetime.datetime, datetime.date)): extra_data[key] = value.isoformat() - params = { - 'AWSAccessKeyId': self.access_key, - self.ACCOUNT_TYPE: self.account_id, - 'SignatureVersion': '2', - 'Timestamp': self.get_timestamp(), - 'Version': self.version, - 'SignatureMethod': 'HmacSHA256', - } - if self.auth_token: - params['MWSAuthToken'] = self.auth_token + params = self.get_params() params.update(extra_data) - request_description = '&'.join(['%s=%s' % (k, quote(params[k], safe='-_.~')) for k in sorted(params)]) + request_description = calc_request_description(params) signature = self.calc_signature(method, request_description) - url = '%s%s?%s&Signature=%s' % (self.domain, self.uri, request_description, quote(signature)) - headers = {'User-Agent': 'python-amazon-mws/0.0.1 (Language=Python)'} + url = "{domain}{uri}?{description}&Signature={signature}".format( + domain=self.domain, + uri=self.uri, + description=request_description, + signature=quote(signature), + ) + headers = {'User-Agent': 'python-amazon-mws/0.8.3 (Language=Python)'} headers.update(kwargs.get('extra_headers', {})) try: @@ -231,14 +284,36 @@ def make_request(self, extra_data, method="GET", **kwargs): def get_service_status(self): """ - Returns a GREEN, GREEN_I, YELLOW or RED status. - Depending on the status/availability of the API its being called from. + Returns a GREEN, GREEN_I, YELLOW or RED status. + Depending on the status/availability of the API its being called from. """ return self.make_request(extra_data=dict(Action='GetServiceStatus')) + def action_by_next_token(self, action, next_token): + """ + Run a '...ByNextToken' action for the given action. + If the action is not listed in self.NEXT_TOKEN_OPERATIONS, MWSError is raised. + Action is expected NOT to include 'ByNextToken' + at the end of its name for this call: function will add that by itself. + """ + if action not in self.NEXT_TOKEN_OPERATIONS: + raise MWSError(( + "{} action not listed in this API's NEXT_TOKEN_OPERATIONS. " + "Please refer to documentation." + ).format(action)) + + action = '{}ByNextToken'.format(action) + + data = dict( + Action=action, + NextToken=next_token + ) + return self.make_request(data, method="POST") + def calc_signature(self, method, request_description): - """Calculate MWS signature to interface with Amazon + """ + Calculate MWS signature to interface with Amazon Args: method (str) @@ -254,37 +329,33 @@ def calc_signature(self, method, request_description): def get_timestamp(self): """ - Returns the current timestamp in proper format. + Returns the current timestamp in proper format. """ return strftime("%Y-%m-%dT%H:%M:%SZ", gmtime()) def enumerate_param(self, param, values): """ - Builds a dictionary of an enumerated parameter. - Takes any iterable and returns a dictionary. - ie. - enumerate_param('MarketplaceIdList.Id', (123, 345, 4343)) - returns - { - MarketplaceIdList.Id.1: 123, - MarketplaceIdList.Id.2: 345, - MarketplaceIdList.Id.3: 4343 - } + DEPRECATED. + Please use `utils.enumerate_param` for one param, or + `utils.enumerate_params` for multiple params. """ - params = {} - if values is not None: - if not param.endswith('.'): - param = "%s." % param - for num, value in enumerate(values): - params['%s%d' % (param, (num + 1))] = value - return params + warnings.warn(( + "Please use `utils.enumerate_param` for one param, or " + "`utils.enumerate_params` for multiple params." + ), DeprecationWarning) + return utils.enumerate_param(param, values) class Feeds(MWS): - """ Amazon MWS Feeds API """ - + """ + Amazon MWS Feeds API + """ ACCOUNT_TYPE = "Merchant" + NEXT_TOKEN_OPERATIONS = [ + 'GetFeedSubmissionList', + ] + def submit_feed(self, feed, feed_type, marketplaceids=None, content_type="text/xml", purge='false'): """ @@ -294,13 +365,15 @@ def submit_feed(self, feed, feed_type, marketplaceids=None, data = dict(Action='SubmitFeed', FeedType=feed_type, PurgeAndReplace=purge) - data.update(self.enumerate_param('MarketplaceIdList.Id.', marketplaceids)) + data.update(utils.enumerate_param('MarketplaceIdList.Id.', marketplaceids)) md = calc_md5(feed) return self.make_request(data, method="POST", body=feed, extra_headers={'Content-MD5': md, 'Content-Type': content_type}) + @utils.next_token_action('GetFeedSubmissionList') def get_feed_submission_list(self, feedids=None, max_count=None, feedtypes=None, - processingstatuses=None, fromdate=None, todate=None): + processingstatuses=None, fromdate=None, todate=None, + next_token=None): """ Returns a list of all feed submissions submitted in the previous 90 days. That match the query parameters. @@ -310,29 +383,38 @@ def get_feed_submission_list(self, feedids=None, max_count=None, feedtypes=None, MaxCount=max_count, SubmittedFromDate=fromdate, SubmittedToDate=todate,) - data.update(self.enumerate_param('FeedSubmissionIdList.Id', feedids)) - data.update(self.enumerate_param('FeedTypeList.Type.', feedtypes)) - data.update(self.enumerate_param('FeedProcessingStatusList.Status.', processingstatuses)) + data.update(utils.enumerate_param('FeedSubmissionIdList.Id', feedids)) + data.update(utils.enumerate_param('FeedTypeList.Type.', feedtypes)) + data.update(utils.enumerate_param('FeedProcessingStatusList.Status.', processingstatuses)) return self.make_request(data) def get_submission_list_by_next_token(self, token): - data = dict(Action='GetFeedSubmissionListByNextToken', NextToken=token) - return self.make_request(data) + """ + Deprecated. + Use `get_feed_submission_list(next_token=token)` instead. + """ + # data = dict(Action='GetFeedSubmissionListByNextToken', NextToken=token) + # return self.make_request(data) + warnings.warn( + "Use `get_feed_submission_list(next_token=token)` instead.", + DeprecationWarning, + ) + return self.get_feed_submission_list(next_token=token) def get_feed_submission_count(self, feedtypes=None, processingstatuses=None, fromdate=None, todate=None): data = dict(Action='GetFeedSubmissionCount', SubmittedFromDate=fromdate, SubmittedToDate=todate) - data.update(self.enumerate_param('FeedTypeList.Type.', feedtypes)) - data.update(self.enumerate_param('FeedProcessingStatusList.Status.', processingstatuses)) + data.update(utils.enumerate_param('FeedTypeList.Type.', feedtypes)) + data.update(utils.enumerate_param('FeedProcessingStatusList.Status.', processingstatuses)) return self.make_request(data) def cancel_feed_submissions(self, feedids=None, feedtypes=None, fromdate=None, todate=None): data = dict(Action='CancelFeedSubmissions', SubmittedFromDate=fromdate, SubmittedToDate=todate) - data.update(self.enumerate_param('FeedSubmissionIdList.Id.', feedids)) - data.update(self.enumerate_param('FeedTypeList.Type.', feedtypes)) + data.update(utils.enumerate_param('FeedSubmissionIdList.Id.', feedids)) + data.update(utils.enumerate_param('FeedTypeList.Type.', feedtypes)) return self.make_request(data) def get_feed_submission_result(self, feedid): @@ -341,9 +423,15 @@ def get_feed_submission_result(self, feedid): class Reports(MWS): - """ Amazon MWS Reports API """ - + """ + Amazon MWS Reports API + """ ACCOUNT_TYPE = "Merchant" + NEXT_TOKEN_OPERATIONS = [ + 'GetReportRequestList', + 'GetReportList', + 'GetReportScheduleList', + ] # * REPORTS * # @@ -356,78 +444,106 @@ def get_report_count(self, report_types=(), acknowledged=None, fromdate=None, to Acknowledged=acknowledged, AvailableFromDate=fromdate, AvailableToDate=todate) - data.update(self.enumerate_param('ReportTypeList.Type.', report_types)) + data.update(utils.enumerate_param('ReportTypeList.Type.', report_types)) return self.make_request(data) + @utils.next_token_action('GetReportList') def get_report_list(self, requestids=(), max_count=None, types=(), acknowledged=None, - fromdate=None, todate=None): + fromdate=None, todate=None, next_token=None): data = dict(Action='GetReportList', Acknowledged=acknowledged, AvailableFromDate=fromdate, AvailableToDate=todate, MaxCount=max_count) - data.update(self.enumerate_param('ReportRequestIdList.Id.', requestids)) - data.update(self.enumerate_param('ReportTypeList.Type.', types)) + data.update(utils.enumerate_param('ReportRequestIdList.Id.', requestids)) + data.update(utils.enumerate_param('ReportTypeList.Type.', types)) return self.make_request(data) def get_report_list_by_next_token(self, token): - data = dict(Action='GetReportListByNextToken', NextToken=token) - return self.make_request(data) - - def get_report_request_count(self, report_types=(), processingstatuses=(), fromdate=None, todate=None): + """ + Deprecated. + Use `get_report_list(next_token=token)` instead. + """ + # data = dict(Action='GetReportListByNextToken', NextToken=token) + # return self.make_request(data) + warnings.warn( + "Use `get_report_list(next_token=token)` instead.", + DeprecationWarning, + ) + return self.get_report_list(next_token=token) + + def get_report_request_count(self, report_types=(), processingstatuses=(), + fromdate=None, todate=None): data = dict(Action='GetReportRequestCount', RequestedFromDate=fromdate, RequestedToDate=todate) - data.update(self.enumerate_param('ReportTypeList.Type.', report_types)) - data.update(self.enumerate_param('ReportProcessingStatusList.Status.', processingstatuses)) + data.update(utils.enumerate_param('ReportTypeList.Type.', report_types)) + data.update(utils.enumerate_param('ReportProcessingStatusList.Status.', processingstatuses)) return self.make_request(data) + @utils.next_token_action('GetReportRequestList') def get_report_request_list(self, requestids=(), types=(), processingstatuses=(), - max_count=None, fromdate=None, todate=None): + max_count=None, fromdate=None, todate=None, next_token=None): data = dict(Action='GetReportRequestList', MaxCount=max_count, RequestedFromDate=fromdate, RequestedToDate=todate) - data.update(self.enumerate_param('ReportRequestIdList.Id.', requestids)) - data.update(self.enumerate_param('ReportTypeList.Type.', types)) - data.update(self.enumerate_param('ReportProcessingStatusList.Status.', processingstatuses)) + data.update(utils.enumerate_param('ReportRequestIdList.Id.', requestids)) + data.update(utils.enumerate_param('ReportTypeList.Type.', types)) + data.update(utils.enumerate_param('ReportProcessingStatusList.Status.', processingstatuses)) return self.make_request(data) def get_report_request_list_by_next_token(self, token): - data = dict(Action='GetReportRequestListByNextToken', NextToken=token) - return self.make_request(data) + """ + Deprecated. + Use `get_report_request_list(next_token=token)` instead. + """ + # data = dict(Action='GetReportRequestListByNextToken', NextToken=token) + # return self.make_request(data) + warnings.warn( + "Use `get_report_request_list(next_token=token)` instead.", + DeprecationWarning, + ) + return self.get_report_request_list(next_token=token) def request_report(self, report_type, start_date=None, end_date=None, marketplaceids=()): data = dict(Action='RequestReport', ReportType=report_type, StartDate=start_date, EndDate=end_date) - data.update(self.enumerate_param('MarketplaceIdList.Id.', marketplaceids)) + data.update(utils.enumerate_param('MarketplaceIdList.Id.', marketplaceids)) return self.make_request(data) # * ReportSchedule * # def get_report_schedule_list(self, types=()): data = dict(Action='GetReportScheduleList') - data.update(self.enumerate_param('ReportTypeList.Type.', types)) + data.update(utils.enumerate_param('ReportTypeList.Type.', types)) return self.make_request(data) def get_report_schedule_count(self, types=()): data = dict(Action='GetReportScheduleCount') - data.update(self.enumerate_param('ReportTypeList.Type.', types)) + data.update(utils.enumerate_param('ReportTypeList.Type.', types)) return self.make_request(data) class Orders(MWS): - """ Amazon Orders API """ - + """ + Amazon Orders API + """ URI = "/Orders/2013-09-01" VERSION = "2013-09-01" - NS = '{https://mws.amazonservices.com/Orders/2013-09-01}' - - def list_orders(self, marketplaceids, created_after=None, created_before=None, lastupdatedafter=None, - lastupdatedbefore=None, orderstatus=(), fulfillment_channels=(), - payment_methods=(), buyer_email=None, seller_orderid=None, max_results='100'): + NAMESPACE = '{https://mws.amazonservices.com/Orders/2013-09-01}' + NEXT_TOKEN_OPERATIONS = [ + 'ListOrders', + 'ListOrderItems', + ] + + @utils.next_token_action('ListOrders') + def list_orders(self, marketplaceids=None, created_after=None, created_before=None, + lastupdatedafter=None, lastupdatedbefore=None, orderstatus=(), + fulfillment_channels=(), payment_methods=(), buyer_email=None, + seller_orderid=None, max_results='100', next_token=None): data = dict(Action='ListOrders', CreatedAfter=created_after, @@ -438,42 +554,64 @@ def list_orders(self, marketplaceids, created_after=None, created_before=None, l SellerOrderId=seller_orderid, MaxResultsPerPage=max_results, ) - data.update(self.enumerate_param('OrderStatus.Status.', orderstatus)) - data.update(self.enumerate_param('MarketplaceId.Id.', marketplaceids)) - data.update(self.enumerate_param('FulfillmentChannel.Channel.', fulfillment_channels)) - data.update(self.enumerate_param('PaymentMethod.Method.', payment_methods)) + data.update(utils.enumerate_param('OrderStatus.Status.', orderstatus)) + data.update(utils.enumerate_param('MarketplaceId.Id.', marketplaceids)) + data.update(utils.enumerate_param('FulfillmentChannel.Channel.', fulfillment_channels)) + data.update(utils.enumerate_param('PaymentMethod.Method.', payment_methods)) return self.make_request(data) def list_orders_by_next_token(self, token): - data = dict(Action='ListOrdersByNextToken', NextToken=token) - return self.make_request(data) + """ + Deprecated. + Use `list_orders(next_token=token)` instead. + """ + # data = dict(Action='ListOrdersByNextToken', NextToken=token) + # return self.make_request(data) + warnings.warn( + "Use `list_orders(next_token=token)` instead.", + DeprecationWarning, + ) + return self.list_orders(next_token=token) def get_order(self, amazon_order_ids): data = dict(Action='GetOrder') - data.update(self.enumerate_param('AmazonOrderId.Id.', amazon_order_ids)) + data.update(utils.enumerate_param('AmazonOrderId.Id.', amazon_order_ids)) return self.make_request(data) - def list_order_items(self, amazon_order_id): + @utils.next_token_action('ListOrderItems') + def list_order_items(self, amazon_order_id=None, next_token=None): data = dict(Action='ListOrderItems', AmazonOrderId=amazon_order_id) return self.make_request(data) def list_order_items_by_next_token(self, token): - data = dict(Action='ListOrderItemsByNextToken', NextToken=token) - return self.make_request(data) + """ + Deprecated. + Use `list_order_items(next_token=token)` instead. + """ + # data = dict(Action='ListOrderItemsByNextToken', NextToken=token) + # return self.make_request(data) + warnings.warn( + "Use `list_order_items(next_token=token)` instead.", + DeprecationWarning, + ) + return self.list_order_items(next_token=token) class Products(MWS): - """ Amazon MWS Products API """ - + """ + Amazon MWS Products API + """ URI = '/Products/2011-10-01' VERSION = '2011-10-01' - NS = '{http://mws.amazonservices.com/schema/Products/2011-10-01}' + NAMESPACE = '{http://mws.amazonservices.com/schema/Products/2011-10-01}' + # NEXT_TOKEN_OPERATIONS = [] def list_matching_products(self, marketplaceid, query, contextid=None): - """ Returns a list of products and their attributes, ordered by - relevancy, based on a search query that you specify. - Your search query can be a phrase that describes the product - or it can be a product identifier such as a UPC, EAN, ISBN, or JAN. + """ + Returns a list of products and their attributes, ordered by + relevancy, based on a search query that you specify. + Your search query can be a phrase that describes the product + or it can be a product identifier such as a UPC, EAN, ISBN, or JAN. """ data = dict(Action='ListMatchingProducts', MarketplaceId=marketplaceid, @@ -482,39 +620,44 @@ def list_matching_products(self, marketplaceid, query, contextid=None): return self.make_request(data) def get_matching_product(self, marketplaceid, asins): - """ Returns a list of products and their attributes, based on a list of - ASIN values that you specify. + """ + Returns a list of products and their attributes, based on a list of + ASIN values that you specify. """ data = dict(Action='GetMatchingProduct', MarketplaceId=marketplaceid) - data.update(self.enumerate_param('ASINList.ASIN.', asins)) + data.update(utils.enumerate_param('ASINList.ASIN.', asins)) return self.make_request(data) - def get_matching_product_for_id(self, marketplaceid, type, ids): - """ Returns a list of products and their attributes, based on a list of - product identifier values (ASIN, SellerSKU, UPC, EAN, ISBN, GCID and JAN) - The identifier type is case sensitive. - Added in Fourth Release, API version 2011-10-01 + def get_matching_product_for_id(self, marketplaceid, type_, ids): + """ + Returns a list of products and their attributes, based on a list of + product identifier values (ASIN, SellerSKU, UPC, EAN, ISBN, GCID and JAN) + The identifier type is case sensitive. + Added in Fourth Release, API version 2011-10-01 """ data = dict(Action='GetMatchingProductForId', MarketplaceId=marketplaceid, - IdType=type) - data.update(self.enumerate_param('IdList.Id.', ids)) + IdType=type_) + + data.update(utils.enumerate_param('IdList.Id.', ids)) return self.make_request(data) def get_competitive_pricing_for_sku(self, marketplaceid, skus): - """ Returns the current competitive pricing of a product, - based on the SellerSKU and MarketplaceId that you specify. + """ + Returns the current competitive pricing of a product, + based on the SellerSKU and MarketplaceId that you specify. """ data = dict(Action='GetCompetitivePricingForSKU', MarketplaceId=marketplaceid) - data.update(self.enumerate_param('SellerSKUList.SellerSKU.', skus)) + data.update(utils.enumerate_param('SellerSKUList.SellerSKU.', skus)) return self.make_request(data) def get_competitive_pricing_for_asin(self, marketplaceid, asins): - """ Returns the current competitive pricing of a product, - based on the ASIN and MarketplaceId that you specify. + """ + Returns the current competitive pricing of a product, + based on the ASIN and MarketplaceId that you specify. """ data = dict(Action='GetCompetitivePricingForASIN', MarketplaceId=marketplaceid) - data.update(self.enumerate_param('ASINList.ASIN.', asins)) + data.update(utils.enumerate_param('ASINList.ASIN.', asins)) return self.make_request(data) def get_lowest_offer_listings_for_sku(self, marketplaceid, skus, condition="Any", excludeme="False"): @@ -522,7 +665,7 @@ def get_lowest_offer_listings_for_sku(self, marketplaceid, skus, condition="Any" MarketplaceId=marketplaceid, ItemCondition=condition, ExcludeMe=excludeme) - data.update(self.enumerate_param('SellerSKUList.SellerSKU.', skus)) + data.update(utils.enumerate_param('SellerSKUList.SellerSKU.', skus)) return self.make_request(data) def get_lowest_offer_listings_for_asin(self, marketplaceid, asins, condition="Any", excludeme="False"): @@ -530,7 +673,7 @@ def get_lowest_offer_listings_for_asin(self, marketplaceid, asins, condition="An MarketplaceId=marketplaceid, ItemCondition=condition, ExcludeMe=excludeme) - data.update(self.enumerate_param('ASINList.ASIN.', asins)) + data.update(utils.enumerate_param('ASINList.ASIN.', asins)) return self.make_request(data) def get_lowest_priced_offers_for_sku(self, marketplaceid, sku, condition="New", excludeme="False"): @@ -565,104 +708,651 @@ def get_my_price_for_sku(self, marketplaceid, skus, condition=None): data = dict(Action='GetMyPriceForSKU', MarketplaceId=marketplaceid, ItemCondition=condition) - data.update(self.enumerate_param('SellerSKUList.SellerSKU.', skus)) + data.update(utils.enumerate_param('SellerSKUList.SellerSKU.', skus)) return self.make_request(data) def get_my_price_for_asin(self, marketplaceid, asins, condition=None): data = dict(Action='GetMyPriceForASIN', MarketplaceId=marketplaceid, ItemCondition=condition) - data.update(self.enumerate_param('ASINList.ASIN.', asins)) + data.update(utils.enumerate_param('ASINList.ASIN.', asins)) return self.make_request(data) class Sellers(MWS): - """ Amazon MWS Sellers API """ - + """ + Amazon MWS Sellers API + """ URI = '/Sellers/2011-07-01' VERSION = '2011-07-01' - NS = '{http://mws.amazonservices.com/schema/Sellers/2011-07-01}' + NAMESPACE = '{http://mws.amazonservices.com/schema/Sellers/2011-07-01}' + NEXT_TOKEN_OPERATIONS = [ + 'ListMarketplaceParticipations', + ] - def list_marketplace_participations(self): - """ - Returns a list of marketplaces a seller can participate in and - a list of participations that include seller-specific information in that marketplace. - The operation returns only those marketplaces where the seller's account is in an active state. + @utils.next_token_action('ListMarketplaceParticipations') + def list_marketplace_participations(self, next_token=None): """ + Returns a list of marketplaces a seller can participate in and + a list of participations that include seller-specific information in that marketplace. + The operation returns only those marketplaces where the seller's account is + in an active state. + Run with `next_token` kwarg to call related "ByNextToken" action. + """ data = dict(Action='ListMarketplaceParticipations') return self.make_request(data) def list_marketplace_participations_by_next_token(self, token): """ - Takes a "NextToken" and returns the same information as "list_marketplace_participations". - Based on the "NextToken". + Deprecated. + Use `list_marketplace_participations(next_token=token)` instead. + """ + # data = dict(Action='ListMarketplaceParticipations', NextToken=token) + # return self.make_request(data) + warnings.warn( + "Use `list_marketplace_participations(next_token=token)` instead.", + DeprecationWarning, + ) + return self.list_marketplace_participations(next_token=token) + + +class Finances(MWS): + """ + Amazon MWS Finances API + """ + URI = "/Finances/2015-05-01" + VERSION = "2015-05-01" + NS = '{https://mws.amazonservices.com/Finances/2015-05-01}' + NEXT_TOKEN_OPERATIONS = [ + 'ListFinancialEventGroups', + 'ListFinancialEvents', + ] + + @utils.next_token_action('ListFinancialEventGroups') + def list_financial_event_groups(self, created_after=None, created_before=None, max_results=None, next_token=None): + """ + Returns a list of financial event groups + """ + data = dict(Action='ListFinancialEventGroups', + FinancialEventGroupStartedAfter=created_after, + FinancialEventGroupStartedBefore=created_before, + MaxResultsPerPage=max_results, + ) + return self.make_request(data) + + def list_financial_event_groups_by_next_token(self, token): + """ + Deprecated. + Use `list_financial_event_groups(next_token=token)` instead. """ - data = dict(Action='ListMarketplaceParticipations', NextToken=token) + warnings.warn( + "Use `list_financial_event_groups(next_token=token)` instead.", + DeprecationWarning, + ) + return self.list_financial_event_groups(next_token=token) + + @utils.next_token_action('ListFinancialEvents') + def list_financial_events(self, financial_event_group_id=None, amazon_order_id=None, posted_after=None, + posted_before=None, max_results=None, next_token=None): + """ + Returns financial events for a user-provided FinancialEventGroupId or AmazonOrderId + """ + data = dict(Action='ListFinancialEvents', + FinancialEventGroupId=financial_event_group_id, + AmazonOrderId=amazon_order_id, + PostedAfter=posted_after, + PostedBefore=posted_before, + MaxResultsPerPage=max_results, + ) return self.make_request(data) + def list_financial_events_by_next_token(self, token): + """ + Deprecated. + Use `list_financial_events(next_token=token)` instead. + """ + warnings.warn( + "Use `list_financial_events(next_token=token)` instead.", + DeprecationWarning, + ) + return self.list_financial_events(next_token=token) + # * Fulfillment APIs * # class InboundShipments(MWS): + """ + Amazon MWS FulfillmentInboundShipment API + """ URI = "/FulfillmentInboundShipment/2010-10-01" VERSION = '2010-10-01' + NAMESPACE = '{http://mws.amazonaws.com/FulfillmentInboundShipment/2010-10-01/}' + NEXT_TOKEN_OPERATIONS = [ + 'ListInboundShipments', + 'ListInboundShipmentItems', + ] + SHIPMENT_STATUSES = ['WORKING', 'SHIPPED', 'CANCELLED'] + DEFAULT_SHIP_STATUS = 'WORKING' + LABEL_PREFERENCES = ['SELLER_LABEL', + 'AMAZON_LABEL_ONLY', + 'AMAZON_LABEL_PREFERRED'] + + def __init__(self, *args, **kwargs): + """ + Allow the addition of a from_address dict during object initialization. + kwarg "from_address" is caught and popped here, + then calls set_ship_from_address. + If empty or left out, empty dict is set by default. + """ + self.from_address = {} + addr = kwargs.pop('from_address', None) + if addr is not None: + self.set_ship_from_address(addr) + super(InboundShipments, self).__init__(*args, **kwargs) + + def set_ship_from_address(self, address): + """ + Verifies the structure of an address dictionary. + Once verified against the KEY_CONFIG, saves a parsed version + of that dictionary, ready to send to requests. + """ + # Clear existing + self.from_address = None + + if not address: + raise MWSError('Missing required `address` dict.') + if not isinstance(address, dict): + raise MWSError("`address` must be a dict") + + key_config = [ + # Tuples composed of: + # (input_key, output_key, is_required, default_value) + ('name', 'Name', True, None), + ('address_1', 'AddressLine1', True, None), + ('address_2', 'AddressLine2', False, None), + ('city', 'City', True, None), + ('district_or_county', 'DistrictOrCounty', False, None), + ('state_or_province', 'StateOrProvinceCode', False, None), + ('postal_code', 'PostalCode', False, None), + ('country', 'CountryCode', False, 'US'), + ] + + # Check if all REQUIRED keys in address exist: + if not all(k in address for k in + [c[0] for c in key_config if c[2]]): + # Required parts of address missing + raise MWSError(( + "`address` dict missing required keys: {required}." + "\n- Optional keys: {optional}." + ).format( + required=", ".join([c[0] for c in key_config if c[2]]), + optional=", ".join([c[0] for c in key_config if not c[2]]), + )) + + # Passed tests. Assign values + addr = {'ShipFromAddress.{}'.format(c[1]): address.get(c[0], c[3]) + for c in key_config} + self.from_address = addr + + def _parse_item_args(self, item_args, operation): + """ + Parses item arguments sent to create_inbound_shipment_plan, create_inbound_shipment, + and update_inbound_shipment methods. + + `item_args` is expected as an iterable containing dicts. + Each dict should have the following keys: + For `create_inbound_shipment_plan`: + REQUIRED: 'sku', 'quantity' + OPTIONAL: 'quantity_in_case', 'asin', 'condition' + Other operations: + REQUIRED: 'sku', 'quantity' + OPTIONAL: 'quantity_in_case' + If a required key is missing, throws MWSError. + All extra keys are ignored. + + Keys (above) are converted to the appropriate MWS key according to `key_config` (below) + based on the particular operation required. + """ + if not item_args: + raise MWSError("One or more `item` dict arguments required.") + + if operation == 'CreateInboundShipmentPlan': + # `key_config` composed of list of tuples, each tuple compose of: + # (input_key, output_key, is_required, default_value) + key_config = [ + ('sku', 'SellerSKU', True, None), + ('quantity', 'Quantity', True, None), + ('quantity_in_case', 'QuantityInCase', False, None), + ('asin', 'ASIN', False, None), + ('condition', 'Condition', False, None), + ] + # The expected MWS key for quantity is different for this operation. + # This ensures we use the right key later on. + quantity_key = 'Quantity' + else: + key_config = [ + ('sku', 'SellerSKU', True, None), + ('quantity', 'QuantityShipped', True, None), + ('quantity_in_case', 'QuantityInCase', False, None), + ] + quantity_key = 'QuantityShipped' + + items = [] + for item in item_args: + if not isinstance(item, dict): + raise MWSError("`item` argument must be a dict.") + if not all(k in item for k in + [c[0] for c in key_config if c[2]]): + # Required keys of an item line missing + raise MWSError(( + "`item` dict missing required keys: {required}." + "\n- Optional keys: {optional}." + ).format( + required=', '.join([c[0] for c in key_config if c[2]]), + optional=', '.join([c[0] for c in key_config if not c[2]]), + )) + + # Get data from the item. + # Convert to str if present, or leave as None if missing + quantity = item.get('quantity') + if quantity is not None: + quantity = str(quantity) + + quantity_in_case = item.get('quantity_in_case') + if quantity_in_case is not None: + quantity_in_case = str(quantity_in_case) + + item_dict = { + 'SellerSKU': item.get('sku'), + quantity_key: quantity, + 'QuantityInCase': quantity_in_case, + } + item_dict.update({ + c[1]: item.get(c[0], c[3]) + for c in key_config + if c[0] not in ['sku', 'quantity', 'quantity_in_case'] + }) + items.append(item_dict) + + return items + + def create_inbound_shipment_plan(self, items, country_code='US', + subdivision_code='', label_preference=''): + """ + Returns one or more inbound shipment plans, which provide the + information you need to create inbound shipments. + + At least one dictionary must be passed as `args`. Each dictionary + should contain the following keys: + REQUIRED: 'sku', 'quantity' + OPTIONAL: 'asin', 'condition', 'quantity_in_case' + + 'from_address' is required. Call 'set_ship_from_address' first before + using this operation. + """ + if not items: + raise MWSError("One or more `item` dict arguments required.") + subdivision_code = subdivision_code or None + label_preference = label_preference or None + + items = self._parse_item_args(items, 'CreateInboundShipmentPlan') + if not self.from_address: + raise MWSError(( + "ShipFromAddress has not been set. " + "Please use `.set_ship_from_address()` first." + )) + + data = dict( + Action='CreateInboundShipmentPlan', + ShipToCountryCode=country_code, + ShipToCountrySubdivisionCode=subdivision_code, + LabelPrepPreference=label_preference, + ) + data.update(self.from_address) + data.update(utils.enumerate_keyed_param( + 'InboundShipmentPlanRequestItems.member', items, + )) + return self.make_request(data, method="POST") + + def create_inbound_shipment(self, shipment_id, shipment_name, + destination, items, shipment_status='', + label_preference='', case_required=False, + box_contents_source=None): + """ + Creates an inbound shipment to Amazon's fulfillment network. + + At least one dictionary must be passed as `items`. Each dictionary + should contain the following keys: + REQUIRED: 'sku', 'quantity' + OPTIONAL: 'quantity_in_case' + + 'from_address' is required. Call 'set_ship_from_address' first before + using this operation. + """ + assert isinstance(shipment_id, str), "`shipment_id` must be a string." + assert isinstance(shipment_name, str), "`shipment_name` must be a string." + assert isinstance(destination, str), "`destination` must be a string." + + if not items: + raise MWSError("One or more `item` dict arguments required.") + + items = self._parse_item_args(items, 'CreateInboundShipment') + + if not self.from_address: + raise MWSError(( + "ShipFromAddress has not been set. " + "Please use `.set_ship_from_address()` first." + )) + from_address = self.from_address + from_address = {'InboundShipmentHeader.{}'.format(k): v + for k, v in from_address.items()} + + if shipment_status not in self.SHIPMENT_STATUSES: + # Status is required for `create` request. + # Set it to default. + shipment_status = self.DEFAULT_SHIP_STATUS + + if label_preference not in self.LABEL_PREFERENCES: + # Label preference not required. Set to None + label_preference = None + + # Explict True/False for case_required, + # written as the strings MWS expects. + case_required = 'true' if case_required else 'false' + + data = { + 'Action': 'CreateInboundShipment', + 'ShipmentId': shipment_id, + 'InboundShipmentHeader.ShipmentName': shipment_name, + 'InboundShipmentHeader.DestinationFulfillmentCenterId': destination, + 'InboundShipmentHeader.LabelPrepPreference': label_preference, + 'InboundShipmentHeader.AreCasesRequired': case_required, + 'InboundShipmentHeader.ShipmentStatus': shipment_status, + 'InboundShipmentHeader.IntendedBoxContentsSource': box_contents_source, + } + data.update(from_address) + data.update(utils.enumerate_keyed_param( + 'InboundShipmentItems.member', items, + )) + return self.make_request(data, method="POST") + + def update_inbound_shipment(self, shipment_id, shipment_name, + destination, items=None, shipment_status='', + label_preference='', case_required=False, + box_contents_source=None): + """ + Updates an existing inbound shipment in Amazon FBA. + 'from_address' is required. Call 'set_ship_from_address' first before + using this operation. + """ + # Assert these are strings, error out if not. + assert isinstance(shipment_id, str), "`shipment_id` must be a string." + assert isinstance(shipment_name, str), "`shipment_name` must be a string." + assert isinstance(destination, str), "`destination` must be a string." + + # Parse item args + if items: + items = self._parse_item_args(items, 'UpdateInboundShipment') + else: + items = None + + # Raise exception if no from_address has been set prior to calling + if not self.from_address: + raise MWSError(( + "ShipFromAddress has not been set. " + "Please use `.set_ship_from_address()` first." + )) + # Assemble the from_address using operation-specific header + from_address = self.from_address + from_address = {'InboundShipmentHeader.{}'.format(k): v + for k, v in from_address.items()} + + if shipment_status not in self.SHIPMENT_STATUSES: + # Passed shipment status is an invalid choice. + # Remove it from this request by setting it to None. + shipment_status = None + + if label_preference not in self.LABEL_PREFERENCES: + # Passed label preference is an invalid choice. + # Remove it from this request by setting it to None. + label_preference = None + + case_required = 'true' if case_required else 'false' + + data = { + 'Action': 'UpdateInboundShipment', + 'ShipmentId': shipment_id, + 'InboundShipmentHeader.ShipmentName': shipment_name, + 'InboundShipmentHeader.DestinationFulfillmentCenterId': destination, + 'InboundShipmentHeader.LabelPrepPreference': label_preference, + 'InboundShipmentHeader.AreCasesRequired': case_required, + 'InboundShipmentHeader.ShipmentStatus': shipment_status, + 'InboundShipmentHeader.IntendedBoxContentsSource': box_contents_source, + } + data.update(from_address) + if items: + # Update with an items paramater only if they exist. + data.update(utils.enumerate_keyed_param( + 'InboundShipmentItems.member', items, + )) + return self.make_request(data, method="POST") + + def get_prep_instructions_for_sku(self, skus=None, country_code=None): + """ + Returns labeling requirements and item preparation instructions + to help you prepare items for an inbound shipment. + """ + country_code = country_code or 'US' + skus = skus or [] + + # 'skus' should be a unique list, or there may be an error returned. + skus = utils.unique_list_order_preserved(skus) + + data = dict( + Action='GetPrepInstructionsForSKU', + ShipToCountryCode=country_code, + ) + data.update(utils.enumerate_params({ + 'SellerSKUList.ID.': skus, + })) + return self.make_request(data, method="POST") + + def get_prep_instructions_for_asin(self, asins=None, country_code=None): + """ + Returns item preparation instructions to help with + item sourcing decisions. + """ + country_code = country_code or 'US' + asins = asins or [] + + # 'asins' should be a unique list, or there may be an error returned. + asins = utils.unique_list_order_preserved(asins) + + data = dict( + Action='GetPrepInstructionsForASIN', + ShipToCountryCode=country_code, + ) + data.update(utils.enumerate_params({ + 'ASINList.ID.': asins, + })) + return self.make_request(data, method="POST") + + def get_package_labels(self, shipment_id, num_packages, page_type=None): + """ + Returns PDF document data for printing package labels for + an inbound shipment. + """ + data = dict( + Action='GetPackageLabels', + ShipmentId=shipment_id, + PageType=page_type, + NumberOfPackages=str(num_packages), + ) + return self.make_request(data, method="POST") + + def get_transport_content(self, shipment_id): + """ + Returns current transportation information about an + inbound shipment. + """ + data = dict( + Action='GetTransportContent', + ShipmentId=shipment_id + ) + return self.make_request(data, method="POST") + + def estimate_transport_request(self, shipment_id): + """ + Requests an estimate of the shipping cost for an inbound shipment. + """ + data = dict( + Action='EstimateTransportRequest', + ShipmentId=shipment_id, + ) + return self.make_request(data, method="POST") + + def void_transport_request(self, shipment_id): + """ + Voids a previously-confirmed request to ship your inbound shipment + using an Amazon-partnered carrier. + """ + data = dict( + Action='VoidTransportRequest', + ShipmentId=shipment_id + ) + return self.make_request(data, method="POST") + + def get_bill_of_lading(self, shipment_id): + """ + Returns PDF document data for printing a bill of lading + for an inbound shipment. + """ + data = dict( + Action='GetBillOfLading', + ShipmentId=shipment_id, + ) + return self.make_request(data, "POST") + + @utils.next_token_action('ListInboundShipments') + def list_inbound_shipments(self, shipment_ids=None, shipment_statuses=None, + last_updated_after=None, last_updated_before=None,): + """ + Returns list of shipments based on statuses, IDs, and/or + before/after datetimes. + """ + last_updated_after = utils.dt_iso_or_none(last_updated_after) + last_updated_before = utils.dt_iso_or_none(last_updated_before) + + data = dict( + Action='ListInboundShipments', + LastUpdatedAfter=last_updated_after, + LastUpdatedBefore=last_updated_before, + ) + data.update(utils.enumerate_params({ + 'ShipmentStatusList.member.': shipment_statuses, + 'ShipmentIdList.member.': shipment_ids, + })) + return self.make_request(data, method="POST") + + @utils.next_token_action('ListInboundShipmentItems') + def list_inbound_shipment_items(self, shipment_id=None, last_updated_after=None, + last_updated_before=None,): + """ + Returns list of items within inbound shipments and/or + before/after datetimes. + """ + last_updated_after = utils.dt_iso_or_none(last_updated_after) + last_updated_before = utils.dt_iso_or_none(last_updated_before) - # To be completed + data = dict( + Action='ListInboundShipmentItems', + ShipmentId=shipment_id, + LastUpdatedAfter=last_updated_after, + LastUpdatedBefore=last_updated_before, + ) + return self.make_request(data, method="POST") class Inventory(MWS): - """ Amazon MWS Inventory Fulfillment API """ + """ + Amazon MWS Inventory Fulfillment API + """ URI = '/FulfillmentInventory/2010-10-01' VERSION = '2010-10-01' - NS = "{http://mws.amazonaws.com/FulfillmentInventory/2010-10-01}" - - def list_inventory_supply(self, skus=(), datetime=None, response_group='Basic'): - """ Returns information on available inventory """ + NAMESPACE = "{http://mws.amazonaws.com/FulfillmentInventory/2010-10-01}" + NEXT_TOKEN_OPERATIONS = [ + 'ListInventorySupply', + ] + + @utils.next_token_action('ListInventorySupply') + def list_inventory_supply(self, skus=(), datetime_=None, + response_group='Basic', next_token=None): + """ + Returns information on available inventory + """ data = dict(Action='ListInventorySupply', - QueryStartDateTime=datetime, + QueryStartDateTime=datetime_, ResponseGroup=response_group, ) - data.update(self.enumerate_param('SellerSkus.member.', skus)) + data.update(utils.enumerate_param('SellerSkus.member.', skus)) return self.make_request(data, "POST") def list_inventory_supply_by_next_token(self, token): - data = dict(Action='ListInventorySupplyByNextToken', NextToken=token) - return self.make_request(data, "POST") + """ + Deprecated. + Use `list_inventory_supply(next_token=token)` instead. + """ + # data = dict(Action='ListInventorySupplyByNextToken', NextToken=token) + # return self.make_request(data, "POST") + warnings.warn( + "Use `list_inventory_supply(next_token=token)` instead.", + DeprecationWarning, + ) + return self.list_inventory_supply(next_token=token) class OutboundShipments(MWS): + """ + Amazon MWS Fulfillment Outbound Shipments API + """ URI = "/FulfillmentOutboundShipment/2010-10-01" VERSION = "2010-10-01" - # To be completed + NEXT_TOKEN_OPERATIONS = [ + 'ListAllFulfillmentOrders', + ] + # TODO: Complete this class section class Recommendations(MWS): - - """ Amazon MWS Recommendations API """ - + """ + Amazon MWS Recommendations API + """ URI = '/Recommendations/2013-04-01' VERSION = '2013-04-01' - NS = "{https://mws.amazonservices.com/Recommendations/2013-04-01}" + NAMESPACE = "{https://mws.amazonservices.com/Recommendations/2013-04-01}" + NEXT_TOKEN_OPERATIONS = [ + "ListRecommendations", + ] def get_last_updated_time_for_recommendations(self, marketplaceid): """ Checks whether there are active recommendations for each category for the given marketplace, and if there are, returns the time when recommendations were last updated for each category. """ - data = dict(Action='GetLastUpdatedTimeForRecommendations', MarketplaceId=marketplaceid) return self.make_request(data, "POST") - def list_recommendations(self, marketplaceid, recommendationcategory=None): + @utils.next_token_action('ListRecommendations') + def list_recommendations(self, marketplaceid=None, + recommendationcategory=None, next_token=None): """ Returns your active recommendations for a specific category or for all categories for a specific marketplace. """ - data = dict(Action="ListRecommendations", MarketplaceId=marketplaceid, RecommendationCategory=recommendationcategory) @@ -670,9 +1360,14 @@ def list_recommendations(self, marketplaceid, recommendationcategory=None): def list_recommendations_by_next_token(self, token): """ - Returns the next page of recommendations using the NextToken parameter. + Deprecated. + Use `list_recommendations(next_token=token)` instead. """ - - data = dict(Action="ListRecommendationsByNextToken", - NextToken=token) - return self.make_request(data, "POST") + # data = dict(Action="ListRecommendationsByNextToken", + # NextToken=token) + # return self.make_request(data, "POST") + warnings.warn( + "Use `list_recommendations(next_token=token)` instead.", + DeprecationWarning, + ) + return self.list_recommendations(next_token=token) diff --git a/mws/utils.py b/mws/utils.py index d6e86cf9..f3d5f26c 100644 --- a/mws/utils.py +++ b/mws/utils.py @@ -7,24 +7,24 @@ @author: pierre """ from __future__ import absolute_import - +from functools import wraps import re +import datetime import xml.etree.ElementTree as ET -class object_dict(dict): - """object view of dict, you can - >>> a = object_dict() +class ObjectDict(dict): + """ + Extension of dict to allow accessing keys as attributes. + + Example: + >>> a = ObjectDict() >>> a.fish = 'fish' >>> a['fish'] 'fish' >>> a['water'] = 'water' >>> a.water 'water' - >>> a.test = {'value': 1} - >>> a.test2 = object_dict({'name': 'test2', 'value': 2}) - >>> a.test, a.test2.name, a.test2.value - (1, 'test2', 2) """ def __init__(self, initd=None): if initd is None: @@ -32,13 +32,11 @@ def __init__(self, initd=None): dict.__init__(self, initd) def __getattr__(self, item): + node = self.__getitem__(item) - d = self.__getitem__(item) - - if isinstance(d, dict) and 'value' in d and len(d) == 1: - return d['value'] - else: - return d + if isinstance(node, dict) and 'value' in node and len(node) == 1: + return node['value'] + return node # if value is the only key in object, you can omit it def __setstate__(self, item): @@ -48,22 +46,25 @@ def __setattr__(self, item, value): self.__setitem__(item, value) def getvalue(self, item, value=None): + """ + Old Python 2-compatible getter method for default value. + """ return self.get(item, {}).get('value', value) -class xml2dict(object): +class XML2Dict(object): def __init__(self): pass def _parse_node(self, node): - node_tree = object_dict() + node_tree = ObjectDict() # Save attrs and text, hope there will not be a child with same name if node.text: node_tree.value = node.text - for (k, v) in node.attrib.items(): - k, v = self._namespace_split(k, object_dict({'value': v})) - node_tree[k] = v + for key, val in node.attrib.items(): + key, val = self._namespace_split(key, ObjectDict({'value': val})) + node_tree[key] = val # Save childrens for child in node.getchildren(): tag, tree = self._namespace_split(child.tag, @@ -85,19 +86,175 @@ def _namespace_split(self, tag, value): ns = http://cs.sfsu.edu/csc867/myscheduler name = patients """ - result = re.compile("\{(.*)\}(.*)").search(tag) + result = re.compile(r"\{(.*)\}(.*)").search(tag) if result: value.namespace, tag = result.groups() return (tag, value) - def parse(self, file): - """parse a xml file to a dict""" - f = open(file, 'r') - return self.fromstring(f.read()) + def parse(self, filename): + """ + Parse XML file to a dict. + """ + file_ = open(filename, 'r') + return self.fromstring(file_.read()) + + def fromstring(self, str_): + """ + Parse a string + """ + text = ET.fromstring(str_) + root_tag, root_tree = self._namespace_split(text.tag, self._parse_node(text)) + return ObjectDict({root_tag: root_tree}) + + +def enumerate_param(param, values): + """ + Builds a dictionary of an enumerated parameter, using the param string and some values. + If values is not a list, tuple, or set, it will be coerced to a list + with a single item. + + Example: + enumerate_param('MarketplaceIdList.Id', (123, 345, 4343)) + Returns: + { + MarketplaceIdList.Id.1: 123, + MarketplaceIdList.Id.2: 345, + MarketplaceIdList.Id.3: 4343 + } + """ + if not values: + # Shortcut for empty values + return {} + if not isinstance(values, (list, tuple, set)): + # Coerces a single value to a list before continuing. + values = [values, ] + if not param.endswith('.'): + # Ensure this enumerated param ends in '.' + param += '.' + # Return final output: dict comprehension of the enumerated param and values. + return { + '{}{}'.format(param, idx+1): val + for idx, val in enumerate(values) + } + + +def enumerate_params(params=None): + """ + For each param and values, runs enumerate_param, + returning a flat dict of all results + """ + if params is None or not isinstance(params, dict): + return {} + params_output = {} + for param, values in params.items(): + params_output.update(enumerate_param(param, values)) + return params_output + + +def enumerate_keyed_param(param, values): + """ + Given a param string and a dict of values, returns a flat dict of keyed, enumerated params. + Each dict in the values list must pertain to a single item and its data points. + + Example: + param = "InboundShipmentPlanRequestItems.member" + values = [ + {'SellerSKU': 'Football2415', + 'Quantity': 3}, + {'SellerSKU': 'TeeballBall3251', + 'Quantity': 5}, + ... + ] + + Returns: + { + 'InboundShipmentPlanRequestItems.member.1.SellerSKU': 'Football2415', + 'InboundShipmentPlanRequestItems.member.1.Quantity': 3, + 'InboundShipmentPlanRequestItems.member.2.SellerSKU': 'TeeballBall3251', + 'InboundShipmentPlanRequestItems.member.2.Quantity': 5, + ... + } + """ + if not values: + # Shortcut for empty values + return {} + if not param.endswith('.'): + # Ensure the enumerated param ends in '.' + param += '.' + if not isinstance(values, (list, tuple, set)): + # If it's a single value, convert it to a list first + values = [values, ] + for val in values: + # Every value in the list must be a dict. + if not isinstance(val, dict): + # Value is not a dict: can't work on it here. + raise ValueError(( + "Non-dict value detected. " + "`values` must be a list, tuple, or set; containing only dicts." + )) + params = {} + for idx, val_dict in enumerate(values): + # Build the final output. + params.update({ + '{param}{idx}.{key}'.format(param=param, idx=idx+1, key=k): v + for k, v in val_dict.items() + }) + return params + + +def unique_list_order_preserved(seq): + """ + Returns a unique list of items from the sequence + while preserving original ordering. + The first occurence of an item is returned in the new sequence: + any subsequent occurrences of the same item are ignored. + """ + seen = set() + seen_add = seen.add + return [x for x in seq if not (x in seen or seen_add(x))] + + +def dt_iso_or_none(dt_obj): + """ + If dt_obj is a datetime, return isoformat() + TODO: if dt_obj is a string in iso8601 already, return it back + Otherwise, return None + """ + # If d is a datetime object, format it to iso and return + if isinstance(dt_obj, datetime.datetime): + return dt_obj.isoformat() + + # TODO: if dt_obj is a string in iso8601 already, return it + + # none of the above: return None + return None + + +def next_token_action(action_name): + """ + Decorator that designates an action as having a "...ByNextToken" associated request. + Checks for a `next_token` kwargs in the request and, if present, redirects the call + to `action_by_next_token` using the given `action_name`. + + Only the `next_token` kwarg is consumed by the "next" call: + all other args and kwargs are ignored and not required. + """ + def _decorator(request_func): + @wraps(request_func) + def _wrapped_func(self, *args, **kwargs): + next_token = kwargs.pop('next_token', None) + if next_token is not None: + # Token captured: run the "next" action. + return self.action_by_next_token(action_name, next_token) + return request_func(self, *args, **kwargs) + return _wrapped_func + return _decorator + - def fromstring(self, s): - """parse a string""" - t = ET.fromstring(s) - root_tag, root_tree = self._namespace_split(t.tag, self._parse_node(t)) - return object_dict({root_tag: root_tree}) +# DEPRECATION: these are old names for these objects, which have been updated +# to more idiomatic naming convention. Leaving these names in place in case +# anyone is using the old object names. +# TODO: remove in 1.0.0 +object_dict = ObjectDict +xml2dict = XML2Dict diff --git a/setup.py b/setup.py index 2970734e..e1c6fef2 100644 --- a/setup.py +++ b/setup.py @@ -11,8 +11,9 @@ setup( name='mws', - version='0.7.4', + version='0.8.3', maintainer="James Hiew", + download_url='https://github.com/python-amazon-mws/python-amazon-mws/archive/v0.8.3.tar.gz', maintainer_email="james@hiew.net", url="http://github.com/jameshiew/mws", description=short_description, @@ -37,6 +38,7 @@ 'Programming Language :: Python :: 3.3', 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', ], platforms=['OS Independent'], license='Unlicense', diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..ce561d03 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,31 @@ +import pytest + + +@pytest.fixture +def access_key(): + return "AAAAAAAAAAAAAAAAAAAA" + + +@pytest.fixture +def secret_key(): + return "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + + +@pytest.fixture +def account_id(): + return "AAAAAAAAAAAAAA" + + +@pytest.fixture +def timestamp(): + return '2017-08-12T19:40:35Z' + + +@pytest.fixture +def credentials(access_key, secret_key, account_id): + """Fake set of MWS credentials""" + return { + "access_key": access_key, + "secret_key": secret_key, + "account_id": account_id, + } diff --git a/tests/test_next_token_decorator.py b/tests/test_next_token_decorator.py new file mode 100644 index 00000000..57c84f09 --- /dev/null +++ b/tests/test_next_token_decorator.py @@ -0,0 +1,62 @@ +""" +Testing the `next_token_action` decorator on a toy class. +""" +import mws +# pylint: disable=invalid-name + +ACTION = "SomeAction" + + +class ToyClass(object): + """ + Example class using a method designed to be run with a `next_token`, + calling the corresponding `action_by_next_token` method + """ + def __init__(self): + self.method_run = None + + def action_by_next_token(self, action, token): + """ + Toy next-action method, simply returns the action and token together. + The decorator should call THIS method automatically if a next_token kwarg + is present in the target call. + """ + self.method_run = 'action_by_next_token' + # Modify the action similar to how live code does it, + # for the sake of our sanity here. + modified_action = "{}ByNextToken".format(action) + return modified_action, token + + @mws.utils.next_token_action(ACTION) + def target_request_method(self, next_token=None): + """ + Toy request method, used as the target for our test. + """ + self.method_run = 'target_function' + return ACTION, next_token + + +def test_request_run_normal(): + """ + Call the target request method with no next_token, and we should + see that method run normally. + """ + instance = ToyClass() + action, token = instance.target_request_method() + assert action == ACTION + assert token is None + assert instance.method_run == 'target_function' + + +def test_request_run_with_next_token(): + """ + Call the target request method with no next_token, and we should + see that method run normally. + """ + instance = ToyClass() + next_token = "Olly Olly Oxen Free!" + action, token = instance.target_request_method(next_token=next_token) + what_action_should_be = "{}ByNextToken".format(ACTION) + assert action == what_action_should_be + assert token == next_token + assert instance.method_run == 'action_by_next_token' diff --git a/tests/test_param_methods.py b/tests/test_param_methods.py new file mode 100644 index 00000000..d5b03fdd --- /dev/null +++ b/tests/test_param_methods.py @@ -0,0 +1,142 @@ +""" +Testing for enumerate_param, enumerate_params, and enumerate_keyed_param +""" +import unittest +import mws +# pylint: disable=invalid-name + + +class TestParamsRaiseExceptions(unittest.TestCase): + """ + Simple test that asserts a ValueError is raised by an improper entry to + `utils.enumerate_keyed_param`. + """ + def test_keyed_param_fails_without_dict(self): + """ + Should raise ValueError for values not being a dict. + """ + param = "something" + values = ["this is not a dict like it should be!"] + with self.assertRaises(ValueError): + mws.utils.enumerate_keyed_param(param, values) + + +def test_single_param_default(): + """ + Test each method type for their default empty dicts. + """ + # Single + assert mws.utils.enumerate_param("something", []) == {} + # Multi + assert mws.utils.enumerate_params() == {} + assert mws.utils.enumerate_params("antler") == {} + # Keyed + assert mws.utils.enumerate_keyed_param("acorn", []) == {} + + +def test_single_param_not_dotted_list_values(): + """ + A param string with no dot at the end and a list of ints. + List should be ingested in order. + """ + param = "SomethingOrOther" + values = (123, 765, 3512, 756437, 3125) + result = mws.utils.enumerate_param(param, values) + assert result == { + "SomethingOrOther.1": 123, + "SomethingOrOther.2": 765, + "SomethingOrOther.3": 3512, + "SomethingOrOther.4": 756437, + "SomethingOrOther.5": 3125, + } + + +def test_single_param_dotted_single_value(): + """ + A param string with a dot at the end and a single string value. + Values that are not list, tuple, or set should coerce to a list and provide a single output. + """ + param = "FooBar." + values = "eleven" + result = mws.utils.enumerate_param(param, values) + assert result == { + "FooBar.1": "eleven", + } + + +def test_multi_params(): + """ + A series of params sent as a list of dicts to enumerate_params. + Each param should generate a unique set of keys and values. + Final result should be a flat dict. + """ + param1 = "Summat." + values1 = ("colorful", "cheery", "turkey") + param2 = "FooBaz.what" + values2 = "singular" + param3 = "hot_dog" + values3 = ["something", "or", "other"] + # We could test with values as a set, but we cannot be 100% of the order of the output, + # and I don't feel it necessary to flesh this out enough to account for it. + result = mws.utils.enumerate_params({ + param1: values1, + param2: values2, + param3: values3, + }) + assert result == { + "Summat.1": "colorful", + "Summat.2": "cheery", + "Summat.3": "turkey", + "FooBaz.what.1": "singular", + "hot_dog.1": "something", + "hot_dog.2": "or", + "hot_dog.3": "other", + } + + +def test_keyed_params(): + """ + Asserting the result through enumerate_keyed_param is as expected. + """ + # Example: + # param = "InboundShipmentPlanRequestItems.member" + # values = [ + # {'SellerSKU': 'Football2415', + # 'Quantity': 3}, + # {'SellerSKU': 'TeeballBall3251', + # 'Quantity': 5}, + # ... + # ] + + # Returns: + # { + # 'InboundShipmentPlanRequestItems.member.1.SellerSKU': 'Football2415', + # 'InboundShipmentPlanRequestItems.member.1.Quantity': 3, + # 'InboundShipmentPlanRequestItems.member.2.SellerSKU': 'TeeballBall3251', + # 'InboundShipmentPlanRequestItems.member.2.Quantity': 5, + # ... + # } + param = "AthingToKeyUp.member" + item1 = { + "thing": "stuff", + "foo": "baz", + } + item2 = { + "thing": 123, + "foo": 908, + "bar": "hello", + } + item3 = { + "stuff": "foobarbazmatazz", + "stuff2": "foobarbazmatazz5", + } + result = mws.utils.enumerate_keyed_param(param, [item1, item2, item3]) + assert result == { + "AthingToKeyUp.member.1.thing": "stuff", + "AthingToKeyUp.member.1.foo": "baz", + "AthingToKeyUp.member.2.thing": 123, + "AthingToKeyUp.member.2.foo": 908, + "AthingToKeyUp.member.2.bar": "hello", + "AthingToKeyUp.member.3.stuff": "foobarbazmatazz", + "AthingToKeyUp.member.3.stuff2": "foobarbazmatazz5", + } diff --git a/tests/test_service_status.py b/tests/test_service_status.py index 7ac78559..580f0c2a 100644 --- a/tests/test_service_status.py +++ b/tests/test_service_status.py @@ -1,9 +1,9 @@ import mws -def test_get_service_status(): +def test_get_service_status(credentials): # we can get the service status without needing API credentials # this is a simple smoke test to check that the simplest API request can be successfully made - orders_api = mws.Orders(access_key='', secret_key='', account_id='') + orders_api = mws.Orders(**credentials) r = orders_api.get_service_status() assert r.response.status_code == 200 diff --git a/tests/test_utils.py b/tests/test_utils.py index e2884199..c8747591 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,5 +1,24 @@ -from mws.mws import calc_md5 +from mws.mws import calc_md5, calc_request_description def test_calc_md5(): assert calc_md5(b'mws') == b'mA5nPbh1CSx9M3dbkr3Cyg==' + + +def test_calc_request_description(access_key, account_id): + request_description = calc_request_description({ + 'AWSAccessKeyId': access_key, + 'Markets': account_id, + 'SignatureVersion': '2', + 'Timestamp': '2017-08-12T19:40:35Z', + 'Version': '2017-01-01', + 'SignatureMethod': 'HmacSHA256', + }) + assert not request_description.startswith('&') + assert request_description == \ + 'AWSAccessKeyId=' + access_key + \ + '&Markets=' + account_id + \ + '&SignatureMethod=HmacSHA256' \ + '&SignatureVersion=2' \ + '&Timestamp=2017-08-12T19%3A40%3A35Z' \ + '&Version=2017-01-01'