diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh index 2e7d5a232..3e5b84ed8 100755 --- a/.github/workflows/wheels-dependencies.sh +++ b/.github/workflows/wheels-dependencies.sh @@ -66,21 +66,25 @@ function build_harfbuzz { } function install_rav1e { - if [[ -n "$IS_MACOS" ]] && [[ "$PLAT" == "arm64" ]]; then - librav1e_tgz=librav1e-${RAV1E_VERSION}-macos-aarch64.tar.gz - elif [ -n "$IS_MACOS" ]; then - librav1e_tgz=librav1e-${RAV1E_VERSION}-macos.tar.gz - elif [ "$PLAT" == "aarch64" ]; then - librav1e_tgz=librav1e-${RAV1E_VERSION}-linux-aarch64.tar.gz + if [ -n "$IS_MACOS" ]; then + suffix="macos" + if [[ "$PLAT" == "arm64" ]]; then + suffix+="-aarch64" + fi else - librav1e_tgz=librav1e-${RAV1E_VERSION}-linux-generic.tar.gz + suffix="linux" + if [[ "$PLAT" == "aarch64" ]]; then + suffix+="-aarch64" + else + suffix+="-generic" + fi fi 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 - - if [ ! -n "$IS_MACOS" ]; then + if [ -z "$IS_MACOS" ]; then sed -i 's/-lgcc_s/-lgcc_eh/g' "${BUILD_PREFIX}/lib/pkgconfig/rav1e.pc" fi @@ -101,7 +105,7 @@ function build_libavif { python -m pip install meson ninja 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 local cmake=$(get_modern_cmake) @@ -210,7 +214,7 @@ if [[ -n "$IS_MACOS" ]]; then brew remove --ignore-dependencies webp aom libavif fi - brew install meson pkg-config + brew install pkg-config # clear bash path cache for curl hash -d curl diff --git a/Tests/check_avif_leaks.py b/Tests/check_avif_leaks.py index de6f370d9..e59c4fd8c 100644 --- a/Tests/check_avif_leaks.py +++ b/Tests/check_avif_leaks.py @@ -20,7 +20,7 @@ pytestmark = [ ] -def test_leak_load(): +def test_leak_load() -> None: from resource import RLIMIT_AS, RLIMIT_STACK, setrlimit setrlimit(RLIMIT_STACK, (stack_size, stack_size)) @@ -30,7 +30,7 @@ def test_leak_load(): im.load() -def test_leak_save(): +def test_leak_save() -> None: from resource import RLIMIT_AS, RLIMIT_STACK, setrlimit setrlimit(RLIMIT_STACK, (stack_size, stack_size)) diff --git a/Tests/test_file_avif.py b/Tests/test_file_avif.py index 7474172ab..129d964e0 100644 --- a/Tests/test_file_avif.py +++ b/Tests/test_file_avif.py @@ -5,13 +5,23 @@ import os import re import warnings import xml.etree.ElementTree +from collections.abc import Generator from contextlib import contextmanager from io import BytesIO +from pathlib import Path from struct import unpack +from typing import Any import pytest -from PIL import AvifImagePlugin, Image, ImageDraw, UnidentifiedImageError, features +from PIL import ( + AvifImagePlugin, + Image, + ImageDraw, + ImageFile, + UnidentifiedImageError, + features, +) from .helper import ( PillowLeakTestCase, @@ -33,41 +43,43 @@ except ImportError: 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) root = xml.etree.ElementTree.fromstring(xmp) orientation = None for elem in root.iter(): if elem.tag.endswith("}Description"): - orientation = elem.attrib.get("{http://ns.adobe.com/tiff/1.0/}Orientation") - if orientation: - orientation = int(orientation) + tag_orientation = elem.attrib.get( + "{http://ns.adobe.com/tiff/1.0/}Orientation" + ) + if tag_orientation: + orientation = int(tag_orientation) break assert orientation == expected -def roundtrip(im, **options): +def roundtrip(im: ImageFile.ImageFile, **options: Any) -> ImageFile.ImageFile: out = BytesIO() im.save(out, "AVIF", **options) out.seek(0) 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" return pytest.mark.skipif( 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" return pytest.mark.skipif( not HAVE_AVIF or not _avif.encoder_codec_available(codec_name), reason=reason ) -def is_docker_qemu(): +def is_docker_qemu() -> bool: try: init_proc_exe = os.readlink("/proc/1/exe") except: # noqa: E722 @@ -76,7 +88,7 @@ def is_docker_qemu(): return "qemu" in init_proc_exe -def has_alpha_premultiplied(im_bytes): +def has_alpha_premultiplied(im_bytes: bytes) -> bool: stream = BytesIO(im_bytes) length = len(im_bytes) while stream.tell() < length: @@ -110,7 +122,7 @@ def has_alpha_premultiplied(im_bytes): class TestUnsupportedAvif: - def test_unsupported(self, monkeypatch): + def test_unsupported(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(AvifImagePlugin, "SUPPORTED", False) file_path = "Tests/images/avif/hopper.avif" @@ -119,7 +131,7 @@ class TestUnsupportedAvif: 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) file_path = "Tests/images/avif/hopper.avif" @@ -129,11 +141,14 @@ class TestUnsupportedAvif: @skip_unless_feature("avif") class TestFileAvif: - def test_version(self): + def test_version(self) -> None: _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? Does it have the bits we expect? @@ -153,10 +168,10 @@ class TestFileAvif: 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") - hopper(mode).save(temp_file, **args) + hopper(mode).save(temp_file) with Image.open(temp_file) as image: assert image.mode == "RGB" assert image.size == (128, 128) @@ -179,7 +194,7 @@ class TestFileAvif: target = target.convert("RGB") 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? Does it have the bits we expect? @@ -187,32 +202,34 @@ class TestFileAvif: 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. """ with pytest.raises(TypeError): _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. """ with pytest.raises(TypeError): _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""" class _mock_avif: class AvifEncoder: - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any) -> None: pass - def add(self, *args, **kwargs): + def add(self, *args: Any) -> None: pass - def finish(self): + def finish(self) -> None: return None monkeypatch.setattr(AvifImagePlugin, "_avif", _mock_avif) @@ -222,25 +239,25 @@ class TestFileAvif: with pytest.raises(OSError): 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: temp_file = str(tmp_path / "temp.avif") with warnings.catch_warnings(): image.save(temp_file) @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 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 Image.open(blob) as im: im.load() with Image.open(blob) as im: 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: original_value = im.convert("RGB").getpixel((1, 1)) @@ -260,20 +277,20 @@ class TestFileAvif: ) 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") with Image.open("Tests/images/chi.gif") as im: im.save(temp_file) with Image.open(temp_file) as im: assert im.n_frames == 1 - def test_invalid_file(self): + def test_invalid_file(self) -> None: invalid_file = "Tests/images/flower.jpg" with pytest.raises(SyntaxError): AvifImagePlugin.AvifImageFile(invalid_file) - def test_load_transparent_rgb(self): + def test_load_transparent_rgb(self) -> None: test_file = "Tests/images/avif/transparency.avif" with Image.open(test_file) as im: assert_image(im, "RGBA", (64, 64)) @@ -281,7 +298,7 @@ class TestFileAvif: # image has 876 transparent pixels 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)) assert im.getcolors() == [(100, (0, 0, 0, 0))] @@ -293,7 +310,7 @@ class TestFileAvif: assert_image(im, "RGBA", (10, 10)) 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: assert im.info.get("icc_profile") is None @@ -304,32 +321,32 @@ class TestFileAvif: im = roundtrip(im, 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: im = roundtrip(im, icc_profile=None) 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: expected_icc = im.info["icc_profile"] im = roundtrip(im) 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: assert im.info.get("icc_profile") is None im = roundtrip(im) assert "icc_profile" not in im.info - def test_exif(self): + def test_exif(self) -> None: # With an EXIF chunk with Image.open("Tests/images/avif/exif.avif") as im: exif = im.getexif() 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: test_file = str(tmp_path / "temp.avif") im.save(test_file) @@ -338,7 +355,7 @@ class TestFileAvif: exif = reloaded.getexif() 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[274] = 1 exif_data = exif.tobytes() @@ -349,7 +366,7 @@ class TestFileAvif: with Image.open(test_file) as reloaded: 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[274] = 1 exif_data = exif.tobytes() @@ -360,18 +377,18 @@ class TestFileAvif: with Image.open(test_file) as reloaded: 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: test_file = str(tmp_path / "temp.avif") with pytest.raises(ValueError): 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: xmp = im.info.get("xmp") 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: test_file = str(tmp_path / "temp.avif") im.save(test_file) @@ -380,7 +397,7 @@ class TestFileAvif: xmp = reloaded.info.get("xmp") 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: test_file = str(tmp_path / "temp.avif") im.save(test_file) @@ -389,7 +406,7 @@ class TestFileAvif: xmp = reloaded.info.get("xmp") 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( [ '', @@ -411,11 +428,11 @@ class TestFileAvif: xmp = reloaded.info.get("xmp") assert_xmp_orientation(xmp, 1) - def test_tell(self): + def test_tell(self) -> None: with Image.open(TEST_AVIF_FILE) as im: assert im.tell() == 0 - def test_seek(self): + def test_seek(self) -> None: with Image.open(TEST_AVIF_FILE) as im: im.seek(0) @@ -423,23 +440,23 @@ class TestFileAvif: im.seek(1) @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: test_file = str(tmp_path / "temp.avif") 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: test_file = str(tmp_path / "temp.avif") with pytest.raises(ValueError): 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: test_file = str(tmp_path / "temp.avif") 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: test_file = str(tmp_path / "temp.avif") with pytest.raises(ValueError): @@ -447,12 +464,12 @@ class TestFileAvif: @skip_unless_avif_encoder("aom") @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: test_file = str(tmp_path / "temp.avif") 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: test_file = str(tmp_path / "temp.avif") with pytest.raises(ValueError): @@ -460,7 +477,7 @@ class TestFileAvif: @skip_unless_avif_decoder("dav1d") @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: test_file = str(tmp_path / "temp.avif") with pytest.raises(ValueError): @@ -468,7 +485,7 @@ class TestFileAvif: @skip_unless_avif_encoder("aom") @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: ctrl_buf = BytesIO() im.save(ctrl_buf, "AVIF", codec="aom") @@ -487,7 +504,9 @@ class TestFileAvif: @skip_unless_avif_encoder("aom") @skip_unless_feature("avif") @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: test_file = str(tmp_path / "temp.avif") with pytest.raises(ValueError): @@ -495,7 +514,7 @@ class TestFileAvif: @skip_unless_avif_decoder("aom") @skip_unless_feature("avif") - def test_decoder_codec_param(self): + def test_decoder_codec_param(self) -> None: AvifImagePlugin.DECODE_CODEC_CHOICE = "aom" try: with Image.open(TEST_AVIF_FILE) as im: @@ -505,7 +524,7 @@ class TestFileAvif: @skip_unless_avif_encoder("rav1e") @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" try: with pytest.raises(ValueError): @@ -514,7 +533,7 @@ class TestFileAvif: finally: AvifImagePlugin.DECODE_CODEC_CHOICE = "auto" - def test_decoder_codec_invalid(self): + def test_decoder_codec_invalid(self) -> None: AvifImagePlugin.DECODE_CODEC_CHOICE = "foo" try: with pytest.raises(ValueError): @@ -525,22 +544,22 @@ class TestFileAvif: @skip_unless_avif_encoder("aom") @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 - def test_encoder_codec_available_bad_params(self): + def test_encoder_codec_available_bad_params(self) -> None: with pytest.raises(TypeError): _avif.encoder_codec_available() @skip_unless_avif_decoder("dav1d") @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 - def test_encoder_codec_available_invalid(self): + def test_encoder_codec_available_invalid(self) -> None: 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: test_file = str(tmp_path / "temp.avif") with pytest.raises(ValueError): @@ -548,23 +567,23 @@ class TestFileAvif: @skip_unless_avif_decoder("aom") @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 - def test_decoder_codec_available_bad_params(self): + def test_decoder_codec_available_bad_params(self) -> None: with pytest.raises(TypeError): _avif.decoder_codec_available() @skip_unless_avif_encoder("rav1e") @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 - def test_decoder_codec_available_invalid(self): + def test_decoder_codec_available_invalid(self) -> None: assert _avif.decoder_codec_available("foo") is False @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 try: with Image.open(TEST_AVIF_FILE): @@ -572,7 +591,7 @@ class TestFileAvif: finally: AvifImagePlugin.CHROMA_UPSAMPLING = "auto" - def test_decoder_upsampling_invalid(self): + def test_decoder_upsampling_invalid(self) -> None: AvifImagePlugin.CHROMA_UPSAMPLING = "foo" try: with pytest.raises(ValueError): @@ -581,7 +600,7 @@ class TestFileAvif: finally: AvifImagePlugin.CHROMA_UPSAMPLING = "auto" - def test_p_mode_transparency(self): + def test_p_mode_transparency(self) -> None: im = Image.new("P", size=(64, 64)) draw = ImageDraw.Draw(im) 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) - def test_decoder_strict_flags(self): + def test_decoder_strict_flags(self) -> None: # This would fail if full avif strictFlags were enabled with Image.open("Tests/images/avif/chimera-missing-pixi.avif") as im: assert im.size == (480, 270) @skip_unless_avif_encoder("aom") - def test_aom_optimizations(self): + def test_aom_optimizations(self) -> None: im = hopper("RGB") buf = BytesIO() im.save(buf, format="AVIF", codec="aom", speed=1) @skip_unless_avif_encoder("svt") - def test_svt_optimizations(self): + def test_svt_optimizations(self) -> None: im = hopper("RGB") buf = BytesIO() im.save(buf, format="AVIF", codec="svt", speed=1) @@ -616,14 +635,14 @@ class TestFileAvif: @skip_unless_feature("avif") class TestAvifAnimation: @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/star90.png") as f2: with Image.open("Tests/images/avif/star180.png") as f3: with Image.open("Tests/images/avif/star270.png") as 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 correctly. @@ -637,7 +656,7 @@ class TestAvifAnimation: assert im.n_frames == 5 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 count, and first and last frames to ensure they're visually similar. @@ -661,13 +680,13 @@ class TestAvifAnimation: im.load() 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 are visually similar to the originals. """ - def check(temp_file): + def check(temp_file: str) -> None: with Image.open(temp_file) as im: assert im.n_frames == 4 @@ -688,7 +707,9 @@ class TestAvifAnimation: check(temp_file1) # Tests appending using a generator - def imGenerator(ims): + def imGenerator( + ims: list[ImageFile.ImageFile], + ) -> Generator[ImageFile.ImageFile, None, None]: yield from ims temp_file2 = str(tmp_path / "temp_generator.avif") @@ -699,27 +720,27 @@ class TestAvifAnimation: ) 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") frame1 = Image.new("RGB", (100, 100)) frame2 = Image.new("RGB", (150, 150)) with pytest.raises(ValueError): 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 Image.open("Tests/images/avif/rgba10.heif"): pass @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_buf = BytesIO() im.save(im_buf, "AVIF", alpha_premultiplied=alpha_premultipled) im_bytes = im_buf.getvalue() 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 timestamps and durations are correct. @@ -748,7 +769,7 @@ class TestAvifAnimation: assert im.info["timestamp"] == ts 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 reverse-order, verifying the timestamps and durations are correct. @@ -777,7 +798,7 @@ class TestAvifAnimation: assert im.info["timestamp"] == ts ts -= dur - def test_seek_errors(self): + def test_seek_errors(self) -> None: with Image.open("Tests/images/avif/star.avifs") as im: with pytest.raises(EOFError): im.seek(-1) @@ -797,11 +818,11 @@ class TestAvifLeaks(PillowLeakTestCase): @pytest.mark.skipif( 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: im_data = f.read() - def core(): + def core() -> None: with Image.open(BytesIO(im_data)) as im: im.load() gc.collect() diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst index af8cd8818..62ef57920 100644 --- a/docs/handbook/image-file-formats.rst +++ b/docs/handbook/image-file-formats.rst @@ -235,7 +235,7 @@ following options are available:: **append_images** A list of images to append as additional frames. Each of the 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 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: **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 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. **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: - * ``"4:0:0"`` - * ``"4:2:0"`` - * ``"4:2:2"`` - * ``"4:4:4"`` + * ``4:0:0`` + * ``4:2:0`` + * ``4:2:2`` + * ``4:4:4`` **speed** Quality/speed trade-off (0=slower-better, 10=fastest). Defaults to 8. **range** - YUV range, either "full" or "limited." Defaults to "full" + YUV range, either "full" or "limited". Defaults to "full" **codec** AV1 codec to use for encoding. Possible values are "aom", "rav1e", and diff --git a/src/PIL/AvifImagePlugin.py b/src/PIL/AvifImagePlugin.py index 06edf2306..56a19f963 100644 --- a/src/PIL/AvifImagePlugin.py +++ b/src/PIL/AvifImagePlugin.py @@ -1,6 +1,7 @@ from __future__ import annotations from io import BytesIO +from typing import IO from . import ExifTags, Image, ImageFile @@ -20,9 +21,9 @@ DEFAULT_MAX_THREADS = 0 _VALID_AVIF_MODES = {"RGB", "RGBA"} -def _accept(prefix): +def _accept(prefix: bytes) -> bool | str: if prefix[4:8] != b"ftyp": - return + return False coding_brands = (b"avif", b"avis") container_brands = (b"mif1", b"msf1") major_brand = prefix[8:12] @@ -42,6 +43,7 @@ def _accept(prefix): # Also, because this file might not actually be an AVIF file, we # don't raise an error if AVIF support isn't properly compiled. return True + return False class AvifImageFile(ImageFile.ImageFile): @@ -53,7 +55,7 @@ class AvifImageFile(ImageFile.ImageFile): def load_seek(self, pos: int) -> None: pass - def _open(self): + def _open(self) -> None: if not SUPPORTED: msg = ( "image file could not be identified because AVIF " @@ -71,7 +73,6 @@ class AvifImageFile(ImageFile.ImageFile): self.n_frames = n_frames self.is_animated = self.n_frames > 1 self._mode = self.rawmode = mode - self.tile = [] if icc: self.info["icc_profile"] = icc @@ -80,13 +81,13 @@ class AvifImageFile(ImageFile.ImageFile): if xmp: self.info["xmp"] = xmp - def seek(self, frame): + def seek(self, frame: int) -> None: if not self._seek_check(frame): return self.__frame = frame - def load(self): + def load(self) -> Image.core.PixelAccess | None: if self.__loaded != self.__frame: # We need to load the image data for this 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: self.fp.close() 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() - def tell(self): + def tell(self) -> int: 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) -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() if save_all: append_images = list(info.get("append_images", []))