From d7361636139504b0081656452a2ca1593b3a96d6 Mon Sep 17 00:00:00 2001 From: Eirini Galati Date: Thu, 3 Sep 2026 11:08:37 +0200 Subject: [PATCH 1/2] feat: add Format constants for convert media types Adds a Format namespace of media-type string constants so the types accepted by convert() are discoverable via autocomplete, matching the other Tinify client libraries. Also adds the missing image/jpg to ConvertTypes. The API accepts it, but the type hints did not, so passing it failed type checking. --- CHANGES.md | 5 +++ test/unit/tinify_format_test.py | 60 +++++++++++++++++++++++++++++++++ tinify/__init__.py | 2 ++ tinify/_format.py | 42 +++++++++++++++++++++++ tinify/_typed.py | 2 +- 5 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 test/unit/tinify_format_test.py create mode 100644 tinify/_format.py diff --git a/CHANGES.md b/CHANGES.md index 4092e15..d33aa7e 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,8 @@ +## Unreleased + +* Added `tinify.Format` constants for the media types `convert` accepts. +* Added the missing `image/jpg` media type to the `convert` type hints. + ## 1.7.2 * Add JXL to supported image types diff --git a/test/unit/tinify_format_test.py b/test/unit/tinify_format_test.py new file mode 100644 index 0000000..7bc693e --- /dev/null +++ b/test/unit/tinify_format_test.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +import json +import pytest + +import tinify +from tinify import Format, Source + + +class TestTinifyFormat: + def test_should_expose_supported_media_types(self): + assert Format.WEBP == "image/webp" + assert Format.PNG == "image/png" + assert Format.JPEG == "image/jpeg" + assert Format.JPG == "image/jpg" + assert Format.AVIF == "image/avif" + assert Format.JXL == "image/jxl" + assert Format.ANY == "*/*" + + def test_should_not_be_instantiable(self): + with pytest.raises(TypeError): + Format() + + +class TestTinifyFormatConvert: + @pytest.fixture(autouse=True) + def setup(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"}, + ) + self.mock_requests = mock_requests + yield + + def test_convert_with_format_should_serialize_media_type(self): + self.mock_requests.post( + "https://api.tinify.com/some/location", + status_code=200, + content=b"converted file", + ) + + Source.from_buffer("png file").convert(type=Format.JXL).to_buffer() + + body = json.loads(self.mock_requests.last_request.text) + assert body["convert"] == {"type": "image/jxl"} + + def test_convert_with_multiple_formats_should_serialize_media_types(self): + self.mock_requests.post( + "https://api.tinify.com/some/location", + status_code=200, + content=b"converted file", + ) + + Source.from_buffer("png file").convert( + type=[Format.JXL, Format.WEBP] + ).to_buffer() + + body = json.loads(self.mock_requests.last_request.text) + assert body["convert"] == {"type": ["image/jxl", "image/webp"]} diff --git a/tinify/__init__.py b/tinify/__init__.py index 9fcbd45..686a3cd 100644 --- a/tinify/__init__.py +++ b/tinify/__init__.py @@ -138,6 +138,7 @@ def from_url(url): # type: (str) -> Source from .version import __version__ +from ._format import Format from .client import Client from .result_meta import ResultMeta from .result import Result @@ -146,6 +147,7 @@ def from_url(url): # type: (str) -> Source __all__ = [ 'Client', + 'Format', 'Result', 'ResultMeta', 'Source', diff --git a/tinify/_format.py b/tinify/_format.py new file mode 100644 index 0000000..fd9e6ce --- /dev/null +++ b/tinify/_format.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, print_function, unicode_literals + + +class Format(object): + """Media types that images can be converted to. + + Use these constants with :meth:`Source.convert`:: + + tinify.from_file("input.png") \\ + .convert(type=tinify.Format.JXL) \\ + .to_file("output.jxl") + + Multiple types may be supplied, in which case the API returns the + smallest result:: + + .convert(type=[tinify.Format.JXL, tinify.Format.WEBP]) + """ + + #: WebP. + WEBP = "image/webp" + + #: PNG. + PNG = "image/png" + + #: JPEG. + JPEG = "image/jpeg" + + #: JPEG, an alias of :attr:`JPEG`. + JPG = "image/jpg" + + #: AVIF. + AVIF = "image/avif" + + #: JPEG XL. + JXL = "image/jxl" + + #: Wildcard, returns the smallest of the supported types. + ANY = "*/*" + + def __init__(self): + raise TypeError("Format is a namespace of constants and cannot be instantiated") diff --git a/tinify/_typed.py b/tinify/_typed.py index fc1a6da..06789e4 100644 --- a/tinify/_typed.py +++ b/tinify/_typed.py @@ -5,7 +5,7 @@ class ResizeOptions(TypedDict,total=False): width: Optional[int] height: Optional[int] -ConvertTypes = Literal['image/webp', 'image/jpeg', 'image/png', "image/avif", "image/jxl", "*/*"] +ConvertTypes = Literal['image/webp', 'image/jpeg', 'image/jpg', 'image/png', "image/avif", "image/jxl", "*/*"] class ConvertOptions(TypedDict, total=False): type: Union[ConvertTypes, List[ConvertTypes]] From 175af416b6109af2623a98c33942c9c71f41be2c Mon Sep 17 00:00:00 2001 From: Eirini Galati Date: Sun, 1 Nov 2026 00:10:07 +0100 Subject: [PATCH 2/2] fix: type Format constants as literals so convert accepts them The constants were inferred as plain str, so passing Format.JXL to convert() failed type checking against the ConvertTypes literal union -- the constants were exactly what you could not pass. Annotating each as Final[Literal[...]] via type comments narrows them to their own literal type. Type comments plus a TYPE_CHECKING-guarded import keep the module importable on the Python 2.7 the package still claims. Also clarifies that JPG is not an alias of JPEG, and annotates __init__. --- tinify/_format.py | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/tinify/_format.py b/tinify/_format.py index fd9e6ce..c3f9521 100644 --- a/tinify/_format.py +++ b/tinify/_format.py @@ -1,6 +1,14 @@ # -*- coding: utf-8 -*- from __future__ import absolute_import, division, print_function, unicode_literals +try: + from typing import TYPE_CHECKING +except ImportError: + TYPE_CHECKING = False # type: ignore + +if TYPE_CHECKING: + from typing import Final, Literal + class Format(object): """Media types that images can be converted to. @@ -15,28 +23,34 @@ class Format(object): smallest result:: .convert(type=[tinify.Format.JXL, tinify.Format.WEBP]) + + Each constant is typed as its own literal, so they satisfy the + :data:`~tinify._typed.ConvertTypes` hints that :meth:`Source.convert` + accepts. """ #: WebP. - WEBP = "image/webp" + WEBP = "image/webp" # type: Final[Literal["image/webp"]] #: PNG. - PNG = "image/png" + PNG = "image/png" # type: Final[Literal["image/png"]] #: JPEG. - JPEG = "image/jpeg" + JPEG = "image/jpeg" # type: Final[Literal["image/jpeg"]] - #: JPEG, an alias of :attr:`JPEG`. - JPG = "image/jpg" + #: JPEG, under the alternative media type the API also accepts. + #: Note this is a distinct value from :attr:`JPEG`, not an alias for it. + JPG = "image/jpg" # type: Final[Literal["image/jpg"]] #: AVIF. - AVIF = "image/avif" + AVIF = "image/avif" # type: Final[Literal["image/avif"]] #: JPEG XL. - JXL = "image/jxl" + JXL = "image/jxl" # type: Final[Literal["image/jxl"]] #: Wildcard, returns the smallest of the supported types. - ANY = "*/*" + ANY = "*/*" # type: Final[Literal["*/*"]] def __init__(self): + # type: () -> None raise TypeError("Format is a namespace of constants and cannot be instantiated")