├── .github └── workflows │ └── ci-cd.yml ├── .gitignore ├── CHANGES.md ├── LICENSE ├── README.md ├── pypi.md ├── requirements.txt ├── setup.cfg ├── setup.py ├── test-requirements.txt ├── test ├── __init__.py ├── examples │ ├── dummy.png │ └── voormedia.png ├── integration.py └── unit │ ├── __init__.py │ ├── conftest.py │ ├── tinify_client_test.py │ ├── tinify_result_meta_test.py │ ├── tinify_result_test.py │ ├── tinify_source_test.py │ └── tinify_test.py ├── tinify ├── __init__.py ├── _typed.py ├── client.py ├── data │ └── cacert.pem ├── errors.py ├── py.typed ├── result.py ├── result_meta.py ├── source.py └── version.py ├── tox.ini └── update-cacert.sh /.github/workflows/ci-cd.yml: -------------------------------------------------------------------------------- 1 | name: Python CI/CD 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: {} 6 | jobs: 7 | Unit_Tests_Py27: 8 | runs-on: ubuntu-20.04 9 | container: 10 | image: python:2.7.18-buster 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Install dependencies 14 | run: | 15 | pip install -r test-requirements.txt -r requirements.txt 16 | - name: Test 17 | run: | 18 | pytest 19 | 20 | Unit_tests: 21 | runs-on: ${{ matrix.os }} 22 | timeout-minutes: 10 23 | strategy: 24 | fail-fast: false 25 | matrix: 26 | python-version: [ 27 | "3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14-dev", 28 | "pypy-2.7", "pypy-3.10" 29 | ] 30 | os: [ubuntu-latest, macOS-latest, windows-latest] 31 | 32 | steps: 33 | - uses: actions/checkout@v3 34 | - name: Set up Python ${{ matrix.python-version }} 35 | uses: actions/setup-python@v4 36 | with: 37 | python-version: ${{ matrix.python-version }} 38 | - name: Install dependencies 39 | run: | 40 | pip install -r test-requirements.txt -r requirements.txt 41 | - name: Run tests 42 | run: | 43 | pytest 44 | 45 | Mypy: 46 | runs-on: ${{ matrix.os }} 47 | timeout-minutes: 10 48 | strategy: 49 | fail-fast: false 50 | matrix: 51 | python-version: [ 52 | "3.12" 53 | ] 54 | os: [ubuntu-latest, macOS-latest, windows-latest] 55 | steps: 56 | - uses: actions/checkout@v3 57 | - name: Set up Python ${{ matrix.python-version }} 58 | uses: actions/setup-python@v4 59 | with: 60 | python-version: ${{ matrix.python-version }} 61 | - name: Install dependencies 62 | run: | 63 | pip install -r test-requirements.txt -r requirements.txt 64 | - name: Run tests 65 | run: | 66 | mypy --check tinify 67 | 68 | Integration_tests: 69 | if: github.event_name == 'push' 70 | runs-on: ${{ matrix.os }} 71 | timeout-minutes: 10 72 | needs: [Unit_tests, Mypy, Unit_Tests_Py27] 73 | strategy: 74 | fail-fast: false 75 | matrix: 76 | python-version: [ 77 | "3.13", 78 | ] 79 | os: [ubuntu-latest, macOS-latest, windows-latest] 80 | steps: 81 | - uses: actions/checkout@v3 82 | - name: Set up Python ${{ matrix.python-version }} 83 | uses: actions/setup-python@v4 84 | with: 85 | python-version: ${{ matrix.python-version }} 86 | - name: Install dependencies 87 | run: | 88 | pip install -r test-requirements.txt -r requirements.txt 89 | - name: Run tests 90 | env: 91 | TINIFY_KEY: ${{ secrets.TINIFY_KEY }} 92 | run: | 93 | pytest test/integration.py 94 | 95 | Publish: 96 | if: | 97 | github.repository == 'tinify/tinify-python' && 98 | startsWith(github.ref, 'refs/tags') && 99 | github.event_name == 'push' 100 | timeout-minutes: 10 101 | needs: [Unit_tests, Integration_tests] 102 | runs-on: ubuntu-latest 103 | steps: 104 | - uses: actions/checkout@v3 105 | with: 106 | fetch-depth: 0 107 | persist-credentials: false 108 | - name: Set up Python 109 | uses: actions/setup-python@v4 110 | with: 111 | python-version: "3.13" 112 | - name: Install dependencies 113 | run: | 114 | pip install -r requirements.txt 115 | pip install build wheel 116 | - name: Check if properly tagged 117 | run: | 118 | PACKAGE_VERSION="$(python -c 'from tinify import __version__;print(__version__)')"; 119 | CURRENT_TAG="${GITHUB_REF#refs/*/}"; 120 | if [[ "${PACKAGE_VERSION}" != "${CURRENT_TAG}" ]]; then 121 | >&2 echo "Tag mismatch" 122 | >&2 echo "Version in tinify/version.py (${PACKAGE_VERSION}) does not match the current tag=${CURRENT_TAG}" 123 | >&2 echo "Skipping deploy" 124 | exit 1; 125 | fi 126 | - name: Build package (sdist & wheel) 127 | run: | 128 | python -m build --sdist --wheel --outdir dist/ 129 | - name: Test sdist install 130 | run: | 131 | python -m venv sdist_env 132 | ./sdist_env/bin/pip install dist/tinify*.tar.gz 133 | - name: Test wheel install 134 | run: | 135 | python -m venv wheel_env 136 | ./wheel_env/bin/pip install dist/tinify*.whl 137 | - name: Publish package to PyPI 138 | uses: pypa/gh-action-pypi-publish@release/v1 139 | with: 140 | user: __token__ 141 | password: ${{ secrets.PYPI_ACCESS_TOKEN }} 142 | # Use the test repository for testing the publish feature 143 | # repository_url: https://test.pypi.org/legacy/ 144 | packages_dir: dist/ 145 | print_hash: true 146 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | __pycache__/ 3 | build/ 4 | dist/ 5 | *.egg-info/ 6 | .tox 7 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | ## 1.7.0 2 | 3 | * Added type annotations 4 | * Updated runtime support 5 | * Dropped python 3.7 6 | * Added Python 3.12 7 | * Added Python 3.13 8 | * Tests: Replaced httpretty with requests-mock 9 | 10 | ## 1.6.0 11 | * Updated runtime support 12 | * Dropped 2.6 13 | * Added python 3.7 14 | * Added python 3.8 15 | * Added python 3.9 16 | * Added python 3.10 17 | * Added python 3.11 18 | * Fixed tests on windows 19 | * Add methods for the transcoding and transformation API 20 | * Add a method for getting the file extension from a Result object 21 | 22 | ## 1.5.2 23 | Remove letsencrypt DST Root from ca bundle for openssl 1.0.0 compatibility 24 | 25 | ## 1.5.1 26 | * Fix proxy setter. 27 | 28 | ## 1.5.0 29 | * Retry failed requests by default. 30 | 31 | ## 1.4.0 32 | * Added support for HTTP proxies. 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2013-2018 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 | -------------------------------------------------------------------------------- /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 | 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). 11 | 12 | ## Documentation 13 | 14 | [Go to the documentation for the Python client](https://tinypng.com/developers/reference/python). 15 | 16 | ## Installation 17 | 18 | Install the API client: 19 | 20 | ``` 21 | pip install tinify 22 | ``` 23 | 24 | ## Usage 25 | 26 | ```python 27 | import tinify 28 | tinify.key = 'YOUR_API_KEY' 29 | 30 | tinify.from_file('unoptimized.png').to_file('optimized.png') 31 | ``` 32 | 33 | ## Running tests 34 | 35 | ``` 36 | pip install -r requirements.txt -r test-requirements.txt 37 | py.test 38 | ``` 39 | 40 | To test more runtimes, tox can be used 41 | 42 | ``` 43 | tox 44 | ``` 45 | 46 | 47 | 48 | ### Integration tests 49 | 50 | ``` 51 | pip install -r requirements.txt -r test-requirements.txt 52 | TINIFY_KEY=$YOUR_API_KEY py.test test/integration.py 53 | ``` 54 | 55 | ## License 56 | 57 | This software is licensed under the MIT License. [View the license](LICENSE). 58 | -------------------------------------------------------------------------------- /pypi.md: -------------------------------------------------------------------------------- 1 | # Tinify 2 | 3 | **Tinify** is the official Python client for the [TinyPNG](https://tinypng.com) and [TinyJPG](https://tinyjpg.com) image compression API, enabling developers to optimize PNG, JPEG, and WebP images programmatically. 4 | 5 | [![PyPI version](https://badge.fury.io/py/tinify.svg)](https://badge.fury.io/py/tinify) 6 | [![Build Status](https://github.com/tinify/tinify-python/actions/workflows/ci-cd.yml/badge.svg)](https://github.com/tinify/tinify-python/) 7 | ## Features 8 | 9 | - Compress images, reducing file size by 50-80% while preserving visual quality 10 | - Resize and crop images with smart compression 11 | - Convert between PNG, JPEG, and WebP formats 12 | - Preserve metadata (optional) 13 | - Apply visual transformations with the Tinify API 14 | - Supports asynchronous operations 15 | - Comprehensive error handling 16 | 17 | ## Installation 18 | 19 | ```python 20 | pip install tinify 21 | ``` 22 | 23 | ## Quick Start 24 | 25 | ```python 26 | import tinify 27 | 28 | # Set your API key (get one for free at https://tinypng.com/developers) 29 | tinify.key = "YOUR_API_KEY" 30 | 31 | # Compress an image from a file 32 | tinify.from_file("unoptimized.png").to_file("optimized.png") 33 | 34 | # Compress from URL 35 | tinify.from_url("https://example.com/image.jpg").to_file("optimized.jpg") 36 | 37 | # Compress from buffer 38 | source_data = b"" 39 | tinify.from_buffer(source_data).to_file("optimized.jpg") 40 | ``` 41 | 42 | ## Advanced Usage 43 | 44 | ### Resizing 45 | 46 | ```python 47 | # Scale image to fit within 300x200px while preserving aspect ratio 48 | tinify.from_file("original.jpg").resize( 49 | method="scale", 50 | width=300, 51 | height=200 52 | ).to_file("resized.jpg") 53 | 54 | # Fit image to exact 300x200px dimensions 55 | tinify.from_file("original.jpg").resize( 56 | method="fit", 57 | width=300, 58 | height=200 59 | ).to_file("resized.jpg") 60 | 61 | # Cover 300x200px area while preserving aspect ratio 62 | tinify.from_file("original.jpg").resize( 63 | method="cover", 64 | width=300, 65 | height=200 66 | ).to_file("resized.jpg") 67 | ``` 68 | 69 | ### Format Conversion 70 | 71 | ```python 72 | # Convert to WebP format 73 | tinify.from_file("image.png").convert( 74 | type=["image/webp"] 75 | ).to_file("image.webp") 76 | ``` 77 | 78 | ### Compression Count Monitoring 79 | 80 | ```python 81 | # Check the number of compressions made this month 82 | compression_count = tinify.compression_count 83 | print(f"You have made {compression_count} compressions this month") 84 | ``` 85 | 86 | ## Error Handling 87 | 88 | ```python 89 | import tinify 90 | 91 | tinify.key = "YOUR_API_KEY" 92 | 93 | try: 94 | tinify.from_file("unoptimized.png").to_file("optimized.png") 95 | except tinify.AccountError as e: 96 | # Verify or update API key 97 | print(f"Account error: {e.message}") 98 | except tinify.ClientError as e: 99 | # Handle client errors (e.g., invalid image) 100 | print(f"Client error: {e.message}") 101 | except tinify.ServerError as e: 102 | # Handle server errors 103 | print(f"Server error: {e.message}") 104 | except tinify.ConnectionError as e: 105 | # Handle network connectivity issues 106 | print(f"Connection error: {e.message}") 107 | except Exception as e: 108 | # Handle general errors 109 | print(f"Error: {str(e)}") 110 | ``` 111 | 112 | ## Requirements 113 | 114 | - Python 3.6+ 115 | - Requests library 116 | 117 | ## Documentation 118 | 119 | For comprehensive documentation, visit [https://tinypng.com/developers/reference/python](https://tinypng.com/developers/reference/python). 120 | 121 | ## License 122 | 123 | This software is licensed under the MIT License. See [LICENSE](https://github.com/tinify/tinify-python/blob/master/LICENSE) for details. 124 | 125 | ## Support 126 | 127 | For issues and feature requests, please use our [GitHub Issues](https://github.com/tinify/tinify-python/issues) page. 128 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -e . 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal=1 3 | -------------------------------------------------------------------------------- /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("pypi.md", encoding="utf-8") as f: 21 | long_description = f.read() 22 | 23 | 24 | setup( 25 | name="tinify", 26 | version=__version__, 27 | description="Tinify API client.", 28 | author="Jacob Middag", 29 | author_email="info@tinify.com", 30 | license="MIT", 31 | long_description=long_description, 32 | long_description_content_type="text/markdown", 33 | url="https://tinify.com/developers", 34 | packages=["tinify"], 35 | package_data={ 36 | "": ["LICENSE", "README.md"], 37 | "tinify": ["data/cacert.pem", "py.typed"], 38 | }, 39 | install_requires=install_require, 40 | tests_require=tests_require, 41 | extras_require={"test": tests_require}, 42 | classifiers=( 43 | "Development Status :: 5 - Production/Stable", 44 | "Intended Audience :: Developers", 45 | "Natural Language :: English", 46 | "License :: OSI Approved :: MIT License", 47 | "Programming Language :: Python", 48 | "Programming Language :: Python :: 2.7", 49 | "Programming Language :: Python :: 3", 50 | "Programming Language :: Python :: 3.8", 51 | "Programming Language :: Python :: 3.9", 52 | "Programming Language :: Python :: 3.10", 53 | "Programming Language :: Python :: 3.11", 54 | "Programming Language :: Python :: 3.12", 55 | "Programming Language :: Python :: 3.13", 56 | ), 57 | ) 58 | -------------------------------------------------------------------------------- /test-requirements.txt: -------------------------------------------------------------------------------- 1 | -e .[test] 2 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinify/tinify-python/ce7f6b6c8f8fba23c1ea445e2b2cddbc6a70c0d2/test/__init__.py -------------------------------------------------------------------------------- /test/examples/dummy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinify/tinify-python/ce7f6b6c8f8fba23c1ea445e2b2cddbc6a70c0d2/test/examples/dummy.png -------------------------------------------------------------------------------- /test/examples/voormedia.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinify/tinify-python/ce7f6b6c8f8fba23c1ea445e2b2cddbc6a70c0d2/test/examples/voormedia.png -------------------------------------------------------------------------------- /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") -------------------------------------------------------------------------------- /test/unit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinify/tinify-python/ce7f6b6c8f8fba23c1ea445e2b2cddbc6a70c0d2/test/unit/__init__.py -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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_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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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_preserve_should_return_source(self, mock_requests): 128 | assert isinstance( 129 | Source.from_buffer(b"png file").preserve("copyright", "location"), Source 130 | ) 131 | assert b"png file" == mock_requests.last_request.body 132 | 133 | def test_preserve_should_return_source_with_data(self, mock_requests): 134 | assert ( 135 | b"copyrighted file" 136 | == Source.from_buffer(b"png file") 137 | .preserve("copyright", "location") 138 | .to_buffer() 139 | ) 140 | assert_json_equal( 141 | '{"preserve":["copyright","location"]}', mock_requests.last_request.json() 142 | ) 143 | 144 | def test_preserve_should_return_source_with_data_for_array(self, mock_requests): 145 | assert ( 146 | b"copyrighted file" 147 | == Source.from_buffer(b"png file") 148 | .preserve(["copyright", "location"]) 149 | .to_buffer() 150 | ) 151 | assert_json_equal( 152 | '{"preserve":["copyright","location"]}', mock_requests.last_request.json() 153 | ) 154 | 155 | def test_preserve_should_return_source_with_data_for_tuple(self, mock_requests): 156 | assert ( 157 | b"copyrighted file" 158 | == Source.from_buffer(b"png file") 159 | .preserve(("copyright", "location")) 160 | .to_buffer() 161 | ) 162 | assert_json_equal( 163 | '{"preserve":["copyright","location"]}', mock_requests.last_request.json() 164 | ) 165 | 166 | def test_preserve_should_include_other_options_if_set(self, mock_requests): 167 | assert ( 168 | b"copyrighted file" 169 | == Source.from_buffer(b"png file") 170 | .resize(width=400) 171 | .preserve("copyright", "location") 172 | .to_buffer() 173 | ) 174 | assert_json_equal( 175 | '{"preserve":["copyright","location"],"resize":{"width":400}}', 176 | mock_requests.last_request.json(), 177 | ) 178 | 179 | def test_resize_should_return_source(self, mock_requests): 180 | assert isinstance(Source.from_buffer(b"png file").resize(width=400), Source) 181 | assert b"png file" == mock_requests.last_request.body 182 | 183 | def test_resize_should_return_source_with_data(self, mock_requests): 184 | assert ( 185 | b"small file" 186 | == Source.from_buffer(b"png file").resize(width=400).to_buffer() 187 | ) 188 | assert_json_equal('{"resize":{"width":400}}', mock_requests.last_request.json()) 189 | 190 | def test_transform_should_return_source(self, mock_requests): 191 | assert isinstance( 192 | Source.from_buffer(b"png file").transform(background="black"), Source 193 | ) 194 | assert b"png file" == mock_requests.last_request.body 195 | 196 | def test_transform_should_return_source_with_data(self, mock_requests): 197 | assert ( 198 | b"transformed file" 199 | == Source.from_buffer(b"png file").transform(background="black").to_buffer() 200 | ) 201 | assert_json_equal( 202 | '{"transform":{"background":"black"}}', mock_requests.last_request.json() 203 | ) 204 | 205 | def test_convert_should_return_source(self, mock_requests): 206 | assert isinstance( 207 | Source.from_buffer(b"png file") 208 | .resize(width=400) 209 | .convert(type=["image/webp"]), 210 | Source, 211 | ) 212 | assert b"png file" == mock_requests.last_request.body 213 | 214 | def test_convert_should_return_source_with_data(self, mock_requests): 215 | assert ( 216 | b"converted file" 217 | == Source.from_buffer(b"png file").convert(type="image/jpg").to_buffer() 218 | ) 219 | assert_json_equal( 220 | '{"convert": {"type": "image/jpg"}}', mock_requests.last_request.json() 221 | ) 222 | 223 | def test_store_should_return_result_meta(self, mock_requests): 224 | assert isinstance( 225 | Source.from_buffer(b"png file").store(service="s3"), ResultMeta 226 | ) 227 | assert_json_equal( 228 | '{"store":{"service":"s3"}}', mock_requests.last_request.json() 229 | ) 230 | 231 | def test_store_should_return_result_meta_with_location(self, mock_requests): 232 | assert ( 233 | "https://bucket.s3-region.amazonaws.com/some/location" 234 | == Source.from_buffer(b"png file").store(service="s3").location 235 | ) 236 | assert_json_equal( 237 | '{"store":{"service":"s3"}}', mock_requests.last_request.json() 238 | ) 239 | 240 | def test_store_should_include_other_options_if_set(self, mock_requests): 241 | assert ( 242 | "https://bucket.s3-region.amazonaws.com/some/location" 243 | == Source.from_buffer(b"png file") 244 | .resize(width=400) 245 | .store(service="s3") 246 | .location 247 | ) 248 | assert_json_equal( 249 | '{"store":{"service":"s3"},"resize":{"width":400}}', 250 | mock_requests.last_request.json(), 251 | ) 252 | 253 | def test_to_buffer_should_return_image_data(self): 254 | assert b"compressed file" == Source.from_buffer(b"png file").to_buffer() 255 | 256 | def test_to_file_with_path_should_store_image_data(self): 257 | with tempfile.TemporaryFile() as tmp: 258 | Source.from_buffer(b"png file").to_file(tmp) 259 | tmp.seek(0) 260 | assert b"compressed file" == tmp.read() 261 | 262 | def test_to_file_with_file_object_should_store_image_data(self): 263 | name = create_named_tmpfile() 264 | try: 265 | Source.from_buffer(b"png file").to_file(name) 266 | with open(name, "rb") as f: 267 | assert b"compressed file" == f.read() 268 | finally: 269 | os.unlink(name) 270 | 271 | def test_all_options_together(self, mock_requests): 272 | assert ( 273 | "https://bucket.s3-region.amazonaws.com/some/location" 274 | == Source.from_buffer(b"png file") 275 | .resize(width=400) 276 | .convert(type=["image/webp", "image/png"]) 277 | .transform(background="black") 278 | .preserve("copyright", "location") 279 | .store(service="s3") 280 | .location 281 | ) 282 | assert_json_equal( 283 | '{"store":{"service":"s3"},"resize":{"width":400},"preserve": ["copyright", "location"], "transform": {"background": "black"}, "convert": {"type": ["image/webp", "image/png"]}}', 284 | mock_requests.last_request.json(), 285 | ) 286 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/py.typed: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | response = tinify.get_client().request('GET', self.url, self.commands) 69 | return Result(response.headers, response.content) 70 | 71 | def to_file(self, path): # type: (Union[str, IO]) -> None 72 | return self.result().to_file(path) 73 | 74 | def to_buffer(self): # type: () -> bytes 75 | return self.result().to_buffer() 76 | 77 | def _merge_commands(self, **options): # type: (**Any) -> Dict[str, Any] 78 | commands = self.commands.copy() 79 | commands.update(options) 80 | return commands 81 | 82 | def _flatten(self, items, seqtypes=(list, tuple)): 83 | items = list(items) 84 | for i, x in enumerate(items): 85 | while isinstance(items[i], seqtypes): 86 | items[i:i+1] = items[i] 87 | return items 88 | -------------------------------------------------------------------------------- /tinify/version.py: -------------------------------------------------------------------------------- 1 | __version__ = '1.7.0' -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------