From aaed13a78a9feba47a6ad2079e8aed61dc3be581 Mon Sep 17 00:00:00 2001 From: Remco Koopmans Date: Mon, 17 Mar 2025 18:09:32 +0100 Subject: [PATCH 1/5] More pytest and replace httpretty with requests-mock --- .github/workflows/ci-cd.yml | 3 +- setup.py | 4 +- test/conftest.py | 32 +++ test/helper.py | 73 ----- test/integration.py | 121 ++++---- test/tinify_client_test.py | 478 +++++++++++++++++++------------- test/tinify_result_meta_test.py | 68 +++-- test/tinify_result_test.py | 94 ++++--- test/tinify_source_test.py | 361 +++++++++++++++--------- test/tinify_test.py | 228 +++++++++------ 10 files changed, 849 insertions(+), 613 deletions(-) create mode 100644 test/conftest.py delete mode 100644 test/helper.py diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 607b4d8..d95494e 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -24,8 +24,7 @@ jobs: fail-fast: false matrix: python-version: [ - "2.7", - "3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14-dev", + "3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14-dev", "pypy-2.7", "pypy-3.10" ] os: [ubuntu-latest, macOS-latest, windows-latest] diff --git a/setup.py b/setup.py index 8a039e4..7d44ebe 100644 --- a/setup.py +++ b/setup.py @@ -10,8 +10,8 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'tinify')) from version import __version__ -install_require = ['requests >= 2.7.0, < 3.0.0'] -tests_require = ['pytest', 'httpretty < 1.1.5'] +install_require = ["requests >= 2.7.0, < 3.0.0"] +tests_require = ["pytest", "pytest-xdist", "requests-mock"] if sys.version_info < (2, 7): tests_require.append('unittest2') diff --git a/test/conftest.py b/test/conftest.py new file mode 100644 index 0000000..3d15961 --- /dev/null +++ b/test/conftest.py @@ -0,0 +1,32 @@ +import pytest +import os +import tinify +import requests_mock + + +@pytest.fixture +def dummy_file(): + return os.path.join(os.path.dirname(__file__), "examples", "dummy.png") + + +@pytest.fixture(autouse=True) +def reset_tinify(): + original_key = tinify.key + original_app_identifier = tinify.app_identifier + original_proxy = tinify.proxy + + tinify.key = None + tinify.app_identifier = None + tinify.proxy = None + + yield + + tinify.key = original_key + tinify.app_identifier = original_app_identifier + tinify.proxy = original_proxy + + +@pytest.fixture +def mock_requests(): + with requests_mock.Mocker(real_http=False) as m: + yield m diff --git a/test/helper.py b/test/helper.py deleted file mode 100644 index df01245..0000000 --- a/test/helper.py +++ /dev/null @@ -1,73 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, division, print_function, unicode_literals -from contextlib import contextmanager -from tempfile import NamedTemporaryFile -import json -import sys -import os -import httpretty - -if sys.version_info < (3, 3): - from mock import DEFAULT -else: - from unittest.mock import DEFAULT - -if sys.version_info < (2, 7): - import unittest2 as unittest -else: - import unittest - -for code in (584, 543, 492, 401): - httpretty.http.STATUSES.setdefault(code) - -dummy_file = os.path.join(os.path.dirname(__file__), 'examples', 'dummy.png') - -import tinify - -class RaiseException(object): - def __init__(self, exception, num=None): - self.exception = exception - self.num = num - - def __call__(self, *args, **kwargs): - if self.num == 0: - return DEFAULT - else: - if self.num: self.num -= 1 - raise self.exception - -class TestHelper(unittest.TestCase): - def setUp(self): - httpretty.enable() - httpretty.HTTPretty.allow_net_connect = False - - def tearDown(self): - httpretty.disable() - httpretty.reset() - - tinify.key = None - tinify.app_identifier = None - tinify.proxy = None - tinify.compression_count - - def assertJsonEqual(self, expected, actual): - self.assertEqual(json.loads(expected), json.loads(actual)) - - @property - def request(self): - return httpretty.last_request() - - - -@contextmanager -def create_named_tmpfile(): - # Due to NamedTemporaryFile requiring to be closed when used on Windows - # we create our own NamedTemporaryFile contextmanager - # See note: https://docs.python.org/3/library/tempfile.html#tempfile.NamedTemporaryFile - - tmp = NamedTemporaryFile(delete=False) - try: - tmp.close() - yield tmp.name - finally: - os.unlink(tmp.name) \ No newline at end of file diff --git a/test/integration.py b/test/integration.py index eb5a3e9..67b1e77 100644 --- a/test/integration.py +++ b/test/integration.py @@ -1,10 +1,14 @@ -import sys, os +import sys +import os from contextlib import contextmanager -import tinify, unittest, tempfile +import tinify +import pytest +import tempfile if not os.environ.get("TINIFY_KEY"): sys.exit("Set the TINIFY_KEY environment variable.") + @contextmanager def create_named_tmpfile(): # Due to NamedTemporaryFile requiring to be closed when used on Windows @@ -19,76 +23,87 @@ def create_named_tmpfile(): os.unlink(tmp.name) -class ClientIntegrationTest(unittest.TestCase): +# Fixture for shared resources +@pytest.fixture(scope="module") +def optimized_image(): tinify.key = os.environ.get("TINIFY_KEY") tinify.proxy = os.environ.get("TINIFY_PROXY") - unoptimized_path = os.path.join(os.path.dirname(__file__), 'examples', 'voormedia.png') - optimized = tinify.from_file(unoptimized_path) + unoptimized_path = os.path.join( + os.path.dirname(__file__), "examples", "voormedia.png" + ) + return tinify.from_file(unoptimized_path) + + +def test_should_compress_from_file(optimized_image): + with create_named_tmpfile() as tmp: + optimized_image.to_file(tmp) + + size = os.path.getsize(tmp) + + with open(tmp, "rb") as f: + contents = f.read() + + assert 1000 < size < 1500 - def test_should_compress_from_file(self): - with create_named_tmpfile() as tmp: - self.optimized.to_file(tmp) + # width == 137 + assert b"\x00\x00\x00\x89" in contents + assert b"Copyright Voormedia" not in contents - size = os.path.getsize(tmp) - with open(tmp, 'rb') as f: - contents = f.read() +def test_should_compress_from_url(): + source = tinify.from_url( + "https://raw.githubusercontent.com/tinify/tinify-python/master/test/examples/voormedia.png" + ) + with create_named_tmpfile() as tmp: + source.to_file(tmp) - self.assertTrue(1000 < size < 1500) + size = os.path.getsize(tmp) + with open(tmp, "rb") as f: + contents = f.read() - # width == 137 - self.assertIn(b'\x00\x00\x00\x89', contents) - self.assertNotIn(b'Copyright Voormedia', contents) + assert 1000 < size < 1500 - def test_should_compress_from_url(self): - source = tinify.from_url('https://raw.githubusercontent.com/tinify/tinify-python/master/test/examples/voormedia.png') - with create_named_tmpfile() as tmp: - source.to_file(tmp) + # width == 137 + assert b"\x00\x00\x00\x89" in contents + assert b"Copyright Voormedia" not in contents - size = os.path.getsize(tmp) - with open(tmp, 'rb') as f: - contents = f.read() - self.assertTrue(1000 < size < 1500) +def test_should_resize(optimized_image): + with create_named_tmpfile() as tmp: + optimized_image.resize(method="fit", width=50, height=20).to_file(tmp) - # width == 137 - self.assertIn(b'\x00\x00\x00\x89', contents) - self.assertNotIn(b'Copyright Voormedia', contents) + size = os.path.getsize(tmp) + with open(tmp, "rb") as f: + contents = f.read() - def test_should_resize(self): - with create_named_tmpfile() as tmp: - self.optimized.resize(method="fit", width=50, height=20).to_file(tmp) + assert 500 < size < 1000 - size = os.path.getsize(tmp) - with open(tmp, 'rb') as f: - contents = f.read() + # width == 50 + assert b"\x00\x00\x00\x32" in contents + assert b"Copyright Voormedia" not in contents - self.assertTrue(500 < size < 1000) - # width == 50 - self.assertIn(b'\x00\x00\x00\x32', contents) - self.assertNotIn(b'Copyright Voormedia', contents) +def test_should_preserve_metadata(optimized_image): + with create_named_tmpfile() as tmp: + optimized_image.preserve("copyright", "creation").to_file(tmp) - def test_should_preserve_metadata(self): - with create_named_tmpfile() as tmp: - self.optimized.preserve("copyright", "creation").to_file(tmp) + size = os.path.getsize(tmp) + with open(tmp, "rb") as f: + contents = f.read() - size = os.path.getsize(tmp) - with open(tmp, 'rb') as f: - contents = f.read() + assert 1000 < size < 2000 - self.assertTrue(1000 < size < 2000) + # width == 137 + assert b"\x00\x00\x00\x89" in contents + assert b"Copyright Voormedia" in contents - # width == 137 - self.assertIn(b'\x00\x00\x00\x89', contents) - self.assertIn(b'Copyright Voormedia', contents) - def test_should_transcode_image(self): - with create_named_tmpfile() as tmp: - a = self.optimized.convert(type=["image/webp"]).to_file(tmp) - with open(tmp, 'rb') as f: - content = f.read() +def test_should_transcode_image(optimized_image): + with create_named_tmpfile() as tmp: + optimized_image.convert(type=["image/webp"]).to_file(tmp) + with open(tmp, "rb") as f: + content = f.read() - self.assertEqual(b'RIFF', content[:4]) - self.assertEqual(b'WEBP', content[8:12]) \ No newline at end of file + assert b"RIFF" == content[:4] + assert b"WEBP" == content[8:12] diff --git a/test/tinify_client_test.py b/test/tinify_client_test.py index ec29165..9223003 100644 --- a/test/tinify_client_test.py +++ b/test/tinify_client_test.py @@ -1,198 +1,292 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, division, print_function, unicode_literals +import pytest +import requests +import json +import base64 +import tinify +from tinify import Client, ClientError, ServerError, ConnectionError, AccountError -import sys -from base64 import b64encode +Client.RETRY_DELAY = 10 -import tinify -from tinify import Client, AccountError, ClientError, ConnectionError, ServerError -import requests -import pytest -from helper import * +def b64encode(data): + return base64.b64encode(data) -try: - from unittest.mock import patch -except ImportError: - from mock import patch -Client.RETRY_DELAY = 10 +@pytest.fixture +def client(): + return Client("key") + + +class TestClientRequestWhenValid: + def test_should_issue_request(self, mock_requests, client): + mock_requests.get( + "https://api.tinify.com/", headers={"compression-count": "12"} + ) + + client.request("GET", "/") + + request = mock_requests.last_request + auth_header = "Basic {0}".format(b64encode(b"api:key").decode("ascii")) + assert request.headers["authorization"] == auth_header + + def test_should_issue_request_without_body_when_options_are_empty( + self, mock_requests, client + ): + mock_requests.get( + "https://api.tinify.com/", headers={"compression-count": "12"} + ) + + client.request("GET", "/", {}) + + request = mock_requests.last_request + assert not request.text or request.text == "" + + def test_should_issue_request_without_content_type_when_options_are_empty( + self, mock_requests, client + ): + mock_requests.get( + "https://api.tinify.com/", headers={"compression-count": "12"} + ) + + client.request("GET", "/", {}) + + request = mock_requests.last_request + assert "content-type" not in request.headers + + def test_should_issue_request_with_json_body(self, mock_requests, client): + mock_requests.get( + "https://api.tinify.com/", headers={"compression-count": "12"} + ) + + client.request("GET", "/", {"hello": "world"}) + + request = mock_requests.last_request + assert request.headers["content-type"] == "application/json" + assert request.text == '{"hello":"world"}' + + def test_should_issue_request_with_user_agent(self, mock_requests, client): + mock_requests.get( + "https://api.tinify.com/", headers={"compression-count": "12"} + ) + + client.request("GET", "/") + + request = mock_requests.last_request + assert request.headers["user-agent"] == Client.USER_AGENT + + def test_should_update_compression_count(self, mock_requests, client): + mock_requests.get( + "https://api.tinify.com/", headers={"compression-count": "12"} + ) + + client.request("GET", "/") + + assert tinify.compression_count == 12 + -class TinifyClientRequestWhenValid(TestHelper): - def setUp(self): - super(type(self), self).setUp() - httpretty.register_uri(httpretty.GET, 'https://api.tinify.com/', **{ - 'compression-count': 12 - }) - - def test_should_issue_request(self): - Client('key').request('GET', '/') - - self.assertEqual(self.request.headers['authorization'], 'Basic {0}'.format( - b64encode(b'api:key').decode('ascii'))) - - def test_should_issue_request_without_body_when_options_are_empty(self): - Client('key').request('GET', '/', {}) - - self.assertEqual(self.request.body, b'') - - def test_should_issue_request_without_content_type_when_options_are_empty(self): - Client('key').request('GET', '/', {}) - - self.assertIsNone(self.request.headers.get('content-type')) - - def test_should_issue_request_with_json_body(self): - Client('key').request('GET', '/', {'hello': 'world'}) - - self.assertEqual(self.request.headers['content-type'], 'application/json') - self.assertEqual(self.request.body, b'{"hello":"world"}') - - def test_should_issue_request_with_user_agent(self): - Client('key').request('GET', '/') - - self.assertEqual(self.request.headers['user-agent'], Client.USER_AGENT) - - def test_should_update_compression_count(self): - Client('key').request('GET', '/') - - self.assertEqual(tinify.compression_count, 12) - -class TinifyClientRequestWhenValidWithAppId(TestHelper): - def setUp(self): - super(type(self), self).setUp() - httpretty.register_uri(httpretty.GET, 'https://api.tinify.com/', **{ - 'compression-count': 12 - }) - - def test_should_issue_request_with_user_agent(self): - Client('key', 'TestApp/0.2').request('GET', '/') - - self.assertEqual(self.request.headers['user-agent'], Client.USER_AGENT + ' TestApp/0.2') - -class TinifyClientRequestWhenValidWithProxy(TestHelper): - def setUp(self): - super(type(self), self).setUp() - httpretty.register_uri(httpretty.CONNECT, 'http://localhost:8080', **{ - 'compression-count': 12 - }) - - @pytest.mark.skip(reason="https://github.com/gabrielfalcao/HTTPretty/issues/122") - def test_should_issue_request_with_proxy_authorization(self): - Client('key', None, 'http://user:pass@localhost:8080').request('GET', '/') - self.assertEqual(self.request.headers['proxy-authorization'], 'Basic dXNlcjpwYXNz') - -class TinifyClientRequestWithTimeoutRepeatedly(TestHelper): - @patch('requests.sessions.Session.request', RaiseException(requests.exceptions.Timeout)) - def test_should_raise_connection_error(self): - with self.assertRaises(ConnectionError) as context: - Client('key').request('GET', '/') - self.assertEqual('Timeout while connecting', str(context.exception)) - - @patch('requests.sessions.Session.request', RaiseException(requests.exceptions.Timeout)) - def test_should_raise_connection_error_with_cause(self): - with self.assertRaises(ConnectionError) as context: - Client('key').request('GET', '/') - self.assertIsInstance(context.exception.__cause__, requests.exceptions.Timeout) - -class TinifyClientRequestWithTimeoutOnce(TestHelper): - @patch('requests.sessions.Session.request') - def test_should_issue_request(self, mock): - mock.side_effect = RaiseException(requests.exceptions.Timeout, num=1) - mock.return_value = requests.Response() - mock.return_value.status_code = 201 - self.assertIsInstance(Client('key').request('GET', '/', {}), requests.Response) - -class TinifyClientRequestWithConnectionErrorRepeatedly(TestHelper): - @patch('requests.sessions.Session.request', RaiseException(requests.exceptions.ConnectionError('connection error'))) - def test_should_raise_connection_error(self): - with self.assertRaises(ConnectionError) as context: - Client('key').request('GET', '/') - self.assertEqual('Error while connecting: connection error', str(context.exception)) - - @patch('requests.sessions.Session.request', RaiseException(requests.exceptions.ConnectionError('connection error'))) - def test_should_raise_connection_error_with_cause(self): - with self.assertRaises(ConnectionError) as context: - Client('key').request('GET', '/') - self.assertIsInstance(context.exception.__cause__, requests.exceptions.ConnectionError) - -class TinifyClientRequestWithConnectionErrorOnce(TestHelper): - @patch('requests.sessions.Session.request') - def test_should_issue_request(self, mock): - mock.side_effect = RaiseException(requests.exceptions.ConnectionError, num=1) - mock.return_value = requests.Response() - mock.return_value.status_code = 201 - self.assertIsInstance(Client('key').request('GET', '/', {}), requests.Response) - -class TinifyClientRequestWithSomeErrorRepeatedly(TestHelper): - @patch('requests.sessions.Session.request', RaiseException(RuntimeError('some error'))) - def test_should_raise_connection_error(self): - with self.assertRaises(ConnectionError) as context: - Client('key').request('GET', '/') - self.assertEqual('Error while connecting: some error', str(context.exception)) - -class TinifyClientRequestWithSomeErrorOnce(TestHelper): - @patch('requests.sessions.Session.request') - def test_should_issue_request(self, mock): - mock.side_effect = RaiseException(RuntimeError('some error'), num=1) - mock.return_value = requests.Response() - mock.return_value.status_code = 201 - self.assertIsInstance(Client('key').request('GET', '/', {}), requests.Response) - -class TinifyClientRequestWithServerErrorRepeatedly(TestHelper): - def test_should_raise_server_error(self): - httpretty.register_uri(httpretty.GET, 'https://api.tinify.com/', status=584, - body='{"error":"InternalServerError","message":"Oops!"}') - - with self.assertRaises(ServerError) as context: - Client('key').request('GET', '/') - self.assertEqual('Oops! (HTTP 584/InternalServerError)', str(context.exception)) - -class TinifyClientRequestWithServerErrorOnce(TestHelper): - def test_should_issue_request(self): - httpretty.register_uri(httpretty.GET, 'https://api.tinify.com/', - responses=[ - httpretty.Response(body='{"error":"InternalServerError","message":"Oops!"}', status=584), - httpretty.Response(body='all good', status=201), - ]) - - response = Client('key').request('GET', '/') - self.assertEqual('201', str(response.status_code)) - -class TinifyClientRequestWithBadServerResponseRepeatedly(TestHelper): - def test_should_raise_server_error(self): - httpretty.register_uri(httpretty.GET, 'https://api.tinify.com/', status=543, - body='') - - with self.assertRaises(ServerError) as context: - Client('key').request('GET', '/') - msg = r'Error while parsing response: .* \(HTTP 543/ParseError\)' - self.assertRegex(str(context.exception), msg) - -class TinifyClientRequestWithBadServerResponseOnce(TestHelper): - def test_should_issue_request(self): - httpretty.register_uri(httpretty.GET, 'https://api.tinify.com/', - responses=[ - httpretty.Response(body='', status=543), - httpretty.Response(body='all good', status=201), - ]) - - response = Client('key').request('GET', '/') - self.assertEqual('201', str(response.status_code)) - -class TinifyClientRequestWithClientError(TestHelper): - def test_should_raise_client_error(self): - httpretty.register_uri(httpretty.GET, 'https://api.tinify.com/', status=492, - body='{"error":"BadRequest","message":"Oops!"}') - - with self.assertRaises(ClientError) as context: - Client('key').request('GET', '/') - self.assertEqual('Oops! (HTTP 492/BadRequest)', str(context.exception)) - -class TinifyClientRequestWithBadCredentialsResponse(TestHelper): - def test_should_raise_account_error(self): - httpretty.register_uri(httpretty.GET, 'https://api.tinify.com/', status=401, - body='{"error":"Unauthorized","message":"Oops!"}') - - with self.assertRaises(AccountError) as context: - Client('key').request('GET', '/') - self.assertEqual('Oops! (HTTP 401/Unauthorized)', str(context.exception)) +class TestClientRequestWhenValidWithAppId: + def test_should_issue_request_with_user_agent(self, mock_requests): + mock_requests.get( + "https://api.tinify.com/", headers={"compression-count": "12"} + ) + + Client("key", "TestApp/0.2").request("GET", "/") + + request = mock_requests.last_request + assert request.headers["user-agent"] == Client.USER_AGENT + " TestApp/0.2" + + +class TestClientRequestWhenValidWithProxy: + @pytest.mark.skip( + reason="requests does not set a proxy unless a real proxy is used" + ) + def test_should_issue_request_with_proxy_authorization(self, mock_requests): + proxy_url = "http://user:pass@localhost:8080" + expected_auth = "Basic " + base64.b64encode(b"user:pass").decode() + + mock_requests.get("https://api.tinify.com/", status_code=200) + + client = Client("key", None, proxy_url) + client.request("GET", "/") + + # Verify the last request captured by requests-mock + last_request = mock_requests.last_request + assert last_request is not None + assert last_request.headers.get("Proxy-Authorization") == expected_auth + + +class TestClientRequestWithTimeout: + def test_should_raise_connection_error_repeatedly(self, mock_requests): + mock_requests.get( + "https://api.tinify.com/", + [ + {"exc": requests.exceptions.Timeout}, + ], + ) + with pytest.raises(ConnectionError) as excinfo: + Client("key").request("GET", "/") + assert str(excinfo.value) == "Timeout while connecting" + assert isinstance(excinfo.value.__cause__, requests.exceptions.Timeout) + + def test_should_issue_request_after_timeout_once(self, mock_requests): + # Confirm retry happens after timeout + mock_requests.get( + "https://api.tinify.com/", + [ + {"exc": requests.exceptions.Timeout("Timeout")}, + { + "status_code": 201, + "headers": {"compression-count": "12"}, + "text": "success", + }, + ], + ) + + result = Client("key").request("GET", "/", {}) + + assert result.status_code == 201 + assert mock_requests.call_count == 2 # Verify retry happened + + +class TestClientRequestWithConnectionError: + def test_should_raise_connection_error_repeatedly(self, mock_requests): + mock_requests.get( + "https://api.tinify.com/", + [ + {"exc": requests.exceptions.ConnectionError("connection error")}, + ], + ) + with pytest.raises(ConnectionError) as excinfo: + Client("key").request("GET", "/") + assert str(excinfo.value) == "Error while connecting: connection error" + assert isinstance(excinfo.value.__cause__, requests.exceptions.ConnectionError) + + def test_should_issue_request_after_connection_error_once(self, mock_requests): + # Mock the request to fail with ConnectionError once, then succeed + mock_requests.get( + "https://api.tinify.com/", + [ + {"exc": requests.exceptions.ConnectionError}, # First attempt fails + { + "status_code": 201, + "headers": {"compression-count": "12"}, + "text": "success", + }, # Second attempt succeeds + ], + ) + + client = Client("key") + result = client.request("GET", "/", {}) + + # Verify results + assert result.status_code == 201 + assert mock_requests.call_count == 2 # Ensure it retried + + +class TestClientRequestWithSomeError: + def test_should_raise_connection_error_repeatedly(self, mock_requests): + mock_requests.get( + "https://api.tinify.com/", + [ + {"exc": RuntimeError("some error")}, + ], + ) + with pytest.raises(ConnectionError) as excinfo: + Client("key").request("GET", "/") + assert str(excinfo.value) == "Error while connecting: some error" + + def test_should_issue_request_after_some_error_once(self, mock_requests): + # Mock the request to fail with RuntimeError once, then succeed + mock_requests.get( + "https://api.tinify.com/", + [ + {"exc": RuntimeError("some error")}, # First attempt fails + { + "status_code": 201, + "headers": {"compression-count": "12"}, + "text": "success", + }, # Second attempt succeeds + ], + ) + + client = Client("key") + result = client.request("GET", "/", {}) + + # Verify results + assert result.status_code == 201 + assert mock_requests.call_count == 2 # Ensure it retried + + +class TestClientRequestWithServerError: + def test_should_raise_server_error_repeatedly(self, mock_requests): + error_body = json.dumps({"error": "InternalServerError", "message": "Oops!"}) + mock_requests.get("https://api.tinify.com/", status_code=584, text=error_body) + + with pytest.raises(ServerError) as excinfo: + Client("key").request("GET", "/") + assert str(excinfo.value) == "Oops! (HTTP 584/InternalServerError)" + + def test_should_issue_request_after_server_error_once(self, mock_requests): + error_body = json.dumps({"error": "InternalServerError", "message": "Oops!"}) + # First call returns error, second succeeds + mock_requests.register_uri( + "GET", + "https://api.tinify.com/", + [ + {"status_code": 584, "text": error_body}, + {"status_code": 201, "text": "all good"}, + ], + ) + + response = Client("key").request("GET", "/") + + assert response.status_code == 201 + + +class TestClientRequestWithBadServerResponse: + def test_should_raise_server_error_repeatedly(self, mock_requests): + mock_requests.get( + "https://api.tinify.com/", status_code=543, text="" + ) + + with pytest.raises(ServerError) as excinfo: + Client("key").request("GET", "/") + # Using pytest's assert to check regex pattern + error_message = str(excinfo.value) + assert "Error while parsing response:" in error_message + assert "(HTTP 543/ParseError)" in error_message + + def test_should_issue_request_after_bad_response_once(self, mock_requests): + # First call returns invalid JSON, second succeeds + mock_requests.register_uri( + "GET", + "https://api.tinify.com/", + [ + {"status_code": 543, "text": ""}, + {"status_code": 201, "text": "all good"}, + ], + ) + + response = Client("key").request("GET", "/") + + assert response.status_code == 201 + + +class TestClientRequestWithClientError: + def test_should_raise_client_error(self, mock_requests): + error_body = json.dumps({"error": "BadRequest", "message": "Oops!"}) + mock_requests.get("https://api.tinify.com/", status_code=492, text=error_body) + + with pytest.raises(ClientError) as excinfo: + Client("key").request("GET", "/") + assert str(excinfo.value) == "Oops! (HTTP 492/BadRequest)" + + +class TestClientRequestWithBadCredentialsResponse: + def test_should_raise_account_error(self, mock_requests): + error_body = json.dumps({"error": "Unauthorized", "message": "Oops!"}) + mock_requests.get("https://api.tinify.com/", status_code=401, text=error_body) + + with pytest.raises(AccountError) as excinfo: + Client("key").request("GET", "/") + assert str(excinfo.value) == "Oops! (HTTP 401/Unauthorized)" diff --git a/test/tinify_result_meta_test.py b/test/tinify_result_meta_test.py index 1bd02ec..22c28b3 100644 --- a/test/tinify_result_meta_test.py +++ b/test/tinify_result_meta_test.py @@ -1,38 +1,52 @@ # -*- coding: utf-8 -*- -from __future__ import absolute_import, division, print_function, unicode_literals - +import pytest from tinify import ResultMeta -from helper import * -class TinifyResultMetaWithMetaTest(TestHelper): - def setUp(self): - self.result = ResultMeta({ - 'Image-Width': '100', - 'Image-Height': '60', - 'Content-Length': '20', - 'Content-Type': 'application/json', - 'Location': 'https://bucket.s3-region.amazonaws.com/some/location' - }) +@pytest.fixture +def result_with_meta(): + """Fixture that returns a ResultMeta instance with metadata""" + return ResultMeta( + { + "Image-Width": "100", + "Image-Height": "60", + "Content-Length": "20", + "Content-Type": "application/json", + "Location": "https://bucket.s3-region.amazonaws.com/some/location", + } + ) + + +@pytest.fixture +def result_without_meta(): + """Fixture that returns a ResultMeta instance without metadata""" + return ResultMeta({}) + + +# Tests for ResultMeta with metadata +def test_width_should_return_image_width(result_with_meta): + assert 100 == result_with_meta.width + + +def test_height_should_return_image_height(result_with_meta): + assert 60 == result_with_meta.height + - def test_width_should_return_image_width(self): - self.assertEqual(100, self.result.width) +def test_location_should_return_stored_location(result_with_meta): + assert ( + "https://bucket.s3-region.amazonaws.com/some/location" + == result_with_meta.location + ) - def test_height_should_return_image_height(self): - self.assertEqual(60, self.result.height) - def test_location_should_return_stored_location(self): - self.assertEqual('https://bucket.s3-region.amazonaws.com/some/location', self.result.location) +# Tests for ResultMeta without metadata +def test_width_should_return_none_when_no_meta(result_without_meta): + assert None is result_without_meta.width -class TinifyResultMetaWithoutMetaTest(TestHelper): - def setUp(self): - self.result = ResultMeta({}) - def test_width_should_return_none(self): - self.assertEqual(None, self.result.width) +def test_height_should_return_none_when_no_meta(result_without_meta): + assert None is result_without_meta.height - def test_height_should_return_none(self): - self.assertEqual(None, self.result.height) - def test_location_should_return_none(self): - self.assertEqual(None, self.result.location) +def test_location_should_return_none_when_no_meta(result_without_meta): + assert None is result_without_meta.location diff --git a/test/tinify_result_test.py b/test/tinify_result_test.py index f4d40aa..2b2f8f2 100644 --- a/test/tinify_result_test.py +++ b/test/tinify_result_test.py @@ -1,69 +1,75 @@ # -*- coding: utf-8 -*- from __future__ import absolute_import, division, print_function, unicode_literals +import pytest from tinify import Result -from helper import * -class TinifyResultWithMetaAndDataTest(TestHelper): - def setUp(self): - self.result = Result({ - 'Image-Width': '100', - 'Image-Height': '60', - 'Content-Length': '450', - 'Content-Type': 'image/png', - }, b'image data') +@pytest.fixture +def result_with_meta_and_data(): + return Result( + { + "Image-Width": "100", + "Image-Height": "60", + "Content-Length": "450", + "Content-Type": "image/png", + }, + b"image data", + ) - def test_width_should_return_image_width(self): - self.assertEqual(100, self.result.width) - def test_height_should_return_image_height(self): - self.assertEqual(60, self.result.height) +@pytest.fixture +def result_without_meta_and_data(): + return Result({}, None) - def test_location_should_return_none(self): - self.assertEqual(None, self.result.location) - def test_size_should_return_content_length(self): - self.assertEqual(450, self.result.size) +class TestTinifyResultWithMetaAndData: + def test_width_should_return_image_width(self, result_with_meta_and_data): + assert 100 == result_with_meta_and_data.width - def test_len_builtin_should_return_content_length(self): - self.assertEqual(450, len(self.result)) + def test_height_should_return_image_height(self, result_with_meta_and_data): + assert 60 == result_with_meta_and_data.height - def test_content_type_should_return_mime_type(self): - self.assertEqual('image/png', self.result.content_type) + def test_location_should_return_none(self, result_with_meta_and_data): + assert None is result_with_meta_and_data.location - def test_to_buffer_should_return_image_data(self): - self.assertEqual(b'image data', self.result.to_buffer()) + def test_size_should_return_content_length(self, result_with_meta_and_data): + assert 450 == result_with_meta_and_data.size - def test_extension(self): - self.assertEqual('png', self.result.extension) + def test_len_builtin_should_return_content_length(self, result_with_meta_and_data): + assert 450 == len(result_with_meta_and_data) + def test_content_type_should_return_mime_type(self, result_with_meta_and_data): + assert "image/png" == result_with_meta_and_data.content_type + def test_to_buffer_should_return_image_data(self, result_with_meta_and_data): + assert b"image data" == result_with_meta_and_data.to_buffer() -class TinifyResultWithoutMetaAndDataTest(TestHelper): - def setUp(self): - self.result = Result({}, None) + def test_extension(self, result_with_meta_and_data): + assert "png" == result_with_meta_and_data.extension - def test_width_should_return_none(self): - self.assertEqual(None, self.result.width) - def test_height_should_return_none(self): - self.assertEqual(None, self.result.height) +class TestTinifyResultWithoutMetaAndData: + def test_width_should_return_none(self, result_without_meta_and_data): + assert None is result_without_meta_and_data.width - def test_location_should_return_none(self): - self.assertEqual(None, self.result.location) + def test_height_should_return_none(self, result_without_meta_and_data): + assert None is result_without_meta_and_data.height - def test_size_should_return_none(self): - self.assertEqual(None, self.result.size) + def test_location_should_return_none(self, result_without_meta_and_data): + assert None is result_without_meta_and_data.location - def test_len_builtin_should_return_zero(self): - self.assertEqual(0, len(self.result)) + def test_size_should_return_none(self, result_without_meta_and_data): + assert None is result_without_meta_and_data.size - def test_content_type_should_return_none(self): - self.assertEqual(None, self.result.content_type) + def test_len_builtin_should_return_zero(self, result_without_meta_and_data): + assert 0 == len(result_without_meta_and_data) - def test_to_buffer_should_return_none(self): - self.assertEqual(None, self.result.to_buffer()) + def test_content_type_should_return_none(self, result_without_meta_and_data): + assert None is result_without_meta_and_data.content_type - def test_extension(self): - self.assertEqual(None, self.result.extension) \ No newline at end of file + def test_to_buffer_should_return_none(self, result_without_meta_and_data): + assert None is result_without_meta_and_data.to_buffer() + + def test_extension(self, result_without_meta_and_data): + assert None is result_without_meta_and_data.extension diff --git a/test/tinify_source_test.py b/test/tinify_source_test.py index a688298..880cf33 100644 --- a/test/tinify_source_test.py +++ b/test/tinify_source_test.py @@ -1,184 +1,285 @@ # -*- coding: utf-8 -*- -from __future__ import absolute_import, division, print_function, unicode_literals - import os import json import tempfile +import pytest import tinify from tinify import Source, Result, ResultMeta, AccountError, ClientError -from helper import * +def create_named_tmpfile(): + """Helper to create a named temporary file""" + fd, name = tempfile.mkstemp() + os.close(fd) + return name + + +def assert_json_equal(expected, actual): + """Helper to assert JSON equality""" + if isinstance(actual, str): + actual = json.loads(actual) + if isinstance(expected, str): + expected = json.loads(expected) + assert expected == actual -class TinifySourceWithInvalidApiKey(TestHelper): - def setUp(self): - super(type(self), self).setUp() - tinify.key = 'invalid' - httpretty.register_uri(httpretty.POST, 'https://api.tinify.com/shrink', **{ - 'status': 401 - }) - def test_from_file_should_raise_account_error(self): - with self.assertRaises(AccountError): +class TestTinifySourceWithInvalidApiKey: + @pytest.fixture(autouse=True) + def setup(self, mock_requests): + tinify.key = "invalid" + mock_requests.post("https://api.tinify.com/shrink", status_code=401) + yield + + def test_from_file_should_raise_account_error(self, dummy_file): + with pytest.raises(AccountError): Source.from_file(dummy_file) def test_from_buffer_should_raise_account_error(self): - with self.assertRaises(AccountError): - Source.from_buffer('png file') + with pytest.raises(AccountError): + Source.from_buffer("png file") def test_from_url_should_raise_account_error(self): - with self.assertRaises(AccountError): - Source.from_url('http://example.com/test.jpg') - -class TinifySourceWithValidApiKey(TestHelper): - def setUp(self): - super(type(self), self).setUp() - tinify.key = 'valid' - httpretty.register_uri(httpretty.POST, 'https://api.tinify.com/shrink', **{ - 'status': 201, - 'location': 'https://api.tinify.com/some/location' - }) - httpretty.register_uri(httpretty.GET, 'https://api.tinify.com/some/location', body=self.return_file) - httpretty.register_uri(httpretty.POST, 'https://api.tinify.com/some/location', body=self.return_file) - - @staticmethod - def return_file(request, uri, headers): - if request.body: - data = json.loads(request.body.decode('utf-8')) - else: - data = {} - response = None - if 'store' in data: - headers['location'] = 'https://bucket.s3-region.amazonaws.com/some/location' - response = json.dumps({'status': 'success'}).encode('utf-8') - elif 'preserve' in data: - response = b'copyrighted file' - elif 'resize' in data: - response = b'small file' - elif 'convert' in data: - response = b'converted file' - elif 'transform' in data: - response = b'transformed file' + with pytest.raises(AccountError): + Source.from_url("http://example.com/test.jpg") + + +class TestTinifySourceWithValidApiKey: + @pytest.fixture(autouse=True) + def setup_teardown(self, mock_requests): + tinify.key = "valid" + mock_requests.post( + "https://api.tinify.com/shrink", + status_code=201, + headers={"location": "https://api.tinify.com/some/location"}, + ) + mock_requests.get( + "https://api.tinify.com/some/location", content=self.return_file + ) + mock_requests.post( + "https://api.tinify.com/some/location", content=self.return_file + ) + yield + + def return_file(self, request, context): + data = request.json() if request.body else {} + if "store" in data: + context.headers["location"] = ( + "https://bucket.s3-region.amazonaws.com/some/location" + ) + return json.dumps({"status": "success"}).encode("utf-8") + elif "preserve" in data: + return b"copyrighted file" + elif "resize" in data: + return b"small file" + elif "convert" in data: + return b"converted file" + elif "transform" in data: + return b"transformed file" else: - response = b'compressed file' - return (200, headers, response) + return b"compressed file" - def test_from_file_with_path_should_return_source(self): - self.assertIsInstance(Source.from_file(dummy_file), Source) + def test_from_file_with_path_should_return_source(self, dummy_file): + assert isinstance(Source.from_file(dummy_file), Source) - def test_from_file_with_path_should_return_source_with_data(self): - self.assertEqual(b'compressed file', Source.from_file(dummy_file).to_buffer()) + def test_from_file_with_path_should_return_source_with_data(self, dummy_file): + assert b"compressed file" == Source.from_file(dummy_file).to_buffer() - def test_from_file_with_file_object_should_return_source(self): - with open(dummy_file, 'rb') as f: - self.assertIsInstance(Source.from_file(f), Source) + def test_from_file_with_file_object_should_return_source(self, dummy_file): + with open(dummy_file, "rb") as f: + assert isinstance(Source.from_file(f), Source) - def test_from_file_with_file_object_should_return_source_with_data(self): - with open(dummy_file, 'rb') as f: - self.assertEqual(b'compressed file', Source.from_file(f).to_buffer()) + def test_from_file_with_file_object_should_return_source_with_data( + self, dummy_file + ): + with open(dummy_file, "rb") as f: + assert b"compressed file" == Source.from_file(f).to_buffer() def test_from_buffer_should_return_source(self): - self.assertIsInstance(Source.from_buffer('png file'), Source) + assert isinstance(Source.from_buffer("png file"), Source) def test_from_buffer_should_return_source_with_data(self): - self.assertEqual(b'compressed file', Source.from_buffer('png file').to_buffer()) + assert b"compressed file" == Source.from_buffer("png file").to_buffer() def test_from_url_should_return_source(self): - self.assertIsInstance(Source.from_url('http://example.com/test.jpg'), Source) + assert isinstance(Source.from_url("http://example.com/test.jpg"), Source) def test_from_url_should_return_source_with_data(self): - self.assertEqual(b'compressed file', Source.from_url('http://example.com/test.jpg').to_buffer()) + assert ( + b"compressed file" + == Source.from_url("http://example.com/test.jpg").to_buffer() + ) - def test_from_url_should_raise_error_when_server_doesnt_return_a_success(self): - httpretty.register_uri(httpretty.POST, 'https://api.tinify.com/shrink', - body='{"error":"Source not found","message":"Cannot parse URL"}', - status=400, + def test_from_url_should_raise_error_when_server_doesnt_return_a_success( + self, mock_requests + ): + mock_requests.post( + "https://api.tinify.com/shrink", + json={"error": "Source not found", "message": "Cannot parse URL"}, + status_code=400, ) - with self.assertRaises(ClientError): - Source.from_url('file://wrong') + with pytest.raises(ClientError): + Source.from_url("file://wrong") def test_result_should_return_result(self): - self.assertIsInstance(Source.from_buffer('png file').result(), Result) + assert isinstance(Source.from_buffer(b"png file").result(), Result) - def test_preserve_should_return_source(self): - self.assertIsInstance(Source.from_buffer('png file').preserve("copyright", "location"), Source) - self.assertEqual(b'png file', httpretty.last_request().body) - - def test_preserve_should_return_source_with_data(self): - self.assertEqual(b'copyrighted file', Source.from_buffer('png file').preserve("copyright", "location").to_buffer()) - self.assertJsonEqual('{"preserve":["copyright","location"]}', httpretty.last_request().body.decode('utf-8')) + def test_preserve_should_return_source(self, mock_requests): + assert isinstance( + Source.from_buffer(b"png file").preserve("copyright", "location"), Source + ) + assert b"png file" == mock_requests.last_request.body + + def test_preserve_should_return_source_with_data(self, mock_requests): + assert ( + b"copyrighted file" + == Source.from_buffer(b"png file") + .preserve("copyright", "location") + .to_buffer() + ) + assert_json_equal( + '{"preserve":["copyright","location"]}', mock_requests.last_request.json() + ) - def test_preserve_should_return_source_with_data_for_array(self): - self.assertEqual(b'copyrighted file', Source.from_buffer('png file').preserve(["copyright", "location"]).to_buffer()) - self.assertJsonEqual('{"preserve":["copyright","location"]}', httpretty.last_request().body.decode('utf-8')) + def test_preserve_should_return_source_with_data_for_array(self, mock_requests): + assert ( + b"copyrighted file" + == Source.from_buffer(b"png file") + .preserve(["copyright", "location"]) + .to_buffer() + ) + assert_json_equal( + '{"preserve":["copyright","location"]}', mock_requests.last_request.json() + ) - def test_preserve_should_return_source_with_data_for_tuple(self): - self.assertEqual(b'copyrighted file', Source.from_buffer('png file').preserve(("copyright", "location")).to_buffer()) - self.assertJsonEqual('{"preserve":["copyright","location"]}', httpretty.last_request().body.decode('utf-8')) + def test_preserve_should_return_source_with_data_for_tuple(self, mock_requests): + assert ( + b"copyrighted file" + == Source.from_buffer(b"png file") + .preserve(("copyright", "location")) + .to_buffer() + ) + assert_json_equal( + '{"preserve":["copyright","location"]}', mock_requests.last_request.json() + ) - def test_preserve_should_include_other_options_if_set(self): - self.assertEqual(b'copyrighted file', Source.from_buffer('png file').resize(width=400).preserve("copyright", "location").to_buffer()) - self.assertJsonEqual('{"preserve":["copyright","location"],"resize":{"width":400}}', httpretty.last_request().body.decode('utf-8')) + def test_preserve_should_include_other_options_if_set(self, mock_requests): + assert ( + b"copyrighted file" + == Source.from_buffer(b"png file") + .resize(width=400) + .preserve("copyright", "location") + .to_buffer() + ) + assert_json_equal( + '{"preserve":["copyright","location"],"resize":{"width":400}}', + mock_requests.last_request.json(), + ) - def test_resize_should_return_source(self): - self.assertIsInstance(Source.from_buffer('png file').resize(width=400), Source) - self.assertEqual(b'png file', httpretty.last_request().body) + def test_resize_should_return_source(self, mock_requests): + assert isinstance(Source.from_buffer(b"png file").resize(width=400), Source) + assert b"png file" == mock_requests.last_request.body - def test_resize_should_return_source_with_data(self): - self.assertEqual(b'small file', Source.from_buffer('png file').resize(width=400).to_buffer()) - self.assertJsonEqual('{"resize":{"width":400}}', httpretty.last_request().body.decode('utf-8')) + def test_resize_should_return_source_with_data(self, mock_requests): + assert ( + b"small file" + == Source.from_buffer(b"png file").resize(width=400).to_buffer() + ) + assert_json_equal('{"resize":{"width":400}}', mock_requests.last_request.json()) - def test_transform_should_return_source(self): - self.assertIsInstance(Source.from_buffer('png file').transform(background='black'), Source) - self.assertEqual(b'png file', httpretty.last_request().body) + def test_transform_should_return_source(self, mock_requests): + assert isinstance( + Source.from_buffer(b"png file").transform(background="black"), Source + ) + assert b"png file" == mock_requests.last_request.body - def test_transform_should_return_source_with_data(self): - self.assertEqual(b'transformed file', Source.from_buffer('png file').transform(background='black').to_buffer()) - self.assertJsonEqual('{"transform":{"background":"black"}}', httpretty.last_request().body.decode('utf-8')) + def test_transform_should_return_source_with_data(self, mock_requests): + assert ( + b"transformed file" + == Source.from_buffer(b"png file").transform(background="black").to_buffer() + ) + assert_json_equal( + '{"transform":{"background":"black"}}', mock_requests.last_request.json() + ) - def test_convert_should_return_source(self): - self.assertIsInstance(Source.from_buffer('png file').resize(width=400).convert(type=['image/webp']), Source) - self.assertEqual(b'png file', httpretty.last_request().body) + def test_convert_should_return_source(self, mock_requests): + assert isinstance( + Source.from_buffer(b"png file") + .resize(width=400) + .convert(type=["image/webp"]), + Source, + ) + assert b"png file" == mock_requests.last_request.body - def test_convert_should_return_source_with_data(self): - self.assertEqual(b'converted file', Source.from_buffer('png file').convert(type='image/jpg').to_buffer()) - self.assertJsonEqual('{"convert": {"type": "image/jpg"}}', httpretty.last_request().body.decode('utf-8')) + def test_convert_should_return_source_with_data(self, mock_requests): + assert ( + b"converted file" + == Source.from_buffer(b"png file").convert(type="image/jpg").to_buffer() + ) + assert_json_equal( + '{"convert": {"type": "image/jpg"}}', mock_requests.last_request.json() + ) - def test_store_should_return_result_meta(self): - self.assertIsInstance(Source.from_buffer('png file').store(service='s3'), ResultMeta) - self.assertJsonEqual('{"store":{"service":"s3"}}', httpretty.last_request().body.decode('utf-8')) + def test_store_should_return_result_meta(self, mock_requests): + assert isinstance( + Source.from_buffer(b"png file").store(service="s3"), ResultMeta + ) + assert_json_equal( + '{"store":{"service":"s3"}}', mock_requests.last_request.json() + ) - def test_store_should_return_result_meta_with_location(self): - self.assertEqual('https://bucket.s3-region.amazonaws.com/some/location', - Source.from_buffer('png file').store(service='s3').location) - self.assertJsonEqual('{"store":{"service":"s3"}}', httpretty.last_request().body.decode('utf-8')) + def test_store_should_return_result_meta_with_location(self, mock_requests): + assert ( + "https://bucket.s3-region.amazonaws.com/some/location" + == Source.from_buffer(b"png file").store(service="s3").location + ) + assert_json_equal( + '{"store":{"service":"s3"}}', mock_requests.last_request.json() + ) - def test_store_should_include_other_options_if_set(self): - self.assertEqual('https://bucket.s3-region.amazonaws.com/some/location', Source.from_buffer('png file').resize(width=400).store(service='s3').location) - self.assertJsonEqual('{"store":{"service":"s3"},"resize":{"width":400}}', httpretty.last_request().body.decode('utf-8')) + def test_store_should_include_other_options_if_set(self, mock_requests): + assert ( + "https://bucket.s3-region.amazonaws.com/some/location" + == Source.from_buffer(b"png file") + .resize(width=400) + .store(service="s3") + .location + ) + assert_json_equal( + '{"store":{"service":"s3"},"resize":{"width":400}}', + mock_requests.last_request.json(), + ) def test_to_buffer_should_return_image_data(self): - self.assertEqual(b'compressed file', Source.from_buffer('png file').to_buffer()) + assert b"compressed file" == Source.from_buffer(b"png file").to_buffer() def test_to_file_with_path_should_store_image_data(self): with tempfile.TemporaryFile() as tmp: - Source.from_buffer('png file').to_file(tmp) + Source.from_buffer(b"png file").to_file(tmp) tmp.seek(0) - self.assertEqual(b'compressed file', tmp.read()) + assert b"compressed file" == tmp.read() def test_to_file_with_file_object_should_store_image_data(self): - with create_named_tmpfile() as name: - Source.from_buffer('png file').to_file(name) - with open(name, 'rb') as f: - self.assertEqual(b'compressed file', f.read()) - - def test_all_options_together(self): - self.assertEqual('https://bucket.s3-region.amazonaws.com/some/location', - Source.from_buffer('png file').resize(width=400)\ - .convert(type=['image/webp', 'image/png'])\ - .transform(background="black")\ - .preserve("copyright", "location")\ - .store(service='s3').location) - self.assertJsonEqual('{"store":{"service":"s3"},"resize":{"width":400},"preserve": ["copyright", "location"], "transform": {"background": "black"}, "convert": {"type": ["image/webp", "image/png"]}}', httpretty.last_request().body.decode('utf-8')) - + name = create_named_tmpfile() + try: + Source.from_buffer(b"png file").to_file(name) + with open(name, "rb") as f: + assert b"compressed file" == f.read() + finally: + os.unlink(name) + + def test_all_options_together(self, mock_requests): + assert ( + "https://bucket.s3-region.amazonaws.com/some/location" + == Source.from_buffer(b"png file") + .resize(width=400) + .convert(type=["image/webp", "image/png"]) + .transform(background="black") + .preserve("copyright", "location") + .store(service="s3") + .location + ) + assert_json_equal( + '{"store":{"service":"s3"},"resize":{"width":400},"preserve": ["copyright", "location"], "transform": {"background": "black"}, "convert": {"type": ["image/webp", "image/png"]}}', + mock_requests.last_request.json(), + ) diff --git a/test/tinify_test.py b/test/tinify_test.py index 5543126..4d0a4af 100644 --- a/test/tinify_test.py +++ b/test/tinify_test.py @@ -1,96 +1,144 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, division, print_function, unicode_literals +import pytest +import tinify +import base64 -from base64 import b64encode -import tinify -import pytest -from helper import * +def test_key_should_reset_client_with_new_key(mock_requests): + mock_requests.get("https://api.tinify.com/") + tinify.key = "abcde" + tinify.get_client() + tinify.key = "fghij" + tinify.get_client().request("GET", "/") + + # Get the last request made to the endpoint + request = mock_requests.last_request + assert request.headers["authorization"] == "Basic {0}".format( + base64.b64encode(b"api:fghij").decode("ascii") + ) + + +def test_app_identifier_should_reset_client_with_new_app_identifier(mock_requests): + mock_requests.get("https://api.tinify.com/") + tinify.key = "abcde" + tinify.app_identifier = "MyApp/1.0" + tinify.get_client() + tinify.app_identifier = "MyApp/2.0" + tinify.get_client().request("GET", "/") + + request = mock_requests.last_request + assert request.headers["user-agent"] == tinify.Client.USER_AGENT + " MyApp/2.0" -class TinifyKey(TestHelper): - def test_should_reset_client_with_new_key(self): - httpretty.register_uri(httpretty.GET, 'https://api.tinify.com/') - tinify.key = 'abcde' - tinify.get_client() - tinify.key = 'fghij' - tinify.get_client().request('GET', '/') - self.assertEqual(self.request.headers['authorization'], 'Basic {0}'.format( - b64encode(b'api:fghij').decode('ascii'))) - -class TinifyAppIdentifier(TestHelper): - def test_should_reset_client_with_new_app_identifier(self): - httpretty.register_uri(httpretty.GET, 'https://api.tinify.com/') - tinify.key = 'abcde' - tinify.app_identifier = 'MyApp/1.0' - tinify.get_client() - tinify.app_identifier = 'MyApp/2.0' - tinify.get_client().request('GET', '/') - self.assertEqual(self.request.headers['user-agent'], tinify.Client.USER_AGENT + " MyApp/2.0") -class TinifyProxy(TestHelper): +def test_proxy_should_reset_client_with_new_proxy(mock_requests): + mock_requests.get("https://api.tinify.com/") - @pytest.mark.skip(reason="https://github.com/gabrielfalcao/HTTPretty/issues/122") - def test_should_reset_client_with_new_proxy(self): - httpretty.register_uri(httpretty.CONNECT, 'http://localhost:8080') - tinify.key = 'abcde' - tinify.proxy = 'http://localhost:8080' + tinify.key = "abcde" + tinify.proxy = "http://localhost:8080" + tinify.get_client() + + tinify.proxy = "http://localhost:9090" + new_client = tinify.get_client() + + new_client.request("GET", "/") + + # Verify the request was made with the correct proxy configuration + # The proxy settings should be in the session's proxies attribute + assert new_client.session.proxies["https"] == "http://localhost:9090" + + +def test_client_with_key_should_return_client(): + tinify.key = "abcde" + assert isinstance(tinify.get_client(), tinify.Client) + + +def test_client_without_key_should_raise_error(): + tinify.key = None + with pytest.raises(tinify.AccountError): tinify.get_client() - tinify.proxy = 'http://localhost:8080' - tinify.get_client().request('GET', '/') - self.assertEqual(self.request.headers['user-agent'], tinify.Client.USER_AGENT + " MyApp/2.0") - -class TinifyClient(TestHelper): - def test_with_key_should_return_client(self): - tinify.key = 'abcde' - self.assertIsInstance(tinify.get_client(), tinify.Client) - - def test_without_key_should_raise_error(self): - with self.assertRaises(tinify.AccountError): - tinify.get_client() - - def test_with_invalid_proxy_should_raise_error(self): - with self.assertRaises(tinify.ConnectionError): - tinify.key = 'abcde' - tinify.proxy = 'http-bad-url' - tinify.get_client().request('GET', '/') - -class TinifyValidate(TestHelper): - def test_with_valid_key_should_return_true(self): - httpretty.register_uri(httpretty.POST, 'https://api.tinify.com/shrink', status=400, - body='{"error":"Input missing","message":"No input"}') - tinify.key = 'valid' - self.assertEqual(True, tinify.validate()) - - def test_with_limited_key_should_return_true(self): - httpretty.register_uri(httpretty.POST, 'https://api.tinify.com/shrink', status=429, - body='{"error":"Too many requests","message":"Your monthly limit has been exceeded"}') - tinify.key = 'valid' - self.assertEqual(True, tinify.validate()) - - def test_with_error_should_raise_error(self): - httpretty.register_uri(httpretty.POST, 'https://api.tinify.com/shrink', status=401, - body='{"error":"Unauthorized","message":"Credentials are invalid"}') - tinify.key = 'valid' - with self.assertRaises(tinify.AccountError): - tinify.validate() - -class TinifyFromFile(TestHelper): - def test_should_return_source(self): - httpretty.register_uri(httpretty.POST, 'https://api.tinify.com/shrink', - location='https://api.tinify.com/some/location') - tinify.key = 'valid' - self.assertIsInstance(tinify.from_file(dummy_file), tinify.Source) - -class TinifyFromBuffer(TestHelper): - def test_should_return_source(self): - httpretty.register_uri(httpretty.POST, 'https://api.tinify.com/shrink', - location='https://api.tinify.com/some/location') - tinify.key = 'valid' - self.assertIsInstance(tinify.from_buffer('png file'), tinify.Source) - -class TinifyFromUrl(TestHelper): - def test_should_return_source(self): - httpretty.register_uri(httpretty.POST, 'https://api.tinify.com/shrink', - location='https://api.tinify.com/some/location') - tinify.key = 'valid' - self.assertIsInstance(tinify.from_url('http://example.com/test.jpg'), tinify.Source) + + +def test_client_with_invalid_proxy_should_raise_error(mock_requests): + # We can test invalid proxy format, but not actual connection issues with requests-mock + tinify.key = "abcde" + tinify.proxy = "http-bad-url" # Invalid proxy URL format + + with pytest.raises(tinify.ConnectionError): + tinify.get_client().request("GET", "/") + + +def test_validate_with_valid_key_should_return_true(mock_requests): + mock_requests.post( + "https://api.tinify.com/shrink", + status_code=400, + json={"error": "Input missing", "message": "No input"}, + ) + + tinify.key = "valid" + assert tinify.validate() is True + + +def test_validate_with_limited_key_should_return_true(mock_requests): + mock_requests.post( + "https://api.tinify.com/shrink", + status_code=429, + json={ + "error": "Too many requests", + "message": "Your monthly limit has been exceeded", + }, + ) + + tinify.key = "valid" + assert tinify.validate() is True + + +def test_validate_with_error_should_raise_error(mock_requests): + mock_requests.post( + "https://api.tinify.com/shrink", + status_code=401, + json={"error": "Unauthorized", "message": "Credentials are invalid"}, + ) + + tinify.key = "valid" + with pytest.raises(tinify.AccountError): + tinify.validate() + + +def test_from_file_should_return_source(mock_requests, tmp_path): + # Create a dummy file + dummy_file = tmp_path / "test.png" + dummy_file.write_bytes(b"png file") + + # Mock the API endpoint + mock_requests.post( + "https://api.tinify.com/shrink", + status_code=201, # Created + headers={"Location": "https://api.tinify.com/some/location"}, + ) + + tinify.key = "valid" + result = tinify.from_file(str(dummy_file)) + assert isinstance(result, tinify.Source) + + +def test_from_buffer_should_return_source(mock_requests): + mock_requests.post( + "https://api.tinify.com/shrink", + status_code=201, # Created + headers={"Location": "https://api.tinify.com/some/location"}, + ) + + tinify.key = "valid" + result = tinify.from_buffer("png file") + assert isinstance(result, tinify.Source) + + +def test_from_url_should_return_source(mock_requests): + mock_requests.post( + "https://api.tinify.com/shrink", + status_code=201, # Created + headers={"Location": "https://api.tinify.com/some/location"}, + ) + + tinify.key = "valid" + result = tinify.from_url("http://example.com/test.jpg") + assert isinstance(result, tinify.Source) From b8c16345d9d024fc18faa6a2bfeab6f727d24ae4 Mon Sep 17 00:00:00 2001 From: Remco Koopmans Date: Mon, 17 Mar 2025 18:34:21 +0100 Subject: [PATCH 2/5] Changelog and classifiers update --- CHANGES.md | 8 +++++++ setup.py | 59 +++++++++++++++++++++-------------------------- tinify/version.py | 2 +- 3 files changed, 35 insertions(+), 34 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 8cbc983..a91250a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,11 @@ +## 1.6.1 + +* Updated runtime support + * Dropped python 3.7 + * Added Python 3.12 + * Added Python 3.13 +* Tests: Replaced httpretty with requests-mock + ## 1.6.0 * Updated runtime support * Dropped 2.6 diff --git a/setup.py b/setup.py index 7d44ebe..47db79d 100644 --- a/setup.py +++ b/setup.py @@ -7,50 +7,43 @@ except ImportError: from distutils.core import setup -sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'tinify')) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "tinify")) from version import __version__ install_require = ["requests >= 2.7.0, < 3.0.0"] tests_require = ["pytest", "pytest-xdist", "requests-mock"] -if sys.version_info < (2, 7): - tests_require.append('unittest2') -if sys.version_info < (3, 3): - tests_require.append('mock >= 1.3, < 2.0') - setup( - name='tinify', + name="tinify", version=__version__, - description='Tinify API client.', - author='Jacob Middag', - author_email='info@tinify.com', - license='MIT', - long_description='Python client for the Tinify API. Tinify compresses your images intelligently. Read more at https://tinify.com.', - long_description_content_type='text/markdown', - url='https://tinify.com/developers', - - packages=['tinify'], + description="Tinify API client.", + author="Jacob Middag", + author_email="info@tinify.com", + license="MIT", + long_description="Python client for the Tinify API. Tinify compresses your images intelligently. Read more at https://tinify.com.", + long_description_content_type="text/markdown", + url="https://tinify.com/developers", + packages=["tinify"], package_data={ - '': ['LICENSE', 'README.md'], - 'tinify': ['data/cacert.pem'], + "": ["LICENSE", "README.md"], + "tinify": ["data/cacert.pem"], }, - install_requires=install_require, tests_require=tests_require, - extras_require={'test': tests_require}, - + extras_require={"test": tests_require}, classifiers=( - 'Development Status :: 5 - Production/Stable', - 'Intended Audience :: Developers', - 'Natural Language :: English', - 'License :: OSI Approved :: MIT License', - 'Programming Language :: Python', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - 'Programming Language :: Python :: 3.10', - 'Programming Language :: Python :: 3.11', + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Natural Language :: English", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python", + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", ), ) diff --git a/tinify/version.py b/tinify/version.py index bcd8d54..bb64aa4 100644 --- a/tinify/version.py +++ b/tinify/version.py @@ -1 +1 @@ -__version__ = '1.6.0' +__version__ = '1.6.1' From f18c8a79ed5bc4a7141d7a0e69b4e40077e3a7d9 Mon Sep 17 00:00:00 2001 From: Remco Koopmans Date: Tue, 18 Mar 2025 08:25:19 +0100 Subject: [PATCH 3/5] Add type annotations --- .github/workflows/ci-cd.yml | 25 +++++++- CHANGES.md | 3 +- setup.py | 7 +- test/__init__.py | 0 test/integration.py | 49 +++++++++++--- test/unit/__init__.py | 0 test/{ => unit}/conftest.py | 2 +- test/{ => unit}/tinify_client_test.py | 0 test/{ => unit}/tinify_result_meta_test.py | 0 test/{ => unit}/tinify_result_test.py | 0 test/{ => unit}/tinify_source_test.py | 0 test/{ => unit}/tinify_test.py | 0 tinify/__init__.py | 71 ++++++++++++++++---- tinify/client.py | 22 +++++-- tinify/errors.py | 15 +++-- tinify/py.typed | 1 + tinify/result.py | 26 +++++--- tinify/result_meta.py | 27 ++++++-- tinify/source.py | 75 +++++++++++++++++----- tinify/version.py | 2 +- 20 files changed, 255 insertions(+), 70 deletions(-) create mode 100644 test/__init__.py create mode 100644 test/unit/__init__.py rename test/{ => unit}/conftest.py (87%) rename test/{ => unit}/tinify_client_test.py (100%) rename test/{ => unit}/tinify_result_meta_test.py (100%) rename test/{ => unit}/tinify_result_test.py (100%) rename test/{ => unit}/tinify_source_test.py (100%) rename test/{ => unit}/tinify_test.py (100%) create mode 100644 tinify/py.typed diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index d95494e..6b3700a 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -42,11 +42,34 @@ jobs: run: | pytest + Mypy: + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + python-version: [ + "3.12" + ] + os: [ubuntu-latest, macOS-latest, windows-latest] + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + pip install -r test-requirements.txt -r requirements.txt + - name: Run tests + run: | + mypy --check tinify + Integration_tests: if: github.event_name == 'push' runs-on: ${{ matrix.os }} timeout-minutes: 10 - needs: Unit_tests + needs: [Unit_tests, Mypy, Unit_Tests_Py27] strategy: fail-fast: false matrix: diff --git a/CHANGES.md b/CHANGES.md index a91250a..50a9e70 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,6 @@ -## 1.6.1 +## 1.7.0 +* Added type annotations * Updated runtime support * Dropped python 3.7 * Added Python 3.12 diff --git a/setup.py b/setup.py index 47db79d..c4a6cd7 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,10 @@ from version import __version__ install_require = ["requests >= 2.7.0, < 3.0.0"] -tests_require = ["pytest", "pytest-xdist", "requests-mock"] +tests_require = ["pytest", "pytest-xdist", "requests-mock", "types-requests"] + +if sys.version_info.major > 2: + tests_require.append("mypy") setup( name="tinify", @@ -26,7 +29,7 @@ packages=["tinify"], package_data={ "": ["LICENSE", "README.md"], - "tinify": ["data/cacert.pem"], + "tinify": ["data/cacert.pem", "py.typed"], }, install_requires=install_require, tests_require=tests_require, diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/integration.py b/test/integration.py index 67b1e77..94cad2e 100644 --- a/test/integration.py +++ b/test/integration.py @@ -8,6 +8,13 @@ if not os.environ.get("TINIFY_KEY"): sys.exit("Set the TINIFY_KEY environment variable.") +try: + from typing import TYPE_CHECKING + if TYPE_CHECKING: + from tinify.source import Source +except ImportError: + pass + @contextmanager def create_named_tmpfile(): @@ -22,20 +29,26 @@ def create_named_tmpfile(): finally: os.unlink(tmp.name) +@pytest.fixture(scope="module", autouse=True) +def tinify_patch(): + tinify.key = os.environ.get("TINIFY_KEY") + tinify.proxy = os.environ.get("TINIFY_PROXY") + + yield + + tinify.key = None + tinify.proxy = None # Fixture for shared resources @pytest.fixture(scope="module") def optimized_image(): - tinify.key = os.environ.get("TINIFY_KEY") - tinify.proxy = os.environ.get("TINIFY_PROXY") - unoptimized_path = os.path.join( os.path.dirname(__file__), "examples", "voormedia.png" ) return tinify.from_file(unoptimized_path) -def test_should_compress_from_file(optimized_image): +def test_should_compress_from_file(optimized_image): # type: (Source) -> None with create_named_tmpfile() as tmp: optimized_image.to_file(tmp) @@ -69,10 +82,9 @@ def test_should_compress_from_url(): assert b"Copyright Voormedia" not in contents -def test_should_resize(optimized_image): +def test_should_resize(optimized_image): # type: (Source) -> None with create_named_tmpfile() as tmp: optimized_image.resize(method="fit", width=50, height=20).to_file(tmp) - size = os.path.getsize(tmp) with open(tmp, "rb") as f: contents = f.read() @@ -84,7 +96,7 @@ def test_should_resize(optimized_image): assert b"Copyright Voormedia" not in contents -def test_should_preserve_metadata(optimized_image): +def test_should_preserve_metadata(optimized_image): # type: (Source) -> None with create_named_tmpfile() as tmp: optimized_image.preserve("copyright", "creation").to_file(tmp) @@ -99,11 +111,30 @@ def test_should_preserve_metadata(optimized_image): assert b"Copyright Voormedia" in contents -def test_should_transcode_image(optimized_image): +def test_should_transcode_image(optimized_image): # type: (Source) -> None with create_named_tmpfile() as tmp: - optimized_image.convert(type=["image/webp"]).to_file(tmp) + conv = optimized_image.convert(type=["image/webp"]) + conv.to_file(tmp) with open(tmp, "rb") as f: content = f.read() assert b"RIFF" == content[:4] assert b"WEBP" == content[8:12] + + assert conv.result().size < optimized_image.result().size + assert conv.result().media_type == "image/webp" + assert conv.result().extension == "webp" + + +def test_should_handle_invalid_key(): + invalid_key = "invalid_key" + tinify.key = invalid_key + with pytest.raises(tinify.AccountError): + tinify.from_url( + "https://raw.githubusercontent.com/tinify/tinify-python/master/test/examples/voormedia.png" + ) + tinify.key = os.environ.get("TINIFY_KEY") + +def test_should_handle_invalid_image(): + with pytest.raises(tinify.ClientError): + tinify.from_buffer("invalid_image.png") \ No newline at end of file diff --git a/test/unit/__init__.py b/test/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/conftest.py b/test/unit/conftest.py similarity index 87% rename from test/conftest.py rename to test/unit/conftest.py index 3d15961..783951b 100644 --- a/test/conftest.py +++ b/test/unit/conftest.py @@ -6,7 +6,7 @@ @pytest.fixture def dummy_file(): - return os.path.join(os.path.dirname(__file__), "examples", "dummy.png") + return os.path.join(os.path.dirname(__file__), "..", "examples", "dummy.png") @pytest.fixture(autouse=True) diff --git a/test/tinify_client_test.py b/test/unit/tinify_client_test.py similarity index 100% rename from test/tinify_client_test.py rename to test/unit/tinify_client_test.py diff --git a/test/tinify_result_meta_test.py b/test/unit/tinify_result_meta_test.py similarity index 100% rename from test/tinify_result_meta_test.py rename to test/unit/tinify_result_meta_test.py diff --git a/test/tinify_result_test.py b/test/unit/tinify_result_test.py similarity index 100% rename from test/tinify_result_test.py rename to test/unit/tinify_result_test.py diff --git a/test/tinify_source_test.py b/test/unit/tinify_source_test.py similarity index 100% rename from test/tinify_source_test.py rename to test/unit/tinify_source_test.py diff --git a/test/tinify_test.py b/test/unit/tinify_test.py similarity index 100% rename from test/tinify_test.py rename to test/unit/tinify_test.py diff --git a/tinify/__init__.py b/tinify/__init__.py index 197cc22..8e4ba15 100644 --- a/tinify/__init__.py +++ b/tinify/__init__.py @@ -3,9 +3,21 @@ import threading import sys +try: + from typing import Optional, Any, TYPE_CHECKING +except ImportError: + TYPE_CHECKING = False # type: ignore class tinify(object): + + _client = None # type: Optional[Client] + _key = None # type: Optional[str] + _app_identifier = None # type: Optional[str] + _proxy = None # type: Optional[str] + _compression_count = None # type: Optional[int] + def __init__(self, module): + # type: (Any) -> None self._module = module self._lock = threading.RLock() @@ -17,40 +29,49 @@ def __init__(self, module): @property def key(self): + # type: () -> Optional[str] return self._key @key.setter def key(self, value): + # type: (str) -> None self._key = value self._client = None @property def app_identifier(self): + # type: () -> Optional[str] return self._app_identifier @app_identifier.setter def app_identifier(self, value): + # type: (str) -> None self._app_identifier = value self._client = None @property def proxy(self): - return self._key + # type: () -> Optional[str] + return self._proxy @proxy.setter def proxy(self, value): + # type: (str) -> None self._proxy = value self._client = None @property def compression_count(self): + # type: () -> Optional[int] return self._compression_count @compression_count.setter def compression_count(self, value): + # type: (int) -> None self._compression_count = value def get_client(self): + # type: () -> Client if not self._key: raise AccountError('Provide an API key with tinify.key = ...') @@ -63,9 +84,11 @@ def get_client(self): # Delegate to underlying base module. def __getattr__(self, attr): + # type: (str) -> Any return getattr(self._module, attr) def validate(self): + # type: () -> bool try: self.get_client().request('post', '/shrink') except AccountError as err: @@ -74,18 +97,44 @@ def validate(self): raise err except ClientError: return True + return False def from_file(self, path): + # type: (str) -> Source return Source.from_file(path) def from_buffer(self, string): + # type: (bytes) -> Source return Source.from_buffer(string) def from_url(self, url): + # type: (str) -> Source return Source.from_url(url) +if TYPE_CHECKING: + # Help the type checker here, as we overrride the module with a singleton object. + def get_client(): # type: () -> Client + ... + key = None # type: Optional[str] + app_identifier = None # type: Optional[str] + proxy = None # type: Optional[str] + compression_count = None # type: Optional[int] + + def validate(): # type: () -> bool + ... + + def from_file(path): # type: (str) -> Source + ... + + def from_buffer(string): # type: (bytes) -> Source + ... + + def from_url(url): # type: (str) -> Source + ... + + # Overwrite current module with singleton object. -tinify = sys.modules[__name__] = tinify(sys.modules[__name__]) +tinify = sys.modules[__name__] = tinify(sys.modules[__name__]) # type: ignore from .version import __version__ @@ -96,13 +145,13 @@ def from_url(self, url): from .errors import * __all__ = [ - b'Client', - b'Result', - b'ResultMeta', - b'Source', - b'Error', - b'AccountError', - b'ClientError', - b'ServerError', - b'ConnectionError' + 'Client', + 'Result', + 'ResultMeta', + 'Source', + 'Error', + 'AccountError', + 'ClientError', + 'ServerError', + 'ConnectionError' ] diff --git a/tinify/client.py b/tinify/client.py index b27a093..a170b05 100644 --- a/tinify/client.py +++ b/tinify/client.py @@ -6,13 +6,18 @@ import platform import requests import requests.exceptions -from requests.compat import json +from requests.compat import json # type: ignore import traceback import time import tinify from .errors import ConnectionError, Error +try: + from typing import Any, Optional +except ImportError: + pass + class Client(object): API_ENDPOINT = 'https://api.tinify.com' @@ -21,7 +26,7 @@ class Client(object): USER_AGENT = 'Tinify/{0} Python/{1} ({2})'.format(tinify.__version__, platform.python_version(), platform.python_implementation()) - def __init__(self, key, app_identifier=None, proxy=None): + def __init__(self, key, app_identifier=None, proxy=None): # type: (str, Optional[str], Optional[str]) -> None self.session = requests.sessions.Session() if proxy: self.session.proxies = {'https': proxy} @@ -31,18 +36,19 @@ def __init__(self, key, app_identifier=None, proxy=None): } self.session.verify = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'data', 'cacert.pem') - def __enter__(self): + def __enter__(self): # type: () -> Client return self - def __exit__(self, *args): + def __exit__(self, *args): # type: (*Any) -> None self.close() + return None - def close(self): + def close(self): # type: () -> None self.session.close() - def request(self, method, url, body=None): + def request(self, method, url, body=None): # type: (str, str, Any) -> requests.Response url = url if url.lower().startswith('https://') else self.API_ENDPOINT + url - params = {} + params = {} # type: dict[str, Any] if isinstance(body, dict): if body: # Dump without whitespace. @@ -77,3 +83,5 @@ def request(self, method, url, body=None): details = {'message': 'Error while parsing response: {0}'.format(err), 'error': 'ParseError'} if retries > 0 and response.status_code >= 500: continue raise Error.create(details.get('message'), details.get('error'), response.status_code) + + raise Error.create("Received no response", "ConnectionError", 0) \ No newline at end of file diff --git a/tinify/errors.py b/tinify/errors.py index d59c75e..092d597 100644 --- a/tinify/errors.py +++ b/tinify/errors.py @@ -1,23 +1,26 @@ # -*- coding: utf-8 -*- from __future__ import absolute_import, division, print_function, unicode_literals +try: + from typing import Optional +except ImportError: + pass + class Error(Exception): @staticmethod - def create(message, kind, status): - klass = None + def create(message, kind, status): # type: (Optional[str], Optional[str], int) -> Error + klass = Error # type: type[Error] if status == 401 or status == 429: klass = AccountError elif status >= 400 and status <= 499: klass = ClientError elif status >= 400 and status < 599: klass = ServerError - else: - klass = Error if not message: message = 'No message was provided' return klass(message, kind, status) - def __init__(self, message, kind=None, status=None, cause=None): + def __init__(self, message, kind=None, status=None, cause=None): # type: (str, Optional[str], Optional[int], Optional[Exception]) -> None self.message = message self.kind = kind self.status = status @@ -25,7 +28,7 @@ def __init__(self, message, kind=None, status=None, cause=None): # Equivalent to 'raise err from cause', also supported by Python 2. self.__cause__ = cause - def __str__(self): + def __str__(self): # type: () -> str if self.status: return '{0} (HTTP {1:d}/{2})'.format(self.message, self.status, self.kind) else: diff --git a/tinify/py.typed b/tinify/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tinify/py.typed @@ -0,0 +1 @@ + diff --git a/tinify/result.py b/tinify/result.py index 619dce0..cbf2956 100644 --- a/tinify/result.py +++ b/tinify/result.py @@ -1,42 +1,50 @@ # -*- coding: utf-8 -*- from __future__ import absolute_import, division, print_function, unicode_literals +from requests.structures import CaseInsensitiveDict from . import ResultMeta +try: + from typing import Union, Optional, IO +except ImportError: + pass + + class Result(ResultMeta): - def __init__(self, meta, data): + def __init__(self, meta, data): # type: (CaseInsensitiveDict[str], bytes) -> None ResultMeta.__init__(self, meta) self.data = data - def to_file(self, path): + def to_file(self, path): # type: (Union[str, IO]) -> None if hasattr(path, 'write'): path.write(self.data) else: with open(path, 'wb') as f: f.write(self.data) - def to_buffer(self): + def to_buffer(self): # type: () -> bytes return self.data @property - def size(self): + def size(self): # type: () -> Optional[int] value = self._meta.get('Content-Length') - return value and int(value) + return int(value) if value is not None else None @property - def media_type(self): + def media_type(self): # type: () -> Optional[str] return self._meta.get('Content-Type') @property - def extension(self): + def extension(self): # type: () -> Optional[str] media_type = self._meta.get('Content-Type') if media_type: return media_type.split('/')[-1] + return None @property - def content_type(self): + def content_type(self): # type: () -> Optional[str] return self.media_type @property - def location(self): + def location(self): # type: () -> Optional[str] return None diff --git a/tinify/result_meta.py b/tinify/result_meta.py index e06114b..fe7de4c 100644 --- a/tinify/result_meta.py +++ b/tinify/result_meta.py @@ -1,23 +1,36 @@ # -*- coding: utf-8 -*- from __future__ import absolute_import, division, print_function, unicode_literals +from requests.structures import CaseInsensitiveDict + +try: + from typing import Optional, Dict +except ImportError: + pass + + class ResultMeta(object): - def __init__(self, meta): + def __init__(self, meta): # type: (CaseInsensitiveDict[str]) -> None self._meta = meta @property - def width(self): + def width(self): # type: () -> Optional[int] value = self._meta.get('Image-Width') - return value and int(value) + return int(value) if value else None @property - def height(self): + def height(self): # type: () -> Optional[int] value = self._meta.get('Image-Height') - return value and int(value) + return int(value) if value else None @property - def location(self): + def location(self): # type: () -> Optional[str] return self._meta.get('Location') - def __len__(self): + @property + def size(self): # type: () -> Optional[int] + value = self._meta.get('Content-Length') + return int(value) if value else None + + def __len__(self): # type: () -> int return self.size or 0 diff --git a/tinify/source.py b/tinify/source.py index b9bbb2b..67a55f3 100644 --- a/tinify/source.py +++ b/tinify/source.py @@ -4,9 +4,45 @@ import tinify from . import Result, ResultMeta +try: + from typing import Union, Dict, IO, Any, TypedDict, List, Literal, Optional, Unpack, TYPE_CHECKING, overload + + class ResizeOptions(TypedDict, total=False): + method: Literal['scale', 'fit', 'cover', 'thumb'] + width: int + height: int + + ConvertTypes = Literal['image/webp', 'image/jpeg', 'image/png', "image/avif", "*/*"] + class ConvertOptions(TypedDict, total=False): + type: Union[ConvertTypes, List[ConvertTypes]] + + class TransformOptions(TypedDict, total=False): + background: str | Literal["white", "black"] + + class S3StoreOptions(TypedDict, total=False): + service: Literal['s3'] + aws_access_key_id: str + aws_secret_access_key: str + region: str + path: str + headers: Optional[Dict[str, str]] + acl: Optional[Literal["no-acl"]] + + class GCSStoreOptions(TypedDict, total=False): + service: Literal['gcs'] + gcp_access_token: str + path: str + headers: Optional[Dict[str, str]] + + PreserveOption = Literal['copyright', 'creation', 'location'] +except ImportError: + TYPE_CHECKING = False # type: ignore + + + class Source(object): @classmethod - def from_file(cls, path): + def from_file(cls, path): # type: (Union[str, IO]) -> Source if hasattr(path, 'read'): return cls._shrink(path) else: @@ -14,49 +50,58 @@ def from_file(cls, path): return cls._shrink(f.read()) @classmethod - def from_buffer(cls, string): + def from_buffer(cls, string): # type: (bytes) -> Source return cls._shrink(string) @classmethod - def from_url(cls, url): + def from_url(cls, url): # type: (str) -> Source return cls._shrink({"source": {"url": url}}) @classmethod - def _shrink(cls, obj): + def _shrink(cls, obj): # type: (Any) -> Source response = tinify.get_client().request('POST', '/shrink', obj) - return cls(response.headers.get('location')) + return cls(response.headers['location']) - def __init__(self, url, **commands): + def __init__(self, url, **commands): # type: (str, **Any) -> None self.url = url self.commands = commands - def preserve(self, *options): + def preserve(self, *options): # type: (*PreserveOption) -> "Source" return type(self)(self.url, **self._merge_commands(preserve=self._flatten(options))) - def resize(self, **options): + def resize(self, **options): # type: (Unpack[ResizeOptions]) -> "Source" return type(self)(self.url, **self._merge_commands(resize=options)) - def convert(self, **options): + def convert(self, **options): # type: (Unpack[ConvertOptions]) -> "Source" return type(self)(self.url, **self._merge_commands(convert=options)) - def transform(self, **options): + def transform(self, **options): # type: (Unpack[TransformOptions]) -> "Source" return type(self)(self.url, **self._merge_commands(transform=options)) - def store(self, **options): + if TYPE_CHECKING: + @overload + def store(self, **options): # type: (Unpack[S3StoreOptions]) -> ResultMeta + ... + + @overload + def store(self, **options): # type: (Unpack[GCSStoreOptions]) -> ResultMeta + ... + + def store(self, **options): # type: (Any) -> ResultMeta response = tinify.get_client().request('POST', self.url, self._merge_commands(store=options)) return ResultMeta(response.headers) - def result(self): + def result(self): # type: () -> Result response = tinify.get_client().request('GET', self.url, self.commands) return Result(response.headers, response.content) - def to_file(self, path): + def to_file(self, path): # type: (Union[str, IO]) -> None return self.result().to_file(path) - def to_buffer(self): + def to_buffer(self): # type: () -> bytes return self.result().to_buffer() - def _merge_commands(self, **options): + def _merge_commands(self, **options): # type: (**Any) -> Dict[str, Any] commands = self.commands.copy() commands.update(options) return commands diff --git a/tinify/version.py b/tinify/version.py index bb64aa4..ad95fc7 100644 --- a/tinify/version.py +++ b/tinify/version.py @@ -1 +1 @@ -__version__ = '1.6.1' +__version__ = '1.7.0' \ No newline at end of file From b4688c517549b14f9986ca8641dfa261754e9866 Mon Sep 17 00:00:00 2001 From: Remco Koopmans Date: Tue, 18 Mar 2025 09:31:09 +0100 Subject: [PATCH 4/5] Python 2 backwards support with types --- tinify/__init__.py | 10 +++++----- tinify/source.py | 43 ++++++++----------------------------------- tinify/typed.py | 30 ++++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 40 deletions(-) create mode 100644 tinify/typed.py diff --git a/tinify/__init__.py b/tinify/__init__.py index 8e4ba15..9fcbd45 100644 --- a/tinify/__init__.py +++ b/tinify/__init__.py @@ -114,23 +114,23 @@ def from_url(self, url): if TYPE_CHECKING: # Help the type checker here, as we overrride the module with a singleton object. def get_client(): # type: () -> Client - ... + pass key = None # type: Optional[str] app_identifier = None # type: Optional[str] proxy = None # type: Optional[str] compression_count = None # type: Optional[int] def validate(): # type: () -> bool - ... + pass def from_file(path): # type: (str) -> Source - ... + pass def from_buffer(string): # type: (bytes) -> Source - ... + pass def from_url(url): # type: (str) -> Source - ... + pass # Overwrite current module with singleton object. diff --git a/tinify/source.py b/tinify/source.py index 67a55f3..1a89ee3 100644 --- a/tinify/source.py +++ b/tinify/source.py @@ -2,44 +2,17 @@ from __future__ import absolute_import, division, print_function, unicode_literals import tinify -from . import Result, ResultMeta +import sys +from tinify.result import Result +from tinify.result_meta import ResultMeta try: - from typing import Union, Dict, IO, Any, TypedDict, List, Literal, Optional, Unpack, TYPE_CHECKING, overload - - class ResizeOptions(TypedDict, total=False): - method: Literal['scale', 'fit', 'cover', 'thumb'] - width: int - height: int - - ConvertTypes = Literal['image/webp', 'image/jpeg', 'image/png', "image/avif", "*/*"] - class ConvertOptions(TypedDict, total=False): - type: Union[ConvertTypes, List[ConvertTypes]] - - class TransformOptions(TypedDict, total=False): - background: str | Literal["white", "black"] - - class S3StoreOptions(TypedDict, total=False): - service: Literal['s3'] - aws_access_key_id: str - aws_secret_access_key: str - region: str - path: str - headers: Optional[Dict[str, str]] - acl: Optional[Literal["no-acl"]] - - class GCSStoreOptions(TypedDict, total=False): - service: Literal['gcs'] - gcp_access_token: str - path: str - headers: Optional[Dict[str, str]] - - PreserveOption = Literal['copyright', 'creation', 'location'] + from typing import Union, Dict, IO, Any, List, Literal, Optional, Unpack, TYPE_CHECKING, overload + if sys.version_info.major > 3 and sys.version_info.minor > 8: + from tinify.typed import * except ImportError: TYPE_CHECKING = False # type: ignore - - class Source(object): @classmethod def from_file(cls, path): # type: (Union[str, IO]) -> Source @@ -81,11 +54,11 @@ def transform(self, **options): # type: (Unpack[TransformOptions]) -> "Source" if TYPE_CHECKING: @overload def store(self, **options): # type: (Unpack[S3StoreOptions]) -> ResultMeta - ... + pass @overload def store(self, **options): # type: (Unpack[GCSStoreOptions]) -> ResultMeta - ... + pass def store(self, **options): # type: (Any) -> ResultMeta response = tinify.get_client().request('POST', self.url, self._merge_commands(store=options)) diff --git a/tinify/typed.py b/tinify/typed.py new file mode 100644 index 0000000..8a904d9 --- /dev/null +++ b/tinify/typed.py @@ -0,0 +1,30 @@ +from typing import Union, Dict, List, Literal, Optional, TypedDict + +class ResizeOptions(TypedDict,total=False): + method: Literal['scale', 'fit', 'cover', 'thumb'] + width: int + height: int + +ConvertTypes = Literal['image/webp', 'image/jpeg', 'image/png', "image/avif", "*/*"] +class ConvertOptions(TypedDict, total=False): + type: Union[ConvertTypes, List[ConvertTypes]] + +class TransformOptions(TypedDict, total=False): + background: Union[str, Literal["white", "black"]] + +class S3StoreOptions(TypedDict, total=False): + service: Literal['s3'] + aws_access_key_id: str + aws_secret_access_key: str + region: str + path: str + headers: Optional[Dict[str, str]] + acl: Optional[Literal["no-acl"]] + +class GCSStoreOptions(TypedDict, total=False): + service: Literal['gcs'] + gcp_access_token: str + path: str + headers: Optional[Dict[str, str]] + +PreserveOption = Literal['copyright', 'creation', 'location'] From c46e4a2d7f2fdcba94474914a9c4b6ab25fbf42e Mon Sep 17 00:00:00 2001 From: Remco Koopmans Date: Tue, 18 Mar 2025 09:39:21 +0100 Subject: [PATCH 5/5] prefix typed file with underscore to show private intent --- tinify/{typed.py => _typed.py} | 4 ++-- tinify/source.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) rename tinify/{typed.py => _typed.py} (94%) diff --git a/tinify/typed.py b/tinify/_typed.py similarity index 94% rename from tinify/typed.py rename to tinify/_typed.py index 8a904d9..6e51a8a 100644 --- a/tinify/typed.py +++ b/tinify/_typed.py @@ -2,8 +2,8 @@ class ResizeOptions(TypedDict,total=False): method: Literal['scale', 'fit', 'cover', 'thumb'] - width: int - height: int + width: Optional[int] + height: Optional[int] ConvertTypes = Literal['image/webp', 'image/jpeg', 'image/png', "image/avif", "*/*"] class ConvertOptions(TypedDict, total=False): diff --git a/tinify/source.py b/tinify/source.py index 1a89ee3..1ec2b65 100644 --- a/tinify/source.py +++ b/tinify/source.py @@ -7,9 +7,9 @@ from tinify.result_meta import ResultMeta try: - from typing import Union, Dict, IO, Any, List, Literal, Optional, Unpack, TYPE_CHECKING, overload + from typing import Union, Dict, IO, Any, Unpack, TYPE_CHECKING, overload if sys.version_info.major > 3 and sys.version_info.minor > 8: - from tinify.typed import * + from tinify._typed import * except ImportError: TYPE_CHECKING = False # type: ignore