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 {
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

View File

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

View File

@ -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(
[
'<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>',
@ -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()

View File

@ -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

View File

@ -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", []))