diff --git a/.appveyor.yml b/.appveyor.yml index 57a8fa5a0..6ce5200b6 100644 --- a/.appveyor.yml +++ b/.appveyor.yml @@ -32,10 +32,10 @@ install: - curl -fsSL -o pillow-test-images.zip https://github.com/python-pillow/test-images/archive/main.zip - 7z x pillow-test-images.zip -oc:\ - xcopy /S /Y c:\test-images-main\* c:\pillow\tests\images -- curl -fsSL -o nasm-win64.zip https://raw.githubusercontent.com/python-pillow/pillow-depends/main/nasm-2.16.01-win64.zip +- curl -fsSL -o nasm-win64.zip https://raw.githubusercontent.com/python-pillow/pillow-depends/main/nasm-2.16.03-win64.zip - 7z x nasm-win64.zip -oc:\ -- choco install ghostscript --version=10.3.0 -- path c:\nasm-2.16.01;C:\Program Files\gs\gs10.00.0\bin;%PATH% +- choco install ghostscript --version=10.3.1 +- path c:\nasm-2.16.03;C:\Program Files\gs\gs10.03.1\bin;%PATH% - cd c:\pillow\winbuild\ - ps: | c:\python38\python.exe c:\pillow\winbuild\build_prepare.py -v --depends=C:\pillow-depends\ diff --git a/.ci/requirements-cibw.txt b/.ci/requirements-cibw.txt index 7e257b75c..0d0f81fbf 100644 --- a/.ci/requirements-cibw.txt +++ b/.ci/requirements-cibw.txt @@ -1 +1 @@ -cibuildwheel==2.18.1 +cibuildwheel==2.19.1 diff --git a/.github/workflows/macos-install.sh b/.github/workflows/macos-install.sh index 28124d7f7..f8f191d38 100755 --- a/.github/workflows/macos-install.sh +++ b/.github/workflows/macos-install.sh @@ -7,11 +7,15 @@ brew install \ ghostscript \ libimagequant \ libjpeg \ - libraqm \ libtiff \ little-cms2 \ openjpeg \ webp +if [[ "$ImageOS" == "macos13" ]]; then + brew install --ignore-dependencies libraqm +else + brew install libraqm +fi export PKG_CONFIG_PATH="/usr/local/opt/openblas/lib/pkgconfig" # TODO Update condition when cffi supports 3.13 diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml index 9edc15173..ee265774b 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.yml @@ -86,7 +86,7 @@ jobs: choco install nasm --no-progress echo "C:\Program Files\NASM" >> $env:GITHUB_PATH - choco install ghostscript --version=10.3.0 --no-progress + choco install ghostscript --version=10.3.1 --no-progress echo "C:\Program Files\gs\gs10.00.0\bin" >> $env:GITHUB_PATH # Install extra test images diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh index 0d45d5a20..97f70ed84 100755 --- a/.github/workflows/wheels-dependencies.sh +++ b/.github/workflows/wheels-dependencies.sh @@ -16,9 +16,9 @@ ARCHIVE_SDIR=pillow-depends-main # Package versions for fresh source builds FREETYPE_VERSION=2.13.2 -HARFBUZZ_VERSION=8.4.0 +HARFBUZZ_VERSION=8.5.0 LIBPNG_VERSION=1.6.43 -JPEGTURBO_VERSION=3.0.2 +JPEGTURBO_VERSION=3.0.3 OPENJPEG_VERSION=2.5.2 XZ_VERSION=5.4.5 TIFF_VERSION=4.6.0 @@ -33,9 +33,9 @@ if [[ -n "$IS_MACOS" ]] || [[ "$MB_ML_VER" != 2014 ]]; then else ZLIB_VERSION=1.2.8 fi -LIBWEBP_VERSION=1.3.2 +LIBWEBP_VERSION=1.4.0 BZIP2_VERSION=1.0.8 -LIBXCB_VERSION=1.16.1 +LIBXCB_VERSION=1.17.0 BROTLI_VERSION=1.1.0 if [[ -n "$IS_MACOS" ]] && [[ "$CIBW_ARCHS" == "x86_64" ]]; then @@ -70,7 +70,7 @@ function build { fi build_new_zlib - build_simple xcb-proto 1.16.0 https://xorg.freedesktop.org/archive/individual/proto + build_simple xcb-proto 1.17.0 https://xorg.freedesktop.org/archive/individual/proto if [ -n "$IS_MACOS" ]; then build_simple xorgproto 2024.1 https://www.x.org/pub/individual/proto build_simple libXau 1.0.11 https://www.x.org/pub/individual/lib diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e848eb670..6a76e8c00 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.4.3 + rev: v0.4.7 hooks: - id: ruff args: [--exit-non-zero-on-fix] @@ -24,7 +24,7 @@ repos: exclude: (Makefile$|\.bat$|\.cmake$|\.eps$|\.fits$|\.gd$|\.opt$) - repo: https://github.com/pre-commit/mirrors-clang-format - rev: v18.1.4 + rev: v18.1.5 hooks: - id: clang-format types: [c] @@ -50,7 +50,7 @@ repos: exclude: ^.github/.*TEMPLATE|^Tests/(fonts|images)/ - repo: https://github.com/python-jsonschema/check-jsonschema - rev: 0.28.2 + rev: 0.28.4 hooks: - id: check-github-workflows - id: check-readthedocs @@ -67,7 +67,7 @@ repos: - id: pyproject-fmt - repo: https://github.com/abravalheri/validate-pyproject - rev: v0.16 + rev: v0.18 hooks: - id: validate-pyproject diff --git a/.readthedocs.yml b/.readthedocs.yml index b83ba05b1..def6282dd 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -3,7 +3,7 @@ version: 2 formats: [pdf] build: - os: ubuntu-22.04 + os: ubuntu-lts-latest tools: python: "3" jobs: diff --git a/CHANGES.rst b/CHANGES.rst index 8a531d1e2..ec3073446 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,30 @@ Changelog (Pillow) 10.4.0 (unreleased) ------------------- +- Support unpacking more rawmodes to RGBA palettes #7966 + [radarhere] + +- Removed support for Qt 5 #8159 + [radarhere] + +- Improve ``ImageFont.freetype`` support for XDG directories on Linux #8135 + [mamg22, radarhere] + +- Improved consistency of XMP handling #8069 + [radarhere] + +- Use pkg-config to help find libwebp and raqm #8142 + [radarhere] + +- Accept 't' suffix for libtiff version #8126, #8129 + [radarhere] + +- Deprecate ImageDraw.getdraw hints parameter #8124 + [radarhere, hugovk] + +- Added ImageDraw circle() #8085 + [void4, hugovk, radarhere] + - Add mypy target to Makefile #8077 [Yay295] diff --git a/Tests/bench_cffi_access.py b/Tests/bench_cffi_access.py index d2a08c07b..c7d105836 100644 --- a/Tests/bench_cffi_access.py +++ b/Tests/bench_cffi_access.py @@ -44,6 +44,7 @@ def test_direct() -> None: caccess = im.im.pixel_access(False) access = PyAccess.new(im, False) + assert access is not None assert caccess[(0, 0)] == access[(0, 0)] print(f"Size: {im.width}x{im.height}") diff --git a/Tests/helper.py b/Tests/helper.py index 5fd4fe332..605a214d4 100644 --- a/Tests/helper.py +++ b/Tests/helper.py @@ -18,7 +18,7 @@ from typing import Any, Callable, Sequence import pytest from packaging.version import parse as parse_version -from PIL import Image, ImageMath, features +from PIL import Image, ImageFile, ImageMath, features logger = logging.getLogger(__name__) @@ -174,12 +174,13 @@ def skip_unless_feature(feature: str) -> pytest.MarkDecorator: def skip_unless_feature_version( feature: str, required: str, reason: str | None = None ) -> pytest.MarkDecorator: - if not features.check(feature): + version = features.version(feature) + if version is None: return pytest.mark.skip(f"{feature} not available") if reason is None: reason = f"{feature} is older than {required}" version_required = parse_version(required) - version_available = parse_version(features.version(feature)) + version_available = parse_version(version) return pytest.mark.skipif(version_available < version_required, reason=reason) @@ -189,12 +190,13 @@ def mark_if_feature_version( version_blacklist: str, reason: str | None = None, ) -> pytest.MarkDecorator: - if not features.check(feature): + version = features.version(feature) + if version is None: return pytest.mark.pil_noop_mark() if reason is None: reason = f"{feature} is {version_blacklist}" version_required = parse_version(version_blacklist) - version_available = parse_version(features.version(feature)) + version_available = parse_version(version) if ( version_available.major == version_required.major and version_available.minor == version_required.minor @@ -220,16 +222,11 @@ class PillowLeakTestCase: from resource import RUSAGE_SELF, getrusage mem = getrusage(RUSAGE_SELF).ru_maxrss - if sys.platform == "darwin": - # man 2 getrusage: - # ru_maxrss - # This is the maximum resident set size utilized (in bytes). - return mem / 1024 # Kb - # linux - # man 2 getrusage - # ru_maxrss (since Linux 2.6.32) - # This is the maximum resident set size used (in kilobytes). - return mem # Kb + # man 2 getrusage: + # ru_maxrss + # This is the maximum resident set size utilized + # in bytes on macOS, in kilobytes on Linux + return mem / 1024 if sys.platform == "darwin" else mem def _test_leak(self, core: Callable[[], None]) -> None: start_mem = self._get_mem_usage() @@ -243,7 +240,7 @@ class PillowLeakTestCase: # helpers -def fromstring(data: bytes) -> Image.Image: +def fromstring(data: bytes) -> ImageFile.ImageFile: return Image.open(BytesIO(data)) diff --git a/Tests/oss-fuzz/test_fuzzers.py b/Tests/oss-fuzz/test_fuzzers.py index 58d0213e8..90eb8713a 100644 --- a/Tests/oss-fuzz/test_fuzzers.py +++ b/Tests/oss-fuzz/test_fuzzers.py @@ -12,8 +12,9 @@ from Tests.helper import skip_unless_feature if sys.platform.startswith("win32"): pytest.skip("Fuzzer is linux only", allow_module_level=True) -if features.check("libjpeg_turbo"): - version = packaging.version.parse(features.version("libjpeg_turbo")) +libjpeg_turbo_version = features.version("libjpeg_turbo") +if libjpeg_turbo_version is not None: + version = packaging.version.parse(libjpeg_turbo_version) if version.major == 2 and version.minor == 0: pytestmark = pytest.mark.valgrind_known_error( reason="Known failing with libjpeg_turbo 2.0" diff --git a/Tests/test_color_lut.py b/Tests/test_color_lut.py index c8886a779..00c8995b0 100644 --- a/Tests/test_color_lut.py +++ b/Tests/test_color_lut.py @@ -354,10 +354,10 @@ class TestColorLut3DCoreAPI: class TestColorLut3DFilter: def test_wrong_args(self) -> None: with pytest.raises(ValueError, match="should be either an integer"): - ImageFilter.Color3DLUT("small", [1]) + ImageFilter.Color3DLUT("small", [1]) # type: ignore[arg-type] with pytest.raises(ValueError, match="should be either an integer"): - ImageFilter.Color3DLUT((11, 11), [1]) + ImageFilter.Color3DLUT((11, 11), [1]) # type: ignore[arg-type] with pytest.raises(ValueError, match=r"in \[2, 65\] range"): ImageFilter.Color3DLUT((11, 11, 1), [1]) diff --git a/Tests/test_decompression_bomb.py b/Tests/test_decompression_bomb.py index 9c21efa45..c140156f9 100644 --- a/Tests/test_decompression_bomb.py +++ b/Tests/test_decompression_bomb.py @@ -12,7 +12,7 @@ ORIGINAL_LIMIT = Image.MAX_IMAGE_PIXELS class TestDecompressionBomb: - def teardown_method(self, method) -> None: + def teardown_method(self) -> None: Image.MAX_IMAGE_PIXELS = ORIGINAL_LIMIT def test_no_warning_small_file(self) -> None: diff --git a/Tests/test_features.py b/Tests/test_features.py index 2d402ca91..b7eefa09a 100644 --- a/Tests/test_features.py +++ b/Tests/test_features.py @@ -30,7 +30,7 @@ def test_version() -> None: # Check the correctness of the convenience function # and the format of version numbers - def test(name: str, function: Callable[[str], bool]) -> None: + def test(name: str, function: Callable[[str], str | None]) -> None: version = features.version(name) if not features.check(name): assert version is None @@ -38,7 +38,9 @@ def test_version() -> None: assert function(name) == version if name != "PIL": if name == "zlib" and version is not None: - version = version.replace(".zlib-ng", "") + version = re.sub(".zlib-ng$", "", version) + elif name == "libtiff" and version is not None: + version = re.sub("t$", "", version) assert version is None or re.search(r"\d+(\.\d+)*$", version) for module in features.modules: @@ -67,12 +69,16 @@ def test_webp_anim() -> None: @skip_unless_feature("libjpeg_turbo") def test_libjpeg_turbo_version() -> None: - assert re.search(r"\d+\.\d+\.\d+$", features.version("libjpeg_turbo")) + version = features.version("libjpeg_turbo") + assert version is not None + assert re.search(r"\d+\.\d+\.\d+$", version) @skip_unless_feature("libimagequant") def test_libimagequant_version() -> None: - assert re.search(r"\d+\.\d+\.\d+$", features.version("libimagequant")) + version = features.version("libimagequant") + assert version is not None + assert re.search(r"\d+\.\d+\.\d+$", version) @pytest.mark.parametrize("feature", features.modules) @@ -120,7 +126,7 @@ def test_unsupported_module() -> None: @pytest.mark.parametrize("supported_formats", (True, False)) -def test_pilinfo(supported_formats) -> None: +def test_pilinfo(supported_formats: bool) -> None: buf = io.StringIO() features.pilinfo(buf, supported_formats=supported_formats) out = buf.getvalue() diff --git a/Tests/test_file_bmp.py b/Tests/test_file_bmp.py index c7c9b24e7..2ff4160bd 100644 --- a/Tests/test_file_bmp.py +++ b/Tests/test_file_bmp.py @@ -140,7 +140,7 @@ def test_load_dib() -> None: (124, "g/pal8v5.bmp"), ), ) -def test_dib_header_size(header_size, path): +def test_dib_header_size(header_size: int, path: str) -> None: image_path = "Tests/images/bmp/" + path with open(image_path, "rb") as fp: data = fp.read()[14:] diff --git a/Tests/test_file_bufrstub.py b/Tests/test_file_bufrstub.py index 3dd24533a..939e82e77 100644 --- a/Tests/test_file_bufrstub.py +++ b/Tests/test_file_bufrstub.py @@ -1,10 +1,11 @@ from __future__ import annotations from pathlib import Path +from typing import IO import pytest -from PIL import BufrStubImagePlugin, Image +from PIL import BufrStubImagePlugin, Image, ImageFile from .helper import hopper @@ -50,20 +51,20 @@ def test_save(tmp_path: Path) -> None: def test_handler(tmp_path: Path) -> None: - class TestHandler: + class TestHandler(ImageFile.StubHandler): opened = False loaded = False saved = False - def open(self, im) -> None: + def open(self, im: ImageFile.StubImageFile) -> None: self.opened = True - def load(self, im): + def load(self, im: ImageFile.StubImageFile) -> Image.Image: self.loaded = True im.fp.close() return Image.new("RGB", (1, 1)) - def save(self, im, fp, filename) -> None: + def save(self, im: Image.Image, fp: IO[bytes], filename: str) -> None: self.saved = True handler = TestHandler() diff --git a/Tests/test_file_gif.py b/Tests/test_file_gif.py index 48c70db8a..e19c88a47 100644 --- a/Tests/test_file_gif.py +++ b/Tests/test_file_gif.py @@ -53,6 +53,7 @@ def test_closed_file() -> None: def test_seek_after_close() -> None: im = Image.open("Tests/images/iss634.gif") + assert isinstance(im, GifImagePlugin.GifImageFile) im.load() im.close() @@ -377,7 +378,8 @@ def test_save_netpbm_bmp_mode(tmp_path: Path) -> None: img = img.convert("RGB") tempfile = str(tmp_path / "temp.gif") - GifImagePlugin._save_netpbm(img, 0, tempfile) + b = BytesIO() + GifImagePlugin._save_netpbm(img, b, tempfile) with Image.open(tempfile) as reloaded: assert_image_similar(img, reloaded.convert("RGB"), 0) @@ -388,7 +390,8 @@ def test_save_netpbm_l_mode(tmp_path: Path) -> None: img = img.convert("L") tempfile = str(tmp_path / "temp.gif") - GifImagePlugin._save_netpbm(img, 0, tempfile) + b = BytesIO() + GifImagePlugin._save_netpbm(img, b, tempfile) with Image.open(tempfile) as reloaded: assert_image_similar(img, reloaded.convert("L"), 0) @@ -648,7 +651,7 @@ def test_dispose2_palette(tmp_path: Path) -> None: assert rgb_img.getpixel((50, 50)) == circle # Check that frame transparency wasn't added unnecessarily - assert img._frame_transparency is None + assert getattr(img, "_frame_transparency") is None def test_dispose2_diff(tmp_path: Path) -> None: @@ -1252,10 +1255,11 @@ def test_palette_save_L(tmp_path: Path) -> None: im = hopper("P") im_l = Image.frombytes("L", im.size, im.tobytes()) - palette = bytes(im.getpalette()) + palette = im.getpalette() + assert palette is not None out = str(tmp_path / "temp.gif") - im_l.save(out, palette=palette) + im_l.save(out, palette=bytes(palette)) with Image.open(out) as reloaded: assert_image_equal(reloaded.convert("RGB"), im.convert("RGB")) diff --git a/Tests/test_file_gribstub.py b/Tests/test_file_gribstub.py index 096a5b88b..86a9064fc 100644 --- a/Tests/test_file_gribstub.py +++ b/Tests/test_file_gribstub.py @@ -5,7 +5,7 @@ from typing import IO import pytest -from PIL import GribStubImagePlugin, Image +from PIL import GribStubImagePlugin, Image, ImageFile from .helper import hopper @@ -51,7 +51,7 @@ def test_save(tmp_path: Path) -> None: def test_handler(tmp_path: Path) -> None: - class TestHandler: + class TestHandler(ImageFile.StubHandler): opened = False loaded = False saved = False diff --git a/Tests/test_file_hdf5stub.py b/Tests/test_file_hdf5stub.py index f871e2eff..ee1544c51 100644 --- a/Tests/test_file_hdf5stub.py +++ b/Tests/test_file_hdf5stub.py @@ -1,11 +1,12 @@ from __future__ import annotations +from io import BytesIO from pathlib import Path from typing import IO import pytest -from PIL import Hdf5StubImagePlugin, Image +from PIL import Hdf5StubImagePlugin, Image, ImageFile TEST_FILE = "Tests/images/hdf5.h5" @@ -41,7 +42,7 @@ def test_load() -> None: def test_save() -> None: # Arrange with Image.open(TEST_FILE) as im: - dummy_fp = None + dummy_fp = BytesIO() dummy_filename = "dummy.filename" # Act / Assert: stub cannot save without an implemented handler @@ -52,7 +53,7 @@ def test_save() -> None: def test_handler(tmp_path: Path) -> None: - class TestHandler: + class TestHandler(ImageFile.StubHandler): opened = False loaded = False saved = False diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py index 5d2157651..dbd917517 100644 --- a/Tests/test_file_jpeg.py +++ b/Tests/test_file_jpeg.py @@ -70,7 +70,9 @@ class TestFileJpeg: def test_sanity(self) -> None: # internal version number - assert re.search(r"\d+\.\d+$", features.version_codec("jpg")) + version = features.version_codec("jpg") + assert version is not None + assert re.search(r"\d+\.\d+$", version) with Image.open(TEST_FILE) as im: im.load() @@ -152,7 +154,7 @@ class TestFileJpeg: assert k > 0.9 def test_rgb(self) -> None: - def getchannels(im: Image.Image) -> tuple[int, int, int]: + def getchannels(im: JpegImagePlugin.JpegImageFile) -> tuple[int, int, int]: return tuple(v[0] for v in im.layer) im = hopper() @@ -169,7 +171,7 @@ class TestFileJpeg: [TEST_FILE, "Tests/images/pil_sample_cmyk.jpg"], ) def test_dpi(self, test_image_path: str) -> None: - def test(xdpi: int, ydpi: int | None = None): + def test(xdpi: int, ydpi: int | None = None) -> tuple[int, int] | None: with Image.open(test_image_path) as im: im = self.roundtrip(im, dpi=(xdpi, ydpi or xdpi)) return im.info.get("dpi") @@ -441,7 +443,9 @@ class TestFileJpeg: assert_image(im1, im2.mode, im2.size) def test_subsampling(self) -> None: - def getsampling(im: Image.Image): + def getsampling( + im: JpegImagePlugin.JpegImageFile, + ) -> tuple[int, int, int, int, int, int]: layer = im.layer return layer[0][1:3] + layer[1][1:3] + layer[2][1:3] @@ -697,7 +701,7 @@ class TestFileJpeg: def test_save_cjpeg(self, tmp_path: Path) -> None: with Image.open(TEST_FILE) as img: tempfile = str(tmp_path / "temp.jpg") - JpegImagePlugin._save_cjpeg(img, 0, tempfile) + JpegImagePlugin._save_cjpeg(img, BytesIO(), tempfile) # Default save quality is 75%, so a tiny bit of difference is alright assert_image_similar_tofile(img, tempfile, 17) @@ -915,24 +919,25 @@ class TestFileJpeg: with Image.open("Tests/images/icc-after-SOF.jpg") as im: assert im.info["icc_profile"] == b"profile" - def test_jpeg_magic_number(self) -> None: + def test_jpeg_magic_number(self, monkeypatch: pytest.MonkeyPatch) -> None: size = 4097 buffer = BytesIO(b"\xFF" * size) # Many xFF bytes - buffer.max_pos = 0 + max_pos = 0 orig_read = buffer.read - def read(n=-1): + def read(n: int | None = -1) -> bytes: + nonlocal max_pos res = orig_read(n) - buffer.max_pos = max(buffer.max_pos, buffer.tell()) + max_pos = max(max_pos, buffer.tell()) return res - buffer.read = read + monkeypatch.setattr(buffer, "read", read) with pytest.raises(UnidentifiedImageError): with Image.open(buffer): pass # Assert the entire file has not been read - assert 0 < buffer.max_pos < size + assert 0 < max_pos < size def test_getxmp(self) -> None: with Image.open("Tests/images/xmp_test.jpg") as im: @@ -943,6 +948,7 @@ class TestFileJpeg: ): assert im.getxmp() == {} else: + assert "xmp" in im.info xmp = im.getxmp() description = xmp["xmpmeta"]["RDF"]["Description"] @@ -1027,8 +1033,10 @@ class TestFileJpeg: def test_repr_jpeg(self) -> None: im = hopper() + b = im._repr_jpeg_() + assert b is not None - with Image.open(BytesIO(im._repr_jpeg_())) as repr_jpeg: + with Image.open(BytesIO(b)) as repr_jpeg: assert repr_jpeg.format == "JPEG" assert_image_similar(im, repr_jpeg, 17) diff --git a/Tests/test_file_jpeg2k.py b/Tests/test_file_jpeg2k.py index a7cae563a..ed7ea4fcf 100644 --- a/Tests/test_file_jpeg2k.py +++ b/Tests/test_file_jpeg2k.py @@ -48,7 +48,9 @@ def roundtrip(im: Image.Image, **options: Any) -> Image.Image: def test_sanity() -> None: # Internal version number - assert re.search(r"\d+\.\d+\.\d+$", features.version_codec("jpg_2000")) + version = features.version_codec("jpg_2000") + assert version is not None + assert re.search(r"\d+\.\d+\.\d+$", version) with Image.open("Tests/images/test-card-lossless.jp2") as im: px = im.load() @@ -458,7 +460,7 @@ def test_plt_marker() -> None: out.seek(length - 2, os.SEEK_CUR) -def test_9bit(): +def test_9bit() -> None: with Image.open("Tests/images/9bit.j2k") as im: assert im.mode == "I;16" assert im.size == (128, 128) diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py index 11883ad24..fe9d017c0 100644 --- a/Tests/test_file_libtiff.py +++ b/Tests/test_file_libtiff.py @@ -52,7 +52,9 @@ class LibTiffTestCase: class TestFileLibTiff(LibTiffTestCase): def test_version(self) -> None: - assert re.search(r"\d+\.\d+\.\d+$", features.version_codec("libtiff")) + version = features.version_codec("libtiff") + assert version is not None + assert re.search(r"\d+\.\d+\.\d+t?$", version) def test_g4_tiff(self, tmp_path: Path) -> None: """Test the ordinary file path load path""" @@ -666,7 +668,8 @@ class TestFileLibTiff(LibTiffTestCase): pilim.save(buffer_io, format="tiff", compression=compression) buffer_io.seek(0) - assert_image_similar_tofile(pilim, buffer_io, 0) + with Image.open(buffer_io) as saved_im: + assert_image_similar(pilim, saved_im, 0) save_bytesio() save_bytesio("raw") diff --git a/Tests/test_file_png.py b/Tests/test_file_png.py index 19462dcb5..0f0976802 100644 --- a/Tests/test_file_png.py +++ b/Tests/test_file_png.py @@ -85,9 +85,9 @@ class TestFilePng: def test_sanity(self, tmp_path: Path) -> None: # internal version number - assert re.search( - r"\d+(\.\d+){1,3}(\.zlib\-ng)?$", features.version_codec("zlib") - ) + version = features.version_codec("zlib") + assert version is not None + assert re.search(r"\d+(\.\d+){1,3}(\.zlib\-ng)?$", version) test_file = str(tmp_path / "temp.png") @@ -535,8 +535,10 @@ class TestFilePng: def test_repr_png(self) -> None: im = hopper() + b = im._repr_png_() + assert b is not None - with Image.open(BytesIO(im._repr_png_())) as repr_png: + with Image.open(BytesIO(b)) as repr_png: assert repr_png.format == "PNG" assert_image_equal(im, repr_png) @@ -683,6 +685,7 @@ class TestFilePng: ): assert im.getxmp() == {} else: + assert "xmp" in im.info xmp = im.getxmp() description = xmp["xmpmeta"]["RDF"]["Description"] @@ -767,14 +770,10 @@ class TestFilePng: def test_save_stdout(self, buffer: bool) -> None: old_stdout = sys.stdout - if buffer: + class MyStdOut: + buffer = BytesIO() - class MyStdOut: - buffer = BytesIO() - - mystdout = MyStdOut() - else: - mystdout = BytesIO() + mystdout: MyStdOut | BytesIO = MyStdOut() if buffer else BytesIO() sys.stdout = mystdout @@ -784,7 +783,7 @@ class TestFilePng: # Reset stdout sys.stdout = old_stdout - if buffer: + if isinstance(mystdout, MyStdOut): mystdout = mystdout.buffer with Image.open(mystdout) as reloaded: assert_image_equal_tofile(reloaded, TEST_PNG_FILE) diff --git a/Tests/test_file_ppm.py b/Tests/test_file_ppm.py index 1bfd0434e..0a61830a4 100644 --- a/Tests/test_file_ppm.py +++ b/Tests/test_file_ppm.py @@ -368,14 +368,10 @@ def test_mimetypes(tmp_path: Path) -> None: def test_save_stdout(buffer: bool) -> None: old_stdout = sys.stdout - if buffer: + class MyStdOut: + buffer = BytesIO() - class MyStdOut: - buffer = BytesIO() - - mystdout = MyStdOut() - else: - mystdout = BytesIO() + mystdout: MyStdOut | BytesIO = MyStdOut() if buffer else BytesIO() sys.stdout = mystdout @@ -385,7 +381,7 @@ def test_save_stdout(buffer: bool) -> None: # Reset stdout sys.stdout = old_stdout - if buffer: + if isinstance(mystdout, MyStdOut): mystdout = mystdout.buffer with Image.open(mystdout) as reloaded: assert_image_equal_tofile(reloaded, TEST_FILE) diff --git a/Tests/test_file_tiff.py b/Tests/test_file_tiff.py index 8821fb46a..08ff334ac 100644 --- a/Tests/test_file_tiff.py +++ b/Tests/test_file_tiff.py @@ -113,14 +113,14 @@ class TestFileTiff: outfile = str(tmp_path / "temp.tif") im.save(outfile, save_all=True, append_images=[im], tiffinfo=im.tag_v2) - def test_seek_too_large(self): + def test_seek_too_large(self) -> None: with pytest.raises(ValueError, match="Unable to seek to frame"): Image.open("Tests/images/seek_too_large.tif") def test_set_legacy_api(self) -> None: ifd = TiffImagePlugin.ImageFileDirectory_v2() with pytest.raises(Exception) as e: - ifd.legacy_api = None + ifd.legacy_api = False assert str(e.value) == "Not allowing setting of legacy api" def test_xyres_tiff(self) -> None: @@ -759,6 +759,7 @@ class TestFileTiff: ): assert im.getxmp() == {} else: + assert "xmp" in im.info xmp = im.getxmp() description = xmp["xmpmeta"]["RDF"]["Description"] diff --git a/Tests/test_file_webp.py b/Tests/test_file_webp.py index 249846da4..1caf032f6 100644 --- a/Tests/test_file_webp.py +++ b/Tests/test_file_webp.py @@ -49,7 +49,9 @@ class TestFileWebp: def test_version(self) -> None: _webp.WebPDecoderVersion() _webp.WebPDecoderBuggyAlpha() - assert re.search(r"\d+\.\d+\.\d+$", features.version_module("webp")) + version = features.version_module("webp") + assert version is not None + assert re.search(r"\d+\.\d+\.\d+$", version) def test_read_rgb(self) -> None: """ @@ -196,7 +198,9 @@ class TestFileWebp: (0, (0,), (-1, 0, 1, 2), (253, 254, 255, 256)), ) @skip_unless_feature("webp_anim") - def test_invalid_background(self, background, tmp_path: Path) -> None: + def test_invalid_background( + self, background: int | tuple[int, ...], tmp_path: Path + ) -> None: temp_file = str(tmp_path / "temp.webp") im = hopper() with pytest.raises(OSError): diff --git a/Tests/test_file_webp_animated.py b/Tests/test_file_webp_animated.py index 6a9337fa5..882dccb32 100644 --- a/Tests/test_file_webp_animated.py +++ b/Tests/test_file_webp_animated.py @@ -52,8 +52,9 @@ def test_write_animation_L(tmp_path: Path) -> None: assert_image_similar(im, orig.convert("RGBA"), 32.9) if is_big_endian(): - webp = parse_version(features.version_module("webp")) - if webp < parse_version("1.2.2"): + version = features.version_module("webp") + assert version is not None + if parse_version(version) < parse_version("1.2.2"): pytest.skip("Fails with libwebp earlier than 1.2.2") orig.seek(orig.n_frames - 1) im.seek(im.n_frames - 1) @@ -68,7 +69,7 @@ def test_write_animation_RGB(tmp_path: Path) -> None: are visually similar to the originals. """ - def check(temp_file) -> None: + def check(temp_file: str) -> None: with Image.open(temp_file) as im: assert im.n_frames == 2 @@ -78,8 +79,9 @@ def test_write_animation_RGB(tmp_path: Path) -> None: # Compare second frame to original if is_big_endian(): - webp = parse_version(features.version_module("webp")) - if webp < parse_version("1.2.2"): + version = features.version_module("webp") + assert version is not None + if parse_version(version) < parse_version("1.2.2"): pytest.skip("Fails with libwebp earlier than 1.2.2") im.seek(1) im.load() diff --git a/Tests/test_file_webp_metadata.py b/Tests/test_file_webp_metadata.py index 875941240..c3df4ad7b 100644 --- a/Tests/test_file_webp_metadata.py +++ b/Tests/test_file_webp_metadata.py @@ -129,6 +129,7 @@ def test_getxmp() -> None: ): assert im.getxmp() == {} else: + assert "xmp" in im.info assert ( im.getxmp()["xmpmeta"]["xmptk"] == "Adobe XMP Core 5.3-c011 66.145661, 2012/02/06-14:56:27 " diff --git a/Tests/test_file_wmf.py b/Tests/test_file_wmf.py index b43e3f296..79e707263 100644 --- a/Tests/test_file_wmf.py +++ b/Tests/test_file_wmf.py @@ -1,10 +1,11 @@ from __future__ import annotations from pathlib import Path +from typing import IO import pytest -from PIL import Image, WmfImagePlugin +from PIL import Image, ImageFile, WmfImagePlugin from .helper import assert_image_similar_tofile, hopper @@ -34,10 +35,13 @@ def test_load() -> None: def test_register_handler(tmp_path: Path) -> None: - class TestHandler: + class TestHandler(ImageFile.StubHandler): methodCalled = False - def save(self, im, fp, filename) -> None: + def load(self, im: ImageFile.StubImageFile) -> Image.Image: + return Image.new("RGB", (1, 1)) + + def save(self, im: Image.Image, fp: IO[bytes], filename: str) -> None: self.methodCalled = True handler = TestHandler() @@ -70,7 +74,7 @@ def test_load_set_dpi() -> None: @pytest.mark.parametrize("ext", (".wmf", ".emf")) -def test_save(ext, tmp_path: Path) -> None: +def test_save(ext: str, tmp_path: Path) -> None: im = hopper() tmpfile = str(tmp_path / ("temp" + ext)) diff --git a/Tests/test_font_leaks.py b/Tests/test_font_leaks.py index 08a0e7431..ab8a7f9ec 100644 --- a/Tests/test_font_leaks.py +++ b/Tests/test_font_leaks.py @@ -12,7 +12,7 @@ class TestTTypeFontLeak(PillowLeakTestCase): iterations = 10 mem_limit = 4096 # k - def _test_font(self, font: ImageFont.FreeTypeFont) -> None: + def _test_font(self, font: ImageFont.FreeTypeFont | ImageFont.ImageFont) -> None: im = Image.new("RGB", (255, 255), "white") draw = ImageDraw.ImageDraw(im) self._test_leak( @@ -34,7 +34,7 @@ class TestDefaultFontLeak(TestTTypeFontLeak): def test_leak(self) -> None: if features.check_module("freetype2"): - ImageFont.core = _util.DeferredError(ImportError) + ImageFont.core = _util.DeferredError(ImportError("Disabled for testing")) try: default_font = ImageFont.load_default() finally: diff --git a/Tests/test_image.py b/Tests/test_image.py index 742d0dfe4..a8f61d677 100644 --- a/Tests/test_image.py +++ b/Tests/test_image.py @@ -25,6 +25,7 @@ from PIL import ( from .helper import ( assert_image_equal, assert_image_equal_tofile, + assert_image_similar, assert_image_similar_tofile, assert_not_all_same, hopper, @@ -99,10 +100,18 @@ class TestImage: JPGFILE = "Tests/images/hopper.jpg" with pytest.raises(TypeError): - with Image.open(PNGFILE, formats=123): + with Image.open(PNGFILE, formats=123): # type: ignore[arg-type] pass - for formats in [["JPEG"], ("JPEG",), ["jpeg"], ["Jpeg"], ["jPeG"], ["JpEg"]]: + format_list: list[list[str] | tuple[str, ...]] = [ + ["JPEG"], + ("JPEG",), + ["jpeg"], + ["Jpeg"], + ["jPeG"], + ["JpEg"], + ] + for formats in format_list: with pytest.raises(UnidentifiedImageError): with Image.open(PNGFILE, formats=formats): pass @@ -138,12 +147,12 @@ class TestImage: def test_bad_mode(self) -> None: with pytest.raises(ValueError): - with Image.open("filename", "bad mode"): + with Image.open("filename", "bad mode"): # type: ignore[arg-type] pass def test_stringio(self) -> None: with pytest.raises(ValueError): - with Image.open(io.StringIO()): + with Image.open(io.StringIO()): # type: ignore[arg-type] pass def test_pathlib(self, tmp_path: Path) -> None: @@ -185,7 +194,8 @@ class TestImage: with tempfile.TemporaryFile() as fp: im.save(fp, "JPEG") fp.seek(0) - assert_image_similar_tofile(im, fp, 20) + with Image.open(fp) as reloaded: + assert_image_similar(im, reloaded, 20) def test_unknown_extension(self, tmp_path: Path) -> None: im = hopper() @@ -383,13 +393,13 @@ class TestImage: # errors with pytest.raises(ValueError): - source.alpha_composite(over, "invalid source") + source.alpha_composite(over, "invalid destination") # type: ignore[arg-type] with pytest.raises(ValueError): - source.alpha_composite(over, (0, 0), "invalid destination") + source.alpha_composite(over, (0, 0), "invalid source") # type: ignore[arg-type] with pytest.raises(ValueError): - source.alpha_composite(over, 0) + source.alpha_composite(over, 0) # type: ignore[arg-type] with pytest.raises(ValueError): - source.alpha_composite(over, (0, 0), 0) + source.alpha_composite(over, (0, 0), 0) # type: ignore[arg-type] with pytest.raises(ValueError): source.alpha_composite(over, (0, 0), (0, -1)) @@ -497,9 +507,11 @@ class TestImage: def test_check_size(self) -> None: # Checking that the _check_size function throws value errors when we want it to with pytest.raises(ValueError): - Image.new("RGB", 0) # not a tuple + # not a tuple + Image.new("RGB", 0) # type: ignore[arg-type] with pytest.raises(ValueError): - Image.new("RGB", (0,)) # Tuple too short + # tuple too short + Image.new("RGB", (0,)) # type: ignore[arg-type] with pytest.raises(ValueError): Image.new("RGB", (-1, -1)) # w,h < 0 @@ -897,6 +909,10 @@ class TestImage: assert tag not in exif.get_ifd(0x8769) assert exif.get_ifd(0xA005) + def test_empty_xmp(self) -> None: + with Image.open("Tests/images/hopper.gif") as im: + assert im.getxmp() == {} + @pytest.mark.parametrize("size", ((1, 0), (0, 1), (0, 0))) def test_zero_tobytes(self, size: tuple[int, int]) -> None: im = Image.new("RGB", size) diff --git a/Tests/test_image_access.py b/Tests/test_image_access.py index 9d6006679..8abb1f69f 100644 --- a/Tests/test_image_access.py +++ b/Tests/test_image_access.py @@ -259,6 +259,7 @@ class TestCffi(AccessTest): caccess = im.im.pixel_access(False) with pytest.warns(DeprecationWarning): access = PyAccess.new(im, False) + assert access is not None w, h = im.size for x in range(0, w, 10): @@ -289,6 +290,7 @@ class TestCffi(AccessTest): caccess = im.im.pixel_access(False) with pytest.warns(DeprecationWarning): access = PyAccess.new(im, False) + assert access is not None w, h = im.size for x in range(0, w, 10): @@ -299,6 +301,8 @@ class TestCffi(AccessTest): # Attempt to set the value on a read-only image with pytest.warns(DeprecationWarning): access = PyAccess.new(im, True) + assert access is not None + with pytest.raises(ValueError): access[(0, 0)] = color @@ -341,6 +345,8 @@ class TestCffi(AccessTest): im = Image.new(mode, (1, 1)) with pytest.warns(DeprecationWarning): access = PyAccess.new(im, False) + assert access is not None + access.putpixel((0, 0), color) if len(color) == 3: diff --git a/Tests/test_image_array.py b/Tests/test_image_array.py index 342bd8654..d7e6c562c 100644 --- a/Tests/test_image_array.py +++ b/Tests/test_image_array.py @@ -86,8 +86,8 @@ def test_fromarray() -> None: assert test("RGBX") == ("RGBA", (128, 100), True) # Test mode is None with no "typestr" in the array interface + wrapped = Wrapper(hopper("L"), {"shape": (100, 128)}) with pytest.raises(TypeError): - wrapped = Wrapper(test("L"), {"shape": (100, 128)}) Image.fromarray(wrapped) diff --git a/Tests/test_image_crop.py b/Tests/test_image_crop.py index d095364ba..07fec2e64 100644 --- a/Tests/test_image_crop.py +++ b/Tests/test_image_crop.py @@ -18,7 +18,7 @@ def test_crop(mode: str) -> None: def test_wide_crop() -> None: - def crop(*bbox: int) -> tuple[int, ...]: + def crop(bbox: tuple[int, int, int, int]) -> tuple[int, ...]: i = im.crop(bbox) h = i.histogram() while h and not h[-1]: @@ -27,23 +27,23 @@ def test_wide_crop() -> None: im = Image.new("L", (100, 100), 1) - assert crop(0, 0, 100, 100) == (0, 10000) - assert crop(25, 25, 75, 75) == (0, 2500) + assert crop((0, 0, 100, 100)) == (0, 10000) + assert crop((25, 25, 75, 75)) == (0, 2500) # sides - assert crop(-25, 0, 25, 50) == (1250, 1250) - assert crop(0, -25, 50, 25) == (1250, 1250) - assert crop(75, 0, 125, 50) == (1250, 1250) - assert crop(0, 75, 50, 125) == (1250, 1250) + assert crop((-25, 0, 25, 50)) == (1250, 1250) + assert crop((0, -25, 50, 25)) == (1250, 1250) + assert crop((75, 0, 125, 50)) == (1250, 1250) + assert crop((0, 75, 50, 125)) == (1250, 1250) - assert crop(-25, 25, 125, 75) == (2500, 5000) - assert crop(25, -25, 75, 125) == (2500, 5000) + assert crop((-25, 25, 125, 75)) == (2500, 5000) + assert crop((25, -25, 75, 125)) == (2500, 5000) # corners - assert crop(-25, -25, 25, 25) == (1875, 625) - assert crop(75, -25, 125, 25) == (1875, 625) - assert crop(75, 75, 125, 125) == (1875, 625) - assert crop(-25, 75, 25, 125) == (1875, 625) + assert crop((-25, -25, 25, 25)) == (1875, 625) + assert crop((75, -25, 125, 25)) == (1875, 625) + assert crop((75, 75, 125, 125)) == (1875, 625) + assert crop((-25, 75, 25, 125)) == (1875, 625) @pytest.mark.parametrize("box", ((8, 2, 2, 8), (2, 8, 8, 2), (8, 8, 2, 2))) diff --git a/Tests/test_image_draft.py b/Tests/test_image_draft.py index 1ce1a7cd8..c9d8c93f3 100644 --- a/Tests/test_image_draft.py +++ b/Tests/test_image_draft.py @@ -16,7 +16,9 @@ def draft_roundtrip( im = Image.new(in_mode, in_size) data = tostring(im, "JPEG") im = fromstring(data) - mode, box = im.draft(req_mode, req_size) + result = im.draft(req_mode, req_size) + assert result is not None + box = result[1] scale, _ = im.decoderconfig assert box[:2] == (0, 0) assert (im.width - scale) < box[2] <= im.width diff --git a/Tests/test_image_filter.py b/Tests/test_image_filter.py index 47f9ffa3d..412ab44c3 100644 --- a/Tests/test_image_filter.py +++ b/Tests/test_image_filter.py @@ -46,9 +46,9 @@ def test_sanity(filter_to_apply: ImageFilter.Filter, mode: str) -> None: @pytest.mark.parametrize("mode", ("L", "I", "RGB", "CMYK")) def test_sanity_error(mode: str) -> None: + im = hopper(mode) with pytest.raises(TypeError): - im = hopper(mode) - im.filter("hello") + im.filter("hello") # type: ignore[arg-type] # crashes on small images @@ -137,7 +137,7 @@ def test_builtinfilter_p() -> None: builtin_filter = ImageFilter.BuiltinFilter() with pytest.raises(ValueError): - builtin_filter.filter(hopper("P")) + builtin_filter.filter(hopper("P").im) def test_kernel_not_enough_coefficients() -> None: diff --git a/Tests/test_image_getextrema.py b/Tests/test_image_getextrema.py index a5b974459..de5956f3e 100644 --- a/Tests/test_image_getextrema.py +++ b/Tests/test_image_getextrema.py @@ -6,7 +6,7 @@ from .helper import hopper def test_extrema() -> None: - def extrema(mode: str) -> tuple[int, int] | tuple[tuple[int, int], ...]: + def extrema(mode: str) -> tuple[float, float] | tuple[tuple[int, int], ...]: return hopper(mode).getextrema() assert extrema("1") == (0, 255) diff --git a/Tests/test_image_mode.py b/Tests/test_image_mode.py index 8e94aafc5..20d3a160e 100644 --- a/Tests/test_image_mode.py +++ b/Tests/test_image_mode.py @@ -68,7 +68,11 @@ def test_sanity() -> None: ), ) def test_properties( - mode, expected_base, expected_type, expected_bands, expected_band_names + mode: str, + expected_base: str, + expected_type: str, + expected_bands: int, + expected_band_names: tuple[str, ...], ) -> None: assert Image.getmodebase(mode) == expected_base assert Image.getmodetype(mode) == expected_type diff --git a/Tests/test_image_paste.py b/Tests/test_image_paste.py index d8f6b65e0..01cb880cb 100644 --- a/Tests/test_image_paste.py +++ b/Tests/test_image_paste.py @@ -338,3 +338,8 @@ class TestImagingPaste: im.copy().paste(im2) im.copy().paste(im2, (0, 0)) + + def test_incorrect_abbreviated_form(self) -> None: + im = Image.new("L", (1, 1)) + with pytest.raises(ValueError): + im.paste(im, im, im) diff --git a/Tests/test_image_point.py b/Tests/test_image_point.py index 05f209351..a5d5a15db 100644 --- a/Tests/test_image_point.py +++ b/Tests/test_image_point.py @@ -61,4 +61,4 @@ def test_f_lut() -> None: def test_f_mode() -> None: im = hopper("F") with pytest.raises(ValueError): - im.point(None) + im.point([]) diff --git a/Tests/test_image_putpalette.py b/Tests/test_image_putpalette.py index cc7cf58f0..75d9d2fc1 100644 --- a/Tests/test_image_putpalette.py +++ b/Tests/test_image_putpalette.py @@ -79,6 +79,7 @@ def test_putpalette_with_alpha_values() -> None: ( ("RGBA", (1, 2, 3, 4)), ("RGBAX", (1, 2, 3, 4, 0)), + ("ARGB", (4, 1, 2, 3)), ), ) def test_rgba_palette(mode: str, palette: tuple[int, ...]) -> None: diff --git a/Tests/test_image_quantize.py b/Tests/test_image_quantize.py index e1aa6252b..2d461d985 100644 --- a/Tests/test_image_quantize.py +++ b/Tests/test_image_quantize.py @@ -24,8 +24,9 @@ def test_sanity() -> None: def test_libimagequant_quantize() -> None: image = hopper() if is_ppc64le(): - libimagequant = parse_version(features.version_feature("libimagequant")) - if libimagequant < parse_version("4"): + version = features.version_feature("libimagequant") + assert version is not None + if parse_version(version) < parse_version("4"): pytest.skip("Fails with libimagequant earlier than 4.0.0 on ppc64le") converted = image.quantize(100, Image.Quantize.LIBIMAGEQUANT) assert converted.mode == "P" @@ -97,7 +98,7 @@ def test_quantize_dither_diff() -> None: @pytest.mark.parametrize( "method", (Image.Quantize.MEDIANCUT, Image.Quantize.MAXCOVERAGE) ) -def test_quantize_kmeans(method) -> None: +def test_quantize_kmeans(method: Image.Quantize) -> None: im = hopper() no_kmeans = im.quantize(kmeans=0, method=method) kmeans = im.quantize(kmeans=1, method=method) diff --git a/Tests/test_image_reduce.py b/Tests/test_image_reduce.py index fcf671daa..6771b46b0 100644 --- a/Tests/test_image_reduce.py +++ b/Tests/test_image_reduce.py @@ -56,10 +56,12 @@ def test_args_factor(size: int | tuple[int, int], expected: tuple[int, int]) -> @pytest.mark.parametrize( "size, expected_error", ((0, ValueError), (2.0, TypeError), ((0, 10), ValueError)) ) -def test_args_factor_error(size: float | tuple[int, int], expected_error) -> None: +def test_args_factor_error( + size: float | tuple[int, int], expected_error: type[Exception] +) -> None: im = Image.new("L", (10, 10)) with pytest.raises(expected_error): - im.reduce(size) + im.reduce(size) # type: ignore[arg-type] @pytest.mark.parametrize( @@ -86,10 +88,12 @@ def test_args_box(size: tuple[int, int, int, int], expected: tuple[int, int]) -> ((5, 0, 5, 10), ValueError), ), ) -def test_args_box_error(size: str | tuple[int, int, int, int], expected_error) -> None: +def test_args_box_error( + size: str | tuple[int, int, int, int], expected_error: type[Exception] +) -> None: im = Image.new("L", (10, 10)) with pytest.raises(expected_error): - im.reduce(2, size).size + im.reduce(2, size).size # type: ignore[arg-type] @pytest.mark.parametrize("mode", ("P", "1", "I;16")) @@ -102,7 +106,7 @@ def test_unsupported_modes(mode: str) -> None: def get_image(mode: str) -> Image.Image: mode_info = ImageMode.getmode(mode) if mode_info.basetype == "L": - bands = [gradients_image] + bands: list[Image.Image] = [gradients_image] for _ in mode_info.bands[1:]: # rotate previous image band = bands[-1].transpose(Image.Transpose.ROTATE_90) diff --git a/Tests/test_image_resample.py b/Tests/test_image_resample.py index 9b3bdf330..d6055b577 100644 --- a/Tests/test_image_resample.py +++ b/Tests/test_image_resample.py @@ -445,7 +445,7 @@ class TestCoreResampleBox: im.resize((32, 32), resample, (20, 20, 100, 20)) with pytest.raises(TypeError, match="must be sequence of length 4"): - im.resize((32, 32), resample, (im.width, im.height)) + im.resize((32, 32), resample, (im.width, im.height)) # type: ignore[arg-type] with pytest.raises(ValueError, match="can't be negative"): im.resize((32, 32), resample, (-20, 20, 100, 100)) diff --git a/Tests/test_image_rotate.py b/Tests/test_image_rotate.py index c10c96da6..252a15db7 100644 --- a/Tests/test_image_rotate.py +++ b/Tests/test_image_rotate.py @@ -124,8 +124,8 @@ def test_fastpath_translate() -> None: def test_center() -> None: im = hopper() rotate(im, im.mode, 45, center=(0, 0)) - rotate(im, im.mode, 45, translate=(im.size[0] / 2, 0)) - rotate(im, im.mode, 45, center=(0, 0), translate=(im.size[0] / 2, 0)) + rotate(im, im.mode, 45, translate=(im.size[0] // 2, 0)) + rotate(im, im.mode, 45, center=(0, 0), translate=(im.size[0] // 2, 0)) def test_rotate_no_fill() -> None: diff --git a/Tests/test_image_thumbnail.py b/Tests/test_image_thumbnail.py index 2ca1d2cfc..bdbf09c40 100644 --- a/Tests/test_image_thumbnail.py +++ b/Tests/test_image_thumbnail.py @@ -16,7 +16,7 @@ from .helper import ( def test_sanity() -> None: im = hopper() - assert im.thumbnail((100, 100)) is None + im.thumbnail((100, 100)) assert im.size == (100, 100) @@ -111,7 +111,9 @@ def test_load_first_unless_jpeg() -> None: with Image.open("Tests/images/hopper.jpg") as im: draft = im.draft - def im_draft(mode: str, size: tuple[int, int]): + def im_draft( + mode: str, size: tuple[int, int] + ) -> tuple[str, tuple[int, int, float, float]] | None: result = draft(mode, size) assert result is not None diff --git a/Tests/test_imagecms.py b/Tests/test_imagecms.py index bf629fa79..8a25460d3 100644 --- a/Tests/test_imagecms.py +++ b/Tests/test_imagecms.py @@ -7,7 +7,7 @@ import shutil import sys from io import BytesIO from pathlib import Path -from typing import Any +from typing import Any, Literal, cast import pytest @@ -60,10 +60,13 @@ def test_sanity() -> None: assert list(map(type, v)) == [str, str, str, str] # internal version number - assert re.search(r"\d+\.\d+(\.\d+)?$", features.version_module("littlecms2")) + version = features.version_module("littlecms2") + assert version is not None + assert re.search(r"\d+\.\d+(\.\d+)?$", version) skip_missing() i = ImageCms.profileToProfile(hopper(), SRGB, SRGB) + assert i is not None assert_image(i, "RGB", (128, 128)) i = hopper() @@ -72,23 +75,27 @@ def test_sanity() -> None: t = ImageCms.buildTransform(SRGB, SRGB, "RGB", "RGB") i = ImageCms.applyTransform(hopper(), t) + assert i is not None assert_image(i, "RGB", (128, 128)) with hopper() as i: t = ImageCms.buildTransform(SRGB, SRGB, "RGB", "RGB") ImageCms.applyTransform(hopper(), t, inPlace=True) + assert i is not None assert_image(i, "RGB", (128, 128)) p = ImageCms.createProfile("sRGB") o = ImageCms.getOpenProfile(SRGB) t = ImageCms.buildTransformFromOpenProfiles(p, o, "RGB", "RGB") i = ImageCms.applyTransform(hopper(), t) + assert i is not None assert_image(i, "RGB", (128, 128)) t = ImageCms.buildProofTransform(SRGB, SRGB, SRGB, "RGB", "RGB") assert t.inputMode == "RGB" assert t.outputMode == "RGB" i = ImageCms.applyTransform(hopper(), t) + assert i is not None assert_image(i, "RGB", (128, 128)) # test PointTransform convenience API @@ -96,7 +103,7 @@ def test_sanity() -> None: def test_flags() -> None: - assert ImageCms.Flags.NONE == 0 + assert ImageCms.Flags.NONE.value == 0 assert ImageCms.Flags.GRIDPOINTS(0) == ImageCms.Flags.NONE assert ImageCms.Flags.GRIDPOINTS(256) == ImageCms.Flags.NONE @@ -202,13 +209,13 @@ def test_exceptions() -> None: ImageCms.buildTransform("foo", "bar", "RGB", "RGB") with pytest.raises(ImageCms.PyCMSError, match="Invalid type for Profile"): - ImageCms.getProfileName(None) + ImageCms.getProfileName(None) # type: ignore[arg-type] skip_missing() # Python <= 3.9: "an integer is required (got type NoneType)" # Python > 3.9: "'NoneType' object cannot be interpreted as an integer" with pytest.raises(ImageCms.PyCMSError, match="integer"): - ImageCms.isIntentSupported(SRGB, None, None) + ImageCms.isIntentSupported(SRGB, None, None) # type: ignore[arg-type] def test_display_profile() -> None: @@ -232,7 +239,7 @@ def test_unsupported_color_space() -> None: "Color space not supported for on-the-fly profile creation (unsupported)" ), ): - ImageCms.createProfile("unsupported") + ImageCms.createProfile("unsupported") # type: ignore[arg-type] def test_invalid_color_temperature() -> None: @@ -240,7 +247,7 @@ def test_invalid_color_temperature() -> None: ImageCms.PyCMSError, match='Color temperature must be numeric, "invalid" not valid', ): - ImageCms.createProfile("LAB", "invalid") + ImageCms.createProfile("LAB", "invalid") # type: ignore[arg-type] @pytest.mark.parametrize("flag", ("my string", -1)) @@ -249,7 +256,7 @@ def test_invalid_flag(flag: str | int) -> None: with pytest.raises( ImageCms.PyCMSError, match="flags must be an integer between 0 and " ): - ImageCms.profileToProfile(im, "foo", "bar", flags=flag) + ImageCms.profileToProfile(im, "foo", "bar", flags=flag) # type: ignore[arg-type] def test_simple_lab() -> None: @@ -260,7 +267,7 @@ def test_simple_lab() -> None: t = ImageCms.buildTransform(psRGB, pLab, "RGB", "LAB") i_lab = ImageCms.applyTransform(i, t) - + assert i_lab is not None assert i_lab.mode == "LAB" k = i_lab.getpixel((0, 0)) @@ -284,6 +291,7 @@ def test_lab_color() -> None: # Need to add a type mapping for some PIL type to TYPE_Lab_8 in findLCMSType, and # have that mapping work back to a PIL mode (likely RGB). i = ImageCms.applyTransform(hopper(), t) + assert i is not None assert_image(i, "LAB", (128, 128)) # i.save('temp.lab.tif') # visually verified vs PS. @@ -298,6 +306,7 @@ def test_lab_srgb() -> None: with Image.open("Tests/images/hopper.Lab.tif") as img: img_srgb = ImageCms.applyTransform(img, t) + assert img_srgb is not None # img_srgb.save('temp.srgb.tif') # visually verified vs ps. @@ -317,11 +326,11 @@ def test_lab_roundtrip() -> None: t2 = ImageCms.buildTransform(pLab, psRGB, "LAB", "RGB") i = ImageCms.applyTransform(hopper(), t) - + assert i is not None assert i.info["icc_profile"] == ImageCmsProfile(pLab).tobytes() out = ImageCms.applyTransform(i, t2) - + assert out is not None assert_image_similar(hopper(), out, 2) @@ -343,7 +352,7 @@ def test_extended_information() -> None: p = o.profile def assert_truncated_tuple_equal( - tup1: tuple[Any, ...], tup2: tuple[Any, ...], digits: int = 10 + tup1: tuple[Any, ...] | None, tup2: tuple[Any, ...], digits: int = 10 ) -> None: # Helper function to reduce precision of tuples of floats # recursively and then check equality. @@ -359,6 +368,7 @@ def test_extended_information() -> None: for val in tuple_value ) + assert tup1 is not None assert truncate_tuple(tup1) == truncate_tuple(tup2) assert p.attributes == 4294967296 @@ -504,22 +514,22 @@ def test_non_ascii_path(tmp_path: Path) -> None: def test_profile_typesafety() -> None: # does not segfault with pytest.raises(TypeError, match="Invalid type for Profile"): - ImageCms.ImageCmsProfile(0).tobytes() + ImageCms.ImageCmsProfile(0) # type: ignore[arg-type] with pytest.raises(TypeError, match="Invalid type for Profile"): - ImageCms.ImageCmsProfile(1).tobytes() + ImageCms.ImageCmsProfile(1) # type: ignore[arg-type] # also check core function with pytest.raises(TypeError): - ImageCms.core.profile_tobytes(0) + ImageCms.core.profile_tobytes(0) # type: ignore[arg-type] with pytest.raises(TypeError): - ImageCms.core.profile_tobytes(1) + ImageCms.core.profile_tobytes(1) # type: ignore[arg-type] if not is_pypy(): # core profile should not be directly instantiable with pytest.raises(TypeError): ImageCms.core.CmsProfile() with pytest.raises(TypeError): - ImageCms.core.CmsProfile(0) + ImageCms.core.CmsProfile(0) # type: ignore[call-arg] @pytest.mark.skipif(is_pypy(), reason="fails on PyPy") @@ -528,7 +538,7 @@ def test_transform_typesafety() -> None: with pytest.raises(TypeError): ImageCms.core.CmsTransform() with pytest.raises(TypeError): - ImageCms.core.CmsTransform(0) + ImageCms.core.CmsTransform(0) # type: ignore[call-arg] def assert_aux_channel_preserved( @@ -559,9 +569,9 @@ def assert_aux_channel_preserved( for delta in nine_grid_deltas: channel_data.paste( channel_pattern, - tuple( - paste_offset[c] + delta[c] * channel_pattern.size[c] - for c in range(2) + ( + paste_offset[0] + delta[0] * channel_pattern.size[0], + paste_offset[1] + delta[1] * channel_pattern.size[1], ), ) chans.append(channel_data) @@ -578,11 +588,13 @@ def assert_aux_channel_preserved( ) # apply transform + result_image: Image.Image | None if transform_in_place: ImageCms.applyTransform(source_image, t, inPlace=True) result_image = source_image else: result_image = ImageCms.applyTransform(source_image, t, inPlace=False) + assert result_image is not None result_image_aux = result_image.getchannel(preserved_channel) assert_image_equal(source_image_aux, result_image_aux) @@ -628,8 +640,10 @@ def test_auxiliary_channels_isolated() -> None: continue # convert with and without AUX data, test colors are equal - source_profile = ImageCms.createProfile(src_format[1]) - destination_profile = ImageCms.createProfile(dst_format[1]) + src_colorSpace = cast(Literal["LAB", "XYZ", "sRGB"], src_format[1]) + source_profile = ImageCms.createProfile(src_colorSpace) + dst_colorSpace = cast(Literal["LAB", "XYZ", "sRGB"], dst_format[1]) + destination_profile = ImageCms.createProfile(dst_colorSpace) source_image = src_format[3] test_transform = ImageCms.buildTransform( source_profile, @@ -639,6 +653,7 @@ def test_auxiliary_channels_isolated() -> None: ) # test conversion from aux-ful source + test_image: Image.Image | None if transform_in_place: test_image = source_image.copy() ImageCms.applyTransform(test_image, test_transform, inPlace=True) @@ -646,6 +661,7 @@ def test_auxiliary_channels_isolated() -> None: test_image = ImageCms.applyTransform( source_image, test_transform, inPlace=False ) + assert test_image is not None # reference conversion from aux-less source reference_transform = ImageCms.buildTransform( @@ -657,7 +673,7 @@ def test_auxiliary_channels_isolated() -> None: reference_image = ImageCms.applyTransform( source_image.convert(src_format[2]), reference_transform ) - + assert reference_image is not None assert_image_equal(test_image.convert(dst_format[2]), reference_image) diff --git a/Tests/test_imagedraw.py b/Tests/test_imagedraw.py index 0a699e2ab..a24aa16ca 100644 --- a/Tests/test_imagedraw.py +++ b/Tests/test_imagedraw.py @@ -2,6 +2,7 @@ from __future__ import annotations import contextlib import os.path +from typing import Sequence import pytest @@ -265,6 +266,21 @@ def test_chord_too_fat() -> None: assert_image_equal_tofile(im, "Tests/images/imagedraw_chord_too_fat.png") +@pytest.mark.parametrize("mode", ("RGB", "L")) +@pytest.mark.parametrize("xy", ((W / 2, H / 2), [W / 2, H / 2])) +def test_circle(mode: str, xy: Sequence[float]) -> None: + # Arrange + im = Image.new(mode, (W, H)) + draw = ImageDraw.Draw(im) + expected = f"Tests/images/imagedraw_ellipse_{mode}.png" + + # Act + draw.circle(xy, 25, fill="green", outline="blue") + + # Assert + assert_image_similar_tofile(im, expected, 1) + + @pytest.mark.parametrize("mode", ("RGB", "L")) @pytest.mark.parametrize("bbox", BBOX) def test_ellipse(mode: str, bbox: Coords) -> None: @@ -432,6 +448,7 @@ def test_shape1() -> None: x3, y3 = 95, 5 # Act + assert ImageDraw.Outline is not None s = ImageDraw.Outline() s.move(x0, y0) s.curve(x1, y1, x2, y2, x3, y3) @@ -453,6 +470,7 @@ def test_shape2() -> None: x3, y3 = 5, 95 # Act + assert ImageDraw.Outline is not None s = ImageDraw.Outline() s.move(x0, y0) s.curve(x1, y1, x2, y2, x3, y3) @@ -471,6 +489,7 @@ def test_transform() -> None: draw = ImageDraw.Draw(im) # Act + assert ImageDraw.Outline is not None s = ImageDraw.Outline() s.line(0, 0) s.transform((0, 0, 0, 0, 0, 0)) @@ -897,7 +916,12 @@ def test_rounded_rectangle_translucent( def test_floodfill(bbox: Coords) -> None: red = ImageColor.getrgb("red") - for mode, value in [("L", 1), ("RGBA", (255, 0, 0, 0)), ("RGB", red)]: + mode_values: list[tuple[str, int | tuple[int, ...]]] = [ + ("L", 1), + ("RGBA", (255, 0, 0, 0)), + ("RGB", red), + ] + for mode, value in mode_values: # Arrange im = Image.new(mode, (W, H)) draw = ImageDraw.Draw(im) @@ -1067,8 +1091,8 @@ def test_line_horizontal() -> None: ) +@pytest.mark.xfail(reason="failing test") def test_line_h_s1_w2() -> None: - pytest.skip("failing") img, draw = create_base_image_draw((20, 20)) draw.line((5, 5, 14, 6), BLACK, 2) assert_image_equal_tofile( @@ -1413,6 +1437,7 @@ def test_same_color_outline(bbox: Coords) -> None: x2, y2 = 95, 50 x3, y3 = 95, 5 + assert ImageDraw.Outline is not None s = ImageDraw.Outline() s.move(x0, y0) s.curve(x1, y1, x2, y2, x3, y3) @@ -1451,7 +1476,7 @@ def test_same_color_outline(bbox: Coords) -> None: (4, "square", {}), (8, "regular_octagon", {}), (4, "square_rotate_45", {"rotation": 45}), - (3, "triangle_width", {"width": 5, "outline": "yellow"}), + (3, "triangle_width", {"outline": "yellow", "width": 5}), ], ) def test_draw_regular_polygon( @@ -1461,7 +1486,10 @@ def test_draw_regular_polygon( filename = f"Tests/images/imagedraw_{polygon_name}.png" draw = ImageDraw.Draw(im) bounding_circle = ((W // 2, H // 2), 25) - draw.regular_polygon(bounding_circle, n_sides, fill="red", **args) + rotation = int(args.get("rotation", 0)) + outline = args.get("outline") + width = int(args.get("width", 1)) + draw.regular_polygon(bounding_circle, n_sides, rotation, "red", outline, width) assert_image_equal_tofile(im, filename) @@ -1546,10 +1574,14 @@ def test_compute_regular_polygon_vertices( ], ) def test_compute_regular_polygon_vertices_input_error_handling( - n_sides, bounding_circle, rotation, expected_error, error_message + n_sides: int, + bounding_circle: int | tuple[int | tuple[int] | str, ...], + rotation: int | str, + expected_error: type[Exception], + error_message: str, ) -> None: with pytest.raises(expected_error) as e: - ImageDraw._compute_regular_polygon_vertices(bounding_circle, n_sides, rotation) + ImageDraw._compute_regular_polygon_vertices(bounding_circle, n_sides, rotation) # type: ignore[arg-type] assert str(e.value) == error_message @@ -1608,3 +1640,8 @@ def test_incorrectly_ordered_coordinates(xy: tuple[int, int, int, int]) -> None: draw.rectangle(xy) with pytest.raises(ValueError): draw.rounded_rectangle(xy) + + +def test_getdraw() -> None: + with pytest.warns(DeprecationWarning): + ImageDraw.getdraw(None, []) diff --git a/Tests/test_imagedraw2.py b/Tests/test_imagedraw2.py index 3171eb9ae..35fe0cf40 100644 --- a/Tests/test_imagedraw2.py +++ b/Tests/test_imagedraw2.py @@ -51,9 +51,10 @@ def test_sanity() -> None: pen = ImageDraw2.Pen("blue", width=7) draw.line(list(range(10)), pen) - draw, handler = ImageDraw.getdraw(im) + draw2, handler = ImageDraw.getdraw(im) + assert draw2 is not None pen = ImageDraw2.Pen("blue", width=7) - draw.line(list(range(10)), pen) + draw2.line(list(range(10)), pen) @pytest.mark.parametrize("bbox", BBOX) diff --git a/Tests/test_imagefile.py b/Tests/test_imagefile.py index ddcae80d6..00cdec952 100644 --- a/Tests/test_imagefile.py +++ b/Tests/test_imagefile.py @@ -202,6 +202,8 @@ class TestImageFile: class MockPyDecoder(ImageFile.PyDecoder): + last: MockPyDecoder + def __init__(self, mode: str, *args: Any) -> None: MockPyDecoder.last = self @@ -213,6 +215,8 @@ class MockPyDecoder(ImageFile.PyDecoder): class MockPyEncoder(ImageFile.PyEncoder): + last: MockPyEncoder | None + def __init__(self, mode: str, *args: Any) -> None: MockPyEncoder.last = self @@ -315,6 +319,7 @@ class TestPyEncoder(CodecsTest): im, fp, [("MOCK", (xoff, yoff, xoff + xsize, yoff + ysize), 0, "RGB")] ) + assert MockPyEncoder.last assert MockPyEncoder.last.state.xoff == xoff assert MockPyEncoder.last.state.yoff == yoff assert MockPyEncoder.last.state.xsize == xsize @@ -329,6 +334,7 @@ class TestPyEncoder(CodecsTest): fp = BytesIO() ImageFile._save(im, fp, [("MOCK", None, 0, "RGB")]) + assert MockPyEncoder.last assert MockPyEncoder.last.state.xoff == 0 assert MockPyEncoder.last.state.yoff == 0 assert MockPyEncoder.last.state.xsize == 200 @@ -375,7 +381,7 @@ class TestPyEncoder(CodecsTest): def test_encode(self) -> None: encoder = ImageFile.PyEncoder(None) with pytest.raises(NotImplementedError): - encoder.encode(None) + encoder.encode(0) bytes_consumed, errcode = encoder.encode_to_pyfd() assert bytes_consumed == 0 diff --git a/Tests/test_imagefont.py b/Tests/test_imagefont.py index 05b5d4716..c3906de24 100644 --- a/Tests/test_imagefont.py +++ b/Tests/test_imagefont.py @@ -34,7 +34,9 @@ pytestmark = skip_unless_feature("freetype2") def test_sanity() -> None: - assert re.search(r"\d+\.\d+\.\d+$", features.version_module("freetype2")) + version = features.version_module("freetype2") + assert version is not None + assert re.search(r"\d+\.\d+\.\d+$", version) @pytest.fixture( @@ -207,7 +209,7 @@ def test_getlength( assert length == length_raqm -def test_float_size() -> None: +def test_float_size(layout_engine: ImageFont.Layout) -> None: lengths = [] for size in (48, 48.5, 49): f = ImageFont.truetype( @@ -222,7 +224,7 @@ def test_render_multiline(font: ImageFont.FreeTypeFont) -> None: draw = ImageDraw.Draw(im) line_spacing = font.getbbox("A")[3] + 4 lines = TEST_TEXT.split("\n") - y = 0 + y: float = 0 for line in lines: draw.text((0, y), line, font=font) y += line_spacing @@ -492,8 +494,8 @@ def test_default_font() -> None: assert_image_equal_tofile(im, "Tests/images/default_font_freetype.png") -@pytest.mark.parametrize("mode", (None, "1", "RGBA")) -def test_getbbox(font: ImageFont.FreeTypeFont, mode: str | None) -> None: +@pytest.mark.parametrize("mode", ("", "1", "RGBA")) +def test_getbbox(font: ImageFont.FreeTypeFont, mode: str) -> None: assert (0, 4, 12, 16) == font.getbbox("A", mode) @@ -547,11 +549,10 @@ def test_find_font( def loadable_font( filepath: str, size: int, index: int, encoding: str, *args: Any ): + _freeTypeFont = getattr(ImageFont, "_FreeTypeFont") if filepath == path_to_fake: - return ImageFont._FreeTypeFont( - FONT_PATH, size, index, encoding, *args - ) - return ImageFont._FreeTypeFont(filepath, size, index, encoding, *args) + return _freeTypeFont(FONT_PATH, size, index, encoding, *args) + return _freeTypeFont(filepath, size, index, encoding, *args) m.setattr(ImageFont, "FreeTypeFont", loadable_font) font = ImageFont.truetype(fontname) @@ -563,6 +564,7 @@ def test_find_font( # catching syntax like errors monkeypatch.setattr(sys, "platform", platform) if platform == "linux": + monkeypatch.setenv("XDG_DATA_HOME", os.path.expanduser("~/.local/share")) monkeypatch.setenv("XDG_DATA_DIRS", "/usr/share/:/usr/local/share/") def fake_walker(path: str) -> list[tuple[str, list[str], list[str]]]: @@ -630,7 +632,9 @@ def test_complex_font_settings() -> None: def test_variation_get(font: ImageFont.FreeTypeFont) -> None: - freetype = parse_version(features.version_module("freetype2")) + version = features.version_module("freetype2") + assert version is not None + freetype = parse_version(version) if freetype < parse_version("2.9.1"): with pytest.raises(NotImplementedError): font.get_variation_names() @@ -700,7 +704,9 @@ def _check_text(font: ImageFont.FreeTypeFont, path: str, epsilon: float) -> None def test_variation_set_by_name(font: ImageFont.FreeTypeFont) -> None: - freetype = parse_version(features.version_module("freetype2")) + version = features.version_module("freetype2") + assert version is not None + freetype = parse_version(version) if freetype < parse_version("2.9.1"): with pytest.raises(NotImplementedError): font.set_variation_by_name("Bold") @@ -725,7 +731,9 @@ def test_variation_set_by_name(font: ImageFont.FreeTypeFont) -> None: def test_variation_set_by_axes(font: ImageFont.FreeTypeFont) -> None: - freetype = parse_version(features.version_module("freetype2")) + version = features.version_module("freetype2") + assert version is not None + freetype = parse_version(version) if freetype < parse_version("2.9.1"): with pytest.raises(NotImplementedError): font.set_variation_by_axes([100]) diff --git a/Tests/test_imagefontpil.py b/Tests/test_imagefontpil.py index acbaecab2..695aecbde 100644 --- a/Tests/test_imagefontpil.py +++ b/Tests/test_imagefontpil.py @@ -33,8 +33,11 @@ def test_default_font(font: ImageFont.ImageFont) -> None: def test_without_freetype() -> None: original_core = ImageFont.core if features.check_module("freetype2"): - ImageFont.core = _util.DeferredError(ImportError) + ImageFont.core = _util.DeferredError(ImportError("Disabled for testing")) try: + with pytest.raises(ImportError): + ImageFont.truetype("Tests/fonts/FreeMono.ttf") + assert isinstance(ImageFont.load_default(), ImageFont.ImageFont) with pytest.raises(ImportError): diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py index d6bdaf450..27a6090c5 100644 --- a/Tests/test_imageops.py +++ b/Tests/test_imageops.py @@ -454,7 +454,7 @@ def test_autocontrast_cutoff() -> None: # Test the cutoff argument of autocontrast with Image.open("Tests/images/bw_gradient.png") as img: - def autocontrast(cutoff: int | tuple[int, int]): + def autocontrast(cutoff: int | tuple[int, int]) -> list[int]: return ImageOps.autocontrast(img, cutoff).histogram() assert autocontrast(10) == autocontrast((10, 10)) diff --git a/Tests/test_imageops_usm.py b/Tests/test_imageops_usm.py index c15907a55..dbdd5b317 100644 --- a/Tests/test_imageops_usm.py +++ b/Tests/test_imageops_usm.py @@ -4,11 +4,11 @@ from typing import Generator import pytest -from PIL import Image, ImageFilter +from PIL import Image, ImageFile, ImageFilter @pytest.fixture -def test_images() -> Generator[dict[str, Image.Image], None, None]: +def test_images() -> Generator[dict[str, ImageFile.ImageFile], None, None]: ims = { "im": Image.open("Tests/images/hopper.ppm"), "snakes": Image.open("Tests/images/color_snakes.png"), @@ -20,7 +20,7 @@ def test_images() -> Generator[dict[str, Image.Image], None, None]: im.close() -def test_filter_api(test_images: dict[str, Image.Image]) -> None: +def test_filter_api(test_images: dict[str, ImageFile.ImageFile]) -> None: im = test_images["im"] test_filter = ImageFilter.GaussianBlur(2.0) @@ -34,7 +34,7 @@ def test_filter_api(test_images: dict[str, Image.Image]) -> None: assert i.size == (128, 128) -def test_usm_formats(test_images: dict[str, Image.Image]) -> None: +def test_usm_formats(test_images: dict[str, ImageFile.ImageFile]) -> None: im = test_images["im"] usm = ImageFilter.UnsharpMask @@ -52,13 +52,12 @@ def test_usm_formats(test_images: dict[str, Image.Image]) -> None: im.convert("YCbCr").filter(usm) -def test_blur_formats(test_images: dict[str, Image.Image]) -> None: +def test_blur_formats(test_images: dict[str, ImageFile.ImageFile]) -> None: im = test_images["im"] blur = ImageFilter.GaussianBlur with pytest.raises(ValueError): im.convert("1").filter(blur) - blur(im.convert("L")) with pytest.raises(ValueError): im.convert("I").filter(blur) with pytest.raises(ValueError): @@ -70,7 +69,7 @@ def test_blur_formats(test_images: dict[str, Image.Image]) -> None: im.convert("YCbCr").filter(blur) -def test_usm_accuracy(test_images: dict[str, Image.Image]) -> None: +def test_usm_accuracy(test_images: dict[str, ImageFile.ImageFile]) -> None: snakes = test_images["snakes"] src = snakes.convert("RGB") @@ -79,7 +78,7 @@ def test_usm_accuracy(test_images: dict[str, Image.Image]) -> None: assert i.tobytes() == src.tobytes() -def test_blur_accuracy(test_images: dict[str, Image.Image]) -> None: +def test_blur_accuracy(test_images: dict[str, ImageFile.ImageFile]) -> None: snakes = test_images["snakes"] i = snakes.filter(ImageFilter.GaussianBlur(0.4)) diff --git a/Tests/test_imagepalette.py b/Tests/test_imagepalette.py index 8e2db15aa..17f68710a 100644 --- a/Tests/test_imagepalette.py +++ b/Tests/test_imagepalette.py @@ -45,7 +45,7 @@ def test_getcolor() -> None: # Test unknown color specifier with pytest.raises(ValueError): - palette.getcolor("unknown") + palette.getcolor("unknown") # type: ignore[arg-type] def test_getcolor_rgba_color_rgb_palette() -> None: diff --git a/Tests/test_imageqt.py b/Tests/test_imageqt.py index 88ad1f9ee..22cd674ce 100644 --- a/Tests/test_imageqt.py +++ b/Tests/test_imageqt.py @@ -41,18 +41,13 @@ def test_rgb() -> None: checkrgb(0, 0, 255) -def test_image() -> None: - modes = ["1", "RGB", "RGBA", "L", "P"] - qt_format = ImageQt.QImage.Format if ImageQt.qt_version == "6" else ImageQt.QImage - if hasattr(qt_format, "Format_Grayscale16"): # Qt 5.13+ - modes.append("I;16") - - for mode in modes: - im = hopper(mode) - roundtripped_im = ImageQt.fromqimage(ImageQt.ImageQt(im)) - if mode not in ("RGB", "RGBA"): - im = im.convert("RGB") - assert_image_similar(roundtripped_im, im, 1) +@pytest.mark.parametrize("mode", ("1", "RGB", "RGBA", "L", "P", "I;16")) +def test_image(mode: str) -> None: + im = hopper(mode) + roundtripped_im = ImageQt.fromqimage(ImageQt.ImageQt(im)) + if mode not in ("RGB", "RGBA"): + im = im.convert("RGB") + assert_image_similar(roundtripped_im, im, 1) def test_closed_file() -> None: diff --git a/Tests/test_imagestat.py b/Tests/test_imagestat.py index b1c1306c1..0dfbc5a2a 100644 --- a/Tests/test_imagestat.py +++ b/Tests/test_imagestat.py @@ -25,10 +25,10 @@ def test_sanity() -> None: st.stddev with pytest.raises(AttributeError): - st.spam() + st.spam() # type: ignore[attr-defined] with pytest.raises(TypeError): - ImageStat.Stat(1) + ImageStat.Stat(1) # type: ignore[arg-type] def test_hopper() -> None: diff --git a/Tests/test_imagewin_pointers.py b/Tests/test_imagewin_pointers.py index f59ee7284..e6c312a0c 100644 --- a/Tests/test_imagewin_pointers.py +++ b/Tests/test_imagewin_pointers.py @@ -70,7 +70,7 @@ if is_win32(): ] CreateDIBSection.restype = ctypes.wintypes.HBITMAP - def serialize_dib(bi, pixels) -> bytearray: + def serialize_dib(bi: BITMAPINFOHEADER, pixels: ctypes.c_void_p) -> bytearray: bf = BITMAPFILEHEADER() bf.bfType = 0x4D42 bf.bfOffBits = ctypes.sizeof(bf) + bi.biSize diff --git a/Tests/test_main.py b/Tests/test_main.py index e9e12b24a..2582dbee3 100644 --- a/Tests/test_main.py +++ b/Tests/test_main.py @@ -11,7 +11,7 @@ import pytest "args, report", ((["PIL"], False), (["PIL", "--report"], True), (["PIL.report"], True)), ) -def test_main(args, report) -> None: +def test_main(args: list[str], report: bool) -> None: args = [sys.executable, "-m"] + args out = subprocess.check_output(args).decode("utf-8") lines = out.splitlines() diff --git a/Tests/test_numpy.py b/Tests/test_numpy.py index 9f4e6534e..32d2cf985 100644 --- a/Tests/test_numpy.py +++ b/Tests/test_numpy.py @@ -1,20 +1,25 @@ from __future__ import annotations import warnings +from typing import TYPE_CHECKING import pytest -from PIL import Image +from PIL import Image, _typing from .helper import assert_deep_equal, assert_image, hopper, skip_unless_feature -numpy = pytest.importorskip("numpy", reason="NumPy not installed") +if TYPE_CHECKING: + import numpy + import numpy.typing as npt +else: + numpy = pytest.importorskip("numpy", reason="NumPy not installed") TEST_IMAGE_SIZE = (10, 10) def test_numpy_to_image() -> None: - def to_image(dtype, bands: int = 1, boolean: int = 0) -> Image.Image: + def to_image(dtype: npt.DTypeLike, bands: int = 1, boolean: int = 0) -> Image.Image: if bands == 1: if boolean: data = [0, 255] * 50 @@ -99,14 +104,14 @@ def test_1d_array() -> None: assert_image(Image.fromarray(a), "L", (1, 5)) -def _test_img_equals_nparray(img: Image.Image, np) -> None: - assert len(np.shape) >= 2 - np_size = np.shape[1], np.shape[0] +def _test_img_equals_nparray(img: Image.Image, np_img: _typing.NumpyArray) -> None: + assert len(np_img.shape) >= 2 + np_size = np_img.shape[1], np_img.shape[0] assert img.size == np_size px = img.load() for x in range(0, img.size[0], int(img.size[0] / 10)): for y in range(0, img.size[1], int(img.size[1] / 10)): - assert_deep_equal(px[x, y], np[y, x]) + assert_deep_equal(px[x, y], np_img[y, x]) def test_16bit() -> None: @@ -157,7 +162,7 @@ def test_save_tiff_uint16() -> None: ("HSV", numpy.uint8), ), ) -def test_to_array(mode: str, dtype) -> None: +def test_to_array(mode: str, dtype: npt.DTypeLike) -> None: img = hopper(mode) # Resize to non-square @@ -207,7 +212,7 @@ def test_putdata() -> None: numpy.float64, ), ) -def test_roundtrip_eye(dtype) -> None: +def test_roundtrip_eye(dtype: npt.DTypeLike) -> None: arr = numpy.eye(10, dtype=dtype) numpy.testing.assert_array_equal(arr, numpy.array(Image.fromarray(arr))) diff --git a/Tests/test_psdraw.py b/Tests/test_psdraw.py index 64dfb2c95..c3afa9089 100644 --- a/Tests/test_psdraw.py +++ b/Tests/test_psdraw.py @@ -54,14 +54,10 @@ def test_stdout(buffer: bool) -> None: # Temporarily redirect stdout old_stdout = sys.stdout - if buffer: + class MyStdOut: + buffer = BytesIO() - class MyStdOut: - buffer = BytesIO() - - mystdout = MyStdOut() - else: - mystdout = BytesIO() + mystdout: MyStdOut | BytesIO = MyStdOut() if buffer else BytesIO() sys.stdout = mystdout @@ -71,6 +67,6 @@ def test_stdout(buffer: bool) -> None: # Reset stdout sys.stdout = old_stdout - if buffer: + if isinstance(mystdout, MyStdOut): mystdout = mystdout.buffer assert mystdout.getvalue() != b"" diff --git a/Tests/test_qt_image_qapplication.py b/Tests/test_qt_image_qapplication.py index 3cd323553..28f66891c 100644 --- a/Tests/test_qt_image_qapplication.py +++ b/Tests/test_qt_image_qapplication.py @@ -46,7 +46,7 @@ def roundtrip(expected: Image.Image) -> None: @pytest.mark.skipif(not ImageQt.qt_is_installed, reason="Qt bindings are not installed") def test_sanity(tmp_path: Path) -> None: # Segfault test - app = QApplication([]) + app: QApplication | None = QApplication([]) ex = Example() assert app # Silence warning assert ex # Silence warning diff --git a/Tests/test_shell_injection.py b/Tests/test_shell_injection.py index 2a072fd44..dd4fc46c3 100644 --- a/Tests/test_shell_injection.py +++ b/Tests/test_shell_injection.py @@ -1,8 +1,9 @@ from __future__ import annotations import shutil +from io import BytesIO from pathlib import Path -from typing import Callable +from typing import IO, Callable import pytest @@ -22,11 +23,11 @@ class TestShellInjection: self, tmp_path: Path, src_img: Image.Image, - save_func: Callable[[Image.Image, int, str], None], + save_func: Callable[[Image.Image, IO[bytes], str | bytes], None], ) -> None: for filename in test_filenames: dest_file = str(tmp_path / filename) - save_func(src_img, 0, dest_file) + save_func(src_img, BytesIO(), dest_file) # If file can't be opened, shell injection probably occurred with Image.open(dest_file) as im: im.load() diff --git a/depends/install_imagequant.sh b/depends/install_imagequant.sh index 973b4374f..9dd7742ed 100755 --- a/depends/install_imagequant.sh +++ b/depends/install_imagequant.sh @@ -2,7 +2,7 @@ # install libimagequant archive_name=libimagequant -archive_version=4.3.0 +archive_version=4.3.1 archive=$archive_name-$archive_version diff --git a/depends/install_webp.sh b/depends/install_webp.sh index 6f867ab37..c47fb35f1 100755 --- a/depends/install_webp.sh +++ b/depends/install_webp.sh @@ -1,7 +1,7 @@ #!/bin/bash # install webp -archive=libwebp-1.3.2 +archive=libwebp-1.4.0 ./download-and-extract.sh $archive https://raw.githubusercontent.com/python-pillow/pillow-depends/main/$archive.tar.gz diff --git a/docs/Makefile b/docs/Makefile index 6495e5866..8f13f1aea 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -9,9 +9,9 @@ PAPER = BUILDDIR = _build # Internal variables. -PAPEROPT_a4 = -D latex_paper_size=a4 -PAPEROPT_letter = -D latex_paper_size=letter -ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . +PAPEROPT_a4 = --define latex_paper_size=a4 +PAPEROPT_letter = --define latex_paper_size=letter +ALLSPHINXOPTS = --doctree-dir $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . # the i18n builder cannot share the environment and doctrees with the others I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . @@ -51,42 +51,42 @@ install-sphinx: .PHONY: html html: $(MAKE) install-sphinx - $(SPHINXBUILD) -b html -W --keep-going $(ALLSPHINXOPTS) $(BUILDDIR)/html + $(SPHINXBUILD) --builder html --fail-on-warning --keep-going $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." .PHONY: dirhtml dirhtml: $(MAKE) install-sphinx - $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml + $(SPHINXBUILD) --builder dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." .PHONY: singlehtml singlehtml: $(MAKE) install-sphinx - $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml + $(SPHINXBUILD) --builder singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml @echo @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." .PHONY: pickle pickle: $(MAKE) install-sphinx - $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle + $(SPHINXBUILD) --builder pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle @echo @echo "Build finished; now you can process the pickle files." .PHONY: json json: $(MAKE) install-sphinx - $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json + $(SPHINXBUILD) --builder json $(ALLSPHINXOPTS) $(BUILDDIR)/json @echo @echo "Build finished; now you can process the JSON files." .PHONY: htmlhelp htmlhelp: $(MAKE) install-sphinx - $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp + $(SPHINXBUILD) --builder htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp @echo @echo "Build finished; now you can run HTML Help Workshop with the" \ ".hhp project file in $(BUILDDIR)/htmlhelp." @@ -94,7 +94,7 @@ htmlhelp: .PHONY: qthelp qthelp: $(MAKE) install-sphinx - $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp + $(SPHINXBUILD) --builder qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp @echo @echo "Build finished; now you can run "qcollectiongenerator" with the" \ ".qhcp project file in $(BUILDDIR)/qthelp, like this:" @@ -105,7 +105,7 @@ qthelp: .PHONY: devhelp devhelp: $(MAKE) install-sphinx - $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp + $(SPHINXBUILD) --builder devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp @echo @echo "Build finished." @echo "To view the help file:" @@ -116,14 +116,14 @@ devhelp: .PHONY: epub epub: $(MAKE) install-sphinx - $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub + $(SPHINXBUILD) --builder epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub @echo @echo "Build finished. The epub file is in $(BUILDDIR)/epub." .PHONY: latex latex: $(MAKE) install-sphinx - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + $(SPHINXBUILD) --builder latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." @echo "Run \`make' in that directory to run these through (pdf)latex" \ @@ -132,7 +132,7 @@ latex: .PHONY: latexpdf latexpdf: $(MAKE) install-sphinx - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + $(SPHINXBUILD) --builder latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through pdflatex..." $(MAKE) -C $(BUILDDIR)/latex all-pdf @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." @@ -140,21 +140,21 @@ latexpdf: .PHONY: text text: $(MAKE) install-sphinx - $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text + $(SPHINXBUILD) --builder text $(ALLSPHINXOPTS) $(BUILDDIR)/text @echo @echo "Build finished. The text files are in $(BUILDDIR)/text." .PHONY: man man: $(MAKE) install-sphinx - $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man + $(SPHINXBUILD) --builder man $(ALLSPHINXOPTS) $(BUILDDIR)/man @echo @echo "Build finished. The manual pages are in $(BUILDDIR)/man." .PHONY: texinfo texinfo: $(MAKE) install-sphinx - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + $(SPHINXBUILD) --builder texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." @echo "Run \`make' in that directory to run these through makeinfo" \ @@ -163,7 +163,7 @@ texinfo: .PHONY: info info: $(MAKE) install-sphinx - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + $(SPHINXBUILD) --builder texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo "Running Texinfo files through makeinfo..." make -C $(BUILDDIR)/texinfo info @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." @@ -171,21 +171,21 @@ info: .PHONY: gettext gettext: $(MAKE) install-sphinx - $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale + $(SPHINXBUILD) --builder gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale @echo @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." .PHONY: changes changes: $(MAKE) install-sphinx - $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes + $(SPHINXBUILD) --builder changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes @echo @echo "The overview file is in $(BUILDDIR)/changes." .PHONY: linkcheck linkcheck: $(MAKE) install-sphinx - $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck -j auto + $(SPHINXBUILD) --builder linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck -j auto @echo @echo "Link check complete; look for any errors in the above output " \ "or in $(BUILDDIR)/linkcheck/output.txt." @@ -193,7 +193,7 @@ linkcheck: .PHONY: doctest doctest: $(MAKE) install-sphinx - $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest + $(SPHINXBUILD) --builder doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest @echo "Testing of doctests in the sources finished, look at the " \ "results in $(BUILDDIR)/doctest/output.txt." diff --git a/docs/deprecations.rst b/docs/deprecations.rst index b2cd968fe..627672e1f 100644 --- a/docs/deprecations.rst +++ b/docs/deprecations.rst @@ -115,6 +115,13 @@ Support for LibTIFF earlier than 4 Support for LibTIFF earlier than version 4 has been deprecated. Upgrade to a newer version of LibTIFF instead. +ImageDraw.getdraw hints parameter +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. deprecated:: 10.4.0 + +The ``hints`` parameter in :py:meth:`~PIL.ImageDraw.getdraw()` has been deprecated. + Removed features ---------------- diff --git a/docs/handbook/concepts.rst b/docs/handbook/concepts.rst index 5094dbf3f..7da1078c1 100644 --- a/docs/handbook/concepts.rst +++ b/docs/handbook/concepts.rst @@ -144,10 +144,12 @@ pixel, the Python Imaging Library provides different resampling *filters*. .. py:currentmodule:: PIL.Image .. data:: Resampling.NEAREST + :noindex: Pick one nearest pixel from the input image. Ignore all other input pixels. .. data:: Resampling.BOX + :noindex: Each pixel of source image contributes to one pixel of the destination image with identical weights. @@ -158,6 +160,7 @@ pixel, the Python Imaging Library provides different resampling *filters*. .. versionadded:: 3.4.0 .. data:: Resampling.BILINEAR + :noindex: For resize calculate the output pixel value using linear interpolation on all pixels that may contribute to the output value. @@ -165,6 +168,7 @@ pixel, the Python Imaging Library provides different resampling *filters*. in the input image is used. .. data:: Resampling.HAMMING + :noindex: Produces a sharper image than :data:`Resampling.BILINEAR`, doesn't have dislocations on local level like with :data:`Resampling.BOX`. @@ -174,6 +178,7 @@ pixel, the Python Imaging Library provides different resampling *filters*. .. versionadded:: 3.4.0 .. data:: Resampling.BICUBIC + :noindex: For resize calculate the output pixel value using cubic interpolation on all pixels that may contribute to the output value. @@ -181,6 +186,7 @@ pixel, the Python Imaging Library provides different resampling *filters*. in the input image is used. .. data:: Resampling.LANCZOS + :noindex: Calculate the output pixel value using a high-quality Lanczos filter (a truncated sinc) on all pixels that may contribute to the output value. diff --git a/docs/installation/building-from-source.rst b/docs/installation/building-from-source.rst index 961312b14..7f7dfa6ff 100644 --- a/docs/installation/building-from-source.rst +++ b/docs/installation/building-from-source.rst @@ -68,7 +68,7 @@ Many of Pillow's features require external libraries: * **libimagequant** provides improved color quantization - * Pillow has been tested with libimagequant **2.6-4.3** + * Pillow has been tested with libimagequant **2.6-4.3.1** * Libimagequant is licensed GPLv3, which is more restrictive than the Pillow license, therefore we will not be distributing binaries with libimagequant support enabled. diff --git a/docs/reference/Image.rst b/docs/reference/Image.rst index 0d9b4d93d..e83c8787f 100644 --- a/docs/reference/Image.rst +++ b/docs/reference/Image.rst @@ -78,8 +78,6 @@ Constructing images ^^^^^^^^^^^^^^^^^^^ .. autofunction:: new -.. autoclass:: SupportsArrayInterface - :show-inheritance: .. autofunction:: fromarray .. autofunction:: frombytes .. autofunction:: frombuffer @@ -197,6 +195,7 @@ This helps to get the bounding box coordinates of the input image:: .. automethod:: PIL.Image.Image.getpalette .. automethod:: PIL.Image.Image.getpixel .. automethod:: PIL.Image.Image.getprojection +.. automethod:: PIL.Image.Image.getxmp .. automethod:: PIL.Image.Image.histogram .. automethod:: PIL.Image.Image.paste .. automethod:: PIL.Image.Image.point @@ -365,6 +364,14 @@ Classes .. autoclass:: PIL.Image.ImagePointHandler .. autoclass:: PIL.Image.ImageTransformHandler +Protocols +--------- + +.. autoclass:: SupportsArrayInterface + :show-inheritance: +.. autoclass:: SupportsGetData + :show-inheritance: + Constants --------- @@ -418,7 +425,6 @@ See :ref:`concept-filters` for details. .. autoclass:: Resampling :members: :undoc-members: - :noindex: Dither modes ^^^^^^^^^^^^ diff --git a/docs/reference/ImageDraw.rst b/docs/reference/ImageDraw.rst index 4ccfacae7..1404869ca 100644 --- a/docs/reference/ImageDraw.rst +++ b/docs/reference/ImageDraw.rst @@ -227,6 +227,18 @@ Methods .. versionadded:: 5.3.0 +.. py:method:: ImageDraw.circle(xy, radius, fill=None, outline=None, width=1) + + Draws a circle with a given radius centering on a point. + + .. versionadded:: 10.4.0 + + :param xy: The point for the center of the circle, e.g. ``(x, y)``. + :param radius: Radius of the circle. + :param outline: Color to use for the outline. + :param fill: Color to use for the fill. + :param width: The line width, in pixels. + .. py:method:: ImageDraw.ellipse(xy, fill=None, outline=None, width=1) Draws an ellipse inside the given bounding box. diff --git a/docs/reference/ImageFile.rst b/docs/reference/ImageFile.rst index 047990f1c..e59c7311a 100644 --- a/docs/reference/ImageFile.rst +++ b/docs/reference/ImageFile.rst @@ -57,6 +57,10 @@ Classes :undoc-members: :show-inheritance: +.. autoclass:: PIL.ImageFile.StubHandler() + :members: + :show-inheritance: + .. autoclass:: PIL.ImageFile.StubImageFile() :members: :show-inheritance: diff --git a/docs/reference/internal_modules.rst b/docs/reference/internal_modules.rst index 899e4966f..e4cb17c4d 100644 --- a/docs/reference/internal_modules.rst +++ b/docs/reference/internal_modules.rst @@ -33,6 +33,10 @@ Internal Modules Provides a convenient way to import type hints that are not available on some Python versions. +.. py:class:: NumpyArray + + Typing alias. + .. py:class:: StrOrBytesPath Typing alias. diff --git a/docs/releasenotes/10.4.0.rst b/docs/releasenotes/10.4.0.rst index 41f33102f..44727efd4 100644 --- a/docs/releasenotes/10.4.0.rst +++ b/docs/releasenotes/10.4.0.rst @@ -34,6 +34,11 @@ Support for LibTIFF earlier than 4 Support for LibTIFF earlier than version 4 has been deprecated. Upgrade to a newer version of LibTIFF instead. +ImageDraw.getdraw hints parameter +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The ``hints`` parameter in :py:meth:`~PIL.ImageDraw.getdraw()` has been deprecated. + API Changes =========== @@ -45,6 +50,13 @@ TODO API Additions ============= +ImageDraw.circle +^^^^^^^^^^^^^^^^ + +Added :py:meth:`~PIL.ImageDraw.ImageDraw.circle`. It provides the same functionality as +:py:meth:`~PIL.ImageDraw.ImageDraw.ellipse`, but instead of taking a bounding box, it +takes a center point and radius. + TODO ^^^^ diff --git a/docs/releasenotes/8.3.0.rst b/docs/releasenotes/8.3.0.rst index 9f46cc1e9..4ef914f64 100644 --- a/docs/releasenotes/8.3.0.rst +++ b/docs/releasenotes/8.3.0.rst @@ -18,9 +18,9 @@ is not secure. - :py:meth:`~PIL.Image.Image.getexif` has used ``xml`` to potentially retrieve orientation data since Pillow 7.2.0. It has been refactored to use ``re`` instead. -- :py:meth:`~PIL.JpegImagePlugin.JpegImageFile.getxmp` was added in Pillow 8.2.0. It - will now use ``defusedxml`` instead. If the dependency is not present, an empty - dictionary will be returned and a warning raised. +- ``getxmp()`` was added to :py:class:`~PIL.JpegImagePlugin.JpegImageFile` in Pillow + 8.2.0. It will now use ``defusedxml`` instead. If the dependency is not present, an + empty dictionary will be returned and a warning raised. Deprecations ============ diff --git a/setup.py b/setup.py index abdd87ea2..0abfaaddc 100644 --- a/setup.py +++ b/setup.py @@ -37,7 +37,9 @@ IMAGEQUANT_ROOT = None JPEG2K_ROOT = None JPEG_ROOT = None LCMS_ROOT = None +RAQM_ROOT = None TIFF_ROOT = None +WEBP_ROOT = None ZLIB_ROOT = None FUZZING_BUILD = "LIB_FUZZING_ENGINE" in os.environ @@ -459,6 +461,8 @@ class pil_build_ext(build_ext): "FREETYPE_ROOT": "freetype2", "HARFBUZZ_ROOT": "harfbuzz", "FRIBIDI_ROOT": "fribidi", + "RAQM_ROOT": "raqm", + "WEBP_ROOT": "libwebp", "LCMS_ROOT": "lcms2", "IMAGEQUANT_ROOT": "libimagequant", }.items(): diff --git a/src/PIL/BdfFontFile.py b/src/PIL/BdfFontFile.py index e3eda4fe9..bc1416c74 100644 --- a/src/PIL/BdfFontFile.py +++ b/src/PIL/BdfFontFile.py @@ -103,7 +103,7 @@ def bdf_char( class BdfFontFile(FontFile.FontFile): """Font file plugin for the X11 BDF format.""" - def __init__(self, fp: BinaryIO): + def __init__(self, fp: BinaryIO) -> None: super().__init__() s = fp.readline() diff --git a/src/PIL/BlpImagePlugin.py b/src/PIL/BlpImagePlugin.py index bdf54baae..59246c6e2 100644 --- a/src/PIL/BlpImagePlugin.py +++ b/src/PIL/BlpImagePlugin.py @@ -31,10 +31,12 @@ BLP files come in many different flavours: from __future__ import annotations +import abc import os import struct from enum import IntEnum from io import BytesIO +from typing import IO from . import Image, ImageFile @@ -55,11 +57,13 @@ class AlphaEncoding(IntEnum): DXT5 = 7 -def unpack_565(i): +def unpack_565(i: int) -> tuple[int, int, int]: return ((i >> 11) & 0x1F) << 3, ((i >> 5) & 0x3F) << 2, (i & 0x1F) << 3 -def decode_dxt1(data, alpha=False): +def decode_dxt1( + data: bytes, alpha: bool = False +) -> tuple[bytearray, bytearray, bytearray, bytearray]: """ input: one "row" of data (i.e. will produce 4*width pixels) """ @@ -67,9 +71,9 @@ def decode_dxt1(data, alpha=False): blocks = len(data) // 8 # number of blocks in row ret = (bytearray(), bytearray(), bytearray(), bytearray()) - for block in range(blocks): + for block_index in range(blocks): # Decode next 8-byte block. - idx = block * 8 + idx = block_index * 8 color0, color1, bits = struct.unpack_from(" tuple[bytearray, bytearray, bytearray, bytearray]: """ input: one "row" of data (i.e. will produce 4*width pixels) """ @@ -122,8 +126,8 @@ def decode_dxt3(data): blocks = len(data) // 16 # number of blocks in row ret = (bytearray(), bytearray(), bytearray(), bytearray()) - for block in range(blocks): - idx = block * 16 + for block_index in range(blocks): + idx = block_index * 16 block = data[idx : idx + 16] # Decode next 16-byte block. bits = struct.unpack_from("<8B", block) @@ -167,7 +171,7 @@ def decode_dxt3(data): return ret -def decode_dxt5(data): +def decode_dxt5(data: bytes) -> tuple[bytearray, bytearray, bytearray, bytearray]: """ input: one "row" of data (i.e. will produce 4 * width pixels) """ @@ -175,8 +179,8 @@ def decode_dxt5(data): blocks = len(data) // 16 # number of blocks in row ret = (bytearray(), bytearray(), bytearray(), bytearray()) - for block in range(blocks): - idx = block * 16 + for block_index in range(blocks): + idx = block_index * 16 block = data[idx : idx + 16] # Decode next 16-byte block. a0, a1 = struct.unpack_from(" tuple[int, int]: try: self._read_blp_header() self._load() @@ -284,7 +288,12 @@ class _BLPBaseDecoder(ImageFile.PyDecoder): raise OSError(msg) from e return -1, 0 - def _read_blp_header(self): + @abc.abstractmethod + def _load(self) -> None: + pass + + def _read_blp_header(self) -> None: + assert self.fd is not None self.fd.seek(4) (self._blp_compression,) = struct.unpack(" bytes: return ImageFile._safe_read(self.fd, length) - def _read_palette(self): + def _read_palette(self) -> list[tuple[int, int, int, int]]: ret = [] for i in range(256): try: @@ -316,7 +325,7 @@ class _BLPBaseDecoder(ImageFile.PyDecoder): ret.append((b, g, r, a)) return ret - def _read_bgra(self, palette): + def _read_bgra(self, palette: list[tuple[int, int, int, int]]) -> bytearray: data = bytearray() _data = BytesIO(self._safe_read(self._blp_lengths[0])) while True: @@ -325,7 +334,7 @@ class _BLPBaseDecoder(ImageFile.PyDecoder): except struct.error: break b, g, r, a = palette[offset] - d = (r, g, b) + d: tuple[int, ...] = (r, g, b) if self._blp_alpha_depth: d += (a,) data.extend(d) @@ -349,29 +358,30 @@ class BLP1Decoder(_BLPBaseDecoder): msg = f"Unsupported BLP compression {repr(self._blp_encoding)}" raise BLPFormatError(msg) - def _decode_jpeg_stream(self): + def _decode_jpeg_stream(self) -> None: from .JpegImagePlugin import JpegImageFile (jpeg_header_size,) = struct.unpack(" None: palette = self._read_palette() + assert self.fd is not None self.fd.seek(self._blp_offsets[0]) if self._blp_compression == 1: @@ -428,7 +438,7 @@ class BLPEncoder(ImageFile.PyEncoder): data += b"\x00" * 4 return data - def encode(self, bufsize): + def encode(self, bufsize: int) -> tuple[int, int, bytes]: palette_data = self._write_palette() offset = 20 + 16 * 4 * 2 + len(palette_data) @@ -446,7 +456,7 @@ class BLPEncoder(ImageFile.PyEncoder): return len(data), 0, data -def _save(im, fp, filename): +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if im.mode != "P": msg = "Unsupported BLP image mode" raise ValueError(msg) diff --git a/src/PIL/BmpImagePlugin.py b/src/PIL/BmpImagePlugin.py index c5d1cd40d..ef2045337 100644 --- a/src/PIL/BmpImagePlugin.py +++ b/src/PIL/BmpImagePlugin.py @@ -25,6 +25,7 @@ from __future__ import annotations import os +from typing import IO from . import Image, ImageFile, ImagePalette from ._binary import i16le as i16 @@ -52,7 +53,7 @@ def _accept(prefix: bytes) -> bool: return prefix[:2] == b"BM" -def _dib_accept(prefix): +def _dib_accept(prefix: bytes) -> bool: return i32(prefix) in [12, 40, 52, 56, 64, 108, 124] @@ -300,7 +301,8 @@ class BmpImageFile(ImageFile.ImageFile): class BmpRleDecoder(ImageFile.PyDecoder): _pulls_fd = True - def decode(self, buffer): + def decode(self, buffer: bytes) -> tuple[int, int]: + assert self.fd is not None rle4 = self.args[1] data = bytearray() x = 0 @@ -394,11 +396,13 @@ SAVE = { } -def _dib_save(im, fp, filename): +def _dib_save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: _save(im, fp, filename, False) -def _save(im, fp, filename, bitmap_header=True): +def _save( + im: Image.Image, fp: IO[bytes], filename: str | bytes, bitmap_header: bool = True +) -> None: try: rawmode, bits, colors = SAVE[im.mode] except KeyError as e: diff --git a/src/PIL/BufrStubImagePlugin.py b/src/PIL/BufrStubImagePlugin.py index 271db7258..0ee2f653b 100644 --- a/src/PIL/BufrStubImagePlugin.py +++ b/src/PIL/BufrStubImagePlugin.py @@ -10,12 +10,14 @@ # from __future__ import annotations +from typing import IO + from . import Image, ImageFile _handler = None -def register_handler(handler): +def register_handler(handler: ImageFile.StubHandler | None) -> None: """ Install application-specific BUFR image handler. @@ -54,11 +56,11 @@ class BufrStubImageFile(ImageFile.StubImageFile): if loader: loader.open(self) - def _load(self): + def _load(self) -> ImageFile.StubHandler | None: return _handler -def _save(im, fp, filename): +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if _handler is None or not hasattr(_handler, "save"): msg = "BUFR save handler not installed" raise OSError(msg) diff --git a/src/PIL/DcxImagePlugin.py b/src/PIL/DcxImagePlugin.py index 1c455b032..f67f27d73 100644 --- a/src/PIL/DcxImagePlugin.py +++ b/src/PIL/DcxImagePlugin.py @@ -42,7 +42,7 @@ class DcxImageFile(PcxImageFile): format_description = "Intel DCX" _close_exclusive_fp_after_loading = False - def _open(self): + def _open(self) -> None: # Header s = self.fp.read(4) if not _accept(s): @@ -58,7 +58,7 @@ class DcxImageFile(PcxImageFile): self._offset.append(offset) self._fp = self.fp - self.frame = None + self.frame = -1 self.n_frames = len(self._offset) self.is_animated = self.n_frames > 1 self.seek(0) diff --git a/src/PIL/DdsImagePlugin.py b/src/PIL/DdsImagePlugin.py index 1575f2d88..a57e4aea2 100644 --- a/src/PIL/DdsImagePlugin.py +++ b/src/PIL/DdsImagePlugin.py @@ -16,6 +16,7 @@ import io import struct import sys from enum import IntEnum, IntFlag +from typing import IO from . import Image, ImageFile, ImagePalette from ._binary import i32le as i32 @@ -379,6 +380,7 @@ class DdsImageFile(ImageFile.ImageFile): elif pfflags & DDPF.PALETTEINDEXED8: self._mode = "P" self.palette = ImagePalette.raw("RGBA", self.fp.read(1024)) + self.palette.mode = "RGBA" elif pfflags & DDPF.FOURCC: offset = header_size + 4 if fourcc == D3DFMT.DXT1: @@ -479,7 +481,8 @@ class DdsImageFile(ImageFile.ImageFile): class DdsRgbDecoder(ImageFile.PyDecoder): _pulls_fd = True - def decode(self, buffer): + def decode(self, buffer: bytes) -> tuple[int, int]: + assert self.fd is not None bitcount, masks = self.args # Some masks will be padded with zeros, e.g. R 0b11 G 0b1100 @@ -510,7 +513,7 @@ class DdsRgbDecoder(ImageFile.PyDecoder): return -1, 0 -def _save(im, fp, filename): +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if im.mode not in ("RGB", "RGBA", "L", "LA"): msg = f"cannot write mode {im.mode} as DDS" raise OSError(msg) diff --git a/src/PIL/EpsImagePlugin.py b/src/PIL/EpsImagePlugin.py index 5a44baa49..380b1cf0e 100644 --- a/src/PIL/EpsImagePlugin.py +++ b/src/PIL/EpsImagePlugin.py @@ -27,6 +27,7 @@ import re import subprocess import sys import tempfile +from typing import IO from . import Image, ImageFile from ._binary import i32le as i32 @@ -228,7 +229,7 @@ class EpsImageFile(ImageFile.ImageFile): reading_trailer_comments = False trailer_reached = False - def check_required_header_comments(): + def check_required_header_comments() -> None: if "PS-Adobe" not in self.info: msg = 'EPS header missing "%!PS-Adobe" comment' raise SyntaxError(msg) @@ -236,7 +237,7 @@ class EpsImageFile(ImageFile.ImageFile): msg = 'EPS header missing "%%BoundingBox" comment' raise SyntaxError(msg) - def _read_comment(s): + def _read_comment(s: str) -> bool: nonlocal reading_trailer_comments try: m = split.match(s) @@ -244,27 +245,25 @@ class EpsImageFile(ImageFile.ImageFile): msg = "not an EPS file" raise SyntaxError(msg) from e - if m: - k, v = m.group(1, 2) - self.info[k] = v - if k == "BoundingBox": - if v == "(atend)": - reading_trailer_comments = True - elif not self._size or ( - trailer_reached and reading_trailer_comments - ): - try: - # Note: The DSC spec says that BoundingBox - # fields should be integers, but some drivers - # put floating point values there anyway. - box = [int(float(i)) for i in v.split()] - self._size = box[2] - box[0], box[3] - box[1] - self.tile = [ - ("eps", (0, 0) + self.size, offset, (length, box)) - ] - except Exception: - pass - return True + if not m: + return False + + k, v = m.group(1, 2) + self.info[k] = v + if k == "BoundingBox": + if v == "(atend)": + reading_trailer_comments = True + elif not self._size or (trailer_reached and reading_trailer_comments): + try: + # Note: The DSC spec says that BoundingBox + # fields should be integers, but some drivers + # put floating point values there anyway. + box = [int(float(i)) for i in v.split()] + self._size = box[2] - box[0], box[3] - box[1] + self.tile = [("eps", (0, 0) + self.size, offset, (length, box))] + except Exception: + pass + return True while True: byte = self.fp.read(1) @@ -413,7 +412,7 @@ class EpsImageFile(ImageFile.ImageFile): # -------------------------------------------------------------------- -def _save(im, fp, filename, eps=1): +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes, eps: int = 1) -> None: """EPS Writer for the Python Imaging Library.""" # make sure image data is available diff --git a/src/PIL/FitsImagePlugin.py b/src/PIL/FitsImagePlugin.py index 071918925..4846054b1 100644 --- a/src/PIL/FitsImagePlugin.py +++ b/src/PIL/FitsImagePlugin.py @@ -115,14 +115,18 @@ class FitsImageFile(ImageFile.ImageFile): elif number_of_bits in (-32, -64): self._mode = "F" - args = (self.mode, 0, -1) if decoder_name == "raw" else (number_of_bits,) + args: tuple[str | int, ...] + if decoder_name == "raw": + args = (self.mode, 0, -1) + else: + args = (number_of_bits,) return decoder_name, offset, args class FitsGzipDecoder(ImageFile.PyDecoder): _pulls_fd = True - def decode(self, buffer): + def decode(self, buffer: bytes) -> tuple[int, int]: assert self.fd is not None value = gzip.decompress(self.fd.read()) diff --git a/src/PIL/FpxImagePlugin.py b/src/PIL/FpxImagePlugin.py index 4ba93bb39..c1927bd26 100644 --- a/src/PIL/FpxImagePlugin.py +++ b/src/PIL/FpxImagePlugin.py @@ -70,7 +70,7 @@ class FpxImageFile(ImageFile.ImageFile): self._open_index(1) - def _open_index(self, index=1): + def _open_index(self, index: int = 1) -> None: # # get the Image Contents Property Set @@ -85,7 +85,7 @@ class FpxImageFile(ImageFile.ImageFile): size = max(self.size) i = 1 while size > 64: - size = size / 2 + size = size // 2 i += 1 self.maxid = i - 1 @@ -118,7 +118,7 @@ class FpxImageFile(ImageFile.ImageFile): self._open_subimage(1, self.maxid) - def _open_subimage(self, index=1, subimage=0): + def _open_subimage(self, index: int = 1, subimage: int = 0) -> None: # # setup tile descriptors for a given subimage @@ -241,7 +241,7 @@ class FpxImageFile(ImageFile.ImageFile): self.ole.close() super().close() - def __exit__(self, *args): + def __exit__(self, *args: object) -> None: self.ole.close() super().__exit__() diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py index 962a92834..284128c77 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -29,8 +29,10 @@ import itertools import math import os import subprocess +import sys from enum import IntEnum from functools import cached_property +from typing import IO, TYPE_CHECKING, Any, List, Literal, NamedTuple, Union from . import ( Image, @@ -45,6 +47,9 @@ from ._binary import i16le as i16 from ._binary import o8 from ._binary import o16le as o16 +if TYPE_CHECKING: + from . import _imaging + class LoadingStrategy(IntEnum): """.. versionadded:: 9.1.0""" @@ -117,7 +122,7 @@ class GifImageFile(ImageFile.ImageFile): self._seek(0) # get ready to read first frame @property - def n_frames(self): + def n_frames(self) -> int: if self._n_frames is None: current = self.tell() try: @@ -162,11 +167,11 @@ class GifImageFile(ImageFile.ImageFile): msg = "no more images in GIF file" raise EOFError(msg) from e - def _seek(self, frame, update_image=True): + def _seek(self, frame: int, update_image: bool = True) -> None: if frame == 0: # rewind self.__offset = 0 - self.dispose = None + self.dispose: _imaging.ImagingCore | None = None self.__frame = -1 self._fp.seek(self.__rewind) self.disposal_method = 0 @@ -194,9 +199,9 @@ class GifImageFile(ImageFile.ImageFile): msg = "no more images in GIF file" raise EOFError(msg) - palette = None + palette: ImagePalette.ImagePalette | Literal[False] | None = None - info = {} + info: dict[str, Any] = {} frame_transparency = None interlace = None frame_dispose_extent = None @@ -212,7 +217,7 @@ class GifImageFile(ImageFile.ImageFile): # s = self.fp.read(1) block = self.data() - if s[0] == 249: + if s[0] == 249 and block is not None: # # graphic control extension # @@ -248,14 +253,14 @@ class GifImageFile(ImageFile.ImageFile): info["comment"] = comment s = None continue - elif s[0] == 255 and frame == 0: + elif s[0] == 255 and frame == 0 and block is not None: # # application extension # info["extension"] = block, self.fp.tell() if block[:11] == b"NETSCAPE2.0": block = self.data() - if len(block) >= 3 and block[0] == 1: + if block and len(block) >= 3 and block[0] == 1: self.info["loop"] = i16(block, 1) while self.data(): pass @@ -336,60 +341,60 @@ class GifImageFile(ImageFile.ImageFile): self._mode = "RGB" self.im = self.im.convert("RGB", Image.Dither.FLOYDSTEINBERG) - def _rgb(color): + def _rgb(color: int) -> tuple[int, int, int]: if self._frame_palette: if color * 3 + 3 > len(self._frame_palette.palette): color = 0 - color = tuple(self._frame_palette.palette[color * 3 : color * 3 + 3]) + return tuple(self._frame_palette.palette[color * 3 : color * 3 + 3]) else: - color = (color, color, color) - return color + return (color, color, color) + self.dispose = None self.dispose_extent = frame_dispose_extent - try: - if self.disposal_method < 2: - # do not dispose or none specified - self.dispose = None - elif self.disposal_method == 2: - # replace with background colour + if self.dispose_extent and self.disposal_method >= 2: + try: + if self.disposal_method == 2: + # replace with background colour - # only dispose the extent in this frame - x0, y0, x1, y1 = self.dispose_extent - dispose_size = (x1 - x0, y1 - y0) - - Image._decompression_bomb_check(dispose_size) - - # by convention, attempt to use transparency first - dispose_mode = "P" - color = self.info.get("transparency", frame_transparency) - if color is not None: - if self.mode in ("RGB", "RGBA"): - dispose_mode = "RGBA" - color = _rgb(color) + (0,) - else: - color = self.info.get("background", 0) - if self.mode in ("RGB", "RGBA"): - dispose_mode = "RGB" - color = _rgb(color) - self.dispose = Image.core.fill(dispose_mode, dispose_size, color) - else: - # replace with previous contents - if self.im is not None: # only dispose the extent in this frame - self.dispose = self._crop(self.im, self.dispose_extent) - elif frame_transparency is not None: x0, y0, x1, y1 = self.dispose_extent dispose_size = (x1 - x0, y1 - y0) Image._decompression_bomb_check(dispose_size) + + # by convention, attempt to use transparency first dispose_mode = "P" - color = frame_transparency - if self.mode in ("RGB", "RGBA"): - dispose_mode = "RGBA" - color = _rgb(frame_transparency) + (0,) + color = self.info.get("transparency", frame_transparency) + if color is not None: + if self.mode in ("RGB", "RGBA"): + dispose_mode = "RGBA" + color = _rgb(color) + (0,) + else: + color = self.info.get("background", 0) + if self.mode in ("RGB", "RGBA"): + dispose_mode = "RGB" + color = _rgb(color) self.dispose = Image.core.fill(dispose_mode, dispose_size, color) - except AttributeError: - pass + else: + # replace with previous contents + if self.im is not None: + # only dispose the extent in this frame + self.dispose = self._crop(self.im, self.dispose_extent) + elif frame_transparency is not None: + x0, y0, x1, y1 = self.dispose_extent + dispose_size = (x1 - x0, y1 - y0) + + Image._decompression_bomb_check(dispose_size) + dispose_mode = "P" + color = frame_transparency + if self.mode in ("RGB", "RGBA"): + dispose_mode = "RGBA" + color = _rgb(frame_transparency) + (0,) + self.dispose = Image.core.fill( + dispose_mode, dispose_size, color + ) + except AttributeError: + pass if interlace is not None: transparency = -1 @@ -428,7 +433,7 @@ class GifImageFile(ImageFile.ImageFile): self._prev_im = self.im if self._frame_palette: self.im = Image.core.fill("P", self.size, self._frame_transparency or 0) - self.im.putpalette(*self._frame_palette.getdata()) + self.im.putpalette("RGB", *self._frame_palette.getdata()) else: self.im = None self._mode = temp_mode @@ -453,6 +458,8 @@ class GifImageFile(ImageFile.ImageFile): frame_im = self.im.convert("RGBA") else: frame_im = self.im.convert("RGB") + + assert self.dispose_extent is not None frame_im = self._crop(frame_im, self.dispose_extent) self.im = self._prev_im @@ -498,7 +505,12 @@ def _normalize_mode(im: Image.Image) -> Image.Image: return im.convert("L") -def _normalize_palette(im, palette, info): +_Palette = Union[bytes, bytearray, List[int], ImagePalette.ImagePalette] + + +def _normalize_palette( + im: Image.Image, palette: _Palette | None, info: dict[str, Any] +) -> Image.Image: """ Normalizes the palette for image. - Sets the palette to the incoming palette, if provided. @@ -526,8 +538,10 @@ def _normalize_palette(im, palette, info): source_palette = bytearray(i // 3 for i in range(768)) im.palette = ImagePalette.ImagePalette("RGB", palette=source_palette) + used_palette_colors: list[int] | None if palette: used_palette_colors = [] + assert source_palette is not None for i in range(0, len(source_palette), 3): source_color = tuple(source_palette[i : i + 3]) index = im.palette.colors.get(source_color) @@ -558,7 +572,11 @@ def _normalize_palette(im, palette, info): return im -def _write_single_frame(im, fp, palette): +def _write_single_frame( + im: Image.Image, + fp: IO[bytes], + palette: _Palette | None, +) -> None: im_out = _normalize_mode(im) for k, v in im_out.info.items(): im.encoderinfo.setdefault(k, v) @@ -579,7 +597,9 @@ def _write_single_frame(im, fp, palette): fp.write(b"\0") # end of image data -def _getbbox(base_im, im_frame): +def _getbbox( + base_im: Image.Image, im_frame: Image.Image +) -> tuple[Image.Image, tuple[int, int, int, int] | None]: if _get_palette_bytes(im_frame) != _get_palette_bytes(base_im): im_frame = im_frame.convert("RGBA") base_im = base_im.convert("RGBA") @@ -587,12 +607,20 @@ def _getbbox(base_im, im_frame): return delta, delta.getbbox(alpha_only=False) -def _write_multiple_frames(im, fp, palette): +class _Frame(NamedTuple): + im: Image.Image + bbox: tuple[int, int, int, int] | None + encoderinfo: dict[str, Any] + + +def _write_multiple_frames( + im: Image.Image, fp: IO[bytes], palette: _Palette | None +) -> bool: duration = im.encoderinfo.get("duration") disposal = im.encoderinfo.get("disposal", im.info.get("disposal")) - im_frames = [] - previous_im = None + im_frames: list[_Frame] = [] + previous_im: Image.Image | None = None frame_count = 0 background_im = None for imSequence in itertools.chain([im], im.encoderinfo.get("append_images", [])): @@ -618,24 +646,22 @@ def _write_multiple_frames(im, fp, palette): frame_count += 1 diff_frame = None - if im_frames: + if im_frames and previous_im: # delta frame delta, bbox = _getbbox(previous_im, im_frame) if not bbox: # This frame is identical to the previous frame if encoderinfo.get("duration"): - im_frames[-1]["encoderinfo"]["duration"] += encoderinfo[ - "duration" - ] + im_frames[-1].encoderinfo["duration"] += encoderinfo["duration"] continue - if im_frames[-1]["encoderinfo"].get("disposal") == 2: + if im_frames[-1].encoderinfo.get("disposal") == 2: if background_im is None: color = im.encoderinfo.get( "transparency", im.info.get("transparency", (0, 0, 0)) ) background = _get_background(im_frame, color) background_im = Image.new("P", im_frame.size, background) - background_im.putpalette(im_frames[0]["im"].palette) + background_im.putpalette(im_frames[0].im.palette) bbox = _getbbox(background_im, im_frame)[1] elif encoderinfo.get("optimize") and im_frame.mode != "1": if "transparency" not in encoderinfo: @@ -681,39 +707,39 @@ def _write_multiple_frames(im, fp, palette): else: bbox = None previous_im = im_frame - im_frames.append( - {"im": diff_frame or im_frame, "bbox": bbox, "encoderinfo": encoderinfo} - ) + im_frames.append(_Frame(diff_frame or im_frame, bbox, encoderinfo)) if len(im_frames) == 1: if "duration" in im.encoderinfo: # Since multiple frames will not be written, use the combined duration - im.encoderinfo["duration"] = im_frames[0]["encoderinfo"]["duration"] - return + im.encoderinfo["duration"] = im_frames[0].encoderinfo["duration"] + return False for frame_data in im_frames: - im_frame = frame_data["im"] - if not frame_data["bbox"]: + im_frame = frame_data.im + if not frame_data.bbox: # global header - for s in _get_global_header(im_frame, frame_data["encoderinfo"]): + for s in _get_global_header(im_frame, frame_data.encoderinfo): fp.write(s) offset = (0, 0) else: # compress difference if not palette: - frame_data["encoderinfo"]["include_color_table"] = True + frame_data.encoderinfo["include_color_table"] = True - im_frame = im_frame.crop(frame_data["bbox"]) - offset = frame_data["bbox"][:2] - _write_frame_data(fp, im_frame, offset, frame_data["encoderinfo"]) + im_frame = im_frame.crop(frame_data.bbox) + offset = frame_data.bbox[:2] + _write_frame_data(fp, im_frame, offset, frame_data.encoderinfo) return True -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: # header if "palette" in im.encoderinfo or "palette" in im.info: palette = im.encoderinfo.get("palette", im.info.get("palette")) @@ -730,7 +756,7 @@ def _save(im, fp, filename, save_all=False): fp.flush() -def get_interlace(im): +def get_interlace(im: Image.Image) -> int: interlace = im.encoderinfo.get("interlace", 1) # workaround for @PIL153 @@ -740,7 +766,9 @@ def get_interlace(im): return interlace -def _write_local_header(fp, im, offset, flags): +def _write_local_header( + fp: IO[bytes], im: Image.Image, offset: tuple[int, int], flags: int +) -> None: try: transparency = im.encoderinfo["transparency"] except KeyError: @@ -788,7 +816,7 @@ def _write_local_header(fp, im, offset, flags): fp.write(o8(8)) # bits -def _save_netpbm(im, fp, filename): +def _save_netpbm(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: # Unused by default. # To use, uncomment the register_save call at the end of the file. # @@ -819,6 +847,7 @@ def _save_netpbm(im, fp, filename): ) # Allow ppmquant to receive SIGPIPE if ppmtogif exits + assert quant_proc.stdout is not None quant_proc.stdout.close() retcode = quant_proc.wait() @@ -840,7 +869,7 @@ def _save_netpbm(im, fp, filename): _FORCE_OPTIMIZE = False -def _get_optimize(im, info): +def _get_optimize(im: Image.Image, info: dict[str, Any]) -> list[int] | None: """ Palette optimization is a potentially expensive operation. @@ -884,6 +913,7 @@ def _get_optimize(im, info): and current_palette_size > 2 ): return used_palette_colors + return None def _get_color_table_size(palette_bytes: bytes) -> int: @@ -924,7 +954,10 @@ def _get_palette_bytes(im: Image.Image) -> bytes: return im.palette.palette if im.palette else b"" -def _get_background(im, info_background): +def _get_background( + im: Image.Image, + info_background: int | tuple[int, int, int] | tuple[int, int, int, int] | None, +) -> int: background = 0 if info_background: if isinstance(info_background, tuple): @@ -947,7 +980,7 @@ def _get_background(im, info_background): return background -def _get_global_header(im, info): +def _get_global_header(im: Image.Image, info: dict[str, Any]) -> list[bytes]: """Return a list of strings representing a GIF header""" # Header Block @@ -1009,7 +1042,12 @@ def _get_global_header(im, info): return header -def _write_frame_data(fp, im_frame, offset, params): +def _write_frame_data( + fp: IO[bytes], + im_frame: Image.Image, + offset: tuple[int, int], + params: dict[str, Any], +) -> None: try: im_frame.encoderinfo = params @@ -1029,7 +1067,9 @@ def _write_frame_data(fp, im_frame, offset, params): # Legacy GIF utilities -def getheader(im, palette=None, info=None): +def getheader( + im: Image.Image, palette: _Palette | None = None, info: dict[str, Any] | None = None +) -> tuple[list[bytes], list[int] | None]: """ Legacy Method to get Gif data from image. @@ -1041,11 +1081,11 @@ def getheader(im, palette=None, info=None): :returns: tuple of(list of header items, optimized palette) """ - used_palette_colors = _get_optimize(im, info) - if info is None: info = {} + used_palette_colors = _get_optimize(im, info) + if "background" not in info and "background" in im.info: info["background"] = im.info["background"] @@ -1057,7 +1097,9 @@ def getheader(im, palette=None, info=None): return header, used_palette_colors -def getdata(im, offset=(0, 0), **params): +def getdata( + im: Image.Image, offset: tuple[int, int] = (0, 0), **params: Any +) -> list[bytes]: """ Legacy Method @@ -1074,12 +1116,23 @@ def getdata(im, offset=(0, 0), **params): :returns: List of bytes containing GIF encoded frame data """ + from io import BytesIO - class Collector: + class Collector(BytesIO): data = [] - def write(self, data): - self.data.append(data) + if sys.version_info >= (3, 12): + from collections.abc import Buffer + + def write(self, data: Buffer) -> int: + self.data.append(data) + return len(data) + + else: + + def write(self, data: Any) -> int: + self.data.append(data) + return len(data) im.load() # make sure raster data is available diff --git a/src/PIL/GimpGradientFile.py b/src/PIL/GimpGradientFile.py index 2d8c78ea9..220eac57e 100644 --- a/src/PIL/GimpGradientFile.py +++ b/src/PIL/GimpGradientFile.py @@ -21,6 +21,7 @@ See the GIMP distribution for more information.) from __future__ import annotations from math import log, pi, sin, sqrt +from typing import IO, Callable from ._binary import o8 @@ -28,7 +29,7 @@ EPSILON = 1e-10 """""" # Enable auto-doc for data member -def linear(middle, pos): +def linear(middle: float, pos: float) -> float: if pos <= middle: if middle < EPSILON: return 0.0 @@ -43,19 +44,19 @@ def linear(middle, pos): return 0.5 + 0.5 * pos / middle -def curved(middle, pos): +def curved(middle: float, pos: float) -> float: return pos ** (log(0.5) / log(max(middle, EPSILON))) -def sine(middle, pos): +def sine(middle: float, pos: float) -> float: return (sin((-pi / 2.0) + pi * linear(middle, pos)) + 1.0) / 2.0 -def sphere_increasing(middle, pos): +def sphere_increasing(middle: float, pos: float) -> float: return sqrt(1.0 - (linear(middle, pos) - 1.0) ** 2) -def sphere_decreasing(middle, pos): +def sphere_decreasing(middle: float, pos: float) -> float: return 1.0 - sqrt(1.0 - linear(middle, pos) ** 2) @@ -64,9 +65,22 @@ SEGMENTS = [linear, curved, sine, sphere_increasing, sphere_decreasing] class GradientFile: - gradient = None + gradient: ( + list[ + tuple[ + float, + float, + float, + list[float], + list[float], + Callable[[float, float], float], + ] + ] + | None + ) = None - def getpalette(self, entries=256): + def getpalette(self, entries: int = 256) -> tuple[bytes, str]: + assert self.gradient is not None palette = [] ix = 0 @@ -101,7 +115,7 @@ class GradientFile: class GimpGradientFile(GradientFile): """File handler for GIMP's gradient format.""" - def __init__(self, fp): + def __init__(self, fp: IO[bytes]) -> None: if fp.readline()[:13] != b"GIMP Gradient": msg = "not a GIMP gradient file" raise SyntaxError(msg) @@ -114,7 +128,7 @@ class GimpGradientFile(GradientFile): count = int(line) - gradient = [] + self.gradient = [] for i in range(count): s = fp.readline().split() @@ -132,6 +146,4 @@ class GimpGradientFile(GradientFile): msg = "cannot handle HSV colour space" raise OSError(msg) - gradient.append((x0, x1, xm, rgb0, rgb1, segment)) - - self.gradient = gradient + self.gradient.append((x0, x1, xm, rgb0, rgb1, segment)) diff --git a/src/PIL/GimpPaletteFile.py b/src/PIL/GimpPaletteFile.py index 2274f1a8b..4cad0ebee 100644 --- a/src/PIL/GimpPaletteFile.py +++ b/src/PIL/GimpPaletteFile.py @@ -16,6 +16,7 @@ from __future__ import annotations import re +from typing import IO from ._binary import o8 @@ -25,8 +26,8 @@ class GimpPaletteFile: rawmode = "RGB" - def __init__(self, fp): - self.palette = [o8(i) * 3 for i in range(256)] + def __init__(self, fp: IO[bytes]) -> None: + palette = [o8(i) * 3 for i in range(256)] if fp.readline()[:12] != b"GIMP Palette": msg = "not a GIMP palette file" @@ -49,9 +50,9 @@ class GimpPaletteFile: msg = "bad palette entry" raise ValueError(msg) - self.palette[i] = o8(v[0]) + o8(v[1]) + o8(v[2]) + palette[i] = o8(v[0]) + o8(v[1]) + o8(v[2]) - self.palette = b"".join(self.palette) + self.palette = b"".join(palette) def getpalette(self) -> tuple[bytes, str]: return self.palette, self.rawmode diff --git a/src/PIL/GribStubImagePlugin.py b/src/PIL/GribStubImagePlugin.py index 13bdfa616..e9aa084b2 100644 --- a/src/PIL/GribStubImagePlugin.py +++ b/src/PIL/GribStubImagePlugin.py @@ -10,12 +10,14 @@ # from __future__ import annotations +from typing import IO + from . import Image, ImageFile _handler = None -def register_handler(handler): +def register_handler(handler: ImageFile.StubHandler | None) -> None: """ Install application-specific GRIB image handler. @@ -54,11 +56,11 @@ class GribStubImageFile(ImageFile.StubImageFile): if loader: loader.open(self) - def _load(self): + def _load(self) -> ImageFile.StubHandler | None: return _handler -def _save(im, fp, filename): +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if _handler is None or not hasattr(_handler, "save"): msg = "GRIB save handler not installed" raise OSError(msg) diff --git a/src/PIL/Hdf5StubImagePlugin.py b/src/PIL/Hdf5StubImagePlugin.py index afbfd1639..cc9e73deb 100644 --- a/src/PIL/Hdf5StubImagePlugin.py +++ b/src/PIL/Hdf5StubImagePlugin.py @@ -10,12 +10,14 @@ # from __future__ import annotations +from typing import IO + from . import Image, ImageFile _handler = None -def register_handler(handler): +def register_handler(handler: ImageFile.StubHandler | None) -> None: """ Install application-specific HDF5 image handler. @@ -54,11 +56,11 @@ class HDF5StubImageFile(ImageFile.StubImageFile): if loader: loader.open(self) - def _load(self): + def _load(self) -> ImageFile.StubHandler | None: return _handler -def _save(im, fp, filename): +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if _handler is None or not hasattr(_handler, "save"): msg = "HDF5 save handler not installed" raise OSError(msg) diff --git a/src/PIL/IcnsImagePlugin.py b/src/PIL/IcnsImagePlugin.py index 0a86ba883..2a89d498c 100644 --- a/src/PIL/IcnsImagePlugin.py +++ b/src/PIL/IcnsImagePlugin.py @@ -22,6 +22,7 @@ import io import os import struct import sys +from typing import IO from . import Image, ImageFile, PngImagePlugin, features @@ -312,7 +313,7 @@ class IcnsImageFile(ImageFile.ImageFile): return px -def _save(im, fp, filename): +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: """ Saves the image as a series of PNG files, that are then combined into a .icns file. @@ -346,29 +347,27 @@ def _save(im, fp, filename): entries = [] for type, size in sizes.items(): stream = size_streams[size] - entries.append( - {"type": type, "size": HEADERSIZE + len(stream), "stream": stream} - ) + entries.append((type, HEADERSIZE + len(stream), stream)) # Header fp.write(MAGIC) file_length = HEADERSIZE # Header file_length += HEADERSIZE + 8 * len(entries) # TOC - file_length += sum(entry["size"] for entry in entries) + file_length += sum(entry[1] for entry in entries) fp.write(struct.pack(">i", file_length)) # TOC fp.write(b"TOC ") fp.write(struct.pack(">i", HEADERSIZE + len(entries) * HEADERSIZE)) for entry in entries: - fp.write(entry["type"]) - fp.write(struct.pack(">i", entry["size"])) + fp.write(entry[0]) + fp.write(struct.pack(">i", entry[1])) # Data for entry in entries: - fp.write(entry["type"]) - fp.write(struct.pack(">i", entry["size"])) - fp.write(entry["stream"]) + fp.write(entry[0]) + fp.write(struct.pack(">i", entry[1])) + fp.write(entry[2]) if hasattr(fp, "flush"): fp.flush() diff --git a/src/PIL/IcoImagePlugin.py b/src/PIL/IcoImagePlugin.py index cea093f9c..227fcf35c 100644 --- a/src/PIL/IcoImagePlugin.py +++ b/src/PIL/IcoImagePlugin.py @@ -25,6 +25,7 @@ from __future__ import annotations import warnings from io import BytesIO from math import ceil, log +from typing import IO from . import BmpImagePlugin, Image, ImageFile, PngImagePlugin from ._binary import i16le as i16 @@ -39,7 +40,7 @@ from ._binary import o32le as o32 _MAGIC = b"\0\0\1\0" -def _save(im, fp, filename): +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: fp.write(_MAGIC) # (2+2) bmp = im.encoderinfo.get("bitmap_format") == "bmp" sizes = im.encoderinfo.get( @@ -194,7 +195,7 @@ class IcoFile: """ return self.frame(self.getentryindex(size, bpp)) - def frame(self, idx): + def frame(self, idx: int) -> Image.Image: """ Get an image from frame idx """ @@ -205,6 +206,7 @@ class IcoFile: data = self.buf.read(8) self.buf.seek(header["offset"]) + im: Image.Image if data[:8] == PngImagePlugin._MAGIC: # png frame im = PngImagePlugin.PngImageFile(self.buf) diff --git a/src/PIL/ImImagePlugin.py b/src/PIL/ImImagePlugin.py index 8e949ebaf..015c2febe 100644 --- a/src/PIL/ImImagePlugin.py +++ b/src/PIL/ImImagePlugin.py @@ -28,6 +28,7 @@ from __future__ import annotations import os import re +from typing import IO, Any from . import Image, ImageFile, ImagePalette @@ -103,7 +104,7 @@ for j in range(2, 33): split = re.compile(rb"^([A-Za-z][^:]*):[ \t]*(.*)[ \t]*$") -def number(s): +def number(s: Any) -> float: try: return int(s) except ValueError: @@ -325,7 +326,7 @@ SAVE = { } -def _save(im, fp, filename): +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: try: image_type, rawmode = SAVE[im.mode] except KeyError as e: @@ -340,6 +341,8 @@ def _save(im, fp, filename): # or: SyntaxError("not an IM file") # 8 characters are used for "Name: " and "\r\n" # Keep just the filename, ditch the potentially overlong path + if isinstance(filename, bytes): + filename = filename.decode("ascii") name, ext = os.path.splitext(os.path.basename(filename)) name = "".join([name[: 92 - len(ext)], ext]) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 958b95e3b..f9152469e 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -41,7 +41,7 @@ import warnings from collections.abc import Callable, MutableMapping from enum import IntEnum from types import ModuleType -from typing import IO, TYPE_CHECKING, Any, Literal, Protocol, Sequence, cast +from typing import IO, TYPE_CHECKING, Any, Literal, Protocol, Sequence, Tuple, cast # VERSION was removed in Pillow 6.0.0. # PILLOW_VERSION was removed in Pillow 9.0.0. @@ -410,7 +410,9 @@ def init() -> bool: # Codec factories (used by tobytes/frombytes and ImageFile.load) -def _getdecoder(mode, decoder_name, args, extra=()): +def _getdecoder( + mode: str, decoder_name: str, args: Any, extra: tuple[Any, ...] = () +) -> core.ImagingDecoder | ImageFile.PyDecoder: # tweak arguments if args is None: args = () @@ -433,7 +435,9 @@ def _getdecoder(mode, decoder_name, args, extra=()): return decoder(mode, *args + extra) -def _getencoder(mode, encoder_name, args, extra=()): +def _getencoder( + mode: str, encoder_name: str, args: Any, extra: tuple[Any, ...] = () +) -> core.ImagingEncoder | ImageFile.PyEncoder: # tweak arguments if args is None: args = () @@ -503,6 +507,12 @@ def _getscaleoffset(expr): # Implementation wrapper +class SupportsGetData(Protocol): + def getdata( + self, + ) -> tuple[Transform, Sequence[int]]: ... + + class Image: """ This class represents an image object. To create @@ -544,10 +554,10 @@ class Image: return self._size @property - def mode(self): + def mode(self) -> str: return self._mode - def _new(self, im) -> Image: + def _new(self, im: core.ImagingCore) -> Image: new = Image() new.im = im new._mode = im.mode @@ -620,7 +630,7 @@ class Image: self.load() def _dump( - self, file: str | None = None, format: str | None = None, **options + self, file: str | None = None, format: str | None = None, **options: Any ) -> str: suffix = "" if format: @@ -643,10 +653,12 @@ class Image: return filename - def __eq__(self, other): + def __eq__(self, other: object) -> bool: + if self.__class__ is not other.__class__: + return False + assert isinstance(other, Image) return ( - self.__class__ is other.__class__ - and self.mode == other.mode + self.mode == other.mode and self.size == other.size and self.info == other.info and self.getpalette() == other.getpalette() @@ -679,7 +691,7 @@ class Image: ) ) - def _repr_image(self, image_format, **kwargs): + def _repr_image(self, image_format: str, **kwargs: Any) -> bytes | None: """Helper function for iPython display hook. :param image_format: Image format. @@ -692,14 +704,14 @@ class Image: return None return b.getvalue() - def _repr_png_(self): + def _repr_png_(self) -> bytes | None: """iPython display hook support for PNG format. :returns: PNG version of the image as bytes """ return self._repr_image("PNG", compress_level=1) - def _repr_jpeg_(self): + def _repr_jpeg_(self) -> bytes | None: """iPython display hook support for JPEG format. :returns: JPEG version of the image as bytes @@ -746,7 +758,7 @@ class Image: self.putpalette(palette) self.frombytes(data) - def tobytes(self, encoder_name: str = "raw", *args) -> bytes: + def tobytes(self, encoder_name: str = "raw", *args: Any) -> bytes: """ Return image as a bytes object. @@ -768,12 +780,13 @@ class Image: :returns: A :py:class:`bytes` object. """ - # may pass tuple instead of argument list - if len(args) == 1 and isinstance(args[0], tuple): - args = args[0] + encoder_args: Any = args + if len(encoder_args) == 1 and isinstance(encoder_args[0], tuple): + # may pass tuple instead of argument list + encoder_args = encoder_args[0] - if encoder_name == "raw" and args == (): - args = self.mode + if encoder_name == "raw" and encoder_args == (): + encoder_args = self.mode self.load() @@ -781,7 +794,7 @@ class Image: return b"" # unpack data - e = _getencoder(self.mode, encoder_name, args) + e = _getencoder(self.mode, encoder_name, encoder_args) e.setimage(self.im) bufsize = max(65536, self.size[0] * 4) # see RawEncode.c @@ -824,7 +837,9 @@ class Image: ] ) - def frombytes(self, data: bytes, decoder_name: str = "raw", *args) -> None: + def frombytes( + self, data: bytes | bytearray, decoder_name: str = "raw", *args: Any + ) -> None: """ Loads this image with pixel data from a bytes object. @@ -835,16 +850,17 @@ class Image: if self.width == 0 or self.height == 0: return - # may pass tuple instead of argument list - if len(args) == 1 and isinstance(args[0], tuple): - args = args[0] + decoder_args: Any = args + if len(decoder_args) == 1 and isinstance(decoder_args[0], tuple): + # may pass tuple instead of argument list + decoder_args = decoder_args[0] # default format - if decoder_name == "raw" and args == (): - args = self.mode + if decoder_name == "raw" and decoder_args == (): + decoder_args = self.mode # unpack data - d = _getdecoder(self.mode, decoder_name, args) + d = _getdecoder(self.mode, decoder_name, decoder_args) d.setimage(self.im) s = d.decode(data) @@ -873,7 +889,7 @@ class Image: if self.im is not None and self.palette and self.palette.dirty: # realize palette mode, arr = self.palette.getdata() - self.im.putpalette(mode, arr) + self.im.putpalette(self.palette.mode, mode, arr) self.palette.dirty = 0 self.palette.rawmode = None if "transparency" in self.info and mode in ("LA", "PA"): @@ -883,9 +899,9 @@ class Image: self.im.putpalettealphas(self.info["transparency"]) self.palette.mode = "RGBA" else: - palette_mode = "RGBA" if mode.startswith("RGBA") else "RGB" - self.palette.mode = palette_mode - self.palette.palette = self.im.getpalette(palette_mode, palette_mode) + self.palette.palette = self.im.getpalette( + self.palette.mode, self.palette.mode + ) if self.im is not None: if cffi and USE_CFFI_ACCESS: @@ -988,9 +1004,11 @@ class Image: if has_transparency and self.im.bands == 3: transparency = new_im.info["transparency"] - def convert_transparency(m, v): - v = m[0] * v[0] + m[1] * v[1] + m[2] * v[2] + m[3] * 0.5 - return max(0, min(255, int(v))) + def convert_transparency( + m: tuple[float, ...], v: tuple[int, int, int] + ) -> int: + value = m[0] * v[0] + m[1] * v[1] + m[2] * v[2] + m[3] * 0.5 + return max(0, min(255, int(value))) if mode == "L": transparency = convert_transparency(matrix, transparency) @@ -1150,7 +1168,7 @@ class Image: def quantize( self, colors: int = 256, - method: Quantize | None = None, + method: int | None = None, kmeans: int = 0, palette=None, dither: Dither = Dither.FLOYDSTEINBERG, @@ -1242,7 +1260,7 @@ class Image: __copy__ = copy - def crop(self, box: tuple[int, int, int, int] | None = None) -> Image: + def crop(self, box: tuple[float, float, float, float] | None = None) -> Image: """ Returns a rectangular region from this image. The box is a 4-tuple defining the left, upper, right, and lower pixel @@ -1268,7 +1286,9 @@ class Image: self.load() return self._new(self._crop(self.im, box)) - def _crop(self, im, box): + def _crop( + self, im: core.ImagingCore, box: tuple[float, float, float, float] + ) -> core.ImagingCore: """ Returns a rectangular region from the core image object im. @@ -1289,7 +1309,7 @@ class Image: return im.crop((x0, y0, x1, y1)) def draft( - self, mode: str, size: tuple[int, int] + self, mode: str | None, size: tuple[int, int] | None ) -> tuple[str, tuple[int, int, float, float]] | None: """ Configures the image file loader so it returns a version of the @@ -1359,7 +1379,7 @@ class Image: """ return ImageMode.getmode(self.mode).bands - def getbbox(self, *, alpha_only: bool = True) -> tuple[int, int, int, int]: + def getbbox(self, *, alpha_only: bool = True) -> tuple[int, int, int, int] | None: """ Calculates the bounding box of the non-zero regions in the image. @@ -1439,8 +1459,15 @@ class Image: return tuple(self.im.getband(i).getextrema() for i in range(self.im.bands)) return self.im.getextrema() - def _getxmp(self, xmp_tags): - def get_name(tag): + def getxmp(self): + """ + Returns a dictionary containing the XMP tags. + Requires defusedxml to be installed. + + :returns: XMP tags in a dictionary. + """ + + def get_name(tag: str) -> str: return re.sub("^{[^}]+}", "", tag) def get_value(element): @@ -1466,9 +1493,10 @@ class Image: if ElementTree is None: warnings.warn("XMP data cannot be read without defusedxml dependency") return {} - else: - root = ElementTree.fromstring(xmp_tags) - return {get_name(root.tag): get_value(root)} + if "xmp" not in self.info: + return {} + root = ElementTree.fromstring(self.info["xmp"]) + return {get_name(root.tag): get_value(root)} def getexif(self) -> Exif: """ @@ -1511,7 +1539,7 @@ class Image: self._exif._loaded = False self.getexif() - def get_child_images(self): + def get_child_images(self) -> list[ImageFile.ImageFile]: child_images = [] exif = self.getexif() ifds = [] @@ -1535,16 +1563,17 @@ class Image: fp = self.fp thumbnail_offset = ifd.get(513) if thumbnail_offset is not None: - try: - thumbnail_offset += self._exif_offset - except AttributeError: - pass + thumbnail_offset += getattr(self, "_exif_offset", 0) self.fp.seek(thumbnail_offset) data = self.fp.read(ifd.get(514)) fp = io.BytesIO(data) with open(fp) as im: - if thumbnail_offset is None: + from . import TiffImagePlugin + + if thumbnail_offset is None and isinstance( + im, TiffImagePlugin.TiffImageFile + ): im._frame_pos = [ifd_offset] im._seek(0) im.load() @@ -1604,7 +1633,7 @@ class Image: or "transparency" in self.info ) - def apply_transparency(self): + def apply_transparency(self) -> None: """ If a P mode image has a "transparency" key in the info dictionary, remove the key and instead apply the transparency to the palette. @@ -1616,6 +1645,7 @@ class Image: from . import ImagePalette palette = self.getpalette("RGBA") + assert palette is not None transparency = self.info["transparency"] if isinstance(transparency, bytes): for i, alpha in enumerate(transparency): @@ -1711,7 +1741,12 @@ class Image: return self.im.entropy(extrema) return self.im.entropy() - def paste(self, im, box=None, mask=None) -> None: + def paste( + self, + im: Image | str | float | tuple[float, ...], + box: Image | tuple[int, int, int, int] | tuple[int, int] | None = None, + mask: Image | None = None, + ) -> None: """ Pastes another image into this image. The box argument is either a 2-tuple giving the upper left corner, a 4-tuple defining the @@ -1739,7 +1774,7 @@ class Image: See :py:meth:`~PIL.Image.Image.alpha_composite` if you want to combine images with respect to their alpha channels. - :param im: Source image or pixel value (integer or tuple). + :param im: Source image or pixel value (integer, float or tuple). :param box: An optional 4-tuple giving the region to paste into. If a 2-tuple is used instead, it's treated as the upper left corner. If omitted or None, the source is pasted into the @@ -1751,10 +1786,14 @@ class Image: :param mask: An optional mask image. """ - if isImageType(box) and mask is None: + if isImageType(box): + if mask is not None: + msg = "If using second argument as mask, third argument must be None" + raise ValueError(msg) # abbreviated paste(im, mask) syntax mask = box box = None + assert not isinstance(box, Image) if box is None: box = (0, 0) @@ -1792,7 +1831,9 @@ class Image: else: self.im.paste(im, box) - def alpha_composite(self, im, dest=(0, 0), source=(0, 0)): + def alpha_composite( + self, im: Image, dest: Sequence[int] = (0, 0), source: Sequence[int] = (0, 0) + ) -> None: """'In-place' analog of Image.alpha_composite. Composites an image onto this image. @@ -1807,32 +1848,35 @@ class Image: """ if not isinstance(source, (list, tuple)): - msg = "Source must be a tuple" + msg = "Source must be a list or tuple" raise ValueError(msg) if not isinstance(dest, (list, tuple)): - msg = "Destination must be a tuple" + msg = "Destination must be a list or tuple" raise ValueError(msg) - if len(source) not in (2, 4): - msg = "Source must be a 2 or 4-tuple" + + if len(source) == 4: + overlay_crop_box = tuple(source) + elif len(source) == 2: + overlay_crop_box = tuple(source) + im.size + else: + msg = "Source must be a sequence of length 2 or 4" raise ValueError(msg) + if not len(dest) == 2: - msg = "Destination must be a 2-tuple" + msg = "Destination must be a sequence of length 2" raise ValueError(msg) if min(source) < 0: msg = "Source must be non-negative" raise ValueError(msg) - if len(source) == 2: - source = source + im.size - - # over image, crop if it's not the whole thing. - if source == (0, 0) + im.size: + # over image, crop if it's not the whole image. + if overlay_crop_box == (0, 0) + im.size: overlay = im else: - overlay = im.crop(source) + overlay = im.crop(overlay_crop_box) # target for the paste - box = dest + (dest[0] + overlay.width, dest[1] + overlay.height) + box = tuple(dest) + (dest[0] + overlay.width, dest[1] + overlay.height) # destination image. don't copy if we're using the whole image. if box == (0, 0) + self.size: @@ -1843,7 +1887,11 @@ class Image: result = alpha_composite(background, overlay) self.paste(result, box) - def point(self, lut, mode: str | None = None) -> Image: + def point( + self, + lut: Sequence[float] | Callable[[int], float] | ImagePointHandler, + mode: str | None = None, + ) -> Image: """ Maps this image through a lookup table or function. @@ -1880,7 +1928,9 @@ class Image: scale, offset = _getscaleoffset(lut) return self._new(self.im.point_transform(scale, offset)) # for other modes, convert the function to a table - lut = [lut(i) for i in range(256)] * self.im.bands + flatLut = [lut(i) for i in range(256)] * self.im.bands + else: + flatLut = lut if self.mode == "F": # FIXME: _imaging returns a confusing error message for this case @@ -1888,8 +1938,8 @@ class Image: raise ValueError(msg) if mode != "F": - lut = [round(i) for i in lut] - return self._new(self.im.point(lut, mode)) + flatLut = [round(i) for i in flatLut] + return self._new(self.im.point(flatLut, mode)) def putalpha(self, alpha): """ @@ -1948,7 +1998,12 @@ class Image: self.im.putband(alpha.im, band) - def putdata(self, data, scale=1.0, offset=0.0): + def putdata( + self, + data: Sequence[float] | Sequence[Sequence[int]], + scale: float = 1.0, + offset: float = 0.0, + ) -> None: """ Copies pixel data from a flattened sequence object into the image. The values should start at the upper left corner (0, 0), continue to the @@ -1998,7 +2053,7 @@ class Image: palette = ImagePalette.raw(rawmode, data) self._mode = "PA" if "A" in self.mode else "P" self.palette = palette - self.palette.mode = "RGB" + self.palette.mode = "RGBA" if "A" in rawmode else "RGB" self.load() # install new palette def putpixel(self, xy, value): @@ -2113,7 +2168,7 @@ class Image: # m_im.putpalette(mapping_palette, 'L') # converts to 'P' # or just force it. # UNDONE -- this is part of the general issue with palettes - m_im.im.putpalette(palette_mode + ";L", m_im.palette.tobytes()) + m_im.im.putpalette(palette_mode, palette_mode + ";L", m_im.palette.tobytes()) m_im = m_im.convert("L") @@ -2146,7 +2201,13 @@ class Image: min(self.size[1], math.ceil(box[3] + support_y)), ) - def resize(self, size, resample=None, box=None, reducing_gap=None) -> Image: + def resize( + self, + size: tuple[int, int], + resample: int | None = None, + box: tuple[float, float, float, float] | None = None, + reducing_gap: float | None = None, + ) -> Image: """ Returns a resized copy of this image. @@ -2211,13 +2272,9 @@ class Image: msg = "reducing_gap must be 1.0 or greater" raise ValueError(msg) - size = tuple(size) - self.load() if box is None: box = (0, 0) + self.size - else: - box = tuple(box) if self.size == size and box == (0, 0) + self.size: return self.copy() @@ -2252,7 +2309,11 @@ class Image: return self._new(self.im.resize(size, resample, box)) - def reduce(self, factor, box=None): + def reduce( + self, + factor: int | tuple[int, int], + box: tuple[int, int, int, int] | None = None, + ) -> Image: """ Returns a copy of the image reduced ``factor`` times. If the size of the image is not dividable by ``factor``, @@ -2270,8 +2331,6 @@ class Image: if box is None: box = (0, 0) + self.size - else: - box = tuple(box) if factor == (1, 1) and box == (0, 0) + self.size: return self.copy() @@ -2287,13 +2346,13 @@ class Image: def rotate( self, - angle, - resample=Resampling.NEAREST, - expand=0, - center=None, - translate=None, - fillcolor=None, - ): + angle: float, + resample: Resampling = Resampling.NEAREST, + expand: int | bool = False, + center: tuple[int, int] | None = None, + translate: tuple[int, int] | None = None, + fillcolor: float | tuple[float, ...] | str | None = None, + ) -> Image: """ Returns a rotated copy of this image. This method returns a copy of this image, rotated the given number of degrees counter @@ -2455,7 +2514,7 @@ class Image: save_all = params.pop("save_all", False) self.encoderinfo = params - self.encoderconfig = () + self.encoderconfig: tuple[Any, ...] = () preinit() @@ -2600,7 +2659,12 @@ class Image: """ return 0 - def thumbnail(self, size, resample=Resampling.BICUBIC, reducing_gap=2.0): + def thumbnail( + self, + size: tuple[float, float], + resample: Resampling = Resampling.BICUBIC, + reducing_gap: float | None = 2.0, + ) -> None: """ Make this image into a thumbnail. This method modifies the image to contain a thumbnail version of itself, no larger than @@ -2660,27 +2724,32 @@ class Image: return x, y box = None + final_size: tuple[int, int] if reducing_gap is not None: - size = preserve_aspect_ratio() - if size is None: + preserved_size = preserve_aspect_ratio() + if preserved_size is None: return + final_size = preserved_size - res = self.draft(None, (size[0] * reducing_gap, size[1] * reducing_gap)) + res = self.draft( + None, (int(size[0] * reducing_gap), int(size[1] * reducing_gap)) + ) if res is not None: box = res[1] if box is None: self.load() # load() may have changed the size of the image - size = preserve_aspect_ratio() - if size is None: + preserved_size = preserve_aspect_ratio() + if preserved_size is None: return + final_size = preserved_size - if self.size != size: - im = self.resize(size, resample, box=box, reducing_gap=reducing_gap) + if self.size != final_size: + im = self.resize(final_size, resample, box=box, reducing_gap=reducing_gap) self.im = im.im - self._size = size + self._size = final_size self._mode = self.im.mode self.readonly = 0 @@ -2690,12 +2759,12 @@ class Image: # instead of bloating the method docs, add a separate chapter. def transform( self, - size, - method, - data=None, - resample=Resampling.NEAREST, - fill=1, - fillcolor=None, + size: tuple[int, int], + method: Transform | ImageTransformHandler | SupportsGetData, + data: Sequence[Any] | None = None, + resample: int = Resampling.NEAREST, + fill: int = 1, + fillcolor: float | tuple[float, ...] | str | None = None, ) -> Image: """ Transforms this image. This method creates a new image with the @@ -2859,7 +2928,7 @@ class Image: if image.mode in ("1", "P"): resample = Resampling.NEAREST - self.im.transform2(box, image.im, method, data, resample, fill) + self.im.transform(box, image.im, method, data, resample, fill) def transpose(self, method: Transpose) -> Image: """ @@ -2875,7 +2944,7 @@ class Image: self.load() return self._new(self.im.transpose(method)) - def effect_spread(self, distance): + def effect_spread(self, distance: int) -> Image: """ Randomly spread pixels in an image. @@ -2929,7 +2998,7 @@ class ImageTransformHandler: self, size: tuple[int, int], image: Image, - **options: dict[str, str | int | tuple[int, ...] | list[int]], + **options: Any, ) -> Image: pass @@ -2941,35 +3010,35 @@ class ImageTransformHandler: # Debugging -def _wedge(): +def _wedge() -> Image: """Create grayscale wedge (for debugging only)""" return Image()._new(core.wedge("L")) -def _check_size(size): +def _check_size(size: Any) -> None: """ Common check to enforce type and sanity check on size tuples :param size: Should be a 2 tuple of (width, height) - :returns: True, or raises a ValueError + :returns: None, or raises a ValueError """ if not isinstance(size, (list, tuple)): - msg = "Size must be a tuple" + msg = "Size must be a list or tuple" raise ValueError(msg) if len(size) != 2: - msg = "Size must be a tuple of length 2" + msg = "Size must be a sequence of length 2" raise ValueError(msg) if size[0] < 0 or size[1] < 0: msg = "Width and height must be >= 0" raise ValueError(msg) - return True - def new( - mode: str, size: tuple[int, int], color: float | tuple[float, ...] | str | None = 0 + mode: str, + size: tuple[int, int] | list[int], + color: float | tuple[float, ...] | str | None = 0, ) -> Image: """ Creates a new image with the given mode and size. @@ -3003,16 +3072,28 @@ def new( color = ImageColor.getcolor(color, mode) im = Image() - if mode == "P" and isinstance(color, (list, tuple)) and len(color) in [3, 4]: - # RGB or RGBA value for a P image - from . import ImagePalette + if ( + mode == "P" + and isinstance(color, (list, tuple)) + and all(isinstance(i, int) for i in color) + ): + color_ints: tuple[int, ...] = cast(Tuple[int, ...], tuple(color)) + if len(color_ints) == 3 or len(color_ints) == 4: + # RGB or RGBA value for a P image + from . import ImagePalette - im.palette = ImagePalette.ImagePalette() - color = im.palette.getcolor(color) + im.palette = ImagePalette.ImagePalette() + color = im.palette.getcolor(color_ints) return im._new(core.fill(mode, size, color)) -def frombytes(mode, size, data, decoder_name="raw", *args) -> Image: +def frombytes( + mode: str, + size: tuple[int, int], + data: bytes | bytearray, + decoder_name: str = "raw", + *args: Any, +) -> Image: """ Creates a copy of an image memory from pixel data in a buffer. @@ -3040,18 +3121,21 @@ def frombytes(mode, size, data, decoder_name="raw", *args) -> Image: im = new(mode, size) if im.width != 0 and im.height != 0: - # may pass tuple instead of argument list - if len(args) == 1 and isinstance(args[0], tuple): - args = args[0] + decoder_args: Any = args + if len(decoder_args) == 1 and isinstance(decoder_args[0], tuple): + # may pass tuple instead of argument list + decoder_args = decoder_args[0] - if decoder_name == "raw" and args == (): - args = mode + if decoder_name == "raw" and decoder_args == (): + decoder_args = mode - im.frombytes(data, decoder_name, args) + im.frombytes(data, decoder_name, decoder_args) return im -def frombuffer(mode, size, data, decoder_name="raw", *args) -> Image: +def frombuffer( + mode: str, size: tuple[int, int], data, decoder_name: str = "raw", *args: Any +) -> Image: """ Creates an image memory referencing pixel data in a byte buffer. @@ -3508,7 +3592,7 @@ def merge(mode: str, bands: Sequence[Image]) -> Image: def register_open( - id, + id: str, factory: Callable[[IO[bytes], str | bytes], ImageFile.ImageFile], accept: Callable[[bytes], bool | str] | None = None, ) -> None: @@ -3542,7 +3626,9 @@ def register_mime(id: str, mimetype: str) -> None: MIME[id.upper()] = mimetype -def register_save(id: str, driver) -> None: +def register_save( + id: str, driver: Callable[[Image, IO[bytes], str | bytes], None] +) -> None: """ Registers an image save function. This function should not be used in application code. @@ -3553,7 +3639,9 @@ def register_save(id: str, driver) -> None: SAVE[id.upper()] = driver -def register_save_all(id, driver) -> None: +def register_save_all( + id: str, driver: Callable[[Image, IO[bytes], str | bytes], None] +) -> None: """ Registers an image function to save all the frames of a multiframe format. This function should not be @@ -3565,7 +3653,7 @@ def register_save_all(id, driver) -> None: SAVE_ALL[id.upper()] = driver -def register_extension(id, extension) -> None: +def register_extension(id: str, extension: str) -> None: """ Registers an image extension. This function should not be used in application code. @@ -3576,7 +3664,7 @@ def register_extension(id, extension) -> None: EXTENSION[extension.lower()] = id.upper() -def register_extensions(id, extensions) -> None: +def register_extensions(id: str, extensions: list[str]) -> None: """ Registers image extensions. This function should not be used in application code. @@ -3588,7 +3676,7 @@ def register_extensions(id, extensions) -> None: register_extension(id, extension) -def registered_extensions(): +def registered_extensions() -> dict[str, str]: """ Returns a dictionary containing all file extensions belonging to registered plugins @@ -3627,7 +3715,7 @@ def register_encoder(name: str, encoder: type[ImageFile.PyEncoder]) -> None: # Simple display support. -def _show(image, **options) -> None: +def _show(image: Image, **options: Any) -> None: from . import ImageShow ImageShow.show(image, **options) @@ -3637,7 +3725,9 @@ def _show(image, **options) -> None: # Effects -def effect_mandelbrot(size, extent, quality): +def effect_mandelbrot( + size: tuple[int, int], extent: tuple[float, float, float, float], quality: int +) -> Image: """ Generate a Mandelbrot set covering the given extent. @@ -3650,7 +3740,7 @@ def effect_mandelbrot(size, extent, quality): return Image()._new(core.effect_mandelbrot(size, extent, quality)) -def effect_noise(size, sigma): +def effect_noise(size: tuple[int, int], sigma: float) -> Image: """ Generate Gaussian noise centered around 128. @@ -3661,7 +3751,7 @@ def effect_noise(size, sigma): return Image()._new(core.effect_noise(size, sigma)) -def linear_gradient(mode): +def linear_gradient(mode: str) -> Image: """ Generate 256x256 linear gradient from black to white, top to bottom. @@ -3670,7 +3760,7 @@ def linear_gradient(mode): return Image()._new(core.linear_gradient(mode)) -def radial_gradient(mode): +def radial_gradient(mode: str) -> Image: """ Generate 256x256 radial gradient from black to white, centre to edge. @@ -3683,19 +3773,18 @@ def radial_gradient(mode): # Resources -def _apply_env_variables(env=None) -> None: - if env is None: - env = os.environ +def _apply_env_variables(env: dict[str, str] | None = None) -> None: + env_dict = env if env is not None else os.environ for var_name, setter in [ ("PILLOW_ALIGNMENT", core.set_alignment), ("PILLOW_BLOCK_SIZE", core.set_block_size), ("PILLOW_BLOCKS_MAX", core.set_blocks_max), ]: - if var_name not in env: + if var_name not in env_dict: continue - var = env[var_name].lower() + var = env_dict[var_name].lower() units = 1 for postfix, mul in [("k", 1024), ("m", 1024 * 1024)]: @@ -3704,13 +3793,13 @@ def _apply_env_variables(env=None) -> None: var = var[: -len(postfix)] try: - var = int(var) * units + var_int = int(var) * units except ValueError: warnings.warn(f"{var_name} is not int") continue try: - setter(var) + setter(var_int) except ValueError as e: warnings.warn(f"{var_name}: {e}") diff --git a/src/PIL/ImageCms.py b/src/PIL/ImageCms.py index 5f5c5df54..19a79facc 100644 --- a/src/PIL/ImageCms.py +++ b/src/PIL/ImageCms.py @@ -754,7 +754,7 @@ def applyTransform( def createProfile( - colorSpace: Literal["LAB", "XYZ", "sRGB"], colorTemp: SupportsFloat = -1 + colorSpace: Literal["LAB", "XYZ", "sRGB"], colorTemp: SupportsFloat = 0 ) -> core.CmsProfile: """ (pyCMS) Creates a profile. @@ -777,7 +777,7 @@ def createProfile( :param colorSpace: String, the color space of the profile you wish to create. Currently only "LAB", "XYZ", and "sRGB" are supported. - :param colorTemp: Positive integer for the white point for the profile, in + :param colorTemp: Positive number for the white point for the profile, in degrees Kelvin (i.e. 5000, 6500, 9600, etc.). The default is for D50 illuminant if omitted (5000k). colorTemp is ONLY applied to LAB profiles, and is ignored for XYZ and sRGB. @@ -1089,7 +1089,7 @@ def isIntentSupported( raise PyCMSError(v) from v -def versions() -> tuple[str, str, str, str]: +def versions() -> tuple[str, str | None, str, str]: """ (pyCMS) Fetches versions. """ diff --git a/src/PIL/ImageColor.py b/src/PIL/ImageColor.py index 5fb80b753..9a15a8eb7 100644 --- a/src/PIL/ImageColor.py +++ b/src/PIL/ImageColor.py @@ -25,7 +25,7 @@ from . import Image @lru_cache -def getrgb(color): +def getrgb(color: str) -> tuple[int, int, int] | tuple[int, int, int, int]: """ Convert a color string to an RGB or RGBA tuple. If the string cannot be parsed, this function raises a :py:exc:`ValueError` exception. @@ -44,8 +44,10 @@ def getrgb(color): if rgb: if isinstance(rgb, tuple): return rgb - colormap[color] = rgb = getrgb(rgb) - return rgb + rgb_tuple = getrgb(rgb) + assert len(rgb_tuple) == 3 + colormap[color] = rgb_tuple + return rgb_tuple # check for known string formats if re.match("#[a-f0-9]{3}$", color): @@ -88,15 +90,15 @@ def getrgb(color): if m: from colorsys import hls_to_rgb - rgb = hls_to_rgb( + rgb_floats = hls_to_rgb( float(m.group(1)) / 360.0, float(m.group(3)) / 100.0, float(m.group(2)) / 100.0, ) return ( - int(rgb[0] * 255 + 0.5), - int(rgb[1] * 255 + 0.5), - int(rgb[2] * 255 + 0.5), + int(rgb_floats[0] * 255 + 0.5), + int(rgb_floats[1] * 255 + 0.5), + int(rgb_floats[2] * 255 + 0.5), ) m = re.match( @@ -105,15 +107,15 @@ def getrgb(color): if m: from colorsys import hsv_to_rgb - rgb = hsv_to_rgb( + rgb_floats = hsv_to_rgb( float(m.group(1)) / 360.0, float(m.group(2)) / 100.0, float(m.group(3)) / 100.0, ) return ( - int(rgb[0] * 255 + 0.5), - int(rgb[1] * 255 + 0.5), - int(rgb[2] * 255 + 0.5), + int(rgb_floats[0] * 255 + 0.5), + int(rgb_floats[1] * 255 + 0.5), + int(rgb_floats[2] * 255 + 0.5), ) m = re.match(r"rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$", color) @@ -124,7 +126,7 @@ def getrgb(color): @lru_cache -def getcolor(color, mode: str) -> tuple[int, ...]: +def getcolor(color: str, mode: str) -> int | tuple[int, ...]: """ Same as :py:func:`~PIL.ImageColor.getrgb` for most modes. However, if ``mode`` is HSV, converts the RGB value to a HSV value, or if ``mode`` is @@ -136,33 +138,34 @@ def getcolor(color, mode: str) -> tuple[int, ...]: :param color: A color string :param mode: Convert result to this mode - :return: ``(graylevel[, alpha]) or (red, green, blue[, alpha])`` + :return: ``graylevel, (graylevel, alpha) or (red, green, blue[, alpha])`` """ # same as getrgb, but converts the result to the given mode - color, alpha = getrgb(color), 255 - if len(color) == 4: - color, alpha = color[:3], color[3] + rgb, alpha = getrgb(color), 255 + if len(rgb) == 4: + alpha = rgb[3] + rgb = rgb[:3] if mode == "HSV": from colorsys import rgb_to_hsv - r, g, b = color + r, g, b = rgb h, s, v = rgb_to_hsv(r / 255, g / 255, b / 255) return int(h * 255), int(s * 255), int(v * 255) elif Image.getmodebase(mode) == "L": - r, g, b = color + r, g, b = rgb # ITU-R Recommendation 601-2 for nonlinear RGB # scaled to 24 bits to match the convert's implementation. - color = (r * 19595 + g * 38470 + b * 7471 + 0x8000) >> 16 + graylevel = (r * 19595 + g * 38470 + b * 7471 + 0x8000) >> 16 if mode[-1] == "A": - return color, alpha - else: - if mode[-1] == "A": - return color + (alpha,) - return color + return graylevel, alpha + return graylevel + elif mode[-1] == "A": + return rgb + (alpha,) + return rgb -colormap = { +colormap: dict[str, str | tuple[int, int, int]] = { # X11 colour table from https://drafts.csswg.org/css-color-4/, with # gray/grey spelling issues fixed. This is a superset of HTML 4.0 # colour names used in CSS 1. diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index 42f2ee8c7..a471a7ffd 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -34,11 +34,25 @@ from __future__ import annotations import math import numbers import struct -from typing import TYPE_CHECKING, Sequence, cast +from types import ModuleType +from typing import TYPE_CHECKING, AnyStr, Callable, List, Sequence, Tuple, Union, cast from . import Image, ImageColor +from ._deprecate import deprecate from ._typing import Coords +# experimental access to the outline API +Outline: Callable[[], Image.core._Outline] | None +try: + Outline = Image.core.outline +except AttributeError: + Outline = None + +if TYPE_CHECKING: + from . import ImageDraw2, ImageFont + +_Ink = Union[float, Tuple[int, ...], str] + """ A simple 2D drawing interface for PIL images.

@@ -48,7 +62,9 @@ directly. class ImageDraw: - font = None + font: ( + ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont | None + ) = None def __init__(self, im: Image.Image, mode: str | None = None) -> None: """ @@ -92,10 +108,9 @@ class ImageDraw: self.fontmode = "L" # aliasing is okay for other modes self.fill = False - if TYPE_CHECKING: - from . import ImageFont - - def getfont(self) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: + def getfont( + self, + ) -> ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont: """ Get the current default font. @@ -120,43 +135,57 @@ class ImageDraw: self.font = ImageFont.load_default() return self.font - def _getfont(self, font_size: float | None): + def _getfont( + self, font_size: float | None + ) -> ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont: if font_size is not None: from . import ImageFont - font = ImageFont.load_default(font_size) + return ImageFont.load_default(font_size) else: - font = self.getfont() - return font + return self.getfont() - def _getink(self, ink, fill=None) -> tuple[int | None, int | None]: + def _getink( + self, ink: _Ink | None, fill: _Ink | None = None + ) -> tuple[int | None, int | None]: + result_ink = None + result_fill = None if ink is None and fill is None: if self.fill: - fill = self.ink + result_fill = self.ink else: - ink = self.ink + result_ink = self.ink else: if ink is not None: if isinstance(ink, str): ink = ImageColor.getcolor(ink, self.mode) if self.palette and not isinstance(ink, numbers.Number): ink = self.palette.getcolor(ink, self._image) - ink = self.draw.draw_ink(ink) + result_ink = self.draw.draw_ink(ink) if fill is not None: if isinstance(fill, str): fill = ImageColor.getcolor(fill, self.mode) if self.palette and not isinstance(fill, numbers.Number): fill = self.palette.getcolor(fill, self._image) - fill = self.draw.draw_ink(fill) - return ink, fill + result_fill = self.draw.draw_ink(fill) + return result_ink, result_fill - def arc(self, xy: Coords, start, end, fill=None, width=1) -> None: + def arc( + self, + xy: Coords, + start: float, + end: float, + fill: _Ink | None = None, + width: int = 1, + ) -> None: """Draw an arc.""" ink, fill = self._getink(fill) if ink is not None: self.draw.draw_arc(xy, start, end, ink, width) - def bitmap(self, xy: Sequence[int], bitmap, fill=None) -> None: + def bitmap( + self, xy: Sequence[int], bitmap: Image.Image, fill: _Ink | None = None + ) -> None: """Draw a bitmap.""" bitmap.load() ink, fill = self._getink(fill) @@ -165,23 +194,55 @@ class ImageDraw: if ink is not None: self.draw.draw_bitmap(xy, bitmap.im, ink) - def chord(self, xy: Coords, start, end, fill=None, outline=None, width=1) -> None: + def chord( + self, + xy: Coords, + start: float, + end: float, + fill: _Ink | None = None, + outline: _Ink | None = None, + width: int = 1, + ) -> None: """Draw a chord.""" - ink, fill = self._getink(outline, fill) - if fill is not None: - self.draw.draw_chord(xy, start, end, fill, 1) - if ink is not None and ink != fill and width != 0: + ink, fill_ink = self._getink(outline, fill) + if fill_ink is not None: + self.draw.draw_chord(xy, start, end, fill_ink, 1) + if ink is not None and ink != fill_ink and width != 0: self.draw.draw_chord(xy, start, end, ink, 0, width) - def ellipse(self, xy: Coords, fill=None, outline=None, width=1) -> None: + def ellipse( + self, + xy: Coords, + fill: _Ink | None = None, + outline: _Ink | None = None, + width: int = 1, + ) -> None: """Draw an ellipse.""" - ink, fill = self._getink(outline, fill) - if fill is not None: - self.draw.draw_ellipse(xy, fill, 1) - if ink is not None and ink != fill and width != 0: + ink, fill_ink = self._getink(outline, fill) + if fill_ink is not None: + self.draw.draw_ellipse(xy, fill_ink, 1) + if ink is not None and ink != fill_ink and width != 0: self.draw.draw_ellipse(xy, ink, 0, width) - def line(self, xy: Coords, fill=None, width=0, joint=None) -> None: + def circle( + self, + xy: Sequence[float], + radius: float, + fill: _Ink | None = None, + outline: _Ink | None = None, + width: int = 1, + ) -> None: + """Draw a circle given center coordinates and a radius.""" + ellipse_xy = (xy[0] - radius, xy[1] - radius, xy[0] + radius, xy[1] + radius) + self.ellipse(ellipse_xy, fill, outline, width) + + def line( + self, + xy: Coords, + fill: _Ink | None = None, + width: int = 0, + joint: str | None = None, + ) -> None: """Draw a line, or a connected sequence of line segments.""" ink = self._getink(fill)[0] if ink is not None: @@ -209,7 +270,9 @@ class ImageDraw: # This is a straight line, so no joint is required continue - def coord_at_angle(coord, angle): + def coord_at_angle( + coord: Sequence[float], angle: float + ) -> tuple[float, ...]: x, y = coord angle -= 90 distance = width / 2 - 1 @@ -250,37 +313,54 @@ class ImageDraw: ] self.line(gap_coords, fill, width=3) - def shape(self, shape, fill=None, outline=None) -> None: + def shape( + self, + shape: Image.core._Outline, + fill: _Ink | None = None, + outline: _Ink | None = None, + ) -> None: """(Experimental) Draw a shape.""" shape.close() - ink, fill = self._getink(outline, fill) - if fill is not None: - self.draw.draw_outline(shape, fill, 1) - if ink is not None and ink != fill: + ink, fill_ink = self._getink(outline, fill) + if fill_ink is not None: + self.draw.draw_outline(shape, fill_ink, 1) + if ink is not None and ink != fill_ink: self.draw.draw_outline(shape, ink, 0) def pieslice( - self, xy: Coords, start, end, fill=None, outline=None, width=1 + self, + xy: Coords, + start: float, + end: float, + fill: _Ink | None = None, + outline: _Ink | None = None, + width: int = 1, ) -> None: """Draw a pieslice.""" - ink, fill = self._getink(outline, fill) - if fill is not None: - self.draw.draw_pieslice(xy, start, end, fill, 1) - if ink is not None and ink != fill and width != 0: + ink, fill_ink = self._getink(outline, fill) + if fill_ink is not None: + self.draw.draw_pieslice(xy, start, end, fill_ink, 1) + if ink is not None and ink != fill_ink and width != 0: self.draw.draw_pieslice(xy, start, end, ink, 0, width) - def point(self, xy: Coords, fill=None) -> None: + def point(self, xy: Coords, fill: _Ink | None = None) -> None: """Draw one or more individual pixels.""" ink, fill = self._getink(fill) if ink is not None: self.draw.draw_points(xy, ink) - def polygon(self, xy: Coords, fill=None, outline=None, width=1) -> None: + def polygon( + self, + xy: Coords, + fill: _Ink | None = None, + outline: _Ink | None = None, + width: int = 1, + ) -> None: """Draw a polygon.""" - ink, fill = self._getink(outline, fill) - if fill is not None: - self.draw.draw_polygon(xy, fill, 1) - if ink is not None and ink != fill and width != 0: + ink, fill_ink = self._getink(outline, fill) + if fill_ink is not None: + self.draw.draw_polygon(xy, fill_ink, 1) + if ink is not None and ink != fill_ink and width != 0: if width == 1: self.draw.draw_polygon(xy, ink, 0, width) elif self.im is not None: @@ -306,22 +386,41 @@ class ImageDraw: self.im.paste(im.im, (0, 0) + im.size, mask.im) def regular_polygon( - self, bounding_circle, n_sides, rotation=0, fill=None, outline=None, width=1 + self, + bounding_circle: Sequence[Sequence[float] | float], + n_sides: int, + rotation: float = 0, + fill: _Ink | None = None, + outline: _Ink | None = None, + width: int = 1, ) -> None: """Draw a regular polygon.""" xy = _compute_regular_polygon_vertices(bounding_circle, n_sides, rotation) self.polygon(xy, fill, outline, width) - def rectangle(self, xy: Coords, fill=None, outline=None, width=1) -> None: + def rectangle( + self, + xy: Coords, + fill: _Ink | None = None, + outline: _Ink | None = None, + width: int = 1, + ) -> None: """Draw a rectangle.""" - ink, fill = self._getink(outline, fill) - if fill is not None: - self.draw.draw_rectangle(xy, fill, 1) - if ink is not None and ink != fill and width != 0: + ink, fill_ink = self._getink(outline, fill) + if fill_ink is not None: + self.draw.draw_rectangle(xy, fill_ink, 1) + if ink is not None and ink != fill_ink and width != 0: self.draw.draw_rectangle(xy, ink, 0, width) def rounded_rectangle( - self, xy: Coords, radius=0, fill=None, outline=None, width=1, *, corners=None + self, + xy: Coords, + radius: float = 0, + fill: _Ink | None = None, + outline: _Ink | None = None, + width: int = 1, + *, + corners: tuple[bool, bool, bool, bool] | None = None, ) -> None: """Draw a rounded rectangle.""" if isinstance(xy[0], (list, tuple)): @@ -363,10 +462,10 @@ class ImageDraw: # that is a rectangle return self.rectangle(xy, fill, outline, width) - r = d // 2 - ink, fill = self._getink(outline, fill) + r = int(d // 2) + ink, fill_ink = self._getink(outline, fill) - def draw_corners(pieslice) -> None: + def draw_corners(pieslice: bool) -> None: parts: tuple[tuple[tuple[float, float, float, float], int, int], ...] if full_x: # Draw top and bottom halves @@ -396,32 +495,32 @@ class ImageDraw: ) for part in parts: if pieslice: - self.draw.draw_pieslice(*(part + (fill, 1))) + self.draw.draw_pieslice(*(part + (fill_ink, 1))) else: self.draw.draw_arc(*(part + (ink, width))) - if fill is not None: + if fill_ink is not None: draw_corners(True) if full_x: - self.draw.draw_rectangle((x0, y0 + r + 1, x1, y1 - r - 1), fill, 1) + self.draw.draw_rectangle((x0, y0 + r + 1, x1, y1 - r - 1), fill_ink, 1) else: - self.draw.draw_rectangle((x0 + r + 1, y0, x1 - r - 1, y1), fill, 1) + self.draw.draw_rectangle((x0 + r + 1, y0, x1 - r - 1, y1), fill_ink, 1) if not full_x and not full_y: left = [x0, y0, x0 + r, y1] if corners[0]: left[1] += r + 1 if corners[3]: left[3] -= r + 1 - self.draw.draw_rectangle(left, fill, 1) + self.draw.draw_rectangle(left, fill_ink, 1) right = [x1 - r, y0, x1, y1] if corners[1]: right[1] += r + 1 if corners[2]: right[3] -= r + 1 - self.draw.draw_rectangle(right, fill, 1) - if ink is not None and ink != fill and width != 0: + self.draw.draw_rectangle(right, fill_ink, 1) + if ink is not None and ink != fill_ink and width != 0: draw_corners(False) if not full_x: @@ -453,15 +552,13 @@ class ImageDraw: right[3] -= r + 1 self.draw.draw_rectangle(right, ink, 1) - def _multiline_check(self, text) -> bool: + def _multiline_check(self, text: AnyStr) -> bool: split_character = "\n" if isinstance(text, str) else b"\n" return split_character in text - def _multiline_split(self, text) -> list[str | bytes]: - split_character = "\n" if isinstance(text, str) else b"\n" - - return text.split(split_character) + def _multiline_split(self, text: AnyStr) -> list[AnyStr]: + return text.split("\n" if isinstance(text, str) else b"\n") def _multiline_spacing(self, font, spacing, stroke_width): return ( @@ -472,10 +569,15 @@ class ImageDraw: def text( self, - xy, - text, + xy: tuple[float, float], + text: str, fill=None, - font=None, + font: ( + ImageFont.ImageFont + | ImageFont.FreeTypeFont + | ImageFont.TransposedFont + | None + ) = None, anchor=None, spacing=4, align="left", @@ -513,10 +615,11 @@ class ImageDraw: embedded_color, ) - def getink(fill): - ink, fill = self._getink(fill) + def getink(fill: _Ink | None) -> int: + ink, fill_ink = self._getink(fill) if ink is None: - return fill + assert fill_ink is not None + return fill_ink return ink def draw_text(ink, stroke_width=0, stroke_offset=None) -> None: @@ -529,7 +632,7 @@ class ImageDraw: coord.append(int(xy[i])) start.append(math.modf(xy[i])[0]) try: - mask, offset = font.getmask2( + mask, offset = font.getmask2( # type: ignore[union-attr,misc] text, mode, direction=direction, @@ -545,7 +648,7 @@ class ImageDraw: coord = [coord[0] + offset[0], coord[1] + offset[1]] except AttributeError: try: - mask = font.getmask( + mask = font.getmask( # type: ignore[misc] text, mode, direction, @@ -594,10 +697,15 @@ class ImageDraw: def multiline_text( self, - xy, - text, + xy: tuple[float, float], + text: str, fill=None, - font=None, + font: ( + ImageFont.ImageFont + | ImageFont.FreeTypeFont + | ImageFont.TransposedFont + | None + ) = None, anchor=None, spacing=4, align="left", @@ -627,7 +735,7 @@ class ImageDraw: font = self._getfont(font_size) widths = [] - max_width = 0 + max_width: float = 0 lines = self._multiline_split(text) line_spacing = self._multiline_spacing(font, spacing, stroke_width) for line in lines: @@ -681,15 +789,20 @@ class ImageDraw: def textlength( self, - text, - font=None, + text: str, + font: ( + ImageFont.ImageFont + | ImageFont.FreeTypeFont + | ImageFont.TransposedFont + | None + ) = None, direction=None, features=None, language=None, embedded_color=False, *, font_size=None, - ): + ) -> float: """Get the length of a given string, in pixels with 1/64 precision.""" if self._multiline_check(text): msg = "can't measure length of multiline text" @@ -781,7 +894,7 @@ class ImageDraw: font = self._getfont(font_size) widths = [] - max_width = 0 + max_width: float = 0 lines = self._multiline_split(text) line_spacing = self._multiline_spacing(font, spacing, stroke_width) for line in lines: @@ -853,7 +966,7 @@ class ImageDraw: return bbox -def Draw(im, mode: str | None = None) -> ImageDraw: +def Draw(im: Image.Image, mode: str | None = None) -> ImageDraw: """ A simple 2D drawing interface for PIL images. @@ -865,43 +978,34 @@ def Draw(im, mode: str | None = None) -> ImageDraw: defaults to the mode of the image. """ try: - return im.getdraw(mode) + return getattr(im, "getdraw")(mode) except AttributeError: return ImageDraw(im, mode) -# experimental access to the outline API -try: - Outline = Image.core.outline -except AttributeError: - Outline = None - - -def getdraw(im=None, hints=None): +def getdraw( + im: Image.Image | None = None, hints: list[str] | None = None +) -> tuple[ImageDraw2.Draw | None, ModuleType]: """ - (Experimental) A more advanced 2D drawing interface for PIL images, - based on the WCK interface. - :param im: The image to draw in. - :param hints: An optional list of hints. + :param hints: An optional list of hints. Deprecated. :returns: A (drawing context, drawing resource factory) tuple. """ - # FIXME: this needs more work! - # FIXME: come up with a better 'hints' scheme. - handler = None - if not hints or "nicest" in hints: - try: - from . import _imagingagg as handler - except ImportError: - pass - if handler is None: - from . import ImageDraw2 as handler - if im: - im = handler.Draw(im) - return im, handler + if hints is not None: + deprecate("'hints' parameter", 12) + from . import ImageDraw2 + + draw = ImageDraw2.Draw(im) if im is not None else None + return draw, ImageDraw2 -def floodfill(image: Image.Image, xy, value, border=None, thresh=0) -> None: +def floodfill( + image: Image.Image, + xy: tuple[int, int], + value: float | tuple[int, ...], + border: float | tuple[int, ...] | None = None, + thresh: float = 0, +) -> None: """ (experimental) Fills a bounded region with a given color. @@ -958,12 +1062,12 @@ def floodfill(image: Image.Image, xy, value, border=None, thresh=0) -> None: def _compute_regular_polygon_vertices( - bounding_circle, n_sides, rotation + bounding_circle: Sequence[Sequence[float] | float], n_sides: int, rotation: float ) -> list[tuple[float, float]]: """ Generate a list of vertices for a 2D regular polygon. - :param bounding_circle: The bounding circle is a tuple defined + :param bounding_circle: The bounding circle is a sequence defined by a point and radius. The polygon is inscribed in this circle. (e.g. ``bounding_circle=(x, y, r)`` or ``((x, y), r)``) :param n_sides: Number of sides @@ -1001,7 +1105,7 @@ def _compute_regular_polygon_vertices( # 1. Error Handling # 1.1 Check `n_sides` has an appropriate value if not isinstance(n_sides, int): - msg = "n_sides should be an int" + msg = "n_sides should be an int" # type: ignore[unreachable] raise TypeError(msg) if n_sides < 3: msg = "n_sides should be an int > 2" @@ -1013,9 +1117,24 @@ def _compute_regular_polygon_vertices( raise TypeError(msg) if len(bounding_circle) == 3: - *centroid, polygon_radius = bounding_circle - elif len(bounding_circle) == 2: - centroid, polygon_radius = bounding_circle + if not all(isinstance(i, (int, float)) for i in bounding_circle): + msg = "bounding_circle should only contain numeric data" + raise ValueError(msg) + + *centroid, polygon_radius = cast(List[float], list(bounding_circle)) + elif len(bounding_circle) == 2 and isinstance(bounding_circle[0], (list, tuple)): + if not all( + isinstance(i, (int, float)) for i in bounding_circle[0] + ) or not isinstance(bounding_circle[1], (int, float)): + msg = "bounding_circle should only contain numeric data" + raise ValueError(msg) + + if len(bounding_circle[0]) != 2: + msg = "bounding_circle centre should contain 2D coordinates (e.g. (x, y))" + raise ValueError(msg) + + centroid = cast(List[float], list(bounding_circle[0])) + polygon_radius = cast(float, bounding_circle[1]) else: msg = ( "bounding_circle should contain 2D coordinates " @@ -1023,25 +1142,17 @@ def _compute_regular_polygon_vertices( ) raise ValueError(msg) - if not all(isinstance(i, (int, float)) for i in (*centroid, polygon_radius)): - msg = "bounding_circle should only contain numeric data" - raise ValueError(msg) - - if not len(centroid) == 2: - msg = "bounding_circle centre should contain 2D coordinates (e.g. (x, y))" - raise ValueError(msg) - if polygon_radius <= 0: msg = "bounding_circle radius should be > 0" raise ValueError(msg) # 1.3 Check `rotation` has an appropriate value if not isinstance(rotation, (int, float)): - msg = "rotation should be an int or float" + msg = "rotation should be an int or float" # type: ignore[unreachable] raise ValueError(msg) # 2. Define Helper Functions - def _apply_rotation(point: list[float], degrees: float) -> tuple[int, int]: + def _apply_rotation(point: list[float], degrees: float) -> tuple[float, float]: return ( round( point[0] * math.cos(math.radians(360 - degrees)) @@ -1057,7 +1168,7 @@ def _compute_regular_polygon_vertices( ), ) - def _compute_polygon_vertex(angle: float) -> tuple[int, int]: + def _compute_polygon_vertex(angle: float) -> tuple[float, float]: start_point = [polygon_radius, 0] return _apply_rotation(start_point, angle) @@ -1080,11 +1191,13 @@ def _compute_regular_polygon_vertices( return [_compute_polygon_vertex(angle) for angle in angles] -def _color_diff(color1, color2: float | tuple[int, ...]) -> float: +def _color_diff( + color1: float | tuple[int, ...], color2: float | tuple[int, ...] +) -> float: """ Uses 1-norm distance to calculate difference between two values. """ - if isinstance(color2, tuple): - return sum(abs(color1[i] - color2[i]) for i in range(0, len(color2))) - else: - return abs(color1 - color2) + first = color1 if isinstance(color1, tuple) else (color1,) + second = color2 if isinstance(color2, tuple) else (color2,) + + return sum(abs(first[i] - second[i]) for i in range(0, len(second))) diff --git a/src/PIL/ImageDraw2.py b/src/PIL/ImageDraw2.py index 35ee5834e..b42f5d9ea 100644 --- a/src/PIL/ImageDraw2.py +++ b/src/PIL/ImageDraw2.py @@ -30,7 +30,7 @@ from . import Image, ImageColor, ImageDraw, ImageFont, ImagePath class Pen: """Stores an outline color and width.""" - def __init__(self, color, width=1, opacity=255): + def __init__(self, color: str, width: int = 1, opacity: int = 255) -> None: self.color = ImageColor.getrgb(color) self.width = width @@ -38,7 +38,7 @@ class Pen: class Brush: """Stores a fill color""" - def __init__(self, color, opacity=255): + def __init__(self, color: str, opacity: int = 255) -> None: self.color = ImageColor.getrgb(color) @@ -63,7 +63,7 @@ class Draw: self.image = image self.transform = None - def flush(self): + def flush(self) -> Image.Image: return self.image def render(self, op, xy, pen, brush=None): diff --git a/src/PIL/ImageEnhance.py b/src/PIL/ImageEnhance.py index 93a50d2a2..d7e99a968 100644 --- a/src/PIL/ImageEnhance.py +++ b/src/PIL/ImageEnhance.py @@ -23,7 +23,10 @@ from . import Image, ImageFilter, ImageStat class _Enhance: - def enhance(self, factor): + image: Image.Image + degenerate: Image.Image + + def enhance(self, factor: float) -> Image.Image: """ Returns an enhanced image. @@ -46,7 +49,7 @@ class Color(_Enhance): the original image. """ - def __init__(self, image): + def __init__(self, image: Image.Image) -> None: self.image = image self.intermediate_mode = "L" if "A" in image.getbands(): @@ -63,7 +66,7 @@ class Contrast(_Enhance): gives a solid gray image. A factor of 1.0 gives the original image. """ - def __init__(self, image): + def __init__(self, image: Image.Image) -> None: self.image = image mean = int(ImageStat.Stat(image.convert("L")).mean[0] + 0.5) self.degenerate = Image.new("L", image.size, mean).convert(image.mode) @@ -80,7 +83,7 @@ class Brightness(_Enhance): original image. """ - def __init__(self, image): + def __init__(self, image: Image.Image) -> None: self.image = image self.degenerate = Image.new(image.mode, image.size, 0) @@ -96,7 +99,7 @@ class Sharpness(_Enhance): original image, and a factor of 2.0 gives a sharpened image. """ - def __init__(self, image): + def __init__(self, image: Image.Image) -> None: self.image = image self.degenerate = image.filter(ImageFilter.SMOOTH) diff --git a/src/PIL/ImageFile.py b/src/PIL/ImageFile.py index 33467fc4f..5d67409ea 100644 --- a/src/PIL/ImageFile.py +++ b/src/PIL/ImageFile.py @@ -28,6 +28,7 @@ # from __future__ import annotations +import abc import io import itertools import struct @@ -347,6 +348,15 @@ class ImageFile(Image.Image): return self.tell() != frame +class StubHandler: + def open(self, im: StubImageFile) -> None: + pass + + @abc.abstractmethod + def load(self, im: StubImageFile) -> Image.Image: + pass + + class StubImageFile(ImageFile): """ Base class for stub image loaders. @@ -477,7 +487,7 @@ class Parser: def __enter__(self): return self - def __exit__(self, *args): + def __exit__(self, *args: object) -> None: self.close() def close(self): @@ -753,7 +763,7 @@ class PyEncoder(PyCodec): def pushes_fd(self): return self._pushes_fd - def encode(self, bufsize): + def encode(self, bufsize: int) -> tuple[int, int, bytes]: """ Override to perform the encoding process. diff --git a/src/PIL/ImageFilter.py b/src/PIL/ImageFilter.py index 678bd29a2..e18b4a446 100644 --- a/src/PIL/ImageFilter.py +++ b/src/PIL/ImageFilter.py @@ -18,11 +18,17 @@ from __future__ import annotations import abc import functools +from types import ModuleType +from typing import TYPE_CHECKING, Any, Callable, Sequence, cast + +if TYPE_CHECKING: + from . import _imaging + from ._typing import NumpyArray class Filter: @abc.abstractmethod - def filter(self, image): + def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore: pass @@ -31,7 +37,9 @@ class MultibandFilter(Filter): class BuiltinFilter(MultibandFilter): - def filter(self, image): + filterargs: tuple[Any, ...] + + def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore: if image.mode == "P": msg = "cannot filter palette images" raise ValueError(msg) @@ -56,7 +64,13 @@ class Kernel(BuiltinFilter): name = "Kernel" - def __init__(self, size, kernel, scale=None, offset=0): + def __init__( + self, + size: tuple[int, int], + kernel: Sequence[float], + scale: float | None = None, + offset: float = 0, + ) -> None: if scale is None: # default scale is sum of kernel scale = functools.reduce(lambda a, b: a + b, kernel) @@ -79,11 +93,11 @@ class RankFilter(Filter): name = "Rank" - def __init__(self, size, rank): + def __init__(self, size: int, rank: int) -> None: self.size = size self.rank = rank - def filter(self, image): + def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore: if image.mode == "P": msg = "cannot filter palette images" raise ValueError(msg) @@ -101,7 +115,7 @@ class MedianFilter(RankFilter): name = "Median" - def __init__(self, size=3): + def __init__(self, size: int = 3) -> None: self.size = size self.rank = size * size // 2 @@ -116,7 +130,7 @@ class MinFilter(RankFilter): name = "Min" - def __init__(self, size=3): + def __init__(self, size: int = 3) -> None: self.size = size self.rank = 0 @@ -131,7 +145,7 @@ class MaxFilter(RankFilter): name = "Max" - def __init__(self, size=3): + def __init__(self, size: int = 3) -> None: self.size = size self.rank = size * size - 1 @@ -147,10 +161,10 @@ class ModeFilter(Filter): name = "Mode" - def __init__(self, size=3): + def __init__(self, size: int = 3) -> None: self.size = size - def filter(self, image): + def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore: return image.modefilter(self.size) @@ -165,12 +179,12 @@ class GaussianBlur(MultibandFilter): name = "GaussianBlur" - def __init__(self, radius=2): + def __init__(self, radius: float | Sequence[float] = 2) -> None: self.radius = radius - def filter(self, image): + def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore: xy = self.radius - if not isinstance(xy, (tuple, list)): + if isinstance(xy, (int, float)): xy = (xy, xy) if xy == (0, 0): return image.copy() @@ -193,18 +207,16 @@ class BoxBlur(MultibandFilter): name = "BoxBlur" - def __init__(self, radius): - xy = radius - if not isinstance(xy, (tuple, list)): - xy = (xy, xy) + def __init__(self, radius: float | Sequence[float]) -> None: + xy = radius if isinstance(radius, (tuple, list)) else (radius, radius) if xy[0] < 0 or xy[1] < 0: msg = "radius must be >= 0" raise ValueError(msg) self.radius = radius - def filter(self, image): + def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore: xy = self.radius - if not isinstance(xy, (tuple, list)): + if isinstance(xy, (int, float)): xy = (xy, xy) if xy == (0, 0): return image.copy() @@ -228,12 +240,14 @@ class UnsharpMask(MultibandFilter): name = "UnsharpMask" - def __init__(self, radius=2, percent=150, threshold=3): + def __init__( + self, radius: float = 2, percent: int = 150, threshold: int = 3 + ) -> None: self.radius = radius self.percent = percent self.threshold = threshold - def filter(self, image): + def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore: return image.unsharp_mask(self.radius, self.percent, self.threshold) @@ -378,7 +392,14 @@ class Color3DLUT(MultibandFilter): name = "Color 3D LUT" - def __init__(self, size, table, channels=3, target_mode=None, **kwargs): + def __init__( + self, + size: int | tuple[int, int, int], + table: Sequence[float] | Sequence[Sequence[int]] | NumpyArray, + channels: int = 3, + target_mode: str | None = None, + **kwargs: bool, + ) -> None: if channels not in (3, 4): msg = "Only 3 or 4 output channels are supported" raise ValueError(msg) @@ -392,7 +413,7 @@ class Color3DLUT(MultibandFilter): items = size[0] * size[1] * size[2] wrong_size = False - numpy = None + numpy: ModuleType | None = None if hasattr(table, "shape"): try: import numpy @@ -400,15 +421,16 @@ class Color3DLUT(MultibandFilter): pass if numpy and isinstance(table, numpy.ndarray): + numpy_table: NumpyArray = table if copy_table: - table = table.copy() + numpy_table = numpy_table.copy() - if table.shape in [ + if numpy_table.shape in [ (items * channels,), (items, channels), (size[2], size[1], size[0], channels), ]: - table = table.reshape(items * channels) + table = numpy_table.reshape(items * channels) else: wrong_size = True @@ -418,7 +440,8 @@ class Color3DLUT(MultibandFilter): # Convert to a flat list if table and isinstance(table[0], (list, tuple)): - table, raw_table = [], table + raw_table = cast(Sequence[Sequence[int]], table) + flat_table: list[int] = [] for pixel in raw_table: if len(pixel) != channels: msg = ( @@ -426,7 +449,8 @@ class Color3DLUT(MultibandFilter): f"have a length of {channels}." ) raise ValueError(msg) - table.extend(pixel) + flat_table.extend(pixel) + table = flat_table if wrong_size or len(table) != items * channels: msg = ( @@ -439,7 +463,7 @@ class Color3DLUT(MultibandFilter): self.table = table @staticmethod - def _check_size(size): + def _check_size(size: Any) -> tuple[int, int, int]: try: _, _, _ = size except ValueError as e: @@ -447,7 +471,7 @@ class Color3DLUT(MultibandFilter): raise ValueError(msg) from e except TypeError: size = (size, size, size) - size = [int(x) for x in size] + size = tuple(int(x) for x in size) for size_1d in size: if not 2 <= size_1d <= 65: msg = "Size should be in [2, 65] range." @@ -455,7 +479,13 @@ class Color3DLUT(MultibandFilter): return size @classmethod - def generate(cls, size, callback, channels=3, target_mode=None): + def generate( + cls, + size: int | tuple[int, int, int], + callback: Callable[[float, float, float], tuple[float, ...]], + channels: int = 3, + target_mode: str | None = None, + ) -> Color3DLUT: """Generates new LUT using provided callback. :param size: Size of the table. Passed to the constructor. @@ -472,7 +502,7 @@ class Color3DLUT(MultibandFilter): msg = "Only 3 or 4 output channels are supported" raise ValueError(msg) - table = [0] * (size_1d * size_2d * size_3d * channels) + table: list[float] = [0] * (size_1d * size_2d * size_3d * channels) idx_out = 0 for b in range(size_3d): for g in range(size_2d): @@ -490,7 +520,13 @@ class Color3DLUT(MultibandFilter): _copy_table=False, ) - def transform(self, callback, with_normals=False, channels=None, target_mode=None): + def transform( + self, + callback: Callable[..., tuple[float, ...]], + with_normals: bool = False, + channels: int | None = None, + target_mode: str | None = None, + ) -> Color3DLUT: """Transforms the table values using provided callback and returns a new LUT with altered values. @@ -554,7 +590,7 @@ class Color3DLUT(MultibandFilter): r.append(f"target_mode={self.mode}") return "<{}>".format(" ".join(r)) - def filter(self, image): + def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore: from . import Image return image.color_lut_3d( diff --git a/src/PIL/ImageFont.py b/src/PIL/ImageFont.py index 87ff8315e..655d7627e 100644 --- a/src/PIL/ImageFont.py +++ b/src/PIL/ImageFont.py @@ -33,11 +33,17 @@ import sys import warnings from enum import IntEnum from io import BytesIO -from typing import BinaryIO +from types import ModuleType +from typing import IO, TYPE_CHECKING, Any, BinaryIO from . import Image from ._typing import StrOrBytesPath -from ._util import is_directory, is_path +from ._util import DeferredError, is_path + +if TYPE_CHECKING: + from . import ImageFile + from ._imaging import ImagingFont + from ._imagingft import Font class Layout(IntEnum): @@ -48,15 +54,14 @@ class Layout(IntEnum): MAX_STRING_LENGTH = 1_000_000 +core: ModuleType | DeferredError try: from . import _imagingft as core except ImportError as ex: - from ._util import DeferredError - core = DeferredError.new(ex) -def _string_length_check(text): +def _string_length_check(text: str | bytes | bytearray) -> None: if MAX_STRING_LENGTH is not None and len(text) > MAX_STRING_LENGTH: msg = "too many characters in string" raise ValueError(msg) @@ -81,9 +86,11 @@ def _string_length_check(text): class ImageFont: """PIL font wrapper""" - def _load_pilfont(self, filename): + font: ImagingFont + + def _load_pilfont(self, filename: str) -> None: with open(filename, "rb") as fp: - image = None + image: ImageFile.ImageFile | None = None for ext in (".png", ".gif", ".pbm"): if image: image.close() @@ -106,7 +113,7 @@ class ImageFont: self._load_pilfont_data(fp, image) image.close() - def _load_pilfont_data(self, file, image): + def _load_pilfont_data(self, file: IO[bytes], image: Image.Image) -> None: # read PILfont header if file.readline() != b"PILfont\n": msg = "Not a PILfont file" @@ -153,7 +160,9 @@ class ImageFont: Image._decompression_bomb_check(self.font.getsize(text)) return self.font.getmask(text, mode) - def getbbox(self, text, *args, **kwargs): + def getbbox( + self, text: str | bytes | bytearray, *args: Any, **kwargs: Any + ) -> tuple[int, int, int, int]: """ Returns bounding box (in pixels) of given text. @@ -167,7 +176,9 @@ class ImageFont: width, height = self.font.getsize(text) return 0, 0, width, height - def getlength(self, text, *args, **kwargs): + def getlength( + self, text: str | bytes | bytearray, *args: Any, **kwargs: Any + ) -> int: """ Returns length (in pixels) of given text. This is the amount by which following text should be offset. @@ -187,6 +198,9 @@ class ImageFont: class FreeTypeFont: """FreeType font wrapper (requires _imagingft service)""" + font: Font + font_bytes: bytes + def __init__( self, font: StrOrBytesPath | BinaryIO | None = None, @@ -197,6 +211,9 @@ class FreeTypeFont: ) -> None: # FIXME: use service provider instead + if isinstance(core, DeferredError): + raise core.ex + if size <= 0: msg = "font size must be greater than 0" raise ValueError(msg) @@ -250,14 +267,14 @@ class FreeTypeFont: path, size, index, encoding, layout_engine = state self.__init__(path, size, index, encoding, layout_engine) - def getname(self): + def getname(self) -> tuple[str | None, str | None]: """ :return: A tuple of the font family (e.g. Helvetica) and the font style (e.g. Bold) """ return self.font.family, self.font.style - def getmetrics(self): + def getmetrics(self) -> tuple[int, int]: """ :return: A tuple of the font ascent (the distance from the baseline to the highest outline point) and descent (the distance from the @@ -265,7 +282,9 @@ class FreeTypeFont: """ return self.font.ascent, self.font.descent - def getlength(self, text, mode="", direction=None, features=None, language=None): + def getlength( + self, text: str, mode="", direction=None, features=None, language=None + ) -> float: """ Returns length (in pixels with 1/64 precision) of given text when rendered in font with provided direction, features, and language. @@ -339,14 +358,14 @@ class FreeTypeFont: def getbbox( self, - text, - mode="", - direction=None, - features=None, - language=None, - stroke_width=0, - anchor=None, - ): + text: str, + mode: str = "", + direction: str | None = None, + features: list[str] | None = None, + language: str | None = None, + stroke_width: float = 0, + anchor: str | None = None, + ) -> tuple[float, float, float, float]: """ Returns bounding box (in pixels) of given text relative to given anchor when rendered in font with provided direction, features, and language. @@ -496,7 +515,7 @@ class FreeTypeFont: def getmask2( self, - text, + text: str, mode="", direction=None, features=None, @@ -624,7 +643,7 @@ class FreeTypeFont: layout_engine=layout_engine or self.layout_engine, ) - def get_variation_names(self): + def get_variation_names(self) -> list[bytes]: """ :returns: A list of the named styles in a variation font. :exception OSError: If the font is not a variation font. @@ -666,10 +685,11 @@ class FreeTypeFont: msg = "FreeType 2.9.1 or greater is required" raise NotImplementedError(msg) from e for axis in axes: - axis["name"] = axis["name"].replace(b"\x00", b"") + if axis["name"]: + axis["name"] = axis["name"].replace(b"\x00", b"") return axes - def set_variation_by_axes(self, axes): + def set_variation_by_axes(self, axes: list[float]) -> None: """ :param axes: A list of values for each axis. :exception OSError: If the font is not a variation font. @@ -714,14 +734,14 @@ class TransposedFont: return 0, 0, height, width return 0, 0, width, height - def getlength(self, text, *args, **kwargs): + def getlength(self, text: str, *args, **kwargs) -> float: if self.orientation in (Image.Transpose.ROTATE_90, Image.Transpose.ROTATE_270): msg = "text length is undefined for text rotated by 90 or 270 degrees" raise ValueError(msg) return self.font.getlength(text, *args, **kwargs) -def load(filename): +def load(filename: str) -> ImageFont: """ Load a font file. This function loads a font object from the given bitmap font file, and returns the corresponding font object. @@ -735,7 +755,13 @@ def load(filename): return f -def truetype(font=None, size=10, index=0, encoding="", layout_engine=None): +def truetype( + font: StrOrBytesPath | BinaryIO | None = None, + size: float = 10, + index: int = 0, + encoding: str = "", + layout_engine: Layout | None = None, +) -> FreeTypeFont: """ Load a TrueType or OpenType font from a file or file-like object, and create a font object. @@ -753,10 +779,15 @@ def truetype(font=None, size=10, index=0, encoding="", layout_engine=None): :param font: A filename or file-like object containing a TrueType font. If the file is not found in this filename, the loader may also - search in other directories, such as the :file:`fonts/` - directory on Windows or :file:`/Library/Fonts/`, - :file:`/System/Library/Fonts/` and :file:`~/Library/Fonts/` on - macOS. + search in other directories, such as: + + * The :file:`fonts/` directory on Windows, + * :file:`/Library/Fonts/`, :file:`/System/Library/Fonts/` + and :file:`~/Library/Fonts/` on macOS. + * :file:`~/.local/share/fonts`, :file:`/usr/local/share/fonts`, + and :file:`/usr/share/fonts` on Linux; or those specified by + the ``XDG_DATA_HOME`` and ``XDG_DATA_DIRS`` environment variables + for user-installed and system-wide fonts, respectively. :param size: The requested size, in pixels. :param index: Which font face to load (default is first available face). @@ -796,7 +827,7 @@ def truetype(font=None, size=10, index=0, encoding="", layout_engine=None): :exception ValueError: If the font size is not greater than zero. """ - def freetype(font): + def freetype(font: StrOrBytesPath | BinaryIO | None) -> FreeTypeFont: return FreeTypeFont(font, size, index, encoding, layout_engine) try: @@ -815,12 +846,21 @@ def truetype(font=None, size=10, index=0, encoding="", layout_engine=None): if windir: dirs.append(os.path.join(windir, "fonts")) elif sys.platform in ("linux", "linux2"): - lindirs = os.environ.get("XDG_DATA_DIRS") - if not lindirs: - # According to the freedesktop spec, XDG_DATA_DIRS should - # default to /usr/share - lindirs = "/usr/share" - dirs += [os.path.join(lindir, "fonts") for lindir in lindirs.split(":")] + data_home = os.environ.get("XDG_DATA_HOME") + if not data_home: + # The freedesktop spec defines the following default directory for + # when XDG_DATA_HOME is unset or empty. This user-level directory + # takes precedence over system-level directories. + data_home = os.path.expanduser("~/.local/share") + xdg_dirs = [data_home] + + data_dirs = os.environ.get("XDG_DATA_DIRS") + if not data_dirs: + # Similarly, defaults are defined for the system-level directories + data_dirs = "/usr/local/share:/usr/share" + xdg_dirs += data_dirs.split(":") + + dirs += [os.path.join(xdg_dir, "fonts") for xdg_dir in xdg_dirs] elif sys.platform == "darwin": dirs += [ "/Library/Fonts", @@ -846,7 +886,7 @@ def truetype(font=None, size=10, index=0, encoding="", layout_engine=None): raise -def load_path(filename): +def load_path(filename: str | bytes) -> ImageFont: """ Load font file. Same as :py:func:`~PIL.ImageFont.load`, but searches for a bitmap font along the Python path. @@ -855,14 +895,13 @@ def load_path(filename): :return: A font object. :exception OSError: If the file could not be read. """ + if not isinstance(filename, str): + filename = filename.decode("utf-8") for directory in sys.path: - if is_directory(directory): - if not isinstance(filename, str): - filename = filename.decode("utf-8") - try: - return load(os.path.join(directory, filename)) - except OSError: - pass + try: + return load(os.path.join(directory, filename)) + except OSError: + pass msg = "cannot find font file" raise OSError(msg) @@ -1017,7 +1056,7 @@ def load_default(size: float | None = None) -> FreeTypeFont | ImageFont: :return: A font object. """ - if core.__class__.__name__ == "module" or size is not None: + if isinstance(core, ModuleType) or size is not None: return truetype( BytesIO( base64.b64decode( diff --git a/src/PIL/ImageMorph.py b/src/PIL/ImageMorph.py index 6ee8c4f25..6a43983d3 100644 --- a/src/PIL/ImageMorph.py +++ b/src/PIL/ImageMorph.py @@ -200,7 +200,7 @@ class MorphOp: elif patterns is not None: self.lut = LutBuilder(patterns=patterns).build_lut() - def apply(self, image: Image.Image): + def apply(self, image: Image.Image) -> tuple[int, Image.Image]: """Run a single morphological operation on an image Returns a tuple of the number of changed pixels and the @@ -216,7 +216,7 @@ class MorphOp: count = _imagingmorph.apply(bytes(self.lut), image.im.id, outimage.im.id) return count, outimage - def match(self, image: Image.Image): + def match(self, image: Image.Image) -> list[tuple[int, int]]: """Get a list of coordinates matching the morphological operation on an image. @@ -231,7 +231,7 @@ class MorphOp: raise ValueError(msg) return _imagingmorph.match(bytes(self.lut), image.im.id) - def get_on_pixels(self, image: Image.Image): + def get_on_pixels(self, image: Image.Image) -> list[tuple[int, int]]: """Get a list of all turned on pixels in a binary image Returns a list of tuples of (x,y) coordinates diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py index 33db8fa50..6d2814345 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -497,7 +497,7 @@ def expand( color = _color(fill, image.mode) if image.palette: palette = ImagePalette.ImagePalette(palette=image.getpalette()) - if isinstance(color, tuple): + if isinstance(color, tuple) and (len(color) == 3 or len(color) == 4): color = palette.getcolor(color) else: palette = None @@ -717,6 +717,9 @@ def exif_transpose(image: Image.Image, *, in_place: bool = False) -> Image.Image exif_image.info["XML:com.adobe.xmp"] = re.sub( pattern, "", exif_image.info["XML:com.adobe.xmp"] ) + exif_image.info["xmp"] = re.sub( + pattern.encode(), b"", exif_image.info["xmp"] + ) if not in_place: return transposed_image elif not in_place: diff --git a/src/PIL/ImagePalette.py b/src/PIL/ImagePalette.py index ae5c5dec0..6473c4577 100644 --- a/src/PIL/ImagePalette.py +++ b/src/PIL/ImagePalette.py @@ -18,10 +18,13 @@ from __future__ import annotations import array -from typing import Sequence +from typing import IO, TYPE_CHECKING, Sequence from . import GimpGradientFile, GimpPaletteFile, ImageColor, PaletteFile +if TYPE_CHECKING: + from . import Image + class ImagePalette: """ @@ -51,7 +54,7 @@ class ImagePalette: self._palette = palette @property - def colors(self): + def colors(self) -> dict[tuple[int, int, int] | tuple[int, int, int, int], int]: if self._colors is None: mode_len = len(self.mode) self._colors = {} @@ -63,7 +66,9 @@ class ImagePalette: return self._colors @colors.setter - def colors(self, colors): + def colors( + self, colors: dict[tuple[int, int, int] | tuple[int, int, int, int], int] + ) -> None: self._colors = colors def copy(self) -> ImagePalette: @@ -104,11 +109,13 @@ class ImagePalette: # Declare tostring as an alias for tobytes tostring = tobytes - def _new_color_index(self, image=None, e=None): + def _new_color_index( + self, image: Image.Image | None = None, e: Exception | None = None + ) -> int: if not isinstance(self.palette, bytearray): self._palette = bytearray(self.palette) index = len(self.palette) // 3 - special_colors = () + special_colors: tuple[int | tuple[int, ...] | None, ...] = () if image: special_colors = ( image.info.get("background"), @@ -128,7 +135,11 @@ class ImagePalette: raise ValueError(msg) from e return index - def getcolor(self, color, image=None) -> int: + def getcolor( + self, + color: tuple[int, int, int] | tuple[int, int, int, int], + image: Image.Image | None = None, + ) -> int: """Given an rgb tuple, allocate palette entry. .. warning:: This method is experimental. @@ -163,10 +174,10 @@ class ImagePalette: self.dirty = 1 return index else: - msg = f"unknown color specifier: {repr(color)}" + msg = f"unknown color specifier: {repr(color)}" # type: ignore[unreachable] raise ValueError(msg) - def save(self, fp): + def save(self, fp: str | IO[str]) -> None: """Save palette to text file. .. warning:: This method is experimental. @@ -213,29 +224,29 @@ def make_linear_lut(black, white): raise NotImplementedError(msg) # FIXME -def make_gamma_lut(exp): +def make_gamma_lut(exp: float) -> list[int]: return [int(((i / 255.0) ** exp) * 255.0 + 0.5) for i in range(256)] -def negative(mode="RGB"): +def negative(mode: str = "RGB") -> ImagePalette: palette = list(range(256 * len(mode))) palette.reverse() return ImagePalette(mode, [i // len(mode) for i in palette]) -def random(mode="RGB"): +def random(mode: str = "RGB") -> ImagePalette: from random import randint palette = [randint(0, 255) for _ in range(256 * len(mode))] return ImagePalette(mode, palette) -def sepia(white="#fff0c0"): +def sepia(white: str = "#fff0c0") -> ImagePalette: bands = [make_linear_lut(0, band) for band in ImageColor.getrgb(white)] return ImagePalette("RGB", [bands[i % 3][i // 3] for i in range(256 * 3)]) -def wedge(mode="RGB"): +def wedge(mode: str = "RGB") -> ImagePalette: palette = list(range(256 * len(mode))) return ImagePalette(mode, [i // len(mode) for i in palette]) diff --git a/src/PIL/ImageQt.py b/src/PIL/ImageQt.py index 293ba4941..35a37760c 100644 --- a/src/PIL/ImageQt.py +++ b/src/PIL/ImageQt.py @@ -152,7 +152,7 @@ def _toqclass_helper(im): elif im.mode == "RGBA": data = im.tobytes("raw", "BGRA") format = qt_format.Format_ARGB32 - elif im.mode == "I;16" and hasattr(qt_format, "Format_Grayscale16"): # Qt 5.13+ + elif im.mode == "I;16": im = im.point(lambda i: i * 256) format = qt_format.Format_Grayscale16 @@ -196,7 +196,7 @@ if qt_is_installed: self.setColorTable(im_data["colortable"]) -def toqimage(im): +def toqimage(im) -> ImageQt: return ImageQt(im) diff --git a/src/PIL/ImageTk.py b/src/PIL/ImageTk.py index 6e2e7db1e..90defdbbc 100644 --- a/src/PIL/ImageTk.py +++ b/src/PIL/ImageTk.py @@ -37,7 +37,7 @@ from . import Image _pilbitmap_ok = None -def _pilbitmap_check(): +def _pilbitmap_check() -> int: global _pilbitmap_ok if _pilbitmap_ok is None: try: @@ -162,7 +162,7 @@ class PhotoImage: """ return self.__size[1] - def paste(self, im): + def paste(self, im: Image.Image) -> None: """ Paste a PIL image into the photo image. Note that this can be very slow if the photo image is displayed. @@ -254,7 +254,7 @@ class BitmapImage: return str(self.__photo) -def getimage(photo): +def getimage(photo: PhotoImage) -> Image.Image: """Copies the contents of a PhotoImage to a PIL image memory.""" im = Image.new("RGBA", (photo.width(), photo.height())) block = im.im diff --git a/src/PIL/ImageTransform.py b/src/PIL/ImageTransform.py index 6aa82dadd..ffd791674 100644 --- a/src/PIL/ImageTransform.py +++ b/src/PIL/ImageTransform.py @@ -14,7 +14,7 @@ # from __future__ import annotations -from typing import Sequence +from typing import Any, Sequence from . import Image @@ -24,7 +24,7 @@ class Transform(Image.ImageTransformHandler): method: Image.Transform - def __init__(self, data: Sequence[int]) -> None: + def __init__(self, data: Sequence[Any]) -> None: self.data = data def getdata(self) -> tuple[Image.Transform, Sequence[int]]: @@ -34,7 +34,7 @@ class Transform(Image.ImageTransformHandler): self, size: tuple[int, int], image: Image.Image, - **options: dict[str, str | int | tuple[int, ...] | list[int]], + **options: Any, ) -> Image.Image: """Perform the transform. Called from :py:meth:`.Image.transform`.""" # can be overridden diff --git a/src/PIL/ImageWin.py b/src/PIL/ImageWin.py index 77e57a415..6c29e2590 100644 --- a/src/PIL/ImageWin.py +++ b/src/PIL/ImageWin.py @@ -28,10 +28,10 @@ class HDC: methods. """ - def __init__(self, dc): + def __init__(self, dc: int) -> None: self.dc = dc - def __int__(self): + def __int__(self) -> int: return self.dc @@ -42,10 +42,10 @@ class HWND: methods, instead of a DC. """ - def __init__(self, wnd): + def __init__(self, wnd: int) -> None: self.wnd = wnd - def __int__(self): + def __int__(self) -> int: return self.wnd @@ -149,7 +149,9 @@ class Dib: result = self.image.query_palette(handle) return result - def paste(self, im, box=None): + def paste( + self, im: Image.Image, box: tuple[int, int, int, int] | None = None + ) -> None: """ Paste a PIL image into the bitmap image. @@ -169,16 +171,16 @@ class Dib: else: self.image.paste(im.im) - def frombytes(self, buffer): + def frombytes(self, buffer: bytes) -> None: """ Load display memory contents from byte data. :param buffer: A buffer containing display data (usually data returned from :py:func:`~PIL.ImageWin.Dib.tobytes`) """ - return self.image.frombytes(buffer) + self.image.frombytes(buffer) - def tobytes(self): + def tobytes(self) -> bytes: """ Copy display memory contents to bytes object. @@ -190,7 +192,9 @@ class Dib: class Window: """Create a Window with the given title size.""" - def __init__(self, title="PIL", width=None, height=None): + def __init__( + self, title: str = "PIL", width: int | None = None, height: int | None = None + ) -> None: self.hwnd = Image.core.createwindow( title, self.__dispatcher, width or 0, height or 0 ) diff --git a/src/PIL/Jpeg2KImagePlugin.py b/src/PIL/Jpeg2KImagePlugin.py index ce6342bdb..39eb1c203 100644 --- a/src/PIL/Jpeg2KImagePlugin.py +++ b/src/PIL/Jpeg2KImagePlugin.py @@ -18,6 +18,7 @@ from __future__ import annotations import io import os import struct +from typing import IO, Tuple, cast from . import Image, ImageFile, ImagePalette, _binary @@ -34,7 +35,7 @@ class BoxReader: self.length = length self.remaining_in_box = -1 - def _can_read(self, num_bytes): + def _can_read(self, num_bytes: int) -> bool: if self.has_length and self.fp.tell() + num_bytes > self.length: # Outside box: ensure we don't read past the known file length return False @@ -44,7 +45,7 @@ class BoxReader: else: return True # No length known, just read - def _read_bytes(self, num_bytes): + def _read_bytes(self, num_bytes: int) -> bytes: if not self._can_read(num_bytes): msg = "Not enough data in header" raise SyntaxError(msg) @@ -58,7 +59,7 @@ class BoxReader: self.remaining_in_box -= num_bytes return data - def read_fields(self, field_format): + def read_fields(self, field_format: str) -> tuple[int | bytes, ...]: size = struct.calcsize(field_format) data = self._read_bytes(size) return struct.unpack(field_format, data) @@ -74,16 +75,16 @@ class BoxReader: else: return True - def next_box_type(self): + def next_box_type(self) -> bytes: # Skip the rest of the box if it has not been read if self.remaining_in_box > 0: self.fp.seek(self.remaining_in_box, os.SEEK_CUR) self.remaining_in_box = -1 # Read the length and type of the next box - lbox, tbox = self.read_fields(">I4s") + lbox, tbox = cast(Tuple[int, bytes], self.read_fields(">I4s")) if lbox == 1: - lbox = self.read_fields(">Q")[0] + lbox = cast(int, self.read_fields(">Q")[0]) hlen = 16 else: hlen = 8 @@ -121,17 +122,18 @@ def _parse_codestream(fp): elif csiz == 4: mode = "RGBA" else: - mode = None + mode = "" return size, mode -def _res_to_dpi(num, denom, exp): +def _res_to_dpi(num: int, denom: int, exp: int) -> float | None: """Convert JPEG2000's (numerator, denominator, exponent-base-10) resolution, calculated as (num / denom) * 10^exp and stored in dots per meter, to floating-point dots per inch.""" - if denom != 0: - return (254 * num * (10**exp)) / (10000 * denom) + if denom == 0: + return None + return (254 * num * (10**exp)) / (10000 * denom) def _parse_jp2_header(fp): @@ -235,7 +237,7 @@ class Jpeg2KImageFile(ImageFile.ImageFile): msg = "not a JPEG 2000 file" raise SyntaxError(msg) - if self.size is None or self.mode is None: + if self.size is None or not self.mode: msg = "unable to determine size/mode" raise SyntaxError(msg) @@ -328,11 +330,13 @@ def _accept(prefix: bytes) -> bool: # Save support -def _save(im, fp, filename): +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: # Get the keyword arguments info = im.encoderinfo - if filename.endswith(".j2k") or info.get("no_jp2", False): + if isinstance(filename, str): + filename = filename.encode() + if filename.endswith(b".j2k") or info.get("no_jp2", False): kind = "j2k" else: kind = "jp2" diff --git a/src/PIL/JpegImagePlugin.py b/src/PIL/JpegImagePlugin.py index 909911dfe..9d6811cb7 100644 --- a/src/PIL/JpegImagePlugin.py +++ b/src/PIL/JpegImagePlugin.py @@ -42,6 +42,7 @@ import subprocess import sys import tempfile import warnings +from typing import IO, Any from . import Image, ImageFile from ._binary import i16be as i16 @@ -54,7 +55,7 @@ from .JpegPresets import presets # Parser -def Skip(self, marker): +def Skip(self: JpegImageFile, marker: int) -> None: n = i16(self.fp.read(2)) - 2 ImageFile._safe_read(self.fp, n) @@ -94,6 +95,8 @@ def APP(self, marker): else: self.info["exif"] = s self._exif_offset = self.fp.tell() - n + 6 + elif marker == 0xFFE1 and s[:29] == b"http://ns.adobe.com/xap/1.0/\x00": + self.info["xmp"] = s.split(b"\x00")[1] elif marker == 0xFFE2 and s[:5] == b"FPXR\0": # extract FlashPix information (incomplete) self.info["flashpix"] = s # FIXME: value will change @@ -191,7 +194,7 @@ def APP(self, marker): self.info["dpi"] = 72, 72 -def COM(self, marker): +def COM(self: JpegImageFile, marker: int) -> None: # # Comment marker. Store these in the APP dictionary. n = i16(self.fp.read(2)) - 2 @@ -202,7 +205,7 @@ def COM(self, marker): self.applist.append(("COM", s)) -def SOF(self, marker): +def SOF(self: JpegImageFile, marker: int) -> None: # # Start of frame marker. Defines the size and mode of the # image. JPEG is colour blind, so we use some simple @@ -250,7 +253,7 @@ def SOF(self, marker): self.layer.append((t[0], t[1] // 16, t[1] & 15, t[2])) -def DQT(self, marker): +def DQT(self: JpegImageFile, marker: int) -> None: # # Define quantization table. Note that there might be more # than one table in each marker. @@ -425,7 +428,7 @@ class JpegImageFile(ImageFile.ImageFile): return s def draft( - self, mode: str, size: tuple[int, int] + self, mode: str | None, size: tuple[int, int] | None ) -> tuple[str, tuple[int, int, float, float]] | None: if len(self.tile) != 1: return None @@ -493,29 +496,14 @@ class JpegImageFile(ImageFile.ImageFile): self.tile = [] - def _getexif(self): + def _getexif(self) -> dict[str, Any] | None: return _getexif(self) def _getmp(self): return _getmp(self) - def getxmp(self): - """ - Returns a dictionary containing the XMP tags. - Requires defusedxml to be installed. - :returns: XMP tags in a dictionary. - """ - - for segment, content in self.applist: - if segment == "APP1": - marker, xmp_tags = content.split(b"\x00")[:2] - if marker == b"http://ns.adobe.com/xap/1.0/": - return self._getxmp(xmp_tags) - return {} - - -def _getexif(self): +def _getexif(self) -> dict[str, Any] | None: if "exif" not in self.info: return None return self.getexif()._get_merged_dict() @@ -643,7 +631,7 @@ def get_sampling(im): return samplings.get(sampling, -1) -def _save(im, fp, filename): +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if im.width == 0 or im.height == 0: msg = "cannot write empty image as JPEG" raise ValueError(msg) @@ -826,7 +814,7 @@ def _save(im, fp, filename): ImageFile._save(im, fp, [("jpeg", (0, 0) + im.size, 0, rawmode)], bufsize) -def _save_cjpeg(im, fp, filename): +def _save_cjpeg(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: # ALTERNATIVE: handle JPEGs via the IJG command line utilities. tempfile = im._dump() subprocess.check_call(["cjpeg", "-outfile", filename, tempfile]) diff --git a/src/PIL/MicImagePlugin.py b/src/PIL/MicImagePlugin.py index 5aef94dfb..07239887f 100644 --- a/src/PIL/MicImagePlugin.py +++ b/src/PIL/MicImagePlugin.py @@ -63,7 +63,7 @@ class MicImageFile(TiffImagePlugin.TiffImageFile): msg = "not an MIC file; no image entries" raise SyntaxError(msg) - self.frame = None + self.frame = -1 self._n_frames = len(self.images) self.is_animated = self._n_frames > 1 @@ -85,7 +85,7 @@ class MicImageFile(TiffImagePlugin.TiffImageFile): self.frame = frame - def tell(self): + def tell(self) -> int: return self.frame def close(self) -> None: @@ -93,7 +93,7 @@ class MicImageFile(TiffImagePlugin.TiffImageFile): self.ole.close() super().close() - def __exit__(self, *args): + def __exit__(self, *args: object) -> None: self.__fp.close() self.ole.close() super().__exit__() diff --git a/src/PIL/MpoImagePlugin.py b/src/PIL/MpoImagePlugin.py index 766e1290c..f21570661 100644 --- a/src/PIL/MpoImagePlugin.py +++ b/src/PIL/MpoImagePlugin.py @@ -22,6 +22,7 @@ from __future__ import annotations import itertools import os import struct +from typing import IO from . import ( Image, @@ -32,23 +33,18 @@ from . import ( from ._binary import o32le -def _save(im, fp, filename): +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: JpegImagePlugin._save(im, fp, filename) -def _save_all(im, fp, filename): +def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: append_images = im.encoderinfo.get("append_images", []) - if not append_images: - try: - animated = im.is_animated - except AttributeError: - animated = False - if not animated: - _save(im, fp, filename) - return + if not append_images and not getattr(im, "is_animated", False): + _save(im, fp, filename) + return mpf_offset = 28 - offsets = [] + offsets: list[int] = [] for imSequence in itertools.chain([im], append_images): for im_frame in ImageSequence.Iterator(imSequence): if not offsets: diff --git a/src/PIL/MspImagePlugin.py b/src/PIL/MspImagePlugin.py index 65cc70624..0a75c868b 100644 --- a/src/PIL/MspImagePlugin.py +++ b/src/PIL/MspImagePlugin.py @@ -164,7 +164,7 @@ Image.register_decoder("MSP", MspDecoder) # write MSP files (uncompressed only) -def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None: +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if im.mode != "1": msg = f"cannot write mode {im.mode} as MSP" raise OSError(msg) diff --git a/src/PIL/PSDraw.py b/src/PIL/PSDraw.py index 49c06ce13..673eae1d1 100644 --- a/src/PIL/PSDraw.py +++ b/src/PIL/PSDraw.py @@ -17,6 +17,7 @@ from __future__ import annotations import sys +from typing import TYPE_CHECKING from . import EpsImagePlugin @@ -38,7 +39,7 @@ class PSDraw: fp = sys.stdout self.fp = fp - def begin_document(self, id=None): + def begin_document(self, id: str | None = None) -> None: """Set up printing of a document. (Write PostScript DSC header.)""" # FIXME: incomplete self.fp.write( @@ -52,7 +53,7 @@ class PSDraw: self.fp.write(EDROFF_PS) self.fp.write(VDI_PS) self.fp.write(b"%%EndProlog\n") - self.isofont = {} + self.isofont: dict[bytes, int] = {} def end_document(self) -> None: """Ends printing. (Write PostScript DSC footer.)""" @@ -60,22 +61,24 @@ class PSDraw: if hasattr(self.fp, "flush"): self.fp.flush() - def setfont(self, font, size): + def setfont(self, font: str, size: int) -> None: """ Selects which font to use. :param font: A PostScript font name :param size: Size in points. """ - font = bytes(font, "UTF-8") - if font not in self.isofont: + font_bytes = bytes(font, "UTF-8") + if font_bytes not in self.isofont: # reencode font - self.fp.write(b"/PSDraw-%s ISOLatin1Encoding /%s E\n" % (font, font)) - self.isofont[font] = 1 + self.fp.write( + b"/PSDraw-%s ISOLatin1Encoding /%s E\n" % (font_bytes, font_bytes) + ) + self.isofont[font_bytes] = 1 # rough - self.fp.write(b"/F0 %d /PSDraw-%s F\n" % (size, font)) + self.fp.write(b"/F0 %d /PSDraw-%s F\n" % (size, font_bytes)) - def line(self, xy0, xy1): + def line(self, xy0: tuple[int, int], xy1: tuple[int, int]) -> None: """ Draws a line between the two points. Coordinates are given in PostScript point coordinates (72 points per inch, (0, 0) is the lower @@ -83,7 +86,7 @@ class PSDraw: """ self.fp.write(b"%d %d %d %d Vl\n" % (*xy0, *xy1)) - def rectangle(self, box): + def rectangle(self, box: tuple[int, int, int, int]) -> None: """ Draws a rectangle. @@ -92,18 +95,22 @@ class PSDraw: """ self.fp.write(b"%d %d M 0 %d %d Vr\n" % box) - def text(self, xy, text): + def text(self, xy: tuple[int, int], text: str) -> None: """ Draws text at the given position. You must use :py:meth:`~PIL.PSDraw.PSDraw.setfont` before calling this method. """ - text = bytes(text, "UTF-8") - text = b"\\(".join(text.split(b"(")) - text = b"\\)".join(text.split(b")")) - xy += (text,) - self.fp.write(b"%d %d M (%s) S\n" % xy) + text_bytes = bytes(text, "UTF-8") + text_bytes = b"\\(".join(text_bytes.split(b"(")) + text_bytes = b"\\)".join(text_bytes.split(b")")) + self.fp.write(b"%d %d M (%s) S\n" % (xy + (text_bytes,))) - def image(self, box, im, dpi=None): + if TYPE_CHECKING: + from . import Image + + def image( + self, box: tuple[int, int, int, int], im: Image.Image, dpi: int | None = None + ) -> None: """Draw a PIL image, centered in the given box.""" # default resolution depends on mode if not dpi: @@ -131,7 +138,7 @@ class PSDraw: sx = x / im.size[0] sy = y / im.size[1] self.fp.write(b"%f %f scale\n" % (sx, sy)) - EpsImagePlugin._save(im, self.fp, None, 0) + EpsImagePlugin._save(im, self.fp, "", 0) self.fp.write(b"\ngrestore\n") diff --git a/src/PIL/PaletteFile.py b/src/PIL/PaletteFile.py index dc3175402..81652e5ee 100644 --- a/src/PIL/PaletteFile.py +++ b/src/PIL/PaletteFile.py @@ -14,6 +14,8 @@ # from __future__ import annotations +from typing import IO + from ._binary import o8 @@ -22,8 +24,8 @@ class PaletteFile: rawmode = "RGB" - def __init__(self, fp): - self.palette = [(i, i, i) for i in range(256)] + def __init__(self, fp: IO[bytes]) -> None: + palette = [o8(i) * 3 for i in range(256)] while True: s = fp.readline() @@ -44,9 +46,9 @@ class PaletteFile: g = b = r if 0 <= i <= 255: - self.palette[i] = o8(r) + o8(g) + o8(b) + palette[i] = o8(r) + o8(g) + o8(b) - self.palette = b"".join(self.palette) + self.palette = b"".join(palette) - def getpalette(self): + def getpalette(self) -> tuple[bytes, str]: return self.palette, self.rawmode diff --git a/src/PIL/PalmImagePlugin.py b/src/PIL/PalmImagePlugin.py index 85f9fe1bf..1735070f8 100644 --- a/src/PIL/PalmImagePlugin.py +++ b/src/PIL/PalmImagePlugin.py @@ -8,6 +8,8 @@ ## from __future__ import annotations +from typing import IO + from . import Image, ImageFile from ._binary import o8 from ._binary import o16be as o16b @@ -82,10 +84,10 @@ _Palm8BitColormapValues = ( # so build a prototype image to be used for palette resampling -def build_prototype_image(): +def build_prototype_image() -> Image.Image: image = Image.new("L", (1, len(_Palm8BitColormapValues))) image.putdata(list(range(len(_Palm8BitColormapValues)))) - palettedata = () + palettedata: tuple[int, ...] = () for colormapValue in _Palm8BitColormapValues: palettedata += colormapValue palettedata += (0, 0, 0) * (256 - len(_Palm8BitColormapValues)) @@ -112,7 +114,7 @@ _COMPRESSION_TYPES = {"none": 0xFF, "rle": 0x01, "scanline": 0x00} # (Internal) Image save plugin for the Palm format. -def _save(im, fp, filename): +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if im.mode == "P": # we assume this is a color Palm image with the standard colormap, # unless the "info" dict has a "custom-colormap" field @@ -127,21 +129,22 @@ def _save(im, fp, filename): # and invert it because # Palm does grayscale from white (0) to black (1) bpp = im.encoderinfo["bpp"] - im = im.point( - lambda x, shift=8 - bpp, maxval=(1 << bpp) - 1: maxval - (x >> shift) - ) + maxval = (1 << bpp) - 1 + shift = 8 - bpp + im = im.point(lambda x: maxval - (x >> shift)) elif im.info.get("bpp") in (1, 2, 4): # here we assume that even though the inherent mode is 8-bit grayscale, # only the lower bpp bits are significant. # We invert them to match the Palm. bpp = im.info["bpp"] - im = im.point(lambda x, maxval=(1 << bpp) - 1: maxval - (x & maxval)) + maxval = (1 << bpp) - 1 + im = im.point(lambda x: maxval - (x & maxval)) else: msg = f"cannot write mode {im.mode} as Palm" raise OSError(msg) # we ignore the palette here - im.mode = "P" + im._mode = "P" rawmode = f"P;{bpp}" version = 1 diff --git a/src/PIL/PcxImagePlugin.py b/src/PIL/PcxImagePlugin.py index 026bfd9a0..dd42003b5 100644 --- a/src/PIL/PcxImagePlugin.py +++ b/src/PIL/PcxImagePlugin.py @@ -144,7 +144,7 @@ SAVE = { } -def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None: +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: try: version, bits, planes, rawmode = SAVE[im.mode] except KeyError as e: diff --git a/src/PIL/PdfImagePlugin.py b/src/PIL/PdfImagePlugin.py index 1777f1f20..f0da1e047 100644 --- a/src/PIL/PdfImagePlugin.py +++ b/src/PIL/PdfImagePlugin.py @@ -25,6 +25,7 @@ import io import math import os import time +from typing import IO from . import Image, ImageFile, ImageSequence, PdfParser, __version__, features @@ -39,7 +40,7 @@ from . import Image, ImageFile, ImageSequence, PdfParser, __version__, features # 5. page contents -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) diff --git a/src/PIL/PdfParser.py b/src/PIL/PdfParser.py index 68501d625..9e2231347 100644 --- a/src/PIL/PdfParser.py +++ b/src/PIL/PdfParser.py @@ -13,7 +13,7 @@ from typing import TYPE_CHECKING, Any, List, NamedTuple, Union # see 7.9.2.2 Text String Type on page 86 and D.3 PDFDocEncoding Character Set # on page 656 -def encode_text(s): +def encode_text(s: str) -> bytes: return codecs.BOM_UTF16_BE + s.encode("utf_16_be") @@ -76,7 +76,7 @@ class PdfFormatError(RuntimeError): pass -def check_format_condition(condition, error_message): +def check_format_condition(condition: bool, error_message: str) -> None: if not condition: raise PdfFormatError(error_message) @@ -93,17 +93,16 @@ class IndirectReference(IndirectReferenceTuple): def __bytes__(self) -> bytes: return self.__str__().encode("us-ascii") - def __eq__(self, other): - return ( - other.__class__ is self.__class__ - and other.object_id == self.object_id - and other.generation == self.generation - ) + def __eq__(self, other: object) -> bool: + if self.__class__ is not other.__class__: + return False + assert isinstance(other, IndirectReference) + return other.object_id == self.object_id and other.generation == self.generation def __ne__(self, other): return not (self == other) - def __hash__(self): + def __hash__(self) -> int: return hash((self.object_id, self.generation)) @@ -219,7 +218,7 @@ class PdfName: isinstance(other, PdfName) and other.name == self.name ) or other == self.name - def __hash__(self): + def __hash__(self) -> int: return hash(self.name) def __repr__(self) -> str: @@ -402,12 +401,11 @@ class PdfParser: if f: self.seek_end() - def __enter__(self): + def __enter__(self) -> PdfParser: return self - def __exit__(self, exc_type, exc_value, traceback): + def __exit__(self, *args: object) -> None: self.close() - return False # do not suppress exceptions def start_writing(self) -> None: self.close_buf() @@ -436,7 +434,7 @@ class PdfParser: def write_comment(self, s): self.f.write(f"% {s}\n".encode()) - def write_catalog(self): + def write_catalog(self) -> IndirectReference: self.del_root() self.root_ref = self.next_object_id(self.f.tell()) self.pages_ref = self.next_object_id(0) diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index 0d5751962..a2ca9399a 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -39,7 +39,7 @@ import struct import warnings import zlib from enum import IntEnum -from typing import IO +from typing import IO, TYPE_CHECKING, Any, NoReturn from . import Image, ImageChops, ImageFile, ImagePalette, ImageSequence from ._binary import i16be as i16 @@ -48,6 +48,9 @@ from ._binary import o8 from ._binary import o16be as o16 from ._binary import o32be as o32 +if TYPE_CHECKING: + from . import _imaging + logger = logging.getLogger(__name__) is_cid = re.compile(rb"\w\w\w\w").match @@ -178,7 +181,7 @@ class ChunkStream: def __enter__(self) -> ChunkStream: return self - def __exit__(self, *args): + def __exit__(self, *args: object) -> None: self.close() def close(self) -> None: @@ -249,6 +252,9 @@ class iTXt(str): """ + lang: str | bytes | None + tkey: str | bytes | None + @staticmethod def __new__(cls, text, lang=None, tkey=None): """ @@ -270,10 +276,10 @@ class PngInfo: """ - def __init__(self): - self.chunks = [] + def __init__(self) -> None: + self.chunks: list[tuple[bytes, bytes, bool]] = [] - def add(self, cid, data, after_idat=False): + def add(self, cid: bytes, data: bytes, after_idat: bool = False) -> None: """Appends an arbitrary chunk. Use with caution. :param cid: a byte string, 4 bytes long. @@ -283,12 +289,16 @@ class PngInfo: """ - chunk = [cid, data] - if after_idat: - chunk.append(True) - self.chunks.append(tuple(chunk)) + self.chunks.append((cid, data, after_idat)) - def add_itxt(self, key, value, lang="", tkey="", zip=False): + def add_itxt( + self, + key: str | bytes, + value: str | bytes, + lang: str | bytes = "", + tkey: str | bytes = "", + zip: bool = False, + ) -> None: """Appends an iTXt chunk. :param key: latin-1 encodable text key name @@ -316,7 +326,9 @@ class PngInfo: else: self.add(b"iTXt", key + b"\0\0\0" + lang + b"\0" + tkey + b"\0" + value) - def add_text(self, key, value, zip=False): + def add_text( + self, key: str | bytes, value: str | bytes | iTXt, zip: bool = False + ) -> None: """Appends a text chunk. :param key: latin-1 encodable text key name @@ -326,7 +338,13 @@ class PngInfo: """ if isinstance(value, iTXt): - return self.add_itxt(key, value, value.lang, value.tkey, zip=zip) + return self.add_itxt( + key, + value, + value.lang if value.lang is not None else b"", + value.tkey if value.tkey is not None else b"", + zip=zip, + ) # The tEXt chunk stores latin-1 text if not isinstance(value, bytes): @@ -434,7 +452,7 @@ class PngStream(ChunkStream): raise SyntaxError(msg) return s - def chunk_IDAT(self, pos, length): + def chunk_IDAT(self, pos: int, length: int) -> NoReturn: # image data if "bbox" in self.im_info: tile = [("zip", self.im_info["bbox"], pos, self.im_rawmode)] @@ -447,7 +465,7 @@ class PngStream(ChunkStream): msg = "image data found" raise EOFError(msg) - def chunk_IEND(self, pos, length): + def chunk_IEND(self, pos: int, length: int) -> NoReturn: msg = "end of PNG image" raise EOFError(msg) @@ -606,6 +624,8 @@ class PngStream(ChunkStream): return s else: return s + if k == b"XML:com.adobe.xmp": + self.im_info["xmp"] = v try: k = k.decode("latin-1", "strict") lang = lang.decode("utf-8", "strict") @@ -821,7 +841,10 @@ class PngImageFile(ImageFile.ImageFile): msg = "no more images in APNG file" raise EOFError(msg) from e - def _seek(self, frame, rewind=False): + def _seek(self, frame: int, rewind: bool = False) -> None: + assert self.png is not None + + self.dispose: _imaging.ImagingCore | None if frame == 0: if rewind: self._fp.seek(self.__rewind) @@ -906,14 +929,14 @@ class PngImageFile(ImageFile.ImageFile): if self._prev_im is None and self.dispose_op == Disposal.OP_PREVIOUS: self.dispose_op = Disposal.OP_BACKGROUND + self.dispose = None if self.dispose_op == Disposal.OP_PREVIOUS: - self.dispose = self._prev_im.copy() - self.dispose = self._crop(self.dispose, self.dispose_extent) + if self._prev_im: + self.dispose = self._prev_im.copy() + self.dispose = self._crop(self.dispose, self.dispose_extent) elif self.dispose_op == Disposal.OP_BACKGROUND: self.dispose = Image.core.fill(self.mode, self.size) self.dispose = self._crop(self.dispose, self.dispose_extent) - else: - self.dispose = None def tell(self) -> int: return self.__frame @@ -1019,32 +1042,19 @@ class PngImageFile(ImageFile.ImageFile): if self.pyaccess: self.pyaccess = None - def _getexif(self): + def _getexif(self) -> dict[str, Any] | None: if "exif" not in self.info: self.load() if "exif" not in self.info and "Raw profile type exif" not in self.info: return None return self.getexif()._get_merged_dict() - def getexif(self): + def getexif(self) -> Image.Exif: if "exif" not in self.info: self.load() return super().getexif() - def getxmp(self): - """ - Returns a dictionary containing the XMP tags. - Requires defusedxml to be installed. - - :returns: XMP tags in a dictionary. - """ - return ( - self._getxmp(self.info["XML:com.adobe.xmp"]) - if "XML:com.adobe.xmp" in self.info - else {} - ) - # -------------------------------------------------------------------- # PNG writer @@ -1234,7 +1244,7 @@ def _write_multiple_frames(im, fp, chunk, rawmode, default_image, append_images) seq_num = fdat_chunks.seq_num -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) @@ -1346,7 +1356,7 @@ def _save(im, fp, filename, chunk=putchunk, save_all=False): chunk(fp, cid, data) elif cid[1:2].islower(): # Private chunk - after_idat = info_chunk[2:3] + after_idat = len(info_chunk) == 3 and info_chunk[2] if not after_idat: chunk(fp, cid, data) @@ -1425,7 +1435,7 @@ def _save(im, fp, filename, chunk=putchunk, save_all=False): cid, data = info_chunk[:2] if cid[1:2].islower(): # Private chunk - after_idat = info_chunk[2:3] + after_idat = len(info_chunk) == 3 and info_chunk[2] if after_idat: chunk(fp, cid, data) diff --git a/src/PIL/PpmImagePlugin.py b/src/PIL/PpmImagePlugin.py index 94bf430b8..16c9ccbba 100644 --- a/src/PIL/PpmImagePlugin.py +++ b/src/PIL/PpmImagePlugin.py @@ -328,7 +328,7 @@ class PpmDecoder(ImageFile.PyDecoder): # -------------------------------------------------------------------- -def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None: +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if im.mode == "1": rawmode, head = "1;I", b"P4" elif im.mode == "L": diff --git a/src/PIL/PyAccess.py b/src/PIL/PyAccess.py index a9da90613..fe12cb641 100644 --- a/src/PIL/PyAccess.py +++ b/src/PIL/PyAccess.py @@ -22,6 +22,7 @@ from __future__ import annotations import logging import sys +from typing import TYPE_CHECKING from ._deprecate import deprecate @@ -48,9 +49,12 @@ except ImportError as ex: logger = logging.getLogger(__name__) +if TYPE_CHECKING: + from . import Image + class PyAccess: - def __init__(self, img, readonly=False): + def __init__(self, img: Image.Image, readonly: bool = False) -> None: deprecate("PyAccess", 11) vals = dict(img.im.unsafe_ptrs) self.readonly = readonly @@ -77,7 +81,8 @@ class PyAccess: """ Modifies the pixel at x,y. The color is given as a single numerical value for single band images, and a tuple for - multi-band images + multi-band images. In addition to this, RGB and RGBA tuples + are accepted for P and PA images. :param xy: The pixel coordinate, given as (x, y). See :ref:`coordinate-system`. @@ -108,7 +113,7 @@ class PyAccess: return self.set_pixel(x, y, color) - def __getitem__(self, xy): + def __getitem__(self, xy: tuple[int, int]) -> float | tuple[int, ...]: """ Returns the pixel at x,y. The pixel is returned as a single value for single band images or a tuple for multiple band @@ -130,13 +135,19 @@ class PyAccess: putpixel = __setitem__ getpixel = __getitem__ - def check_xy(self, xy): + def check_xy(self, xy: tuple[int, int]) -> tuple[int, int]: (x, y) = xy if not (0 <= x < self.xsize and 0 <= y < self.ysize): msg = "pixel location out of range" raise ValueError(msg) return xy + def get_pixel(self, x: int, y: int) -> float | tuple[int, ...]: + raise NotImplementedError() + + def set_pixel(self, x: int, y: int, color: float | tuple[int, ...]) -> None: + raise NotImplementedError() + class _PyAccess32_2(PyAccess): """PA, LA, stored in first and last bytes of a 32 bit word""" @@ -144,7 +155,7 @@ class _PyAccess32_2(PyAccess): def _post_init(self, *args, **kwargs): self.pixels = ffi.cast("struct Pixel_RGBA **", self.image32) - def get_pixel(self, x, y): + def get_pixel(self, x: int, y: int) -> tuple[int, int]: pixel = self.pixels[y][x] return pixel.r, pixel.a @@ -161,7 +172,7 @@ class _PyAccess32_3(PyAccess): def _post_init(self, *args, **kwargs): self.pixels = ffi.cast("struct Pixel_RGBA **", self.image32) - def get_pixel(self, x, y): + def get_pixel(self, x: int, y: int) -> tuple[int, int, int]: pixel = self.pixels[y][x] return pixel.r, pixel.g, pixel.b @@ -180,7 +191,7 @@ class _PyAccess32_4(PyAccess): def _post_init(self, *args, **kwargs): self.pixels = ffi.cast("struct Pixel_RGBA **", self.image32) - def get_pixel(self, x, y): + def get_pixel(self, x: int, y: int) -> tuple[int, int, int, int]: pixel = self.pixels[y][x] return pixel.r, pixel.g, pixel.b, pixel.a @@ -199,7 +210,7 @@ class _PyAccess8(PyAccess): def _post_init(self, *args, **kwargs): self.pixels = self.image8 - def get_pixel(self, x, y): + def get_pixel(self, x: int, y: int) -> int: return self.pixels[y][x] def set_pixel(self, x, y, color): @@ -217,7 +228,7 @@ class _PyAccessI16_N(PyAccess): def _post_init(self, *args, **kwargs): self.pixels = ffi.cast("unsigned short **", self.image) - def get_pixel(self, x, y): + def get_pixel(self, x: int, y: int) -> int: return self.pixels[y][x] def set_pixel(self, x, y, color): @@ -235,7 +246,7 @@ class _PyAccessI16_L(PyAccess): def _post_init(self, *args, **kwargs): self.pixels = ffi.cast("struct Pixel_I16 **", self.image) - def get_pixel(self, x, y): + def get_pixel(self, x: int, y: int) -> int: pixel = self.pixels[y][x] return pixel.l + pixel.r * 256 @@ -256,7 +267,7 @@ class _PyAccessI16_B(PyAccess): def _post_init(self, *args, **kwargs): self.pixels = ffi.cast("struct Pixel_I16 **", self.image) - def get_pixel(self, x, y): + def get_pixel(self, x: int, y: int) -> int: pixel = self.pixels[y][x] return pixel.l * 256 + pixel.r @@ -277,7 +288,7 @@ class _PyAccessI32_N(PyAccess): def _post_init(self, *args, **kwargs): self.pixels = self.image32 - def get_pixel(self, x, y): + def get_pixel(self, x: int, y: int) -> int: return self.pixels[y][x] def set_pixel(self, x, y, color): @@ -296,7 +307,7 @@ class _PyAccessI32_Swap(PyAccess): chars[0], chars[1], chars[2], chars[3] = chars[3], chars[2], chars[1], chars[0] return ffi.cast("int *", chars)[0] - def get_pixel(self, x, y): + def get_pixel(self, x: int, y: int) -> int: return self.reverse(self.pixels[y][x]) def set_pixel(self, x, y, color): @@ -309,7 +320,7 @@ class _PyAccessF(PyAccess): def _post_init(self, *args, **kwargs): self.pixels = ffi.cast("float **", self.image32) - def get_pixel(self, x, y): + def get_pixel(self, x: int, y: int) -> float: return self.pixels[y][x] def set_pixel(self, x, y, color): @@ -357,7 +368,7 @@ else: mode_map["I;32B"] = _PyAccessI32_N -def new(img, readonly=False): +def new(img: Image.Image, readonly: bool = False) -> PyAccess | None: access_type = mode_map.get(img.mode, None) if not access_type: logger.debug("PyAccess Not Implemented: %s", img.mode) diff --git a/src/PIL/QoiImagePlugin.py b/src/PIL/QoiImagePlugin.py index cea8b60da..202ef52d0 100644 --- a/src/PIL/QoiImagePlugin.py +++ b/src/PIL/QoiImagePlugin.py @@ -37,17 +37,20 @@ class QoiImageFile(ImageFile.ImageFile): class QoiDecoder(ImageFile.PyDecoder): _pulls_fd = True + _previous_pixel: bytes | bytearray | None = None + _previously_seen_pixels: dict[int, bytes | bytearray] = {} - def _add_to_previous_pixels(self, value): + def _add_to_previous_pixels(self, value: bytes | bytearray) -> None: self._previous_pixel = value r, g, b, a = value hash_value = (r * 3 + g * 5 + b * 7 + a * 11) % 64 self._previously_seen_pixels[hash_value] = value - def decode(self, buffer): + def decode(self, buffer: bytes) -> tuple[int, int]: + assert self.fd is not None + self._previously_seen_pixels = {} - self._previous_pixel = None self._add_to_previous_pixels(bytearray((0, 0, 0, 255))) data = bytearray() @@ -55,7 +58,8 @@ class QoiDecoder(ImageFile.PyDecoder): dest_length = self.state.xsize * self.state.ysize * bands while len(data) < dest_length: byte = self.fd.read(1)[0] - if byte == 0b11111110: # QOI_OP_RGB + value: bytes | bytearray + if byte == 0b11111110 and self._previous_pixel: # QOI_OP_RGB value = bytearray(self.fd.read(3)) + self._previous_pixel[3:] elif byte == 0b11111111: # QOI_OP_RGBA value = self.fd.read(4) @@ -66,7 +70,7 @@ class QoiDecoder(ImageFile.PyDecoder): value = self._previously_seen_pixels.get( op_index, bytearray((0, 0, 0, 0)) ) - elif op == 1: # QOI_OP_DIFF + elif op == 1 and self._previous_pixel: # QOI_OP_DIFF value = bytearray( ( (self._previous_pixel[0] + ((byte & 0b00110000) >> 4) - 2) @@ -77,7 +81,7 @@ class QoiDecoder(ImageFile.PyDecoder): self._previous_pixel[3], ) ) - elif op == 2: # QOI_OP_LUMA + elif op == 2 and self._previous_pixel: # QOI_OP_LUMA second_byte = self.fd.read(1)[0] diff_green = (byte & 0b00111111) - 32 diff_red = ((second_byte & 0b11110000) >> 4) - 8 @@ -90,7 +94,7 @@ class QoiDecoder(ImageFile.PyDecoder): ) ) value += self._previous_pixel[3:] - elif op == 3: # QOI_OP_RUN + elif op == 3 and self._previous_pixel: # QOI_OP_RUN run_length = (byte & 0b00111111) + 1 value = self._previous_pixel if bands == 3: diff --git a/src/PIL/SgiImagePlugin.py b/src/PIL/SgiImagePlugin.py index 7bd84ebd4..50d979109 100644 --- a/src/PIL/SgiImagePlugin.py +++ b/src/PIL/SgiImagePlugin.py @@ -125,7 +125,7 @@ class SgiImageFile(ImageFile.ImageFile): ] -def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None: +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if im.mode not in {"RGB", "RGBA", "L"}: msg = "Unsupported SGI image mode" raise ValueError(msg) @@ -171,8 +171,9 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None: # Maximum Byte value (255 = 8bits per pixel) pinmax = 255 # Image name (79 characters max, truncated below in write) - filename = os.path.basename(filename) - img_name = os.path.splitext(filename)[0].encode("ascii", "ignore") + img_name = os.path.splitext(os.path.basename(filename))[0] + if isinstance(img_name, str): + img_name = img_name.encode("ascii", "ignore") # Standard representation of pixel in the file colormap = 0 fp.write(struct.pack(">h", magic_number)) diff --git a/src/PIL/SpiderImagePlugin.py b/src/PIL/SpiderImagePlugin.py index 5b8ad47f0..a6cc00019 100644 --- a/src/PIL/SpiderImagePlugin.py +++ b/src/PIL/SpiderImagePlugin.py @@ -37,7 +37,7 @@ from __future__ import annotations import os import struct import sys -from typing import TYPE_CHECKING +from typing import IO, TYPE_CHECKING from . import Image, ImageFile @@ -233,7 +233,7 @@ def loadImageSeries(filelist=None): # For saving images in Spider format -def makeSpiderHeader(im): +def makeSpiderHeader(im: Image.Image) -> list[bytes]: nsam, nrow = im.size lenbyt = nsam * 4 # There are labrec records in the header labrec = int(1024 / lenbyt) @@ -263,7 +263,7 @@ def makeSpiderHeader(im): return [struct.pack("f", v) for v in hdr] -def _save(im, fp, filename): +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if im.mode[0] != "F": im = im.convert("F") @@ -279,9 +279,10 @@ def _save(im, fp, filename): ImageFile._save(im, fp, [("raw", (0, 0) + im.size, 0, (rawmode, 0, 1))]) -def _save_spider(im, fp, filename): +def _save_spider(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: # get the filename extension and register it with Image - ext = os.path.splitext(filename)[1] + filename_ext = os.path.splitext(filename)[1] + ext = filename_ext.decode() if isinstance(filename_ext, bytes) else filename_ext Image.register_extension(SpiderImageFile.format, ext) _save(im, fp, filename) diff --git a/src/PIL/TarIO.py b/src/PIL/TarIO.py index 7470663b4..cba26d4b0 100644 --- a/src/PIL/TarIO.py +++ b/src/PIL/TarIO.py @@ -16,7 +16,6 @@ from __future__ import annotations import io -from types import TracebackType from . import ContainerIO @@ -61,12 +60,7 @@ class TarIO(ContainerIO.ContainerIO[bytes]): def __enter__(self) -> TarIO: return self - def __exit__( - self, - exc_type: type[BaseException] | None, - exc_val: BaseException | None, - exc_tb: TracebackType | None, - ) -> None: + def __exit__(self, *args: object) -> None: self.close() def close(self) -> None: diff --git a/src/PIL/TgaImagePlugin.py b/src/PIL/TgaImagePlugin.py index 401a83f9f..f16f075df 100644 --- a/src/PIL/TgaImagePlugin.py +++ b/src/PIL/TgaImagePlugin.py @@ -178,7 +178,7 @@ SAVE = { } -def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None: +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: try: rawmode, bits, colormaptype, imagetype = SAVE[im.mode] except KeyError as e: diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index 54faa59c5..b8e486929 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -50,7 +50,7 @@ import warnings from collections.abc import MutableMapping from fractions import Fraction from numbers import Number, Rational -from typing import TYPE_CHECKING, Any, Callable +from typing import IO, TYPE_CHECKING, Any, Callable, NoReturn from . import ExifTags, Image, ImageFile, ImageOps, ImagePalette, TiffTags from ._binary import i16be as i16 @@ -384,10 +384,10 @@ class IFDRational(Rational): def __repr__(self) -> str: return str(float(self._val)) - def __hash__(self): + def __hash__(self) -> int: return self._val.__hash__() - def __eq__(self, other): + def __eq__(self, other: object) -> bool: val = self._val if isinstance(other, IFDRational): other = other._val @@ -551,7 +551,12 @@ class ImageFileDirectory_v2(_IFDv2Base): _load_dispatch: dict[int, Callable[[ImageFileDirectory_v2, bytes, bool], Any]] = {} _write_dispatch: dict[int, Callable[..., Any]] = {} - def __init__(self, ifh=b"II\052\0\0\0\0\0", prefix=None, group=None): + def __init__( + self, + ifh: bytes = b"II\052\0\0\0\0\0", + prefix: bytes | None = None, + group: int | None = None, + ) -> None: """Initialize an ImageFileDirectory. To construct an ImageFileDirectory from a real file, pass the 8-byte @@ -575,7 +580,7 @@ class ImageFileDirectory_v2(_IFDv2Base): raise SyntaxError(msg) self._bigtiff = ifh[2] == 43 self.group = group - self.tagtype = {} + self.tagtype: dict[int, int] = {} """ Dictionary of tag types """ self.reset() (self.next,) = ( @@ -587,18 +592,18 @@ class ImageFileDirectory_v2(_IFDv2Base): offset = property(lambda self: self._offset) @property - def legacy_api(self): + def legacy_api(self) -> bool: return self._legacy_api @legacy_api.setter - def legacy_api(self, value): + def legacy_api(self, value: bool) -> NoReturn: msg = "Not allowing setting of legacy api" raise Exception(msg) - def reset(self): - self._tags_v1 = {} # will remain empty if legacy_api is false - self._tags_v2 = {} # main tag storage - self._tagdata = {} + def reset(self) -> None: + self._tags_v1: dict[int, Any] = {} # will remain empty if legacy_api is false + self._tags_v2: dict[int, Any] = {} # main tag storage + self._tagdata: dict[int, bytes] = {} self.tagtype = {} # added 2008-06-05 by Florian Hoech self._next = None self._offset = None @@ -717,7 +722,7 @@ class ImageFileDirectory_v2(_IFDv2Base): # Unspec'd, and length > 1 dest[tag] = values - def __delitem__(self, tag): + def __delitem__(self, tag: int) -> None: self._tags_v2.pop(tag, None) self._tags_v1.pop(tag, None) self._tagdata.pop(tag, None) @@ -1106,7 +1111,7 @@ class TiffImageFile(ImageFile.ImageFile): super().__init__(fp, filename) - def _open(self): + def _open(self) -> None: """Open the first image in a TIFF file""" # Header @@ -1123,8 +1128,8 @@ class TiffImageFile(ImageFile.ImageFile): self.__first = self.__next = self.tag_v2.next self.__frame = -1 self._fp = self.fp - self._frame_pos = [] - self._n_frames = None + self._frame_pos: list[int] = [] + self._n_frames: int | None = None logger.debug("*** TiffImageFile._open ***") logger.debug("- __first: %s", self.__first) @@ -1192,6 +1197,10 @@ class TiffImageFile(ImageFile.ImageFile): self.__frame += 1 self.fp.seek(self._frame_pos[frame]) self.tag_v2.load(self.fp) + if XMP in self.tag_v2: + self.info["xmp"] = self.tag_v2[XMP] + elif "xmp" in self.info: + del self.info["xmp"] self._reload_exif() # fill the legacy tag/ifd entries self.tag = self.ifd = ImageFileDirectory_v1.from_v2(self.tag_v2) @@ -1202,15 +1211,6 @@ class TiffImageFile(ImageFile.ImageFile): """Return the current frame number""" return self.__frame - def getxmp(self): - """ - Returns a dictionary containing the XMP tags. - Requires defusedxml to be installed. - - :returns: XMP tags in a dictionary. - """ - return self._getxmp(self.tag_v2[XMP]) if XMP in self.tag_v2 else {} - def get_photoshop_blocks(self): """ Returns a dictionary of Photoshop "Image Resource Blocks". @@ -1995,13 +1995,12 @@ class AppendingTiffWriter: self.finalize() self.setup() - def __enter__(self): + def __enter__(self) -> AppendingTiffWriter: return self - def __exit__(self, exc_type, exc_value, traceback): + def __exit__(self, *args: object) -> None: if self.close_fp: self.close() - return False def tell(self) -> int: return self.f.tell() - self.offsetOfNewPage @@ -2023,7 +2022,7 @@ class AppendingTiffWriter: self.f.write(bytes(pad_bytes)) self.offsetOfNewPage = self.f.tell() - def setEndian(self, endian): + def setEndian(self, endian: str) -> None: self.endian = endian self.longFmt = f"{self.endian}L" self.shortFmt = f"{self.endian}H" @@ -2040,45 +2039,45 @@ class AppendingTiffWriter: num_tags = self.readShort() self.f.seek(num_tags * 12, os.SEEK_CUR) - def write(self, data): + def write(self, data: bytes) -> int | None: return self.f.write(data) - def readShort(self): + def readShort(self) -> int: (value,) = struct.unpack(self.shortFmt, self.f.read(2)) return value - def readLong(self): + def readLong(self) -> int: (value,) = struct.unpack(self.longFmt, self.f.read(4)) return value - def rewriteLastShortToLong(self, value): + def rewriteLastShortToLong(self, value: int) -> None: self.f.seek(-2, os.SEEK_CUR) bytes_written = self.f.write(struct.pack(self.longFmt, value)) if bytes_written is not None and bytes_written != 4: msg = f"wrote only {bytes_written} bytes but wanted 4" raise RuntimeError(msg) - def rewriteLastShort(self, value): + def rewriteLastShort(self, value: int) -> None: self.f.seek(-2, os.SEEK_CUR) bytes_written = self.f.write(struct.pack(self.shortFmt, value)) if bytes_written is not None and bytes_written != 2: msg = f"wrote only {bytes_written} bytes but wanted 2" raise RuntimeError(msg) - def rewriteLastLong(self, value): + def rewriteLastLong(self, value: int) -> None: self.f.seek(-4, os.SEEK_CUR) bytes_written = self.f.write(struct.pack(self.longFmt, value)) if bytes_written is not None and bytes_written != 4: msg = f"wrote only {bytes_written} bytes but wanted 4" raise RuntimeError(msg) - def writeShort(self, value): + def writeShort(self, value: int) -> None: bytes_written = self.f.write(struct.pack(self.shortFmt, value)) if bytes_written is not None and bytes_written != 2: msg = f"wrote only {bytes_written} bytes but wanted 2" raise RuntimeError(msg) - def writeLong(self, value): + def writeLong(self, value: int) -> None: bytes_written = self.f.write(struct.pack(self.longFmt, value)) if bytes_written is not None and bytes_written != 4: msg = f"wrote only {bytes_written} bytes but wanted 4" @@ -2097,9 +2096,9 @@ class AppendingTiffWriter: field_size = self.fieldSizes[field_type] total_size = field_size * count is_local = total_size <= 4 + offset: int | None if not is_local: - offset = self.readLong() - offset += self.offsetOfNewPage + offset = self.readLong() + self.offsetOfNewPage self.rewriteLastLong(offset) if tag in self.Tags: @@ -2123,7 +2122,9 @@ class AppendingTiffWriter: # skip the locally stored value that is not an offset self.f.seek(4, os.SEEK_CUR) - def fixOffsets(self, count, isShort=False, isLong=False): + def fixOffsets( + self, count: int, isShort: bool = False, isLong: bool = False + ) -> None: if not isShort and not isLong: msg = "offset is neither short nor long" raise RuntimeError(msg) @@ -2149,7 +2150,7 @@ class AppendingTiffWriter: self.rewriteLastLong(offset) -def _save_all(im, fp, filename): +def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: encoderinfo = im.encoderinfo.copy() encoderconfig = im.encoderconfig append_images = list(encoderinfo.get("append_images", [])) diff --git a/src/PIL/TiffTags.py b/src/PIL/TiffTags.py index 89fad7033..e318c8739 100644 --- a/src/PIL/TiffTags.py +++ b/src/PIL/TiffTags.py @@ -89,7 +89,7 @@ DOUBLE = 12 IFD = 13 LONG8 = 16 -TAGS_V2 = { +_tags_v2 = { 254: ("NewSubfileType", LONG, 1), 255: ("SubfileType", SHORT, 1), 256: ("ImageWidth", LONG, 1), @@ -425,9 +425,11 @@ TAGS = { 50784: "Alias Layer Metadata", } +TAGS_V2: dict[int, TagInfo] = {} + def _populate(): - for k, v in TAGS_V2.items(): + for k, v in _tags_v2.items(): # Populate legacy structure. TAGS[k] = v[0] if len(v) == 4: diff --git a/src/PIL/WebPImagePlugin.py b/src/PIL/WebPImagePlugin.py index cae124e9f..59be5bf9d 100644 --- a/src/PIL/WebPImagePlugin.py +++ b/src/PIL/WebPImagePlugin.py @@ -1,6 +1,7 @@ from __future__ import annotations from io import BytesIO +from typing import IO, Any from . import Image, ImageFile @@ -95,20 +96,11 @@ class WebPImageFile(ImageFile.ImageFile): # Initialize seek state self._reset(reset=False) - def _getexif(self): + def _getexif(self) -> dict[str, Any] | None: if "exif" not in self.info: return None return self.getexif()._get_merged_dict() - def getxmp(self): - """ - Returns a dictionary containing the XMP tags. - Requires defusedxml to be installed. - - :returns: XMP tags in a dictionary. - """ - return self._getxmp(self.info["xmp"]) if "xmp" in self.info else {} - def seek(self, frame: int) -> None: if not self._seek_check(frame): return @@ -116,7 +108,7 @@ class WebPImageFile(ImageFile.ImageFile): # Set logical frame to requested position self.__logical_frame = frame - def _reset(self, reset=True): + def _reset(self, reset: bool = True) -> None: if reset: self._decoder.reset() self.__physical_frame = 0 @@ -181,7 +173,7 @@ class WebPImageFile(ImageFile.ImageFile): return self.__logical_frame -def _save_all(im, fp, filename): +def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: encoderinfo = im.encoderinfo.copy() append_images = list(encoderinfo.get("append_images", [])) @@ -194,7 +186,7 @@ def _save_all(im, fp, filename): _save(im, fp, filename) return - background = (0, 0, 0, 0) + background: int | tuple[int, ...] = (0, 0, 0, 0) if "background" in encoderinfo: background = encoderinfo["background"] elif "background" in im.info: @@ -324,7 +316,7 @@ def _save_all(im, fp, filename): fp.write(data) -def _save(im, fp, filename): +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: lossless = im.encoderinfo.get("lossless", False) quality = im.encoderinfo.get("quality", 80) alpha_quality = im.encoderinfo.get("alpha_quality", 100) diff --git a/src/PIL/WmfImagePlugin.py b/src/PIL/WmfImagePlugin.py index b0328657b..3d5cddcc8 100644 --- a/src/PIL/WmfImagePlugin.py +++ b/src/PIL/WmfImagePlugin.py @@ -20,6 +20,8 @@ # http://wvware.sourceforge.net/caolan/ora-wmf.html from __future__ import annotations +from typing import IO + from . import Image, ImageFile from ._binary import i16le as word from ._binary import si16le as short @@ -28,7 +30,7 @@ from ._binary import si32le as _long _handler = None -def register_handler(handler): +def register_handler(handler: ImageFile.StubHandler | None) -> None: """ Install application-specific WMF image handler. @@ -41,12 +43,12 @@ def register_handler(handler): if hasattr(Image.core, "drawwmf"): # install default handler (windows only) - class WmfHandler: - def open(self, im): + class WmfHandler(ImageFile.StubHandler): + def open(self, im: ImageFile.StubImageFile) -> None: im._mode = "RGB" self.bbox = im.info["wmf_bbox"] - def load(self, im): + def load(self, im: ImageFile.StubImageFile) -> Image.Image: im.fp.seek(0) # rewind return Image.frombytes( "RGB", @@ -147,7 +149,7 @@ class WmfStubImageFile(ImageFile.StubImageFile): if loader: loader.open(self) - def _load(self): + def _load(self) -> ImageFile.StubHandler | None: return _handler def load(self, dpi=None): @@ -161,7 +163,7 @@ class WmfStubImageFile(ImageFile.StubImageFile): return super().load() -def _save(im, fp, filename): +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if _handler is None or not hasattr(_handler, "save"): msg = "WMF save handler not installed" raise OSError(msg) diff --git a/src/PIL/XbmImagePlugin.py b/src/PIL/XbmImagePlugin.py index eee727436..6d11bbfcf 100644 --- a/src/PIL/XbmImagePlugin.py +++ b/src/PIL/XbmImagePlugin.py @@ -70,7 +70,7 @@ class XbmImageFile(ImageFile.ImageFile): self.tile = [("xbm", (0, 0) + self.size, m.end(), None)] -def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None: +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if im.mode != "1": msg = f"cannot write mode {im.mode} as XBM" raise OSError(msg) diff --git a/src/PIL/_imaging.pyi b/src/PIL/_imaging.pyi index e27843e53..2bfe421e6 100644 --- a/src/PIL/_imaging.pyi +++ b/src/PIL/_imaging.pyi @@ -1,3 +1,27 @@ from typing import Any +class ImagingCore: + def __getattr__(self, name: str) -> Any: ... + +class ImagingFont: + def __getattr__(self, name: str) -> Any: ... + +class ImagingDraw: + def __getattr__(self, name: str) -> Any: ... + +class PixelAccess: + def __getattr__(self, name: str) -> Any: ... + +class ImagingDecoder: + def __getattr__(self, name: str) -> Any: ... + +class ImagingEncoder: + def __getattr__(self, name: str) -> Any: ... + +class _Outline: + def close(self) -> None: ... + def __getattr__(self, name: str) -> Any: ... + +def font(image: ImagingCore, glyphdata: bytes) -> ImagingFont: ... +def outline() -> _Outline: ... def __getattr__(name: str) -> Any: ... diff --git a/src/PIL/_imagingcms.pyi b/src/PIL/_imagingcms.pyi index f704047be..2abd6d0f7 100644 --- a/src/PIL/_imagingcms.pyi +++ b/src/PIL/_imagingcms.pyi @@ -2,7 +2,7 @@ import datetime import sys from typing import Literal, SupportsFloat, TypedDict -littlecms_version: str +littlecms_version: str | None _Tuple3f = tuple[float, float, float] _Tuple2x3f = tuple[_Tuple3f, _Tuple3f] diff --git a/src/PIL/_imagingft.pyi b/src/PIL/_imagingft.pyi index e27843e53..6e0ddd2f1 100644 --- a/src/PIL/_imagingft.pyi +++ b/src/PIL/_imagingft.pyi @@ -1,3 +1,69 @@ -from typing import Any +from typing import Any, TypedDict +from . import _imaging + +class _Axis(TypedDict): + minimum: int | None + default: int | None + maximum: int | None + name: bytes | None + +class Font: + @property + def family(self) -> str | None: ... + @property + def style(self) -> str | None: ... + @property + def ascent(self) -> int: ... + @property + def descent(self) -> int: ... + @property + def height(self) -> int: ... + @property + def x_ppem(self) -> int: ... + @property + def y_ppem(self) -> int: ... + @property + def glyphs(self) -> int: ... + def render( + self, + string: str, + fill, + mode=..., + dir=..., + features=..., + lang=..., + stroke_width=..., + anchor=..., + foreground_ink_long=..., + x_start=..., + y_start=..., + /, + ) -> tuple[_imaging.ImagingCore, tuple[int, int]]: ... + def getsize( + self, + string: str | bytes | bytearray, + mode=..., + dir=..., + features=..., + lang=..., + anchor=..., + /, + ) -> tuple[tuple[int, int], tuple[int, int]]: ... + def getlength( + self, string: str, mode=..., dir=..., features=..., lang=..., / + ) -> float: ... + def getvarnames(self) -> list[bytes]: ... + def getvaraxes(self) -> list[_Axis] | None: ... + def setvarname(self, instance_index: int, /) -> None: ... + def setvaraxes(self, axes: list[float], /) -> None: ... + +def getfont( + filename: str | bytes, + size: float, + index=..., + encoding=..., + font_bytes=..., + layout_engine=..., +) -> Font: ... def __getattr__(name: str) -> Any: ... diff --git a/src/PIL/_typing.py b/src/PIL/_typing.py index 7075e8672..09ece18fa 100644 --- a/src/PIL/_typing.py +++ b/src/PIL/_typing.py @@ -2,7 +2,14 @@ from __future__ import annotations import os import sys -from typing import Protocol, Sequence, TypeVar, Union +from typing import Any, Protocol, Sequence, TypeVar, Union + +try: + import numpy.typing as npt + + NumpyArray = npt.NDArray[Any] +except ImportError: + pass if sys.version_info >= (3, 10): from typing import TypeGuard @@ -10,7 +17,6 @@ else: try: from typing_extensions import TypeGuard except ImportError: - from typing import Any class TypeGuard: # type: ignore[no-redef] def __class_getitem__(cls, item: Any) -> type[bool]: diff --git a/src/PIL/features.py b/src/PIL/features.py index 16c749f14..13908c4eb 100644 --- a/src/PIL/features.py +++ b/src/PIL/features.py @@ -4,6 +4,7 @@ import collections import os import sys import warnings +from typing import IO import PIL @@ -223,7 +224,7 @@ def get_supported() -> list[str]: return ret -def pilinfo(out=None, supported_formats=True): +def pilinfo(out: IO[str] | None = None, supported_formats: bool = True) -> None: """ Prints information about this installation of Pillow. This function can be called with ``python3 -m PIL``. @@ -244,9 +245,9 @@ def pilinfo(out=None, supported_formats=True): print("-" * 68, file=out) print(f"Pillow {PIL.__version__}", file=out) - py_version = sys.version.splitlines() - print(f"Python {py_version[0].strip()}", file=out) - for py_version in py_version[1:]: + py_version_lines = sys.version.splitlines() + print(f"Python {py_version_lines[0].strip()}", file=out) + for py_version in py_version_lines[1:]: print(f" {py_version.strip()}", file=out) print("-" * 68, file=out) print(f"Python executable is {sys.executable or 'unknown'}", file=out) @@ -282,9 +283,12 @@ def pilinfo(out=None, supported_formats=True): ("xcb", "XCB (X protocol)"), ]: if check(name): - if name == "jpg" and check_feature("libjpeg_turbo"): - v = "libjpeg-turbo " + version_feature("libjpeg_turbo") - else: + v: str | None = None + if name == "jpg": + libjpeg_turbo_version = version_feature("libjpeg_turbo") + if libjpeg_turbo_version is not None: + v = "libjpeg-turbo " + libjpeg_turbo_version + if v is None: v = version(name) if v is not None: version_static = name in ("pil", "jpg") diff --git a/src/_imaging.c b/src/_imaging.c index c565c21bb..ddc8d2885 100644 --- a/src/_imaging.c +++ b/src/_imaging.c @@ -1725,10 +1725,11 @@ _putpalette(ImagingObject *self, PyObject *args) { ImagingShuffler unpack; int bits; - char *rawmode, *palette_mode; + char *palette_mode, *rawmode; UINT8 *palette; Py_ssize_t palettesize; - if (!PyArg_ParseTuple(args, "sy#", &rawmode, &palette, &palettesize)) { + if (!PyArg_ParseTuple( + args, "ssy#", &palette_mode, &rawmode, &palette, &palettesize)) { return NULL; } @@ -1738,7 +1739,6 @@ _putpalette(ImagingObject *self, PyObject *args) { return NULL; } - palette_mode = strncmp("RGBA", rawmode, 4) == 0 ? "RGBA" : "RGB"; unpack = ImagingFindUnpacker(palette_mode, rawmode, &bits); if (!unpack) { PyErr_SetString(PyExc_ValueError, wrong_raw_mode); @@ -2028,7 +2028,7 @@ im_setmode(ImagingObject *self, PyObject *args) { } static PyObject * -_transform2(ImagingObject *self, PyObject *args) { +_transform(ImagingObject *self, PyObject *args) { static const char *wrong_number = "wrong number of matrix entries"; Imaging imOut; @@ -3647,7 +3647,7 @@ static struct PyMethodDef methods[] = { {"resize", (PyCFunction)_resize, METH_VARARGS}, {"reduce", (PyCFunction)_reduce, METH_VARARGS}, {"transpose", (PyCFunction)_transpose, METH_VARARGS}, - {"transform2", (PyCFunction)_transform2, METH_VARARGS}, + {"transform", (PyCFunction)_transform, METH_VARARGS}, {"isblock", (PyCFunction)_isblock, METH_NOARGS}, diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index 0d6da7754..9837589b2 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -112,12 +112,12 @@ ARCHITECTURES = { V = { "BROTLI": "1.1.0", "FREETYPE": "2.13.2", - "FRIBIDI": "1.0.13", - "HARFBUZZ": "8.4.0", - "JPEGTURBO": "3.0.2", + "FRIBIDI": "1.0.15", + "HARFBUZZ": "8.5.0", + "JPEGTURBO": "3.0.3", "LCMS2": "2.16", "LIBPNG": "1.6.43", - "LIBWEBP": "1.3.2", + "LIBWEBP": "1.4.0", "OPENJPEG": "2.5.2", "TIFF": "4.6.0", "XZ": "5.4.5",