From a46eb438182546741137e1780048be538e6d98b6 Mon Sep 17 00:00:00 2001 From: Galen Rice Date: Sun, 4 Feb 2018 10:42:07 -0500 Subject: [PATCH 01/11] Release for 0.8.0 (#42) * Added Finances API Feature * .setup.py: bump version to 0.7.5-dev0 * Split out request_description building from make_request() So that it can be tested more easily, and refactored * Split out building the initial params dict from make_request() So that it can be tested more easily * Add fake MWS credentials pytest fixture * test_service_status() should use the pytest fake credentials fixture * Add more pytest fixtures (access_key, secret_key, account_id, timestamp) * Add test for calc_request_description() * Split out calc_request_description() into more statements So that it is easier to debug * Fix calc_request_description - don't include leading ampersand * Don't do automated deployments via Travis (for the moment) * Update README.md badges * InboundShipments, next_token_action decorator, and some style cleanups. (#33) * Testing out git commits from VS Code * Reverting the test commit * Adding VS Code settings to gitignore. * Style fixes * MWS.enumerate_param deprecated: now using utils.enumerate_param and utils.enumerate_params * InboundShipments fleshed out; added `utils.next_token_action` decorator; deprecated separate methods for `...by_next_token()` * Bugfix, rename `_enumerate_param` to `enumerate_param` (no need for private) * Fix for next_token issues. * TravisCI flake8 complaining, fixes. * Minor flake8 complaint. * Hack to get flake8 to stop complaining. * Strip pylint disables to clear line length issue. * Correction to keyed params, now tests every item in values sequence to ensure all are dicts. * Add tests for param methods in utils. * Add test for next token decorator. * Adding 'InboundShipments' to `__all__` * Assigning response a default in __init__ for DictWrapper and DataWrapper * Unneeded line breaks removed + docstring formatting * Comment corrected. They're tuples, not sets. * Finances methods updated to use next_token_action decorator * Create .travis.yaml * Update .gitignore * Removing deploy code from local travis * Delete .travis.yaml * Pushed to 0.8.0-dev0 Recently added functionality to InboundShipments, as well as Finances API. These constitute feature additions with backwards compatibility, which calls for a minor version update. * Adding Python 3.6 category We are testing in 3.6 in Travis anyway. May as well include the note. * Specified master and develop branches for badges Ensured the badges for Travis and Codecov are pointing to the appropriate branches (used to be pointing to default, which was master in both cases). * Updated comments throughout module No substantial code changes, comment changes only. Also ensured all docstrings follow same format. * Fixed docstring formatting Also made slight update to docstring for ObjectDict to more clearly note what it does, vs what the original code did. * Fix for flake8 (trailing whitespace) * Fix for flake8 (trailing whitespace) * Bump to 0.8.0 (drop dev tag) for release * Bug: Incorrect use of `super` for back-compat Using the old-style `super` syntax to comply with Python 2.7 compatibility. Not revealed in tests, because current tests don't touch the APIs. Whoops! * Added back old object names in case needed Old names `object_dict` and `xml2dict` added back in case the old objects are being used directly by some users. To be removed in 1.0.0 release down the road. --- .gitignore | 9 +- .travis.yml | 16 - README.md | 8 +- mws/mws.py | 1007 +++++++++++++++++++++++----- mws/utils.py | 215 +++++- setup.py | 3 +- tests/conftest.py | 31 + tests/test_next_token_decorator.py | 62 ++ tests/test_param_methods.py | 142 ++++ tests/test_service_status.py | 4 +- tests/test_utils.py | 21 +- 11 files changed, 1297 insertions(+), 221 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/test_next_token_decorator.py create mode 100644 tests/test_param_methods.py diff --git a/.gitignore b/.gitignore index 420be1b5..6074a6eb 100644 --- a/.gitignore +++ b/.gitignore @@ -21,8 +21,13 @@ pip-log.txt .coverage .tox -#Translations +# Translations *.mo -#Mr Developer +# Mr Developer .mr.developer.cfg + +# VS Code Settings +.vscode/settings.json + +.travis.yml 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/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..ed254cb9 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 @@ -26,12 +27,14 @@ __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 +58,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 +66,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,25 +101,25 @@ 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): self.original = data + self.response = None if 'content-md5' in header: hash_ = calc_md5(self.original) if header['content-md5'].encode() != hash_: @@ -120,8 +131,9 @@ def parsed(self): class MWS(object): - """ Base Amazon API class """ - + """ + 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 +144,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 +164,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 +185,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,18 +215,9 @@ 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)'} @@ -231,14 +259,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 +304,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 +340,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 +358,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 +398,14 @@ 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', + 'GetReportScheduleList', + ] # * REPORTS * # @@ -356,78 +418,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 +528,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 +594,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 +639,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 +647,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 +682,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) + # 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. + """ + 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.from_address = self.set_ship_from_address(addr) + super(InboundShipments, self).__init__(*args, **kwargs) - # To be completed + 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) + + 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 +1334,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..ff258563 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='mws', - version='0.7.4', + version='0.8.0', maintainer="James Hiew", maintainer_email="james@hiew.net", url="http://github.com/jameshiew/mws", @@ -37,6 +37,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' From f55e17335a1572759a7ec59affc21d0d30e42a0b Mon Sep 17 00:00:00 2001 From: Galen Rice Date: Tue, 6 Feb 2018 11:39:43 -0500 Subject: [PATCH 02/11] Version bump for agent string. --- mws/mws.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mws/mws.py b/mws/mws.py index ed254cb9..9f218c5a 100644 --- a/mws/mws.py +++ b/mws/mws.py @@ -220,7 +220,7 @@ def make_request(self, extra_data, method="GET", **kwargs): 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)'} + headers = {'User-Agent': 'python-amazon-mws/0.8.0 (Language=Python)'} headers.update(kwargs.get('extra_headers', {})) try: From a900825f5798ca4a95e5ff69a6f08d0b23b5cdec Mon Sep 17 00:00:00 2001 From: Galen Rice Date: Tue, 6 Feb 2018 11:43:25 -0500 Subject: [PATCH 03/11] Hi, howyadoin? --- AUTHORS | 1 + 1 file changed, 1 insertion(+) 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): From 91d77f9df3d8f36a08b415b674b83f77e311db3d Mon Sep 17 00:00:00 2001 From: Galen Rice Date: Tue, 6 Feb 2018 12:21:01 -0500 Subject: [PATCH 04/11] Format request url with str.format Remove old-style string formatting for sake of clarity. --- mws/mws.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/mws/mws.py b/mws/mws.py index 9f218c5a..c83b621d 100644 --- a/mws/mws.py +++ b/mws/mws.py @@ -219,7 +219,12 @@ def make_request(self, extra_data, method="GET", **kwargs): params.update(extra_data) 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)) + 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.0 (Language=Python)'} headers.update(kwargs.get('extra_headers', {})) From 966e0b1818a91b2a8fbed06ab81013ae9a7ac777 Mon Sep 17 00:00:00 2001 From: Galen Rice Date: Tue, 13 Mar 2018 15:28:42 -0400 Subject: [PATCH 05/11] BUG: GetReportList missing from next token ops Added in with a hotfix. --- mws/mws.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mws/mws.py b/mws/mws.py index c83b621d..dc83d814 100644 --- a/mws/mws.py +++ b/mws/mws.py @@ -409,6 +409,7 @@ class Reports(MWS): ACCOUNT_TYPE = "Merchant" NEXT_TOKEN_OPERATIONS = [ 'GetReportRequestList', + 'GetReportList', 'GetReportScheduleList', ] From d7e333fbd74e2a65d56c34d64703741bddcb4b4c Mon Sep 17 00:00:00 2001 From: Galen Rice Date: Tue, 13 Mar 2018 15:31:45 -0400 Subject: [PATCH 06/11] Version push --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index ff258563..85f4b879 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='mws', - version='0.8.0', + version='0.8.1', maintainer="James Hiew", maintainer_email="james@hiew.net", url="http://github.com/jameshiew/mws", From 98e7ddfc539168c9ab9d13d7a67b98ca37b05892 Mon Sep 17 00:00:00 2001 From: Galen Rice Date: Tue, 13 Mar 2018 15:32:12 -0400 Subject: [PATCH 07/11] version push --- mws/mws.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mws/mws.py b/mws/mws.py index dc83d814..1cc74617 100644 --- a/mws/mws.py +++ b/mws/mws.py @@ -225,7 +225,7 @@ def make_request(self, extra_data, method="GET", **kwargs): description=request_description, signature=quote(signature), ) - headers = {'User-Agent': 'python-amazon-mws/0.8.0 (Language=Python)'} + headers = {'User-Agent': 'python-amazon-mws/0.8.2 (Language=Python)'} headers.update(kwargs.get('extra_headers', {})) try: From 6969f6755e913fc74bc4d8ba17fa3fc6e054aea3 Mon Sep 17 00:00:00 2001 From: Galen Rice Date: Tue, 13 Mar 2018 15:32:43 -0400 Subject: [PATCH 08/11] version bump (corrects wrong version from before) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 85f4b879..17521386 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='mws', - version='0.8.1', + version='0.8.2', maintainer="James Hiew", maintainer_email="james@hiew.net", url="http://github.com/jameshiew/mws", From 06670bb9e71312c568ac8a2223de5691898cf019 Mon Sep 17 00:00:00 2001 From: Galen Rice Date: Wed, 14 Mar 2018 10:19:35 -0400 Subject: [PATCH 09/11] Bugfix inbound constructor (0.8.3 release) (#57) * version bump * No from_address assignment needed in Inbound init Similar fix as commit on `develop`, which will come in with 1.0 release. Also, version bump for new bugfix release. --- mws/mws.py | 4 ++-- setup.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mws/mws.py b/mws/mws.py index 1cc74617..1e3f65ec 100644 --- a/mws/mws.py +++ b/mws/mws.py @@ -225,7 +225,7 @@ def make_request(self, extra_data, method="GET", **kwargs): description=request_description, signature=quote(signature), ) - headers = {'User-Agent': 'python-amazon-mws/0.8.2 (Language=Python)'} + headers = {'User-Agent': 'python-amazon-mws/0.8.3 (Language=Python)'} headers.update(kwargs.get('extra_headers', {})) try: @@ -829,7 +829,7 @@ def __init__(self, *args, **kwargs): self.from_address = {} addr = kwargs.pop('from_address', None) if addr is not None: - self.from_address = self.set_ship_from_address(addr) + self.set_ship_from_address(addr) super(InboundShipments, self).__init__(*args, **kwargs) def set_ship_from_address(self, address): diff --git a/setup.py b/setup.py index 17521386..f7b91918 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='mws', - version='0.8.2', + version='0.8.3', maintainer="James Hiew", maintainer_email="james@hiew.net", url="http://github.com/jameshiew/mws", From 6c67339c5a1f99937c60f809526ce06a4ada460e Mon Sep 17 00:00:00 2001 From: James Hiew Date: Tue, 20 Mar 2018 10:51:52 +0000 Subject: [PATCH 10/11] Add `download_url` to setup.py --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index f7b91918..e1c6fef2 100644 --- a/setup.py +++ b/setup.py @@ -13,6 +13,7 @@ name='mws', 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, From baac4a8e292e4cf2f018d92f66e6c25c5fb65d52 Mon Sep 17 00:00:00 2001 From: Anmol Gulati Date: Fri, 18 May 2018 19:48:30 +0530 Subject: [PATCH 11/11] Fallback to _response_dict if _rootkey is not present in response dict This is the case while fetching some of the reports. I particularly found this in '_GET_XML_RETURNS_DATA_BY_RETURN_DATE_'. In this case the data is returned but not in rootkey. This will at least let the user parse the data on there own --- mws/mws.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mws/mws.py b/mws/mws.py index 1e3f65ec..45577b17 100644 --- a/mws/mws.py +++ b/mws/mws.py @@ -109,7 +109,7 @@ def __init__(self, xml, rootkey=None): @property def parsed(self): if self._rootkey: - return self._response_dict.get(self._rootkey) + return self._response_dict.get(self._rootkey, self._response_dict) return self._response_dict