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 @@ -[Build Status](https://travis-ci.org/tinify/tinify-python) +[![MIT License](http://img.shields.io/badge/license-MIT-green.svg) ](https://github.com/tinify/tinify-python/blob/main/LICENSE) +[![CI](https://github.com/tinify/tinify-python/actions/workflows/ci-cd.yml/badge.svg)](https://github.com/tinify/tinify-python/actions/workflows/ci-cd.yml) +[![PyPI](https://img.shields.io/pypi/v/tinify)](https://pypi.org/project/tinify/#history) +[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/tinify)](https://pypi.org/project/tinify/) +[![PyPI - Wheel](https://img.shields.io/pypi/wheel/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}