Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
60 changes: 60 additions & 0 deletions test/unit/tinify_format_test.py
Original file line number Diff line number Diff line change
@@ -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"]}
2 changes: 2 additions & 0 deletions tinify/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -146,6 +147,7 @@ def from_url(url): # type: (str) -> Source

__all__ = [
'Client',
'Format',
'Result',
'ResultMeta',
'Source',
Expand Down
56 changes: 56 additions & 0 deletions tinify/_format.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# -*- 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.

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])

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" # type: Final[Literal["image/webp"]]

#: PNG.
PNG = "image/png" # type: Final[Literal["image/png"]]

#: JPEG.
JPEG = "image/jpeg" # type: Final[Literal["image/jpeg"]]

#: 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" # type: Final[Literal["image/avif"]]

#: JPEG XL.
JXL = "image/jxl" # type: Final[Literal["image/jxl"]]

#: Wildcard, returns the smallest of the supported types.
ANY = "*/*" # type: Final[Literal["*/*"]]

def __init__(self):
# type: () -> None
raise TypeError("Format is a namespace of constants and cannot be instantiated")
2 changes: 1 addition & 1 deletion tinify/_typed.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", "*/*"]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We also need a type hint in the API to inform the user that they may use the enum from _format.py

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 175af41 — and it was worse than a missing hint: the constants did not typecheck at all.

JXL = "image/jxl" was inferred as str, so convert(type=Format.JXL) failed against the ConvertTypes literal union:

Argument "type" to "convert" of "Source" has incompatible type "str";
expected "Literal['image/webp', ..., 'image/jxl', '*/*']"

The constants were exactly what you could not pass. Each is now annotated Final[Literal[...]], which narrows it to its own literal type and makes it a member of ConvertTypes, so no change to _typed.py is needed.

Used # type: comments with a TYPE_CHECKING-guarded import rather than inline annotations, since setup.py still classifies 2.7 and this module is imported at runtime by __init__.py. Verified with mypy: all three call sites (single, list, bare literal) now pass; python -c "import tinify" still works.

Worth noting the repo's own mypy --check tinify would not have caught this — it checks the package, not call sites that consume these hints.

class ConvertOptions(TypedDict, total=False):
type: Union[ConvertTypes, List[ConvertTypes]]

Expand Down
Loading