From 1a14957c1954b18368a65bff06d8a959731d9e55 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sun, 9 Jun 2024 15:16:17 +1000 Subject: [PATCH 01/10] Added type hints --- Tests/test_decompression_bomb.py | 2 +- Tests/test_file_jpeg.py | 17 ++++++++++------- Tests/test_file_tiff.py | 2 +- Tests/test_image_mode.py | 6 +++++- Tests/test_image_quantize.py | 2 +- Tests/test_image_reduce.py | 12 ++++++++---- Tests/test_image_thumbnail.py | 2 +- Tests/test_imagedraw.py | 6 +++++- Tests/test_imagefont.py | 2 +- Tests/test_imageops.py | 2 +- Tests/test_imagewin_pointers.py | 2 +- Tests/test_main.py | 2 +- 12 files changed, 36 insertions(+), 21 deletions(-) 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_file_jpeg.py b/Tests/test_file_jpeg.py index 18dc752d8..8e4d694c1 100644 --- a/Tests/test_file_jpeg.py +++ b/Tests/test_file_jpeg.py @@ -443,7 +443,9 @@ class TestFileJpeg: assert_image(im1, im2.mode, im2.size) def test_subsampling(self) -> None: - def getsampling(im: JpegImagePlugin.JpegImageFile): + 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] @@ -917,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: diff --git a/Tests/test_file_tiff.py b/Tests/test_file_tiff.py index 8821fb46a..06591a29a 100644 --- a/Tests/test_file_tiff.py +++ b/Tests/test_file_tiff.py @@ -113,7 +113,7 @@ 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") 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_quantize.py b/Tests/test_image_quantize.py index 2daaf5c3c..2d461d985 100644 --- a/Tests/test_image_quantize.py +++ b/Tests/test_image_quantize.py @@ -98,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 f6609a1a0..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")) diff --git a/Tests/test_image_thumbnail.py b/Tests/test_image_thumbnail.py index 1593eaaf7..1606d8939 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 + assert im.thumbnail((100, 100)) is None # type: ignore[func-returns-value] assert im.size == (100, 100) diff --git a/Tests/test_imagedraw.py b/Tests/test_imagedraw.py index c221fe008..51543d785 100644 --- a/Tests/test_imagedraw.py +++ b/Tests/test_imagedraw.py @@ -1562,7 +1562,11 @@ 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) diff --git a/Tests/test_imagefont.py b/Tests/test_imagefont.py index 4398f8a30..73cad513e 100644 --- a/Tests/test_imagefont.py +++ b/Tests/test_imagefont.py @@ -224,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 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_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() From de0779eee876ae92c48458d38778a3c557566312 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Sun, 9 Jun 2024 18:09:54 +1000 Subject: [PATCH 02/10] Removed return value assertion --- Tests/test_image_thumbnail.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/test_image_thumbnail.py b/Tests/test_image_thumbnail.py index 1606d8939..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 # type: ignore[func-returns-value] + im.thumbnail((100, 100)) assert im.size == (100, 100) From be73b13ad33a88a17e8055e74993d02c5d3f68a0 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 12 Jun 2024 21:15:55 +1000 Subject: [PATCH 03/10] Added type hints --- src/PIL/BdfFontFile.py | 2 +- src/PIL/ImageDraw.py | 20 ++++++------ src/PIL/ImagePalette.py | 12 +++++--- src/PIL/MicImagePlugin.py | 4 +-- src/PIL/PngImagePlugin.py | 63 +++++++++++++++++++++++++------------- src/PIL/TiffImagePlugin.py | 31 +++++++++++-------- src/PIL/_imaging.pyi | 2 +- src/PIL/features.py | 18 ++++++----- 8 files changed, 95 insertions(+), 57 deletions(-) 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/ImageDraw.py b/src/PIL/ImageDraw.py index e74fab9fb..41a3eb0cb 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -34,12 +34,16 @@ from __future__ import annotations import math import numbers import struct +from types import ModuleType from typing import TYPE_CHECKING, AnyStr, Sequence, cast from . import Image, ImageColor from ._deprecate import deprecate from ._typing import Coords +if TYPE_CHECKING: + from . import ImageDraw2, ImageFont + """ A simple 2D drawing interface for PIL images.

@@ -93,9 +97,6 @@ class ImageDraw: self.fontmode = "L" # aliasing is okay for other modes self.fill = False - if TYPE_CHECKING: - from . import ImageFont - def getfont( self, ) -> ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont: @@ -879,7 +880,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. @@ -891,7 +892,7 @@ 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) @@ -903,7 +904,9 @@ 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]: """ :param im: The image to draw in. :param hints: An optional list of hints. Deprecated. @@ -913,9 +916,8 @@ def getdraw(im=None, hints=None): deprecate("'hints' parameter", 12) from . import ImageDraw2 - if im: - im = ImageDraw2.Draw(im) - return im, ImageDraw2 + draw = ImageDraw2.Draw(im) if im is not None else None + return draw, ImageDraw2 def floodfill( diff --git a/src/PIL/ImagePalette.py b/src/PIL/ImagePalette.py index 1ff05a3ef..6473c4577 100644 --- a/src/PIL/ImagePalette.py +++ b/src/PIL/ImagePalette.py @@ -54,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 = {} @@ -66,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: @@ -107,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"), diff --git a/src/PIL/MicImagePlugin.py b/src/PIL/MicImagePlugin.py index ed2ea2849..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: diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index ba9598065..927d6c0cf 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, Any +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 @@ -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) @@ -821,7 +839,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 +927,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 @@ -1026,7 +1047,7 @@ class PngImageFile(ImageFile.ImageFile): return None return self.getexif()._get_merged_dict() - def getexif(self): + def getexif(self) -> Image.Exif: if "exif" not in self.info: self.load() @@ -1346,7 +1367,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 +1446,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/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index 702d8f33b..833e12d2b 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 IO, 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,7 +384,7 @@ 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: object) -> bool: @@ -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 @@ -2039,7 +2044,7 @@ 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) -> int: @@ -2122,7 +2127,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) diff --git a/src/PIL/_imaging.pyi b/src/PIL/_imaging.pyi index 1fe954417..b233eb34d 100644 --- a/src/PIL/_imaging.pyi +++ b/src/PIL/_imaging.pyi @@ -12,5 +12,5 @@ class ImagingDraw: class PixelAccess: def __getattr__(self, name: str) -> Any: ... -def font(image, glyphdata: bytes) -> ImagingFont: ... +def font(image: ImagingCore, glyphdata: bytes) -> ImagingFont: ... def __getattr__(name: str) -> Any: ... 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") From c9a9e81749c12fdc4c7187284fe661810c9bc5c4 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 13 Jun 2024 00:03:16 +1000 Subject: [PATCH 04/10] Use latest Ubuntu --- .readthedocs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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: From 05a70e7861f37d2eb65be0186345ea8ea62fc3fe Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 14 Jun 2024 20:59:12 +1000 Subject: [PATCH 05/10] Corrected Ghostscript path --- .appveyor.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.appveyor.yml b/.appveyor.yml index 92a04a021..6ce5200b6 100644 --- a/.appveyor.yml +++ b/.appveyor.yml @@ -35,7 +35,7 @@ install: - 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.1 -- path c:\nasm-2.16.03;C:\Program Files\gs\gs10.00.0\bin;%PATH% +- 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\ From dfd53564ff6a3fc7d35a5884bc0ef03939bcec0a Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 15 Jun 2024 11:51:02 +1000 Subject: [PATCH 06/10] Ignore brew dependencies for libraqm on macOS 13 --- .github/workflows/macos-install.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 From ed5e8f91c57a402ba67ee23530084b8e99c0c41e Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 15 Jun 2024 19:11:11 +1000 Subject: [PATCH 07/10] Use pkg-config to help find libwebp and raqm --- setup.py | 4 ++++ 1 file changed, 4 insertions(+) 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(): From e4887610e9ac48699d386b399dbcbf104a470e0f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 15 Jun 2024 22:40:46 +0000 Subject: [PATCH 08/10] Update dependency cibuildwheel to v2.19.1 --- .ci/requirements-cibw.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.ci/requirements-cibw.txt b/.ci/requirements-cibw.txt index bf1d1315b..0d0f81fbf 100644 --- a/.ci/requirements-cibw.txt +++ b/.ci/requirements-cibw.txt @@ -1 +1 @@ -cibuildwheel==2.19.0 +cibuildwheel==2.19.1 From 6b5b2f6e58ed77aad8b2950319a5c0178b00285a Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 18 Jun 2024 22:44:17 +1000 Subject: [PATCH 09/10] Added type hints to Image --- src/PIL/FitsImagePlugin.py | 6 +- src/PIL/GifImagePlugin.py | 2 + src/PIL/Image.py | 173 +++++++++++++++++++++-------------- src/PIL/Jpeg2KImagePlugin.py | 4 +- src/PIL/PalmImagePlugin.py | 9 +- src/PIL/_imaging.pyi | 6 ++ 6 files changed, 124 insertions(+), 76 deletions(-) diff --git a/src/PIL/FitsImagePlugin.py b/src/PIL/FitsImagePlugin.py index a169b6083..4846054b1 100644 --- a/src/PIL/FitsImagePlugin.py +++ b/src/PIL/FitsImagePlugin.py @@ -115,7 +115,11 @@ 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 diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py index a305e8de6..541d97f8c 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -458,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 diff --git a/src/PIL/Image.py b/src/PIL/Image.py index bdd869ccc..472581127 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -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 = () @@ -550,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 @@ -687,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. @@ -700,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 @@ -754,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. @@ -776,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() @@ -789,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 @@ -832,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. @@ -843,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) @@ -996,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) @@ -1250,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 @@ -1276,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. @@ -1448,7 +1460,7 @@ class Image: return self.im.getextrema() def _getxmp(self, xmp_tags): - def get_name(tag): + def get_name(tag: str) -> str: return re.sub("^{[^}]+}", "", tag) def get_value(element): @@ -1549,7 +1561,11 @@ class Image: 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() @@ -1803,7 +1819,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. @@ -1818,32 +1836,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: @@ -1854,7 +1875,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. @@ -1891,7 +1916,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 @@ -1899,8 +1926,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): """ @@ -2973,29 +3000,29 @@ def _wedge() -> Image: 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. @@ -3044,7 +3071,13 @@ def new( return im._new(core.fill(mode, size, color)) -def frombytes(mode, size, data, decoder_name: str = "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. @@ -3072,18 +3105,21 @@ def frombytes(mode, size, data, decoder_name: str = "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: str, size, data, decoder_name: str = "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. @@ -3540,7 +3576,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: @@ -3674,7 +3710,7 @@ def _show(image: Image, **options: Any) -> None: def effect_mandelbrot( - size: tuple[int, int], extent: tuple[int, int, int, int], quality: int + size: tuple[int, int], extent: tuple[float, float, float, float], quality: int ) -> Image: """ Generate a Mandelbrot set covering the given extent. @@ -3721,19 +3757,18 @@ def radial_gradient(mode: str) -> Image: # 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)]: @@ -3742,13 +3777,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/Jpeg2KImagePlugin.py b/src/PIL/Jpeg2KImagePlugin.py index 60f3bff0a..39eb1c203 100644 --- a/src/PIL/Jpeg2KImagePlugin.py +++ b/src/PIL/Jpeg2KImagePlugin.py @@ -122,7 +122,7 @@ def _parse_codestream(fp): elif csiz == 4: mode = "RGBA" else: - mode = None + mode = "" return size, mode @@ -237,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) diff --git a/src/PIL/PalmImagePlugin.py b/src/PIL/PalmImagePlugin.py index fc83918b5..1735070f8 100644 --- a/src/PIL/PalmImagePlugin.py +++ b/src/PIL/PalmImagePlugin.py @@ -129,15 +129,16 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: # 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) diff --git a/src/PIL/_imaging.pyi b/src/PIL/_imaging.pyi index 1fe954417..3467eeb45 100644 --- a/src/PIL/_imaging.pyi +++ b/src/PIL/_imaging.pyi @@ -12,5 +12,11 @@ class ImagingDraw: class PixelAccess: def __getattr__(self, name: str) -> Any: ... +class ImagingDecoder: + def __getattr__(self, name: str) -> Any: ... + +class ImagingEncoder: + def __getattr__(self, name: str) -> Any: ... + def font(image, glyphdata: bytes) -> ImagingFont: ... def __getattr__(name: str) -> Any: ... From 291ee352047c845e2e6e5062c2c219923689da8d Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 18 Jun 2024 23:03:03 +1000 Subject: [PATCH 10/10] Added type hints --- Tests/test_file_jpeg.py | 2 +- Tests/test_file_jpeg2k.py | 2 +- Tests/test_image.py | 2 +- Tests/test_image_putdata.py | 8 ++++---- Tests/test_numpy.py | 25 +++++++++++++++++-------- Tests/test_shell_injection.py | 7 ++++--- 6 files changed, 28 insertions(+), 18 deletions(-) diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py index 8e4d694c1..1459a87eb 100644 --- a/Tests/test_file_jpeg.py +++ b/Tests/test_file_jpeg.py @@ -701,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) diff --git a/Tests/test_file_jpeg2k.py b/Tests/test_file_jpeg2k.py index 5a208739f..ed7ea4fcf 100644 --- a/Tests/test_file_jpeg2k.py +++ b/Tests/test_file_jpeg2k.py @@ -460,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_image.py b/Tests/test_image.py index d6a739c79..0d7d03480 100644 --- a/Tests/test_image.py +++ b/Tests/test_image.py @@ -152,7 +152,7 @@ class TestImage: 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: diff --git a/Tests/test_image_putdata.py b/Tests/test_image_putdata.py index dad26ef14..5e57e4c4c 100644 --- a/Tests/test_image_putdata.py +++ b/Tests/test_image_putdata.py @@ -113,13 +113,13 @@ def test_array_F() -> None: def test_not_flattened() -> None: im = Image.new("L", (1, 1)) with pytest.raises(TypeError): - im.putdata([[0]]) + im.putdata([[0]]) # type: ignore[list-item] with pytest.raises(TypeError): - im.putdata([[0]], 2) + im.putdata([[0]], 2) # type: ignore[list-item] with pytest.raises(TypeError): im = Image.new("I", (1, 1)) - im.putdata([[0]]) + im.putdata([[0]]) # type: ignore[list-item] with pytest.raises(TypeError): im = Image.new("F", (1, 1)) - im.putdata([[0]]) + im.putdata([[0]]) # type: ignore[list-item] diff --git a/Tests/test_numpy.py b/Tests/test_numpy.py index 9f4e6534e..36cdb3682 100644 --- a/Tests/test_numpy.py +++ b/Tests/test_numpy.py @@ -1,6 +1,7 @@ from __future__ import annotations import warnings +from typing import TYPE_CHECKING, Any import pytest @@ -8,13 +9,19 @@ from PIL import Image 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 +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: numpy.typing.DTypeLike, bands: int = 1, boolean: int = 0 + ) -> Image.Image: if bands == 1: if boolean: data = [0, 255] * 50 @@ -99,14 +106,16 @@ 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: numpy.typing.NDArray[Any] +) -> 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 +166,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: numpy.typing.DTypeLike) -> None: img = hopper(mode) # Resize to non-square @@ -207,7 +216,7 @@ def test_putdata() -> None: numpy.float64, ), ) -def test_roundtrip_eye(dtype) -> None: +def test_roundtrip_eye(dtype: numpy.typing.DTypeLike) -> None: arr = numpy.eye(10, dtype=dtype) numpy.testing.assert_array_equal(arr, numpy.array(Image.fromarray(arr))) 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()