diff --git a/.gitignore b/.gitignore index 3566106..c75da77 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,8 @@ MANIFEST build/ dist/ .idea +test_settings.py +_build +_static +_templates +.tox/ diff --git a/.travis.yml b/.travis.yml index 8ee4693..e29bfd8 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,9 +1,22 @@ language: python python: - - 2.7 -install: - - pip install . --use-mirrors - - pip install pep8 --use-mirrors - - pip install -r requirements.txt --use-mirrors -script: - - "pep8 --ignore=E501,E225,E128 amazon" +- 2.7 +- 3.5 +install: +- pip install -r requirements.txt +- pip install -r test-requirements.txt +- pip install . +script: +- pep8 --ignore=E501,E225,E128 amazon +- sphinx-build -b html -d _build/doctrees ./docs/ _build/html +- nosetests --with-flaky --with-coverage --cover-package=amazon +after_success: +- coveralls +sudo: false +deploy: + provider: pypi + user: "Yoav.Aviram" + password: + secure: FOaqWOnjlWbM/K7MmyAXy1V6zDUYEkfO4FgLYiAOoTXGfNdOKSJGxKD7cb+Wq5CjN3XX/Y1KNXwXZZ4odKRWX463Q80vU4x6wpxMbFYLP36EuwiFNqW63qYLftbSkqDrLWRjp06vENDGmadStdYb9CruboLzyau07mFPpHmuWmg= + on: + tags: true diff --git a/CODESHELTER.md b/CODESHELTER.md new file mode 100644 index 0000000..365ea79 --- /dev/null +++ b/CODESHELTER.md @@ -0,0 +1,25 @@ +# Note to Code Shelter maintainers + +I started this project a while ago and it grow and grow in popularity. Over the +past few years I've hed less and less time to work on it, and that's unfair to +all the people who use. I'd appreciate some help with maintaining the project. +I will generally be available to consult and maybe develop the occasional +feature or bugfix, but my availability is not reliable. + +It would be great if you could help out with issue triage, fixing bugs, +merging pull request, improving documentation and test coverage and +occasionally developing new features that you think are necessary. The project +is mainly feature-complete and the Amazon API is quite stable, so I don't +expect any major deviation and would appreciate being consulted before making +any drastic changes, but other than that, you have free reign. + +I have already added Code Shelter to the project's PyPI page, so feel free to +make any releases necessary. + +The codebase is in reasonable shape. The one major issue which needs sorting out +is that Amazon have terminated my associate account due to inactivity, and won't +let me create a new one (apperently maintaining this library does not qualify as +a reason). The implication is that I do not have credentials to test the library with. +I am working on sorting this one out but any help is welcome. + +Thank you! diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 999a966..0000000 --- a/LICENSE +++ /dev/null @@ -1,13 +0,0 @@ -Copyright (C) 2012 Yoav Aviram. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. \ No newline at end of file diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..8dada3e --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..7c1e6db --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,5 @@ +include *.md *.rst *.txt INSTALL LICENSE tox.ini .travis.yml docs/Makefile +include tests.py +recursive-include docs *.py +recursive-include docs *.rst +prune docs/_build diff --git a/README.md b/README.md index 98121b5..ab4d917 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,15 @@ -Amazon Simple Product API +Amazon Simple Product API ========================== -A simple Python wrapper for the Amazon.com Product Advertising API. -[![Build Status](https://secure.travis-ci.org/yoavaviram/python-amazon-simple-product-api.png?branch=master)](http://travis-ci.org/yoavaviram/python-amazon-simple-product-api) +A simple Python wrapper for the Amazon.com Product Advertising API. +[![Build Status](https://secure.travis-ci.org/yoavaviram/python-amazon-simple-product-api.png?branch=master)](http://travis-ci.org/yoavaviram/python-amazon-simple-product-api) +[![Documentation Status](https://readthedocs.org/projects/python-amazon-simple-product-api/badge/?version=latest)](http://python-amazon-simple-product-api.readthedocs.org/en/latest/?badge=latest) +[![Coverage Status](https://coveralls.io/repos/github/yoavaviram/python-amazon-simple-product-api/badge.svg?branch=master&bust=1)](https://coveralls.io/github/yoavaviram/python-amazon-simple-product-api?branch=master) +[![PyPI version](https://badge.fury.io/py/python-amazon-simple-product-api.svg)](https://badge.fury.io/py/python-amazon-simple-product-api) +![PyPI - License](https://img.shields.io/pypi/l/python-amazon-simple-product-api.svg) +![PyPI - Downloads](https://img.shields.io/pypi/dm/python-amazon-simple-product-api.svg) +![PyPI - Python Version](https://img.shields.io/pypi/pyversions/python-amazon-simple-product-api.svg) +[![Code Shelter](https://www.codeshelter.co/static/badges/badge-flat.svg)](https://www.codeshelter.co/) Features -------- @@ -18,9 +25,13 @@ Before you get started, make sure you have: * Installed [Bottlenose](https://github.com/lionheart/bottlenose) (`pip install bottlenose`) * Installed lxml (`pip install lxml`) +* Installed [dateutil](http://labix.org/python-dateutil) (`pip install python-dateutil`) * An Amazon Product Advertising account * An AWS account +Installation +------------- + pip install python-amazon-simple-product-api Usage ----- @@ -29,19 +40,19 @@ Lookup: >>> from amazon.api import AmazonAPI >>> amazon = AmazonAPI(AMAZON_ACCESS_KEY, AMAZON_SECRET_KEY, AMAZON_ASSOC_TAG) - >>> product = amazon.lookup(ItemId='B0051QVF7A') + >>> product = amazon.lookup(ItemId='B00EOE0WKQ') >>> product.title - 'Kindle, Wi-Fi, 6" E Ink Display - for international shipment' + 'Amazon Fire Phone, 32GB (AT&T)' >>> product.price_and_currency - (89.0, 'USD') + (199.0, 'USD') >>> product.ean - '0814916014354' + '0848719035209' >>> product.large_image_url - 'http://ecx.images-amazon.com/images/I/411H%2B731ZzL.jpg' + 'http://ecx.images-amazon.com/images/I/51BrZzpkWrL.jpg' >>> product.get_attribute('Publisher') - 'Amazon Digital Services, Inc' + 'Amazon' >>> product.get_attributes(['ItemDimensions.Width', 'ItemDimensions.Height']) - {'ItemDimensions.Width': '450', 'ItemDimensions.Height': '34'} + {'ItemDimensions.Width': '262', 'ItemDimensions.Height': '35'} (the API wrapper also supports many other product attributes) @@ -51,7 +62,7 @@ Lookup on amazon.de instead of amazon.com by setting the region: >>> import bottlenose.api >>> region_options = bottlenose.api.SERVICE_DOMAINS.keys() >>> region_options - ['US', 'FR', 'CN', 'UK', 'CA', 'DE', 'JP', 'IT', 'ES'] + ['US', 'FR', 'CN', 'UK', 'IN', 'CA', 'DE', 'JP', 'IT', 'ES'] >>> amazon_de = AmazonAPI(AMAZON_ACCESS_KEY, AMAZON_SECRET_KEY, AMAZON_ASSOC_TAG, region="DE") >>> product = amazon_de.lookup(ItemId='B0051QVF7A') >>> product.title @@ -59,16 +70,18 @@ Lookup on amazon.de instead of amazon.com by setting the region: >>> product.price_and_currency (99.0, 'EUR') -Batch lookup requests are also supported: +Bulk lookup requests are also supported: >>> from amazon.api import AmazonAPI >>> amazon = AmazonAPI(AMAZON_ACCESS_KEY, AMAZON_SECRET_KEY, AMAZON_ASSOC_TAG) - >>> products = amazon.lookup(ItemId='B0051QVESA,B005DOK8NW,B005890G8Y,B0051VVOB2,B005890G8O') + >>> products = amazon.lookup(ItemId='B00KC6I06S,B005DOK8NW,B00TSUGXKE') >>> len(products) 5 >>> products[0].asin 'B0051QVESA' +If you'd rather get an empty list intead of exceptions use lookup_bulk() instead. + Search: >>> from amazon.api import AmazonAPI @@ -123,6 +136,29 @@ Browse Node Lookup: >>> bn.name 'eBook Readers' +Create and manipulate Carts: + + >>> from amazon.api import AmazonAPI + >>> amazon = AmazonAPI(AMAZON_ACCESS_KEY, AMAZON_SECRET_KEY, AMAZON_ASSOC_TAG) + >>> product = amazon.lookup(ItemId="B0016J8AOC") + >>> item = {'offer_id': product.offer_id, 'quantity': 1} + >>> cart = amazon.cart_create(item) + >>> fetched_cart = amazon.cart_get(cart.cart_id, cart.hmac) + >>> another_product = amazon.lookup(ItemId='0312098286') + >>> another_item = {'offer_id': another_product.offer_id, 'quantity': 1} + >>> another_cart = amazon.cart_add(another_item, cart.cart_id, cart.hmac) + >>> cart_item_id = None + >>> for item in cart: + >>> cart_item_id = item.cart_item_id + >>> modify_item = {'cart_item_id': cart_item_id, 'quantity': 3} + >>> modified_cart = amazon.cart_modify(item, cart.cart_id, cart.hmac) + >>> cleared_cart = amazon.cart_clear(cart.cart_id, cart.hmac) + +For the 'Books' SearchIndex a [Power Search](https://docs.aws.amazon.com/AWSECommerceService/latest/DG/PowerSearchSyntax.html) option is avaialble: + + >>> products = amazon.search(Power="subject:history and (spain or mexico) and not military and language:spanish",SearchIndex='Books') + + For more information about these calls, please consult the [Product Advertising API Developer Guide](http://docs.amazonwebservices.com/AWSECommerceService/latest/DG/index.html). @@ -134,10 +170,20 @@ To run the test suite please follow these steps: * Create a local file named: `test_settings.py` with the following variables set to the relevant values: `AMAZON_ACCESS_KEY`, `AMAZON_SECRET_KEY`, `AMAZON_ASSOC_TAG` * Run `nosetests` +Pull Requests +-------------- + +* All code should be unit tested +* All tests must pass +* Source code should be PEP8 complient +* Coverage shouldn't decrease +* All Pull Requests should be rebased against master before submitting the PR + +**This project is looking for core contributors. Please message me.** + License ------- Copyright © 2012 Yoav Aviram See LICENSE for details. - diff --git a/amazon/__init__.py b/amazon/__init__.py index e69de29..bf8cadb 100644 --- a/amazon/__init__.py +++ b/amazon/__init__.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- + +""" +Python Amazon Simple Product Api +~~~~~~~ +Amazon Product Advertising API Client library. +""" + +__version__ = '2.2.11' +__author__ = 'Yoav Aviram' diff --git a/amazon/api.py b/amazon/api.py index 70e9257..99a7ad9 100644 --- a/amazon/api.py +++ b/amazon/api.py @@ -1,4 +1,4 @@ -#!/usr/bin/python +# !/usr/bin/python # # Copyright (C) 2012 Yoav Aviram. # @@ -6,7 +6,7 @@ # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # -# http://www.apache.org/licenses/LICENSE-2.0 +# http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, @@ -18,9 +18,25 @@ import bottlenose from lxml import objectify, etree +import dateutil.parser +from decimal import Decimal -AMAZON_ASSOCIATES_BASE_URL = 'http://www.amazon.{region}/dp/' +# https://kdp.amazon.com/help?topicId=A1CT8LK6UW2FXJ +DOMAINS = { + 'CA': 'ca', + 'DE': 'de', + 'ES': 'es', + 'FR': 'fr', + 'IN': 'in', + 'IT': 'it', + 'JP': 'co.jp', + 'UK': 'co.uk', + 'US': 'com', + 'CN': 'cn' +} + +AMAZON_ASSOCIATES_BASE_URL = 'http://www.amazon.{domain}/dp/' class AmazonException(Exception): @@ -29,6 +45,18 @@ class AmazonException(Exception): pass +class CartException(AmazonException): + """Cart related Exception + """ + pass + + +class CartInfoMismatchException(CartException): + """HMAC, CartId and AssociateTag did not match + """ + pass + + class AsinNotFound(AmazonException): """ASIN Not Found Exception. """ @@ -53,6 +81,13 @@ class NoMorePages(SearchException): pass +class RequestThrottled(AmazonException): + """Exception for when Amazon has throttled a request, per: + http://docs.aws.amazon.com/AWSECommerceService/latest/DG/ErrorNumbers.html + """ + pass + + class SimilartyLookupException(AmazonException): """Similarty Lookup Exception. """ @@ -66,24 +101,67 @@ class BrowseNodeLookupException(AmazonException): class AmazonAPI(object): - def __init__(self, aws_key, aws_secret, aws_associate_tag, region="US"): + def __init__(self, aws_key, aws_secret, aws_associate_tag, **kwargs): """Initialize an Amazon API Proxy. + kwargs values are passed directly to Bottlenose. Check the Bottlenose + API for valid values (some are provided below). + For legacy support, the older 'region' value is still supported. + Code should be updated to use the Bottlenose 'Region' value + instead. + :param aws_key: A string representing an AWS authentication key. :param aws_secret: A string representing an AWS authentication secret. :param aws_associate_tag: A string representing an AWS associate tag. - :param region: - A string representing the region, defaulting to "US" (amazon.com) + + Important Bottlenose arguments: + :param Region: + ccTLD you want to search for products on (e.g. 'UK' + for amazon.co.uk). See keys of bottlenose.api.SERVICE_DOMAINS for options, which were CA, CN, DE, ES, FR, IT, JP, UK, US at the time of writing. + Must be uppercase. Default is 'US' (amazon.com). + :param MaxQPS: + Optional maximum queries per second. If we've made an API call + on this object more recently that 1/MaxQPS, we'll wait + before making the call. Useful for making batches of queries. + You generally want to set this a little lower than the + max (so 0.9, not 1.0). + Amazon limits the number of calls per hour, so for long running + tasks this should be set to 0.9 to ensure you don't hit the + maximum. + Defaults to None (unlimited). + :param Timeout: + Optional timeout for queries. + Defaults to None. + :param CacheReader: + Called before attempting to make an API call. + A function that takes a single argument, the URL that + would be passed to the API, minus auth information, + and returns a cached version of the (unparsed) response, + or None. + Defaults to None. + :param CacheWriter: + Called after a successful API call. A function that + takes two arguments, the same URL passed to + CacheReader, and the (unparsed) API response. + Defaults to None. """ + # support older style calls + if 'region' in kwargs: + kwargs['Region'] = kwargs['region'] + del kwargs['region'] + + if 'Version' not in kwargs: + kwargs['Version'] = '2013-08-01' + self.api = bottlenose.Amazon( - aws_key, aws_secret, aws_associate_tag, Region=region) + aws_key, aws_secret, aws_associate_tag, **kwargs) self.aws_associate_tag = aws_associate_tag - self.region = region + self.region = kwargs.get('Region', 'US') def lookup(self, ResponseGroup="Large", **kwargs): """Lookup an Amazon Product. @@ -99,7 +177,7 @@ def lookup(self, ResponseGroup="Large", **kwargs): code = root.Items.Request.Errors.Error.Code msg = root.Items.Request.Errors.Error.Message raise LookupException( - "Amazon Product Lookup Error: '{0}', '{1}'".format(code, msg)) + u"Amazon Product Lookup Error: '{0}', '{1}'".format(code, msg)) if not hasattr(root.Items, 'Item'): raise AsinNotFound("ASIN(s) not found: '{0}'".format( etree.tostring(root, pretty_print=True))) @@ -119,6 +197,27 @@ def lookup(self, ResponseGroup="Large", **kwargs): region=self.region ) + def lookup_bulk(self, ResponseGroup="Large", **kwargs): + """Lookup Amazon Products in bulk. + + Returns all products matching requested ASINs, ignoring invalid + entries. + + :return: + A list of :class:`~.AmazonProduct` instances. + """ + response = self.api.ItemLookup(ResponseGroup=ResponseGroup, **kwargs) + root = objectify.fromstring(response) + if not hasattr(root.Items, 'Item'): + return [] + return list( + AmazonProduct( + item, + self.aws_associate_tag, + self, + region=self.region) for item in root.Items.Item + ) + def similarity_lookup(self, ResponseGroup="Large", **kwargs): """Similarty Lookup. @@ -171,6 +270,8 @@ def search(self, **kwargs): :return: An :class:`~.AmazonSearch` iterable. """ + region = kwargs.get('region', self.region) + kwargs.update({'region': region}) return AmazonSearch(self.api, self.aws_associate_tag, **kwargs) def search_n(self, n, **kwargs): @@ -181,15 +282,240 @@ def search_n(self, n, **kwargs): :return: A list of :class:`~.AmazonProduct`. """ + region = kwargs.get('region', self.region) + kwargs.update({'region': region}) items = AmazonSearch(self.api, self.aws_associate_tag, **kwargs) return list(islice(items, n)) + def cart_create(self, items, **kwargs): + """CartCreate. + :param items: + A dictionary containing the items to be added to the cart. + Or a list containing these dictionaries. + It is not possible to create an empty cart! + example: [{'offer_id': 'rt2ofih3f389nwiuhf8934z87o3f4h', + 'quantity': 1}] + + :return: + An :class:`~.AmazonCart`. + """ + + if isinstance(items, dict): + items = [items] + + if len(items) > 10: + raise CartException("You can't add more than 10 items at once") + + offer_id_key_template = 'Item.{0}.OfferListingId' + quantity_key_template = 'Item.{0}.Quantity' + + for i, item in enumerate(items): + kwargs[offer_id_key_template.format(i)] = item['offer_id'] + kwargs[quantity_key_template.format(i)] = item['quantity'] + + response = self.api.CartCreate(**kwargs) + root = objectify.fromstring(response) + + return AmazonCart(root) + + def cart_add(self, items, CartId=None, HMAC=None, **kwargs): + """CartAdd. + :param items: + A dictionary containing the items to be added to the cart. + Or a list containing these dictionaries. + It is not possible to create an empty cart! + example: [{'offer_id': 'rt2ofih3f389nwiuhf8934z87o3f4h', + 'quantity': 1}] + :param CartId: Id of Cart + :param HMAC: HMAC of Cart, see CartCreate for more info + :return: + An :class:`~.AmazonCart`. + """ + if not CartId or not HMAC: + raise CartException('CartId and HMAC required for CartAdd call') + + if isinstance(items, dict): + items = [items] + + if len(items) > 10: + raise CartException("You can't add more than 10 items at once") + + offer_id_key_template = 'Item.{0}.OfferListingId' + quantity_key_template = 'Item.{0}.Quantity' + + for i, item in enumerate(items): + kwargs[offer_id_key_template.format(i)] = item['offer_id'] + kwargs[quantity_key_template.format(i)] = item['quantity'] + + response = self.api.CartAdd(CartId=CartId, HMAC=HMAC, **kwargs) + root = objectify.fromstring(response) + + new_cart = AmazonCart(root) + self._check_for_cart_error(new_cart) + + return new_cart + + def cart_clear(self, CartId=None, HMAC=None, **kwargs): + """CartClear. Removes all items from cart + :param CartId: Id of cart + :param HMAC: HMAC of cart. Do not use url encoded + :return: An :class:`~.AmazonCart`. + """ + if not CartId or not HMAC: + raise CartException('CartId required for CartClear call') + response = self.api.CartClear(CartId=CartId, HMAC=HMAC, **kwargs) + root = objectify.fromstring(response) + + new_cart = AmazonCart(root) + self._check_for_cart_error(new_cart) + + return new_cart + + def cart_get(self, CartId=None, HMAC=None, **kwargs): + """CartGet fetches existing cart + :param CartId: see CartCreate + :param HMAC: see CartCreate + :return: An :class:`~.AmazonCart`. + """ + if not CartId or not HMAC: + raise CartException('CartId required for CartGet call') + response = self.api.CartGet(CartId=CartId, HMAC=HMAC, **kwargs) + root = objectify.fromstring(response) + + cart = AmazonCart(root) + self._check_for_cart_error(cart) + + return cart + + def cart_modify(self, items, CartId=None, HMAC=None, **kwargs): + """CartAdd. + :param items: + A dictionary containing the items to be added to the cart. + Or a list containing these dictionaries. + example: [{'cart_item_id': 'rt2ofih3f389nwiuhf8934z87o3f4h', + 'quantity': 1}] + :param CartId: Id of Cart + :param HMAC: HMAC of Cart, see CartCreate for more info + :return: + An :class:`~.AmazonCart`. + """ + if not CartId or not HMAC: + raise CartException('CartId required for CartModify call') + + if isinstance(items, dict): + items = [items] + + if len(items) > 10: + raise CartException("You can't add more than 10 items at once") + + cart_item_id_key_template = 'Item.{0}.CartItemId' + quantity_key_template = 'Item.{0}.Quantity' + + for i, item in enumerate(items): + kwargs[cart_item_id_key_template.format(i)] = item['cart_item_id'] + kwargs[quantity_key_template.format(i)] = item['quantity'] + + response = self.api.CartModify(CartId=CartId, HMAC=HMAC, **kwargs) + root = objectify.fromstring(response) + + new_cart = AmazonCart(root) + self._check_for_cart_error(new_cart) + + return new_cart + + @staticmethod + def _check_for_cart_error(cart): + if cart._safe_get_element('Cart.Request.Errors') is not None: + error = cart._safe_get_element( + 'Cart.Request.Errors.Error.Code').text + if error == 'AWS.ECommerceService.CartInfoMismatch': + raise CartInfoMismatchException( + 'CartGet failed: AWS.ECommerceService.CartInfoMismatch ' + 'make sure AssociateTag, CartId and HMAC are correct ' + '(dont use URLEncodedHMAC!!!)' + ) + raise CartException('CartGet failed: ' + error) + + +class LXMLWrapper(object): + def __init__(self, parsed_response): + self.parsed_response = parsed_response + + def to_string(self): + """Convert Item XML to string. + + :return: + A string representation of the Item xml. + """ + return etree.tostring(self.parsed_response, pretty_print=True) + + def _safe_get_element(self, path, root=None): + """Safe Get Element. + + Get a child element of root (multiple levels deep) failing silently + if any descendant does not exist. + + :param root: + Lxml element. + :param path: + String path (i.e. 'Items.Item.Offers.Offer'). + :return: + Element or None. + """ + elements = path.split('.') + parent = root if root is not None else self.parsed_response + for element in elements[:-1]: + parent = getattr(parent, element, None) + if parent is None: + return None + return getattr(parent, elements[-1], None) + + def _safe_get_element_text(self, path, root=None): + """Safe get element text. + + Get element as string or None, + :param root: + Lxml element. + :param path: + String path (i.e. 'Items.Item.Offers.Offer'). + :return: + String or None. + """ + element = self._safe_get_element(path, root) + if element is not None: + return element.text + else: + return None + + def _safe_get_element_date(self, path, root=None): + """Safe get elemnent date. + + Get element as datetime.date or None, + :param root: + Lxml element. + :param path: + String path (i.e. 'Items.Item.Offers.Offer'). + :return: + datetime.date or None. + """ + value = self._safe_get_element_text(path=path, root=root) + if value is not None: + try: + value = dateutil.parser.parse(value) + if value: + value = value.date() + except ValueError: + value = None + + return value + class AmazonSearch(object): """ Amazon Search. A class providing an iterable over amazon search results. """ + def __init__(self, api, aws_associate_tag, **kwargs): """Initialise @@ -201,7 +527,8 @@ def __init__(self, api, aws_associate_tag, **kwargs): An string representing an Amazon Associates tag. """ self.kwargs = kwargs - self.current_page = 1 + self.current_page = 0 + self.is_last_page = False self.api = api self.aws_associate_tag = aws_associate_tag @@ -216,7 +543,8 @@ def __iter__(self): """ for page in self.iterate_pages(): for item in getattr(page.Items, 'Item', []): - yield AmazonProduct(item, self.aws_associate_tag, self.api) + yield AmazonProduct( + item, self.aws_associate_tag, self.api, **self.kwargs) def iterate_pages(self): """Iterate Pages. @@ -228,9 +556,9 @@ def iterate_pages(self): Yields lxml root elements. """ try: - while True: - yield self._query(ItemPage=self.current_page, **self.kwargs) + while not self.is_last_page: self.current_page += 1 + yield self._query(ItemPage=self.current_page, **self.kwargs) except NoMorePages: pass @@ -244,22 +572,25 @@ def _query(self, ResponseGroup="Large", **kwargs): """ response = self.api.ItemSearch(ResponseGroup=ResponseGroup, **kwargs) root = objectify.fromstring(response) - if root.Items.Request.IsValid == 'False': + if (hasattr(root.Items.Request, 'Errors') and + not hasattr(root.Items, 'Item')): code = root.Items.Request.Errors.Error.Code msg = root.Items.Request.Errors.Error.Message if code == 'AWS.ParameterOutOfRange': raise NoMorePages(msg) + elif code == 'HTTP Error 503': + raise RequestThrottled( + "Request Throttled Error: '{0}', '{1}'".format(code, msg)) else: raise SearchException( "Amazon Search Error: '{0}', '{1}'".format(code, msg)) + if hasattr(root.Items, 'TotalPages'): + if root.Items.TotalPages == self.current_page: + self.is_last_page = True return root -class AmazonBrowseNode(object): - - def __init__(self, element): - self.element = element - +class AmazonBrowseNode(LXMLWrapper): @property def id(self): """Browse Node ID. @@ -269,8 +600,8 @@ def id(self): :return: ID (integer) """ - if hasattr(self.element, 'BrowseNodeId'): - return int(self.element['BrowseNodeId']) + if hasattr(self.parsed_response, 'BrowseNodeId'): + return int(self.parsed_response['BrowseNodeId']) return None @property @@ -280,14 +611,14 @@ def name(self): :return: Name (string) """ - return getattr(self.element, 'Name', None) + return getattr(self.parsed_response, 'Name', None) @property def is_category_root(self): """Boolean value that specifies if the browse node is at the top of the browse node tree. """ - return getattr(self.element, 'IsCategoryRoot', False) + return getattr(self.parsed_response, 'IsCategoryRoot', False) @property def ancestor(self): @@ -296,7 +627,7 @@ def ancestor(self): :return: The ancestor as an :class:`~.AmazonBrowseNode`, or None. """ - ancestors = getattr(self.element, 'Ancestors', None) + ancestors = getattr(self.parsed_response, 'Ancestors', None) if hasattr(ancestors, 'BrowseNode'): return AmazonBrowseNode(ancestors['BrowseNode']) return None @@ -315,8 +646,21 @@ def ancestors(self): node = node.ancestor return ancestors + @property + def children(self): + """This browse node's children in the browse node tree. + + :return: + A list of this browse node's children in the browse node tree. + """ + children = [] + child_nodes = getattr(self.parsed_response, 'Children') + for child in getattr(child_nodes, 'BrowseNode', []): + children.append(AmazonBrowseNode(child)) + return children + -class AmazonProduct(object): +class AmazonProduct(LXMLWrapper): """A wrapper class for an Amazon product. """ @@ -326,83 +670,18 @@ def __init__(self, item, aws_associate_tag, api, *args, **kwargs): :param item: Lxml Item element. """ - self.item = item + super(AmazonProduct, self).__init__(item) self.aws_associate_tag = aws_associate_tag self.api = api self.parent = None - if 'region' in kwargs: - if kwargs['region'] != "US": - self.region = kwargs['region'] - else: - self.region = "com" - else: - self.region = "com" + self.region = kwargs.get('region', 'US') - def to_string(self): - """Convert Item XML to string. + def __str__(self): + """Return redable representation. - :return: - A string representation of the Item xml. + Uses the item's title. """ - return etree.tostring(self.item, pretty_print=True) - - def _safe_get_element(self, path, root=None): - """Safe Get Element. - - Get a child element of root (multiple levels deep) failing silently - if any descendant does not exist. - - :param root: - Lxml element. - :param path: - String path (i.e. 'Items.Item.Offers.Offer'). - :return: - Element or None. - """ - elements = path.split('.') - parent = root if root is not None else self.item - for element in elements[:-1]: - parent = getattr(parent, element, None) - if parent is None: - return None - return getattr(parent, elements[-1], None) - - def _safe_get_element_text(self, path, root=None): - """Safe get element text. - - Get element as string or None, - :param root: - Lxml element. - :param path: - String path (i.e. 'Items.Item.Offers.Offer'). - :return: - String or None. - """ - element = self._safe_get_element(path, root) - if element: - return element.text - else: - return None - - def _safe_get_element_date(self, path, root=None): - """Safe get elemnent date. - - Get element as datetime.date or None, - :param root: - Lxml element. - :param path: - String path (i.e. 'Items.Item.Offers.Offer'). - :return: - datetime.date or None. - """ - value = self._safe_get_element_text(path=path, root=root) - if value is not None: - try: - value = datetime.datetime.strptime(value, '%Y-%m-%d').date() - except ValueError: - value = None - - return value + return self.title @property def price_and_currency(self): @@ -418,7 +697,7 @@ def price_and_currency(self): :return: A tuple containing: - 1. Float representation of price. + 1. Decimal representation of price. 2. ISO Currency code (string). """ price = self._safe_get_element_text( @@ -438,10 +717,22 @@ def price_and_currency(self): currency = self._safe_get_element_text( 'OfferSummary.LowestNewPrice.CurrencyCode') if price: - return float(price) / 100, currency + dprice = Decimal( + price) / 100 if 'JP' not in self.region else Decimal(price) + return dprice, currency else: return None, None + @property + def offer_id(self): + """Offer ID + + :return: + Offer ID (string). + """ + return self._safe_get_element( + 'Offers.Offer.OfferListing.OfferListingId') + @property def asin(self): """ASIN (Amazon ID) @@ -451,6 +742,35 @@ def asin(self): """ return self._safe_get_element_text('ASIN') + @property + def sales_rank(self): + """Sales Rank + + :return: + Sales Rank (integer). + """ + return self._safe_get_element_text('SalesRank') + + @property + def super_saver_shipping(self): + """Super Saver Shipping + + :return: + Super Saver Shipping (boolean). + """ + return self._safe_get_element_text( + 'Offers.Offer.OfferListing.IsEligibleForSuperSaverShipping') + + @property + def prime(self): + """Prime + + :return: + Prime (boolean). + """ + return self._safe_get_element_text( + 'Offers.Offer.OfferListing.IsEligibleForPrime') + @property def offer_url(self): """Offer URL @@ -459,15 +779,15 @@ def offer_url(self): Offer URL (string). """ return "{0}{1}/?tag={2}".format( - AMAZON_ASSOCIATES_BASE_URL.format(region=self.region.lower()), + AMAZON_ASSOCIATES_BASE_URL.format(domain=DOMAINS[self.region]), self.asin, self.aws_associate_tag) @property def author(self): """Author. - Depricated, please use `authors`. + :return: Author (string). """ @@ -485,9 +805,34 @@ def authors(self): Returns of list of authors """ result = [] - authors = self._safe_get_element('ItemAttributes.Author') or [] - for author in authors: - result.append(author.text) + authors = self._safe_get_element('ItemAttributes.Author') + if authors is not None: + for author in authors: + result.append(author.text) + return result + + @property + def creators(self): + """Creators. + + Creators are not the authors. These are usually editors, translators, + narrators, etc. + + :return: + Returns a list of creators where each is a tuple containing: + + 1. The creators name (string). + 2. The creators role (string). + + """ + # return tuples of name and role + result = [] + creators = self._safe_get_element('ItemAttributes.Creator') + if creators is not None: + for creator in creators: + role = creator.attrib['Role'] if \ + 'Role' in creator.attrib else None + result.append((creator.text, role)) return result @property @@ -630,12 +975,13 @@ def reviews(self): """Customer Reviews. Get a iframe URL for customer reviews. + :return: A tuple of: has_reviews (bool), reviews url (string) """ iframe = self._safe_get_element_text('CustomerReviews.IFrameURL') has_reviews = self._safe_get_element_text('CustomerReviews.HasReviews') - if has_reviews and has_reviews == 'true': + if has_reviews is not None and has_reviews == 'true': has_reviews = True else: has_reviews = False @@ -671,6 +1017,15 @@ def upc(self): 'UPCListElement', root=upc_list[0]) return upc + @property + def color(self): + """Color. + + :return: + Color (string) + """ + return self._safe_get_element_text('ItemAttributes.Color') + @property def sku(self): """SKU. @@ -725,21 +1080,64 @@ def editorial_review(self): :return: Editorial Review (string) """ - return self._safe_get_element_text( - 'EditorialReviews.EditorialReview.Content') + reviews = self.editorial_reviews + if reviews: + return reviews[0] + return '' + + @property + def editorial_reviews(self): + """Editorial Review. + + Returns a list of all editorial reviews. + + :return: + A list containing: + + Editorial Review (string) + """ + result = [] + reviews_node = self._safe_get_element('EditorialReviews') + + if reviews_node is not None: + for review_node in reviews_node.iterchildren(): + content_node = getattr(review_node, 'Content') + if content_node is not None: + result.append(content_node.text) + return result + + @property + def languages(self): + """Languages. + + Returns a set of languages in lower-case. + + :return: + Returns a set of languages in lower-case (strings). + """ + result = set() + languages = self._safe_get_element('ItemAttributes.Languages') + if languages is not None: + for language in languages.iterchildren(): + text = self._safe_get_element_text('Name', language) + if text: + result.add(text.lower()) + return result @property def features(self): """Features. Returns a list of feature descriptions. + :return: Returns a list of 'ItemAttributes.Feature' elements (strings). """ result = [] - features = self._safe_get_element('ItemAttributes.Feature') or [] - for feature in features: - result.append(feature.text) + features = self._safe_get_element('ItemAttributes.Feature') + if features is not None: + for feature in features: + result.append(feature.text) return result @property @@ -749,14 +1147,16 @@ def list_price(self): :return: A tuple containing: - 1. Float representation of price. + 1. Decimal representation of price. 2. ISO Currency code (string). """ price = self._safe_get_element_text('ItemAttributes.ListPrice.Amount') currency = self._safe_get_element_text( 'ItemAttributes.ListPrice.CurrencyCode') if price: - return float(price) / 100, currency + dprice = Decimal( + price) / 100 if 'JP' not in self.region else Decimal(price) + return dprice, currency else: return None, None @@ -777,6 +1177,7 @@ def get_attribute_details(self, name): Gets XML attributes of the product attribute. These usually contain details about the product attributes such as units. + :param name: Attribute name (string) :return: @@ -806,6 +1207,7 @@ def parent_asin(self): """Parent ASIN. Can be used to test if product has a parent. + :return: Parent ASIN if product has a parent. """ @@ -816,6 +1218,7 @@ def get_parent(self): Fetch parent product if it exists. Use `parent_asin` to check if a parent exist before fetching. + :return: An instance of :class:`~.AmazonProduct` representing the parent product. @@ -838,3 +1241,285 @@ def browse_nodes(self): return [] return [AmazonBrowseNode(child) for child in root.iterchildren()] + + @property + def images(self): + """List of images for a response. + When using lookup with RespnoseGroup 'Images', you'll get a + list of images. Parse them so they are returned in an easily + used list format. + + :return: + A list of `ObjectifiedElement` images + """ + try: + images = [image for image in self._safe_get_element( + 'ImageSets.ImageSet')] + except TypeError: # No images in this ResponseGroup + images = [] + return images + + @property + def genre(self): + """Movie Genre. + + :return: + The genre of a movie. + """ + return self._safe_get_element_text('ItemAttributes.Genre') + + @property + def actors(self): + """Movie Actors. + + :return: + A list of actors names. + """ + result = [] + actors = self._safe_get_element('ItemAttributes.Actor') or [] + for actor in actors: + result.append(actor.text) + return result + + @property + def directors(self): + """Movie Directors. + + :return: + A list of directors for a movie. + """ + result = [] + directors = self._safe_get_element('ItemAttributes.Director') or [] + for director in directors: + result.append(director.text) + return result + + @property + def is_adult(self): + """IsAdultProduct. + + :return: + IsAdultProduct (string) + """ + return self._safe_get_element_text('ItemAttributes.IsAdultProduct') + + @property + def product_group(self): + """ProductGroup. + + :return: + ProductGroup (string) + """ + return self._safe_get_element_text('ItemAttributes.ProductGroup') + + @property + def product_type_name(self): + """ProductTypeName. + + :return: + ProductTypeName (string) + """ + return self._safe_get_element_text('ItemAttributes.ProductTypeName') + + @property + def formatted_price(self): + """FormattedPrice. + + :return: + FormattedPrice (string) + """ + return self._safe_get_element_text( + 'OfferSummary.LowestNewPrice.FormattedPrice') + + @property + def running_time(self): + """RunningTime. + + :return: + RunningTime (string) + """ + return self._safe_get_element_text('ItemAttributes.RunningTime') + + @property + def studio(self): + """Studio. + + :return: + Studio (string) + """ + return self._safe_get_element_text('ItemAttributes.Studio') + + @property + def is_preorder(self): + """IsPreorder (Is Preorder) + + :return: + IsPreorder (string). + """ + return self._safe_get_element_text( + 'Offers.Offer.OfferListing.AvailabilityAttributes.IsPreorder') + + @property + def availability(self): + """Availability + + :return: + Availability (string). + """ + return self._safe_get_element_text( + 'Offers.Offer.OfferListing.Availability') + + @property + def availability_type(self): + """AvailabilityAttributes.AvailabilityType + + :return: + AvailabilityType (string). + """ + return self._safe_get_element_text( + 'Offers.Offer.OfferListing.AvailabilityAttributes.AvailabilityType' + ) + + @property + def availability_min_hours(self): + """AvailabilityAttributes.MinimumHours + + :return: + MinimumHours (string). + """ + return self._safe_get_element_text( + 'Offers.Offer.OfferListing.AvailabilityAttributes.MinimumHours') + + @property + def availability_max_hours(self): + """AvailabilityAttributes.MaximumHours + + :return: + MaximumHours (string). + """ + return self._safe_get_element_text( + 'Offers.Offer.OfferListing.AvailabilityAttributes.MaximumHours') + + @property + def detail_page_url(self): + """DetailPageURL. + + :return: + DetailPageURL (string) + """ + return self._safe_get_element_text('DetailPageURL') + + @property + def number_sellers(self): + """Number of offers - New. + + :return: + Number of offers - New (string)\ + """ + return self._safe_get_element_text('OfferSummary.TotalNew') + + @property + def is_eligible_for_super_saver_shipping(self): + """IsEligibleForSuperSaverShipping + + :return: + IsEligibleForSuperSaverShipping (Bool). + """ + return self._safe_get_element_text('Offers.Offer.OfferListing.IsEligibleForSuperSaverShipping') + + @property + def is_eligible_for_prime(self): + """IsEligibleForPrime + + :return: + IsEligibleForPrime (Bool). + """ + return self._safe_get_element_text('Offers.Offer.OfferListing.IsEligibleForPrime') + + +class AmazonCart(LXMLWrapper): + """Wrapper around Amazon shopping cart. + Allows iterating over Items in the cart. + """ + + @property + def cart_id(self): + return self._safe_get_element_text('Cart.CartId') + + @property + def purchase_url(self): + return self._safe_get_element_text('Cart.PurchaseURL') + + @property + def amount(self): + return self._safe_get_element_text('Cart.SubTotal.Amount') + + @property + def formatted_price(self): + return self._safe_get_element_text('Cart.SubTotal.FormattedPrice') + + @property + def currency_code(self): + return self._safe_get_element_text('Cart.SubTotal.CurrencyCode') + + @property + def hmac(self): + return self._safe_get_element_text('Cart.HMAC') + + @property + def url_encoded_hmac(self): + return self._safe_get_element_text('Cart.URLEncodedHMAC') + + def __len__(self): + return len(self._safe_get_element('Cart.CartItems.CartItem')) + + def __iter__(self): + items = self._safe_get_element('Cart.CartItems.CartItem') + if items is not None: + for item in items: + yield AmazonCartItem(item) + + def __getitem__(self, cart_item_id): + """ + :param cart_item_id: access item by CartItemId + :return: AmazonCartItem + """ + for item in self: + if item.cart_item_id == cart_item_id: + return item + raise KeyError( + 'no item found with CartItemId: {0}'.format(cart_item_id,)) + + +class AmazonCartItem(LXMLWrapper): + @property + def asin(self): + return self._safe_get_element_text('ASIN') + + @property + def quantity(self): + return self._safe_get_element_text('Quantity') + + @property + def cart_item_id(self): + return self._safe_get_element_text('CartItemId') + + @property + def title(self): + return self._safe_get_element_text('Title') + + @property + def product_group(self): + return self._safe_get_element_text('ProductGroup') + + @property + def formatted_price(self): + return self._safe_get_element_text('Price.FormattedPrice') + + @property + def amount(self): + return self._safe_get_element_text('Price.Amount') + + @property + def currency_code(self): + return self._safe_get_element_text('Price.CurrencyCode') diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..bcc7fda --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,192 @@ +# Makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +PAPER = +BUILDDIR = _build + +# User-friendly check for sphinx-build +ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) +$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) +endif + +# Internal variables. +PAPEROPT_a4 = -D latex_paper_size=a4 +PAPEROPT_letter = -D latex_paper_size=letter +ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . +# the i18n builder cannot share the environment and doctrees with the others +I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . + +.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext + +help: + @echo "Please use \`make ' where is one of" + @echo " html to make standalone HTML files" + @echo " dirhtml to make HTML files named index.html in directories" + @echo " singlehtml to make a single large HTML file" + @echo " pickle to make pickle files" + @echo " json to make JSON files" + @echo " htmlhelp to make HTML files and a HTML help project" + @echo " qthelp to make HTML files and a qthelp project" + @echo " applehelp to make an Apple Help Book" + @echo " devhelp to make HTML files and a Devhelp project" + @echo " epub to make an epub" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " latexpdf to make LaTeX files and run them through pdflatex" + @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" + @echo " text to make text files" + @echo " man to make manual pages" + @echo " texinfo to make Texinfo files" + @echo " info to make Texinfo files and run them through makeinfo" + @echo " gettext to make PO message catalogs" + @echo " changes to make an overview of all changed/added/deprecated items" + @echo " xml to make Docutils-native XML files" + @echo " pseudoxml to make pseudoxml-XML files for display purposes" + @echo " linkcheck to check all external links for integrity" + @echo " doctest to run all doctests embedded in the documentation (if enabled)" + @echo " coverage to run coverage check of the documentation (if enabled)" + +clean: + rm -rf $(BUILDDIR)/* + +html: + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + +dirhtml: + $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." + +singlehtml: + $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml + @echo + @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." + +pickle: + $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle + @echo + @echo "Build finished; now you can process the pickle files." + +json: + $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json + @echo + @echo "Build finished; now you can process the JSON files." + +htmlhelp: + $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp + @echo + @echo "Build finished; now you can run HTML Help Workshop with the" \ + ".hhp project file in $(BUILDDIR)/htmlhelp." + +qthelp: + $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp + @echo + @echo "Build finished; now you can run "qcollectiongenerator" with the" \ + ".qhcp project file in $(BUILDDIR)/qthelp, like this:" + @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/PythonAmazonSimpleProductAPI.qhcp" + @echo "To view the help file:" + @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/PythonAmazonSimpleProductAPI.qhc" + +applehelp: + $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp + @echo + @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." + @echo "N.B. You won't be able to view it unless you put it in" \ + "~/Library/Documentation/Help or install it in your application" \ + "bundle." + +devhelp: + $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp + @echo + @echo "Build finished." + @echo "To view the help file:" + @echo "# mkdir -p $$HOME/.local/share/devhelp/PythonAmazonSimpleProductAPI" + @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/PythonAmazonSimpleProductAPI" + @echo "# devhelp" + +epub: + $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub + @echo + @echo "Build finished. The epub file is in $(BUILDDIR)/epub." + +latex: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo + @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." + @echo "Run \`make' in that directory to run these through (pdf)latex" \ + "(use \`make latexpdf' here to do that automatically)." + +latexpdf: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through pdflatex..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +latexpdfja: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through platex and dvipdfmx..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +text: + $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text + @echo + @echo "Build finished. The text files are in $(BUILDDIR)/text." + +man: + $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man + @echo + @echo "Build finished. The manual pages are in $(BUILDDIR)/man." + +texinfo: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo + @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." + @echo "Run \`make' in that directory to run these through makeinfo" \ + "(use \`make info' here to do that automatically)." + +info: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo "Running Texinfo files through makeinfo..." + make -C $(BUILDDIR)/texinfo info + @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." + +gettext: + $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale + @echo + @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." + +changes: + $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes + @echo + @echo "The overview file is in $(BUILDDIR)/changes." + +linkcheck: + $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck + @echo + @echo "Link check complete; look for any errors in the above output " \ + "or in $(BUILDDIR)/linkcheck/output.txt." + +doctest: + $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest + @echo "Testing of doctests in the sources finished, look at the " \ + "results in $(BUILDDIR)/doctest/output.txt." + +coverage: + $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage + @echo "Testing of coverage in the sources finished, look at the " \ + "results in $(BUILDDIR)/coverage/python.txt." + +xml: + $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml + @echo + @echo "Build finished. The XML files are in $(BUILDDIR)/xml." + +pseudoxml: + $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml + @echo + @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." diff --git a/docs/amazon.rst b/docs/amazon.rst new file mode 100644 index 0000000..a18616a --- /dev/null +++ b/docs/amazon.rst @@ -0,0 +1,5 @@ +Python Amazon Simple Product API +================================ + +.. automodule:: amazon.api + :members: diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..3871b7e --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,290 @@ +# -*- coding: utf-8 -*- +# +# Python Amazon Simple Product API documentation build configuration file, created by +# sphinx-quickstart on Fri Oct 16 13:21:48 2015. +# +# This file is execfile()d with the current directory set to its +# containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +import sys +import os +import shlex + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +#sys.path.insert(0, os.path.abspath('.')) +docs_root = os.path.dirname(__file__) +root_folder = os.path.split(docs_root)[0] +sys.path.insert(0, root_folder) + +# -- General configuration ------------------------------------------------ + +# If your documentation needs a minimal Sphinx version, state it here. +#needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.viewcode', +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# source_suffix = ['.rst', '.md'] +source_suffix = '.rst' + +# The encoding of source files. +#source_encoding = 'utf-8-sig' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = u'Python Amazon Simple Product API' +copyright = u'2015, Yoav Aviram' +author = u'Yoav Aviram' + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +version = '2.0.1' +# The full version, including alpha/beta/rc tags. +release = '2.0.1' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = None + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +#today = '' +# Else, today_fmt is used as the format for a strftime call. +#today_fmt = '%B %d, %Y' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +exclude_patterns = ['_build'] + +# The reST default role (used for this markup: `text`) to use for all +# documents. +#default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +#add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +#add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +#show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# A list of ignored prefixes for module index sorting. +#modindex_common_prefix = [] + +# If true, keep warnings as "system message" paragraphs in the built documents. +#keep_warnings = False + +# If true, `todo` and `todoList` produce output, else they produce nothing. +todo_include_todos = False + + +# -- Options for HTML output ---------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +html_theme = 'default' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +#html_theme_options = {} + +# Add any paths that contain custom themes here, relative to this directory. +#html_theme_path = [] + +# The name for this set of Sphinx documents. If None, it defaults to +# " v documentation". +#html_title = None + +# A shorter title for the navigation bar. Default is the same as html_title. +#html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +#html_logo = None + +# The name of an image file (within the static path) to use as favicon of the +# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +#html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# Add any extra paths that contain custom files (such as robots.txt or +# .htaccess) here, relative to this directory. These files are copied +# directly to the root of the documentation. +#html_extra_path = [] + +# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, +# using the given strftime format. +#html_last_updated_fmt = '%b %d, %Y' + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +#html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +#html_sidebars = {} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +#html_additional_pages = {} + +# If false, no module index is generated. +#html_domain_indices = True + +# If false, no index is generated. +#html_use_index = True + +# If true, the index is split into individual pages for each letter. +#html_split_index = False + +# If true, links to the reST sources are added to the pages. +#html_show_sourcelink = True + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +#html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +#html_show_copyright = True + +# If true, an OpenSearch description file will be output, and all pages will +# contain a tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +#html_use_opensearch = '' + +# This is the file name suffix for HTML files (e.g. ".xhtml"). +#html_file_suffix = None + +# Language to be used for generating the HTML full-text search index. +# Sphinx supports the following languages: +# 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' +# 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr' +#html_search_language = 'en' + +# A dictionary with options for the search language support, empty by default. +# Now only 'ja' uses this config value +#html_search_options = {'type': 'default'} + +# The name of a javascript file (relative to the configuration directory) that +# implements a search results scorer. If empty, the default will be used. +#html_search_scorer = 'scorer.js' + +# Output file base name for HTML help builder. +htmlhelp_basename = 'PythonAmazonSimpleProductAPIdoc' + +# -- Options for LaTeX output --------------------------------------------- + +latex_elements = { +# The paper size ('letterpaper' or 'a4paper'). +#'papersize': 'letterpaper', + +# The font size ('10pt', '11pt' or '12pt'). +#'pointsize': '10pt', + +# Additional stuff for the LaTeX preamble. +#'preamble': '', + +# Latex figure (float) alignment +#'figure_align': 'htbp', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + (master_doc, 'PythonAmazonSimpleProductAPI.tex', u'Python Amazon Simple Product API Documentation', + u'Yoav Aviram', 'manual'), +] + +# The name of an image file (relative to this directory) to place at the top of +# the title page. +#latex_logo = None + +# For "manual" documents, if this is true, then toplevel headings are parts, +# not chapters. +#latex_use_parts = False + +# If true, show page references after internal links. +#latex_show_pagerefs = False + +# If true, show URL addresses after external links. +#latex_show_urls = False + +# Documents to append as an appendix to all manuals. +#latex_appendices = [] + +# If false, no module index is generated. +#latex_domain_indices = True + + +# -- Options for manual page output --------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + (master_doc, 'pythonamazonsimpleproductapi', u'Python Amazon Simple Product API Documentation', + [author], 1) +] + +# If true, show URL addresses after external links. +#man_show_urls = False + + +# -- Options for Texinfo output ------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + (master_doc, 'PythonAmazonSimpleProductAPI', u'Python Amazon Simple Product API Documentation', + author, 'PythonAmazonSimpleProductAPI', 'One line description of project.', + 'Miscellaneous'), +] + +# Documents to append as an appendix to all manuals. +#texinfo_appendices = [] + +# If false, no module index is generated. +#texinfo_domain_indices = True + +# How to display URL addresses: 'footnote', 'no', or 'inline'. +#texinfo_show_urls = 'footnote' + +# If true, do not generate a @detailmenu in the "Top" node's menu. +#texinfo_no_detailmenu = False diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..cc12971 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,23 @@ +.. Python Amazon Simple Product API documentation master file, created by + sphinx-quickstart on Fri Oct 16 13:21:48 2015. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Welcome to Python Amazon Simple Product API's documentation! +============================================================ + +Contents: + +.. toctree:: + :maxdepth: 2 + + amazon.rst + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` + diff --git a/requirements.txt b/requirements.txt index ee08ee9..b390d76 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ lxml bottlenose +python-dateutil \ No newline at end of file diff --git a/setup.py b/setup.py index 3b093b2..1b77dcd 100644 --- a/setup.py +++ b/setup.py @@ -1,12 +1,18 @@ +import amazon + from setuptools import setup, find_packages -version = '1.4.0' +try: + long_description=open('READMxE.md', 'r').read() +except IOError: + long_description="" setup(name='python-amazon-simple-product-api', - version=version, + version=amazon.__version__, description="A simple Python wrapper for the Amazon.com Product Advertising API", + long_description=long_description, # http://pypi.python.org/pypi?:action=list_classifiers classifiers=[ "Development Status :: 5 - Production/Stable", @@ -15,18 +21,22 @@ "Natural Language :: English", "Operating System :: OS Independent", "Programming Language :: Python", + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3.3", + "Programming Language :: Python :: 3.4", + "Programming Language :: Python :: 3.5", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Internet :: WWW/HTTP :: Dynamic Content :: CGI Tools/Libraries", "Topic :: Utilities", - "License :: OSI Approved :: BSD License", + "License :: OSI Approved :: Apache Software License", ], keywords='amazon, product advertising, api', author='Yoav Aviram', - author_email='support@cleverblocks.com', + author_email='yoav.aviram@gmail.com', url='https://github.com/yoavaviram/python-amazon-simple-product-api', - license='BSD', + license='Apache 2.0', packages=find_packages(exclude=['ez_setup', 'examples', 'tests']), include_package_data=True, zip_safe=True, - install_requires=["bottlenose", "lxml"], + install_requires=["bottlenose", "lxml", "python-dateutil"], ) diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 0000000..eb0ee24 --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,6 @@ +nose +Sphinx==1.1.3 +coverage +coveralls +flaky +pep8 \ No newline at end of file diff --git a/tests.py b/tests.py index fe5c2d5..d8b7c69 100644 --- a/tests.py +++ b/tests.py @@ -1,12 +1,41 @@ -from unittest import TestCase +#!/usr/bin/env python +# -*- coding: utf-8 -*- -from nose.tools import assert_equals, assert_true +import unittest -from amazon.api import AmazonAPI -from test_settings import (AMAZON_ACCESS_KEY, - AMAZON_SECRET_KEY, - AMAZON_ASSOC_TAG) +from nose.tools import assert_equals, assert_true, assert_false, assert_raises +from flaky import flaky +import time +import datetime +from decimal import Decimal +from amazon.api import (AmazonAPI, + CartException, + CartInfoMismatchException, + SearchException, + AmazonSearch, + AsinNotFound) + +_AMAZON_ACCESS_KEY = None +_AMAZON_SECRET_KEY = None +_AMAZON_ASSOC_TAG = None + + +import os +if 'AMAZON_ACCESS_KEY' in os.environ and 'AMAZON_SECRET_KEY' in os.environ and 'AMAZON_ASSOC_TAG' in os.environ: + _AMAZON_ACCESS_KEY = os.environ['AMAZON_ACCESS_KEY'] + _AMAZON_SECRET_KEY = os.environ['AMAZON_SECRET_KEY'] + _AMAZON_ASSOC_TAG = os.environ['AMAZON_ASSOC_TAG'] +else: + from test_settings import (AMAZON_ACCESS_KEY, + AMAZON_SECRET_KEY, + AMAZON_ASSOC_TAG) + _AMAZON_ACCESS_KEY = AMAZON_ACCESS_KEY + _AMAZON_SECRET_KEY = AMAZON_SECRET_KEY + _AMAZON_ASSOC_TAG = AMAZON_ASSOC_TAG + + +TEST_ASIN = "0312098286" PRODUCT_ATTRIBUTES = [ 'asin', 'author', 'binding', 'brand', 'browse_nodes', 'ean', 'edition', @@ -18,12 +47,42 @@ 'title', 'upc' ] +CART_ATTRIBUTES = [ + 'cart_id', 'purchase_url', 'amount', 'formatted_price', 'currency_code', + 'url_encoded_hmac', 'hmac' +] + +CART_ITEM_ATTRIBUTES = [ + 'cart_item_id', 'asin', 'title', 'amount', 'formatted_price', + 'currency_code', 'quantity', 'product_group', +] + +CACHE = {} + + +def cache_writer(url, response): + CACHE[url] = response + + +def cache_reader(url): + return CACHE.get(url, None) -class TestAmazonApi(TestCase): + +def cache_clear(): + global CACHE + CACHE = {} + +def delay_rerun(err, *args): + time.sleep(5) + return True + + +class TestAmazonApi(unittest.TestCase): """Test Amazon API Test Class for Amazon simple API wrapper. """ + def setUp(self): """Set Up. @@ -36,47 +95,88 @@ def setUp(self): Are imported from a custom file named: 'test_settings.py' """ self.amazon = AmazonAPI( - AMAZON_ACCESS_KEY, AMAZON_SECRET_KEY, AMAZON_ASSOC_TAG) + _AMAZON_ACCESS_KEY, + _AMAZON_SECRET_KEY, + _AMAZON_ASSOC_TAG, + CacheReader=cache_reader, + CacheWriter=cache_writer, + MaxQPS=0.5 + ) + @flaky(max_runs=3, rerun_filter=delay_rerun) def test_lookup(self): """Test Product Lookup. Tests that a product lookup for a kindle returns results and that the main methods are working. """ - product = self.amazon.lookup(ItemId="B0051QVF7A") - assert_equals( - product.title, - 'Kindle, Wi-Fi, 6" E Ink Display - for international shipment' - ) - assert_equals(product.ean, '0814916014354') + product = self.amazon.lookup(ItemId="B00ZV9PXP2") + assert_true('Kindle' in product.title) + assert_equals(product.ean, '0848719083774') assert_equals( product.large_image_url, - 'http://ecx.images-amazon.com/images/I/411H%2B731ZzL.jpg' + 'https://images-na.ssl-images-amazon.com/images/I/51hrdzXLUHL.jpg' ) assert_equals( product.get_attribute('Publisher'), - 'Amazon Digital Services, Inc' + 'Amazon' ) assert_equals(product.get_attributes( ['ItemDimensions.Width', 'ItemDimensions.Height']), - {'ItemDimensions.Width': '450', 'ItemDimensions.Height': '34'}) + {'ItemDimensions.Width': '450', 'ItemDimensions.Height': '36'}) assert_true(len(product.browse_nodes) > 0) + assert_true(product.price_and_currency[0] is not None) + assert_true(product.price_and_currency[1] is not None) assert_equals(product.browse_nodes[0].id, 2642129011) assert_equals(product.browse_nodes[0].name, 'eBook Readers') - def test_batch_lookup(self): - """Test Batch Product Lookup. + @flaky(max_runs=3, rerun_filter=delay_rerun) + def test_lookup_nonexistent_asin(self): + """Test Product Lookup with a nonexistent ASIN. + + Tests that a product lookup for a nonexistent ASIN raises AsinNotFound. + """ + assert_raises(AsinNotFound, self.amazon.lookup, ItemId="ABCD1234") + + @flaky(max_runs=3, rerun_filter=delay_rerun) + def test_bulk_lookup(self): + """Test Baulk Product Lookup. - Tests that a batch product lookup request returns multiple results. + Tests that a bulk product lookup request returns multiple results. """ - asins = ['B0051QVESA', 'B005DOK8NW', 'B005890G8Y', - 'B0051VVOB2', 'B005890G8O'] + asins = [TEST_ASIN, 'B00BWYQ9YE', + 'B00BWYRF7E', 'B00D2KJDXA'] products = self.amazon.lookup(ItemId=','.join(asins)) - assert_equals(len(products), 5) + assert_equals(len(products), len(asins)) + for i, product in enumerate(products): + assert_equals(asins[i], product.asin) + + @flaky(max_runs=3, rerun_filter=delay_rerun) + def test_lookup_bulk(self): + """Test Bulk Product Lookup. + + Tests that a bulk product lookup request returns multiple results. + """ + asins = [TEST_ASIN, 'B00BWYQ9YE', + 'B00BWYRF7E', 'B00D2KJDXA'] + products = self.amazon.lookup_bulk(ItemId=','.join(asins)) + assert_equals(len(products), len(asins)) for i, product in enumerate(products): assert_equals(asins[i], product.asin) + @flaky(max_runs=3, rerun_filter=delay_rerun) + def test_lookup_bulk_empty(self): + """Test Bulk Product Lookup With No Results. + + Tests that a bulk product lookup request with no results + returns an empty list. + """ + asins = ['not-an-asin', 'als-not-an-asin'] + products = self.amazon.lookup_bulk(ItemId=','.join(asins)) + assert_equals(type(products), list) + assert_equals(len(products), 0) + + @flaky(max_runs=3, rerun_filter=delay_rerun) def test_search(self): """Test Product Search. @@ -91,6 +191,7 @@ def test_search(self): else: assert_true(False, 'No search results returned.') + @flaky(max_runs=3, rerun_filter=delay_rerun) def test_search_n(self): """Test Product Search N. @@ -104,15 +205,37 @@ def test_search_n(self): ) assert_equals(len(products), 1) + @flaky(max_runs=3, rerun_filter=delay_rerun) + def test_search_iterate_pages(self): + products = self.amazon.search(Keywords='internet of things oreilly', + SearchIndex='Books') + assert_false(products.is_last_page) + for product in products: + pass + assert_true(products.is_last_page) + + + @flaky(max_runs=3, rerun_filter=delay_rerun) + def test_search_no_results(self): + """Test Product Search with no results. + + Tests that a product search with that returns no results throws a + SearchException. + """ + products = self.amazon.search(Title='no-such-thing-on-amazon', + SearchIndex='Automotive') + assert_raises(SearchException, next, (x for x in products)) + def test_amazon_api_defaults_to_US(self): """Test Amazon API defaults to the US store.""" amazon = AmazonAPI( - AMAZON_ACCESS_KEY, - AMAZON_SECRET_KEY, - AMAZON_ASSOC_TAG + _AMAZON_ACCESS_KEY, + _AMAZON_SECRET_KEY, + _AMAZON_ASSOC_TAG ) assert_equals(amazon.api.Region, "US") + @flaky(max_runs=3, rerun_filter=delay_rerun) def test_search_amazon_uk(self): """Test Poduct Search on Amazon UK. @@ -121,9 +244,9 @@ def test_search_amazon_uk(self): results were returned. """ amazon = AmazonAPI( - AMAZON_ACCESS_KEY, - AMAZON_SECRET_KEY, - AMAZON_ASSOC_TAG, + _AMAZON_ACCESS_KEY, + _AMAZON_SECRET_KEY, + _AMAZON_ASSOC_TAG, region="UK" ) assert_equals(amazon.api.Region, "UK", "Region has not been set to UK") @@ -135,23 +258,26 @@ def test_search_amazon_uk(self): is_gbp = 'GBP' in currencies assert_true(is_gbp, "Currency is not GBP, cannot be Amazon UK, though") + @flaky(max_runs=3, rerun_filter=delay_rerun) def test_similarity_lookup(self): """Test Similarity Lookup. Tests that a similarity lookup for a kindle returns 10 results. """ - products = self.amazon.similarity_lookup(ItemId="B0051QVF7A") - assert_equals(len(products), 10) + products = self.amazon.similarity_lookup(ItemId=TEST_ASIN) + assert_true(len(products) > 5) + @flaky(max_runs=3, rerun_filter=delay_rerun) def test_product_attributes(self): """Test Product Attributes. Tests that all product that are supposed to be accessible are. """ - product = self.amazon.lookup(ItemId="B0051QVF7A") + product = self.amazon.lookup(ItemId=TEST_ASIN) for attribute in PRODUCT_ATTRIBUTES: getattr(product, attribute) + @flaky(max_runs=3, rerun_filter=delay_rerun) def test_browse_node_lookup(self): """Test Browse Node Lookup. @@ -162,3 +288,322 @@ def test_browse_node_lookup(self): assert_equals(bn.id, bnid) assert_equals(bn.name, 'eBook Readers') assert_equals(bn.is_category_root, False) + + @flaky(max_runs=3, rerun_filter=delay_rerun) + def test_obscure_date(self): + """Test Obscure Date Formats + + Test a product with an obscure date format + """ + product = self.amazon.lookup(ItemId="0933635869") + assert_equals(product.publication_date.year, 1992) + assert_equals(product.publication_date.month, 5) + assert_true(isinstance(product.publication_date, datetime.date)) + + @flaky(max_runs=3, rerun_filter=delay_rerun) + def test_single_creator(self): + """Test a product with a single creator + """ + product = self.amazon.lookup(ItemId="B00005NZJA") + creators = dict(product.creators) + assert_equals(creators[u"Jonathan Davis"], u"Narrator") + assert_equals(len(creators.values()), 2) + + @flaky(max_runs=3, rerun_filter=delay_rerun) + def test_multiple_creators(self): + """Test a product with multiple creators + """ + product = self.amazon.lookup(ItemId="B007V8RQC4") + creators = dict(product.creators) + assert_equals(creators[u"John Gregory Betancourt"], u"Editor") + assert_equals(creators[u"Colin Azariah-Kribbs"], u"Editor") + assert_equals(len(creators.values()), 2) + + @flaky(max_runs=3, rerun_filter=delay_rerun) + def test_no_creators(self): + """Test a product with no creators + """ + product = self.amazon.lookup(ItemId="8420658537") + assert_false(product.creators) + + @flaky(max_runs=3, rerun_filter=delay_rerun) + def test_single_editorial_review(self): + product = self.amazon.lookup(ItemId="1930846258") + expected = u'In the title piece, Alan Turing' + assert_equals(product.editorial_reviews[0][:len(expected)], expected) + assert_equals(product.editorial_review, product.editorial_reviews[0]) + assert_equals(len(product.editorial_reviews), 1) + + @flaky(max_runs=3, rerun_filter=delay_rerun) + def test_multiple_editorial_reviews(self): + product = self.amazon.lookup(ItemId="B01HQA6EOC") + expected = u'

Introducing an instant classic—master storyteller' + assert_equals(product.editorial_reviews[0][:len(expected)], expected) + expected = u'An Amazon Best Book of February 2017:' + assert_equals(product.editorial_reviews[1][:len(expected)], expected) + # duplicate data, amazon user data is great... + expected = u'

Introducing an instant classic—master storyteller' + assert_equals(product.editorial_reviews[2][:len(expected)], expected) + + assert_equals(len(product.editorial_reviews), 3) + + @flaky(max_runs=3, rerun_filter=delay_rerun) + def test_languages_english(self): + """Test Language Data + + Test an English product + """ + product = self.amazon.lookup(ItemId="1930846258") + assert_true('english' in product.languages) + assert_equals(len(product.languages), 1) + + @flaky(max_runs=3, rerun_filter=delay_rerun) + def test_languages_spanish(self): + """Test Language Data + + Test an English product + """ + product = self.amazon.lookup(ItemId="8420658537") + assert_true('spanish' in product.languages) + assert_equals(len(product.languages), 1) + + def test_region(self): + amazon = AmazonAPI(_AMAZON_ACCESS_KEY, _AMAZON_SECRET_KEY, + _AMAZON_ASSOC_TAG) + assert_equals(amazon.region, 'US') + + # old 'region' parameter + amazon = AmazonAPI(_AMAZON_ACCESS_KEY, _AMAZON_SECRET_KEY, + _AMAZON_ASSOC_TAG, region='UK') + assert_equals(amazon.region, 'UK') + + # kwargs method + amazon = AmazonAPI(_AMAZON_ACCESS_KEY, _AMAZON_SECRET_KEY, + _AMAZON_ASSOC_TAG, Region='UK') + assert_equals(amazon.region, 'UK') + + @flaky(max_runs=3, rerun_filter=delay_rerun) + def test_is_adult(self): + product = self.amazon.lookup(ItemId="B01E7P9LEE") + assert_true(product.is_adult is not None) + + @flaky(max_runs=3, rerun_filter=delay_rerun) + def test_product_group(self): + product = self.amazon.lookup(ItemId="B01LXM0S25") + assert_equals(product.product_group, 'DVD') + + product = self.amazon.lookup(ItemId="B01NBTSVDN") + assert_equals(product.product_group, 'Digital Music Album') + + @flaky(max_runs=3, rerun_filter=delay_rerun) + def test_product_type_name(self): + product = self.amazon.lookup(ItemId="B01NBTSVDN") + assert_equals(product.product_type_name, 'DOWNLOADABLE_MUSIC_ALBUM') + + @flaky(max_runs=3, rerun_filter=delay_rerun) + def test_formatted_price(self): + product = self.amazon.lookup(ItemId="B01NBTSVDN") + assert_equals(product.formatted_price, '$12.49') + + @flaky(max_runs=3, rerun_filter=delay_rerun) + def test_price_and_currency(self): + product = self.amazon.lookup(ItemId="B01NBTSVDN") + price, currency = product.price_and_currency + assert_equals(price, Decimal('12.49')) + assert_equals(currency, 'USD') + + @flaky(max_runs=3, rerun_filter=delay_rerun) + def test_list_price(self): + product = self.amazon.lookup(ItemId="B01NBTSVDN") + price, currency = product.list_price + assert_equals(price, Decimal('12.49')) + assert_equals(currency, 'USD') + + @flaky(max_runs=3, rerun_filter=delay_rerun) + def test_running_time(self): + product = self.amazon.lookup(ItemId="B01NBTSVDN") + assert_equals(product.running_time, '3567') + + @flaky(max_runs=3, rerun_filter=delay_rerun) + def test_studio(self): + product = self.amazon.lookup(ItemId="B01NBTSVDN") + assert_equals(product.studio, 'Atlantic Records UK') + + @flaky(max_runs=3, rerun_filter=delay_rerun) + def test_is_preorder(self): + product = self.amazon.lookup(ItemId="B01NBTSVDN") + assert_equals(product.is_preorder , None) + + @flaky(max_runs=3, rerun_filter=delay_rerun) + def test_detail_page_url(self): + product = self.amazon.lookup(ItemId="B01NBTSVDN") + assert_true(product.detail_page_url.startswith('https://www.amazon.com/%C3%B7-Deluxe-Ed-Sheeran/dp/B01NBTSVDN')) + + @flaky(max_runs=3, rerun_filter=delay_rerun) + def test_availability(self): + product = self.amazon.lookup(ItemId="B00ZV9PXP2") + assert_equals(product.availability, 'Usually ships in 24 hours') + + product = self.amazon.lookup(ItemId="1491914254") # pre-order book + assert_equals(product.availability, 'Not yet published') + + @flaky(max_runs=3, rerun_filter=delay_rerun) + def test_availability_type(self): + product = self.amazon.lookup(ItemId="B00ZV9PXP2") + assert_equals(product.availability_type, 'now') + + product = self.amazon.lookup(ItemId="1491914254") # pre-order book + assert_equals(product.availability_type, 'now') + + product = self.amazon.lookup(ItemId="B00ZV9PXP2") # late availability + assert_equals(product.availability_type, 'now') + + @flaky(max_runs=3, rerun_filter=delay_rerun) + def test_availability_min_max_hours(self): + product = self.amazon.lookup(ItemId="B00ZV9PXP2") + assert_equals(product.availability_min_hours, '0') + assert_equals(product.availability_max_hours, '0') + + + def test_kwargs(self): + amazon = AmazonAPI(_AMAZON_ACCESS_KEY, _AMAZON_SECRET_KEY, + _AMAZON_ASSOC_TAG, MaxQPS=0.7) + + @flaky(max_runs=3, rerun_filter=delay_rerun) + def test_images(self): + """Test images property + + Test that the images property has a value when using the + Images ResponseGroup + """ + product = self.amazon.lookup(ResponseGroup='Images', + ItemId='B00TSVVNQC') + assert_equals(type(product.images), list) + assert_equals(len(product.images), 7) + + +class TestAmazonCart(unittest.TestCase): + def setUp(self): + self.amazon = AmazonAPI( + _AMAZON_ACCESS_KEY, + _AMAZON_SECRET_KEY, + _AMAZON_ASSOC_TAG, + CacheReader=cache_reader, + CacheWriter=cache_writer, + MaxQPS=0.5 + ) + + def test_cart_clear_required_params(self): + assert_raises(CartException, self.amazon.cart_clear, None, None) + assert_raises(CartException, self.amazon.cart_clear, 'NotNone', + None) + assert_raises(CartException, self.amazon.cart_clear, None, + 'NotNone') + + def build_cart_object(self): + product = self.amazon.lookup(ItemId="B00ZV9PXP2") + return self.amazon.cart_create( + { + 'offer_id': product.offer_id, + 'quantity': 1 + } + ) + + def test_cart_create_single_item(self): + cart = self.build_cart_object() + assert_equals(len(cart), 1) + + def test_cart_create_multiple_item(self): + product1 = self.amazon.lookup(ItemId="B00ZV9PXP2") + product2 = self.amazon.lookup(ItemId=TEST_ASIN) + asins = [product1.asin, product2.asin] + + cart = self.amazon.cart_create([ + { + 'offer_id': product1._safe_get_element( + 'Offers.Offer.OfferListing.OfferListingId'), + 'quantity': 1 + }, + { + 'offer_id': product2._safe_get_element( + 'Offers.Offer.OfferListing.OfferListingId'), + 'quantity': 1 + }, + ]) + assert_equals(len(cart), 2) + for item in cart: + assert_true(item.asin in asins) + + def test_cart_clear(self): + cart = self.build_cart_object() + new_cart = self.amazon.cart_clear(cart.cart_id, cart.hmac) + assert_true(new_cart._safe_get_element('Cart.Request.IsValid')) + + def test_cart_clear_wrong_hmac(self): + cart = self.build_cart_object() + # never use urlencoded hmac, as library encodes as well. Just in case + # hmac = url_encoded_hmac we add some noise + hmac = cart.url_encoded_hmac + '%3d' + assert_raises(CartInfoMismatchException, self.amazon.cart_clear, + cart.cart_id, hmac) + + def test_cart_attributes(self): + cart = self.build_cart_object() + for attribute in CART_ATTRIBUTES: + getattr(cart, attribute) + + def test_cart_item_attributes(self): + cart = self.build_cart_object() + for item in cart: + for attribute in CART_ITEM_ATTRIBUTES: + getattr(item, attribute) + + def test_cart_get(self): + # We need to flush the cache here so we will get a new cart that has + # not been used in test_cart_clear + cache_clear() + cart = self.build_cart_object() + fetched_cart = self.amazon.cart_get(cart.cart_id, cart.hmac) + + assert_equals(fetched_cart.cart_id, cart.cart_id) + assert_equals(len(fetched_cart), len(cart)) + + def test_cart_get_wrong_hmac(self): + # We need to flush the cache here so we will get a new cart that has + # not been used in test_cart_clear + cache_clear() + cart = self.build_cart_object() + assert_raises(CartInfoMismatchException, self.amazon.cart_get, + cart.cart_id, cart.hmac + '%3d') + + def test_cart_add(self): + cart = self.build_cart_object() + product = self.amazon.lookup(ItemId=TEST_ASIN) + item = { + 'offer_id': product._safe_get_element( + 'Offers.Offer.OfferListing.OfferListingId'), + 'quantity': 1 + } + new_cart = self.amazon.cart_add(item, cart.cart_id, cart.hmac) + assert_true(len(new_cart) > len(cart)) + + def test_cart_modify(self): + cart = self.build_cart_object() + cart_item_id = None + for item in cart: + cart_item_id = item.cart_item_id + item = {'cart_item_id': cart_item_id, 'quantity': 3} + new_cart = self.amazon.cart_modify(item, cart.cart_id, cart.hmac) + assert_equals(new_cart[cart_item_id].quantity, '3') + + def test_cart_delete(self): + cart = self.build_cart_object() + cart_item_id = None + for item in cart: + cart_item_id = item.cart_item_id + item = {'cart_item_id': cart_item_id, 'quantity': 0} + new_cart = self.amazon.cart_modify(item, cart.cart_id, cart.hmac) + assert_raises(KeyError, new_cart.__getitem__, cart_item_id) + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..6e39443 --- /dev/null +++ b/tox.ini @@ -0,0 +1,5 @@ +[tox] +envlist = py27, py33, py34, py35 +[testenv] +deps = nose +commands = nosetests