diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml
new file mode 100644
index 0000000..76b4b5d
--- /dev/null
+++ b/.github/workflows/ci-cd.yml
@@ -0,0 +1,132 @@
+name: Python CI/CD
+
+on: [push, pull_request]
+
+permissions: {}
+jobs:
+ Unit_tests:
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 10
+ strategy:
+ fail-fast: false
+ matrix:
+ python-version: [
+ "3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14",
+ "pypy-2.7", "pypy-3.11"
+ ]
+ os: [ubuntu-latest, macOS-latest, windows-latest]
+
+ steps:
+ - uses: actions/checkout@v3
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v4
+ with:
+ python-version: ${{ matrix.python-version }}
+ - name: Install dependencies
+ run: |
+ pip install -r test-requirements.txt -r requirements.txt
+ - name: Run tests
+ run: |
+ pytest
+
+ Mypy:
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 10
+ strategy:
+ fail-fast: false
+ matrix:
+ python-version: [
+ "3.12"
+ ]
+ os: [ubuntu-latest, macOS-latest, windows-latest]
+ steps:
+ - uses: actions/checkout@v3
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v4
+ with:
+ python-version: ${{ matrix.python-version }}
+ - name: Install dependencies
+ run: |
+ pip install -r test-requirements.txt -r requirements.txt
+ - name: Run tests
+ run: |
+ mypy --check tinify
+
+ Integration_tests:
+ if: github.event_name == 'push'
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 10
+ needs: [Unit_tests, Mypy]
+ strategy:
+ fail-fast: false
+ matrix:
+ python-version: [
+ "3.13",
+ ]
+ os: [ubuntu-latest, macOS-latest, windows-latest]
+ steps:
+ - uses: actions/checkout@v3
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v4
+ with:
+ python-version: ${{ matrix.python-version }}
+ - name: Install dependencies
+ run: |
+ pip install -r test-requirements.txt -r requirements.txt
+ - name: Run tests
+ env:
+ TINIFY_KEY: ${{ secrets.TINIFY_KEY }}
+ run: |
+ pytest test/integration.py
+
+ Publish:
+ if: |
+ github.repository == 'tinify/tinify-python' &&
+ startsWith(github.ref, 'refs/tags') &&
+ github.event_name == 'push'
+ timeout-minutes: 10
+ needs: [Unit_tests, Integration_tests]
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+ - name: Set up Python
+ uses: actions/setup-python@v4
+ with:
+ python-version: "3.13"
+ - name: Install dependencies
+ run: |
+ pip install -r requirements.txt
+ pip install build wheel
+ - name: Check if properly tagged
+ run: |
+ PACKAGE_VERSION="$(python -c 'from tinify import __version__;print(__version__)')";
+ CURRENT_TAG="${GITHUB_REF#refs/*/}";
+ if [[ "${PACKAGE_VERSION}" != "${CURRENT_TAG}" ]]; then
+ >&2 echo "Tag mismatch"
+ >&2 echo "Version in tinify/version.py (${PACKAGE_VERSION}) does not match the current tag=${CURRENT_TAG}"
+ >&2 echo "Skipping deploy"
+ exit 1;
+ fi
+ - name: Build package (sdist & wheel)
+ run: |
+ python -m build --sdist --wheel --outdir dist/
+ - name: Test sdist install
+ run: |
+ python -m venv sdist_env
+ ./sdist_env/bin/pip install dist/tinify*.tar.gz
+ - name: Test wheel install
+ run: |
+ python -m venv wheel_env
+ ./wheel_env/bin/pip install dist/tinify*.whl
+ - name: Publish package to PyPI
+ uses: pypa/gh-action-pypi-publish@release/v1
+ with:
+ user: __token__
+ password: ${{ secrets.PYPI_ACCESS_TOKEN }}
+ # Use the test repository for testing the publish feature
+ # repository_url: https://test.pypi.org/legacy/
+ packages_dir: dist/
+ print_hash: true
diff --git a/.gitignore b/.gitignore
index 19dcbed..fbbfca4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,4 +2,5 @@
__pycache__/
build/
dist/
-*.egg-info/
\ No newline at end of file
+*.egg-info/
+.tox
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 20d9c32..0000000
--- a/.travis.yml
+++ /dev/null
@@ -1,39 +0,0 @@
-language: python
-python:
-- 2.6
-- 2.7
-- 3.3
-- 3.4
-- 3.5
-- pypy
-- pypy3
-- nightly
-env:
- global:
- secure: h7a/ENjLEULz7J2e6AAwquDzg5WEW3+NxjuSQKoDXsfu4mKUfMRitEZGYCUe58x+n+vQ/YkhRoZWg4gr+uhzY//y8rLeQL2gcfs5uMeP48S6fUNK0U/kg0DcAKdfQatPyUZ+mzOVGX5ghzr4e8XjR9nkdnbFmLOn8Zk1jc2Il8Qr9sc5owug+DQO45Gv8iXTWjndW0TdYh8DFydJG3gaBqemtRk5NexJxQ3ejwS8RmkncKojIQkKBkWbuGJHuFQwCO03NOSpPZSkHYFJS63FX11hh9ilfA7FNf1PQ53RqeSg3dze2ulNMs/lh8+yxSx41MzkQ1Ap2gd9g24xRQoIMBP+MNviYr9O9CUD99GuP2Lb0MKqwzgbXDdt/gNi5GI9fbsD7LJxiuymGsiwRhkAENBceq3NebsLfea5qXUq84Jke+RQ1Zj0MWyhu9UBGb2LuirECDgqG7b3VxCvEMhDhD3cbn0hOqXmnPsDxnLPjrgW6YAWmfSGzDwX3JaWcVjXmy0li0KztBDD5VvL2SNrDQ7UrQIDdGqqXNOvoVl1vda9PrJk82YncCbTAylMP/Kga3GQv4SBCxI9CFQevhxBRlZZIjL5sHVBy+ctsKSsEhyMNp33PVcflIwlfMnZPx/7uy1FpTJbDZZOOVJOUUZbXmPingd9vkZ0Aj8x02zbHOQ=
-matrix:
- allow_failures:
- - python: nightly
- - python: pypy3
- include:
- - python: 3.5
- env: INTEGRATION_TESTS=true
- script: "if [ \"$TRAVIS_PULL_REQUEST\" == \"false\" ]; then nosetests test/integration.py; fi"
-install:
-- if [[ $TRAVIS_PYTHON_VERSION == 2.6 ]]; then pip install unittest2; fi
-- pip install -r test-requirements.txt
-script: nosetests
-notifications:
- email: false
- slack:
- secure: MmkcC1Et6B7vEvdUPBMFO640F8iOmLzekuNPbB5aq39IJ1RbpjfTVOpvBV0i4SjoZHxuLRqZ8l0P7CAhaIwqlxZuzm5znJqw3GNSBbK4/HdDNAxtkyL1noxsi+9VUgFrTI0aYlQdy9FcTWxTeUrpq0EMbmrKpMCt2RqFce4zPz/l8tWo5LNr31dnXtRtOztwllBozmOpU/qlr5dm5QQz0hQ5tyjfegB7B9puYC7z42IzZMO7XsAOUxOQ8U3YD0htmc/QcGRKxbcjoUGP7SooOylcs/Q1rNFDpII8CDIPcmQybIbL/2yd+3Gfiv3CqWciJCeq4+vlPg1mO+1RrbeDvZSzYE40MgKyKFkX8SrdJ7zVkiEDOBml/kv427pxV8wLAxryW/jwasnQSiz8IPpzivwqGvU6i40ksaCyQJVv3sDsQcp+2wIAbjSD20bZdq3sGInEwgf3Rihp22CdIf/5K5Tu6NDh566mycIZaUcyB+Z6VeLDbksxyL5gKzyKTuYvgzMdBwmNE35qnq5GwOge5YSZfgXzXK4sL6ykxeIqU4i9CzFZKif+xCrPl3jqwYo6zzrsMOPAyYGXOnVpHSmLzWYSiEytL/IPWXKdUaevf2vWKN3+Hi4aRK1vQ8vZSq6RVLto0FKSQkaYoK5EYJHlCIWQMhGj/sP+W2PsxzbC5qA=
-deploy:
- provider: pypi
- user: tinify
- password:
- secure: pxKKc21iBmZcc3ZTDX6zqZD+DsmgkbRnrRAUiiuginzR4a1lDxw9UH2AyVdzZt3Dc85KFYE0GGTPonuz6Vm/CNi6blImggwPlzpE8COEd9SNUbCOpwXbMSaxb9P1eWUyZdzktuZLPxGqCWhbZtMB3Gp+KH3fNEKvWIovBaM1VhOl2Wq2SRVFuzRRcijODSPr6cLOBvv7pSyLnjrbpwCe4clgqxQNS7nCq0TSwWhHG576ZBzBMAKK+mKU/W5twPL3oiHl2ngcQNEgH9bEUYEqctnzPvI2m1+4j/xFKcp5JBOSBfpxrWVxWeWrSb5LFG7Psyx3SL1IhUayH4xppYo3K4+WkrsAqDeDNGD2M5F4X1sKLgm0jyo2RVwWDmbmJP3/tStwKaSeqNAw99XoyYhwDNmtgLY3kpVAIJm56NQsecHjxVgdFNRdISHHMp4LRA9BD5dauPIG+1w+1GOFoeuzJOzVKFOcpuRymYVC+H1faD3YWBKy6bo8LSw4jkSRZB5vpdh5qK8iKvS+4Hu+/oVrCCg1/0ZlgnaBLdLtKz14Zk5mqqU4Mp8NSmrgj35gWRfVKpy9lXiSoH3XHW1DVjcmPckRC6gA4NdH2srxhjUNvu7q5TVES7w/a6QWzh6af9hNpRNg4WE9ysA73ShQYVwX0BhKHPd+Tl4IMVVdprSD1w4=
- on:
- tags: true
- repo: tinify/tinify-python
- python: 3.5
- condition: "$INTEGRATION_TESTS != true"
diff --git a/CHANGES.md b/CHANGES.md
index a5db7a4..4092e15 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,3 +1,33 @@
+## 1.7.2
+
+* Add JXL to supported image types
+* Update package classifier to support python 3.14
+
+## 1.7.1
+
+* Use only a GET request when no body, otherwise POST
+
+## 1.7.0
+
+* Added type annotations
+* Updated runtime support
+ * Dropped python 3.7
+ * Added Python 3.12
+ * Added Python 3.13
+* Tests: Replaced httpretty with requests-mock
+
+## 1.6.0
+* Updated runtime support
+ * Dropped 2.6
+ * Added python 3.7
+ * Added python 3.8
+ * Added python 3.9
+ * Added python 3.10
+ * Added python 3.11
+* Fixed tests on windows
+* Add methods for the transcoding and transformation API
+* Add a method for getting the file extension from a Result object
+
## 1.5.2
Remove letsencrypt DST Root from ca bundle for openssl 1.0.0 compatibility
diff --git a/LICENSE b/LICENSE
index 9b140b8..ea915ae 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
The MIT License
-Copyright (c) 2013-2018 Tinify
+Copyright (c) 2013-2025 Tinify
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/README.md b/README.md
index 7c9b3a4..eeb99c9 100644
--- a/README.md
+++ b/README.md
@@ -1,44 +1,167 @@
-[
](https://travis-ci.org/tinify/tinify-python)
+[ ](https://github.com/tinify/tinify-python/blob/main/LICENSE)
+[](https://github.com/tinify/tinify-python/actions/workflows/ci-cd.yml)
+[](https://pypi.org/project/tinify/#history)
+[](https://pypi.org/project/tinify/)
+[](https://pypi.org/project/tinify/)
+
# Tinify API client for Python
-Python client for the Tinify API, used for [TinyPNG](https://tinypng.com) and [TinyJPG](https://tinyjpg.com). Tinify compresses your images intelligently. Read more at [http://tinify.com](http://tinify.com).
+**Tinify** is the official Python client for the [TinyPNG](https://tinypng.com) and [TinyJPG](https://tinyjpg.com/) image compression API, enabling developers to intelligently compress, resize, convert and optimize PNG, APNG, JPEG, WebP and AVIF images programmatically. Read more at [https://tinify.com](https://tinify.com/developers).
+
+
+[Go to the full documentation for the Python client](https://tinypng.com/developers/reference/python).
+
+## Features
+
+- Compress and optimize images, reducing file size by 50-80% while preserving visual quality
+- Resize and crop images with smart compression
+- Convert between PNG, JPEG, WebP and AVIF formats
+- Preserve metadata (optional)
+- Upload to storage providers like Amazon S3, Google cloud storage.
+- Apply visual transformations with the Tinify API
+- Comprehensive error handling
+
-## Documentation
-[Go to the documentation for the Python client](https://tinypng.com/developers/reference/python).
+## Requirements
+
+- Python 2.7+
+- Requests library
## Installation
-Install the API client:
+Install the API client with pip:
-```
+```bash
pip install tinify
```
-## Usage
+## Quick start
+
+
+```python
+import tinify
+
+# Set your API key (get one for free at https://tinypng.com/developers)
+tinify.key = "YOUR_API_KEY"
+
+# Compress an image from a file
+tinify.from_file("unoptimized.png").to_file("optimized.png")
+
+# Compress from URL
+tinify.from_url("https://example.com/image.jpg").to_file("optimized.jpg")
+
+# Compress from buffer
+source_data = b""
+tinify.from_buffer(source_data).to_file("optimized.jpg")
+```
+
+## Advanced Usage
+
+### Resizing
+
+```python
+# Scale image to fit within 300x200px while preserving aspect ratio
+tinify.from_file("original.jpg").resize(
+ method="scale",
+ width=300,
+ height=200
+).to_file("resized.jpg")
+
+# Fit image to exact 300x200px dimensions
+tinify.from_file("original.jpg").resize(
+ method="fit",
+ width=300,
+ height=200
+).to_file("resized.jpg")
+
+# Cover 300x200px area while preserving aspect ratio
+tinify.from_file("original.jpg").resize(
+ method="cover",
+ width=300,
+ height=200
+).to_file("resized.jpg")
+```
+
+### Format Conversion
+
+```python
+# Convert to WebP format
+tinify.from_file("image.png").convert(
+ type=["image/webp"]
+).to_file("image.webp")
+```
+
+```python
+# Convert to smallest format
+converted = tinify.from_file("image.png").convert(
+ type=["image/webp", "image/avif", "image/jxl"]
+)
+extension = converted.result().extension
+converted.to_file("image." + extension)
+```
+
+### Compression Count Monitoring
+
+```python
+# Check the number of compressions made this month
+compression_count = tinify.compression_count
+print(f"You have made {compression_count} compressions this month")
+```
+
+## Error Handling
```python
import tinify
-tinify.key = 'YOUR_API_KEY'
-tinify.from_file('unoptimized.png').to_file('optimized.png')
+tinify.key = "YOUR_API_KEY"
+
+try:
+ tinify.from_file("unoptimized.png").to_file("optimized.png")
+except tinify.AccountError as e:
+ # Verify or update API key
+ print(f"Account error: {e.message}")
+except tinify.ClientError as e:
+ # Handle client errors (e.g., invalid image)
+ print(f"Client error: {e.message}")
+except tinify.ServerError as e:
+ # Handle server errors
+ print(f"Server error: {e.message}")
+except tinify.ConnectionError as e:
+ # Handle network connectivity issues
+ print(f"Connection error: {e.message}")
+except Exception as e:
+ # Handle general errors
+ print(f"Error: {str(e)}")
```
## Running tests
```
pip install -r requirements.txt -r test-requirements.txt
-nosetests
+py.test
+```
+
+To test more runtimes, tox can be used
+
```
+tox
+```
+
+
### Integration tests
```
pip install -r requirements.txt -r test-requirements.txt
-TINIFY_KEY=$YOUR_API_KEY nosetests test/integration.py
+TINIFY_KEY=$YOUR_API_KEY py.test test/integration.py
```
## License
-This software is licensed under the MIT License. [View the license](LICENSE).
+This software is licensed under the MIT License. See [LICENSE](https://github.com/tinify/tinify-python/blob/master/LICENSE) for details.
+
+## Support
+
+For issues and feature requests, please use our [GitHub Issues](https://github.com/tinify/tinify-python/issues) page or contact us at [support@tinify.com](mailto:support@tinify.com)
diff --git a/setup.py b/setup.py
index 2c0a5a4..fd55554 100644
--- a/setup.py
+++ b/setup.py
@@ -1,53 +1,57 @@
import sys
import os
import re
+import io
try:
from setuptools import setup
except ImportError:
from distutils.core import setup
-sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'tinify'))
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), "tinify"))
from version import __version__
-install_require = ['requests >= 2.7.0, < 3.0.0']
-tests_require = ['nose >= 1.3, < 2.0', 'httpretty >= 0.8.10, < 1.0.0']
+install_require = ["requests >= 2.7.0, < 3.0.0"]
+tests_require = ["pytest", "pytest-xdist", "requests-mock", "types-requests"]
-if sys.version_info < (2, 7):
- tests_require.append('unittest2')
-if sys.version_info < (3, 3):
- tests_require.append('mock >= 1.3, < 2.0')
+if sys.version_info.major > 2:
+ tests_require.append("mypy")
+
+with io.open("README.md", encoding="utf-8") as f:
+ long_description = f.read()
setup(
- name='tinify',
+ name="tinify",
version=__version__,
- description='Tinify API client.',
- author='Jacob Middag',
- author_email='info@tinify.com',
- license='MIT',
- long_description='Python client for the Tinify API. Tinify compresses your images intelligently. Read more at https://tinify.com.',
- url='https://tinify.com/developers',
-
- packages=['tinify'],
+ description="Tinify API client.",
+ author="Jacob Middag",
+ author_email="info@tinify.com",
+ license="MIT",
+ long_description=long_description,
+ long_description_content_type="text/markdown",
+ url="https://tinify.com/developers",
+ packages=["tinify"],
package_data={
- '': ['LICENSE', 'README.md'],
- 'tinify': ['data/cacert.pem'],
+ "": ["LICENSE", "README.md"],
+ "tinify": ["data/cacert.pem", "py.typed"],
},
-
install_requires=install_require,
tests_require=tests_require,
- extras_require={'test': tests_require},
-
+ extras_require={"test": tests_require},
classifiers=(
- 'Development Status :: 5 - Production/Stable',
- 'Intended Audience :: Developers',
- 'Natural Language :: English',
- 'License :: OSI Approved :: MIT License',
- 'Programming Language :: Python',
- 'Programming Language :: Python :: 2.6',
- 'Programming Language :: Python :: 2.7',
- 'Programming Language :: Python :: 3',
- 'Programming Language :: Python :: 3.3',
- 'Programming Language :: Python :: 3.4'
+ "Development Status :: 5 - Production/Stable",
+ "Intended Audience :: Developers",
+ "Natural Language :: English",
+ "License :: OSI Approved :: MIT License",
+ "Programming Language :: Python",
+ "Programming Language :: Python :: 2.7",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.8",
+ "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",
),
)
diff --git a/test/__init__.py b/test/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/test/helper.py b/test/helper.py
deleted file mode 100644
index 7160d04..0000000
--- a/test/helper.py
+++ /dev/null
@@ -1,58 +0,0 @@
-# -*- coding: utf-8 -*-
-from __future__ import absolute_import, division, print_function, unicode_literals
-
-import json
-import sys
-import os
-import httpretty
-from nose.exc import SkipTest
-
-if sys.version_info < (3, 3):
- from mock import DEFAULT
-else:
- from unittest.mock import DEFAULT
-
-if sys.version_info < (2, 7):
- import unittest2 as unittest
-else:
- import unittest
-
-for code in (584, 543, 492, 401):
- httpretty.http.STATUSES.setdefault(code)
-
-dummy_file = os.path.join(os.path.dirname(__file__), 'examples', 'dummy.png')
-
-import tinify
-
-class RaiseException(object):
- def __init__(self, exception, num=None):
- self.exception = exception
- self.num = num
-
- def __call__(self, *args, **kwargs):
- if self.num == 0:
- return DEFAULT
- else:
- if self.num: self.num -= 1
- raise self.exception
-
-class TestHelper(unittest.TestCase):
- def setUp(self):
- httpretty.enable()
- httpretty.HTTPretty.allow_net_connect = False
-
- def tearDown(self):
- httpretty.disable()
- httpretty.reset()
-
- tinify.key = None
- tinify.app_identifier = None
- tinify.proxy = None
- tinify.compression_count
-
- def assertJsonEqual(self, expected, actual):
- self.assertEqual(json.loads(expected), json.loads(actual))
-
- @property
- def request(self):
- return httpretty.last_request()
diff --git a/test/integration.py b/test/integration.py
index a831907..94cad2e 100644
--- a/test/integration.py
+++ b/test/integration.py
@@ -1,66 +1,140 @@
-import sys, os
+import sys
+import os
+from contextlib import contextmanager
+import tinify
+import pytest
+import tempfile
if not os.environ.get("TINIFY_KEY"):
sys.exit("Set the TINIFY_KEY environment variable.")
-import tinify, unittest, tempfile
-
-class ClientIntegrationTest(unittest.TestCase):
+try:
+ from typing import TYPE_CHECKING
+ if TYPE_CHECKING:
+ from tinify.source import Source
+except ImportError:
+ pass
+
+
+@contextmanager
+def create_named_tmpfile():
+ # Due to NamedTemporaryFile requiring to be closed when used on Windows
+ # we create our own NamedTemporaryFile contextmanager
+ # See note: https://docs.python.org/3/library/tempfile.html#tempfile.NamedTemporaryFile
+
+ tmp = tempfile.NamedTemporaryFile(delete=False)
+ try:
+ tmp.close()
+ yield tmp.name
+ finally:
+ os.unlink(tmp.name)
+
+@pytest.fixture(scope="module", autouse=True)
+def tinify_patch():
tinify.key = os.environ.get("TINIFY_KEY")
tinify.proxy = os.environ.get("TINIFY_PROXY")
- unoptimized_path = os.path.join(os.path.dirname(__file__), 'examples', 'voormedia.png')
- optimized = tinify.from_file(unoptimized_path)
+ yield
+
+ tinify.key = None
+ tinify.proxy = None
+
+# Fixture for shared resources
+@pytest.fixture(scope="module")
+def optimized_image():
+ unoptimized_path = os.path.join(
+ os.path.dirname(__file__), "examples", "voormedia.png"
+ )
+ return tinify.from_file(unoptimized_path)
+
+
+def test_should_compress_from_file(optimized_image): # type: (Source) -> None
+ with create_named_tmpfile() as tmp:
+ optimized_image.to_file(tmp)
+
+ size = os.path.getsize(tmp)
+
+ with open(tmp, "rb") as f:
+ contents = f.read()
+
+ assert 1000 < size < 1500
- def test_should_compress_from_file(self):
- with tempfile.NamedTemporaryFile() as tmp:
- self.optimized.to_file(tmp.name)
+ # width == 137
+ assert b"\x00\x00\x00\x89" in contents
+ assert b"Copyright Voormedia" not in contents
- size = os.path.getsize(tmp.name)
- contents = tmp.read()
- self.assertTrue(1000 < size < 1500)
+def test_should_compress_from_url():
+ source = tinify.from_url(
+ "https://raw.githubusercontent.com/tinify/tinify-python/master/test/examples/voormedia.png"
+ )
+ with create_named_tmpfile() as tmp:
+ source.to_file(tmp)
- # width == 137
- self.assertIn(b'\x00\x00\x00\x89', contents)
- self.assertNotIn(b'Copyright Voormedia', contents)
+ size = os.path.getsize(tmp)
+ with open(tmp, "rb") as f:
+ contents = f.read()
- def test_should_compress_from_url(self):
- source = tinify.from_url('https://raw.githubusercontent.com/tinify/tinify-python/master/test/examples/voormedia.png')
- with tempfile.NamedTemporaryFile() as tmp:
- source.to_file(tmp.name)
+ assert 1000 < size < 1500
- size = os.path.getsize(tmp.name)
- contents = tmp.read()
+ # width == 137
+ assert b"\x00\x00\x00\x89" in contents
+ assert b"Copyright Voormedia" not in contents
- self.assertTrue(1000 < size < 1500)
- # width == 137
- self.assertIn(b'\x00\x00\x00\x89', contents)
- self.assertNotIn(b'Copyright Voormedia', contents)
+def test_should_resize(optimized_image): # type: (Source) -> None
+ with create_named_tmpfile() as tmp:
+ optimized_image.resize(method="fit", width=50, height=20).to_file(tmp)
+ size = os.path.getsize(tmp)
+ with open(tmp, "rb") as f:
+ contents = f.read()
- def test_should_resize(self):
- with tempfile.NamedTemporaryFile() as tmp:
- self.optimized.resize(method="fit", width=50, height=20).to_file(tmp.name)
+ assert 500 < size < 1000
- size = os.path.getsize(tmp.name)
- contents = tmp.read()
+ # width == 50
+ assert b"\x00\x00\x00\x32" in contents
+ assert b"Copyright Voormedia" not in contents
- self.assertTrue(500 < size < 1000)
- # width == 50
- self.assertIn(b'\x00\x00\x00\x32', contents)
- self.assertNotIn(b'Copyright Voormedia', contents)
+def test_should_preserve_metadata(optimized_image): # type: (Source) -> None
+ with create_named_tmpfile() as tmp:
+ optimized_image.preserve("copyright", "creation").to_file(tmp)
- def test_should_preserve_metadata(self):
- with tempfile.NamedTemporaryFile() as tmp:
- self.optimized.preserve("copyright", "creation").to_file(tmp.name)
+ size = os.path.getsize(tmp)
+ with open(tmp, "rb") as f:
+ contents = f.read()
- size = os.path.getsize(tmp.name)
- contents = tmp.read()
+ assert 1000 < size < 2000
- self.assertTrue(1000 < size < 2000)
+ # width == 137
+ assert b"\x00\x00\x00\x89" in contents
+ assert b"Copyright Voormedia" in contents
+
+
+def test_should_transcode_image(optimized_image): # type: (Source) -> None
+ with create_named_tmpfile() as tmp:
+ conv = optimized_image.convert(type=["image/webp"])
+ conv.to_file(tmp)
+ with open(tmp, "rb") as f:
+ content = f.read()
+
+ assert b"RIFF" == content[:4]
+ assert b"WEBP" == content[8:12]
+
+ assert conv.result().size < optimized_image.result().size
+ assert conv.result().media_type == "image/webp"
+ assert conv.result().extension == "webp"
+
+
+def test_should_handle_invalid_key():
+ invalid_key = "invalid_key"
+ tinify.key = invalid_key
+ with pytest.raises(tinify.AccountError):
+ tinify.from_url(
+ "https://raw.githubusercontent.com/tinify/tinify-python/master/test/examples/voormedia.png"
+ )
+ tinify.key = os.environ.get("TINIFY_KEY")
- # width == 137
- self.assertIn(b'\x00\x00\x00\x89', contents)
- self.assertIn(b'Copyright Voormedia', contents)
+def test_should_handle_invalid_image():
+ with pytest.raises(tinify.ClientError):
+ tinify.from_buffer("invalid_image.png")
\ No newline at end of file
diff --git a/test/tinify_client_test.py b/test/tinify_client_test.py
deleted file mode 100644
index 96bda04..0000000
--- a/test/tinify_client_test.py
+++ /dev/null
@@ -1,199 +0,0 @@
-# -*- coding: utf-8 -*-
-from __future__ import absolute_import, division, print_function, unicode_literals
-
-import sys
-from base64 import b64encode
-
-import tinify
-from tinify import Client, AccountError, ClientError, ConnectionError, ServerError
-import requests
-
-from helper import *
-
-try:
- from unittest.mock import patch
-except ImportError:
- from mock import patch
-
-Client.RETRY_DELAY = 10
-
-class TinifyClientRequestWhenValid(TestHelper):
- def setUp(self):
- super(type(self), self).setUp()
- httpretty.register_uri(httpretty.GET, 'https://api.tinify.com/', **{
- 'compression-count': 12
- })
-
- def test_should_issue_request(self):
- Client('key').request('GET', '/')
-
- self.assertEqual(self.request.headers['authorization'], 'Basic {0}'.format(
- b64encode(b'api:key').decode('ascii')))
-
- def test_should_issue_request_without_body_when_options_are_empty(self):
- Client('key').request('GET', '/', {})
-
- self.assertEqual(self.request.body, b'')
-
- def test_should_issue_request_without_content_type_when_options_are_empty(self):
- Client('key').request('GET', '/', {})
-
- self.assertIsNone(self.request.headers.get('content-type'))
-
- def test_should_issue_request_with_json_body(self):
- Client('key').request('GET', '/', {'hello': 'world'})
-
- self.assertEqual(self.request.headers['content-type'], 'application/json')
- self.assertEqual(self.request.body, b'{"hello":"world"}')
-
- def test_should_issue_request_with_user_agent(self):
- Client('key').request('GET', '/')
-
- self.assertEqual(self.request.headers['user-agent'], Client.USER_AGENT)
-
- def test_should_update_compression_count(self):
- Client('key').request('GET', '/')
-
- self.assertEqual(tinify.compression_count, 12)
-
-class TinifyClientRequestWhenValidWithAppId(TestHelper):
- def setUp(self):
- super(type(self), self).setUp()
- httpretty.register_uri(httpretty.GET, 'https://api.tinify.com/', **{
- 'compression-count': 12
- })
-
- def test_should_issue_request_with_user_agent(self):
- Client('key', 'TestApp/0.2').request('GET', '/')
-
- self.assertEqual(self.request.headers['user-agent'], Client.USER_AGENT + ' TestApp/0.2')
-
-class TinifyClientRequestWhenValidWithProxy(TestHelper):
- def setUp(self):
- super(type(self), self).setUp()
- httpretty.register_uri(httpretty.CONNECT, 'http://localhost:8080', **{
- 'compression-count': 12
- })
-
- def test_should_issue_request_with_proxy_authorization(self):
- raise SkipTest('https://github.com/gabrielfalcao/HTTPretty/issues/122')
- Client('key', None, 'http://user:pass@localhost:8080').request('GET', '/')
-
- self.assertEqual(self.request.headers['proxy-authorization'], 'Basic dXNlcjpwYXNz')
-
-class TinifyClientRequestWithTimeoutRepeatedly(TestHelper):
- @patch('requests.sessions.Session.request', RaiseException(requests.exceptions.Timeout))
- def test_should_raise_connection_error(self):
- with self.assertRaises(ConnectionError) as context:
- Client('key').request('GET', '/')
- self.assertEqual('Timeout while connecting', str(context.exception))
-
- @patch('requests.sessions.Session.request', RaiseException(requests.exceptions.Timeout))
- def test_should_raise_connection_error_with_cause(self):
- with self.assertRaises(ConnectionError) as context:
- Client('key').request('GET', '/')
- self.assertIsInstance(context.exception.__cause__, requests.exceptions.Timeout)
-
-class TinifyClientRequestWithTimeoutOnce(TestHelper):
- @patch('requests.sessions.Session.request')
- def test_should_issue_request(self, mock):
- mock.side_effect = RaiseException(requests.exceptions.Timeout, num=1)
- mock.return_value = requests.Response()
- mock.return_value.status_code = 201
- self.assertIsInstance(Client('key').request('GET', '/', {}), requests.Response)
-
-class TinifyClientRequestWithConnectionErrorRepeatedly(TestHelper):
- @patch('requests.sessions.Session.request', RaiseException(requests.exceptions.ConnectionError('connection error')))
- def test_should_raise_connection_error(self):
- with self.assertRaises(ConnectionError) as context:
- Client('key').request('GET', '/')
- self.assertEqual('Error while connecting: connection error', str(context.exception))
-
- @patch('requests.sessions.Session.request', RaiseException(requests.exceptions.ConnectionError('connection error')))
- def test_should_raise_connection_error_with_cause(self):
- with self.assertRaises(ConnectionError) as context:
- Client('key').request('GET', '/')
- self.assertIsInstance(context.exception.__cause__, requests.exceptions.ConnectionError)
-
-class TinifyClientRequestWithConnectionErrorOnce(TestHelper):
- @patch('requests.sessions.Session.request')
- def test_should_issue_request(self, mock):
- mock.side_effect = RaiseException(requests.exceptions.ConnectionError, num=1)
- mock.return_value = requests.Response()
- mock.return_value.status_code = 201
- self.assertIsInstance(Client('key').request('GET', '/', {}), requests.Response)
-
-class TinifyClientRequestWithSomeErrorRepeatedly(TestHelper):
- @patch('requests.sessions.Session.request', RaiseException(RuntimeError('some error')))
- def test_should_raise_connection_error(self):
- with self.assertRaises(ConnectionError) as context:
- Client('key').request('GET', '/')
- self.assertEqual('Error while connecting: some error', str(context.exception))
-
-class TinifyClientRequestWithSomeErrorOnce(TestHelper):
- @patch('requests.sessions.Session.request')
- def test_should_issue_request(self, mock):
- mock.side_effect = RaiseException(RuntimeError('some error'), num=1)
- mock.return_value = requests.Response()
- mock.return_value.status_code = 201
- self.assertIsInstance(Client('key').request('GET', '/', {}), requests.Response)
-
-class TinifyClientRequestWithServerErrorRepeatedly(TestHelper):
- def test_should_raise_server_error(self):
- httpretty.register_uri(httpretty.GET, 'https://api.tinify.com/', status=584,
- body='{"error":"InternalServerError","message":"Oops!"}')
-
- with self.assertRaises(ServerError) as context:
- Client('key').request('GET', '/')
- self.assertEqual('Oops! (HTTP 584/InternalServerError)', str(context.exception))
-
-class TinifyClientRequestWithServerErrorOnce(TestHelper):
- def test_should_issue_request(self):
- httpretty.register_uri(httpretty.GET, 'https://api.tinify.com/',
- responses=[
- httpretty.Response(body='{"error":"InternalServerError","message":"Oops!"}', status=584),
- httpretty.Response(body='all good', status=201),
- ])
-
- response = Client('key').request('GET', '/')
- self.assertEqual('201', str(response.status_code))
-
-class TinifyClientRequestWithBadServerResponseRepeatedly(TestHelper):
- def test_should_raise_server_error(self):
- httpretty.register_uri(httpretty.GET, 'https://api.tinify.com/', status=543,
- body='')
-
- with self.assertRaises(ServerError) as context:
- Client('key').request('GET', '/')
-
- msg = r'Error while parsing response: .* \(HTTP 543/ParseError\)'
- self.assertRegexpMatches(str(context.exception), msg)
-
-class TinifyClientRequestWithBadServerResponseOnce(TestHelper):
- def test_should_issue_request(self):
- httpretty.register_uri(httpretty.GET, 'https://api.tinify.com/',
- responses=[
- httpretty.Response(body='', status=543),
- httpretty.Response(body='all good', status=201),
- ])
-
- response = Client('key').request('GET', '/')
- self.assertEqual('201', str(response.status_code))
-
-class TinifyClientRequestWithClientError(TestHelper):
- def test_should_raise_client_error(self):
- httpretty.register_uri(httpretty.GET, 'https://api.tinify.com/', status=492,
- body='{"error":"BadRequest","message":"Oops!"}')
-
- with self.assertRaises(ClientError) as context:
- Client('key').request('GET', '/')
- self.assertEqual('Oops! (HTTP 492/BadRequest)', str(context.exception))
-
-class TinifyClientRequestWithBadCredentialsResponse(TestHelper):
- def test_should_raise_account_error(self):
- httpretty.register_uri(httpretty.GET, 'https://api.tinify.com/', status=401,
- body='{"error":"Unauthorized","message":"Oops!"}')
-
- with self.assertRaises(AccountError) as context:
- Client('key').request('GET', '/')
- self.assertEqual('Oops! (HTTP 401/Unauthorized)', str(context.exception))
diff --git a/test/tinify_result_meta_test.py b/test/tinify_result_meta_test.py
deleted file mode 100644
index 1bd02ec..0000000
--- a/test/tinify_result_meta_test.py
+++ /dev/null
@@ -1,38 +0,0 @@
-# -*- coding: utf-8 -*-
-from __future__ import absolute_import, division, print_function, unicode_literals
-
-from tinify import ResultMeta
-
-from helper import *
-
-class TinifyResultMetaWithMetaTest(TestHelper):
- def setUp(self):
- self.result = ResultMeta({
- 'Image-Width': '100',
- 'Image-Height': '60',
- 'Content-Length': '20',
- 'Content-Type': 'application/json',
- 'Location': 'https://bucket.s3-region.amazonaws.com/some/location'
- })
-
- def test_width_should_return_image_width(self):
- self.assertEqual(100, self.result.width)
-
- def test_height_should_return_image_height(self):
- self.assertEqual(60, self.result.height)
-
- def test_location_should_return_stored_location(self):
- self.assertEqual('https://bucket.s3-region.amazonaws.com/some/location', self.result.location)
-
-class TinifyResultMetaWithoutMetaTest(TestHelper):
- def setUp(self):
- self.result = ResultMeta({})
-
- def test_width_should_return_none(self):
- self.assertEqual(None, self.result.width)
-
- def test_height_should_return_none(self):
- self.assertEqual(None, self.result.height)
-
- def test_location_should_return_none(self):
- self.assertEqual(None, self.result.location)
diff --git a/test/tinify_result_test.py b/test/tinify_result_test.py
deleted file mode 100644
index 5e29dd2..0000000
--- a/test/tinify_result_test.py
+++ /dev/null
@@ -1,61 +0,0 @@
-# -*- coding: utf-8 -*-
-from __future__ import absolute_import, division, print_function, unicode_literals
-
-from tinify import Result
-
-from helper import *
-
-class TinifyResultWithMetaAndDataTest(TestHelper):
- def setUp(self):
- self.result = Result({
- 'Image-Width': '100',
- 'Image-Height': '60',
- 'Content-Length': '450',
- 'Content-Type': 'image/png',
- }, b'image data')
-
- def test_width_should_return_image_width(self):
- self.assertEqual(100, self.result.width)
-
- def test_height_should_return_image_height(self):
- self.assertEqual(60, self.result.height)
-
- def test_location_should_return_none(self):
- self.assertEqual(None, self.result.location)
-
- def test_size_should_return_content_length(self):
- self.assertEqual(450, self.result.size)
-
- def test_len_builtin_should_return_content_length(self):
- self.assertEqual(450, len(self.result))
-
- def test_content_type_should_return_mime_type(self):
- self.assertEqual('image/png', self.result.content_type)
-
- def test_to_buffer_should_return_image_data(self):
- self.assertEqual(b'image data', self.result.to_buffer())
-
-class TinifyResultWithoutMetaAndDataTest(TestHelper):
- def setUp(self):
- self.result = Result({}, None)
-
- def test_width_should_return_none(self):
- self.assertEqual(None, self.result.width)
-
- def test_height_should_return_none(self):
- self.assertEqual(None, self.result.height)
-
- def test_location_should_return_none(self):
- self.assertEqual(None, self.result.location)
-
- def test_size_should_return_none(self):
- self.assertEqual(None, self.result.size)
-
- def test_len_builtin_should_return_zero(self):
- self.assertEqual(0, len(self.result))
-
- def test_content_type_should_return_none(self):
- self.assertEqual(None, self.result.content_type)
-
- def test_to_buffer_should_return_none(self):
- self.assertEqual(None, self.result.to_buffer())
diff --git a/test/tinify_source_test.py b/test/tinify_source_test.py
deleted file mode 100644
index 1834d3b..0000000
--- a/test/tinify_source_test.py
+++ /dev/null
@@ -1,152 +0,0 @@
-# -*- coding: utf-8 -*-
-from __future__ import absolute_import, division, print_function, unicode_literals
-
-import os
-import json
-import tempfile
-
-import tinify
-from tinify import Source, Result, ResultMeta, AccountError, ClientError
-
-from helper import *
-
-class TinifySourceWithInvalidApiKey(TestHelper):
- def setUp(self):
- super(type(self), self).setUp()
- tinify.key = 'invalid'
- httpretty.register_uri(httpretty.POST, 'https://api.tinify.com/shrink', **{
- 'status': 401
- })
-
- def test_from_file_should_raise_account_error(self):
- with self.assertRaises(AccountError):
- Source.from_file(dummy_file)
-
- def test_from_buffer_should_raise_account_error(self):
- with self.assertRaises(AccountError):
- Source.from_buffer('png file')
-
- def test_from_url_should_raise_account_error(self):
- with self.assertRaises(AccountError):
- Source.from_url('http://example.com/test.jpg')
-
-class TinifySourceWithValidApiKey(TestHelper):
- def setUp(self):
- super(type(self), self).setUp()
- tinify.key = 'valid'
- httpretty.register_uri(httpretty.POST, 'https://api.tinify.com/shrink', **{
- 'status': 201,
- 'location': 'https://api.tinify.com/some/location'
- })
- httpretty.register_uri(httpretty.GET, 'https://api.tinify.com/some/location', body=self.return_file)
- httpretty.register_uri(httpretty.POST, 'https://api.tinify.com/some/location', body=self.return_file)
-
- @staticmethod
- def return_file(request, uri, headers):
- if request.body:
- data = json.loads(request.body.decode('utf-8'))
- else:
- data = {}
- response = None
- if 'store' in data:
- headers['location'] = 'https://bucket.s3-region.amazonaws.com/some/location'
- response = json.dumps({'status': 'success'}).encode('utf-8')
- elif 'preserve' in data:
- response = b'copyrighted file'
- elif 'resize' in data:
- response = b'small file'
- else:
- response = b'compressed file'
- return (200, headers, response)
-
- def test_from_file_with_path_should_return_source(self):
- self.assertIsInstance(Source.from_file(dummy_file), Source)
-
- def test_from_file_with_path_should_return_source_with_data(self):
- self.assertEqual(b'compressed file', Source.from_file(dummy_file).to_buffer())
-
- def test_from_file_with_file_object_should_return_source(self):
- with open(dummy_file, 'rb') as f:
- self.assertIsInstance(Source.from_file(f), Source)
-
- def test_from_file_with_file_object_should_return_source_with_data(self):
- with open(dummy_file, 'rb') as f:
- self.assertEqual(b'compressed file', Source.from_file(f).to_buffer())
-
- def test_from_buffer_should_return_source(self):
- self.assertIsInstance(Source.from_buffer('png file'), Source)
-
- def test_from_buffer_should_return_source_with_data(self):
- self.assertEqual(b'compressed file', Source.from_buffer('png file').to_buffer())
-
- def test_from_url_should_return_source(self):
- self.assertIsInstance(Source.from_url('http://example.com/test.jpg'), Source)
-
- def test_from_url_should_return_source_with_data(self):
- self.assertEqual(b'compressed file', Source.from_url('http://example.com/test.jpg').to_buffer())
-
- def test_from_url_should_raise_error_when_server_doesnt_return_a_success(self):
- httpretty.register_uri(httpretty.POST, 'https://api.tinify.com/shrink',
- body='{"error":"Source not found","message":"Cannot parse URL"}',
- status=400,
- )
- with self.assertRaises(ClientError):
- Source.from_url('file://wrong')
-
- def test_result_should_return_result(self):
- self.assertIsInstance(Source.from_buffer('png file').result(), Result)
-
- def test_preserve_should_return_source(self):
- self.assertIsInstance(Source.from_buffer('png file').preserve("copyright", "location"), Source)
- self.assertEqual(b'png file', httpretty.last_request().body)
-
- def test_preserve_should_return_source_with_data(self):
- self.assertEqual(b'copyrighted file', Source.from_buffer('png file').preserve("copyright", "location").to_buffer())
- self.assertJsonEqual('{"preserve":["copyright","location"]}', httpretty.last_request().body.decode('utf-8'))
-
- def test_preserve_should_return_source_with_data_for_array(self):
- self.assertEqual(b'copyrighted file', Source.from_buffer('png file').preserve(["copyright", "location"]).to_buffer())
- self.assertJsonEqual('{"preserve":["copyright","location"]}', httpretty.last_request().body.decode('utf-8'))
-
- def test_preserve_should_return_source_with_data_for_tuple(self):
- self.assertEqual(b'copyrighted file', Source.from_buffer('png file').preserve(("copyright", "location")).to_buffer())
- self.assertJsonEqual('{"preserve":["copyright","location"]}', httpretty.last_request().body.decode('utf-8'))
-
- def test_preserve_should_include_other_options_if_set(self):
- self.assertEqual(b'copyrighted file', Source.from_buffer('png file').resize(width=400).preserve("copyright", "location").to_buffer())
- self.assertJsonEqual('{"preserve":["copyright","location"],"resize":{"width":400}}', httpretty.last_request().body.decode('utf-8'))
-
- def test_resize_should_return_source(self):
- self.assertIsInstance(Source.from_buffer('png file').resize(width=400), Source)
- self.assertEqual(b'png file', httpretty.last_request().body)
-
- def test_resize_should_return_source_with_data(self):
- self.assertEqual(b'small file', Source.from_buffer('png file').resize(width=400).to_buffer())
- self.assertJsonEqual('{"resize":{"width":400}}', httpretty.last_request().body.decode('utf-8'))
-
- def test_store_should_return_result_meta(self):
- self.assertIsInstance(Source.from_buffer('png file').store(service='s3'), ResultMeta)
- self.assertJsonEqual('{"store":{"service":"s3"}}', httpretty.last_request().body.decode('utf-8'))
-
- def test_store_should_return_result_meta_with_location(self):
- self.assertEqual('https://bucket.s3-region.amazonaws.com/some/location',
- Source.from_buffer('png file').store(service='s3').location)
- self.assertJsonEqual('{"store":{"service":"s3"}}', httpretty.last_request().body.decode('utf-8'))
-
- def test_store_should_include_other_options_if_set(self):
- self.assertEqual('https://bucket.s3-region.amazonaws.com/some/location', Source.from_buffer('png file').resize(width=400).store(service='s3').location)
- self.assertJsonEqual('{"store":{"service":"s3"},"resize":{"width":400}}', httpretty.last_request().body.decode('utf-8'))
-
- def test_to_buffer_should_return_image_data(self):
- self.assertEqual(b'compressed file', Source.from_buffer('png file').to_buffer())
-
- def test_to_file_with_path_should_store_image_data(self):
- with tempfile.TemporaryFile() as tmp:
- Source.from_buffer('png file').to_file(tmp)
- tmp.seek(0)
- self.assertEqual(b'compressed file', tmp.read())
-
- def test_to_file_with_file_object_should_store_image_data(self):
- with tempfile.NamedTemporaryFile() as tmp:
- Source.from_buffer('png file').to_file(tmp.name)
- self.assertEqual(b'compressed file', tmp.read())
diff --git a/test/tinify_test.py b/test/tinify_test.py
deleted file mode 100644
index 8cebab3..0000000
--- a/test/tinify_test.py
+++ /dev/null
@@ -1,94 +0,0 @@
-# -*- coding: utf-8 -*-
-from __future__ import absolute_import, division, print_function, unicode_literals
-
-from base64 import b64encode
-
-import tinify
-from helper import *
-
-class TinifyKey(TestHelper):
- def test_should_reset_client_with_new_key(self):
- httpretty.register_uri(httpretty.GET, 'https://api.tinify.com/')
- tinify.key = 'abcde'
- tinify.get_client()
- tinify.key = 'fghij'
- tinify.get_client().request('GET', '/')
- self.assertEqual(self.request.headers['authorization'], 'Basic {0}'.format(
- b64encode(b'api:fghij').decode('ascii')))
-
-class TinifyAppIdentifier(TestHelper):
- def test_should_reset_client_with_new_app_identifier(self):
- httpretty.register_uri(httpretty.GET, 'https://api.tinify.com/')
- tinify.key = 'abcde'
- tinify.app_identifier = 'MyApp/1.0'
- tinify.get_client()
- tinify.app_identifier = 'MyApp/2.0'
- tinify.get_client().request('GET', '/')
- self.assertEqual(self.request.headers['user-agent'], tinify.Client.USER_AGENT + " MyApp/2.0")
-
-class TinifyProxy(TestHelper):
- def test_should_reset_client_with_new_proxy(self):
- httpretty.register_uri(httpretty.CONNECT, 'http://localhost:8080')
- tinify.key = 'abcde'
- tinify.proxy = 'http://localhost:8080'
- tinify.get_client()
- tinify.proxy = 'http://localhost:8080'
- raise SkipTest('https://github.com/gabrielfalcao/HTTPretty/issues/122')
- tinify.get_client().request('GET', '/')
- self.assertEqual(self.request.headers['user-agent'], tinify.Client.USER_AGENT + " MyApp/2.0")
-
-class TinifyClient(TestHelper):
- def test_with_key_should_return_client(self):
- tinify.key = 'abcde'
- self.assertIsInstance(tinify.get_client(), tinify.Client)
-
- def test_without_key_should_raise_error(self):
- with self.assertRaises(tinify.AccountError):
- tinify.get_client()
-
- def test_with_invalid_proxy_should_raise_error(self):
- with self.assertRaises(tinify.ConnectionError):
- tinify.key = 'abcde'
- tinify.proxy = 'http-bad-url'
- tinify.get_client().request('GET', '/')
-
-class TinifyValidate(TestHelper):
- def test_with_valid_key_should_return_true(self):
- httpretty.register_uri(httpretty.POST, 'https://api.tinify.com/shrink', status=400,
- body='{"error":"Input missing","message":"No input"}')
- tinify.key = 'valid'
- self.assertEqual(True, tinify.validate())
-
- def test_with_limited_key_should_return_true(self):
- httpretty.register_uri(httpretty.POST, 'https://api.tinify.com/shrink', status=429,
- body='{"error":"Too many requests","message":"Your monthly limit has been exceeded"}')
- tinify.key = 'valid'
- self.assertEqual(True, tinify.validate())
-
- def test_with_error_should_raise_error(self):
- httpretty.register_uri(httpretty.POST, 'https://api.tinify.com/shrink', status=401,
- body='{"error":"Unauthorized","message":"Credentials are invalid"}')
- tinify.key = 'valid'
- with self.assertRaises(tinify.AccountError):
- tinify.validate()
-
-class TinifyFromFile(TestHelper):
- def test_should_return_source(self):
- httpretty.register_uri(httpretty.POST, 'https://api.tinify.com/shrink',
- location='https://api.tinify.com/some/location')
- tinify.key = 'valid'
- self.assertIsInstance(tinify.from_file(dummy_file), tinify.Source)
-
-class TinifyFromBuffer(TestHelper):
- def test_should_return_source(self):
- httpretty.register_uri(httpretty.POST, 'https://api.tinify.com/shrink',
- location='https://api.tinify.com/some/location')
- tinify.key = 'valid'
- self.assertIsInstance(tinify.from_buffer('png file'), tinify.Source)
-
-class TinifyFromUrl(TestHelper):
- def test_should_return_source(self):
- httpretty.register_uri(httpretty.POST, 'https://api.tinify.com/shrink',
- location='https://api.tinify.com/some/location')
- tinify.key = 'valid'
- self.assertIsInstance(tinify.from_url('http://example.com/test.jpg'), tinify.Source)
diff --git a/test/unit/__init__.py b/test/unit/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/test/unit/conftest.py b/test/unit/conftest.py
new file mode 100644
index 0000000..783951b
--- /dev/null
+++ b/test/unit/conftest.py
@@ -0,0 +1,32 @@
+import pytest
+import os
+import tinify
+import requests_mock
+
+
+@pytest.fixture
+def dummy_file():
+ return os.path.join(os.path.dirname(__file__), "..", "examples", "dummy.png")
+
+
+@pytest.fixture(autouse=True)
+def reset_tinify():
+ original_key = tinify.key
+ original_app_identifier = tinify.app_identifier
+ original_proxy = tinify.proxy
+
+ tinify.key = None
+ tinify.app_identifier = None
+ tinify.proxy = None
+
+ yield
+
+ tinify.key = original_key
+ tinify.app_identifier = original_app_identifier
+ tinify.proxy = original_proxy
+
+
+@pytest.fixture
+def mock_requests():
+ with requests_mock.Mocker(real_http=False) as m:
+ yield m
diff --git a/test/unit/tinify_client_test.py b/test/unit/tinify_client_test.py
new file mode 100644
index 0000000..9223003
--- /dev/null
+++ b/test/unit/tinify_client_test.py
@@ -0,0 +1,292 @@
+import pytest
+import requests
+import json
+import base64
+import tinify
+from tinify import Client, ClientError, ServerError, ConnectionError, AccountError
+
+Client.RETRY_DELAY = 10
+
+
+def b64encode(data):
+ return base64.b64encode(data)
+
+
+@pytest.fixture
+def client():
+ return Client("key")
+
+
+class TestClientRequestWhenValid:
+ def test_should_issue_request(self, mock_requests, client):
+ mock_requests.get(
+ "https://api.tinify.com/", headers={"compression-count": "12"}
+ )
+
+ client.request("GET", "/")
+
+ request = mock_requests.last_request
+ auth_header = "Basic {0}".format(b64encode(b"api:key").decode("ascii"))
+ assert request.headers["authorization"] == auth_header
+
+ def test_should_issue_request_without_body_when_options_are_empty(
+ self, mock_requests, client
+ ):
+ mock_requests.get(
+ "https://api.tinify.com/", headers={"compression-count": "12"}
+ )
+
+ client.request("GET", "/", {})
+
+ request = mock_requests.last_request
+ assert not request.text or request.text == ""
+
+ def test_should_issue_request_without_content_type_when_options_are_empty(
+ self, mock_requests, client
+ ):
+ mock_requests.get(
+ "https://api.tinify.com/", headers={"compression-count": "12"}
+ )
+
+ client.request("GET", "/", {})
+
+ request = mock_requests.last_request
+ assert "content-type" not in request.headers
+
+ def test_should_issue_request_with_json_body(self, mock_requests, client):
+ mock_requests.get(
+ "https://api.tinify.com/", headers={"compression-count": "12"}
+ )
+
+ client.request("GET", "/", {"hello": "world"})
+
+ request = mock_requests.last_request
+ assert request.headers["content-type"] == "application/json"
+ assert request.text == '{"hello":"world"}'
+
+ def test_should_issue_request_with_user_agent(self, mock_requests, client):
+ mock_requests.get(
+ "https://api.tinify.com/", headers={"compression-count": "12"}
+ )
+
+ client.request("GET", "/")
+
+ request = mock_requests.last_request
+ assert request.headers["user-agent"] == Client.USER_AGENT
+
+ def test_should_update_compression_count(self, mock_requests, client):
+ mock_requests.get(
+ "https://api.tinify.com/", headers={"compression-count": "12"}
+ )
+
+ client.request("GET", "/")
+
+ assert tinify.compression_count == 12
+
+
+class TestClientRequestWhenValidWithAppId:
+ def test_should_issue_request_with_user_agent(self, mock_requests):
+ mock_requests.get(
+ "https://api.tinify.com/", headers={"compression-count": "12"}
+ )
+
+ Client("key", "TestApp/0.2").request("GET", "/")
+
+ request = mock_requests.last_request
+ assert request.headers["user-agent"] == Client.USER_AGENT + " TestApp/0.2"
+
+
+class TestClientRequestWhenValidWithProxy:
+ @pytest.mark.skip(
+ reason="requests does not set a proxy unless a real proxy is used"
+ )
+ def test_should_issue_request_with_proxy_authorization(self, mock_requests):
+ proxy_url = "http://user:pass@localhost:8080"
+ expected_auth = "Basic " + base64.b64encode(b"user:pass").decode()
+
+ mock_requests.get("https://api.tinify.com/", status_code=200)
+
+ client = Client("key", None, proxy_url)
+ client.request("GET", "/")
+
+ # Verify the last request captured by requests-mock
+ last_request = mock_requests.last_request
+ assert last_request is not None
+ assert last_request.headers.get("Proxy-Authorization") == expected_auth
+
+
+class TestClientRequestWithTimeout:
+ def test_should_raise_connection_error_repeatedly(self, mock_requests):
+ mock_requests.get(
+ "https://api.tinify.com/",
+ [
+ {"exc": requests.exceptions.Timeout},
+ ],
+ )
+ with pytest.raises(ConnectionError) as excinfo:
+ Client("key").request("GET", "/")
+ assert str(excinfo.value) == "Timeout while connecting"
+ assert isinstance(excinfo.value.__cause__, requests.exceptions.Timeout)
+
+ def test_should_issue_request_after_timeout_once(self, mock_requests):
+ # Confirm retry happens after timeout
+ mock_requests.get(
+ "https://api.tinify.com/",
+ [
+ {"exc": requests.exceptions.Timeout("Timeout")},
+ {
+ "status_code": 201,
+ "headers": {"compression-count": "12"},
+ "text": "success",
+ },
+ ],
+ )
+
+ result = Client("key").request("GET", "/", {})
+
+ assert result.status_code == 201
+ assert mock_requests.call_count == 2 # Verify retry happened
+
+
+class TestClientRequestWithConnectionError:
+ def test_should_raise_connection_error_repeatedly(self, mock_requests):
+ mock_requests.get(
+ "https://api.tinify.com/",
+ [
+ {"exc": requests.exceptions.ConnectionError("connection error")},
+ ],
+ )
+ with pytest.raises(ConnectionError) as excinfo:
+ Client("key").request("GET", "/")
+ assert str(excinfo.value) == "Error while connecting: connection error"
+ assert isinstance(excinfo.value.__cause__, requests.exceptions.ConnectionError)
+
+ def test_should_issue_request_after_connection_error_once(self, mock_requests):
+ # Mock the request to fail with ConnectionError once, then succeed
+ mock_requests.get(
+ "https://api.tinify.com/",
+ [
+ {"exc": requests.exceptions.ConnectionError}, # First attempt fails
+ {
+ "status_code": 201,
+ "headers": {"compression-count": "12"},
+ "text": "success",
+ }, # Second attempt succeeds
+ ],
+ )
+
+ client = Client("key")
+ result = client.request("GET", "/", {})
+
+ # Verify results
+ assert result.status_code == 201
+ assert mock_requests.call_count == 2 # Ensure it retried
+
+
+class TestClientRequestWithSomeError:
+ def test_should_raise_connection_error_repeatedly(self, mock_requests):
+ mock_requests.get(
+ "https://api.tinify.com/",
+ [
+ {"exc": RuntimeError("some error")},
+ ],
+ )
+ with pytest.raises(ConnectionError) as excinfo:
+ Client("key").request("GET", "/")
+ assert str(excinfo.value) == "Error while connecting: some error"
+
+ def test_should_issue_request_after_some_error_once(self, mock_requests):
+ # Mock the request to fail with RuntimeError once, then succeed
+ mock_requests.get(
+ "https://api.tinify.com/",
+ [
+ {"exc": RuntimeError("some error")}, # First attempt fails
+ {
+ "status_code": 201,
+ "headers": {"compression-count": "12"},
+ "text": "success",
+ }, # Second attempt succeeds
+ ],
+ )
+
+ client = Client("key")
+ result = client.request("GET", "/", {})
+
+ # Verify results
+ assert result.status_code == 201
+ assert mock_requests.call_count == 2 # Ensure it retried
+
+
+class TestClientRequestWithServerError:
+ def test_should_raise_server_error_repeatedly(self, mock_requests):
+ error_body = json.dumps({"error": "InternalServerError", "message": "Oops!"})
+ mock_requests.get("https://api.tinify.com/", status_code=584, text=error_body)
+
+ with pytest.raises(ServerError) as excinfo:
+ Client("key").request("GET", "/")
+ assert str(excinfo.value) == "Oops! (HTTP 584/InternalServerError)"
+
+ def test_should_issue_request_after_server_error_once(self, mock_requests):
+ error_body = json.dumps({"error": "InternalServerError", "message": "Oops!"})
+ # First call returns error, second succeeds
+ mock_requests.register_uri(
+ "GET",
+ "https://api.tinify.com/",
+ [
+ {"status_code": 584, "text": error_body},
+ {"status_code": 201, "text": "all good"},
+ ],
+ )
+
+ response = Client("key").request("GET", "/")
+
+ assert response.status_code == 201
+
+
+class TestClientRequestWithBadServerResponse:
+ def test_should_raise_server_error_repeatedly(self, mock_requests):
+ mock_requests.get(
+ "https://api.tinify.com/", status_code=543, text=""
+ )
+
+ with pytest.raises(ServerError) as excinfo:
+ Client("key").request("GET", "/")
+ # Using pytest's assert to check regex pattern
+ error_message = str(excinfo.value)
+ assert "Error while parsing response:" in error_message
+ assert "(HTTP 543/ParseError)" in error_message
+
+ def test_should_issue_request_after_bad_response_once(self, mock_requests):
+ # First call returns invalid JSON, second succeeds
+ mock_requests.register_uri(
+ "GET",
+ "https://api.tinify.com/",
+ [
+ {"status_code": 543, "text": ""},
+ {"status_code": 201, "text": "all good"},
+ ],
+ )
+
+ response = Client("key").request("GET", "/")
+
+ assert response.status_code == 201
+
+
+class TestClientRequestWithClientError:
+ def test_should_raise_client_error(self, mock_requests):
+ error_body = json.dumps({"error": "BadRequest", "message": "Oops!"})
+ mock_requests.get("https://api.tinify.com/", status_code=492, text=error_body)
+
+ with pytest.raises(ClientError) as excinfo:
+ Client("key").request("GET", "/")
+ assert str(excinfo.value) == "Oops! (HTTP 492/BadRequest)"
+
+
+class TestClientRequestWithBadCredentialsResponse:
+ def test_should_raise_account_error(self, mock_requests):
+ error_body = json.dumps({"error": "Unauthorized", "message": "Oops!"})
+ mock_requests.get("https://api.tinify.com/", status_code=401, text=error_body)
+
+ with pytest.raises(AccountError) as excinfo:
+ Client("key").request("GET", "/")
+ assert str(excinfo.value) == "Oops! (HTTP 401/Unauthorized)"
diff --git a/test/unit/tinify_result_meta_test.py b/test/unit/tinify_result_meta_test.py
new file mode 100644
index 0000000..22c28b3
--- /dev/null
+++ b/test/unit/tinify_result_meta_test.py
@@ -0,0 +1,52 @@
+# -*- coding: utf-8 -*-
+import pytest
+from tinify import ResultMeta
+
+
+@pytest.fixture
+def result_with_meta():
+ """Fixture that returns a ResultMeta instance with metadata"""
+ return ResultMeta(
+ {
+ "Image-Width": "100",
+ "Image-Height": "60",
+ "Content-Length": "20",
+ "Content-Type": "application/json",
+ "Location": "https://bucket.s3-region.amazonaws.com/some/location",
+ }
+ )
+
+
+@pytest.fixture
+def result_without_meta():
+ """Fixture that returns a ResultMeta instance without metadata"""
+ return ResultMeta({})
+
+
+# Tests for ResultMeta with metadata
+def test_width_should_return_image_width(result_with_meta):
+ assert 100 == result_with_meta.width
+
+
+def test_height_should_return_image_height(result_with_meta):
+ assert 60 == result_with_meta.height
+
+
+def test_location_should_return_stored_location(result_with_meta):
+ assert (
+ "https://bucket.s3-region.amazonaws.com/some/location"
+ == result_with_meta.location
+ )
+
+
+# Tests for ResultMeta without metadata
+def test_width_should_return_none_when_no_meta(result_without_meta):
+ assert None is result_without_meta.width
+
+
+def test_height_should_return_none_when_no_meta(result_without_meta):
+ assert None is result_without_meta.height
+
+
+def test_location_should_return_none_when_no_meta(result_without_meta):
+ assert None is result_without_meta.location
diff --git a/test/unit/tinify_result_test.py b/test/unit/tinify_result_test.py
new file mode 100644
index 0000000..2b2f8f2
--- /dev/null
+++ b/test/unit/tinify_result_test.py
@@ -0,0 +1,75 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import, division, print_function, unicode_literals
+
+import pytest
+from tinify import Result
+
+
+@pytest.fixture
+def result_with_meta_and_data():
+ return Result(
+ {
+ "Image-Width": "100",
+ "Image-Height": "60",
+ "Content-Length": "450",
+ "Content-Type": "image/png",
+ },
+ b"image data",
+ )
+
+
+@pytest.fixture
+def result_without_meta_and_data():
+ return Result({}, None)
+
+
+class TestTinifyResultWithMetaAndData:
+ def test_width_should_return_image_width(self, result_with_meta_and_data):
+ assert 100 == result_with_meta_and_data.width
+
+ def test_height_should_return_image_height(self, result_with_meta_and_data):
+ assert 60 == result_with_meta_and_data.height
+
+ def test_location_should_return_none(self, result_with_meta_and_data):
+ assert None is result_with_meta_and_data.location
+
+ def test_size_should_return_content_length(self, result_with_meta_and_data):
+ assert 450 == result_with_meta_and_data.size
+
+ def test_len_builtin_should_return_content_length(self, result_with_meta_and_data):
+ assert 450 == len(result_with_meta_and_data)
+
+ def test_content_type_should_return_mime_type(self, result_with_meta_and_data):
+ assert "image/png" == result_with_meta_and_data.content_type
+
+ def test_to_buffer_should_return_image_data(self, result_with_meta_and_data):
+ assert b"image data" == result_with_meta_and_data.to_buffer()
+
+ def test_extension(self, result_with_meta_and_data):
+ assert "png" == result_with_meta_and_data.extension
+
+
+class TestTinifyResultWithoutMetaAndData:
+ def test_width_should_return_none(self, result_without_meta_and_data):
+ assert None is result_without_meta_and_data.width
+
+ def test_height_should_return_none(self, result_without_meta_and_data):
+ assert None is result_without_meta_and_data.height
+
+ def test_location_should_return_none(self, result_without_meta_and_data):
+ assert None is result_without_meta_and_data.location
+
+ def test_size_should_return_none(self, result_without_meta_and_data):
+ assert None is result_without_meta_and_data.size
+
+ def test_len_builtin_should_return_zero(self, result_without_meta_and_data):
+ assert 0 == len(result_without_meta_and_data)
+
+ def test_content_type_should_return_none(self, result_without_meta_and_data):
+ assert None is result_without_meta_and_data.content_type
+
+ def test_to_buffer_should_return_none(self, result_without_meta_and_data):
+ assert None is result_without_meta_and_data.to_buffer()
+
+ def test_extension(self, result_without_meta_and_data):
+ assert None is result_without_meta_and_data.extension
diff --git a/test/unit/tinify_source_test.py b/test/unit/tinify_source_test.py
new file mode 100644
index 0000000..9415edd
--- /dev/null
+++ b/test/unit/tinify_source_test.py
@@ -0,0 +1,305 @@
+# -*- coding: utf-8 -*-
+import os
+import json
+import tempfile
+import pytest
+
+import tinify
+from tinify import Source, Result, ResultMeta, AccountError, ClientError
+
+
+def create_named_tmpfile():
+ """Helper to create a named temporary file"""
+ fd, name = tempfile.mkstemp()
+ os.close(fd)
+ return name
+
+
+def assert_json_equal(expected, actual):
+ """Helper to assert JSON equality"""
+ if isinstance(actual, str):
+ actual = json.loads(actual)
+ if isinstance(expected, str):
+ expected = json.loads(expected)
+ assert expected == actual
+
+
+class TestTinifySourceWithInvalidApiKey:
+ @pytest.fixture(autouse=True)
+ def setup(self, mock_requests):
+ tinify.key = "invalid"
+ mock_requests.post("https://api.tinify.com/shrink", status_code=401)
+ yield
+
+ def test_from_file_should_raise_account_error(self, dummy_file):
+ with pytest.raises(AccountError):
+ Source.from_file(dummy_file)
+
+ def test_from_buffer_should_raise_account_error(self):
+ with pytest.raises(AccountError):
+ Source.from_buffer("png file")
+
+ def test_from_url_should_raise_account_error(self):
+ with pytest.raises(AccountError):
+ Source.from_url("http://example.com/test.jpg")
+
+
+class TestTinifySourceWithValidApiKey:
+ @pytest.fixture(autouse=True)
+ def setup_teardown(self, mock_requests):
+ tinify.key = "valid"
+ mock_requests.post(
+ "https://api.tinify.com/shrink",
+ status_code=201,
+ headers={"location": "https://api.tinify.com/some/location"},
+ )
+ mock_requests.get(
+ "https://api.tinify.com/some/location", content=self.return_file
+ )
+ mock_requests.post(
+ "https://api.tinify.com/some/location", content=self.return_file
+ )
+ yield
+
+ def return_file(self, request, context):
+ data = request.json() if request.body else {}
+ if "store" in data:
+ context.headers["location"] = (
+ "https://bucket.s3-region.amazonaws.com/some/location"
+ )
+ return json.dumps({"status": "success"}).encode("utf-8")
+ elif "preserve" in data:
+ return b"copyrighted file"
+ elif "resize" in data:
+ return b"small file"
+ elif "convert" in data:
+ return b"converted file"
+ elif "transform" in data:
+ return b"transformed file"
+ else:
+ return b"compressed file"
+
+ def test_from_file_with_path_should_return_source(self, dummy_file):
+ assert isinstance(Source.from_file(dummy_file), Source)
+
+ def test_from_file_with_path_should_return_source_with_data(self, dummy_file):
+ assert b"compressed file" == Source.from_file(dummy_file).to_buffer()
+
+ def test_from_file_with_file_object_should_return_source(self, dummy_file):
+ with open(dummy_file, "rb") as f:
+ assert isinstance(Source.from_file(f), Source)
+
+ def test_from_file_with_file_object_should_return_source_with_data(
+ self, dummy_file
+ ):
+ with open(dummy_file, "rb") as f:
+ assert b"compressed file" == Source.from_file(f).to_buffer()
+
+ def test_from_buffer_should_return_source(self):
+ assert isinstance(Source.from_buffer("png file"), Source)
+
+ def test_from_buffer_should_return_source_with_data(self):
+ assert b"compressed file" == Source.from_buffer("png file").to_buffer()
+
+ def test_from_url_should_return_source(self):
+ assert isinstance(Source.from_url("http://example.com/test.jpg"), Source)
+
+ def test_from_url_should_return_source_with_data(self):
+ assert (
+ b"compressed file"
+ == Source.from_url("http://example.com/test.jpg").to_buffer()
+ )
+
+ def test_from_url_should_raise_error_when_server_doesnt_return_a_success(
+ self, mock_requests
+ ):
+ mock_requests.post(
+ "https://api.tinify.com/shrink",
+ json={"error": "Source not found", "message": "Cannot parse URL"},
+ status_code=400,
+ )
+ with pytest.raises(ClientError):
+ Source.from_url("file://wrong")
+
+ def test_result_should_return_result(self):
+ assert isinstance(Source.from_buffer(b"png file").result(), Result)
+
+ def test_result_should_use_get_when_commands_is_empty(self, mock_requests):
+ source = Source(b"png file")
+ source.url = "https://api.tinify.com/some/location"
+ mock_requests.get(
+ "https://api.tinify.com/some/location", content=b"compressed file"
+ )
+ source.result()
+ assert mock_requests.call_count == 1
+ assert mock_requests.last_request.method == "GET"
+
+ def test_result_should_use_post_when_commands_is_not_empty(self, mock_requests):
+ source = Source(b"png file").resize(width=400)
+ source.url = "https://api.tinify.com/some/location"
+ mock_requests.post(
+ "https://api.tinify.com/some/location", content=b"small file"
+ )
+ source.result()
+ assert mock_requests.call_count == 1
+ assert mock_requests.last_request.method == "POST"
+
+ def test_preserve_should_return_source(self, mock_requests):
+ assert isinstance(
+ Source.from_buffer(b"png file").preserve("copyright", "location"), Source
+ )
+ assert b"png file" == mock_requests.last_request.body
+
+ def test_preserve_should_return_source_with_data(self, mock_requests):
+ assert (
+ b"copyrighted file"
+ == Source.from_buffer(b"png file")
+ .preserve("copyright", "location")
+ .to_buffer()
+ )
+ assert_json_equal(
+ '{"preserve":["copyright","location"]}', mock_requests.last_request.json()
+ )
+
+ def test_preserve_should_return_source_with_data_for_array(self, mock_requests):
+ assert (
+ b"copyrighted file"
+ == Source.from_buffer(b"png file")
+ .preserve(["copyright", "location"])
+ .to_buffer()
+ )
+ assert_json_equal(
+ '{"preserve":["copyright","location"]}', mock_requests.last_request.json()
+ )
+
+ def test_preserve_should_return_source_with_data_for_tuple(self, mock_requests):
+ assert (
+ b"copyrighted file"
+ == Source.from_buffer(b"png file")
+ .preserve(("copyright", "location"))
+ .to_buffer()
+ )
+ assert_json_equal(
+ '{"preserve":["copyright","location"]}', mock_requests.last_request.json()
+ )
+
+ def test_preserve_should_include_other_options_if_set(self, mock_requests):
+ assert (
+ b"copyrighted file"
+ == Source.from_buffer(b"png file")
+ .resize(width=400)
+ .preserve("copyright", "location")
+ .to_buffer()
+ )
+ assert_json_equal(
+ '{"preserve":["copyright","location"],"resize":{"width":400}}',
+ mock_requests.last_request.json(),
+ )
+
+ def test_resize_should_return_source(self, mock_requests):
+ assert isinstance(Source.from_buffer(b"png file").resize(width=400), Source)
+ assert b"png file" == mock_requests.last_request.body
+
+ def test_resize_should_return_source_with_data(self, mock_requests):
+ assert (
+ b"small file"
+ == Source.from_buffer(b"png file").resize(width=400).to_buffer()
+ )
+ assert_json_equal('{"resize":{"width":400}}', mock_requests.last_request.json())
+
+ def test_transform_should_return_source(self, mock_requests):
+ assert isinstance(
+ Source.from_buffer(b"png file").transform(background="black"), Source
+ )
+ assert b"png file" == mock_requests.last_request.body
+
+ def test_transform_should_return_source_with_data(self, mock_requests):
+ assert (
+ b"transformed file"
+ == Source.from_buffer(b"png file").transform(background="black").to_buffer()
+ )
+ assert_json_equal(
+ '{"transform":{"background":"black"}}', mock_requests.last_request.json()
+ )
+
+ def test_convert_should_return_source(self, mock_requests):
+ assert isinstance(
+ Source.from_buffer(b"png file")
+ .resize(width=400)
+ .convert(type=["image/webp"]),
+ Source,
+ )
+ assert b"png file" == mock_requests.last_request.body
+
+ def test_convert_should_return_source_with_data(self, mock_requests):
+ assert (
+ b"converted file"
+ == Source.from_buffer(b"png file").convert(type="image/jpg").to_buffer()
+ )
+ assert_json_equal(
+ '{"convert": {"type": "image/jpg"}}', mock_requests.last_request.json()
+ )
+
+ def test_store_should_return_result_meta(self, mock_requests):
+ assert isinstance(
+ Source.from_buffer(b"png file").store(service="s3"), ResultMeta
+ )
+ assert_json_equal(
+ '{"store":{"service":"s3"}}', mock_requests.last_request.json()
+ )
+
+ def test_store_should_return_result_meta_with_location(self, mock_requests):
+ assert (
+ "https://bucket.s3-region.amazonaws.com/some/location"
+ == Source.from_buffer(b"png file").store(service="s3").location
+ )
+ assert_json_equal(
+ '{"store":{"service":"s3"}}', mock_requests.last_request.json()
+ )
+
+ def test_store_should_include_other_options_if_set(self, mock_requests):
+ assert (
+ "https://bucket.s3-region.amazonaws.com/some/location"
+ == Source.from_buffer(b"png file")
+ .resize(width=400)
+ .store(service="s3")
+ .location
+ )
+ assert_json_equal(
+ '{"store":{"service":"s3"},"resize":{"width":400}}',
+ mock_requests.last_request.json(),
+ )
+
+ def test_to_buffer_should_return_image_data(self):
+ assert b"compressed file" == Source.from_buffer(b"png file").to_buffer()
+
+ def test_to_file_with_path_should_store_image_data(self):
+ with tempfile.TemporaryFile() as tmp:
+ Source.from_buffer(b"png file").to_file(tmp)
+ tmp.seek(0)
+ assert b"compressed file" == tmp.read()
+
+ def test_to_file_with_file_object_should_store_image_data(self):
+ name = create_named_tmpfile()
+ try:
+ Source.from_buffer(b"png file").to_file(name)
+ with open(name, "rb") as f:
+ assert b"compressed file" == f.read()
+ finally:
+ os.unlink(name)
+
+ def test_all_options_together(self, mock_requests):
+ assert (
+ "https://bucket.s3-region.amazonaws.com/some/location"
+ == Source.from_buffer(b"png file")
+ .resize(width=400)
+ .convert(type=["image/webp", "image/png"])
+ .transform(background="black")
+ .preserve("copyright", "location")
+ .store(service="s3")
+ .location
+ )
+ assert_json_equal(
+ '{"store":{"service":"s3"},"resize":{"width":400},"preserve": ["copyright", "location"], "transform": {"background": "black"}, "convert": {"type": ["image/webp", "image/png"]}}',
+ mock_requests.last_request.json(),
+ )
diff --git a/test/unit/tinify_test.py b/test/unit/tinify_test.py
new file mode 100644
index 0000000..4d0a4af
--- /dev/null
+++ b/test/unit/tinify_test.py
@@ -0,0 +1,144 @@
+import pytest
+import tinify
+import base64
+
+
+def test_key_should_reset_client_with_new_key(mock_requests):
+ mock_requests.get("https://api.tinify.com/")
+ tinify.key = "abcde"
+ tinify.get_client()
+ tinify.key = "fghij"
+ tinify.get_client().request("GET", "/")
+
+ # Get the last request made to the endpoint
+ request = mock_requests.last_request
+ assert request.headers["authorization"] == "Basic {0}".format(
+ base64.b64encode(b"api:fghij").decode("ascii")
+ )
+
+
+def test_app_identifier_should_reset_client_with_new_app_identifier(mock_requests):
+ mock_requests.get("https://api.tinify.com/")
+ tinify.key = "abcde"
+ tinify.app_identifier = "MyApp/1.0"
+ tinify.get_client()
+ tinify.app_identifier = "MyApp/2.0"
+ tinify.get_client().request("GET", "/")
+
+ request = mock_requests.last_request
+ assert request.headers["user-agent"] == tinify.Client.USER_AGENT + " MyApp/2.0"
+
+
+def test_proxy_should_reset_client_with_new_proxy(mock_requests):
+ mock_requests.get("https://api.tinify.com/")
+
+ tinify.key = "abcde"
+ tinify.proxy = "http://localhost:8080"
+ tinify.get_client()
+
+ tinify.proxy = "http://localhost:9090"
+ new_client = tinify.get_client()
+
+ new_client.request("GET", "/")
+
+ # Verify the request was made with the correct proxy configuration
+ # The proxy settings should be in the session's proxies attribute
+ assert new_client.session.proxies["https"] == "http://localhost:9090"
+
+
+def test_client_with_key_should_return_client():
+ tinify.key = "abcde"
+ assert isinstance(tinify.get_client(), tinify.Client)
+
+
+def test_client_without_key_should_raise_error():
+ tinify.key = None
+ with pytest.raises(tinify.AccountError):
+ tinify.get_client()
+
+
+def test_client_with_invalid_proxy_should_raise_error(mock_requests):
+ # We can test invalid proxy format, but not actual connection issues with requests-mock
+ tinify.key = "abcde"
+ tinify.proxy = "http-bad-url" # Invalid proxy URL format
+
+ with pytest.raises(tinify.ConnectionError):
+ tinify.get_client().request("GET", "/")
+
+
+def test_validate_with_valid_key_should_return_true(mock_requests):
+ mock_requests.post(
+ "https://api.tinify.com/shrink",
+ status_code=400,
+ json={"error": "Input missing", "message": "No input"},
+ )
+
+ tinify.key = "valid"
+ assert tinify.validate() is True
+
+
+def test_validate_with_limited_key_should_return_true(mock_requests):
+ mock_requests.post(
+ "https://api.tinify.com/shrink",
+ status_code=429,
+ json={
+ "error": "Too many requests",
+ "message": "Your monthly limit has been exceeded",
+ },
+ )
+
+ tinify.key = "valid"
+ assert tinify.validate() is True
+
+
+def test_validate_with_error_should_raise_error(mock_requests):
+ mock_requests.post(
+ "https://api.tinify.com/shrink",
+ status_code=401,
+ json={"error": "Unauthorized", "message": "Credentials are invalid"},
+ )
+
+ tinify.key = "valid"
+ with pytest.raises(tinify.AccountError):
+ tinify.validate()
+
+
+def test_from_file_should_return_source(mock_requests, tmp_path):
+ # Create a dummy file
+ dummy_file = tmp_path / "test.png"
+ dummy_file.write_bytes(b"png file")
+
+ # Mock the API endpoint
+ mock_requests.post(
+ "https://api.tinify.com/shrink",
+ status_code=201, # Created
+ headers={"Location": "https://api.tinify.com/some/location"},
+ )
+
+ tinify.key = "valid"
+ result = tinify.from_file(str(dummy_file))
+ assert isinstance(result, tinify.Source)
+
+
+def test_from_buffer_should_return_source(mock_requests):
+ mock_requests.post(
+ "https://api.tinify.com/shrink",
+ status_code=201, # Created
+ headers={"Location": "https://api.tinify.com/some/location"},
+ )
+
+ tinify.key = "valid"
+ result = tinify.from_buffer("png file")
+ assert isinstance(result, tinify.Source)
+
+
+def test_from_url_should_return_source(mock_requests):
+ mock_requests.post(
+ "https://api.tinify.com/shrink",
+ status_code=201, # Created
+ headers={"Location": "https://api.tinify.com/some/location"},
+ )
+
+ tinify.key = "valid"
+ result = tinify.from_url("http://example.com/test.jpg")
+ assert isinstance(result, tinify.Source)
diff --git a/tinify/__init__.py b/tinify/__init__.py
index 197cc22..9fcbd45 100644
--- a/tinify/__init__.py
+++ b/tinify/__init__.py
@@ -3,9 +3,21 @@
import threading
import sys
+try:
+ from typing import Optional, Any, TYPE_CHECKING
+except ImportError:
+ TYPE_CHECKING = False # type: ignore
class tinify(object):
+
+ _client = None # type: Optional[Client]
+ _key = None # type: Optional[str]
+ _app_identifier = None # type: Optional[str]
+ _proxy = None # type: Optional[str]
+ _compression_count = None # type: Optional[int]
+
def __init__(self, module):
+ # type: (Any) -> None
self._module = module
self._lock = threading.RLock()
@@ -17,40 +29,49 @@ def __init__(self, module):
@property
def key(self):
+ # type: () -> Optional[str]
return self._key
@key.setter
def key(self, value):
+ # type: (str) -> None
self._key = value
self._client = None
@property
def app_identifier(self):
+ # type: () -> Optional[str]
return self._app_identifier
@app_identifier.setter
def app_identifier(self, value):
+ # type: (str) -> None
self._app_identifier = value
self._client = None
@property
def proxy(self):
- return self._key
+ # type: () -> Optional[str]
+ return self._proxy
@proxy.setter
def proxy(self, value):
+ # type: (str) -> None
self._proxy = value
self._client = None
@property
def compression_count(self):
+ # type: () -> Optional[int]
return self._compression_count
@compression_count.setter
def compression_count(self, value):
+ # type: (int) -> None
self._compression_count = value
def get_client(self):
+ # type: () -> Client
if not self._key:
raise AccountError('Provide an API key with tinify.key = ...')
@@ -63,9 +84,11 @@ def get_client(self):
# Delegate to underlying base module.
def __getattr__(self, attr):
+ # type: (str) -> Any
return getattr(self._module, attr)
def validate(self):
+ # type: () -> bool
try:
self.get_client().request('post', '/shrink')
except AccountError as err:
@@ -74,18 +97,44 @@ def validate(self):
raise err
except ClientError:
return True
+ return False
def from_file(self, path):
+ # type: (str) -> Source
return Source.from_file(path)
def from_buffer(self, string):
+ # type: (bytes) -> Source
return Source.from_buffer(string)
def from_url(self, url):
+ # type: (str) -> Source
return Source.from_url(url)
+if TYPE_CHECKING:
+ # Help the type checker here, as we overrride the module with a singleton object.
+ def get_client(): # type: () -> Client
+ pass
+ key = None # type: Optional[str]
+ app_identifier = None # type: Optional[str]
+ proxy = None # type: Optional[str]
+ compression_count = None # type: Optional[int]
+
+ def validate(): # type: () -> bool
+ pass
+
+ def from_file(path): # type: (str) -> Source
+ pass
+
+ def from_buffer(string): # type: (bytes) -> Source
+ pass
+
+ def from_url(url): # type: (str) -> Source
+ pass
+
+
# Overwrite current module with singleton object.
-tinify = sys.modules[__name__] = tinify(sys.modules[__name__])
+tinify = sys.modules[__name__] = tinify(sys.modules[__name__]) # type: ignore
from .version import __version__
@@ -96,13 +145,13 @@ def from_url(self, url):
from .errors import *
__all__ = [
- b'Client',
- b'Result',
- b'ResultMeta',
- b'Source',
- b'Error',
- b'AccountError',
- b'ClientError',
- b'ServerError',
- b'ConnectionError'
+ 'Client',
+ 'Result',
+ 'ResultMeta',
+ 'Source',
+ 'Error',
+ 'AccountError',
+ 'ClientError',
+ 'ServerError',
+ 'ConnectionError'
]
diff --git a/tinify/_typed.py b/tinify/_typed.py
new file mode 100644
index 0000000..fc1a6da
--- /dev/null
+++ b/tinify/_typed.py
@@ -0,0 +1,30 @@
+from typing import Union, Dict, List, Literal, Optional, TypedDict
+
+class ResizeOptions(TypedDict,total=False):
+ method: Literal['scale', 'fit', 'cover', 'thumb']
+ width: Optional[int]
+ height: Optional[int]
+
+ConvertTypes = Literal['image/webp', 'image/jpeg', 'image/png', "image/avif", "image/jxl", "*/*"]
+class ConvertOptions(TypedDict, total=False):
+ type: Union[ConvertTypes, List[ConvertTypes]]
+
+class TransformOptions(TypedDict, total=False):
+ background: Union[str, Literal["white", "black"]]
+
+class S3StoreOptions(TypedDict, total=False):
+ service: Literal['s3']
+ aws_access_key_id: str
+ aws_secret_access_key: str
+ region: str
+ path: str
+ headers: Optional[Dict[str, str]]
+ acl: Optional[Literal["no-acl"]]
+
+class GCSStoreOptions(TypedDict, total=False):
+ service: Literal['gcs']
+ gcp_access_token: str
+ path: str
+ headers: Optional[Dict[str, str]]
+
+PreserveOption = Literal['copyright', 'creation', 'location']
diff --git a/tinify/client.py b/tinify/client.py
index b27a093..a170b05 100644
--- a/tinify/client.py
+++ b/tinify/client.py
@@ -6,13 +6,18 @@
import platform
import requests
import requests.exceptions
-from requests.compat import json
+from requests.compat import json # type: ignore
import traceback
import time
import tinify
from .errors import ConnectionError, Error
+try:
+ from typing import Any, Optional
+except ImportError:
+ pass
+
class Client(object):
API_ENDPOINT = 'https://api.tinify.com'
@@ -21,7 +26,7 @@ class Client(object):
USER_AGENT = 'Tinify/{0} Python/{1} ({2})'.format(tinify.__version__, platform.python_version(), platform.python_implementation())
- def __init__(self, key, app_identifier=None, proxy=None):
+ def __init__(self, key, app_identifier=None, proxy=None): # type: (str, Optional[str], Optional[str]) -> None
self.session = requests.sessions.Session()
if proxy:
self.session.proxies = {'https': proxy}
@@ -31,18 +36,19 @@ def __init__(self, key, app_identifier=None, proxy=None):
}
self.session.verify = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'data', 'cacert.pem')
- def __enter__(self):
+ def __enter__(self): # type: () -> Client
return self
- def __exit__(self, *args):
+ def __exit__(self, *args): # type: (*Any) -> None
self.close()
+ return None
- def close(self):
+ def close(self): # type: () -> None
self.session.close()
- def request(self, method, url, body=None):
+ def request(self, method, url, body=None): # type: (str, str, Any) -> requests.Response
url = url if url.lower().startswith('https://') else self.API_ENDPOINT + url
- params = {}
+ params = {} # type: dict[str, Any]
if isinstance(body, dict):
if body:
# Dump without whitespace.
@@ -77,3 +83,5 @@ def request(self, method, url, body=None):
details = {'message': 'Error while parsing response: {0}'.format(err), 'error': 'ParseError'}
if retries > 0 and response.status_code >= 500: continue
raise Error.create(details.get('message'), details.get('error'), response.status_code)
+
+ raise Error.create("Received no response", "ConnectionError", 0)
\ No newline at end of file
diff --git a/tinify/errors.py b/tinify/errors.py
index d59c75e..092d597 100644
--- a/tinify/errors.py
+++ b/tinify/errors.py
@@ -1,23 +1,26 @@
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, print_function, unicode_literals
+try:
+ from typing import Optional
+except ImportError:
+ pass
+
class Error(Exception):
@staticmethod
- def create(message, kind, status):
- klass = None
+ def create(message, kind, status): # type: (Optional[str], Optional[str], int) -> Error
+ klass = Error # type: type[Error]
if status == 401 or status == 429:
klass = AccountError
elif status >= 400 and status <= 499:
klass = ClientError
elif status >= 400 and status < 599:
klass = ServerError
- else:
- klass = Error
if not message: message = 'No message was provided'
return klass(message, kind, status)
- def __init__(self, message, kind=None, status=None, cause=None):
+ def __init__(self, message, kind=None, status=None, cause=None): # type: (str, Optional[str], Optional[int], Optional[Exception]) -> None
self.message = message
self.kind = kind
self.status = status
@@ -25,7 +28,7 @@ def __init__(self, message, kind=None, status=None, cause=None):
# Equivalent to 'raise err from cause', also supported by Python 2.
self.__cause__ = cause
- def __str__(self):
+ def __str__(self): # type: () -> str
if self.status:
return '{0} (HTTP {1:d}/{2})'.format(self.message, self.status, self.kind)
else:
diff --git a/tinify/py.typed b/tinify/py.typed
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/tinify/py.typed
@@ -0,0 +1 @@
+
diff --git a/tinify/result.py b/tinify/result.py
index f954644..cbf2956 100644
--- a/tinify/result.py
+++ b/tinify/result.py
@@ -1,36 +1,50 @@
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, print_function, unicode_literals
+from requests.structures import CaseInsensitiveDict
from . import ResultMeta
+try:
+ from typing import Union, Optional, IO
+except ImportError:
+ pass
+
+
class Result(ResultMeta):
- def __init__(self, meta, data):
+ def __init__(self, meta, data): # type: (CaseInsensitiveDict[str], bytes) -> None
ResultMeta.__init__(self, meta)
self.data = data
- def to_file(self, path):
+ def to_file(self, path): # type: (Union[str, IO]) -> None
if hasattr(path, 'write'):
path.write(self.data)
else:
with open(path, 'wb') as f:
f.write(self.data)
- def to_buffer(self):
+ def to_buffer(self): # type: () -> bytes
return self.data
@property
- def size(self):
+ def size(self): # type: () -> Optional[int]
value = self._meta.get('Content-Length')
- return value and int(value)
+ return int(value) if value is not None else None
@property
- def media_type(self):
+ def media_type(self): # type: () -> Optional[str]
return self._meta.get('Content-Type')
@property
- def content_type(self):
+ def extension(self): # type: () -> Optional[str]
+ media_type = self._meta.get('Content-Type')
+ if media_type:
+ return media_type.split('/')[-1]
+ return None
+
+ @property
+ def content_type(self): # type: () -> Optional[str]
return self.media_type
@property
- def location(self):
+ def location(self): # type: () -> Optional[str]
return None
diff --git a/tinify/result_meta.py b/tinify/result_meta.py
index e06114b..fe7de4c 100644
--- a/tinify/result_meta.py
+++ b/tinify/result_meta.py
@@ -1,23 +1,36 @@
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, print_function, unicode_literals
+from requests.structures import CaseInsensitiveDict
+
+try:
+ from typing import Optional, Dict
+except ImportError:
+ pass
+
+
class ResultMeta(object):
- def __init__(self, meta):
+ def __init__(self, meta): # type: (CaseInsensitiveDict[str]) -> None
self._meta = meta
@property
- def width(self):
+ def width(self): # type: () -> Optional[int]
value = self._meta.get('Image-Width')
- return value and int(value)
+ return int(value) if value else None
@property
- def height(self):
+ def height(self): # type: () -> Optional[int]
value = self._meta.get('Image-Height')
- return value and int(value)
+ return int(value) if value else None
@property
- def location(self):
+ def location(self): # type: () -> Optional[str]
return self._meta.get('Location')
- def __len__(self):
+ @property
+ def size(self): # type: () -> Optional[int]
+ value = self._meta.get('Content-Length')
+ return int(value) if value else None
+
+ def __len__(self): # type: () -> int
return self.size or 0
diff --git a/tinify/source.py b/tinify/source.py
index 36c4301..5fdf147 100644
--- a/tinify/source.py
+++ b/tinify/source.py
@@ -2,11 +2,20 @@
from __future__ import absolute_import, division, print_function, unicode_literals
import tinify
-from . import Result, ResultMeta
+import sys
+from tinify.result import Result
+from tinify.result_meta import ResultMeta
+
+try:
+ from typing import Union, Dict, IO, Any, Unpack, TYPE_CHECKING, overload
+ if sys.version_info.major > 3 and sys.version_info.minor > 8:
+ from tinify._typed import *
+except ImportError:
+ TYPE_CHECKING = False # type: ignore
class Source(object):
@classmethod
- def from_file(cls, path):
+ def from_file(cls, path): # type: (Union[str, IO]) -> Source
if hasattr(path, 'read'):
return cls._shrink(path)
else:
@@ -14,43 +23,61 @@ def from_file(cls, path):
return cls._shrink(f.read())
@classmethod
- def from_buffer(cls, string):
+ def from_buffer(cls, string): # type: (bytes) -> Source
return cls._shrink(string)
@classmethod
- def from_url(cls, url):
+ def from_url(cls, url): # type: (str) -> Source
return cls._shrink({"source": {"url": url}})
@classmethod
- def _shrink(cls, obj):
+ def _shrink(cls, obj): # type: (Any) -> Source
response = tinify.get_client().request('POST', '/shrink', obj)
- return cls(response.headers.get('location'))
+ return cls(response.headers['location'])
- def __init__(self, url, **commands):
+ def __init__(self, url, **commands): # type: (str, **Any) -> None
self.url = url
self.commands = commands
- def preserve(self, *options):
+ def preserve(self, *options): # type: (*PreserveOption) -> "Source"
return type(self)(self.url, **self._merge_commands(preserve=self._flatten(options)))
- def resize(self, **options):
+ def resize(self, **options): # type: (Unpack[ResizeOptions]) -> "Source"
return type(self)(self.url, **self._merge_commands(resize=options))
- def store(self, **options):
+ def convert(self, **options): # type: (Unpack[ConvertOptions]) -> "Source"
+ return type(self)(self.url, **self._merge_commands(convert=options))
+
+ def transform(self, **options): # type: (Unpack[TransformOptions]) -> "Source"
+ return type(self)(self.url, **self._merge_commands(transform=options))
+
+ if TYPE_CHECKING:
+ @overload
+ def store(self, **options): # type: (Unpack[S3StoreOptions]) -> ResultMeta
+ pass
+
+ @overload
+ def store(self, **options): # type: (Unpack[GCSStoreOptions]) -> ResultMeta
+ pass
+
+ def store(self, **options): # type: (Any) -> ResultMeta
response = tinify.get_client().request('POST', self.url, self._merge_commands(store=options))
return ResultMeta(response.headers)
- def result(self):
- response = tinify.get_client().request('GET', self.url, self.commands)
+ def result(self): # type: () -> Result
+ if not self.commands:
+ response = tinify.get_client().request('GET', self.url, self.commands)
+ else:
+ response = tinify.get_client().request('POST', self.url, self.commands)
return Result(response.headers, response.content)
- def to_file(self, path):
+ def to_file(self, path): # type: (Union[str, IO]) -> None
return self.result().to_file(path)
- def to_buffer(self):
+ def to_buffer(self): # type: () -> bytes
return self.result().to_buffer()
- def _merge_commands(self, **options):
+ def _merge_commands(self, **options): # type: (**Any) -> Dict[str, Any]
commands = self.commands.copy()
commands.update(options)
return commands
diff --git a/tinify/version.py b/tinify/version.py
index c3b3841..8adfee4 100644
--- a/tinify/version.py
+++ b/tinify/version.py
@@ -1 +1 @@
-__version__ = '1.5.2'
+__version__ = '1.7.2'
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 0000000..456da68
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,8 @@
+[tox]
+envlist = py27,py36,py37,py38,py39,py310,py311,pypy2,pypy3
+
+[testenv]
+deps = -rtest-requirements.txt
+ -rrequirements.txt
+commands =
+ pytest {posargs}