diff --git a/.agent/rules/code-style-guide.md b/.agent/rules/code-style-guide.md new file mode 100644 index 0000000..76ddac1 --- /dev/null +++ b/.agent/rules/code-style-guide.md @@ -0,0 +1,27 @@ +--- +trigger: always_on +--- + +# General + +- All code in English +- Add comments only when needed +- Add docstrings for every function +- Use type hints in all functions +- Use f-strings +- Follow PEP 8 and clean code principles +- Imports always at the top +- Avoid short variable names, abbreviations, or single-letter names +- Avoid the use of noqa unless strictly necessary + +# Test + +- Add tests following TDD practices +- Mirror the amazon_paapi structure in the tests directory +- Use unittest.TestCase with setUp() and tearDown() +- Use unittest assertions, not native assert +- Use @patch decorators for mocks (avoid context managers) + +# References + +- Documentation for the Product Advertising API here: https://webservices.amazon.com/paapi5/documentation/operations.html diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index b70b6ec..0000000 --- a/.coveragerc +++ /dev/null @@ -1,14 +0,0 @@ -[run] -branch = True -source = amazon_paapi -relative_files = True -omit = **/sdk/** - -[report] -fail_under = 80 -skip_covered = true -skip_empty = true - -[html] -directory = coverage_html_report -skip_covered = false diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..79882c3 --- /dev/null +++ b/.env.template @@ -0,0 +1,4 @@ +API_KEY= +API_SECRET= +AFFILIATE_TAG= +COUNTRY_CODE= diff --git a/.flake8 b/.flake8 deleted file mode 100644 index 597ed9e..0000000 --- a/.flake8 +++ /dev/null @@ -1,4 +0,0 @@ -[flake8] -max-line-length = 88 -extend-ignore = W503,E203,F401 -exclude = */sdk/* diff --git a/.githooks/pre-push b/.githooks/pre-push deleted file mode 100755 index df00d64..0000000 --- a/.githooks/pre-push +++ /dev/null @@ -1,12 +0,0 @@ -#! /bin/bash -e - -ROOT_DIR="$(git rev-parse --show-toplevel)" -source "${ROOT_DIR}/scripts/helpers" - -./scripts/check_isort -./scripts/check_black -./scripts/check_flake8 -./scripts/check_pylint -./scripts/run_tests - -header "Proceeding with push" diff --git a/.github/ISSUE_TEMPLATE/---bug-report.md b/.github/ISSUE_TEMPLATE/---bug-report.md index 5f08e08..a0fa1fc 100644 --- a/.github/ISSUE_TEMPLATE/---bug-report.md +++ b/.github/ISSUE_TEMPLATE/---bug-report.md @@ -8,9 +8,9 @@ assignees: '' --- **Steps to reproduce** -1. -2. -3. +1. +2. +3. **Code example** ```python diff --git a/.github/ISSUE_TEMPLATE/---feature-request.md b/.github/ISSUE_TEMPLATE/---feature-request.md index f74fa80..924da4f 100644 --- a/.github/ISSUE_TEMPLATE/---feature-request.md +++ b/.github/ISSUE_TEMPLATE/---feature-request.md @@ -8,7 +8,7 @@ assignees: '' --- **Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. +A clear and concise description of what the problem is. **Describe the solution you'd like** A clear and concise description of what you want to happen. diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..93d6e44 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,94 @@ +name: Run linters and tests + +on: + push: + branches: + - master + pull_request: + +permissions: + pull-requests: read + +jobs: + changelog: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Check CHANGELOG was updated + run: | + if git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -q "^CHANGELOG.md$"; then + echo "✅ CHANGELOG.md was updated" + else + echo "❌ ERROR: CHANGELOG.md was not updated" + echo "Please add your changes to the CHANGELOG.md file" + exit 1 + fi + + check: + runs-on: ubuntu-latest + env: + API_KEY: ${{ secrets.API_KEY }} + API_SECRET: ${{ secrets.API_SECRET }} + AFFILIATE_TAG: ${{ secrets.AFFILIATE_TAG }} + COUNTRY_CODE: ${{ secrets.COUNTRY_CODE }} + + steps: + - uses: actions/checkout@v5 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.13" + + - name: Install UV + run: curl -LsSf https://astral.sh/uv/install.sh | sh + + - name: Cache UV dependencies + uses: actions/cache@v4 + with: + path: ~/.cache/uv + key: ${{ runner.os }}-uv-${{ hashFiles('pyproject.toml') }} + restore-keys: | + ${{ runner.os }}-uv- + + - name: Cache pre-commit + uses: actions/cache@v4 + with: + path: ~/.cache/pre-commit + key: ${{ runner.os }}-pre-commit-${{ hashFiles('.pre-commit-config.yaml') }} + restore-keys: | + ${{ runner.os }}-pre-commit- + + - name: Run all checks + run: | + uv run pre-commit run --all-files + + test: + runs-on: ubuntu-latest + timeout-minutes: 2 + + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14", "3.15"] + + steps: + - uses: actions/checkout@v5 + + - name: Install UV + run: curl -LsSf https://astral.sh/uv/install.sh | sh + + - name: Cache UV dependencies + uses: actions/cache@v4 + with: + path: ~/.cache/uv + key: ${{ runner.os }}-uv-py${{ matrix.python-version }}-${{ hashFiles('pyproject.toml') }} + restore-keys: | + ${{ runner.os }}-uv-py${{ matrix.python-version }}- + + - name: Run tests + run: uv run --python "${{ matrix.python-version }}" pytest -rs --no-cov diff --git a/.github/workflows/lint-and-test.yml b/.github/workflows/lint-and-test.yml deleted file mode 100644 index 4a32a57..0000000 --- a/.github/workflows/lint-and-test.yml +++ /dev/null @@ -1,94 +0,0 @@ -name: Lint and test - -on: - push: - pull_request: - types: [opened, reopened] - -permissions: - pull-requests: read - -jobs: - - isort: - runs-on: ubuntu-latest - container: - image: python:3.12 - steps: - - name: Check out code - uses: actions/checkout@v4 - - name: Install dependencies - run: pip install isort - - name: Check imports order - run: isort -c . - - black: - runs-on: ubuntu-latest - container: - image: python:3.12 - steps: - - name: Check out code - uses: actions/checkout@v4 - - name: Install dependencies - run: pip install black - - name: Check code format - run: black --check --diff --color . - - flake8: - runs-on: ubuntu-latest - container: - image: python:3.12 - steps: - - name: Check out code - uses: actions/checkout@v4 - - name: Install dependencies - run: pip install flake8 - - name: Check code errors - run: flake8 . - - pylint: - runs-on: ubuntu-latest - container: - image: python:3.12 - steps: - - name: Check out code - uses: actions/checkout@v4 - - name: Install dependencies - run: pip install pylint - - name: Check code errors - run: find . -type f -name '*.py' | xargs pylint --disable=missing-docstring --disable=too-few-public-methods - - test: - runs-on: ubuntu-latest - container: - image: python:3.12 - steps: - - name: Check out code - uses: actions/checkout@v4 - - name: Install dependencies - run: pip install coverage certifi six python_dateutil setuptools urllib3 - - name: Run tests - run: coverage run -m unittest && coverage xml && coverage report - - name: Save code coverage file - uses: actions/upload-artifact@v4 - with: - name: coverage - path: coverage.xml - - sonar: - runs-on: ubuntu-latest - needs: [test] - steps: - - name: Check out code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Download a single artifact - uses: actions/download-artifact@v4 - with: - name: coverage - - name: Check code errors - uses: SonarSource/sonarcloud-github-action@master - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..76ac267 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,30 @@ +name: Upload Python Package + +on: + release: + types: [created] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.x" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build twine + - name: Update version number + run: | + sed -i 's/version = ".*"/version = "${{ github.ref_name }}"/' pyproject.toml + - name: Build and publish + env: + TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} + TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + run: | + python -m build + twine upload dist/* diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml deleted file mode 100644 index a71080a..0000000 --- a/.github/workflows/python-publish.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Upload Python Package - -on: - release: - types: [created] - -jobs: - - deploy: - runs-on: ubuntu-latest - steps: - - name: Check out code - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.x' - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install setuptools wheel twine - - name: Update version number - run: | - sed -i 's/version="5"/version="${{ github.ref_name }}"/' setup.py - - name: Build and publish - env: - TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} - TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} - run: | - python setup.py sdist bdist_wheel - twine upload dist/* diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..924831e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,58 @@ +name: Create Release + +on: + workflow_dispatch: + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/master' + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Extract version and notes from CHANGELOG + id: changelog + run: | + # Extract version from first ## [x.x.x] header + VERSION=$(grep -m1 -oP '## \[\K[0-9]+\.[0-9]+\.[0-9]+' CHANGELOG.md) + echo "version=$VERSION" >> $GITHUB_OUTPUT + + # Extract release notes (content between first and second ## headers) + NOTES=$(awk '/^## \['"$VERSION"'\]/{flag=1; next} /^## \[/{flag=0} flag' CHANGELOG.md) + + # Handle multiline output + { + echo "notes<> $GITHUB_OUTPUT + + - name: Check if tag already exists + id: check_tag + run: | + if git rev-parse "v${{ steps.changelog.outputs.version }}" >/dev/null 2>&1; then + echo "exists=true" >> $GITHUB_OUTPUT + else + echo "exists=false" >> $GITHUB_OUTPUT + fi + + - name: Create Release + if: steps.check_tag.outputs.exists == 'false' + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.changelog.outputs.version }} + name: v${{ steps.changelog.outputs.version }} + body: ${{ steps.changelog.outputs.notes }} + draft: false + prerelease: false + + - name: Skip release (tag exists) + if: steps.check_tag.outputs.exists == 'true' + run: | + echo "⚠️ Tag v${{ steps.changelog.outputs.version }} already exists. Skipping release creation." + exit 1 diff --git a/.gitignore b/.gitignore index 388bc29..41404dd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,8 @@ # Custom folders and files to ignore .vscode/ .DS_Store -secrets.py -test.py coverage_html_report - # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] @@ -32,6 +29,7 @@ wheels/ .installed.cfg *.egg MANIFEST +uv.lock # PyInstaller # Usually these files are written by a python script from a template diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..c90fd04 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,61 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +default_language_version: + python: python3.13 + +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.14.11 + hooks: + - id: ruff-format + - id: ruff-check + args: [--fix, --exit-non-zero-on-fix] + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: mixed-line-ending + - id: check-yaml + - id: check-added-large-files + - id: check-ast + - id: check-executables-have-shebangs + - id: check-shebang-scripts-are-executable + - id: check-toml + - id: check-merge-conflict + - id: debug-statements + - id: name-tests-test + + - repo: https://github.com/lk16/detect-missing-init + rev: v0.1.6 + hooks: + - id: detect-missing-init + args: ["--create", "--python-folders", "amazon_paapi"] + + - repo: https://github.com/gitleaks/gitleaks + rev: v8.30.0 + hooks: + - id: gitleaks + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: "v1.19.1" + hooks: + - id: mypy + exclude: sdk/ + + - repo: local + hooks: + - id: test + name: Running tests + language: system + entry: "bash -c 'set -a && source .env 2>/dev/null; set +a && uv run pytest -rs --cov=amazon_paapi'" + types_or: [python] + pass_filenames: false + + - id: version-check + name: Check version consistency + language: system + entry: uv run python scripts/check_version.py + files: (CHANGELOG\.md|pyproject\.toml|docs/conf\.py)$ + pass_filenames: false diff --git a/.readthedocs.yml b/.readthedocs.yml index 2092e44..fc7b933 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -1,22 +1,21 @@ -# .readthedocs.yaml # Read the Docs configuration file # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details -# Required version: 2 -# Build documentation in the docs/ directory with Sphinx +build: + os: ubuntu-24.04 + tools: + python: "3.12" + sphinx: - configuration: docs/conf.py + configuration: docs/conf.py -# Optionally build your docs in additional formats such as PDF formats: - - pdf + - pdf -# Optionally set the version of Python and requirements required to build your docs python: - version: 3 - install: - - method: pip - path: . - - requirements: docs/requirements.txt + install: + - method: pip + path: . + - requirements: docs/requirements.txt diff --git a/.shellcheckrc b/.shellcheckrc deleted file mode 100644 index 1c2cbb5..0000000 --- a/.shellcheckrc +++ /dev/null @@ -1,3 +0,0 @@ -# ~/.shellcheckrc - -disable=SC1091 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7c1a84f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,41 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [5.1.0] - 2026-01-11 + +### Added + +- Integration tests with real Amazon API calls +- Type hints throughout the codebase using `Literal` types for country codes +- `.env.template` file for easier development setup +- Code style guide for AI assistants (`.agent/rules/code-style-guide.md`) +- Pre-commit hooks with Ruff integration +- Version consistency check script (`scripts/check_version.py`) +- Manual release workflow (`release.yml`) that creates GitHub releases from CHANGELOG +- CI check to ensure CHANGELOG is updated in every PR + +### Changed + +- **BREAKING**: Minimum Python version raised from 3.7 to 3.9 +- Migrated from `setup.py` to `pyproject.toml` for project configuration +- Replaced multiple linters (Flake8, isort, Black, Pylint) with Ruff +- Replaced Docker-based development environment with `uv` package manager +- Consolidated coverage, mypy, and pytest configuration into `pyproject.toml` +- Renamed test files to use `_test.py` suffix instead of `test_` prefix +- Updated GitHub Actions workflows to use `uv` instead of Docker +- Improved docstrings across the codebase +- Completely rewritten README with clearer structure and examples +- Updated Read the Docs configuration to v2 format with modern Sphinx versions + +### Removed + +- `setup.py` - replaced by `pyproject.toml` +- `.coveragerc` - configuration moved to `pyproject.toml` +- `.flake8` - replaced by Ruff configuration in `pyproject.toml` +- Docker development environment (`docker/`, `docker-compose.yml`) +- Legacy shell scripts (`scripts/` directory) +- Custom git hooks (`.githooks/`) - replaced by pre-commit diff --git a/LICENSE b/LICENSE index 67111a9..b965aee 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2020 Sergio Abad +Copyright (c) 2026 Sergio Abad Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..749593d --- /dev/null +++ b/Makefile @@ -0,0 +1,29 @@ +export UID:=$(shell id -u) +export GID:=$(shell id -g) + +export PYTHON_TAGS = 3.9 3.10 3.11 3.12 3.13 3.14 3.15 + +setup: + @uv run pre-commit install + +test: + @touch .env + @uv run --env-file .env pytest -rs + +coverage: + @uv run pytest -rs --cov=amazon_paapi --cov-report=html --cov-report=term --cov-report=xml + +test-all-python-tags: + @touch .env + @for tag in $$PYTHON_TAGS; do \ + uv run --env-file .env --python "$$tag" pytest -rs --no-cov; \ + done + +lint: + @uv run ruff check --fix . + +mypy: + @uv run mypy . + +pre-commit: + @uv run pre-commit run -a diff --git a/README.md b/README.md index b7055e4..3d89915 100644 --- a/README.md +++ b/README.md @@ -1,120 +1,129 @@ -# Amazon Product Advertising API 5.0 wrapper for Python +# Python Amazon PAAPI -A simple Python wrapper for the [last version of the Amazon Product Advertising -API](https://webservices.amazon.com/paapi5/documentation/quick-start/using-sdk.html). -This module allows interacting with Amazon using the official API in an easier way. +A simple Python wrapper for the [Amazon Product Advertising API 5.0](https://webservices.amazon.com/paapi5/documentation/). Easily interact with Amazon's official API to retrieve product information, search for items, and more. [![PyPI](https://img.shields.io/pypi/v/python-amazon-paapi?color=%231182C2&label=PyPI)](https://pypi.org/project/python-amazon-paapi/) -[![Python](https://img.shields.io/badge/Python->3.6-%23FFD140)](https://www.python.org/) +[![Python](https://img.shields.io/badge/Python-≥3.9-%23FFD140)](https://www.python.org/) [![License](https://img.shields.io/badge/License-MIT-%23e83633)](https://github.com/sergioteula/python-amazon-paapi/blob/master/LICENSE) [![Amazon API](https://img.shields.io/badge/Amazon%20API-5.0-%23FD9B15)](https://webservices.amazon.com/paapi5/documentation/) -[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=sergioteula_python-amazon-paapi&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=sergioteula_python-amazon-paapi) -[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=sergioteula_python-amazon-paapi&metric=coverage)](https://sonarcloud.io/summary/new_code?id=sergioteula_python-amazon-paapi) -[![PyPI - Downloads](https://img.shields.io/pypi/dm/python-amazon-paapi?label=Installs)](https://pypi.org/project/python-amazon-paapi/) +[![Downloads](https://img.shields.io/pypi/dm/python-amazon-paapi?label=Downloads)](https://pypi.org/project/python-amazon-paapi/) ## Features -- Object oriented interface for simple usage. -- Get information about a product through its ASIN or URL. -- Get item variations or search for products on Amazon. -- Get browse nodes information. -- Get multiple results at once without the 10 items limitation from Amazon. -- Configurable throttling to avoid requests exceptions. -- Type hints to help you coding. -- Support for [all available countries](https://github.com/sergioteula/python-amazon-paapi/blob/956f639b2ab3eab3f61644ae2ca8ae6500881312/amazon_paapi/models/regions.py#L1). -- Ask for new features through the [issues](https://github.com/sergioteula/python-amazon-paapi/issues) section. -- Join our [Telegram group](https://t.me/PythonAmazonPAAPI) for support or development. -- Check the [documentation](https://python-amazon-paapi.readthedocs.io/en/latest/index.html) for reference. +- 🎯 **Simple object-oriented interface** for easy integration +- 🔍 **Product search** by keywords, categories, or browse nodes +- 📦 **Product details** via ASIN or Amazon URL +- 🔄 **Item variations** support (size, color, etc.) +- 🌍 **20+ countries** supported ([full list](https://github.com/sergioteula/python-amazon-paapi/blob/master/amazon_paapi/models/regions.py)) +- ⚡ **Batch requests** to get multiple items without the 10-item limit +- 🛡️ **Built-in throttling** to avoid API rate limits +- 📝 **Full type hints** for better IDE support ## Installation -You can install or upgrade the module with: - - pip install python-amazon-paapi --upgrade - -## Usage guide +```bash +pip install python-amazon-paapi --upgrade +``` -**Basic usage:** +## Quick Start ```python from amazon_paapi import AmazonApi + +# Initialize the API (get credentials from Amazon Associates) amazon = AmazonApi(KEY, SECRET, TAG, COUNTRY) + +# Get product information by ASIN item = amazon.get_items('B01N5IB20Q')[0] -print(item.item_info.title.display_value) # Item title +print(item.item_info.title.display_value) ``` -**Get multiple items information:** +## Usage Examples + +### Get Multiple Products ```python items = amazon.get_items(['B01N5IB20Q', 'B01F9G43WU']) for item in items: - print(item.images.primary.large.url) # Primary image url - print(item.offers.listings[0].price.amount) # Current price + print(item.images.primary.large.url) + print(item.offers.listings[0].price.amount) ``` -**Use URL insted of ASIN:** +### Use Amazon URL Instead of ASIN ```python item = amazon.get_items('https://www.amazon.com/dp/B01N5IB20Q') ``` -**Get item variations:** +### Search Products ```python -variations = amazon.get_variations('B01N5IB20Q') -for item in variations.items: - print(item.detail_page_url) # Affiliate url +results = amazon.search_items(keywords='nintendo switch') +for item in results.items: + print(item.item_info.title.display_value) ``` -**Search items:** +### Get Product Variations ```python -search_result = amazon.search_items(keywords='nintendo') -for item in search_result.items: - print(item.item_info.product_info.color) # Item color +variations = amazon.get_variations('B01N5IB20Q') +for item in variations.items: + print(item.detail_page_url) ``` -**Get browse node information:** +### Get Browse Node Information ```python -browse_nodes = amazon.get_browse_nodes(['667049031', '599385031']) -for browse_node in browse_nodes: - print(browse_node.display_name) # The name of the node +nodes = amazon.get_browse_nodes(['667049031', '599385031']) +for node in nodes: + print(node.display_name) ``` -**Get the ASIN from URL:** +### Extract ASIN from URL ```python from amazon_paapi import get_asin + asin = get_asin('https://www.amazon.com/dp/B01N5IB20Q') +# Returns: 'B01N5IB20Q' ``` -**Throttling:** +### Configure Throttling -Throttling value represents the wait time in seconds between API calls, being the -default value 1 second. Use it to avoid reaching Amazon request limits. +Control the wait time between API calls to avoid rate limits: ```python -amazon = AmazonApi(KEY, SECRET, TAG, COUNTRY, throttling=4) # Makes 1 request every 4 seconds -amazon = AmazonApi(KEY, SECRET, TAG, COUNTRY, throttling=0) # No wait time between requests +# Wait 4 seconds between requests +amazon = AmazonApi(KEY, SECRET, TAG, COUNTRY, throttling=4) + +# No throttling (use with caution) +amazon = AmazonApi(KEY, SECRET, TAG, COUNTRY, throttling=0) ``` -## Contribution +## Documentation -Creating pull requests for this repo is higly appreciated to add new features or solve -bugs. To help during development process, githooks can be activated to run some scripts -before pushing new commits. This will run checks for code format and tests, to ensure -everything follows this repo guidelines. Use next command to activate them: +- 📖 [Full Documentation](https://python-amazon-paapi.readthedocs.io/) +- 📋 [Changelog](https://github.com/sergioteula/python-amazon-paapi/blob/master/CHANGELOG.md) +- 💬 [Telegram Support Group](https://t.me/PythonAmazonPAAPI) +## Contributing + +Contributions are welcome! To get started: + +1. Install [uv](https://docs.astral.sh/uv/) package manager +2. Clone and set up the project: + +```bash +git clone https://github.com/sergioteula/python-amazon-paapi.git +cd python-amazon-paapi +uv sync +uv run pre-commit install ``` -git config core.hooksPath .githooks -``` -The same checks will also run on the repo with GitHub Actions to ensure all tests pass -before merge. +3. Copy `.env.template` to `.env` and add your Amazon API credentials for integration tests. + +Pre-commit hooks will automatically run Ruff, mypy, and tests before each commit. ## License -Copyright © 2021 Sergio Abad. See -[license](https://github.com/sergioteula/python-amazon-paapi/blob/master/LICENSE) for -details. +MIT License © 2026 [Sergio Abad](https://github.com/sergioteula) diff --git a/amazon_paapi/__init__.py b/amazon_paapi/__init__.py index 61887fa..939cd8d 100644 --- a/amazon_paapi/__init__.py +++ b/amazon_paapi/__init__.py @@ -1,6 +1,7 @@ -"""Amazon Product Advertising API wrapper for Python""" +"""Amazon Product Advertising API wrapper for Python.""" __author__ = "Sergio Abad" +__all__ = ["AmazonApi", "get_asin"] from .api import AmazonApi from .tools import get_asin diff --git a/amazon_paapi/api.py b/amazon_paapi/api.py index a6e54d1..9dee50a 100644 --- a/amazon_paapi/api.py +++ b/amazon_paapi/api.py @@ -1,10 +1,12 @@ -"""Amazon Product Advertising API wrapper for Python +"""Amazon Product Advertising API wrapper for Python. A simple Python wrapper for the last version of the Amazon Product Advertising API. """ +from __future__ import annotations + import time -from typing import List, Union +from typing import TYPE_CHECKING, Any from . import models from .errors import InvalidArgument @@ -13,6 +15,9 @@ from .helpers.items import sort_items from .sdk.api.default_api import DefaultApi +if TYPE_CHECKING: + from .models.regions import CountryCode + class AmazonApi: """Provides methods to get information from Amazon using your API credentials. @@ -21,12 +26,14 @@ class AmazonApi: key (``str``): Your API key. secret (``str``): Your API secret. tag (``str``): Your affiliate tracking id, used to create the affiliate link. - country (``models.Country``): Country code for your affiliate account. + country (``CountryCode``): Country code for your affiliate account. + Use values from ``models.Country``, e.g. ``Country.ES``. throttling (``float``, optional): Wait time in seconds between API calls. Use it to avoid reaching Amazon limits. Defaults to 1 second. Raises: ``InvalidArgumentException`` + """ def __init__( @@ -34,10 +41,10 @@ def __init__( key: str, secret: str, tag: str, - country: models.Country, + country: CountryCode, throttling: float = 1, - **kwargs - ): + ) -> None: + """Initialize the Amazon API client with the provided credentials.""" self._key = key self._secret = secret self._last_query_time = time.time() - throttling @@ -50,20 +57,21 @@ def __init__( self.region = models.regions.REGIONS[country] self.marketplace = "www.amazon." + models.regions.DOMAINS[country] except KeyError as error: - raise InvalidArgument("Country code is not correct") from error + msg = "Country code is not correct" + raise InvalidArgument(msg) from error self.api = DefaultApi(key, secret, self._host, self.region) def get_items( self, - items: Union[str, List[str]], + items: str | list[str], condition: models.Condition = None, merchant: models.Merchant = None, - currency_of_preference: str = None, - languages_of_preference: List[str] = None, + currency_of_preference: str | None = None, + languages_of_preference: list[str] | None = None, include_unavailable: bool = False, - **kwargs - ) -> List[models.Item]: + **kwargs: Any, + ) -> list[models.Item]: """Get items information from Amazon. Args: @@ -91,8 +99,8 @@ def get_items( ``MalformedRequestException`` ``ApiRequestException`` ``ItemsNotFoundException`` - """ + """ kwargs.update( { "condition": condition, @@ -111,36 +119,38 @@ def get_items( items_response = requests.get_items_response(self, request) results.extend(items_response) - return sort_items(results, items_ids, include_unavailable) + return sort_items(results, items_ids, include_unavailable=include_unavailable) def search_items( self, # NOSONAR - item_count: int = None, - item_page: int = None, - actor: str = None, - artist: str = None, - author: str = None, - brand: str = None, - keywords: str = None, - title: str = None, + item_count: int | None = None, + item_page: int | None = None, + actor: str | None = None, + artist: str | None = None, + author: str | None = None, + brand: str | None = None, + keywords: str | None = None, + title: str | None = None, availability: models.Availability = None, - browse_node_id: str = None, + browse_node_id: str | None = None, condition: models.Condition = None, - currency_of_preference: str = None, - delivery_flags: List[str] = None, - languages_of_preference: List[str] = None, + currency_of_preference: str | None = None, + delivery_flags: list[str] | None = None, + languages_of_preference: list[str] | None = None, merchant: models.Merchant = None, - max_price: int = None, - min_price: int = None, - min_saving_percent: int = None, - min_reviews_rating: int = None, - search_index: str = None, + max_price: int | None = None, + min_price: int | None = None, + min_saving_percent: int | None = None, + min_reviews_rating: int | None = None, + search_index: str | None = None, sort_by: models.SortBy = None, - **kwargs + **kwargs: Any, ) -> models.SearchResult: - """Searches for items on Amazon based on a search query. At least one of the - following parameters should be specified: ``keywords``, ``actor``, ``artist``, - ``author``, ``brand``, ``title``, ``browse_node_id`` or ``search_index``. + """Search for items on Amazon based on a search query. + + At least one of the following parameters should be specified: ``keywords``, + ``actor``, ``artist``, ``author``, ``brand``, ``title``, ``browse_node_id`` + or ``search_index``. Args: item_count (``int``, optional): Number of items returned. Should be between @@ -195,8 +205,8 @@ def search_items( ``MalformedRequestException`` ``ApiRequestException`` ``ItemsNotFoundException`` - """ + """ kwargs.update( { "item_count": item_count, @@ -231,16 +241,18 @@ def search_items( def get_variations( self, asin: str, - variation_count: int = None, - variation_page: int = None, + variation_count: int | None = None, + variation_page: int | None = None, condition: models.Condition = None, - currency_of_preference: str = None, - languages_of_preference: List[str] = None, + currency_of_preference: str | None = None, + languages_of_preference: list[str] | None = None, merchant: models.Merchant = None, - **kwargs + **kwargs: Any, ) -> models.VariationsResult: - """Returns a set of items that are the same product, but differ according to a - consistent theme, for example size and color. A variation is a child ASIN. + """Return a set of items that are the same product but differ by theme. + + Items can differ by size, color, or other variation attributes. + A variation is a child ASIN. Args: asin (``str``): One item, using ASIN or product URL. @@ -268,8 +280,8 @@ def get_variations( ``MalformedRequestException`` ``ApiRequestException`` ``ItemsNotFoundException`` - """ + """ asin = arguments.get_items_ids(asin)[0] kwargs.update( @@ -291,12 +303,13 @@ def get_variations( def get_browse_nodes( self, - browse_node_ids: List[str], - languages_of_preference: List[str] = None, - **kwargs - ) -> List[models.BrowseNode]: - """Returns the specified browse node's information like name, children and - ancestors. + browse_node_ids: list[str], + languages_of_preference: list[str] | None = None, + **kwargs: Any, + ) -> list[models.BrowseNode]: + """Return the specified browse node's information. + + Information includes name, children, and ancestors. Args: browse_node_ids (``list[str]``): List of browse node ids. A browse node id @@ -314,8 +327,8 @@ def get_browse_nodes( ``MalformedRequestException`` ``ApiRequestException`` ``ItemsNotFoundException`` - """ + """ kwargs.update( { "browse_node_ids": browse_node_ids, @@ -328,7 +341,8 @@ def get_browse_nodes( self._throttle() return requests.get_browse_nodes_response(self, request) - def _throttle(self): + def _throttle(self) -> None: + """Wait for the throttling interval to elapse since the last API call.""" wait_time = self.throttling - (time.time() - self._last_query_time) if wait_time > 0: time.sleep(wait_time) diff --git a/amazon_paapi/errors/__init__.py b/amazon_paapi/errors/__init__.py index 28b58bd..46d33de 100644 --- a/amazon_paapi/errors/__init__.py +++ b/amazon_paapi/errors/__init__.py @@ -1,3 +1,5 @@ +"""Custom exceptions for the Amazon Product Advertising API.""" + from .exceptions import ( AmazonError, AsinNotFound, diff --git a/amazon_paapi/errors/exceptions.py b/amazon_paapi/errors/exceptions.py index 707d4d3..35e2100 100644 --- a/amazon_paapi/errors/exceptions.py +++ b/amazon_paapi/errors/exceptions.py @@ -1,14 +1,16 @@ -"""Custom exceptions module""" +"""Custom exceptions module.""" class AmazonError(Exception): """Common base class for all Amazon API exceptions.""" - def __init__(self, reason: str): + def __init__(self, reason: str) -> None: + """Initialize the exception with a reason message.""" super().__init__() self.reason = reason def __str__(self) -> str: + """Return the string representation of the exception.""" return self.reason diff --git a/amazon_paapi/helpers/__init__.py b/amazon_paapi/helpers/__init__.py index e69de29..c5bc5b8 100644 --- a/amazon_paapi/helpers/__init__.py +++ b/amazon_paapi/helpers/__init__.py @@ -0,0 +1 @@ +"""Helper modules for Amazon PAAPI requests and responses.""" diff --git a/amazon_paapi/helpers/arguments.py b/amazon_paapi/helpers/arguments.py index 29ef67a..ba8ae18 100644 --- a/amazon_paapi/helpers/arguments.py +++ b/amazon_paapi/helpers/arguments.py @@ -1,34 +1,47 @@ """Module with helper functions for managing arguments.""" +from __future__ import annotations -from typing import List, Union +from typing import Any -from ..errors import InvalidArgument -from ..tools import get_asin +from amazon_paapi.errors import InvalidArgument +from amazon_paapi.tools import get_asin +MAX_PAGINATION_VALUE = 10 -def get_items_ids(items: Union[str, List[str]]) -> List[str]: - if not isinstance(items, str) and not isinstance(items, List): - raise InvalidArgument( - "Invalid items argument, it should be a string or List of strings" - ) +def get_items_ids(items: str | list[str]) -> list[str]: + """Parse and extract ASINs from items input. + + Args: + items: Either a comma-separated string of ASINs/URLs or a list of ASINs/URLs. + + Returns: + A list of extracted ASINs. + + Raises: + InvalidArgument: If items is not a string or list. + + """ if isinstance(items, str): items_ids = items.split(",") - items_ids = [get_asin(x.strip()) for x in items_ids] + return [get_asin(x.strip()) for x in items_ids] - else: - items_ids = [get_asin(x.strip()) for x in items] + if isinstance(items, list): + return [get_asin(x.strip()) for x in items] - return items_ids + msg = "Invalid items argument, it should be a string or List of strings" # type: ignore[unreachable] + raise InvalidArgument(msg) -def check_search_args(**kwargs): +def check_search_args(**kwargs: Any) -> None: + """Validate all search arguments.""" check_search_mandatory_args(**kwargs) check_search_pagination_args(**kwargs) -def check_search_mandatory_args(**kwargs): +def check_search_mandatory_args(**kwargs: Any) -> None: + """Validate that at least one mandatory search argument is provided.""" mandatory_args = [ kwargs.get("keywords"), kwargs.get("actor"), @@ -47,33 +60,34 @@ def check_search_mandatory_args(**kwargs): raise InvalidArgument(error_message) -def check_search_pagination_args(**kwargs): +def check_search_pagination_args(**kwargs: Any) -> None: + """Validate pagination arguments for search requests.""" error_message = "Args item_count and item_page should be integers between 1 and 10." pagination_args = [kwargs.get("item_count"), kwargs.get("item_page")] - pagination_args = [arg for arg in pagination_args if arg] - if not all(isinstance(arg, int) for arg in pagination_args): - raise InvalidArgument(error_message) - - if not all(1 <= arg <= 10 for arg in pagination_args): - raise InvalidArgument(error_message) + for arg in pagination_args: + if arg is not None and ( + not isinstance(arg, int) or not 1 <= arg <= MAX_PAGINATION_VALUE + ): + raise InvalidArgument(error_message) -def check_variations_args(**kwargs): +def check_variations_args(**kwargs: Any) -> None: + """Validate variation arguments for get_variations requests.""" error_message = ( "Args variation_count and variation_page should be integers between 1 and 10." ) variation_args = [kwargs.get("variation_count"), kwargs.get("variation_page")] - variation_args = [arg for arg in variation_args if arg] - if not all(isinstance(arg, int) for arg in variation_args): - raise InvalidArgument(error_message) - - if not all(1 <= arg <= 10 for arg in variation_args): - raise InvalidArgument(error_message) + for arg in variation_args: + if arg is not None and ( + not isinstance(arg, int) or not 1 <= arg <= MAX_PAGINATION_VALUE + ): + raise InvalidArgument(error_message) -def check_browse_nodes_args(**kwargs): - if not isinstance(kwargs.get("browse_node_ids"), List): +def check_browse_nodes_args(**kwargs: Any) -> None: + """Validate browse node arguments.""" + if not isinstance(kwargs.get("browse_node_ids"), list): error_message = "Argument browse_node_ids should be a List of strings." raise InvalidArgument(error_message) diff --git a/amazon_paapi/helpers/generators.py b/amazon_paapi/helpers/generators.py index f0d6537..fd6a9e1 100644 --- a/amazon_paapi/helpers/generators.py +++ b/amazon_paapi/helpers/generators.py @@ -1,12 +1,16 @@ """Module with helper functions for making generators.""" +from __future__ import annotations -from typing import Generator, List +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Generator def get_list_chunks( - full_list: List[str], chunk_size: int -) -> Generator[List[str], None, None]: + full_list: list[str], chunk_size: int +) -> Generator[list[str], None, None]: """Yield successive chunks from List.""" for i in range(0, len(full_list), chunk_size): yield full_list[i : i + chunk_size] diff --git a/amazon_paapi/helpers/items.py b/amazon_paapi/helpers/items.py index 40b139f..e0b3831 100644 --- a/amazon_paapi/helpers/items.py +++ b/amazon_paapi/helpers/items.py @@ -1,17 +1,18 @@ -"""Module to manage items""" +"""Module to manage items.""" -from typing import List +from __future__ import annotations -from .. import models +from amazon_paapi import models def sort_items( - items: List[models.Item], items_ids: List[str], include_unavailable: bool -) -> List[models.Item]: - sorted_items = [] + items: list[models.Item], items_ids: list[str], *, include_unavailable: bool +) -> list[models.Item]: + """Sort items by the order of the provided items_ids list.""" + sorted_items: list[models.Item] = [] for asin in items_ids: - matches = list(filter(lambda item, asin=asin: item.asin == asin, items)) + matches: list[models.Item] = [item for item in items if item.asin == asin] if matches: sorted_items.append(matches[0]) elif include_unavailable: diff --git a/amazon_paapi/helpers/requests.py b/amazon_paapi/helpers/requests.py index fa6880a..7ce47cf 100644 --- a/amazon_paapi/helpers/requests.py +++ b/amazon_paapi/helpers/requests.py @@ -1,10 +1,11 @@ """Module with helper functions for creating requests.""" +from __future__ import annotations import inspect -from typing import List +from typing import TYPE_CHECKING, Any, NoReturn, cast -from ..errors import ( +from amazon_paapi.errors import ( AssociateValidationError, InvalidArgument, ItemsNotFound, @@ -12,23 +13,34 @@ RequestError, TooManyRequests, ) -from ..models.browse_nodes_result import BrowseNode -from ..models.item_result import Item -from ..models.search_result import SearchResult -from ..models.variations_result import VariationsResult -from ..sdk.models.get_browse_nodes_request import GetBrowseNodesRequest -from ..sdk.models.get_browse_nodes_resource import GetBrowseNodesResource -from ..sdk.models.get_items_request import GetItemsRequest -from ..sdk.models.get_items_resource import GetItemsResource -from ..sdk.models.get_variations_request import GetVariationsRequest -from ..sdk.models.get_variations_resource import GetVariationsResource -from ..sdk.models.partner_type import PartnerType -from ..sdk.models.search_items_request import SearchItemsRequest -from ..sdk.models.search_items_resource import SearchItemsResource -from ..sdk.rest import ApiException - - -def get_items_request(amazon_api, asin_chunk: List[str], **kwargs) -> GetItemsRequest: +from amazon_paapi.sdk.models.get_browse_nodes_request import GetBrowseNodesRequest +from amazon_paapi.sdk.models.get_browse_nodes_resource import GetBrowseNodesResource +from amazon_paapi.sdk.models.get_items_request import GetItemsRequest +from amazon_paapi.sdk.models.get_items_resource import GetItemsResource +from amazon_paapi.sdk.models.get_variations_request import GetVariationsRequest +from amazon_paapi.sdk.models.get_variations_resource import GetVariationsResource +from amazon_paapi.sdk.models.partner_type import PartnerType +from amazon_paapi.sdk.models.search_items_request import SearchItemsRequest +from amazon_paapi.sdk.models.search_items_resource import SearchItemsResource +from amazon_paapi.sdk.rest import ApiException +from amazon_paapi.sdk.rest import ApiException as ApiExceptionType + +HTTP_TOO_MANY_REQUESTS = 429 + +if TYPE_CHECKING: + from amazon_paapi.api import AmazonApi + from amazon_paapi.models.browse_nodes_result import BrowseNode + from amazon_paapi.models.item_result import Item + from amazon_paapi.models.search_result import SearchResult + from amazon_paapi.models.variations_result import VariationsResult + + +def get_items_request( + amazon_api: AmazonApi, + asin_chunk: list[str], + **kwargs: Any, +) -> GetItemsRequest: + """Create a GetItemsRequest for the Amazon API.""" try: return GetItemsRequest( resources=_get_request_resources(GetItemsResource), @@ -39,24 +51,29 @@ def get_items_request(amazon_api, asin_chunk: List[str], **kwargs) -> GetItemsRe **kwargs, ) except TypeError as exc: - raise MalformedRequest( - f"Parameters for get_items request are not correct: {exc}" - ) from exc + msg = f"Parameters for get_items request are not correct: {exc}" + raise MalformedRequest(msg) from exc -def get_items_response(amazon_api, request: GetItemsRequest) -> List[Item]: +def get_items_response(amazon_api: AmazonApi, request: GetItemsRequest) -> list[Item]: + """Execute a GetItemsRequest and return the list of items.""" try: response = amazon_api.api.get_items(request) except ApiException as exc: _manage_response_exceptions(exc) if response.items_result is None: - raise ItemsNotFound("No items have been found") + msg = "No items have been found" + raise ItemsNotFound(msg) - return response.items_result.items + return cast("list[Item]", response.items_result.items) -def get_search_items_request(amazon_api, **kwargs) -> SearchItemsRequest: +def get_search_items_request( + amazon_api: AmazonApi, + **kwargs: Any, +) -> SearchItemsRequest: + """Create a SearchItemsRequest for the Amazon API.""" try: return SearchItemsRequest( resources=_get_request_resources(SearchItemsResource), @@ -66,24 +83,31 @@ def get_search_items_request(amazon_api, **kwargs) -> SearchItemsRequest: **kwargs, ) except TypeError as exc: - raise MalformedRequest( - f"Parameters for search_items request are not correct: {exc}" - ) from exc + msg = f"Parameters for search_items request are not correct: {exc}" + raise MalformedRequest(msg) from exc -def get_search_items_response(amazon_api, request: SearchItemsRequest) -> SearchResult: +def get_search_items_response( + amazon_api: AmazonApi, request: SearchItemsRequest +) -> SearchResult: + """Execute a SearchItemsRequest and return the search result.""" try: response = amazon_api.api.search_items(request) except ApiException as exc: _manage_response_exceptions(exc) if response.search_result is None: - raise ItemsNotFound("No items have been found") + msg = "No items have been found" + raise ItemsNotFound(msg) - return response.search_result + return cast("SearchResult", response.search_result) -def get_variations_request(amazon_api, **kwargs) -> GetVariationsRequest: +def get_variations_request( + amazon_api: AmazonApi, + **kwargs: Any, +) -> GetVariationsRequest: + """Create a GetVariationsRequest for the Amazon API.""" try: return GetVariationsRequest( resources=_get_request_resources(GetVariationsResource), @@ -93,26 +117,31 @@ def get_variations_request(amazon_api, **kwargs) -> GetVariationsRequest: **kwargs, ) except TypeError as exc: - raise MalformedRequest( - f"Parameters for get_variations request are not correct: {exc}" - ) from exc + msg = f"Parameters for get_variations request are not correct: {exc}" + raise MalformedRequest(msg) from exc def get_variations_response( - amazon_api, request: GetVariationsRequest + amazon_api: AmazonApi, request: GetVariationsRequest ) -> VariationsResult: + """Execute a GetVariationsRequest and return the variations result.""" try: response = amazon_api.api.get_variations(request) except ApiException as exc: _manage_response_exceptions(exc) if response.variations_result is None: - raise ItemsNotFound("No variation items have been found") + msg = "No variation items have been found" + raise ItemsNotFound(msg) - return response.variations_result + return cast("VariationsResult", response.variations_result) -def get_browse_nodes_request(amazon_api, **kwargs) -> GetBrowseNodesRequest: +def get_browse_nodes_request( + amazon_api: AmazonApi, + **kwargs: Any, +) -> GetBrowseNodesRequest: + """Create a GetBrowseNodesRequest for the Amazon API.""" try: return GetBrowseNodesRequest( resources=_get_request_resources(GetBrowseNodesResource), @@ -122,54 +151,54 @@ def get_browse_nodes_request(amazon_api, **kwargs) -> GetBrowseNodesRequest: **kwargs, ) except TypeError as exc: - raise MalformedRequest( - f"Parameters for get_browse_nodes request are not correct: {exc}" - ) from exc + msg = f"Parameters for get_browse_nodes request are not correct: {exc}" + raise MalformedRequest(msg) from exc def get_browse_nodes_response( - amazon_api, request: GetBrowseNodesRequest -) -> List[BrowseNode]: + amazon_api: AmazonApi, request: GetBrowseNodesRequest +) -> list[BrowseNode]: + """Execute a GetBrowseNodesRequest and return the list of browse nodes.""" try: response = amazon_api.api.get_browse_nodes(request) except ApiException as exc: _manage_response_exceptions(exc) if response.browse_nodes_result is None: - raise ItemsNotFound("No browse nodes have been found") + msg = "No browse nodes have been found" + raise ItemsNotFound(msg) - return response.browse_nodes_result.browse_nodes + return cast("list[BrowseNode]", response.browse_nodes_result.browse_nodes) -def _get_request_resources(resources) -> List[str]: - resources = inspect.getmembers(resources, lambda a: not inspect.isroutine(a)) - resources = [ - x[-1] for x in resources if isinstance(x[-1], str) and x[0][0:2] != "__" - ] - return resources +def _get_request_resources(resource_class: type[object]) -> list[str]: + """Extract all resource strings from a resource class.""" + members = inspect.getmembers(resource_class, lambda a: not inspect.isroutine(a)) + return [x[-1] for x in members if isinstance(x[-1], str) and x[0][0:2] != "__"] -def _manage_response_exceptions(error) -> None: +def _manage_response_exceptions(error: ApiExceptionType) -> NoReturn: + """Handle API exceptions and raise appropriate custom exceptions.""" error_status = getattr(error, "status", None) error_body = getattr(error, "body", "") or "" - if error_status == 429: - raise TooManyRequests( + if error_status == HTTP_TOO_MANY_REQUESTS: + msg = ( "Requests limit reached, try increasing throttling or wait before" " trying again" ) + raise TooManyRequests(msg) if "InvalidParameterValue" in error_body: - raise InvalidArgument( - "The value provided in the request for atleast one parameter is invalid." - ) + msg = "The value provided in the request for atleast one parameter is invalid." + raise InvalidArgument(msg) if "InvalidPartnerTag" in error_body: - raise InvalidArgument("The partner tag is invalid or not present.") + msg = "The partner tag is invalid or not present." + raise InvalidArgument(msg) if "InvalidAssociate" in error_body: - raise AssociateValidationError( - "Used credentials are not valid for the selected country." - ) + msg = "Used credentials are not valid for the selected country." + raise AssociateValidationError(msg) raise RequestError("Request failed: " + str(error.reason)) diff --git a/amazon_paapi/models/__init__.py b/amazon_paapi/models/__init__.py index 415cda1..10d2b7f 100644 --- a/amazon_paapi/models/__init__.py +++ b/amazon_paapi/models/__init__.py @@ -1,18 +1,22 @@ -from ..sdk.models import Availability, Condition, Merchant, SortBy +"""Models for the Amazon Product Advertising API responses.""" + +from amazon_paapi.sdk.models import Availability, Condition, Merchant, SortBy + from .browse_nodes_result import BrowseNode from .item_result import Item -from .regions import Country +from .regions import Country, CountryCode from .search_result import SearchResult from .variations_result import VariationsResult __all__ = [ "Availability", - "Condition", - "Merchant", - "SortBy", "BrowseNode", - "Item", + "Condition", "Country", + "CountryCode", + "Item", + "Merchant", "SearchResult", + "SortBy", "VariationsResult", ] diff --git a/amazon_paapi/models/browse_nodes_result.py b/amazon_paapi/models/browse_nodes_result.py index 14dcf2f..ff353de 100644 --- a/amazon_paapi/models/browse_nodes_result.py +++ b/amazon_paapi/models/browse_nodes_result.py @@ -1,22 +1,30 @@ -from typing import List +"""Browse node result models for Amazon Product Advertising API.""" -from ..sdk import models as sdk_models +from __future__ import annotations + +from amazon_paapi.sdk import models as sdk_models class BrowseNodeChild(sdk_models.BrowseNodeChild): + """Represent a child browse node.""" + context_free_name: str display_name: str id: str class BrowseNodeAncestor(BrowseNodeChild, sdk_models.BrowseNodeAncestor): + """Represent an ancestor browse node.""" + ancestor: BrowseNodeChild class BrowseNode(sdk_models.BrowseNode): + """Represent a browse node with its hierarchy information.""" + display_name: str id: str is_root: bool context_free_name: str - children: List[BrowseNodeChild] + children: list[BrowseNodeChild] ancestor: BrowseNodeAncestor diff --git a/amazon_paapi/models/item_result.py b/amazon_paapi/models/item_result.py index 616a1d6..45e4373 100644 --- a/amazon_paapi/models/item_result.py +++ b/amazon_paapi/models/item_result.py @@ -1,27 +1,39 @@ -from typing import List, Optional +"""Item result models for Amazon Product Advertising API.""" -from ..sdk import models as sdk_models +from __future__ import annotations + +from amazon_paapi.sdk import models as sdk_models class ApiLabelLocale: + """Base class for API attributes with label and locale.""" + label: str locale: str class ApiMultiValuedAttributeStr(ApiLabelLocale, sdk_models.MultiValuedAttribute): - display_values: List[str] + """Multi-valued attribute with string display values.""" + + display_values: list[str] class ApiDisplayValuesType: + """Display value with type information.""" + display_value: str type: str class ApiMultiValuedAttributeType(ApiLabelLocale, sdk_models.MultiValuedAttribute): - display_values: List[ApiDisplayValuesType] + """Multi-valued attribute with typed display values.""" + + display_values: list[ApiDisplayValuesType] class ApiUnitBasedAttribute(ApiLabelLocale, sdk_models.UnitBasedAttribute): + """Unit-based attribute with numeric value and unit.""" + display_value: float unit: str @@ -29,22 +41,30 @@ class ApiUnitBasedAttribute(ApiLabelLocale, sdk_models.UnitBasedAttribute): class ApiSingleStringValuedAttribute( ApiLabelLocale, sdk_models.SingleStringValuedAttribute ): + """Single-valued attribute with string display value.""" + display_value: str class ApiSingleBooleanValuedAttribute( ApiLabelLocale, sdk_models.SingleBooleanValuedAttribute ): + """Single-valued attribute with boolean display value.""" + display_value: bool class ApiSingleIntegerValuedAttribute( ApiLabelLocale, sdk_models.SingleIntegerValuedAttribute ): + """Single-valued attribute with integer display value.""" + display_value: float class ApiPrice: + """Price information model.""" + amount: float currency: str price_per_unit: float @@ -54,60 +74,82 @@ class ApiPrice: class ApiImageSize(sdk_models.ImageSize): + """Image size with URL and dimensions.""" + url: str height: str width: str class ApiImageType(sdk_models.ImageType): + """Image type with multiple size variants.""" + large: ApiImageSize medium: ApiImageSize small: ApiImageSize class ApiImages(sdk_models.Images): + """Container for primary and variant images.""" + primary: ApiImageType - variants: List[ApiImageType] + variants: list[ApiImageType] class ApiByLineInfo(sdk_models.ByLineInfo): + """Product by-line information including brand and manufacturer.""" + brand: ApiSingleStringValuedAttribute contributors: ApiSingleStringValuedAttribute manufacturer: ApiSingleStringValuedAttribute class ApiClassifications(sdk_models.Classifications): + """Product classification information.""" + binding: ApiSingleStringValuedAttribute product_group: ApiSingleStringValuedAttribute class ApiContentInfo(sdk_models.ContentInfo): + """Product content information.""" + edition: ApiSingleStringValuedAttribute languages: ApiMultiValuedAttributeType - publication_date: Optional[ApiSingleStringValuedAttribute] + publication_date: ApiSingleStringValuedAttribute | None class ApiContentRating(sdk_models.ContentRating): + """Content rating information.""" + audience_rating: ApiSingleStringValuedAttribute class ApiExternalIds(sdk_models.ExternalIds): + """External product identifiers.""" + ea_ns: ApiMultiValuedAttributeStr isb_ns: ApiMultiValuedAttributeStr up_cs: ApiMultiValuedAttributeStr class ApiFeatures: + """Product features container.""" + features: ApiMultiValuedAttributeStr class ApiManufactureInfo(sdk_models.ManufactureInfo): + """Manufacturing information.""" + item_part_number: ApiSingleStringValuedAttribute model: ApiSingleStringValuedAttribute warranty: ApiSingleStringValuedAttribute class ApiItemDimensions(sdk_models.DimensionBasedAttribute): + """Item physical dimensions.""" + height: ApiUnitBasedAttribute length: ApiUnitBasedAttribute weight: ApiUnitBasedAttribute @@ -115,6 +157,8 @@ class ApiItemDimensions(sdk_models.DimensionBasedAttribute): class ApiProductInfo(sdk_models.ProductInfo): + """Product information container.""" + color: ApiSingleStringValuedAttribute is_adult_product: ApiSingleBooleanValuedAttribute item_dimensions: ApiItemDimensions @@ -124,36 +168,46 @@ class ApiProductInfo(sdk_models.ProductInfo): class ApiTechnicalInfo(sdk_models.TechnicalInfo): + """Technical specifications.""" + formats: ApiMultiValuedAttributeStr energy_efficiency_class: ApiSingleStringValuedAttribute class ApiTradeInPrice(sdk_models.TradeInPrice): + """Trade-in price information.""" + amount: float currency: str display_amount: str class ApiTradeInInfo(sdk_models.TradeInInfo): + """Trade-in eligibility and pricing.""" + is_eligible_for_trade_in: bool price: ApiTradeInPrice class ApiItemInfo(sdk_models.ItemInfo): + """Comprehensive item information container.""" + by_line_info: ApiByLineInfo classifications: ApiClassifications - content_info: Optional[ApiContentInfo] + content_info: ApiContentInfo | None content_rating: ApiContentRating external_ids: ApiExternalIds features: ApiFeatures manufacture_info: ApiManufactureInfo - product_info: Optional[ApiProductInfo] + product_info: ApiProductInfo | None technical_info: ApiTechnicalInfo title: ApiSingleStringValuedAttribute trade_in_info: ApiTradeInInfo class ApiOfferAvailability(sdk_models.OfferAvailability): + """Offer availability information.""" + max_order_quantity: int message: str min_order_quantity: int @@ -161,6 +215,8 @@ class ApiOfferAvailability(sdk_models.OfferAvailability): class ApiOfferConditionInfo: + """Base class for offer condition information.""" + display_value: str label: str locale: str @@ -168,30 +224,40 @@ class ApiOfferConditionInfo: class ApiOfferSubCondition(ApiOfferConditionInfo, sdk_models.OfferSubCondition): - pass + """Offer sub-condition details.""" class ApiOfferConditionNote(sdk_models.OfferConditionNote): + """Offer condition note.""" + locale: str value: str class ApiOfferCondition(ApiOfferConditionInfo, sdk_models.OfferCondition): + """Offer condition with sub-condition.""" + sub_condition: ApiOfferSubCondition condition_note: ApiOfferConditionNote class ApiOfferDeliveryInfo(sdk_models.OfferDeliveryInfo): + """Offer delivery information.""" + is_amazon_fulfilled: bool is_free_shipping_eligible: bool is_prime_eligible: bool class ApiOfferLoyaltyPoints(sdk_models.OfferLoyaltyPoints): + """Loyalty points for offer.""" + points: int class ApiOfferMerchantInfo(sdk_models.OfferMerchantInfo): + """Merchant information for offer.""" + default_shipping_country: str feedback_count: int feedback_rating: float @@ -200,24 +266,34 @@ class ApiOfferMerchantInfo(sdk_models.OfferMerchantInfo): class ApiOfferSavings(ApiPrice, sdk_models.OfferSavings): + """Offer savings information.""" + percentage: float class ApiOfferPrice(ApiPrice, sdk_models.OfferPrice): + """Offer price with savings.""" + savings: sdk_models.OfferSavings class ApiOfferProgramEligibility(sdk_models.OfferProgramEligibility): + """Program eligibility for offer.""" + is_prime_exclusive: bool is_prime_pantry: bool class ApiPromotion(ApiPrice, sdk_models.OfferPromotion): + """Promotion information.""" + type: str discount_percent: float class ApiListings(sdk_models.OfferListing): + """Offer listing with all details.""" + availability: ApiOfferAvailability condition: ApiOfferCondition delivery_info: ApiOfferDeliveryInfo @@ -227,16 +303,20 @@ class ApiListings(sdk_models.OfferListing): merchant_info: ApiOfferMerchantInfo price: ApiOfferPrice program_eligibility: ApiOfferProgramEligibility - promotions: List[ApiPromotion] + promotions: list[ApiPromotion] saving_basis: ApiPrice violates_map: bool class ApiOffers(sdk_models.Offers): - listings: List[ApiListings] + """Container for offer listings.""" + + listings: list[ApiListings] class ApiBrowseNode(sdk_models.BrowseNode): + """Browse node information.""" + ancestor: str context_free_name: str display_name: str @@ -246,17 +326,23 @@ class ApiBrowseNode(sdk_models.BrowseNode): class ApiWebsiteSalesRank(sdk_models.WebsiteSalesRank): + """Website sales rank information.""" + context_free_name: str display_name: str sales_rank: str class ApiBrowseNodeInfo(sdk_models.BrowseNodeInfo): - browse_nodes: List[ApiBrowseNode] + """Browse node information container.""" + + browse_nodes: list[ApiBrowseNode] website_sales_rank: ApiWebsiteSalesRank class Item(sdk_models.Item): + """Amazon product item with all details.""" + asin: str browse_node_info: ApiBrowseNodeInfo customer_reviews: sdk_models.CustomerReviews @@ -267,4 +353,4 @@ class Item(sdk_models.Item): parent_asin: str rental_offers: sdk_models.RentalOffers score: float - variation_attributes: List[sdk_models.VariationAttribute] + variation_attributes: list[sdk_models.VariationAttribute] diff --git a/amazon_paapi/models/regions.py b/amazon_paapi/models/regions.py index a9881b5..1e6e201 100644 --- a/amazon_paapi/models/regions.py +++ b/amazon_paapi/models/regions.py @@ -1,27 +1,63 @@ +"""Region and country code definitions for Amazon marketplaces.""" + +from __future__ import annotations + +from typing import Literal + +CountryCode = Literal[ + "AU", + "BE", + "BR", + "CA", + "FR", + "DE", + "IN", + "IT", + "JP", + "MX", + "NL", + "PL", + "SG", + "SA", + "ES", + "SE", + "TR", + "AE", + "UK", + "US", +] + + class Country: - AU = "AU" - BE = "BE" - BR = "BR" - CA = "CA" - FR = "FR" - DE = "DE" - IN = "IN" - IT = "IT" - JP = "JP" - MX = "MX" - NL = "NL" - PL = "PL" - SG = "SG" - SA = "SA" - ES = "ES" - SE = "SE" - TR = "TR" - AE = "AE" - UK = "UK" - US = "US" + """Constants for supported Amazon countries. + + Use these constants when specifying the country parameter. + Example: AmazonApi(key, secret, tag, Country.ES) + """ + + AU: CountryCode = "AU" + BE: CountryCode = "BE" + BR: CountryCode = "BR" + CA: CountryCode = "CA" + FR: CountryCode = "FR" + DE: CountryCode = "DE" + IN: CountryCode = "IN" + IT: CountryCode = "IT" + JP: CountryCode = "JP" + MX: CountryCode = "MX" + NL: CountryCode = "NL" + PL: CountryCode = "PL" + SG: CountryCode = "SG" + SA: CountryCode = "SA" + ES: CountryCode = "ES" + SE: CountryCode = "SE" + TR: CountryCode = "TR" + AE: CountryCode = "AE" + UK: CountryCode = "UK" + US: CountryCode = "US" -REGIONS = { +REGIONS: dict[str, str] = { "AU": "us-west-2", "BE": "eu-west-1", "BR": "us-east-1", @@ -45,7 +81,7 @@ class Country: } -DOMAINS = { +DOMAINS: dict[str, str] = { "AU": "com.au", "BE": "com.be", "BR": "com.br", diff --git a/amazon_paapi/models/search_result.py b/amazon_paapi/models/search_result.py index 7e27143..f089db5 100644 --- a/amazon_paapi/models/search_result.py +++ b/amazon_paapi/models/search_result.py @@ -1,10 +1,18 @@ -from typing import List +"""Search result model for Amazon Product Advertising API.""" -from ..sdk import models as sdk_models -from .item_result import Item +from __future__ import annotations + +from typing import TYPE_CHECKING + +from amazon_paapi.sdk import models as sdk_models + +if TYPE_CHECKING: + from .item_result import Item class SearchResult(sdk_models.SearchResult): - items: List[Item] + """Represent the result of a search operation.""" + + items: list[Item] total_result_count: int search_url: str diff --git a/amazon_paapi/models/variations_result.py b/amazon_paapi/models/variations_result.py index a15db36..70ea447 100644 --- a/amazon_paapi/models/variations_result.py +++ b/amazon_paapi/models/variations_result.py @@ -1,33 +1,49 @@ -from typing import List +"""Variations result models for Amazon Product Advertising API.""" -from ..sdk import models as sdk_models -from .item_result import Item +from __future__ import annotations + +from typing import TYPE_CHECKING + +from amazon_paapi.sdk import models as sdk_models + +if TYPE_CHECKING: + from .item_result import Item class ApiPrice: + """Represent a price with amount and currency.""" + amount: float currency: str display_amount: str class ApiVariationDimension: + """Represent a variation dimension like size or color.""" + display_name: str name: str - values: List[str] + values: list[str] class ApiVariationPrice: + """Represent the price range for variations.""" + highest_price: ApiPrice lowest_price: ApiPrice class ApiVariationSummary(sdk_models.VariationSummary): + """Represent a summary of variations for a product.""" + page_count: int price: ApiVariationPrice variation_count: int - variation_dimensions: List[ApiVariationDimension] + variation_dimensions: list[ApiVariationDimension] class VariationsResult(sdk_models.VariationsResult): - items: List[Item] + """Represent the result of a get variations operation.""" + + items: list[Item] variation_summary: ApiVariationSummary diff --git a/amazon_paapi/sdk/NOTICE.txt b/amazon_paapi/sdk/NOTICE.txt index 526e5d9..93040d3 100644 --- a/amazon_paapi/sdk/NOTICE.txt +++ b/amazon_paapi/sdk/NOTICE.txt @@ -1,2 +1,2 @@ Product Advertising API 5.0 SDK for Python -Copyright 2019-2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. \ No newline at end of file +Copyright 2019-2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. diff --git a/amazon_paapi/sdk/api_client.py b/amazon_paapi/sdk/api_client.py index e14223e..5136bc8 100644 --- a/amazon_paapi/sdk/api_client.py +++ b/amazon_paapi/sdk/api_client.py @@ -103,8 +103,13 @@ def __init__(self, self.region = region def __del__(self): - self.pool.close() - self.pool.join() + try: + self.pool.close() + self.pool.join() + except (OSError, TypeError): + # Ignore errors during interpreter shutdown when file descriptors + # or other resources may already be deallocated + pass @property def user_agent(self): diff --git a/amazon_paapi/tools/__init__.py b/amazon_paapi/tools/__init__.py index 279d187..c77eaba 100644 --- a/amazon_paapi/tools/__init__.py +++ b/amazon_paapi/tools/__init__.py @@ -1 +1,5 @@ +"""Tools for extracting information from Amazon URLs and ASINs.""" + +__all__ = ["get_asin"] + from .asin import get_asin diff --git a/amazon_paapi/tools/asin.py b/amazon_paapi/tools/asin.py index 46a585c..919e8ef 100644 --- a/amazon_paapi/tools/asin.py +++ b/amazon_paapi/tools/asin.py @@ -2,11 +2,11 @@ import re -from ..errors import AsinNotFound +from amazon_paapi.errors import AsinNotFound def get_asin(text: str) -> str: - """Returns the ASIN from a given text. Raises AsinNotFoundException on fail.""" + """Extract the ASIN from a given text or URL.""" # Return if text is an ASIN if re.search(r"^[a-zA-Z0-9]{10}$", text): return text.upper() diff --git a/docs/conf.py b/docs/conf.py index 52591b6..85e1b7b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -19,11 +19,11 @@ # -- Project information ----------------------------------------------------- project = "python-amazon-paapi" -copyright = "2021, Sergio Abad" +copyright = "2026, Sergio Abad" author = "Sergio Abad" # The full version, including alpha/beta/rc tags -release = "4.2.0" +release = "5.1.0" # -- General configuration --------------------------------------------------- @@ -38,7 +38,7 @@ autodoc_typehints = "none" # Add any paths that contain templates here, relative to this directory. -templates_path = ["_templates"] +# templates_path = ["_templates"] # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. diff --git a/docs/index.rst b/docs/index.rst index 4278dec..215bdc4 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -36,3 +36,8 @@ If you are still using version 4.x or lower, it is highly recommended upgrading ./pages/migration-guide-4.md ./pages/migration-guide-5.md + +Changelog +--------- + +See the `changelog `_ for a detailed history of changes. diff --git a/docs/pages/usage-guide.md b/docs/pages/usage-guide.md index 4cd5c38..1d47ffa 100644 --- a/docs/pages/usage-guide.md +++ b/docs/pages/usage-guide.md @@ -24,7 +24,7 @@ for item in items: print(item.offers.listings[0].price.amount) # Current price ``` -**Use URL insted of ASIN:** +**Use URL instead of ASIN:** ```python item = amazon.get_items('https://www.amazon.com/dp/B01N5IB20Q') diff --git a/docs/requirements.txt b/docs/requirements.txt index 011ccac..998af5b 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,3 +1,3 @@ -sphinx==4.3.0 -sphinx_rtd_theme==1.0.0 -myst-parser==0.15.2 +sphinx> 9.1.0 +sphinx-rtd-theme>=3.0.2 +myst-parser>=4.0.1 diff --git a/examples/example.py b/examples/example.py deleted file mode 100644 index dde03ad..0000000 --- a/examples/example.py +++ /dev/null @@ -1,35 +0,0 @@ -import secrets - -from amazon_paapi import AmazonApi - -# pylint: disable=no-member -amazon = AmazonApi( - secrets.KEY, secrets.SECRET, secrets.TAG, secrets.COUNTRY, throttling=2 -) - - -print("\nGet items") -print("=========================================================") -product = amazon.get_items("B01N5IB20Q") -print(product[0].item_info.title.display_value) - - -print("Search items") -print("=========================================================") -items = amazon.search_items(keywords="nintendo", item_count=3) -for item in items.items: - print(item.item_info.title.display_value) - - -print("\nGet variations") -print("=========================================================") -items = amazon.get_variations("B08F63PPNV", variation_count=3) -for item in items.items: - print(item.item_info.title.display_value) - - -print("\nGet nodes") -print("=========================================================") -items = amazon.get_browse_nodes(["667049031"]) # Only available in spanish marketplace -for item in items: - print(item.display_name) diff --git a/pyproject.toml b/pyproject.toml index a833497..4b814b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,24 +1,118 @@ -[tool.black] -preview = true -exclude = ".*/sdk/.*" - -[tool.isort] -profile = "black" -skip_glob = "*/sdk/*" - -[tool.pylint] - [tool.pylint.master] - ignore = ["test.py"] - ignore-paths = [".*/sdk/", ".*docs/"] - [tool.pylint.message_control] - disable = [ - "no-self-use", - "protected-access", - "too-many-arguments", - "too-many-instance-attributes", - "too-many-locals", - "too-many-public-methods", - ] - ignored-argument-names = "args|kwargs" - [tool.pylint.similarities] - ignore-imports = true +[project] +name = "python-amazon-paapi" +version = "5.1.0" +description = "Amazon Product Advertising API 5.0 wrapper for Python" +readme = "README.md" +requires-python = ">=3.9" +license = "MIT" +authors = [{ name = "Sergio Abad", email = "sergio.abad@bytelix.com" }] +keywords = ["amazon", "paapi", "product-advertising-api", "affiliate", "api"] +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Topic :: Software Development :: Libraries :: Python Modules", +] +dependencies = [ + "certifi>=2023.0.0", + "six>=1.16.0", + "python_dateutil>=2.8.0", + "urllib3>=1.26.0,<3", +] + +[project.urls] +Homepage = "https://github.com/sergioteula/python-amazon-paapi" +Repository = "https://github.com/sergioteula/python-amazon-paapi" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["amazon_paapi"] + +[dependency-groups] +dev = ["pre-commit>=2.21.0", "pytest>=7.4.4", "pytest-cov>=4.1.0"] + +[tool.ruff] +target-version = "py39" +cache-dir = ".cache/ruff" + +[tool.ruff.lint] +select = ["ALL"] +ignore = [ + "ANN003", # Missing type annotation for **kwargs + "ANN401", # Dynamically typed expressions (typing.Any) are disallowed + "D203", # 1 blank line required before class docstring (conflicts with D211) + "D213", # Multi-line docstring summary should start at the second line (conflicts with D212) + "COM812", # Missing trailing comma (conflicts with Ruff formatter) + "ISC001", # Implicitly concatenated string literals (conflicts with Ruff formatter) + "FBT001", # Boolean positional arg in function definition + "FBT002", # Boolean default value in function definition + "N818", # TODO: Exception name should be named with an Error suffix +] +exclude = ["amazon_paapi/sdk/*", "docs/*"] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = [ + "ANN201", # Missing return type annotation for public function + "ANN206", # Missing return type annotation for classmethod + "D", # pydocstring rules (no docstrings required for tests) + "PT009", # Use a regular assert instead of unittest-style + "PT027", # Use pytest.raises instead of unittest-style + "SLF001", # Private member accessed +] +"scripts/*" = [ + "T201", # print found (CLI scripts use print) +] +"api.py" = ["PLR0913"] # Too many arguments to function call + +[tool.ruff.format] +exclude = ["amazon_paapi/sdk/*", "docs/*", ".github"] + +[tool.mypy] +python_version = "3.9" +ignore_missing_imports = true +no_implicit_optional = true +strict_equality = true +warn_redundant_casts = true +warn_return_any = true +warn_unreachable = true +warn_unused_configs = true +warn_unused_ignores = true +cache_dir = '.cache/mypy' +exclude = ["amazon_paapi/sdk/*"] + +[[tool.mypy.overrides]] +module = ["amazon_paapi/sdk/*"] +follow_imports = "skip" + +[tool.coverage.run] +branch = true +relative_files = true +source = ["amazon_paapi"] +omit = ["amazon_paapi/sdk/*"] + +[tool.coverage.report] +precision = 2 +skip_covered = true +skip_empty = true +fail_under = 99 + +[tool.coverage.html] +directory = "coverage_html_report" +skip_covered = false + +[tool.pytest.ini_options] +testpaths = "tests" +cache_dir = ".cache/pytest" diff --git a/scripts/check_black b/scripts/check_black deleted file mode 100755 index bdebf44..0000000 --- a/scripts/check_black +++ /dev/null @@ -1,23 +0,0 @@ -#! /bin/bash - -ROOT_DIR="$(git rev-parse --show-toplevel)" -source "${ROOT_DIR}/scripts/helpers" - -header "Checking code format with black" - -if [ -n "$(check_if_installed docker)" ]; then - docker run -v "${PWD}:/code" sergioteula/pytools black --check --diff --color . -elif [ -n "$(check_if_installed black)" ]; then - black --check --diff --color . -else - error "black is not installed" - exit 1 -fi - -EXIT_CODE="$?" -if [ "$EXIT_CODE" = "0" ]; then - success "Code is correctly formatted" -else - error "Code should be formatted using black" - exit 1 -fi diff --git a/scripts/check_flake8 b/scripts/check_flake8 deleted file mode 100755 index 1a117a1..0000000 --- a/scripts/check_flake8 +++ /dev/null @@ -1,23 +0,0 @@ -#! /bin/bash - -ROOT_DIR="$(git rev-parse --show-toplevel)" -source "${ROOT_DIR}/scripts/helpers" - -header "Checking code errors with flake8" - -if [ -n "$(check_if_installed docker)" ]; then - docker run -v "${PWD}:/code" sergioteula/pytools flake8 --color always . -elif [ -n "$(check_if_installed flake8)" ]; then - flake8 --color always . -else - error "flake8 is not installed" - exit 1 -fi - -EXIT_CODE="$?" -if [ "$EXIT_CODE" = "0" ]; then - success "Code analysis with flake8 is correct" -else - error "There are errors detected by flake8" - exit 1 -fi diff --git a/scripts/check_isort b/scripts/check_isort deleted file mode 100755 index 046205e..0000000 --- a/scripts/check_isort +++ /dev/null @@ -1,23 +0,0 @@ -#! /bin/bash - -ROOT_DIR="$(git rev-parse --show-toplevel)" -source "${ROOT_DIR}/scripts/helpers" - -header "Checking imports order with isort" - -if [ -n "$(check_if_installed docker)" ]; then - docker run -v "${PWD}:/code" sergioteula/pytools isort -c --color . -elif [ -n "$(check_if_installed isort)" ]; then - isort -c --color . -else - error "isort is not installed" - exit 1 -fi - -EXIT_CODE="$?" -if [ "$EXIT_CODE" = "0" ]; then - success "Imports are correctly ordered" -else - error "Imports order is not correct" - exit 1 -fi diff --git a/scripts/check_pylint b/scripts/check_pylint deleted file mode 100755 index 8cdb438..0000000 --- a/scripts/check_pylint +++ /dev/null @@ -1,23 +0,0 @@ -#! /bin/bash - -ROOT_DIR="$(git rev-parse --show-toplevel)" -source "${ROOT_DIR}/scripts/helpers" - -header "Checking code errors with pylint" - -if [ -n "$(check_if_installed docker)" ]; then - docker run -v "${PWD}:/code" sergioteula/pytools find . -type f -name '*.py' | xargs pylint --disable=missing-docstring --disable=too-few-public-methods -elif [ -n "$(check_if_installed pylint)" ]; then - find . -type f -name '*.py' | xargs pylint --disable=missing-docstring --disable=too-few-public-methods -else - error "pylint is not installed" - exit 1 -fi - -EXIT_CODE="$?" -if [ "$EXIT_CODE" = "0" ]; then - success "Code analysis with pylint is correct" -else - error "There are errors detected by pylint" - exit 1 -fi diff --git a/scripts/check_version.py b/scripts/check_version.py new file mode 100755 index 0000000..b97bda5 --- /dev/null +++ b/scripts/check_version.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Check that version numbers are consistent across the project.""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + + +def get_changelog_version() -> str | None: + """Extract the latest non-Unreleased version from CHANGELOG.md.""" + changelog = Path("CHANGELOG.md").read_text() + match = re.search(r"## \[(\d+\.\d+\.\d+)\]", changelog) + return match.group(1) if match else None + + +def get_pyproject_version() -> str | None: + """Extract version from pyproject.toml.""" + pyproject = Path("pyproject.toml").read_text() + match = re.search(r'^version = "(\d+\.\d+\.\d+)"', pyproject, re.MULTILINE) + return match.group(1) if match else None + + +def get_docs_version() -> str | None: + """Extract release version from docs/conf.py.""" + conf = Path("docs/conf.py").read_text() + match = re.search(r'^release = "(\d+\.\d+\.\d+)"', conf, re.MULTILINE) + return match.group(1) if match else None + + +def main() -> int: + """Check version consistency across project files.""" + changelog_version = get_changelog_version() + pyproject_version = get_pyproject_version() + docs_version = get_docs_version() + + if not changelog_version: + print("❌ Could not find version in CHANGELOG.md") + return 1 + + errors = [] + + if pyproject_version != changelog_version: + errors.append( + f" pyproject.toml: {pyproject_version} (expected {changelog_version})" + ) + + if docs_version != changelog_version: + errors.append( + f" docs/conf.py: {docs_version} (expected {changelog_version})" + ) + + if errors: + print(f"❌ Version mismatch! CHANGELOG.md has version {changelog_version}") + print("\nMismatched files:") + for error in errors: + print(error) + print("\nPlease update all version numbers to match.") + return 1 + + print(f"✅ All versions match: {changelog_version}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/helpers b/scripts/helpers deleted file mode 100755 index 6c44dde..0000000 --- a/scripts/helpers +++ /dev/null @@ -1,30 +0,0 @@ -#! /bin/bash -e - -BLUE="\033[0;34m" -GREEN="\033[0;32m" -RED="\033[0;31m" -YELLOW="\033[0;33m" -NC="\033[0m" -LINE="----------------------------------------------------------------------" - -check_if_installed() { - if [ -x "$(command -v "${1}")" ]; then - echo "${1} is installed" - fi -} - -header(){ - echo -e "\n${BLUE}${*}\n${BLUE}${LINE}${NC}" -} - -warning(){ - echo -e "${YELLOW}WARNING: ${*}${NC}" -} - -error(){ - echo -e "${RED}ERROR: ${*}${NC}" -} - -success(){ - echo -e "${GREEN}${*}${NC}" -} diff --git a/scripts/run_tests b/scripts/run_tests deleted file mode 100755 index 23eb2fd..0000000 --- a/scripts/run_tests +++ /dev/null @@ -1,24 +0,0 @@ -#! /bin/bash - -ROOT_DIR="$(git rev-parse --show-toplevel)" -source "${ROOT_DIR}/scripts/helpers" - -header "Running tests" - -if [ -n "$(check_if_installed docker)" ]; then - docker run -v "${PWD}:/code" -u "$(id -u):$(id -g)" sergioteula/pytools bash -c \ - "coverage run -m unittest && coverage xml && coverage html && echo && coverage report" -elif [ -n "$(check_if_installed coverage)" ]; then - coverage run -m unittest && coverage xml && coverage html && echo && coverage report -else - error "coverage is not installed" - exit 1 -fi - -EXIT_CODE="$?" -if [ "$EXIT_CODE" = "0" ]; then - success "Tests passed" -else - error "Tests failed" - exit 1 -fi diff --git a/setup.py b/setup.py deleted file mode 100644 index f570722..0000000 --- a/setup.py +++ /dev/null @@ -1,24 +0,0 @@ -import setuptools - -with open("README.md", "r", encoding="utf8") as fh: - long_description = fh.read() - -setuptools.setup( - name="python-amazon-paapi", - version="5", - author="Sergio Abad", - author_email="sergio.abad@bytelix.com", - description="Amazon Product Advertising API 5.0 wrapper for Python", - long_description=long_description, - long_description_content_type="text/markdown", - license="MIT", - url="https://github.com/sergioteula/python-amazon-paapi", - packages=setuptools.find_packages(), - install_requires=["certifi", "six", "python_dateutil", "setuptools", "urllib3"], - classifiers=[ - "Programming Language :: Python", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - ], - python_requires=">=3.6", -) diff --git a/sonar-project.properties b/sonar-project.properties deleted file mode 100644 index fade800..0000000 --- a/sonar-project.properties +++ /dev/null @@ -1,10 +0,0 @@ -sonar.organization=sergioteula -sonar.projectKey=sergioteula_python-amazon-paapi - -sonar.sources=amazon_paapi -sonar.exclusions=**/sdk/** -sonar.tests=tests -sonar.python.coverage.reportPaths=coverage.xml - -sonar.qualitygate.wait=true -sonar.python.version=3.6,3.7,3.8,3.9,3.10,3.11,3.12 diff --git a/tests/__init__.py b/tests/__init__.py index e69de29..ed694da 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Test suite for python-amazon-paapi.""" diff --git a/tests/test_api.py b/tests/api_test.py similarity index 89% rename from tests/test_api.py rename to tests/api_test.py index 3bbcfec..b92e39b 100644 --- a/tests/test_api.py +++ b/tests/api_test.py @@ -1,6 +1,9 @@ +"""Tests for AmazonApi class.""" + import time import unittest from unittest import mock +from unittest.mock import MagicMock from amazon_paapi import AmazonApi, models from amazon_paapi.errors.exceptions import InvalidArgument @@ -31,14 +34,14 @@ def test_api_throttling_sleeps(self): self.assertTrue(start < int(time.time() * 10)) @mock.patch.object(requests, "get_items_response") - def test_get_items(self, mocked_get_items_response): + def test_get_items(self, mocked_get_items_response: MagicMock): mocked_get_items_response.return_value = [] amazon = AmazonApi("key", "secret", "tag", "ES") response = amazon.get_items("ABCDEFGHIJ") self.assertTrue(isinstance(response, list)) @mock.patch.object(requests, "get_search_items_response") - def test_search_items(self, mocked_get_search_items_response): + def test_search_items(self, mocked_get_search_items_response: MagicMock): mocked_response = models.SearchResult() mocked_response.items = [] mocked_get_search_items_response.return_value = mocked_response @@ -47,7 +50,7 @@ def test_search_items(self, mocked_get_search_items_response): self.assertTrue(isinstance(response.items, list)) @mock.patch.object(requests, "get_variations_response") - def test_get_variations(self, mocked_get_variations_response): + def test_get_variations(self, mocked_get_variations_response: MagicMock): mocked_response = models.VariationsResult() mocked_response.items = [] mocked_get_variations_response.return_value = mocked_response @@ -56,9 +59,9 @@ def test_get_variations(self, mocked_get_variations_response): self.assertTrue(isinstance(response.items, list)) @mock.patch.object(requests, "get_browse_nodes_response") - def test_get_browse_nodes(self, mocked_get_browse_nodes_response): - mocked_response = [] + def test_get_browse_nodes(self, mocked_get_browse_nodes_response: MagicMock): + mocked_response: list[models.BrowseNode] = [] mocked_get_browse_nodes_response.return_value = mocked_response amazon = AmazonApi("key", "secret", "tag", "ES") response = amazon.get_browse_nodes(["ABCDEFGHIJ"]) - self.assertTrue(isinstance(response, list)) + self.assertIsInstance(response, list) diff --git a/tests/test_errors.py b/tests/errors_test.py similarity index 88% rename from tests/test_errors.py rename to tests/errors_test.py index 60595e2..700567c 100644 --- a/tests/test_errors.py +++ b/tests/errors_test.py @@ -1,3 +1,5 @@ +"""Tests for error classes.""" + import unittest from amazon_paapi.errors import AmazonError diff --git a/tests/test_helpers_arguments.py b/tests/helpers_arguments_test.py similarity index 98% rename from tests/test_helpers_arguments.py rename to tests/helpers_arguments_test.py index 715d489..f0fb7b8 100644 --- a/tests/test_helpers_arguments.py +++ b/tests/helpers_arguments_test.py @@ -1,3 +1,5 @@ +"""Tests for arguments helper functions.""" + import unittest from amazon_paapi.errors import AsinNotFound, InvalidArgument diff --git a/tests/test_helpers_generators.py b/tests/helpers_generators_test.py similarity index 92% rename from tests/test_helpers_generators.py rename to tests/helpers_generators_test.py index 5988dff..175390f 100644 --- a/tests/test_helpers_generators.py +++ b/tests/helpers_generators_test.py @@ -1,3 +1,5 @@ +"""Tests for generators helper functions.""" + import unittest from amazon_paapi.helpers.generators import get_list_chunks diff --git a/tests/test_helpers_items.py b/tests/helpers_items_test.py similarity index 66% rename from tests/test_helpers_items.py rename to tests/helpers_items_test.py index 2f0dea6..49a7ade 100644 --- a/tests/test_helpers_items.py +++ b/tests/helpers_items_test.py @@ -1,3 +1,5 @@ +"""Tests for items helper functions.""" + import unittest from unittest import mock @@ -5,7 +7,7 @@ class MockedItem(mock.MagicMock): - def __init__(self, asin): + def __init__(self, asin: str) -> None: super().__init__() self.asin = asin @@ -21,22 +23,30 @@ def setUp(self): self.mocked_items_ids = ["B", "A", "D", "C", "E", "A"] def test_sort_items(self): - sorted_items = sort_items(self.mocked_items, self.mocked_items_ids, False) + sorted_items = sort_items( + self.mocked_items, self.mocked_items_ids, include_unavailable=False + ) self.assertEqual(sorted_items[0].asin, "B") self.assertEqual(sorted_items[1].asin, "A") self.assertEqual(sorted_items[2].asin, "D") self.assertEqual(sorted_items[3].asin, "C") def test_sort_items_include_unavailable(self): - sorted_items = sort_items(self.mocked_items, self.mocked_items_ids, True) + sorted_items = sort_items( + self.mocked_items, self.mocked_items_ids, include_unavailable=True + ) self.assertEqual(sorted_items[4].asin, "E") self.assertEqual(len(sorted_items), 6) def test_sort_items_not_include_unavailable(self): - sorted_items = sort_items(self.mocked_items, self.mocked_items_ids, False) + sorted_items = sort_items( + self.mocked_items, self.mocked_items_ids, include_unavailable=False + ) self.assertEqual(sorted_items[4].asin, "A") self.assertEqual(len(sorted_items), 5) def test_sort_items_include_repeated(self): - sorted_items = sort_items(self.mocked_items, self.mocked_items_ids, True) + sorted_items = sort_items( + self.mocked_items, self.mocked_items_ids, include_unavailable=True + ) self.assertEqual(sorted_items[1].asin, sorted_items[5].asin) diff --git a/tests/test_helpers_requests.py b/tests/helpers_requests_test.py similarity index 95% rename from tests/test_helpers_requests.py rename to tests/helpers_requests_test.py index 6d051db..36ef000 100644 --- a/tests/test_helpers_requests.py +++ b/tests/helpers_requests_test.py @@ -1,5 +1,7 @@ +"""Tests for requests helper functions.""" + import unittest -from unittest.mock import Mock, patch +from unittest.mock import MagicMock, Mock, patch from amazon_paapi.errors import ( AssociateValidationError, @@ -15,56 +17,56 @@ class TestRequests(unittest.TestCase): @patch.object(requests, "GetItemsRequest") - def test_get_items_request(self, mock_get_items_request): + def test_get_items_request(self, mock_get_items_request: MagicMock): mock_get_items_request.return_value = "foo" result = requests.get_items_request(Mock(), ["test"]) self.assertEqual("foo", result) @patch.object(requests, "GetItemsRequest") - def test_get_items_request_error(self, mock_get_items_request): + def test_get_items_request_error(self, mock_get_items_request: MagicMock): mock_get_items_request.side_effect = TypeError() with self.assertRaises(MalformedRequest): requests.get_items_request(Mock(), ["test"]) @patch.object(requests, "SearchItemsRequest") - def test_search_items_request(self, mock_search_items_request): + def test_search_items_request(self, mock_search_items_request: MagicMock): mock_search_items_request.return_value = "foo" result = requests.get_search_items_request(Mock()) self.assertEqual("foo", result) @patch.object(requests, "SearchItemsRequest") - def test_search_items_request_error(self, mock_search_items_request): + def test_search_items_request_error(self, mock_search_items_request: MagicMock): mock_search_items_request.side_effect = TypeError() with self.assertRaises(MalformedRequest): requests.get_search_items_request(Mock()) @patch.object(requests, "GetVariationsRequest") - def test_variations_request(self, mock_variations_request): + def test_variations_request(self, mock_variations_request: MagicMock): mock_variations_request.return_value = "foo" result = requests.get_variations_request(Mock()) self.assertEqual("foo", result) @patch.object(requests, "GetVariationsRequest") - def test_variations_request_error(self, mock_variations_request): + def test_variations_request_error(self, mock_variations_request: MagicMock): mock_variations_request.side_effect = TypeError() with self.assertRaises(MalformedRequest): requests.get_variations_request(Mock()) @patch.object(requests, "GetBrowseNodesRequest") - def test_browse_nodes_request(self, mock_browse_nodes_request): + def test_browse_nodes_request(self, mock_browse_nodes_request: MagicMock): mock_browse_nodes_request.return_value = "foo" result = requests.get_browse_nodes_request(Mock()) self.assertEqual("foo", result) @patch.object(requests, "GetBrowseNodesRequest") - def test_browse_nodes_request_error(self, mock_browse_nodes_request): + def test_browse_nodes_request_error(self, mock_browse_nodes_request: MagicMock): mock_browse_nodes_request.side_effect = TypeError() with self.assertRaises(MalformedRequest): diff --git a/tests/integration_test.py b/tests/integration_test.py new file mode 100644 index 0000000..2d38faf --- /dev/null +++ b/tests/integration_test.py @@ -0,0 +1,38 @@ +"""Integration tests for Amazon Product Advertising API.""" + +from __future__ import annotations + +import os +from unittest import TestCase, skipUnless + +from amazon_paapi.api import AmazonApi + + +def get_api_credentials() -> tuple[str | None, str | None, str | None, str | None]: + api_key = os.environ.get("API_KEY") + api_secret = os.environ.get("API_SECRET") + affiliate_tag = os.environ.get("AFFILIATE_TAG") + country_code = os.environ.get("COUNTRY_CODE") + + return api_key, api_secret, affiliate_tag, country_code + + +@skipUnless(all(get_api_credentials()), "Needs Amazon API credentials") +class IntegrationTest(TestCase): + @classmethod + def setUpClass(cls): + api_key, api_secret, affiliate_tag, country_code = get_api_credentials() + cls.api = AmazonApi(api_key, api_secret, affiliate_tag, country_code) + cls.affiliate_tag = affiliate_tag + + def test_search_items_and_get_information_for_the_first_one(self): + search_result = self.api.search_items(keywords="zapatillas") + searched_item = search_result.items[0] + + self.assertEqual(10, len(search_result.items)) + self.assertIn(self.affiliate_tag, searched_item.detail_page_url) + + get_results = self.api.get_items(searched_item.asin) + + self.assertEqual(1, len(get_results)) + self.assertIn(self.affiliate_tag, get_results[0].detail_page_url) diff --git a/tests/test_tools.py b/tests/tools_test.py similarity index 97% rename from tests/test_tools.py rename to tests/tools_test.py index 0f13a3a..cf750da 100644 --- a/tests/test_tools.py +++ b/tests/tools_test.py @@ -1,3 +1,5 @@ +"""Tests for tools module.""" + import unittest from amazon_paapi.errors import AsinNotFound