├── test ├── __init__.py ├── examples │ ├── dummy.png │ └── voormedia.png ├── unit │ ├── __init__.py │ ├── conftest.py │ ├── tinify_result_meta_test.py │ ├── tinify_result_test.py │ ├── tinify_test.py │ ├── tinify_client_test.py │ └── tinify_source_test.py └── integration.py ├── tinify ├── py.typed ├── version.py ├── _typed.py ├── result_meta.py ├── errors.py ├── result.py ├── client.py ├── source.py └── __init__.py ├── requirements.txt ├── test-requirements.txt ├── setup.cfg ├── .gitignore ├── tox.ini ├── update-cacert.sh ├── CHANGES.md ├── LICENSE ├── setup.py ├── .github └── workflows │ └── ci-cd.yml └── README.md /test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/examples/dummy.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/unit/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tinify/py.typed: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -e . 2 | -------------------------------------------------------------------------------- /test-requirements.txt: -------------------------------------------------------------------------------- 1 | -e .[test] 2 | -------------------------------------------------------------------------------- /tinify/version.py: -------------------------------------------------------------------------------- 1 | __version__ = '1.7.1' -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal=1 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | __pycache__/ 3 | build/ 4 | dist/ 5 | *.egg-info/ 6 | .tox 7 | -------------------------------------------------------------------------------- /test/examples/voormedia.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinify/tinify-python/HEAD/test/examples/voormedia.png -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27,py36,py37,py38,py39,py310,py311,pypy2,pypy3 3 | 4 | [testenv] 5 | deps = -rtest-requirements.txt 6 | -rrequirements.txt 7 | commands = 8 | pytest {posargs} 9 | -------------------------------------------------------------------------------- /update-cacert.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | dir=tinify/data 3 | 4 | cert=0 5 | curl --silent --fail https://curl.se/ca/cacert.pem | while read -r line; do 6 | if [ "-----BEGIN CERTIFICATE-----" == "$line" ]; then 7 | cert=1 8 | echo "$line" 9 | elif [ "-----END CERTIFICATE-----" == "$line" ]; then 10 | cert=0 11 | echo "$line" 12 | else 13 | if [ $cert == 1 ]; then 14 | echo "$line" 15 | fi 16 | fi 17 | done > "$dir/cacert.pem" 18 | -------------------------------------------------------------------------------- /test/unit/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import os 3 | import tinify 4 | import requests_mock 5 | 6 | 7 | @pytest.fixture 8 | def dummy_file(): 9 | return os.path.join(os.path.dirname(__file__), "..", "examples", "dummy.png") 10 | 11 | 12 | @pytest.fixture(autouse=True) 13 | def reset_tinify(): 14 | original_key = tinify.key 15 | original_app_identifier = tinify.app_identifier 16 | original_proxy = tinify.proxy 17 | 18 | tinify.key = None 19 | tinify.app_identifier = None 20 | tinify.proxy = None 21 | 22 | yield 23 | 24 | tinify.key = original_key 25 | tinify.app_identifier = original_app_identifier 26 | tinify.proxy = original_proxy 27 | 28 | 29 | @pytest.fixture 30 | def mock_requests(): 31 | with requests_mock.Mocker(real_http=False) as m: 32 | yield m 33 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | ## 1.7.1 2 | 3 | * Use only a GET request when no body, otherwise POST 4 | 5 | ## 1.7.0 6 | 7 | * Added type annotations 8 | * Updated runtime support 9 | * Dropped python 3.7 10 | * Added Python 3.12 11 | * Added Python 3.13 12 | * Tests: Replaced httpretty with requests-mock 13 | 14 | ## 1.6.0 15 | * Updated runtime support 16 | * Dropped 2.6 17 | * Added python 3.7 18 | * Added python 3.8 19 | * Added python 3.9 20 | * Added python 3.10 21 | * Added python 3.11 22 | * Fixed tests on windows 23 | * Add methods for the transcoding and transformation API 24 | * Add a method for getting the file extension from a Result object 25 | 26 | ## 1.5.2 27 | Remove letsencrypt DST Root from ca bundle for openssl 1.0.0 compatibility 28 | 29 | ## 1.5.1 30 | * Fix proxy setter. 31 | 32 | ## 1.5.0 33 | * Retry failed requests by default. 34 | 35 | ## 1.4.0 36 | * Added support for HTTP proxies. 37 | -------------------------------------------------------------------------------- /tinify/_typed.py: -------------------------------------------------------------------------------- 1 | from typing import Union, Dict, List, Literal, Optional, TypedDict 2 | 3 | class ResizeOptions(TypedDict,total=False): 4 | method: Literal['scale', 'fit', 'cover', 'thumb'] 5 | width: Optional[int] 6 | height: Optional[int] 7 | 8 | ConvertTypes = Literal['image/webp', 'image/jpeg', 'image/png', "image/avif", "*/*"] 9 | class ConvertOptions(TypedDict, total=False): 10 | type: Union[ConvertTypes, List[ConvertTypes]] 11 | 12 | class TransformOptions(TypedDict, total=False): 13 | background: Union[str, Literal["white", "black"]] 14 | 15 | class S3StoreOptions(TypedDict, total=False): 16 | service: Literal['s3'] 17 | aws_access_key_id: str 18 | aws_secret_access_key: str 19 | region: str 20 | path: str 21 | headers: Optional[Dict[str, str]] 22 | acl: Optional[Literal["no-acl"]] 23 | 24 | class GCSStoreOptions(TypedDict, total=False): 25 | service: Literal['gcs'] 26 | gcp_access_token: str 27 | path: str 28 | headers: Optional[Dict[str, str]] 29 | 30 | PreserveOption = Literal['copyright', 'creation', 'location'] 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2013-2025 Tinify 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /tinify/result_meta.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, division, print_function, unicode_literals 3 | from requests.structures import CaseInsensitiveDict 4 | 5 | try: 6 | from typing import Optional, Dict 7 | except ImportError: 8 | pass 9 | 10 | 11 | 12 | class ResultMeta(object): 13 | def __init__(self, meta): # type: (CaseInsensitiveDict[str]) -> None 14 | self._meta = meta 15 | 16 | @property 17 | def width(self): # type: () -> Optional[int] 18 | value = self._meta.get('Image-Width') 19 | return int(value) if value else None 20 | 21 | @property 22 | def height(self): # type: () -> Optional[int] 23 | value = self._meta.get('Image-Height') 24 | return int(value) if value else None 25 | 26 | @property 27 | def location(self): # type: () -> Optional[str] 28 | return self._meta.get('Location') 29 | 30 | @property 31 | def size(self): # type: () -> Optional[int] 32 | value = self._meta.get('Content-Length') 33 | return int(value) if value else None 34 | 35 | def __len__(self): # type: () -> int 36 | return self.size or 0 37 | -------------------------------------------------------------------------------- /tinify/errors.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, division, print_function, unicode_literals 3 | 4 | try: 5 | from typing import Optional 6 | except ImportError: 7 | pass 8 | 9 | class Error(Exception): 10 | @staticmethod 11 | def create(message, kind, status): # type: (Optional[str], Optional[str], int) -> Error 12 | klass = Error # type: type[Error] 13 | if status == 401 or status == 429: 14 | klass = AccountError 15 | elif status >= 400 and status <= 499: 16 | klass = ClientError 17 | elif status >= 400 and status < 599: 18 | klass = ServerError 19 | 20 | if not message: message = 'No message was provided' 21 | return klass(message, kind, status) 22 | 23 | def __init__(self, message, kind=None, status=None, cause=None): # type: (str, Optional[str], Optional[int], Optional[Exception]) -> None 24 | self.message = message 25 | self.kind = kind 26 | self.status = status 27 | if cause: 28 | # Equivalent to 'raise err from cause', also supported by Python 2. 29 | self.__cause__ = cause 30 | 31 | def __str__(self): # type: () -> str 32 | if self.status: 33 | return '{0} (HTTP {1:d}/{2})'.format(self.message, self.status, self.kind) 34 | else: 35 | return self.message 36 | 37 | class AccountError(Error): pass 38 | class ClientError(Error): pass 39 | class ServerError(Error): pass 40 | class ConnectionError(Error): pass 41 | -------------------------------------------------------------------------------- /tinify/result.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, division, print_function, unicode_literals 3 | from requests.structures import CaseInsensitiveDict 4 | 5 | from . import ResultMeta 6 | 7 | try: 8 | from typing import Union, Optional, IO 9 | except ImportError: 10 | pass 11 | 12 | 13 | class Result(ResultMeta): 14 | def __init__(self, meta, data): # type: (CaseInsensitiveDict[str], bytes) -> None 15 | ResultMeta.__init__(self, meta) 16 | self.data = data 17 | 18 | def to_file(self, path): # type: (Union[str, IO]) -> None 19 | if hasattr(path, 'write'): 20 | path.write(self.data) 21 | else: 22 | with open(path, 'wb') as f: 23 | f.write(self.data) 24 | 25 | def to_buffer(self): # type: () -> bytes 26 | return self.data 27 | 28 | @property 29 | def size(self): # type: () -> Optional[int] 30 | value = self._meta.get('Content-Length') 31 | return int(value) if value is not None else None 32 | 33 | @property 34 | def media_type(self): # type: () -> Optional[str] 35 | return self._meta.get('Content-Type') 36 | 37 | @property 38 | def extension(self): # type: () -> Optional[str] 39 | media_type = self._meta.get('Content-Type') 40 | if media_type: 41 | return media_type.split('/')[-1] 42 | return None 43 | 44 | @property 45 | def content_type(self): # type: () -> Optional[str] 46 | return self.media_type 47 | 48 | @property 49 | def location(self): # type: () -> Optional[str] 50 | return None 51 | -------------------------------------------------------------------------------- /test/unit/tinify_result_meta_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import pytest 3 | from tinify import ResultMeta 4 | 5 | 6 | @pytest.fixture 7 | def result_with_meta(): 8 | """Fixture that returns a ResultMeta instance with metadata""" 9 | return ResultMeta( 10 | { 11 | "Image-Width": "100", 12 | "Image-Height": "60", 13 | "Content-Length": "20", 14 | "Content-Type": "application/json", 15 | "Location": "https://bucket.s3-region.amazonaws.com/some/location", 16 | } 17 | ) 18 | 19 | 20 | @pytest.fixture 21 | def result_without_meta(): 22 | """Fixture that returns a ResultMeta instance without metadata""" 23 | return ResultMeta({}) 24 | 25 | 26 | # Tests for ResultMeta with metadata 27 | def test_width_should_return_image_width(result_with_meta): 28 | assert 100 == result_with_meta.width 29 | 30 | 31 | def test_height_should_return_image_height(result_with_meta): 32 | assert 60 == result_with_meta.height 33 | 34 | 35 | def test_location_should_return_stored_location(result_with_meta): 36 | assert ( 37 | "https://bucket.s3-region.amazonaws.com/some/location" 38 | == result_with_meta.location 39 | ) 40 | 41 | 42 | # Tests for ResultMeta without metadata 43 | def test_width_should_return_none_when_no_meta(result_without_meta): 44 | assert None is result_without_meta.width 45 | 46 | 47 | def test_height_should_return_none_when_no_meta(result_without_meta): 48 | assert None is result_without_meta.height 49 | 50 | 51 | def test_location_should_return_none_when_no_meta(result_without_meta): 52 | assert None is result_without_meta.location 53 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | import re 4 | import io 5 | 6 | try: 7 | from setuptools import setup 8 | except ImportError: 9 | from distutils.core import setup 10 | 11 | sys.path.insert(0, os.path.join(os.path.dirname(__file__), "tinify")) 12 | from version import __version__ 13 | 14 | install_require = ["requests >= 2.7.0, < 3.0.0"] 15 | tests_require = ["pytest", "pytest-xdist", "requests-mock", "types-requests"] 16 | 17 | if sys.version_info.major > 2: 18 | tests_require.append("mypy") 19 | 20 | with io.open("README.md", encoding="utf-8") as f: 21 | long_description = f.read() 22 | 23 | setup( 24 | name="tinify", 25 | version=__version__, 26 | description="Tinify API client.", 27 | author="Jacob Middag", 28 | author_email="info@tinify.com", 29 | license="MIT", 30 | long_description=long_description, 31 | long_description_content_type="text/markdown", 32 | url="https://tinify.com/developers", 33 | packages=["tinify"], 34 | package_data={ 35 | "": ["LICENSE", "README.md"], 36 | "tinify": ["data/cacert.pem", "py.typed"], 37 | }, 38 | install_requires=install_require, 39 | tests_require=tests_require, 40 | extras_require={"test": tests_require}, 41 | classifiers=( 42 | "Development Status :: 5 - Production/Stable", 43 | "Intended Audience :: Developers", 44 | "Natural Language :: English", 45 | "License :: OSI Approved :: MIT License", 46 | "Programming Language :: Python", 47 | "Programming Language :: Python :: 2.7", 48 | "Programming Language :: Python :: 3", 49 | "Programming Language :: Python :: 3.8", 50 | "Programming Language :: Python :: 3.9", 51 | "Programming Language :: Python :: 3.10", 52 | "Programming Language :: Python :: 3.11", 53 | "Programming Language :: Python :: 3.12", 54 | "Programming Language :: Python :: 3.13", 55 | ), 56 | ) 57 | -------------------------------------------------------------------------------- /test/unit/tinify_result_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, division, print_function, unicode_literals 3 | 4 | import pytest 5 | from tinify import Result 6 | 7 | 8 | @pytest.fixture 9 | def result_with_meta_and_data(): 10 | return Result( 11 | { 12 | "Image-Width": "100", 13 | "Image-Height": "60", 14 | "Content-Length": "450", 15 | "Content-Type": "image/png", 16 | }, 17 | b"image data", 18 | ) 19 | 20 | 21 | @pytest.fixture 22 | def result_without_meta_and_data(): 23 | return Result({}, None) 24 | 25 | 26 | class TestTinifyResultWithMetaAndData: 27 | def test_width_should_return_image_width(self, result_with_meta_and_data): 28 | assert 100 == result_with_meta_and_data.width 29 | 30 | def test_height_should_return_image_height(self, result_with_meta_and_data): 31 | assert 60 == result_with_meta_and_data.height 32 | 33 | def test_location_should_return_none(self, result_with_meta_and_data): 34 | assert None is result_with_meta_and_data.location 35 | 36 | def test_size_should_return_content_length(self, result_with_meta_and_data): 37 | assert 450 == result_with_meta_and_data.size 38 | 39 | def test_len_builtin_should_return_content_length(self, result_with_meta_and_data): 40 | assert 450 == len(result_with_meta_and_data) 41 | 42 | def test_content_type_should_return_mime_type(self, result_with_meta_and_data): 43 | assert "image/png" == result_with_meta_and_data.content_type 44 | 45 | def test_to_buffer_should_return_image_data(self, result_with_meta_and_data): 46 | assert b"image data" == result_with_meta_and_data.to_buffer() 47 | 48 | def test_extension(self, result_with_meta_and_data): 49 | assert "png" == result_with_meta_and_data.extension 50 | 51 | 52 | class TestTinifyResultWithoutMetaAndData: 53 | def test_width_should_return_none(self, result_without_meta_and_data): 54 | assert None is result_without_meta_and_data.width 55 | 56 | def test_height_should_return_none(self, result_without_meta_and_data): 57 | assert None is result_without_meta_and_data.height 58 | 59 | def test_location_should_return_none(self, result_without_meta_and_data): 60 | assert None is result_without_meta_and_data.location 61 | 62 | def test_size_should_return_none(self, result_without_meta_and_data): 63 | assert None is result_without_meta_and_data.size 64 | 65 | def test_len_builtin_should_return_zero(self, result_without_meta_and_data): 66 | assert 0 == len(result_without_meta_and_data) 67 | 68 | def test_content_type_should_return_none(self, result_without_meta_and_data): 69 | assert None is result_without_meta_and_data.content_type 70 | 71 | def test_to_buffer_should_return_none(self, result_without_meta_and_data): 72 | assert None is result_without_meta_and_data.to_buffer() 73 | 74 | def test_extension(self, result_without_meta_and_data): 75 | assert None is result_without_meta_and_data.extension 76 | -------------------------------------------------------------------------------- /tinify/client.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, division, print_function, unicode_literals 3 | 4 | import sys 5 | import os 6 | import platform 7 | import requests 8 | import requests.exceptions 9 | from requests.compat import json # type: ignore 10 | import traceback 11 | import time 12 | 13 | import tinify 14 | from .errors import ConnectionError, Error 15 | 16 | try: 17 | from typing import Any, Optional 18 | except ImportError: 19 | pass 20 | 21 | class Client(object): 22 | API_ENDPOINT = 'https://api.tinify.com' 23 | 24 | RETRY_COUNT = 1 25 | RETRY_DELAY = 500 26 | 27 | USER_AGENT = 'Tinify/{0} Python/{1} ({2})'.format(tinify.__version__, platform.python_version(), platform.python_implementation()) 28 | 29 | def __init__(self, key, app_identifier=None, proxy=None): # type: (str, Optional[str], Optional[str]) -> None 30 | self.session = requests.sessions.Session() 31 | if proxy: 32 | self.session.proxies = {'https': proxy} 33 | self.session.auth = ('api', key) 34 | self.session.headers = { 35 | 'user-agent': self.USER_AGENT + ' ' + app_identifier if app_identifier else self.USER_AGENT, 36 | } 37 | self.session.verify = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'data', 'cacert.pem') 38 | 39 | def __enter__(self): # type: () -> Client 40 | return self 41 | 42 | def __exit__(self, *args): # type: (*Any) -> None 43 | self.close() 44 | return None 45 | 46 | def close(self): # type: () -> None 47 | self.session.close() 48 | 49 | def request(self, method, url, body=None): # type: (str, str, Any) -> requests.Response 50 | url = url if url.lower().startswith('https://') else self.API_ENDPOINT + url 51 | params = {} # type: dict[str, Any] 52 | if isinstance(body, dict): 53 | if body: 54 | # Dump without whitespace. 55 | params['headers'] = {'Content-Type': 'application/json'} 56 | params['data'] = json.dumps(body, separators=(',', ':')) 57 | elif body: 58 | params['data'] = body 59 | 60 | for retries in range(self.RETRY_COUNT, -1, -1): 61 | if retries < self.RETRY_COUNT: time.sleep(self.RETRY_DELAY / 1000.0) 62 | 63 | try: 64 | response = self.session.request(method, url, **params) 65 | except requests.exceptions.Timeout as err: 66 | if retries > 0: continue 67 | raise ConnectionError('Timeout while connecting', cause=err) 68 | except Exception as err: 69 | if retries > 0: continue 70 | raise ConnectionError('Error while connecting: {0}'.format(err), cause=err) 71 | 72 | count = response.headers.get('compression-count') 73 | if count: 74 | tinify.compression_count = int(count) 75 | 76 | if response.ok: 77 | return response 78 | 79 | details = None 80 | try: 81 | details = response.json() 82 | except Exception as err: 83 | details = {'message': 'Error while parsing response: {0}'.format(err), 'error': 'ParseError'} 84 | if retries > 0 and response.status_code >= 500: continue 85 | raise Error.create(details.get('message'), details.get('error'), response.status_code) 86 | 87 | raise Error.create("Received no response", "ConnectionError", 0) -------------------------------------------------------------------------------- /tinify/source.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, division, print_function, unicode_literals 3 | 4 | import tinify 5 | import sys 6 | from tinify.result import Result 7 | from tinify.result_meta import ResultMeta 8 | 9 | try: 10 | from typing import Union, Dict, IO, Any, Unpack, TYPE_CHECKING, overload 11 | if sys.version_info.major > 3 and sys.version_info.minor > 8: 12 | from tinify._typed import * 13 | except ImportError: 14 | TYPE_CHECKING = False # type: ignore 15 | 16 | class Source(object): 17 | @classmethod 18 | def from_file(cls, path): # type: (Union[str, IO]) -> Source 19 | if hasattr(path, 'read'): 20 | return cls._shrink(path) 21 | else: 22 | with open(path, 'rb') as f: 23 | return cls._shrink(f.read()) 24 | 25 | @classmethod 26 | def from_buffer(cls, string): # type: (bytes) -> Source 27 | return cls._shrink(string) 28 | 29 | @classmethod 30 | def from_url(cls, url): # type: (str) -> Source 31 | return cls._shrink({"source": {"url": url}}) 32 | 33 | @classmethod 34 | def _shrink(cls, obj): # type: (Any) -> Source 35 | response = tinify.get_client().request('POST', '/shrink', obj) 36 | return cls(response.headers['location']) 37 | 38 | def __init__(self, url, **commands): # type: (str, **Any) -> None 39 | self.url = url 40 | self.commands = commands 41 | 42 | def preserve(self, *options): # type: (*PreserveOption) -> "Source" 43 | return type(self)(self.url, **self._merge_commands(preserve=self._flatten(options))) 44 | 45 | def resize(self, **options): # type: (Unpack[ResizeOptions]) -> "Source" 46 | return type(self)(self.url, **self._merge_commands(resize=options)) 47 | 48 | def convert(self, **options): # type: (Unpack[ConvertOptions]) -> "Source" 49 | return type(self)(self.url, **self._merge_commands(convert=options)) 50 | 51 | def transform(self, **options): # type: (Unpack[TransformOptions]) -> "Source" 52 | return type(self)(self.url, **self._merge_commands(transform=options)) 53 | 54 | if TYPE_CHECKING: 55 | @overload 56 | def store(self, **options): # type: (Unpack[S3StoreOptions]) -> ResultMeta 57 | pass 58 | 59 | @overload 60 | def store(self, **options): # type: (Unpack[GCSStoreOptions]) -> ResultMeta 61 | pass 62 | 63 | def store(self, **options): # type: (Any) -> ResultMeta 64 | response = tinify.get_client().request('POST', self.url, self._merge_commands(store=options)) 65 | return ResultMeta(response.headers) 66 | 67 | def result(self): # type: () -> Result 68 | if not self.commands: 69 | response = tinify.get_client().request('GET', self.url, self.commands) 70 | else: 71 | response = tinify.get_client().request('POST', self.url, self.commands) 72 | return Result(response.headers, response.content) 73 | 74 | def to_file(self, path): # type: (Union[str, IO]) -> None 75 | return self.result().to_file(path) 76 | 77 | def to_buffer(self): # type: () -> bytes 78 | return self.result().to_buffer() 79 | 80 | def _merge_commands(self, **options): # type: (**Any) -> Dict[str, Any] 81 | commands = self.commands.copy() 82 | commands.update(options) 83 | return commands 84 | 85 | def _flatten(self, items, seqtypes=(list, tuple)): 86 | items = list(items) 87 | for i, x in enumerate(items): 88 | while isinstance(items[i], seqtypes): 89 | items[i:i+1] = items[i] 90 | return items 91 | -------------------------------------------------------------------------------- /.github/workflows/ci-cd.yml: -------------------------------------------------------------------------------- 1 | name: Python CI/CD 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: {} 6 | jobs: 7 | Unit_tests: 8 | runs-on: ${{ matrix.os }} 9 | timeout-minutes: 10 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | python-version: [ 14 | "3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14", 15 | "pypy-2.7", "pypy-3.10" 16 | ] 17 | os: [ubuntu-latest, macOS-latest, windows-latest] 18 | 19 | steps: 20 | - uses: actions/checkout@v3 21 | - name: Set up Python ${{ matrix.python-version }} 22 | uses: actions/setup-python@v4 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | - name: Install dependencies 26 | run: | 27 | pip install -r test-requirements.txt -r requirements.txt 28 | - name: Run tests 29 | run: | 30 | pytest 31 | 32 | Mypy: 33 | runs-on: ${{ matrix.os }} 34 | timeout-minutes: 10 35 | strategy: 36 | fail-fast: false 37 | matrix: 38 | python-version: [ 39 | "3.12" 40 | ] 41 | os: [ubuntu-latest, macOS-latest, windows-latest] 42 | steps: 43 | - uses: actions/checkout@v3 44 | - name: Set up Python ${{ matrix.python-version }} 45 | uses: actions/setup-python@v4 46 | with: 47 | python-version: ${{ matrix.python-version }} 48 | - name: Install dependencies 49 | run: | 50 | pip install -r test-requirements.txt -r requirements.txt 51 | - name: Run tests 52 | run: | 53 | mypy --check tinify 54 | 55 | Integration_tests: 56 | if: github.event_name == 'push' 57 | runs-on: ${{ matrix.os }} 58 | timeout-minutes: 10 59 | needs: [Unit_tests, Mypy] 60 | strategy: 61 | fail-fast: false 62 | matrix: 63 | python-version: [ 64 | "3.13", 65 | ] 66 | os: [ubuntu-latest, macOS-latest, windows-latest] 67 | steps: 68 | - uses: actions/checkout@v3 69 | - name: Set up Python ${{ matrix.python-version }} 70 | uses: actions/setup-python@v4 71 | with: 72 | python-version: ${{ matrix.python-version }} 73 | - name: Install dependencies 74 | run: | 75 | pip install -r test-requirements.txt -r requirements.txt 76 | - name: Run tests 77 | env: 78 | TINIFY_KEY: ${{ secrets.TINIFY_KEY }} 79 | run: | 80 | pytest test/integration.py 81 | 82 | Publish: 83 | if: | 84 | github.repository == 'tinify/tinify-python' && 85 | startsWith(github.ref, 'refs/tags') && 86 | github.event_name == 'push' 87 | timeout-minutes: 10 88 | needs: [Unit_tests, Integration_tests] 89 | runs-on: ubuntu-latest 90 | steps: 91 | - uses: actions/checkout@v3 92 | with: 93 | fetch-depth: 0 94 | persist-credentials: false 95 | - name: Set up Python 96 | uses: actions/setup-python@v4 97 | with: 98 | python-version: "3.13" 99 | - name: Install dependencies 100 | run: | 101 | pip install -r requirements.txt 102 | pip install build wheel 103 | - name: Check if properly tagged 104 | run: | 105 | PACKAGE_VERSION="$(python -c 'from tinify import __version__;print(__version__)')"; 106 | CURRENT_TAG="${GITHUB_REF#refs/*/}"; 107 | if [[ "${PACKAGE_VERSION}" != "${CURRENT_TAG}" ]]; then 108 | >&2 echo "Tag mismatch" 109 | >&2 echo "Version in tinify/version.py (${PACKAGE_VERSION}) does not match the current tag=${CURRENT_TAG}" 110 | >&2 echo "Skipping deploy" 111 | exit 1; 112 | fi 113 | - name: Build package (sdist & wheel) 114 | run: | 115 | python -m build --sdist --wheel --outdir dist/ 116 | - name: Test sdist install 117 | run: | 118 | python -m venv sdist_env 119 | ./sdist_env/bin/pip install dist/tinify*.tar.gz 120 | - name: Test wheel install 121 | run: | 122 | python -m venv wheel_env 123 | ./wheel_env/bin/pip install dist/tinify*.whl 124 | - name: Publish package to PyPI 125 | uses: pypa/gh-action-pypi-publish@release/v1 126 | with: 127 | user: __token__ 128 | password: ${{ secrets.PYPI_ACCESS_TOKEN }} 129 | # Use the test repository for testing the publish feature 130 | # repository_url: https://test.pypi.org/legacy/ 131 | packages_dir: dist/ 132 | print_hash: true 133 | -------------------------------------------------------------------------------- /test/integration.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | from contextlib import contextmanager 4 | import tinify 5 | import pytest 6 | import tempfile 7 | 8 | if not os.environ.get("TINIFY_KEY"): 9 | sys.exit("Set the TINIFY_KEY environment variable.") 10 | 11 | try: 12 | from typing import TYPE_CHECKING 13 | if TYPE_CHECKING: 14 | from tinify.source import Source 15 | except ImportError: 16 | pass 17 | 18 | 19 | @contextmanager 20 | def create_named_tmpfile(): 21 | # Due to NamedTemporaryFile requiring to be closed when used on Windows 22 | # we create our own NamedTemporaryFile contextmanager 23 | # See note: https://docs.python.org/3/library/tempfile.html#tempfile.NamedTemporaryFile 24 | 25 | tmp = tempfile.NamedTemporaryFile(delete=False) 26 | try: 27 | tmp.close() 28 | yield tmp.name 29 | finally: 30 | os.unlink(tmp.name) 31 | 32 | @pytest.fixture(scope="module", autouse=True) 33 | def tinify_patch(): 34 | tinify.key = os.environ.get("TINIFY_KEY") 35 | tinify.proxy = os.environ.get("TINIFY_PROXY") 36 | 37 | yield 38 | 39 | tinify.key = None 40 | tinify.proxy = None 41 | 42 | # Fixture for shared resources 43 | @pytest.fixture(scope="module") 44 | def optimized_image(): 45 | unoptimized_path = os.path.join( 46 | os.path.dirname(__file__), "examples", "voormedia.png" 47 | ) 48 | return tinify.from_file(unoptimized_path) 49 | 50 | 51 | def test_should_compress_from_file(optimized_image): # type: (Source) -> None 52 | with create_named_tmpfile() as tmp: 53 | optimized_image.to_file(tmp) 54 | 55 | size = os.path.getsize(tmp) 56 | 57 | with open(tmp, "rb") as f: 58 | contents = f.read() 59 | 60 | assert 1000 < size < 1500 61 | 62 | # width == 137 63 | assert b"\x00\x00\x00\x89" in contents 64 | assert b"Copyright Voormedia" not in contents 65 | 66 | 67 | def test_should_compress_from_url(): 68 | source = tinify.from_url( 69 | "https://raw.githubusercontent.com/tinify/tinify-python/master/test/examples/voormedia.png" 70 | ) 71 | with create_named_tmpfile() as tmp: 72 | source.to_file(tmp) 73 | 74 | size = os.path.getsize(tmp) 75 | with open(tmp, "rb") as f: 76 | contents = f.read() 77 | 78 | assert 1000 < size < 1500 79 | 80 | # width == 137 81 | assert b"\x00\x00\x00\x89" in contents 82 | assert b"Copyright Voormedia" not in contents 83 | 84 | 85 | def test_should_resize(optimized_image): # type: (Source) -> None 86 | with create_named_tmpfile() as tmp: 87 | optimized_image.resize(method="fit", width=50, height=20).to_file(tmp) 88 | size = os.path.getsize(tmp) 89 | with open(tmp, "rb") as f: 90 | contents = f.read() 91 | 92 | assert 500 < size < 1000 93 | 94 | # width == 50 95 | assert b"\x00\x00\x00\x32" in contents 96 | assert b"Copyright Voormedia" not in contents 97 | 98 | 99 | def test_should_preserve_metadata(optimized_image): # type: (Source) -> None 100 | with create_named_tmpfile() as tmp: 101 | optimized_image.preserve("copyright", "creation").to_file(tmp) 102 | 103 | size = os.path.getsize(tmp) 104 | with open(tmp, "rb") as f: 105 | contents = f.read() 106 | 107 | assert 1000 < size < 2000 108 | 109 | # width == 137 110 | assert b"\x00\x00\x00\x89" in contents 111 | assert b"Copyright Voormedia" in contents 112 | 113 | 114 | def test_should_transcode_image(optimized_image): # type: (Source) -> None 115 | with create_named_tmpfile() as tmp: 116 | conv = optimized_image.convert(type=["image/webp"]) 117 | conv.to_file(tmp) 118 | with open(tmp, "rb") as f: 119 | content = f.read() 120 | 121 | assert b"RIFF" == content[:4] 122 | assert b"WEBP" == content[8:12] 123 | 124 | assert conv.result().size < optimized_image.result().size 125 | assert conv.result().media_type == "image/webp" 126 | assert conv.result().extension == "webp" 127 | 128 | 129 | def test_should_handle_invalid_key(): 130 | invalid_key = "invalid_key" 131 | tinify.key = invalid_key 132 | with pytest.raises(tinify.AccountError): 133 | tinify.from_url( 134 | "https://raw.githubusercontent.com/tinify/tinify-python/master/test/examples/voormedia.png" 135 | ) 136 | tinify.key = os.environ.get("TINIFY_KEY") 137 | 138 | def test_should_handle_invalid_image(): 139 | with pytest.raises(tinify.ClientError): 140 | tinify.from_buffer("invalid_image.png") -------------------------------------------------------------------------------- /tinify/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, division, print_function, unicode_literals 3 | 4 | import threading 5 | import sys 6 | try: 7 | from typing import Optional, Any, TYPE_CHECKING 8 | except ImportError: 9 | TYPE_CHECKING = False # type: ignore 10 | 11 | class tinify(object): 12 | 13 | _client = None # type: Optional[Client] 14 | _key = None # type: Optional[str] 15 | _app_identifier = None # type: Optional[str] 16 | _proxy = None # type: Optional[str] 17 | _compression_count = None # type: Optional[int] 18 | 19 | def __init__(self, module): 20 | # type: (Any) -> None 21 | self._module = module 22 | self._lock = threading.RLock() 23 | 24 | self._client = None 25 | self._key = None 26 | self._app_identifier = None 27 | self._proxy = None 28 | self._compression_count = None 29 | 30 | @property 31 | def key(self): 32 | # type: () -> Optional[str] 33 | return self._key 34 | 35 | @key.setter 36 | def key(self, value): 37 | # type: (str) -> None 38 | self._key = value 39 | self._client = None 40 | 41 | @property 42 | def app_identifier(self): 43 | # type: () -> Optional[str] 44 | return self._app_identifier 45 | 46 | @app_identifier.setter 47 | def app_identifier(self, value): 48 | # type: (str) -> None 49 | self._app_identifier = value 50 | self._client = None 51 | 52 | @property 53 | def proxy(self): 54 | # type: () -> Optional[str] 55 | return self._proxy 56 | 57 | @proxy.setter 58 | def proxy(self, value): 59 | # type: (str) -> None 60 | self._proxy = value 61 | self._client = None 62 | 63 | @property 64 | def compression_count(self): 65 | # type: () -> Optional[int] 66 | return self._compression_count 67 | 68 | @compression_count.setter 69 | def compression_count(self, value): 70 | # type: (int) -> None 71 | self._compression_count = value 72 | 73 | def get_client(self): 74 | # type: () -> Client 75 | if not self._key: 76 | raise AccountError('Provide an API key with tinify.key = ...') 77 | 78 | if not self._client: 79 | with self._lock: 80 | if not self._client: 81 | self._client = Client(self._key, self._app_identifier, self._proxy) 82 | 83 | return self._client 84 | 85 | # Delegate to underlying base module. 86 | def __getattr__(self, attr): 87 | # type: (str) -> Any 88 | return getattr(self._module, attr) 89 | 90 | def validate(self): 91 | # type: () -> bool 92 | try: 93 | self.get_client().request('post', '/shrink') 94 | except AccountError as err: 95 | if err.status == 429: 96 | return True 97 | raise err 98 | except ClientError: 99 | return True 100 | return False 101 | 102 | def from_file(self, path): 103 | # type: (str) -> Source 104 | return Source.from_file(path) 105 | 106 | def from_buffer(self, string): 107 | # type: (bytes) -> Source 108 | return Source.from_buffer(string) 109 | 110 | def from_url(self, url): 111 | # type: (str) -> Source 112 | return Source.from_url(url) 113 | 114 | if TYPE_CHECKING: 115 | # Help the type checker here, as we overrride the module with a singleton object. 116 | def get_client(): # type: () -> Client 117 | pass 118 | key = None # type: Optional[str] 119 | app_identifier = None # type: Optional[str] 120 | proxy = None # type: Optional[str] 121 | compression_count = None # type: Optional[int] 122 | 123 | def validate(): # type: () -> bool 124 | pass 125 | 126 | def from_file(path): # type: (str) -> Source 127 | pass 128 | 129 | def from_buffer(string): # type: (bytes) -> Source 130 | pass 131 | 132 | def from_url(url): # type: (str) -> Source 133 | pass 134 | 135 | 136 | # Overwrite current module with singleton object. 137 | tinify = sys.modules[__name__] = tinify(sys.modules[__name__]) # type: ignore 138 | 139 | from .version import __version__ 140 | 141 | from .client import Client 142 | from .result_meta import ResultMeta 143 | from .result import Result 144 | from .source import Source 145 | from .errors import * 146 | 147 | __all__ = [ 148 | 'Client', 149 | 'Result', 150 | 'ResultMeta', 151 | 'Source', 152 | 'Error', 153 | 'AccountError', 154 | 'ClientError', 155 | 'ServerError', 156 | 'ConnectionError' 157 | ] 158 | -------------------------------------------------------------------------------- /test/unit/tinify_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import tinify 3 | import base64 4 | 5 | 6 | def test_key_should_reset_client_with_new_key(mock_requests): 7 | mock_requests.get("https://api.tinify.com/") 8 | tinify.key = "abcde" 9 | tinify.get_client() 10 | tinify.key = "fghij" 11 | tinify.get_client().request("GET", "/") 12 | 13 | # Get the last request made to the endpoint 14 | request = mock_requests.last_request 15 | assert request.headers["authorization"] == "Basic {0}".format( 16 | base64.b64encode(b"api:fghij").decode("ascii") 17 | ) 18 | 19 | 20 | def test_app_identifier_should_reset_client_with_new_app_identifier(mock_requests): 21 | mock_requests.get("https://api.tinify.com/") 22 | tinify.key = "abcde" 23 | tinify.app_identifier = "MyApp/1.0" 24 | tinify.get_client() 25 | tinify.app_identifier = "MyApp/2.0" 26 | tinify.get_client().request("GET", "/") 27 | 28 | request = mock_requests.last_request 29 | assert request.headers["user-agent"] == tinify.Client.USER_AGENT + " MyApp/2.0" 30 | 31 | 32 | def test_proxy_should_reset_client_with_new_proxy(mock_requests): 33 | mock_requests.get("https://api.tinify.com/") 34 | 35 | tinify.key = "abcde" 36 | tinify.proxy = "http://localhost:8080" 37 | tinify.get_client() 38 | 39 | tinify.proxy = "http://localhost:9090" 40 | new_client = tinify.get_client() 41 | 42 | new_client.request("GET", "/") 43 | 44 | # Verify the request was made with the correct proxy configuration 45 | # The proxy settings should be in the session's proxies attribute 46 | assert new_client.session.proxies["https"] == "http://localhost:9090" 47 | 48 | 49 | def test_client_with_key_should_return_client(): 50 | tinify.key = "abcde" 51 | assert isinstance(tinify.get_client(), tinify.Client) 52 | 53 | 54 | def test_client_without_key_should_raise_error(): 55 | tinify.key = None 56 | with pytest.raises(tinify.AccountError): 57 | tinify.get_client() 58 | 59 | 60 | def test_client_with_invalid_proxy_should_raise_error(mock_requests): 61 | # We can test invalid proxy format, but not actual connection issues with requests-mock 62 | tinify.key = "abcde" 63 | tinify.proxy = "http-bad-url" # Invalid proxy URL format 64 | 65 | with pytest.raises(tinify.ConnectionError): 66 | tinify.get_client().request("GET", "/") 67 | 68 | 69 | def test_validate_with_valid_key_should_return_true(mock_requests): 70 | mock_requests.post( 71 | "https://api.tinify.com/shrink", 72 | status_code=400, 73 | json={"error": "Input missing", "message": "No input"}, 74 | ) 75 | 76 | tinify.key = "valid" 77 | assert tinify.validate() is True 78 | 79 | 80 | def test_validate_with_limited_key_should_return_true(mock_requests): 81 | mock_requests.post( 82 | "https://api.tinify.com/shrink", 83 | status_code=429, 84 | json={ 85 | "error": "Too many requests", 86 | "message": "Your monthly limit has been exceeded", 87 | }, 88 | ) 89 | 90 | tinify.key = "valid" 91 | assert tinify.validate() is True 92 | 93 | 94 | def test_validate_with_error_should_raise_error(mock_requests): 95 | mock_requests.post( 96 | "https://api.tinify.com/shrink", 97 | status_code=401, 98 | json={"error": "Unauthorized", "message": "Credentials are invalid"}, 99 | ) 100 | 101 | tinify.key = "valid" 102 | with pytest.raises(tinify.AccountError): 103 | tinify.validate() 104 | 105 | 106 | def test_from_file_should_return_source(mock_requests, tmp_path): 107 | # Create a dummy file 108 | dummy_file = tmp_path / "test.png" 109 | dummy_file.write_bytes(b"png file") 110 | 111 | # Mock the API endpoint 112 | mock_requests.post( 113 | "https://api.tinify.com/shrink", 114 | status_code=201, # Created 115 | headers={"Location": "https://api.tinify.com/some/location"}, 116 | ) 117 | 118 | tinify.key = "valid" 119 | result = tinify.from_file(str(dummy_file)) 120 | assert isinstance(result, tinify.Source) 121 | 122 | 123 | def test_from_buffer_should_return_source(mock_requests): 124 | mock_requests.post( 125 | "https://api.tinify.com/shrink", 126 | status_code=201, # Created 127 | headers={"Location": "https://api.tinify.com/some/location"}, 128 | ) 129 | 130 | tinify.key = "valid" 131 | result = tinify.from_buffer("png file") 132 | assert isinstance(result, tinify.Source) 133 | 134 | 135 | def test_from_url_should_return_source(mock_requests): 136 | mock_requests.post( 137 | "https://api.tinify.com/shrink", 138 | status_code=201, # Created 139 | headers={"Location": "https://api.tinify.com/some/location"}, 140 | ) 141 | 142 | tinify.key = "valid" 143 | result = tinify.from_url("http://example.com/test.jpg") 144 | assert isinstance(result, tinify.Source) 145 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![MIT License](http://img.shields.io/badge/license-MIT-green.svg) ](https://github.com/tinify/tinify-python/blob/main/LICENSE) 2 | [![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) 3 | [![PyPI](https://img.shields.io/pypi/v/tinify)](https://pypi.org/project/tinify/#history) 4 | [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/tinify)](https://pypi.org/project/tinify/) 5 | [![PyPI - Wheel](https://img.shields.io/pypi/wheel/tinify)](https://pypi.org/project/tinify/) 6 | 7 | 8 | # Tinify API client for Python 9 | 10 | **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). 11 | 12 | 13 | [Go to the full documentation for the Python client](https://tinypng.com/developers/reference/python). 14 | 15 | ## Features 16 | 17 | - Compress and optimize images, reducing file size by 50-80% while preserving visual quality 18 | - Resize and crop images with smart compression 19 | - Convert between PNG, JPEG, WebP and AVIF formats 20 | - Preserve metadata (optional) 21 | - Upload to storage providers like Amazon S3, Google cloud storage. 22 | - Apply visual transformations with the Tinify API 23 | - Comprehensive error handling 24 | 25 | 26 | 27 | ## Requirements 28 | 29 | - Python 2.7+ 30 | - Requests library 31 | 32 | ## Installation 33 | 34 | Install the API client with pip: 35 | 36 | ```bash 37 | pip install tinify 38 | ``` 39 | 40 | ## Quick start 41 | 42 | 43 | ```python 44 | import tinify 45 | 46 | # Set your API key (get one for free at https://tinypng.com/developers) 47 | tinify.key = "YOUR_API_KEY" 48 | 49 | # Compress an image from a file 50 | tinify.from_file("unoptimized.png").to_file("optimized.png") 51 | 52 | # Compress from URL 53 | tinify.from_url("https://example.com/image.jpg").to_file("optimized.jpg") 54 | 55 | # Compress from buffer 56 | source_data = b"" 57 | tinify.from_buffer(source_data).to_file("optimized.jpg") 58 | ``` 59 | 60 | ## Advanced Usage 61 | 62 | ### Resizing 63 | 64 | ```python 65 | # Scale image to fit within 300x200px while preserving aspect ratio 66 | tinify.from_file("original.jpg").resize( 67 | method="scale", 68 | width=300, 69 | height=200 70 | ).to_file("resized.jpg") 71 | 72 | # Fit image to exact 300x200px dimensions 73 | tinify.from_file("original.jpg").resize( 74 | method="fit", 75 | width=300, 76 | height=200 77 | ).to_file("resized.jpg") 78 | 79 | # Cover 300x200px area while preserving aspect ratio 80 | tinify.from_file("original.jpg").resize( 81 | method="cover", 82 | width=300, 83 | height=200 84 | ).to_file("resized.jpg") 85 | ``` 86 | 87 | ### Format Conversion 88 | 89 | ```python 90 | # Convert to WebP format 91 | tinify.from_file("image.png").convert( 92 | type=["image/webp"] 93 | ).to_file("image.webp") 94 | ``` 95 | 96 | ```python 97 | # Convert to smallest format 98 | converted = tinify.from_file("image.png").convert( 99 | type=["image/webp", "image/webp"] 100 | ) 101 | extension = converted.result().extension 102 | converted.to_file("image." + extension) 103 | ``` 104 | 105 | ### Compression Count Monitoring 106 | 107 | ```python 108 | # Check the number of compressions made this month 109 | compression_count = tinify.compression_count 110 | print(f"You have made {compression_count} compressions this month") 111 | ``` 112 | 113 | ## Error Handling 114 | 115 | ```python 116 | import tinify 117 | 118 | tinify.key = "YOUR_API_KEY" 119 | 120 | try: 121 | tinify.from_file("unoptimized.png").to_file("optimized.png") 122 | except tinify.AccountError as e: 123 | # Verify or update API key 124 | print(f"Account error: {e.message}") 125 | except tinify.ClientError as e: 126 | # Handle client errors (e.g., invalid image) 127 | print(f"Client error: {e.message}") 128 | except tinify.ServerError as e: 129 | # Handle server errors 130 | print(f"Server error: {e.message}") 131 | except tinify.ConnectionError as e: 132 | # Handle network connectivity issues 133 | print(f"Connection error: {e.message}") 134 | except Exception as e: 135 | # Handle general errors 136 | print(f"Error: {str(e)}") 137 | ``` 138 | 139 | ## Running tests 140 | 141 | ``` 142 | pip install -r requirements.txt -r test-requirements.txt 143 | py.test 144 | ``` 145 | 146 | To test more runtimes, tox can be used 147 | 148 | ``` 149 | tox 150 | ``` 151 | 152 | 153 | 154 | ### Integration tests 155 | 156 | ``` 157 | pip install -r requirements.txt -r test-requirements.txt 158 | TINIFY_KEY=$YOUR_API_KEY py.test test/integration.py 159 | ``` 160 | 161 | ## License 162 | 163 | This software is licensed under the MIT License. See [LICENSE](https://github.com/tinify/tinify-python/blob/master/LICENSE) for details. 164 | 165 | ## Support 166 | 167 | 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) 168 | -------------------------------------------------------------------------------- /test/unit/tinify_client_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import requests 3 | import json 4 | import base64 5 | import tinify 6 | from tinify import Client, ClientError, ServerError, ConnectionError, AccountError 7 | 8 | Client.RETRY_DELAY = 10 9 | 10 | 11 | def b64encode(data): 12 | return base64.b64encode(data) 13 | 14 | 15 | @pytest.fixture 16 | def client(): 17 | return Client("key") 18 | 19 | 20 | class TestClientRequestWhenValid: 21 | def test_should_issue_request(self, mock_requests, client): 22 | mock_requests.get( 23 | "https://api.tinify.com/", headers={"compression-count": "12"} 24 | ) 25 | 26 | client.request("GET", "/") 27 | 28 | request = mock_requests.last_request 29 | auth_header = "Basic {0}".format(b64encode(b"api:key").decode("ascii")) 30 | assert request.headers["authorization"] == auth_header 31 | 32 | def test_should_issue_request_without_body_when_options_are_empty( 33 | self, mock_requests, client 34 | ): 35 | mock_requests.get( 36 | "https://api.tinify.com/", headers={"compression-count": "12"} 37 | ) 38 | 39 | client.request("GET", "/", {}) 40 | 41 | request = mock_requests.last_request 42 | assert not request.text or request.text == "" 43 | 44 | def test_should_issue_request_without_content_type_when_options_are_empty( 45 | self, mock_requests, client 46 | ): 47 | mock_requests.get( 48 | "https://api.tinify.com/", headers={"compression-count": "12"} 49 | ) 50 | 51 | client.request("GET", "/", {}) 52 | 53 | request = mock_requests.last_request 54 | assert "content-type" not in request.headers 55 | 56 | def test_should_issue_request_with_json_body(self, mock_requests, client): 57 | mock_requests.get( 58 | "https://api.tinify.com/", headers={"compression-count": "12"} 59 | ) 60 | 61 | client.request("GET", "/", {"hello": "world"}) 62 | 63 | request = mock_requests.last_request 64 | assert request.headers["content-type"] == "application/json" 65 | assert request.text == '{"hello":"world"}' 66 | 67 | def test_should_issue_request_with_user_agent(self, mock_requests, client): 68 | mock_requests.get( 69 | "https://api.tinify.com/", headers={"compression-count": "12"} 70 | ) 71 | 72 | client.request("GET", "/") 73 | 74 | request = mock_requests.last_request 75 | assert request.headers["user-agent"] == Client.USER_AGENT 76 | 77 | def test_should_update_compression_count(self, mock_requests, client): 78 | mock_requests.get( 79 | "https://api.tinify.com/", headers={"compression-count": "12"} 80 | ) 81 | 82 | client.request("GET", "/") 83 | 84 | assert tinify.compression_count == 12 85 | 86 | 87 | class TestClientRequestWhenValidWithAppId: 88 | def test_should_issue_request_with_user_agent(self, mock_requests): 89 | mock_requests.get( 90 | "https://api.tinify.com/", headers={"compression-count": "12"} 91 | ) 92 | 93 | Client("key", "TestApp/0.2").request("GET", "/") 94 | 95 | request = mock_requests.last_request 96 | assert request.headers["user-agent"] == Client.USER_AGENT + " TestApp/0.2" 97 | 98 | 99 | class TestClientRequestWhenValidWithProxy: 100 | @pytest.mark.skip( 101 | reason="requests does not set a proxy unless a real proxy is used" 102 | ) 103 | def test_should_issue_request_with_proxy_authorization(self, mock_requests): 104 | proxy_url = "http://user:pass@localhost:8080" 105 | expected_auth = "Basic " + base64.b64encode(b"user:pass").decode() 106 | 107 | mock_requests.get("https://api.tinify.com/", status_code=200) 108 | 109 | client = Client("key", None, proxy_url) 110 | client.request("GET", "/") 111 | 112 | # Verify the last request captured by requests-mock 113 | last_request = mock_requests.last_request 114 | assert last_request is not None 115 | assert last_request.headers.get("Proxy-Authorization") == expected_auth 116 | 117 | 118 | class TestClientRequestWithTimeout: 119 | def test_should_raise_connection_error_repeatedly(self, mock_requests): 120 | mock_requests.get( 121 | "https://api.tinify.com/", 122 | [ 123 | {"exc": requests.exceptions.Timeout}, 124 | ], 125 | ) 126 | with pytest.raises(ConnectionError) as excinfo: 127 | Client("key").request("GET", "/") 128 | assert str(excinfo.value) == "Timeout while connecting" 129 | assert isinstance(excinfo.value.__cause__, requests.exceptions.Timeout) 130 | 131 | def test_should_issue_request_after_timeout_once(self, mock_requests): 132 | # Confirm retry happens after timeout 133 | mock_requests.get( 134 | "https://api.tinify.com/", 135 | [ 136 | {"exc": requests.exceptions.Timeout("Timeout")}, 137 | { 138 | "status_code": 201, 139 | "headers": {"compression-count": "12"}, 140 | "text": "success", 141 | }, 142 | ], 143 | ) 144 | 145 | result = Client("key").request("GET", "/", {}) 146 | 147 | assert result.status_code == 201 148 | assert mock_requests.call_count == 2 # Verify retry happened 149 | 150 | 151 | class TestClientRequestWithConnectionError: 152 | def test_should_raise_connection_error_repeatedly(self, mock_requests): 153 | mock_requests.get( 154 | "https://api.tinify.com/", 155 | [ 156 | {"exc": requests.exceptions.ConnectionError("connection error")}, 157 | ], 158 | ) 159 | with pytest.raises(ConnectionError) as excinfo: 160 | Client("key").request("GET", "/") 161 | assert str(excinfo.value) == "Error while connecting: connection error" 162 | assert isinstance(excinfo.value.__cause__, requests.exceptions.ConnectionError) 163 | 164 | def test_should_issue_request_after_connection_error_once(self, mock_requests): 165 | # Mock the request to fail with ConnectionError once, then succeed 166 | mock_requests.get( 167 | "https://api.tinify.com/", 168 | [ 169 | {"exc": requests.exceptions.ConnectionError}, # First attempt fails 170 | { 171 | "status_code": 201, 172 | "headers": {"compression-count": "12"}, 173 | "text": "success", 174 | }, # Second attempt succeeds 175 | ], 176 | ) 177 | 178 | client = Client("key") 179 | result = client.request("GET", "/", {}) 180 | 181 | # Verify results 182 | assert result.status_code == 201 183 | assert mock_requests.call_count == 2 # Ensure it retried 184 | 185 | 186 | class TestClientRequestWithSomeError: 187 | def test_should_raise_connection_error_repeatedly(self, mock_requests): 188 | mock_requests.get( 189 | "https://api.tinify.com/", 190 | [ 191 | {"exc": RuntimeError("some error")}, 192 | ], 193 | ) 194 | with pytest.raises(ConnectionError) as excinfo: 195 | Client("key").request("GET", "/") 196 | assert str(excinfo.value) == "Error while connecting: some error" 197 | 198 | def test_should_issue_request_after_some_error_once(self, mock_requests): 199 | # Mock the request to fail with RuntimeError once, then succeed 200 | mock_requests.get( 201 | "https://api.tinify.com/", 202 | [ 203 | {"exc": RuntimeError("some error")}, # First attempt fails 204 | { 205 | "status_code": 201, 206 | "headers": {"compression-count": "12"}, 207 | "text": "success", 208 | }, # Second attempt succeeds 209 | ], 210 | ) 211 | 212 | client = Client("key") 213 | result = client.request("GET", "/", {}) 214 | 215 | # Verify results 216 | assert result.status_code == 201 217 | assert mock_requests.call_count == 2 # Ensure it retried 218 | 219 | 220 | class TestClientRequestWithServerError: 221 | def test_should_raise_server_error_repeatedly(self, mock_requests): 222 | error_body = json.dumps({"error": "InternalServerError", "message": "Oops!"}) 223 | mock_requests.get("https://api.tinify.com/", status_code=584, text=error_body) 224 | 225 | with pytest.raises(ServerError) as excinfo: 226 | Client("key").request("GET", "/") 227 | assert str(excinfo.value) == "Oops! (HTTP 584/InternalServerError)" 228 | 229 | def test_should_issue_request_after_server_error_once(self, mock_requests): 230 | error_body = json.dumps({"error": "InternalServerError", "message": "Oops!"}) 231 | # First call returns error, second succeeds 232 | mock_requests.register_uri( 233 | "GET", 234 | "https://api.tinify.com/", 235 | [ 236 | {"status_code": 584, "text": error_body}, 237 | {"status_code": 201, "text": "all good"}, 238 | ], 239 | ) 240 | 241 | response = Client("key").request("GET", "/") 242 | 243 | assert response.status_code == 201 244 | 245 | 246 | class TestClientRequestWithBadServerResponse: 247 | def test_should_raise_server_error_repeatedly(self, mock_requests): 248 | mock_requests.get( 249 | "https://api.tinify.com/", status_code=543, text="" 250 | ) 251 | 252 | with pytest.raises(ServerError) as excinfo: 253 | Client("key").request("GET", "/") 254 | # Using pytest's assert to check regex pattern 255 | error_message = str(excinfo.value) 256 | assert "Error while parsing response:" in error_message 257 | assert "(HTTP 543/ParseError)" in error_message 258 | 259 | def test_should_issue_request_after_bad_response_once(self, mock_requests): 260 | # First call returns invalid JSON, second succeeds 261 | mock_requests.register_uri( 262 | "GET", 263 | "https://api.tinify.com/", 264 | [ 265 | {"status_code": 543, "text": ""}, 266 | {"status_code": 201, "text": "all good"}, 267 | ], 268 | ) 269 | 270 | response = Client("key").request("GET", "/") 271 | 272 | assert response.status_code == 201 273 | 274 | 275 | class TestClientRequestWithClientError: 276 | def test_should_raise_client_error(self, mock_requests): 277 | error_body = json.dumps({"error": "BadRequest", "message": "Oops!"}) 278 | mock_requests.get("https://api.tinify.com/", status_code=492, text=error_body) 279 | 280 | with pytest.raises(ClientError) as excinfo: 281 | Client("key").request("GET", "/") 282 | assert str(excinfo.value) == "Oops! (HTTP 492/BadRequest)" 283 | 284 | 285 | class TestClientRequestWithBadCredentialsResponse: 286 | def test_should_raise_account_error(self, mock_requests): 287 | error_body = json.dumps({"error": "Unauthorized", "message": "Oops!"}) 288 | mock_requests.get("https://api.tinify.com/", status_code=401, text=error_body) 289 | 290 | with pytest.raises(AccountError) as excinfo: 291 | Client("key").request("GET", "/") 292 | assert str(excinfo.value) == "Oops! (HTTP 401/Unauthorized)" 293 | -------------------------------------------------------------------------------- /test/unit/tinify_source_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | import json 4 | import tempfile 5 | import pytest 6 | 7 | import tinify 8 | from tinify import Source, Result, ResultMeta, AccountError, ClientError 9 | 10 | 11 | def create_named_tmpfile(): 12 | """Helper to create a named temporary file""" 13 | fd, name = tempfile.mkstemp() 14 | os.close(fd) 15 | return name 16 | 17 | 18 | def assert_json_equal(expected, actual): 19 | """Helper to assert JSON equality""" 20 | if isinstance(actual, str): 21 | actual = json.loads(actual) 22 | if isinstance(expected, str): 23 | expected = json.loads(expected) 24 | assert expected == actual 25 | 26 | 27 | class TestTinifySourceWithInvalidApiKey: 28 | @pytest.fixture(autouse=True) 29 | def setup(self, mock_requests): 30 | tinify.key = "invalid" 31 | mock_requests.post("https://api.tinify.com/shrink", status_code=401) 32 | yield 33 | 34 | def test_from_file_should_raise_account_error(self, dummy_file): 35 | with pytest.raises(AccountError): 36 | Source.from_file(dummy_file) 37 | 38 | def test_from_buffer_should_raise_account_error(self): 39 | with pytest.raises(AccountError): 40 | Source.from_buffer("png file") 41 | 42 | def test_from_url_should_raise_account_error(self): 43 | with pytest.raises(AccountError): 44 | Source.from_url("http://example.com/test.jpg") 45 | 46 | 47 | class TestTinifySourceWithValidApiKey: 48 | @pytest.fixture(autouse=True) 49 | def setup_teardown(self, mock_requests): 50 | tinify.key = "valid" 51 | mock_requests.post( 52 | "https://api.tinify.com/shrink", 53 | status_code=201, 54 | headers={"location": "https://api.tinify.com/some/location"}, 55 | ) 56 | mock_requests.get( 57 | "https://api.tinify.com/some/location", content=self.return_file 58 | ) 59 | mock_requests.post( 60 | "https://api.tinify.com/some/location", content=self.return_file 61 | ) 62 | yield 63 | 64 | def return_file(self, request, context): 65 | data = request.json() if request.body else {} 66 | if "store" in data: 67 | context.headers["location"] = ( 68 | "https://bucket.s3-region.amazonaws.com/some/location" 69 | ) 70 | return json.dumps({"status": "success"}).encode("utf-8") 71 | elif "preserve" in data: 72 | return b"copyrighted file" 73 | elif "resize" in data: 74 | return b"small file" 75 | elif "convert" in data: 76 | return b"converted file" 77 | elif "transform" in data: 78 | return b"transformed file" 79 | else: 80 | return b"compressed file" 81 | 82 | def test_from_file_with_path_should_return_source(self, dummy_file): 83 | assert isinstance(Source.from_file(dummy_file), Source) 84 | 85 | def test_from_file_with_path_should_return_source_with_data(self, dummy_file): 86 | assert b"compressed file" == Source.from_file(dummy_file).to_buffer() 87 | 88 | def test_from_file_with_file_object_should_return_source(self, dummy_file): 89 | with open(dummy_file, "rb") as f: 90 | assert isinstance(Source.from_file(f), Source) 91 | 92 | def test_from_file_with_file_object_should_return_source_with_data( 93 | self, dummy_file 94 | ): 95 | with open(dummy_file, "rb") as f: 96 | assert b"compressed file" == Source.from_file(f).to_buffer() 97 | 98 | def test_from_buffer_should_return_source(self): 99 | assert isinstance(Source.from_buffer("png file"), Source) 100 | 101 | def test_from_buffer_should_return_source_with_data(self): 102 | assert b"compressed file" == Source.from_buffer("png file").to_buffer() 103 | 104 | def test_from_url_should_return_source(self): 105 | assert isinstance(Source.from_url("http://example.com/test.jpg"), Source) 106 | 107 | def test_from_url_should_return_source_with_data(self): 108 | assert ( 109 | b"compressed file" 110 | == Source.from_url("http://example.com/test.jpg").to_buffer() 111 | ) 112 | 113 | def test_from_url_should_raise_error_when_server_doesnt_return_a_success( 114 | self, mock_requests 115 | ): 116 | mock_requests.post( 117 | "https://api.tinify.com/shrink", 118 | json={"error": "Source not found", "message": "Cannot parse URL"}, 119 | status_code=400, 120 | ) 121 | with pytest.raises(ClientError): 122 | Source.from_url("file://wrong") 123 | 124 | def test_result_should_return_result(self): 125 | assert isinstance(Source.from_buffer(b"png file").result(), Result) 126 | 127 | def test_result_should_use_get_when_commands_is_empty(self, mock_requests): 128 | source = Source(b"png file") 129 | source.url = "https://api.tinify.com/some/location" 130 | mock_requests.get( 131 | "https://api.tinify.com/some/location", content=b"compressed file" 132 | ) 133 | source.result() 134 | assert mock_requests.call_count == 1 135 | assert mock_requests.last_request.method == "GET" 136 | 137 | def test_result_should_use_post_when_commands_is_not_empty(self, mock_requests): 138 | source = Source(b"png file").resize(width=400) 139 | source.url = "https://api.tinify.com/some/location" 140 | mock_requests.post( 141 | "https://api.tinify.com/some/location", content=b"small file" 142 | ) 143 | source.result() 144 | assert mock_requests.call_count == 1 145 | assert mock_requests.last_request.method == "POST" 146 | 147 | def test_preserve_should_return_source(self, mock_requests): 148 | assert isinstance( 149 | Source.from_buffer(b"png file").preserve("copyright", "location"), Source 150 | ) 151 | assert b"png file" == mock_requests.last_request.body 152 | 153 | def test_preserve_should_return_source_with_data(self, mock_requests): 154 | assert ( 155 | b"copyrighted file" 156 | == Source.from_buffer(b"png file") 157 | .preserve("copyright", "location") 158 | .to_buffer() 159 | ) 160 | assert_json_equal( 161 | '{"preserve":["copyright","location"]}', mock_requests.last_request.json() 162 | ) 163 | 164 | def test_preserve_should_return_source_with_data_for_array(self, mock_requests): 165 | assert ( 166 | b"copyrighted file" 167 | == Source.from_buffer(b"png file") 168 | .preserve(["copyright", "location"]) 169 | .to_buffer() 170 | ) 171 | assert_json_equal( 172 | '{"preserve":["copyright","location"]}', mock_requests.last_request.json() 173 | ) 174 | 175 | def test_preserve_should_return_source_with_data_for_tuple(self, mock_requests): 176 | assert ( 177 | b"copyrighted file" 178 | == Source.from_buffer(b"png file") 179 | .preserve(("copyright", "location")) 180 | .to_buffer() 181 | ) 182 | assert_json_equal( 183 | '{"preserve":["copyright","location"]}', mock_requests.last_request.json() 184 | ) 185 | 186 | def test_preserve_should_include_other_options_if_set(self, mock_requests): 187 | assert ( 188 | b"copyrighted file" 189 | == Source.from_buffer(b"png file") 190 | .resize(width=400) 191 | .preserve("copyright", "location") 192 | .to_buffer() 193 | ) 194 | assert_json_equal( 195 | '{"preserve":["copyright","location"],"resize":{"width":400}}', 196 | mock_requests.last_request.json(), 197 | ) 198 | 199 | def test_resize_should_return_source(self, mock_requests): 200 | assert isinstance(Source.from_buffer(b"png file").resize(width=400), Source) 201 | assert b"png file" == mock_requests.last_request.body 202 | 203 | def test_resize_should_return_source_with_data(self, mock_requests): 204 | assert ( 205 | b"small file" 206 | == Source.from_buffer(b"png file").resize(width=400).to_buffer() 207 | ) 208 | assert_json_equal('{"resize":{"width":400}}', mock_requests.last_request.json()) 209 | 210 | def test_transform_should_return_source(self, mock_requests): 211 | assert isinstance( 212 | Source.from_buffer(b"png file").transform(background="black"), Source 213 | ) 214 | assert b"png file" == mock_requests.last_request.body 215 | 216 | def test_transform_should_return_source_with_data(self, mock_requests): 217 | assert ( 218 | b"transformed file" 219 | == Source.from_buffer(b"png file").transform(background="black").to_buffer() 220 | ) 221 | assert_json_equal( 222 | '{"transform":{"background":"black"}}', mock_requests.last_request.json() 223 | ) 224 | 225 | def test_convert_should_return_source(self, mock_requests): 226 | assert isinstance( 227 | Source.from_buffer(b"png file") 228 | .resize(width=400) 229 | .convert(type=["image/webp"]), 230 | Source, 231 | ) 232 | assert b"png file" == mock_requests.last_request.body 233 | 234 | def test_convert_should_return_source_with_data(self, mock_requests): 235 | assert ( 236 | b"converted file" 237 | == Source.from_buffer(b"png file").convert(type="image/jpg").to_buffer() 238 | ) 239 | assert_json_equal( 240 | '{"convert": {"type": "image/jpg"}}', mock_requests.last_request.json() 241 | ) 242 | 243 | def test_store_should_return_result_meta(self, mock_requests): 244 | assert isinstance( 245 | Source.from_buffer(b"png file").store(service="s3"), ResultMeta 246 | ) 247 | assert_json_equal( 248 | '{"store":{"service":"s3"}}', mock_requests.last_request.json() 249 | ) 250 | 251 | def test_store_should_return_result_meta_with_location(self, mock_requests): 252 | assert ( 253 | "https://bucket.s3-region.amazonaws.com/some/location" 254 | == Source.from_buffer(b"png file").store(service="s3").location 255 | ) 256 | assert_json_equal( 257 | '{"store":{"service":"s3"}}', mock_requests.last_request.json() 258 | ) 259 | 260 | def test_store_should_include_other_options_if_set(self, mock_requests): 261 | assert ( 262 | "https://bucket.s3-region.amazonaws.com/some/location" 263 | == Source.from_buffer(b"png file") 264 | .resize(width=400) 265 | .store(service="s3") 266 | .location 267 | ) 268 | assert_json_equal( 269 | '{"store":{"service":"s3"},"resize":{"width":400}}', 270 | mock_requests.last_request.json(), 271 | ) 272 | 273 | def test_to_buffer_should_return_image_data(self): 274 | assert b"compressed file" == Source.from_buffer(b"png file").to_buffer() 275 | 276 | def test_to_file_with_path_should_store_image_data(self): 277 | with tempfile.TemporaryFile() as tmp: 278 | Source.from_buffer(b"png file").to_file(tmp) 279 | tmp.seek(0) 280 | assert b"compressed file" == tmp.read() 281 | 282 | def test_to_file_with_file_object_should_store_image_data(self): 283 | name = create_named_tmpfile() 284 | try: 285 | Source.from_buffer(b"png file").to_file(name) 286 | with open(name, "rb") as f: 287 | assert b"compressed file" == f.read() 288 | finally: 289 | os.unlink(name) 290 | 291 | def test_all_options_together(self, mock_requests): 292 | assert ( 293 | "https://bucket.s3-region.amazonaws.com/some/location" 294 | == Source.from_buffer(b"png file") 295 | .resize(width=400) 296 | .convert(type=["image/webp", "image/png"]) 297 | .transform(background="black") 298 | .preserve("copyright", "location") 299 | .store(service="s3") 300 | .location 301 | ) 302 | assert_json_equal( 303 | '{"store":{"service":"s3"},"resize":{"width":400},"preserve": ["copyright", "location"], "transform": {"background": "black"}, "convert": {"type": ["image/webp", "image/png"]}}', 304 | mock_requests.last_request.json(), 305 | ) 306 | --------------------------------------------------------------------------------