Added type hints (#2)

* Added type hints

* Updated nasm to 2.16.03

* Removed duplicate meson install

* Simplified code

* Sort formats alphabetically

* tile is already an empty list

---------

Co-authored-by: Andrew Murray <radarhere@users.noreply.github.com>
This commit is contained in:
Andrew Murray 2024-10-17 00:41:14 +11:00 committed by Frankie Dintino
parent 3878b588a4
commit e2add24ec5
No known key found for this signature in database
GPG Key ID: 97E295AACFBABD9E
5 changed files with 147 additions and 119 deletions

View File

@ -66,21 +66,25 @@ function build_harfbuzz {
} }
function install_rav1e { function install_rav1e {
if [[ -n "$IS_MACOS" ]] && [[ "$PLAT" == "arm64" ]]; then if [ -n "$IS_MACOS" ]; then
librav1e_tgz=librav1e-${RAV1E_VERSION}-macos-aarch64.tar.gz suffix="macos"
elif [ -n "$IS_MACOS" ]; then if [[ "$PLAT" == "arm64" ]]; then
librav1e_tgz=librav1e-${RAV1E_VERSION}-macos.tar.gz suffix+="-aarch64"
elif [ "$PLAT" == "aarch64" ]; then fi
librav1e_tgz=librav1e-${RAV1E_VERSION}-linux-aarch64.tar.gz
else else
librav1e_tgz=librav1e-${RAV1E_VERSION}-linux-generic.tar.gz suffix="linux"
if [[ "$PLAT" == "aarch64" ]]; then
suffix+="-aarch64"
else
suffix+="-generic"
fi
fi fi
curl -sLo - \ curl -sLo - \
https://github.com/xiph/rav1e/releases/download/v$RAV1E_VERSION/$librav1e_tgz \ https://github.com/xiph/rav1e/releases/download/v$RAV1E_VERSION/librav1e-$RAV1E_VERSION-$suffix.tar.gz \
| tar -C $BUILD_PREFIX --exclude LICENSE --exclude LICENSE --exclude '*.so' --exclude '*.dylib' -zxf - | tar -C $BUILD_PREFIX --exclude LICENSE --exclude LICENSE --exclude '*.so' --exclude '*.dylib' -zxf -
if [ ! -n "$IS_MACOS" ]; then if [ -z "$IS_MACOS" ]; then
sed -i 's/-lgcc_s/-lgcc_eh/g' "${BUILD_PREFIX}/lib/pkgconfig/rav1e.pc" sed -i 's/-lgcc_s/-lgcc_eh/g' "${BUILD_PREFIX}/lib/pkgconfig/rav1e.pc"
fi fi
@ -101,7 +105,7 @@ function build_libavif {
python -m pip install meson ninja python -m pip install meson ninja
if [[ "$PLAT" == "x86_64" ]]; then if [[ "$PLAT" == "x86_64" ]]; then
build_simple nasm 2.15.05 https://www.nasm.us/pub/nasm/releasebuilds/2.15.05/ build_simple nasm 2.16.03 https://www.nasm.us/pub/nasm/releasebuilds/2.16.03/
fi fi
local cmake=$(get_modern_cmake) local cmake=$(get_modern_cmake)
@ -210,7 +214,7 @@ if [[ -n "$IS_MACOS" ]]; then
brew remove --ignore-dependencies webp aom libavif brew remove --ignore-dependencies webp aom libavif
fi fi
brew install meson pkg-config brew install pkg-config
# clear bash path cache for curl # clear bash path cache for curl
hash -d curl hash -d curl

View File

@ -20,7 +20,7 @@ pytestmark = [
] ]
def test_leak_load(): def test_leak_load() -> None:
from resource import RLIMIT_AS, RLIMIT_STACK, setrlimit from resource import RLIMIT_AS, RLIMIT_STACK, setrlimit
setrlimit(RLIMIT_STACK, (stack_size, stack_size)) setrlimit(RLIMIT_STACK, (stack_size, stack_size))
@ -30,7 +30,7 @@ def test_leak_load():
im.load() im.load()
def test_leak_save(): def test_leak_save() -> None:
from resource import RLIMIT_AS, RLIMIT_STACK, setrlimit from resource import RLIMIT_AS, RLIMIT_STACK, setrlimit
setrlimit(RLIMIT_STACK, (stack_size, stack_size)) setrlimit(RLIMIT_STACK, (stack_size, stack_size))

View File

@ -5,13 +5,23 @@ import os
import re import re
import warnings import warnings
import xml.etree.ElementTree import xml.etree.ElementTree
from collections.abc import Generator
from contextlib import contextmanager from contextlib import contextmanager
from io import BytesIO from io import BytesIO
from pathlib import Path
from struct import unpack from struct import unpack
from typing import Any
import pytest import pytest
from PIL import AvifImagePlugin, Image, ImageDraw, UnidentifiedImageError, features from PIL import (
AvifImagePlugin,
Image,
ImageDraw,
ImageFile,
UnidentifiedImageError,
features,
)
from .helper import ( from .helper import (
PillowLeakTestCase, PillowLeakTestCase,
@ -33,41 +43,43 @@ except ImportError:
TEST_AVIF_FILE = "Tests/images/avif/hopper.avif" TEST_AVIF_FILE = "Tests/images/avif/hopper.avif"
def assert_xmp_orientation(xmp, expected): def assert_xmp_orientation(xmp: bytes | None, expected: int) -> None:
assert isinstance(xmp, bytes) assert isinstance(xmp, bytes)
root = xml.etree.ElementTree.fromstring(xmp) root = xml.etree.ElementTree.fromstring(xmp)
orientation = None orientation = None
for elem in root.iter(): for elem in root.iter():
if elem.tag.endswith("}Description"): if elem.tag.endswith("}Description"):
orientation = elem.attrib.get("{http://ns.adobe.com/tiff/1.0/}Orientation") tag_orientation = elem.attrib.get(
if orientation: "{http://ns.adobe.com/tiff/1.0/}Orientation"
orientation = int(orientation) )
if tag_orientation:
orientation = int(tag_orientation)
break break
assert orientation == expected assert orientation == expected
def roundtrip(im, **options): def roundtrip(im: ImageFile.ImageFile, **options: Any) -> ImageFile.ImageFile:
out = BytesIO() out = BytesIO()
im.save(out, "AVIF", **options) im.save(out, "AVIF", **options)
out.seek(0) out.seek(0)
return Image.open(out) return Image.open(out)
def skip_unless_avif_decoder(codec_name): def skip_unless_avif_decoder(codec_name: str) -> pytest.MarkDecorator:
reason = f"{codec_name} decode not available" reason = f"{codec_name} decode not available"
return pytest.mark.skipif( return pytest.mark.skipif(
not HAVE_AVIF or not _avif.decoder_codec_available(codec_name), reason=reason not HAVE_AVIF or not _avif.decoder_codec_available(codec_name), reason=reason
) )
def skip_unless_avif_encoder(codec_name): def skip_unless_avif_encoder(codec_name: str) -> pytest.MarkDecorator:
reason = f"{codec_name} encode not available" reason = f"{codec_name} encode not available"
return pytest.mark.skipif( return pytest.mark.skipif(
not HAVE_AVIF or not _avif.encoder_codec_available(codec_name), reason=reason not HAVE_AVIF or not _avif.encoder_codec_available(codec_name), reason=reason
) )
def is_docker_qemu(): def is_docker_qemu() -> bool:
try: try:
init_proc_exe = os.readlink("/proc/1/exe") init_proc_exe = os.readlink("/proc/1/exe")
except: # noqa: E722 except: # noqa: E722
@ -76,7 +88,7 @@ def is_docker_qemu():
return "qemu" in init_proc_exe return "qemu" in init_proc_exe
def has_alpha_premultiplied(im_bytes): def has_alpha_premultiplied(im_bytes: bytes) -> bool:
stream = BytesIO(im_bytes) stream = BytesIO(im_bytes)
length = len(im_bytes) length = len(im_bytes)
while stream.tell() < length: while stream.tell() < length:
@ -110,7 +122,7 @@ def has_alpha_premultiplied(im_bytes):
class TestUnsupportedAvif: class TestUnsupportedAvif:
def test_unsupported(self, monkeypatch): def test_unsupported(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(AvifImagePlugin, "SUPPORTED", False) monkeypatch.setattr(AvifImagePlugin, "SUPPORTED", False)
file_path = "Tests/images/avif/hopper.avif" file_path = "Tests/images/avif/hopper.avif"
@ -119,7 +131,7 @@ class TestUnsupportedAvif:
lambda: pytest.raises(UnidentifiedImageError, Image.open, file_path), lambda: pytest.raises(UnidentifiedImageError, Image.open, file_path),
) )
def test_unsupported_open(self, monkeypatch): def test_unsupported_open(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(AvifImagePlugin, "SUPPORTED", False) monkeypatch.setattr(AvifImagePlugin, "SUPPORTED", False)
file_path = "Tests/images/avif/hopper.avif" file_path = "Tests/images/avif/hopper.avif"
@ -129,11 +141,14 @@ class TestUnsupportedAvif:
@skip_unless_feature("avif") @skip_unless_feature("avif")
class TestFileAvif: class TestFileAvif:
def test_version(self): def test_version(self) -> None:
_avif.AvifCodecVersions() _avif.AvifCodecVersions()
assert re.search(r"\d+\.\d+\.\d+$", features.version_module("avif"))
def test_read(self): version = features.version_module("avif")
assert version is not None
assert re.search(r"\d+\.\d+\.\d+$", version)
def test_read(self) -> None:
""" """
Can we read an AVIF file without error? Can we read an AVIF file without error?
Does it have the bits we expect? Does it have the bits we expect?
@ -153,10 +168,10 @@ class TestFileAvif:
image, "Tests/images/avif/hopper_avif_write.png", 12.0 image, "Tests/images/avif/hopper_avif_write.png", 12.0
) )
def _roundtrip(self, tmp_path, mode, epsilon, args={}): def _roundtrip(self, tmp_path: Path, mode: str, epsilon: float) -> None:
temp_file = str(tmp_path / "temp.avif") temp_file = str(tmp_path / "temp.avif")
hopper(mode).save(temp_file, **args) hopper(mode).save(temp_file)
with Image.open(temp_file) as image: with Image.open(temp_file) as image:
assert image.mode == "RGB" assert image.mode == "RGB"
assert image.size == (128, 128) assert image.size == (128, 128)
@ -179,7 +194,7 @@ class TestFileAvif:
target = target.convert("RGB") target = target.convert("RGB")
assert_image_similar(image, target, epsilon) assert_image_similar(image, target, epsilon)
def test_write_rgb(self, tmp_path): def test_write_rgb(self, tmp_path: Path) -> None:
""" """
Can we write a RGB mode file to avif without error? Can we write a RGB mode file to avif without error?
Does it have the bits we expect? Does it have the bits we expect?
@ -187,32 +202,34 @@ class TestFileAvif:
self._roundtrip(tmp_path, "RGB", 12.5) self._roundtrip(tmp_path, "RGB", 12.5)
def test_AvifEncoder_with_invalid_args(self): def test_AvifEncoder_with_invalid_args(self) -> None:
""" """
Calling encoder functions with no arguments should result in an error. Calling encoder functions with no arguments should result in an error.
""" """
with pytest.raises(TypeError): with pytest.raises(TypeError):
_avif.AvifEncoder() _avif.AvifEncoder()
def test_AvifDecoder_with_invalid_args(self): def test_AvifDecoder_with_invalid_args(self) -> None:
""" """
Calling decoder functions with no arguments should result in an error. Calling decoder functions with no arguments should result in an error.
""" """
with pytest.raises(TypeError): with pytest.raises(TypeError):
_avif.AvifDecoder() _avif.AvifDecoder()
def test_encoder_finish_none_error(self, monkeypatch, tmp_path): def test_encoder_finish_none_error(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""Save should raise an OSError if AvifEncoder.finish returns None""" """Save should raise an OSError if AvifEncoder.finish returns None"""
class _mock_avif: class _mock_avif:
class AvifEncoder: class AvifEncoder:
def __init__(self, *args, **kwargs): def __init__(self, *args: Any) -> None:
pass pass
def add(self, *args, **kwargs): def add(self, *args: Any) -> None:
pass pass
def finish(self): def finish(self) -> None:
return None return None
monkeypatch.setattr(AvifImagePlugin, "_avif", _mock_avif) monkeypatch.setattr(AvifImagePlugin, "_avif", _mock_avif)
@ -222,25 +239,25 @@ class TestFileAvif:
with pytest.raises(OSError): with pytest.raises(OSError):
im.save(test_file) im.save(test_file)
def test_no_resource_warning(self, tmp_path): def test_no_resource_warning(self, tmp_path: Path) -> None:
with Image.open(TEST_AVIF_FILE) as image: with Image.open(TEST_AVIF_FILE) as image:
temp_file = str(tmp_path / "temp.avif") temp_file = str(tmp_path / "temp.avif")
with warnings.catch_warnings(): with warnings.catch_warnings():
image.save(temp_file) image.save(temp_file)
@pytest.mark.parametrize("major_brand", [b"avif", b"avis", b"mif1", b"msf1"]) @pytest.mark.parametrize("major_brand", [b"avif", b"avis", b"mif1", b"msf1"])
def test_accept_ftyp_brands(self, major_brand): def test_accept_ftyp_brands(self, major_brand: bytes) -> None:
data = b"\x00\x00\x00\x1cftyp%s\x00\x00\x00\x00" % major_brand data = b"\x00\x00\x00\x1cftyp%s\x00\x00\x00\x00" % major_brand
assert AvifImagePlugin._accept(data) is True assert AvifImagePlugin._accept(data) is True
def test_file_pointer_could_be_reused(self): def test_file_pointer_could_be_reused(self) -> None:
with open(TEST_AVIF_FILE, "rb") as blob: with open(TEST_AVIF_FILE, "rb") as blob:
with Image.open(blob) as im: with Image.open(blob) as im:
im.load() im.load()
with Image.open(blob) as im: with Image.open(blob) as im:
im.load() im.load()
def test_background_from_gif(self, tmp_path): def test_background_from_gif(self, tmp_path: Path) -> None:
with Image.open("Tests/images/chi.gif") as im: with Image.open("Tests/images/chi.gif") as im:
original_value = im.convert("RGB").getpixel((1, 1)) original_value = im.convert("RGB").getpixel((1, 1))
@ -260,20 +277,20 @@ class TestFileAvif:
) )
assert difference < 5 assert difference < 5
def test_save_single_frame(self, tmp_path): def test_save_single_frame(self, tmp_path: Path) -> None:
temp_file = str(tmp_path / "temp.avif") temp_file = str(tmp_path / "temp.avif")
with Image.open("Tests/images/chi.gif") as im: with Image.open("Tests/images/chi.gif") as im:
im.save(temp_file) im.save(temp_file)
with Image.open(temp_file) as im: with Image.open(temp_file) as im:
assert im.n_frames == 1 assert im.n_frames == 1
def test_invalid_file(self): def test_invalid_file(self) -> None:
invalid_file = "Tests/images/flower.jpg" invalid_file = "Tests/images/flower.jpg"
with pytest.raises(SyntaxError): with pytest.raises(SyntaxError):
AvifImagePlugin.AvifImageFile(invalid_file) AvifImagePlugin.AvifImageFile(invalid_file)
def test_load_transparent_rgb(self): def test_load_transparent_rgb(self) -> None:
test_file = "Tests/images/avif/transparency.avif" test_file = "Tests/images/avif/transparency.avif"
with Image.open(test_file) as im: with Image.open(test_file) as im:
assert_image(im, "RGBA", (64, 64)) assert_image(im, "RGBA", (64, 64))
@ -281,7 +298,7 @@ class TestFileAvif:
# image has 876 transparent pixels # image has 876 transparent pixels
assert im.getchannel("A").getcolors()[0][0] == 876 assert im.getchannel("A").getcolors()[0][0] == 876
def test_save_transparent(self, tmp_path): def test_save_transparent(self, tmp_path: Path) -> None:
im = Image.new("RGBA", (10, 10), (0, 0, 0, 0)) im = Image.new("RGBA", (10, 10), (0, 0, 0, 0))
assert im.getcolors() == [(100, (0, 0, 0, 0))] assert im.getcolors() == [(100, (0, 0, 0, 0))]
@ -293,7 +310,7 @@ class TestFileAvif:
assert_image(im, "RGBA", (10, 10)) assert_image(im, "RGBA", (10, 10))
assert im.getcolors() == [(100, (0, 0, 0, 0))] assert im.getcolors() == [(100, (0, 0, 0, 0))]
def test_save_icc_profile(self): def test_save_icc_profile(self) -> None:
with Image.open("Tests/images/avif/icc_profile_none.avif") as im: with Image.open("Tests/images/avif/icc_profile_none.avif") as im:
assert im.info.get("icc_profile") is None assert im.info.get("icc_profile") is None
@ -304,32 +321,32 @@ class TestFileAvif:
im = roundtrip(im, icc_profile=expected_icc) im = roundtrip(im, icc_profile=expected_icc)
assert im.info["icc_profile"] == expected_icc assert im.info["icc_profile"] == expected_icc
def test_discard_icc_profile(self): def test_discard_icc_profile(self) -> None:
with Image.open("Tests/images/avif/icc_profile.avif") as im: with Image.open("Tests/images/avif/icc_profile.avif") as im:
im = roundtrip(im, icc_profile=None) im = roundtrip(im, icc_profile=None)
assert "icc_profile" not in im.info assert "icc_profile" not in im.info
def test_roundtrip_icc_profile(self): def test_roundtrip_icc_profile(self) -> None:
with Image.open("Tests/images/avif/icc_profile.avif") as im: with Image.open("Tests/images/avif/icc_profile.avif") as im:
expected_icc = im.info["icc_profile"] expected_icc = im.info["icc_profile"]
im = roundtrip(im) im = roundtrip(im)
assert im.info["icc_profile"] == expected_icc assert im.info["icc_profile"] == expected_icc
def test_roundtrip_no_icc_profile(self): def test_roundtrip_no_icc_profile(self) -> None:
with Image.open("Tests/images/avif/icc_profile_none.avif") as im: with Image.open("Tests/images/avif/icc_profile_none.avif") as im:
assert im.info.get("icc_profile") is None assert im.info.get("icc_profile") is None
im = roundtrip(im) im = roundtrip(im)
assert "icc_profile" not in im.info assert "icc_profile" not in im.info
def test_exif(self): def test_exif(self) -> None:
# With an EXIF chunk # With an EXIF chunk
with Image.open("Tests/images/avif/exif.avif") as im: with Image.open("Tests/images/avif/exif.avif") as im:
exif = im.getexif() exif = im.getexif()
assert exif[274] == 1 assert exif[274] == 1
def test_exif_save(self, tmp_path): def test_exif_save(self, tmp_path: Path) -> None:
with Image.open("Tests/images/avif/exif.avif") as im: with Image.open("Tests/images/avif/exif.avif") as im:
test_file = str(tmp_path / "temp.avif") test_file = str(tmp_path / "temp.avif")
im.save(test_file) im.save(test_file)
@ -338,7 +355,7 @@ class TestFileAvif:
exif = reloaded.getexif() exif = reloaded.getexif()
assert exif[274] == 1 assert exif[274] == 1
def test_exif_obj_argument(self, tmp_path): def test_exif_obj_argument(self, tmp_path: Path) -> None:
exif = Image.Exif() exif = Image.Exif()
exif[274] = 1 exif[274] = 1
exif_data = exif.tobytes() exif_data = exif.tobytes()
@ -349,7 +366,7 @@ class TestFileAvif:
with Image.open(test_file) as reloaded: with Image.open(test_file) as reloaded:
assert reloaded.info["exif"] == exif_data assert reloaded.info["exif"] == exif_data
def test_exif_bytes_argument(self, tmp_path): def test_exif_bytes_argument(self, tmp_path: Path) -> None:
exif = Image.Exif() exif = Image.Exif()
exif[274] = 1 exif[274] = 1
exif_data = exif.tobytes() exif_data = exif.tobytes()
@ -360,18 +377,18 @@ class TestFileAvif:
with Image.open(test_file) as reloaded: with Image.open(test_file) as reloaded:
assert reloaded.info["exif"] == exif_data assert reloaded.info["exif"] == exif_data
def test_exif_invalid(self, tmp_path): def test_exif_invalid(self, tmp_path: Path) -> None:
with Image.open(TEST_AVIF_FILE) as im: with Image.open(TEST_AVIF_FILE) as im:
test_file = str(tmp_path / "temp.avif") test_file = str(tmp_path / "temp.avif")
with pytest.raises(ValueError): with pytest.raises(ValueError):
im.save(test_file, exif=b"invalid") im.save(test_file, exif=b"invalid")
def test_xmp(self): def test_xmp(self) -> None:
with Image.open("Tests/images/avif/xmp_tags_orientation.avif") as im: with Image.open("Tests/images/avif/xmp_tags_orientation.avif") as im:
xmp = im.info.get("xmp") xmp = im.info.get("xmp")
assert_xmp_orientation(xmp, 3) assert_xmp_orientation(xmp, 3)
def test_xmp_save(self, tmp_path): def test_xmp_save(self, tmp_path: Path) -> None:
with Image.open("Tests/images/avif/xmp_tags_orientation.avif") as im: with Image.open("Tests/images/avif/xmp_tags_orientation.avif") as im:
test_file = str(tmp_path / "temp.avif") test_file = str(tmp_path / "temp.avif")
im.save(test_file) im.save(test_file)
@ -380,7 +397,7 @@ class TestFileAvif:
xmp = reloaded.info.get("xmp") xmp = reloaded.info.get("xmp")
assert_xmp_orientation(xmp, 3) assert_xmp_orientation(xmp, 3)
def test_xmp_save_from_png(self, tmp_path): def test_xmp_save_from_png(self, tmp_path: Path) -> None:
with Image.open("Tests/images/xmp_tags_orientation.png") as im: with Image.open("Tests/images/xmp_tags_orientation.png") as im:
test_file = str(tmp_path / "temp.avif") test_file = str(tmp_path / "temp.avif")
im.save(test_file) im.save(test_file)
@ -389,7 +406,7 @@ class TestFileAvif:
xmp = reloaded.info.get("xmp") xmp = reloaded.info.get("xmp")
assert_xmp_orientation(xmp, 3) assert_xmp_orientation(xmp, 3)
def test_xmp_save_argument(self, tmp_path): def test_xmp_save_argument(self, tmp_path: Path) -> None:
xmp_arg = "\n".join( xmp_arg = "\n".join(
[ [
'<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>', '<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>',
@ -411,11 +428,11 @@ class TestFileAvif:
xmp = reloaded.info.get("xmp") xmp = reloaded.info.get("xmp")
assert_xmp_orientation(xmp, 1) assert_xmp_orientation(xmp, 1)
def test_tell(self): def test_tell(self) -> None:
with Image.open(TEST_AVIF_FILE) as im: with Image.open(TEST_AVIF_FILE) as im:
assert im.tell() == 0 assert im.tell() == 0
def test_seek(self): def test_seek(self) -> None:
with Image.open(TEST_AVIF_FILE) as im: with Image.open(TEST_AVIF_FILE) as im:
im.seek(0) im.seek(0)
@ -423,23 +440,23 @@ class TestFileAvif:
im.seek(1) im.seek(1)
@pytest.mark.parametrize("subsampling", ["4:4:4", "4:2:2", "4:0:0"]) @pytest.mark.parametrize("subsampling", ["4:4:4", "4:2:2", "4:0:0"])
def test_encoder_subsampling(self, tmp_path, subsampling): def test_encoder_subsampling(self, tmp_path: Path, subsampling: str) -> None:
with Image.open(TEST_AVIF_FILE) as im: with Image.open(TEST_AVIF_FILE) as im:
test_file = str(tmp_path / "temp.avif") test_file = str(tmp_path / "temp.avif")
im.save(test_file, subsampling=subsampling) im.save(test_file, subsampling=subsampling)
def test_encoder_subsampling_invalid(self, tmp_path): def test_encoder_subsampling_invalid(self, tmp_path: Path) -> None:
with Image.open(TEST_AVIF_FILE) as im: with Image.open(TEST_AVIF_FILE) as im:
test_file = str(tmp_path / "temp.avif") test_file = str(tmp_path / "temp.avif")
with pytest.raises(ValueError): with pytest.raises(ValueError):
im.save(test_file, subsampling="foo") im.save(test_file, subsampling="foo")
def test_encoder_range(self, tmp_path): def test_encoder_range(self, tmp_path: Path) -> None:
with Image.open(TEST_AVIF_FILE) as im: with Image.open(TEST_AVIF_FILE) as im:
test_file = str(tmp_path / "temp.avif") test_file = str(tmp_path / "temp.avif")
im.save(test_file, range="limited") im.save(test_file, range="limited")
def test_encoder_range_invalid(self, tmp_path): def test_encoder_range_invalid(self, tmp_path: Path) -> None:
with Image.open(TEST_AVIF_FILE) as im: with Image.open(TEST_AVIF_FILE) as im:
test_file = str(tmp_path / "temp.avif") test_file = str(tmp_path / "temp.avif")
with pytest.raises(ValueError): with pytest.raises(ValueError):
@ -447,12 +464,12 @@ class TestFileAvif:
@skip_unless_avif_encoder("aom") @skip_unless_avif_encoder("aom")
@skip_unless_feature("avif") @skip_unless_feature("avif")
def test_encoder_codec_param(self, tmp_path): def test_encoder_codec_param(self, tmp_path: Path) -> None:
with Image.open(TEST_AVIF_FILE) as im: with Image.open(TEST_AVIF_FILE) as im:
test_file = str(tmp_path / "temp.avif") test_file = str(tmp_path / "temp.avif")
im.save(test_file, codec="aom") im.save(test_file, codec="aom")
def test_encoder_codec_invalid(self, tmp_path): def test_encoder_codec_invalid(self, tmp_path: Path) -> None:
with Image.open(TEST_AVIF_FILE) as im: with Image.open(TEST_AVIF_FILE) as im:
test_file = str(tmp_path / "temp.avif") test_file = str(tmp_path / "temp.avif")
with pytest.raises(ValueError): with pytest.raises(ValueError):
@ -460,7 +477,7 @@ class TestFileAvif:
@skip_unless_avif_decoder("dav1d") @skip_unless_avif_decoder("dav1d")
@skip_unless_feature("avif") @skip_unless_feature("avif")
def test_encoder_codec_cannot_encode(self, tmp_path): def test_encoder_codec_cannot_encode(self, tmp_path: Path) -> None:
with Image.open(TEST_AVIF_FILE) as im: with Image.open(TEST_AVIF_FILE) as im:
test_file = str(tmp_path / "temp.avif") test_file = str(tmp_path / "temp.avif")
with pytest.raises(ValueError): with pytest.raises(ValueError):
@ -468,7 +485,7 @@ class TestFileAvif:
@skip_unless_avif_encoder("aom") @skip_unless_avif_encoder("aom")
@skip_unless_feature("avif") @skip_unless_feature("avif")
def test_encoder_advanced_codec_options(self): def test_encoder_advanced_codec_options(self) -> None:
with Image.open(TEST_AVIF_FILE) as im: with Image.open(TEST_AVIF_FILE) as im:
ctrl_buf = BytesIO() ctrl_buf = BytesIO()
im.save(ctrl_buf, "AVIF", codec="aom") im.save(ctrl_buf, "AVIF", codec="aom")
@ -487,7 +504,9 @@ class TestFileAvif:
@skip_unless_avif_encoder("aom") @skip_unless_avif_encoder("aom")
@skip_unless_feature("avif") @skip_unless_feature("avif")
@pytest.mark.parametrize("val", [{"foo": "bar"}, 1234]) @pytest.mark.parametrize("val", [{"foo": "bar"}, 1234])
def test_encoder_advanced_codec_options_invalid(self, tmp_path, val): def test_encoder_advanced_codec_options_invalid(
self, tmp_path: Path, val: dict[str, str] | int
) -> None:
with Image.open(TEST_AVIF_FILE) as im: with Image.open(TEST_AVIF_FILE) as im:
test_file = str(tmp_path / "temp.avif") test_file = str(tmp_path / "temp.avif")
with pytest.raises(ValueError): with pytest.raises(ValueError):
@ -495,7 +514,7 @@ class TestFileAvif:
@skip_unless_avif_decoder("aom") @skip_unless_avif_decoder("aom")
@skip_unless_feature("avif") @skip_unless_feature("avif")
def test_decoder_codec_param(self): def test_decoder_codec_param(self) -> None:
AvifImagePlugin.DECODE_CODEC_CHOICE = "aom" AvifImagePlugin.DECODE_CODEC_CHOICE = "aom"
try: try:
with Image.open(TEST_AVIF_FILE) as im: with Image.open(TEST_AVIF_FILE) as im:
@ -505,7 +524,7 @@ class TestFileAvif:
@skip_unless_avif_encoder("rav1e") @skip_unless_avif_encoder("rav1e")
@skip_unless_feature("avif") @skip_unless_feature("avif")
def test_decoder_codec_cannot_decode(self, tmp_path): def test_decoder_codec_cannot_decode(self, tmp_path: Path) -> None:
AvifImagePlugin.DECODE_CODEC_CHOICE = "rav1e" AvifImagePlugin.DECODE_CODEC_CHOICE = "rav1e"
try: try:
with pytest.raises(ValueError): with pytest.raises(ValueError):
@ -514,7 +533,7 @@ class TestFileAvif:
finally: finally:
AvifImagePlugin.DECODE_CODEC_CHOICE = "auto" AvifImagePlugin.DECODE_CODEC_CHOICE = "auto"
def test_decoder_codec_invalid(self): def test_decoder_codec_invalid(self) -> None:
AvifImagePlugin.DECODE_CODEC_CHOICE = "foo" AvifImagePlugin.DECODE_CODEC_CHOICE = "foo"
try: try:
with pytest.raises(ValueError): with pytest.raises(ValueError):
@ -525,22 +544,22 @@ class TestFileAvif:
@skip_unless_avif_encoder("aom") @skip_unless_avif_encoder("aom")
@skip_unless_feature("avif") @skip_unless_feature("avif")
def test_encoder_codec_available(self): def test_encoder_codec_available(self) -> None:
assert _avif.encoder_codec_available("aom") is True assert _avif.encoder_codec_available("aom") is True
def test_encoder_codec_available_bad_params(self): def test_encoder_codec_available_bad_params(self) -> None:
with pytest.raises(TypeError): with pytest.raises(TypeError):
_avif.encoder_codec_available() _avif.encoder_codec_available()
@skip_unless_avif_decoder("dav1d") @skip_unless_avif_decoder("dav1d")
@skip_unless_feature("avif") @skip_unless_feature("avif")
def test_encoder_codec_available_cannot_decode(self): def test_encoder_codec_available_cannot_decode(self) -> None:
assert _avif.encoder_codec_available("dav1d") is False assert _avif.encoder_codec_available("dav1d") is False
def test_encoder_codec_available_invalid(self): def test_encoder_codec_available_invalid(self) -> None:
assert _avif.encoder_codec_available("foo") is False assert _avif.encoder_codec_available("foo") is False
def test_encoder_quality_valueerror(self, tmp_path): def test_encoder_quality_valueerror(self, tmp_path: Path) -> None:
with Image.open("Tests/images/avif/hopper.avif") as im: with Image.open("Tests/images/avif/hopper.avif") as im:
test_file = str(tmp_path / "temp.avif") test_file = str(tmp_path / "temp.avif")
with pytest.raises(ValueError): with pytest.raises(ValueError):
@ -548,23 +567,23 @@ class TestFileAvif:
@skip_unless_avif_decoder("aom") @skip_unless_avif_decoder("aom")
@skip_unless_feature("avif") @skip_unless_feature("avif")
def test_decoder_codec_available(self): def test_decoder_codec_available(self) -> None:
assert _avif.decoder_codec_available("aom") is True assert _avif.decoder_codec_available("aom") is True
def test_decoder_codec_available_bad_params(self): def test_decoder_codec_available_bad_params(self) -> None:
with pytest.raises(TypeError): with pytest.raises(TypeError):
_avif.decoder_codec_available() _avif.decoder_codec_available()
@skip_unless_avif_encoder("rav1e") @skip_unless_avif_encoder("rav1e")
@skip_unless_feature("avif") @skip_unless_feature("avif")
def test_decoder_codec_available_cannot_decode(self): def test_decoder_codec_available_cannot_decode(self) -> None:
assert _avif.decoder_codec_available("rav1e") is False assert _avif.decoder_codec_available("rav1e") is False
def test_decoder_codec_available_invalid(self): def test_decoder_codec_available_invalid(self) -> None:
assert _avif.decoder_codec_available("foo") is False assert _avif.decoder_codec_available("foo") is False
@pytest.mark.parametrize("upsampling", ["fastest", "best", "nearest", "bilinear"]) @pytest.mark.parametrize("upsampling", ["fastest", "best", "nearest", "bilinear"])
def test_decoder_upsampling(self, upsampling): def test_decoder_upsampling(self, upsampling: str) -> None:
AvifImagePlugin.CHROMA_UPSAMPLING = upsampling AvifImagePlugin.CHROMA_UPSAMPLING = upsampling
try: try:
with Image.open(TEST_AVIF_FILE): with Image.open(TEST_AVIF_FILE):
@ -572,7 +591,7 @@ class TestFileAvif:
finally: finally:
AvifImagePlugin.CHROMA_UPSAMPLING = "auto" AvifImagePlugin.CHROMA_UPSAMPLING = "auto"
def test_decoder_upsampling_invalid(self): def test_decoder_upsampling_invalid(self) -> None:
AvifImagePlugin.CHROMA_UPSAMPLING = "foo" AvifImagePlugin.CHROMA_UPSAMPLING = "foo"
try: try:
with pytest.raises(ValueError): with pytest.raises(ValueError):
@ -581,7 +600,7 @@ class TestFileAvif:
finally: finally:
AvifImagePlugin.CHROMA_UPSAMPLING = "auto" AvifImagePlugin.CHROMA_UPSAMPLING = "auto"
def test_p_mode_transparency(self): def test_p_mode_transparency(self) -> None:
im = Image.new("P", size=(64, 64)) im = Image.new("P", size=(64, 64))
draw = ImageDraw.Draw(im) draw = ImageDraw.Draw(im)
draw.rectangle(xy=[(0, 0), (32, 32)], fill=255) draw.rectangle(xy=[(0, 0), (32, 32)], fill=255)
@ -595,19 +614,19 @@ class TestFileAvif:
assert_image_similar(im_png.convert("RGBA"), Image.open(buf_out), 1) assert_image_similar(im_png.convert("RGBA"), Image.open(buf_out), 1)
def test_decoder_strict_flags(self): def test_decoder_strict_flags(self) -> None:
# This would fail if full avif strictFlags were enabled # This would fail if full avif strictFlags were enabled
with Image.open("Tests/images/avif/chimera-missing-pixi.avif") as im: with Image.open("Tests/images/avif/chimera-missing-pixi.avif") as im:
assert im.size == (480, 270) assert im.size == (480, 270)
@skip_unless_avif_encoder("aom") @skip_unless_avif_encoder("aom")
def test_aom_optimizations(self): def test_aom_optimizations(self) -> None:
im = hopper("RGB") im = hopper("RGB")
buf = BytesIO() buf = BytesIO()
im.save(buf, format="AVIF", codec="aom", speed=1) im.save(buf, format="AVIF", codec="aom", speed=1)
@skip_unless_avif_encoder("svt") @skip_unless_avif_encoder("svt")
def test_svt_optimizations(self): def test_svt_optimizations(self) -> None:
im = hopper("RGB") im = hopper("RGB")
buf = BytesIO() buf = BytesIO()
im.save(buf, format="AVIF", codec="svt", speed=1) im.save(buf, format="AVIF", codec="svt", speed=1)
@ -616,14 +635,14 @@ class TestFileAvif:
@skip_unless_feature("avif") @skip_unless_feature("avif")
class TestAvifAnimation: class TestAvifAnimation:
@contextmanager @contextmanager
def star_frames(self): def star_frames(self) -> Generator[list[ImageFile.ImageFile], None, None]:
with Image.open("Tests/images/avif/star.png") as f1: with Image.open("Tests/images/avif/star.png") as f1:
with Image.open("Tests/images/avif/star90.png") as f2: with Image.open("Tests/images/avif/star90.png") as f2:
with Image.open("Tests/images/avif/star180.png") as f3: with Image.open("Tests/images/avif/star180.png") as f3:
with Image.open("Tests/images/avif/star270.png") as f4: with Image.open("Tests/images/avif/star270.png") as f4:
yield [f1, f2, f3, f4] yield [f1, f2, f3, f4]
def test_n_frames(self): def test_n_frames(self) -> None:
""" """
Ensure that AVIF format sets n_frames and is_animated attributes Ensure that AVIF format sets n_frames and is_animated attributes
correctly. correctly.
@ -637,7 +656,7 @@ class TestAvifAnimation:
assert im.n_frames == 5 assert im.n_frames == 5
assert im.is_animated assert im.is_animated
def test_write_animation_L(self, tmp_path): def test_write_animation_L(self, tmp_path: Path) -> None:
""" """
Convert an animated GIF to animated AVIF, then compare the frame Convert an animated GIF to animated AVIF, then compare the frame
count, and first and last frames to ensure they're visually similar. count, and first and last frames to ensure they're visually similar.
@ -661,13 +680,13 @@ class TestAvifAnimation:
im.load() im.load()
assert_image_similar(im.convert("RGB"), orig.convert("RGB"), 25.0) assert_image_similar(im.convert("RGB"), orig.convert("RGB"), 25.0)
def test_write_animation_RGB(self, tmp_path): def test_write_animation_RGB(self, tmp_path: Path) -> None:
""" """
Write an animated AVIF from RGB frames, and ensure the frames Write an animated AVIF from RGB frames, and ensure the frames
are visually similar to the originals. are visually similar to the originals.
""" """
def check(temp_file): def check(temp_file: str) -> None:
with Image.open(temp_file) as im: with Image.open(temp_file) as im:
assert im.n_frames == 4 assert im.n_frames == 4
@ -688,7 +707,9 @@ class TestAvifAnimation:
check(temp_file1) check(temp_file1)
# Tests appending using a generator # Tests appending using a generator
def imGenerator(ims): def imGenerator(
ims: list[ImageFile.ImageFile],
) -> Generator[ImageFile.ImageFile, None, None]:
yield from ims yield from ims
temp_file2 = str(tmp_path / "temp_generator.avif") temp_file2 = str(tmp_path / "temp_generator.avif")
@ -699,27 +720,27 @@ class TestAvifAnimation:
) )
check(temp_file2) check(temp_file2)
def test_sequence_dimension_mismatch_check(self, tmp_path): def test_sequence_dimension_mismatch_check(self, tmp_path: Path) -> None:
temp_file = str(tmp_path / "temp.avif") temp_file = str(tmp_path / "temp.avif")
frame1 = Image.new("RGB", (100, 100)) frame1 = Image.new("RGB", (100, 100))
frame2 = Image.new("RGB", (150, 150)) frame2 = Image.new("RGB", (150, 150))
with pytest.raises(ValueError): with pytest.raises(ValueError):
frame1.save(temp_file, save_all=True, append_images=[frame2], duration=100) frame1.save(temp_file, save_all=True, append_images=[frame2], duration=100)
def test_heif_raises_unidentified_image_error(self): def test_heif_raises_unidentified_image_error(self) -> None:
with pytest.raises(UnidentifiedImageError): with pytest.raises(UnidentifiedImageError):
with Image.open("Tests/images/avif/rgba10.heif"): with Image.open("Tests/images/avif/rgba10.heif"):
pass pass
@pytest.mark.parametrize("alpha_premultipled", [False, True]) @pytest.mark.parametrize("alpha_premultipled", [False, True])
def test_alpha_premultiplied_true(self, alpha_premultipled): def test_alpha_premultiplied_true(self, alpha_premultipled: bool) -> None:
im = Image.new("RGBA", (10, 10), (0, 0, 0, 0)) im = Image.new("RGBA", (10, 10), (0, 0, 0, 0))
im_buf = BytesIO() im_buf = BytesIO()
im.save(im_buf, "AVIF", alpha_premultiplied=alpha_premultipled) im.save(im_buf, "AVIF", alpha_premultiplied=alpha_premultipled)
im_bytes = im_buf.getvalue() im_bytes = im_buf.getvalue()
assert has_alpha_premultiplied(im_bytes) is alpha_premultipled assert has_alpha_premultiplied(im_bytes) is alpha_premultipled
def test_timestamp_and_duration(self, tmp_path): def test_timestamp_and_duration(self, tmp_path: Path) -> None:
""" """
Try passing a list of durations, and make sure the encoded Try passing a list of durations, and make sure the encoded
timestamps and durations are correct. timestamps and durations are correct.
@ -748,7 +769,7 @@ class TestAvifAnimation:
assert im.info["timestamp"] == ts assert im.info["timestamp"] == ts
ts += durations[frame] ts += durations[frame]
def test_seeking(self, tmp_path): def test_seeking(self, tmp_path: Path) -> None:
""" """
Create an animated AVIF file, and then try seeking through frames in Create an animated AVIF file, and then try seeking through frames in
reverse-order, verifying the timestamps and durations are correct. reverse-order, verifying the timestamps and durations are correct.
@ -777,7 +798,7 @@ class TestAvifAnimation:
assert im.info["timestamp"] == ts assert im.info["timestamp"] == ts
ts -= dur ts -= dur
def test_seek_errors(self): def test_seek_errors(self) -> None:
with Image.open("Tests/images/avif/star.avifs") as im: with Image.open("Tests/images/avif/star.avifs") as im:
with pytest.raises(EOFError): with pytest.raises(EOFError):
im.seek(-1) im.seek(-1)
@ -797,11 +818,11 @@ class TestAvifLeaks(PillowLeakTestCase):
@pytest.mark.skipif( @pytest.mark.skipif(
is_docker_qemu(), reason="Skipping on cross-architecture containers" is_docker_qemu(), reason="Skipping on cross-architecture containers"
) )
def test_leak_load(self): def test_leak_load(self) -> None:
with open(TEST_AVIF_FILE, "rb") as f: with open(TEST_AVIF_FILE, "rb") as f:
im_data = f.read() im_data = f.read()
def core(): def core() -> None:
with Image.open(BytesIO(im_data)) as im: with Image.open(BytesIO(im_data)) as im:
im.load() im.load()
gc.collect() gc.collect()

View File

@ -235,7 +235,7 @@ following options are available::
**append_images** **append_images**
A list of images to append as additional frames. Each of the A list of images to append as additional frames. Each of the
images in the list can be single or multiframe images. images in the list can be single or multiframe images.
This is currently supported for GIF, PDF, PNG, TIFF, WebP, and AVIF. This is currently supported for AVIF, GIF, PDF, PNG, TIFF and WebP.
It is also supported for ICO and ICNS. If images are passed in of relevant It is also supported for ICO and ICNS. If images are passed in of relevant
sizes, they will be used instead of scaling down the main image. sizes, they will be used instead of scaling down the main image.
@ -1321,7 +1321,7 @@ as 8-bit RGB(A).
The :py:meth:`~PIL.Image.Image.save` method supports the following options: The :py:meth:`~PIL.Image.Image.save` method supports the following options:
**quality** **quality**
Integer, 1-100, Defaults to 90. 0 gives the smallest size and poorest Integer, 1-100, defaults to 90. 0 gives the smallest size and poorest
quality, 100 the largest and best quality. The value of this setting quality, 100 the largest and best quality. The value of this setting
controls the ``qmin`` and ``qmax`` encoder options. controls the ``qmin`` and ``qmax`` encoder options.
@ -1331,19 +1331,19 @@ The :py:meth:`~PIL.Image.Image.save` method supports the following options:
values are, the worse the quality. values are, the worse the quality.
**subsampling** **subsampling**
If present, sets the subsampling for the encoder. Defaults to ``"4:2:0``". If present, sets the subsampling for the encoder. Defaults to ``4:2:0``.
Options include: Options include:
* ``"4:0:0"`` * ``4:0:0``
* ``"4:2:0"`` * ``4:2:0``
* ``"4:2:2"`` * ``4:2:2``
* ``"4:4:4"`` * ``4:4:4``
**speed** **speed**
Quality/speed trade-off (0=slower-better, 10=fastest). Defaults to 8. Quality/speed trade-off (0=slower-better, 10=fastest). Defaults to 8.
**range** **range**
YUV range, either "full" or "limited." Defaults to "full" YUV range, either "full" or "limited". Defaults to "full"
**codec** **codec**
AV1 codec to use for encoding. Possible values are "aom", "rav1e", and AV1 codec to use for encoding. Possible values are "aom", "rav1e", and

View File

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from io import BytesIO from io import BytesIO
from typing import IO
from . import ExifTags, Image, ImageFile from . import ExifTags, Image, ImageFile
@ -20,9 +21,9 @@ DEFAULT_MAX_THREADS = 0
_VALID_AVIF_MODES = {"RGB", "RGBA"} _VALID_AVIF_MODES = {"RGB", "RGBA"}
def _accept(prefix): def _accept(prefix: bytes) -> bool | str:
if prefix[4:8] != b"ftyp": if prefix[4:8] != b"ftyp":
return return False
coding_brands = (b"avif", b"avis") coding_brands = (b"avif", b"avis")
container_brands = (b"mif1", b"msf1") container_brands = (b"mif1", b"msf1")
major_brand = prefix[8:12] major_brand = prefix[8:12]
@ -42,6 +43,7 @@ def _accept(prefix):
# Also, because this file might not actually be an AVIF file, we # Also, because this file might not actually be an AVIF file, we
# don't raise an error if AVIF support isn't properly compiled. # don't raise an error if AVIF support isn't properly compiled.
return True return True
return False
class AvifImageFile(ImageFile.ImageFile): class AvifImageFile(ImageFile.ImageFile):
@ -53,7 +55,7 @@ class AvifImageFile(ImageFile.ImageFile):
def load_seek(self, pos: int) -> None: def load_seek(self, pos: int) -> None:
pass pass
def _open(self): def _open(self) -> None:
if not SUPPORTED: if not SUPPORTED:
msg = ( msg = (
"image file could not be identified because AVIF " "image file could not be identified because AVIF "
@ -71,7 +73,6 @@ class AvifImageFile(ImageFile.ImageFile):
self.n_frames = n_frames self.n_frames = n_frames
self.is_animated = self.n_frames > 1 self.is_animated = self.n_frames > 1
self._mode = self.rawmode = mode self._mode = self.rawmode = mode
self.tile = []
if icc: if icc:
self.info["icc_profile"] = icc self.info["icc_profile"] = icc
@ -80,13 +81,13 @@ class AvifImageFile(ImageFile.ImageFile):
if xmp: if xmp:
self.info["xmp"] = xmp self.info["xmp"] = xmp
def seek(self, frame): def seek(self, frame: int) -> None:
if not self._seek_check(frame): if not self._seek_check(frame):
return return
self.__frame = frame self.__frame = frame
def load(self): def load(self) -> Image.core.PixelAccess | None:
if self.__loaded != self.__frame: if self.__loaded != self.__frame:
# We need to load the image data for this frame # We need to load the image data for this frame
data, timescale, tsp_in_ts, dur_in_ts = self._decoder.get_frame( data, timescale, tsp_in_ts, dur_in_ts = self._decoder.get_frame(
@ -102,19 +103,21 @@ class AvifImageFile(ImageFile.ImageFile):
if self.fp and self._exclusive_fp: if self.fp and self._exclusive_fp:
self.fp.close() self.fp.close()
self.fp = BytesIO(data) self.fp = BytesIO(data)
self.tile = [("raw", (0, 0) + self.size, 0, self.rawmode)] self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 0, self.rawmode)]
return super().load() return super().load()
def tell(self): def tell(self) -> int:
return self.__frame return self.__frame
def _save_all(im, fp, filename): def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
_save(im, fp, filename, save_all=True) _save(im, fp, filename, save_all=True)
def _save(im, fp, filename, save_all=False): def _save(
im: Image.Image, fp: IO[bytes], filename: str | bytes, save_all: bool = False
) -> None:
info = im.encoderinfo.copy() info = im.encoderinfo.copy()
if save_all: if save_all:
append_images = list(info.get("append_images", [])) append_images = list(info.get("append_images", []))