From 776dd63f33741f02c3f9cae50fdeaf1a82d7c7d2 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 4 Jul 2024 11:20:00 +1000 Subject: [PATCH 01/30] Update CHANGES.rst [ci skip] --- CHANGES.rst | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index d071f3214..856458aa9 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,21 @@ Changelog (Pillow) ================== +11.0.0 (unreleased) +------------------- + +- Drop support for Python 3.8 #8183 + [hugovk, radarhere] + +- Add support for Python 3.13 #8181 + [hugovk, radarhere] + +- Fix incompatibility with NumPy 1.20 #8187 + [neutrinoceros, radarhere] + +- Remove PSFile, PyAccess and USE_CFFI_ACCESS #8182 + [hugovk, radarhere] + 10.4.0 (2024-07-01) ------------------- From 936012e861ae1458ca01b7bb3bf2f5cade7405e2 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 4 Jul 2024 15:13:03 +1000 Subject: [PATCH 02/30] Allow size argument to resize() to be a list --- Tests/test_image_resize.py | 6 +++--- src/PIL/Image.py | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/Tests/test_image_resize.py b/Tests/test_image_resize.py index 3ef05a25f..c9e304512 100644 --- a/Tests/test_image_resize.py +++ b/Tests/test_image_resize.py @@ -285,14 +285,14 @@ class TestReducingGapResize: class TestImageResize: def test_resize(self) -> None: - def resize(mode: str, size: tuple[int, int]) -> None: + def resize(mode: str, size: tuple[int, int] | list[int]) -> None: out = hopper(mode).resize(size) assert out.mode == mode - assert out.size == size + assert out.size == tuple(size) for mode in "1", "P", "L", "RGB", "I", "F": resize(mode, (112, 103)) - resize(mode, (188, 214)) + resize(mode, [188, 214]) # Test unknown resampling filter with hopper() as im: diff --git a/src/PIL/Image.py b/src/PIL/Image.py index b4ef62510..3c7a3735c 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -2203,7 +2203,7 @@ class Image: def resize( self, - size: tuple[int, int], + size: tuple[int, int] | list[int], resample: int | None = None, box: tuple[float, float, float, float] | None = None, reducing_gap: float | None = None, @@ -2276,6 +2276,7 @@ class Image: if box is None: box = (0, 0) + self.size + size = tuple(size) if self.size == size and box == (0, 0) + self.size: return self.copy() From 6990fc4a0996753c5faa6cc272b753cac23cffdf Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 4 Jul 2024 15:25:35 +1000 Subject: [PATCH 03/30] Allow size argument to resize() to be a NumPy array --- Tests/test_numpy.py | 9 +++++++++ src/PIL/Image.py | 6 +++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/Tests/test_numpy.py b/Tests/test_numpy.py index a082e5a4d..312e32e0c 100644 --- a/Tests/test_numpy.py +++ b/Tests/test_numpy.py @@ -198,6 +198,15 @@ def test_putdata() -> None: assert len(im.getdata()) == len(arr) +def test_resize() -> None: + im = hopper() + size = (64, 64) + + im_resized = im.resize(numpy.array(size)) + + assert im_resized.size == size + + @pytest.mark.parametrize( "dtype", ( diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 3c7a3735c..8e595eedf 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -63,7 +63,6 @@ from . import ( ) from ._binary import i32le, o32be, o32le from ._deprecate import deprecate -from ._typing import StrOrBytesPath, TypeGuard from ._util import DeferredError, is_path ElementTree: ModuleType | None @@ -220,6 +219,7 @@ if hasattr(core, "DEFAULT_STRATEGY"): if TYPE_CHECKING: from . import ImageFile, ImagePalette + from ._typing import NumpyArray, StrOrBytesPath, TypeGuard ID: list[str] = [] OPEN: dict[ str, @@ -2203,7 +2203,7 @@ class Image: def resize( self, - size: tuple[int, int] | list[int], + size: tuple[int, int] | list[int] | NumpyArray, resample: int | None = None, box: tuple[float, float, float, float] | None = None, reducing_gap: float | None = None, @@ -2211,7 +2211,7 @@ class Image: """ Returns a resized copy of this image. - :param size: The requested size in pixels, as a 2-tuple: + :param size: The requested size in pixels, as a tuple or array: (width, height). :param resample: An optional resampling filter. This can be one of :py:data:`Resampling.NEAREST`, :py:data:`Resampling.BOX`, From d4f1d252efa5e2ca54b2d43eaa92880cdf4ad8dc Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Thu, 4 Jul 2024 15:45:58 +0300 Subject: [PATCH 04/30] Test 3.13 free-threaded build on CI --- .ci/install.sh | 6 ++++-- .github/workflows/test.yml | 10 ++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.ci/install.sh b/.ci/install.sh index 1eb098be9..0dc738ed8 100755 --- a/.ci/install.sh +++ b/.ci/install.sh @@ -37,12 +37,14 @@ python3 -m pip install -U pytest-timeout python3 -m pip install pyroma if [[ $(uname) != CYGWIN* ]]; then - python3 -m pip install numpy + # TODO Update condition when NumPy supports free-threading + if ! [[ "$GHA_PYTHON_VERSION" == "3.13-dev" ]]; then python3 -m pip install numpy ; fi # PyQt6 doesn't support PyPy3 if [[ $GHA_PYTHON_VERSION == 3.* ]]; then sudo apt-get -qq install libegl1 libxcb-cursor0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-shape0 libxkbcommon-x11-0 - python3 -m pip install pyqt6 + # TODO Update condition when pyqt6 supports free-threading + if ! [[ "$GHA_PYTHON_VERSION" == "3.13-dev" ]]; then python3 -m pip install pyqt6 ; fi fi # Pyroma uses non-isolated build and fails with old setuptools diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0972459b0..75165909c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -55,6 +55,8 @@ jobs: REVERSE: "--reverse" - python-version: "3.10" PYTHONOPTIMIZE: 2 + # Free-threaded + - { os: "ubuntu-latest", python-version: "3.13-dev", disable-gil: true } # M1 only available for 3.10+ - os: "macos-13" python-version: "3.9" @@ -70,6 +72,7 @@ jobs: - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 + if: "!endsWith(matrix.python-version, '-dev')" with: python-version: ${{ matrix.python-version }} allow-prereleases: true @@ -78,6 +81,13 @@ jobs: ".ci/*.sh" "pyproject.toml" + - name: Set up Python ${{ matrix.python-version }} (free-threaded) + uses: deadsnakes/action@v3.1.0 + if: endsWith(matrix.python-version, '-dev') + with: + python-version: ${{ matrix.python-version }} + nogil: ${{ matrix.disable-gil }} + - name: Build system information run: python3 .github/workflows/system-info.py From e76c31b67db5e1cdfe28b593862bbe2d83a81e23 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Thu, 4 Jul 2024 15:46:46 +0300 Subject: [PATCH 05/30] Refactor to single line for readability --- .github/workflows/test.yml | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 75165909c..d858dcfff 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -50,19 +50,14 @@ jobs: "3.9", ] include: - - python-version: "3.11" - PYTHONOPTIMIZE: 1 - REVERSE: "--reverse" - - python-version: "3.10" - PYTHONOPTIMIZE: 2 + - { python-version: "3.11", PYTHONOPTIMIZE: 1, REVERSE: "--reverse" } + - { python-version: "3.10", PYTHONOPTIMIZE: 2 } # Free-threaded - { os: "ubuntu-latest", python-version: "3.13-dev", disable-gil: true } # M1 only available for 3.10+ - - os: "macos-13" - python-version: "3.9" + - { os: "macos-13", python-version: "3.9" } exclude: - - os: "macos-14" - python-version: "3.9" + - { os: "macos-14", python-version: "3.9" } runs-on: ${{ matrix.os }} name: ${{ matrix.os }} Python ${{ matrix.python-version }} From 701539d017356d8624fba6a6efc97b8e2ebf75e2 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Fri, 5 Jul 2024 00:38:50 +0300 Subject: [PATCH 06/30] Add PYTHON_GIL=0 env var to keep GIL disabled --- .github/workflows/test.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d858dcfff..fc8be6ced 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -83,6 +83,11 @@ jobs: python-version: ${{ matrix.python-version }} nogil: ${{ matrix.disable-gil }} + - name: Set PYTHON_GIL + if: "${{ matrix.disable-gil }}" + run: | + echo "PYTHON_GIL=0" >> $GITHUB_ENV + - name: Build system information run: python3 .github/workflows/system-info.py From 2d2889e6178ab619bae30952f4d477ac66352dea Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Fri, 5 Jul 2024 00:49:27 +0300 Subject: [PATCH 07/30] Install nightly NumPy for free-threaded --- .ci/install.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.ci/install.sh b/.ci/install.sh index 0dc738ed8..e1274f41c 100755 --- a/.ci/install.sh +++ b/.ci/install.sh @@ -38,7 +38,11 @@ python3 -m pip install pyroma if [[ $(uname) != CYGWIN* ]]; then # TODO Update condition when NumPy supports free-threading - if ! [[ "$GHA_PYTHON_VERSION" == "3.13-dev" ]]; then python3 -m pip install numpy ; fi + if [[ "$GHA_PYTHON_VERSION" == "3.13-dev" ]]; then + python3 -m pip install numpy --index-url https://pypi.anaconda.org/scientific-python-nightly-wheels/simple + else + python3 -m pip install numpy + fi # PyQt6 doesn't support PyPy3 if [[ $GHA_PYTHON_VERSION == 3.* ]]; then From 7e1a6be76789a59760c5f3d87bdab7e736918b88 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Fri, 5 Jul 2024 20:05:59 +1000 Subject: [PATCH 08/30] Updated macOS tested Pillow versions (#8202) Co-authored-by: Andrew Murray --- docs/installation/platform-support.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/installation/platform-support.rst b/docs/installation/platform-support.rst index 1d449a036..f2ef9cacb 100644 --- a/docs/installation/platform-support.rst +++ b/docs/installation/platform-support.rst @@ -75,7 +75,7 @@ These platforms have been reported to work at the versions mentioned. | Operating system | | Tested Python | | Latest tested | | Tested | | | | versions | | Pillow version | | processors | +==================================+============================+==================+==============+ -| macOS 14 Sonoma | 3.8, 3.9, 3.10, 3.11, 3.12 | 10.3.0 |arm | +| macOS 14 Sonoma | 3.8, 3.9, 3.10, 3.11, 3.12 | 10.4.0 |arm | +----------------------------------+----------------------------+------------------+--------------+ | macOS 13 Ventura | 3.8, 3.9, 3.10, 3.11 | 10.0.1 |arm | | +----------------------------+------------------+ | From bcecaf6addc5bd7102987ec01939b35f73ce2da4 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 6 Jul 2024 02:08:37 +1000 Subject: [PATCH 09/30] Group aarch64 jobs --- .github/workflows/wheels.yml | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 4eb907bcd..f8dff3a3e 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -41,11 +41,8 @@ jobs: python-version: - pp39 - pp310 - - cp39 - - cp310 - - cp311 - - cp312 - - cp313 + - cp3{9,10,11} + - cp3{12,13} spec: - manylinux2014 - manylinux_2_28 From dcd833280538d8cd00b3c2a4617b534efdfa4ff0 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Fri, 5 Jul 2024 19:36:58 +0300 Subject: [PATCH 10/30] Include 'free-threading' in job name --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fc8be6ced..702841814 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -60,7 +60,7 @@ jobs: - { os: "macos-14", python-version: "3.9" } runs-on: ${{ matrix.os }} - name: ${{ matrix.os }} Python ${{ matrix.python-version }} + name: ${{ matrix.os }} Python ${{ matrix.python-version }} ${{ matrix.disable-gil && 'free-threaded' || '' }} steps: - uses: actions/checkout@v4 From f3c3e527973bd5a579f15b3d7ed6ee8f51dfd3e4 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Sat, 6 Jul 2024 03:55:23 +1000 Subject: [PATCH 11/30] Added type hints (#8204) Co-authored-by: Andrew Murray --- Tests/test_imageshow.py | 7 +++-- src/PIL/EpsImagePlugin.py | 6 ++-- src/PIL/FliImagePlugin.py | 11 ++++---- src/PIL/FpxImagePlugin.py | 2 +- src/PIL/GbrImagePlugin.py | 2 +- src/PIL/IcnsImagePlugin.py | 54 ++++++++++++++++++++++-------------- src/PIL/IcoImagePlugin.py | 11 ++++---- src/PIL/Image.py | 4 +-- src/PIL/ImageShow.py | 9 ++---- src/PIL/IptcImagePlugin.py | 3 +- src/PIL/Jpeg2KImagePlugin.py | 2 +- src/PIL/TiffImagePlugin.py | 2 +- src/PIL/WalImageFile.py | 2 +- src/PIL/WebPImagePlugin.py | 2 +- 14 files changed, 67 insertions(+), 50 deletions(-) diff --git a/Tests/test_imageshow.py b/Tests/test_imageshow.py index 0bff43896..a4f7e5cc5 100644 --- a/Tests/test_imageshow.py +++ b/Tests/test_imageshow.py @@ -16,8 +16,11 @@ def test_sanity() -> None: def test_register() -> None: - # Test registering a viewer that is not a class - ImageShow.register("not a class") + # Test registering a viewer that is an instance + class TestViewer(ImageShow.Viewer): + pass + + ImageShow.register(TestViewer()) # Restore original state ImageShow._viewers.pop() diff --git a/src/PIL/EpsImagePlugin.py b/src/PIL/EpsImagePlugin.py index 71e869045..59bb8594d 100644 --- a/src/PIL/EpsImagePlugin.py +++ b/src/PIL/EpsImagePlugin.py @@ -338,7 +338,7 @@ class EpsImageFile(ImageFile.ImageFile): msg = "cannot determine EPS bounding box" raise OSError(msg) - def _find_offset(self, fp): + def _find_offset(self, fp: IO[bytes]) -> tuple[int, int]: s = fp.read(4) if s == b"%!PS": @@ -361,7 +361,9 @@ class EpsImageFile(ImageFile.ImageFile): return length, offset - def load(self, scale=1, transparency=False): + def load( + self, scale: int = 1, transparency: bool = False + ) -> Image.core.PixelAccess | None: # Load EPS via Ghostscript if self.tile: self.im = Ghostscript(self.tile, self.size, self.fp, scale, transparency) diff --git a/src/PIL/FliImagePlugin.py b/src/PIL/FliImagePlugin.py index dceb83927..52d1fce31 100644 --- a/src/PIL/FliImagePlugin.py +++ b/src/PIL/FliImagePlugin.py @@ -45,7 +45,7 @@ class FliImageFile(ImageFile.ImageFile): format_description = "Autodesk FLI/FLC Animation" _close_exclusive_fp_after_loading = False - def _open(self): + def _open(self) -> None: # HEAD s = self.fp.read(128) if not (_accept(s) and s[20:22] == b"\x00\x00"): @@ -83,7 +83,7 @@ class FliImageFile(ImageFile.ImageFile): if i16(s, 4) == 0xF1FA: # look for palette chunk number_of_subchunks = i16(s, 6) - chunk_size = None + chunk_size: int | None = None for _ in range(number_of_subchunks): if chunk_size is not None: self.fp.seek(chunk_size - 6, os.SEEK_CUR) @@ -96,8 +96,9 @@ class FliImageFile(ImageFile.ImageFile): if not chunk_size: break - palette = [o8(r) + o8(g) + o8(b) for (r, g, b) in palette] - self.palette = ImagePalette.raw("RGB", b"".join(palette)) + self.palette = ImagePalette.raw( + "RGB", b"".join(o8(r) + o8(g) + o8(b) for (r, g, b) in palette) + ) # set things up to decode first frame self.__frame = -1 @@ -105,7 +106,7 @@ class FliImageFile(ImageFile.ImageFile): self.__rewind = self.fp.tell() self.seek(0) - def _palette(self, palette, shift): + def _palette(self, palette: list[tuple[int, int, int]], shift: int) -> None: # load palette i = 0 diff --git a/src/PIL/FpxImagePlugin.py b/src/PIL/FpxImagePlugin.py index 93eef48d2..386e37233 100644 --- a/src/PIL/FpxImagePlugin.py +++ b/src/PIL/FpxImagePlugin.py @@ -231,7 +231,7 @@ class FpxImageFile(ImageFile.ImageFile): self._fp = self.fp self.fp = None - def load(self): + def load(self) -> Image.core.PixelAccess | None: if not self.fp: self.fp = self.ole.openstream(self.stream[:2] + ["Subimage 0000 Data"]) diff --git a/src/PIL/GbrImagePlugin.py b/src/PIL/GbrImagePlugin.py index 93e89b1e6..3c8feea5f 100644 --- a/src/PIL/GbrImagePlugin.py +++ b/src/PIL/GbrImagePlugin.py @@ -88,7 +88,7 @@ class GbrImageFile(ImageFile.ImageFile): # Data is an uncompressed block of w * h * bytes/pixel self._data_size = width * height * color_depth - def load(self): + def load(self) -> Image.core.PixelAccess | None: if not self.im: self.im = Image.core.new(self.mode, self.size) self.frombytes(self.fp.read(self._data_size)) diff --git a/src/PIL/IcnsImagePlugin.py b/src/PIL/IcnsImagePlugin.py index 2a89d498c..8729f7643 100644 --- a/src/PIL/IcnsImagePlugin.py +++ b/src/PIL/IcnsImagePlugin.py @@ -34,11 +34,13 @@ MAGIC = b"icns" HEADERSIZE = 8 -def nextheader(fobj): +def nextheader(fobj: IO[bytes]) -> tuple[bytes, int]: return struct.unpack(">4sI", fobj.read(HEADERSIZE)) -def read_32t(fobj, start_length, size): +def read_32t( + fobj: IO[bytes], start_length: tuple[int, int], size: tuple[int, int, int] +) -> dict[str, Image.Image]: # The 128x128 icon seems to have an extra header for some reason. (start, length) = start_length fobj.seek(start) @@ -49,7 +51,9 @@ def read_32t(fobj, start_length, size): return read_32(fobj, (start + 4, length - 4), size) -def read_32(fobj, start_length, size): +def read_32( + fobj: IO[bytes], start_length: tuple[int, int], size: tuple[int, int, int] +) -> dict[str, Image.Image]: """ Read a 32bit RGB icon resource. Seems to be either uncompressed or an RLE packbits-like scheme. @@ -72,14 +76,14 @@ def read_32(fobj, start_length, size): byte = fobj.read(1) if not byte: break - byte = byte[0] - if byte & 0x80: - blocksize = byte - 125 + byte_int = byte[0] + if byte_int & 0x80: + blocksize = byte_int - 125 byte = fobj.read(1) for i in range(blocksize): data.append(byte) else: - blocksize = byte + 1 + blocksize = byte_int + 1 data.append(fobj.read(blocksize)) bytesleft -= blocksize if bytesleft <= 0: @@ -92,7 +96,9 @@ def read_32(fobj, start_length, size): return {"RGB": im} -def read_mk(fobj, start_length, size): +def read_mk( + fobj: IO[bytes], start_length: tuple[int, int], size: tuple[int, int, int] +) -> dict[str, Image.Image]: # Alpha masks seem to be uncompressed start = start_length[0] fobj.seek(start) @@ -102,10 +108,14 @@ def read_mk(fobj, start_length, size): return {"A": band} -def read_png_or_jpeg2000(fobj, start_length, size): +def read_png_or_jpeg2000( + fobj: IO[bytes], start_length: tuple[int, int], size: tuple[int, int, int] +) -> dict[str, Image.Image]: (start, length) = start_length fobj.seek(start) sig = fobj.read(12) + + im: Image.Image if sig[:8] == b"\x89PNG\x0d\x0a\x1a\x0a": fobj.seek(start) im = PngImagePlugin.PngImageFile(fobj) @@ -164,12 +174,12 @@ class IcnsFile: ], } - def __init__(self, fobj): + def __init__(self, fobj: IO[bytes]) -> None: """ fobj is a file-like object as an icns resource """ # signature : (start, length) - self.dct = dct = {} + self.dct = {} self.fobj = fobj sig, filesize = nextheader(fobj) if not _accept(sig): @@ -183,11 +193,11 @@ class IcnsFile: raise SyntaxError(msg) i += HEADERSIZE blocksize -= HEADERSIZE - dct[sig] = (i, blocksize) + self.dct[sig] = (i, blocksize) fobj.seek(blocksize, io.SEEK_CUR) i += blocksize - def itersizes(self): + def itersizes(self) -> list[tuple[int, int, int]]: sizes = [] for size, fmts in self.SIZES.items(): for fmt, reader in fmts: @@ -196,14 +206,14 @@ class IcnsFile: break return sizes - def bestsize(self): + def bestsize(self) -> tuple[int, int, int]: sizes = self.itersizes() if not sizes: msg = "No 32bit icon resources found" raise SyntaxError(msg) return max(sizes) - def dataforsize(self, size): + def dataforsize(self, size: tuple[int, int, int]) -> dict[str, Image.Image]: """ Get an icon resource as {channel: array}. Note that the arrays are bottom-up like windows bitmaps and will likely @@ -216,18 +226,20 @@ class IcnsFile: dct.update(reader(self.fobj, desc, size)) return dct - def getimage(self, size=None): + def getimage( + self, size: tuple[int, int] | tuple[int, int, int] | None = None + ) -> Image.Image: if size is None: size = self.bestsize() - if len(size) == 2: + elif len(size) == 2: size = (size[0], size[1], 1) channels = self.dataforsize(size) - im = channels.get("RGBA", None) + im = channels.get("RGBA") if im: return im - im = channels.get("RGB").copy() + im = channels["RGB"].copy() try: im.putalpha(channels["A"]) except KeyError: @@ -268,7 +280,7 @@ class IcnsImageFile(ImageFile.ImageFile): return self._size @size.setter - def size(self, value): + def size(self, value) -> None: info_size = value if info_size not in self.info["sizes"] and len(info_size) == 2: info_size = (info_size[0], info_size[1], 1) @@ -287,7 +299,7 @@ class IcnsImageFile(ImageFile.ImageFile): raise ValueError(msg) self._size = value - def load(self): + def load(self) -> Image.core.PixelAccess | None: if len(self.size) == 3: self.best_size = self.size self.size = ( diff --git a/src/PIL/IcoImagePlugin.py b/src/PIL/IcoImagePlugin.py index 086c87b1a..650f5e4f1 100644 --- a/src/PIL/IcoImagePlugin.py +++ b/src/PIL/IcoImagePlugin.py @@ -120,7 +120,7 @@ def _accept(prefix: bytes) -> bool: class IcoFile: - def __init__(self, buf): + def __init__(self, buf) -> None: """ Parse image from file-like object containing ico file data """ @@ -177,19 +177,19 @@ class IcoFile: # ICO images are usually squares self.entry = sorted(self.entry, key=lambda x: x["square"], reverse=True) - def sizes(self): + def sizes(self) -> set[tuple[int, int]]: """ Get a list of all available icon sizes and color depths. """ return {(h["width"], h["height"]) for h in self.entry} - def getentryindex(self, size, bpp=False): + def getentryindex(self, size: tuple[int, int], bpp: int | bool = False) -> int: for i, h in enumerate(self.entry): if size == h["dim"] and (bpp is False or bpp == h["color_depth"]): return i return 0 - def getimage(self, size, bpp=False): + def getimage(self, size: tuple[int, int], bpp: int | bool = False) -> Image.Image: """ Get an image from the icon """ @@ -321,7 +321,7 @@ class IcoImageFile(ImageFile.ImageFile): raise ValueError(msg) self._size = value - def load(self): + def load(self) -> Image.core.PixelAccess | None: if self.im is not None and self.im.size == self.size: # Already loaded return Image.Image.load(self) @@ -341,6 +341,7 @@ class IcoImageFile(ImageFile.ImageFile): self.info["sizes"] = set(sizes) self.size = im.size + return None def load_seek(self, pos: int) -> None: # Flag the ImageFile.Parser so that it diff --git a/src/PIL/Image.py b/src/PIL/Image.py index b4ef62510..c9bb008b0 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -1395,7 +1395,7 @@ class Image: def getcolors( self, maxcolors: int = 256 - ) -> list[tuple[int, int]] | list[tuple[int, float]] | None: + ) -> list[tuple[int, tuple[int, ...]]] | list[tuple[int, float]] | None: """ Returns a list of colors used in this image. @@ -1412,7 +1412,7 @@ class Image: self.load() if self.mode in ("1", "L", "P"): h = self.im.histogram() - out = [(h[i], i) for i in range(256) if h[i]] + out: list[tuple[int, float]] = [(h[i], i) for i in range(256) if h[i]] if len(out) > maxcolors: return None return out diff --git a/src/PIL/ImageShow.py b/src/PIL/ImageShow.py index 037d6f492..d62893d9c 100644 --- a/src/PIL/ImageShow.py +++ b/src/PIL/ImageShow.py @@ -26,7 +26,7 @@ from . import Image _viewers = [] -def register(viewer, order: int = 1) -> None: +def register(viewer: type[Viewer] | Viewer, order: int = 1) -> None: """ The :py:func:`register` function is used to register additional viewers:: @@ -40,11 +40,8 @@ def register(viewer, order: int = 1) -> None: Zero or a negative integer to prepend this viewer to the list, a positive integer to append it. """ - try: - if issubclass(viewer, Viewer): - viewer = viewer() - except TypeError: - pass # raised if viewer wasn't a class + if isinstance(viewer, type) and issubclass(viewer, Viewer): + viewer = viewer() if order > 0: _viewers.append(viewer) else: diff --git a/src/PIL/IptcImagePlugin.py b/src/PIL/IptcImagePlugin.py index f9d5b75f0..a04616fbd 100644 --- a/src/PIL/IptcImagePlugin.py +++ b/src/PIL/IptcImagePlugin.py @@ -148,7 +148,7 @@ class IptcImageFile(ImageFile.ImageFile): if tag == (8, 10): self.tile = [("iptc", (0, 0) + self.size, offset, compression)] - def load(self): + def load(self) -> Image.core.PixelAccess | None: if len(self.tile) != 1 or self.tile[0][0] != "iptc": return ImageFile.ImageFile.load(self) @@ -176,6 +176,7 @@ class IptcImageFile(ImageFile.ImageFile): with Image.open(o) as _im: _im.load() self.im = _im.im + return None Image.register_open(IptcImageFile.format, IptcImageFile) diff --git a/src/PIL/Jpeg2KImagePlugin.py b/src/PIL/Jpeg2KImagePlugin.py index c8a57567a..992b9ccaf 100644 --- a/src/PIL/Jpeg2KImagePlugin.py +++ b/src/PIL/Jpeg2KImagePlugin.py @@ -299,7 +299,7 @@ class Jpeg2KImageFile(ImageFile.ImageFile): def reduce(self, value): self._reduce = value - def load(self): + def load(self) -> Image.core.PixelAccess | None: if self.tile and self._reduce: power = 1 << self._reduce adjust = power >> 1 diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index ac5b63c1b..b89144803 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -1232,7 +1232,7 @@ class TiffImageFile(ImageFile.ImageFile): val = val[math.ceil((10 + n + size) / 2) * 2 :] return blocks - def load(self): + def load(self) -> Image.core.PixelAccess | None: if self.tile and self.use_load_libtiff: return self._load_libtiff() return super().load() diff --git a/src/PIL/WalImageFile.py b/src/PIL/WalImageFile.py index fbd7be6ed..895d5616a 100644 --- a/src/PIL/WalImageFile.py +++ b/src/PIL/WalImageFile.py @@ -50,7 +50,7 @@ class WalImageFile(ImageFile.ImageFile): if next_name: self.info["next_name"] = next_name - def load(self): + def load(self) -> Image.core.PixelAccess | None: if not self.im: self.im = Image.core.new(self.mode, self.size) self.frombytes(self.fp.read(self.size[0] * self.size[1])) diff --git a/src/PIL/WebPImagePlugin.py b/src/PIL/WebPImagePlugin.py index 59be5bf9d..530b88c8b 100644 --- a/src/PIL/WebPImagePlugin.py +++ b/src/PIL/WebPImagePlugin.py @@ -144,7 +144,7 @@ class WebPImageFile(ImageFile.ImageFile): while self.__physical_frame < frame: self._get_next() # Advance to the requested frame - def load(self): + def load(self) -> Image.core.PixelAccess | None: if _webp.HAVE_WEBPANIM: if self.__loaded != self.__logical_frame: self._seek(self.__logical_frame) From 4aa24f88d9a066318b75f0ad4bf69240d595ffb6 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Sat, 6 Jul 2024 03:56:24 +1000 Subject: [PATCH 12/30] Added type hints to tests (#8203) Co-authored-by: Andrew Murray --- Tests/helper.py | 4 +-- Tests/test_file_libtiff.py | 6 +++- Tests/test_file_webp_animated.py | 5 +++- Tests/test_image.py | 9 ++++-- Tests/test_image_access.py | 24 ++++++++++------ Tests/test_image_convert.py | 2 +- Tests/test_image_getcolors.py | 4 +++ Tests/test_image_putdata.py | 2 +- Tests/test_image_quantize.py | 12 ++++++-- Tests/test_image_resample.py | 20 +++++++++----- Tests/test_image_transform.py | 5 ++-- Tests/test_imagechops.py | 26 ++++++++++-------- Tests/test_imagecms.py | 4 ++- Tests/test_imagedraw.py | 31 +++++++++++++++++---- Tests/test_imagefile.py | 2 +- Tests/test_imagemorph.py | 14 ++++++---- Tests/test_imageops.py | 47 +++++++++++++++++++++++--------- Tests/test_imagetk.py | 2 ++ Tests/test_tiff_ifdrational.py | 6 +++- 19 files changed, 156 insertions(+), 69 deletions(-) diff --git a/Tests/helper.py b/Tests/helper.py index 7abc495b6..d6a93a803 100644 --- a/Tests/helper.py +++ b/Tests/helper.py @@ -60,9 +60,7 @@ def convert_to_comparable( return new_a, new_b -def assert_deep_equal( - a: Sequence[Any], b: Sequence[Any], msg: str | None = None -) -> None: +def assert_deep_equal(a: Any, b: Any, msg: str | None = None) -> None: try: assert len(a) == len(b), msg or f"got length {len(a)}, expected {len(b)}" except Exception: diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py index f491f9875..8707311dc 100644 --- a/Tests/test_file_libtiff.py +++ b/Tests/test_file_libtiff.py @@ -1048,7 +1048,11 @@ class TestFileLibTiff(LibTiffTestCase): ], ) def test_wrong_bits_per_sample( - self, file_name: str, mode: str, size: tuple[int, int], tile + self, + file_name: str, + mode: str, + size: tuple[int, int], + tile: list[tuple[str, tuple[int, int, int, int], int, tuple[Any, ...]]], ) -> None: with Image.open("Tests/images/" + file_name) as im: assert im.mode == mode diff --git a/Tests/test_file_webp_animated.py b/Tests/test_file_webp_animated.py index 882dccb32..e0d7999e3 100644 --- a/Tests/test_file_webp_animated.py +++ b/Tests/test_file_webp_animated.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Generator from pathlib import Path import pytest @@ -96,7 +97,9 @@ def test_write_animation_RGB(tmp_path: Path) -> None: check(temp_file1) # Tests appending using a generator - def im_generator(ims): + def im_generator( + ims: list[Image.Image], + ) -> Generator[Image.Image, None, None]: yield from ims temp_file2 = str(tmp_path / "temp_generator.webp") diff --git a/Tests/test_image.py b/Tests/test_image.py index 07161fcfa..8e6f63fac 100644 --- a/Tests/test_image.py +++ b/Tests/test_image.py @@ -372,8 +372,9 @@ class TestImage: img = Image.alpha_composite(dst, src) # Assert - img_colors = sorted(img.getcolors()) - assert img_colors == expected_colors + img_colors = img.getcolors() + assert img_colors is not None + assert sorted(img_colors) == expected_colors def test_alpha_inplace(self) -> None: src = Image.new("RGBA", (128, 128), "blue") @@ -670,7 +671,9 @@ class TestImage: im_remapped = im.remap_palette([1, 0]) assert im_remapped.info["transparency"] == 1 - assert len(im_remapped.getpalette()) == 6 + palette = im_remapped.getpalette() + assert palette is not None + assert len(palette) == 6 # Test unused transparency im.info["transparency"] = 2 diff --git a/Tests/test_image_access.py b/Tests/test_image_access.py index c860771dc..854c79dae 100644 --- a/Tests/test_image_access.py +++ b/Tests/test_image_access.py @@ -27,7 +27,9 @@ class TestImagePutPixel: for y in range(im1.size[1]): for x in range(im1.size[0]): pos = x, y - im2.putpixel(pos, im1.getpixel(pos)) + value = im1.getpixel(pos) + assert value is not None + im2.putpixel(pos, value) assert_image_equal(im1, im2) @@ -37,7 +39,9 @@ class TestImagePutPixel: for y in range(im1.size[1]): for x in range(im1.size[0]): pos = x, y - im2.putpixel(pos, im1.getpixel(pos)) + value = im1.getpixel(pos) + assert value is not None + im2.putpixel(pos, value) assert not im2.readonly assert_image_equal(im1, im2) @@ -50,9 +54,9 @@ class TestImagePutPixel: assert pix1 is not None assert pix2 is not None with pytest.raises(TypeError): - pix1[0, "0"] + pix1[0, "0"] # type: ignore[index] with pytest.raises(TypeError): - pix1["0", 0] + pix1["0", 0] # type: ignore[index] for y in range(im1.size[1]): for x in range(im1.size[0]): @@ -71,7 +75,9 @@ class TestImagePutPixel: for y in range(-1, -im1.size[1] - 1, -1): for x in range(-1, -im1.size[0] - 1, -1): pos = x, y - im2.putpixel(pos, im1.getpixel(pos)) + value = im1.getpixel(pos) + assert value is not None + im2.putpixel(pos, value) assert_image_equal(im1, im2) @@ -81,7 +87,9 @@ class TestImagePutPixel: for y in range(-1, -im1.size[1] - 1, -1): for x in range(-1, -im1.size[0] - 1, -1): pos = x, y - im2.putpixel(pos, im1.getpixel(pos)) + value = im1.getpixel(pos) + assert value is not None + im2.putpixel(pos, value) assert not im2.readonly assert_image_equal(im1, im2) @@ -219,7 +227,7 @@ class TestImagePutPixelError: im = hopper(mode) for v in self.INVALID_TYPES: with pytest.raises(TypeError, match="color must be int or tuple"): - im.putpixel((0, 0), v) + im.putpixel((0, 0), v) # type: ignore[arg-type] @pytest.mark.parametrize( ("mode", "band_numbers", "match"), @@ -253,7 +261,7 @@ class TestImagePutPixelError: with pytest.raises( TypeError, match="color must be int or single-element tuple" ): - im.putpixel((0, 0), v) + im.putpixel((0, 0), v) # type: ignore[arg-type] @pytest.mark.parametrize("mode", IMAGE_MODES1 + IMAGE_MODES2) def test_putpixel_overflow_error(self, mode: str) -> None: diff --git a/Tests/test_image_convert.py b/Tests/test_image_convert.py index 5b63ceec3..ebb7f2822 100644 --- a/Tests/test_image_convert.py +++ b/Tests/test_image_convert.py @@ -225,7 +225,7 @@ def test_l_macro_rounding(convert_mode: str) -> None: assert px is not None converted_color = px[0, 0] if convert_mode == "LA": - assert converted_color is not None + assert isinstance(converted_color, tuple) converted_color = converted_color[0] assert converted_color == 1 diff --git a/Tests/test_image_getcolors.py b/Tests/test_image_getcolors.py index 8f8870f4f..8dbe82b29 100644 --- a/Tests/test_image_getcolors.py +++ b/Tests/test_image_getcolors.py @@ -54,17 +54,21 @@ def test_pack() -> None: assert A is None A = im.getcolors(maxcolors=3) + assert A is not None A.sort() assert A == expected A = im.getcolors(maxcolors=4) + assert A is not None A.sort() assert A == expected A = im.getcolors(maxcolors=8) + assert A is not None A.sort() assert A == expected A = im.getcolors(maxcolors=16) + assert A is not None A.sort() assert A == expected diff --git a/Tests/test_image_putdata.py b/Tests/test_image_putdata.py index dad26ef14..27cb7c59d 100644 --- a/Tests/test_image_putdata.py +++ b/Tests/test_image_putdata.py @@ -31,7 +31,7 @@ def test_sanity() -> None: def test_long_integers() -> None: # see bug-200802-systemerror - def put(value: int) -> tuple[int, int, int, int]: + def put(value: int) -> float | tuple[int, ...] | None: im = Image.new("RGBA", (1, 1)) im.putdata([value]) return im.getpixel((0, 0)) diff --git a/Tests/test_image_quantize.py b/Tests/test_image_quantize.py index 9f8aa601c..903cd8550 100644 --- a/Tests/test_image_quantize.py +++ b/Tests/test_image_quantize.py @@ -31,7 +31,9 @@ def test_libimagequant_quantize() -> None: converted = image.quantize(100, Image.Quantize.LIBIMAGEQUANT) assert converted.mode == "P" assert_image_similar(converted.convert("RGB"), image, 15) - assert len(converted.getcolors()) == 100 + colors = converted.getcolors() + assert colors is not None + assert len(colors) == 100 def test_octree_quantize() -> None: @@ -39,7 +41,9 @@ def test_octree_quantize() -> None: converted = image.quantize(100, Image.Quantize.FASTOCTREE) assert converted.mode == "P" assert_image_similar(converted.convert("RGB"), image, 20) - assert len(converted.getcolors()) == 100 + colors = converted.getcolors() + assert colors is not None + assert len(colors) == 100 def test_rgba_quantize() -> None: @@ -158,4 +162,6 @@ def test_small_palette() -> None: im = im.quantize(palette=p) # Assert - assert len(im.getcolors()) == 2 + quantized_colors = im.getcolors() + assert quantized_colors is not None + assert len(quantized_colors) == 2 diff --git a/Tests/test_image_resample.py b/Tests/test_image_resample.py index 92ba1553c..ce6209c0d 100644 --- a/Tests/test_image_resample.py +++ b/Tests/test_image_resample.py @@ -237,13 +237,13 @@ class TestImagingCoreResampleAccuracy: class TestCoreResampleConsistency: def make_case( self, mode: str, fill: tuple[int, int, int] | float - ) -> tuple[Image.Image, tuple[int, ...]]: + ) -> tuple[Image.Image, float | tuple[int, ...]]: im = Image.new(mode, (512, 9), fill) px = im.load() assert px is not None return im.resize((9, 512), Image.Resampling.LANCZOS), px[0, 0] - def run_case(self, case: tuple[Image.Image, int | tuple[int, ...]]) -> None: + def run_case(self, case: tuple[Image.Image, float | tuple[int, ...]]) -> None: channel, color = case px = channel.load() assert px is not None @@ -256,6 +256,7 @@ class TestCoreResampleConsistency: def test_8u(self) -> None: im, color = self.make_case("RGB", (0, 64, 255)) r, g, b = im.split() + assert isinstance(color, tuple) self.run_case((r, color[0])) self.run_case((g, color[1])) self.run_case((b, color[2])) @@ -290,7 +291,11 @@ class TestCoreResampleAlphaCorrect: px = i.load() assert px is not None for y in range(i.size[1]): - used_colors = {px[x, y][0] for x in range(i.size[0])} + used_colors = set() + for x in range(i.size[0]): + value = px[x, y] + assert isinstance(value, tuple) + used_colors.add(value[0]) assert 256 == len(used_colors), ( "All colors should be present in resized image. " f"Only {len(used_colors)} on line {y}." @@ -332,12 +337,13 @@ class TestCoreResampleAlphaCorrect: assert px is not None for y in range(i.size[1]): for x in range(i.size[0]): - if px[x, y][-1] != 0 and px[x, y][:-1] != clean_pixel: + value = px[x, y] + assert isinstance(value, tuple) + if value[-1] != 0 and value[:-1] != clean_pixel: message = ( - f"pixel at ({x}, {y}) is different:\n" - f"{px[x, y]}\n{clean_pixel}" + f"pixel at ({x}, {y}) is different:\n{value}\n{clean_pixel}" ) - assert px[x, y][:3] == clean_pixel, message + assert value[:3] == clean_pixel, message def test_dirty_pixels_rgba(self) -> None: case = self.make_dirty_case("RGBA", (255, 255, 0, 128), (0, 0, 255, 0)) diff --git a/Tests/test_image_transform.py b/Tests/test_image_transform.py index 638d12247..7e83396de 100644 --- a/Tests/test_image_transform.py +++ b/Tests/test_image_transform.py @@ -192,8 +192,9 @@ class TestImageTransform: im = op(im, (40, 10)) - colors = sorted(im.getcolors()) - assert colors == sorted( + colors = im.getcolors() + assert colors is not None + assert sorted(colors) == sorted( ( (20 * 10, opaque), (20 * 10, transparent), diff --git a/Tests/test_imagechops.py b/Tests/test_imagechops.py index 7e2290c15..4fc28cdb9 100644 --- a/Tests/test_imagechops.py +++ b/Tests/test_imagechops.py @@ -391,23 +391,25 @@ def test_overlay() -> None: def test_logical() -> None: def table( op: Callable[[Image.Image, Image.Image], Image.Image], a: int, b: int - ) -> tuple[int, int, int, int]: + ) -> list[float]: out = [] for x in (a, b): imx = Image.new("1", (1, 1), x) for y in (a, b): imy = Image.new("1", (1, 1), y) - out.append(op(imx, imy).getpixel((0, 0))) - return tuple(out) + value = op(imx, imy).getpixel((0, 0)) + assert not isinstance(value, tuple) and value is not None + out.append(value) + return out - assert table(ImageChops.logical_and, 0, 1) == (0, 0, 0, 255) - assert table(ImageChops.logical_or, 0, 1) == (0, 255, 255, 255) - assert table(ImageChops.logical_xor, 0, 1) == (0, 255, 255, 0) + assert table(ImageChops.logical_and, 0, 1) == [0, 0, 0, 255] + assert table(ImageChops.logical_or, 0, 1) == [0, 255, 255, 255] + assert table(ImageChops.logical_xor, 0, 1) == [0, 255, 255, 0] - assert table(ImageChops.logical_and, 0, 128) == (0, 0, 0, 255) - assert table(ImageChops.logical_or, 0, 128) == (0, 255, 255, 255) - assert table(ImageChops.logical_xor, 0, 128) == (0, 255, 255, 0) + assert table(ImageChops.logical_and, 0, 128) == [0, 0, 0, 255] + assert table(ImageChops.logical_or, 0, 128) == [0, 255, 255, 255] + assert table(ImageChops.logical_xor, 0, 128) == [0, 255, 255, 0] - assert table(ImageChops.logical_and, 0, 255) == (0, 0, 0, 255) - assert table(ImageChops.logical_or, 0, 255) == (0, 255, 255, 255) - assert table(ImageChops.logical_xor, 0, 255) == (0, 255, 255, 0) + assert table(ImageChops.logical_and, 0, 255) == [0, 0, 0, 255] + assert table(ImageChops.logical_or, 0, 255) == [0, 255, 255, 255] + assert table(ImageChops.logical_xor, 0, 255) == [0, 255, 255, 0] diff --git a/Tests/test_imagecms.py b/Tests/test_imagecms.py index c51f1155d..5ee5fcedf 100644 --- a/Tests/test_imagecms.py +++ b/Tests/test_imagecms.py @@ -691,7 +691,9 @@ def test_rgb_lab(mode: str) -> None: im = Image.new("LAB", (1, 1), (255, 0, 0)) converted_im = im.convert(mode) - assert converted_im.getpixel((0, 0))[:3] == (0, 255, 255) + value = converted_im.getpixel((0, 0)) + assert isinstance(value, tuple) + assert value[:3] == (0, 255, 255) def test_deprecation() -> None: diff --git a/Tests/test_imagedraw.py b/Tests/test_imagedraw.py index 1ff3fea2a..e397978cb 100644 --- a/Tests/test_imagedraw.py +++ b/Tests/test_imagedraw.py @@ -1,8 +1,8 @@ from __future__ import annotations -import contextlib import os.path from collections.abc import Sequence +from typing import Callable import pytest @@ -1422,25 +1422,44 @@ def test_default_font_size() -> None: im = Image.new("RGB", (220, 25)) draw = ImageDraw.Draw(im) - with contextlib.nullcontext() if freetype_support else pytest.raises(ImportError): + + def check(func: Callable[[], None]) -> None: + if freetype_support: + func() + else: + with pytest.raises(ImportError): + func() + + def draw_text() -> None: draw.text((0, 0), text, font_size=16) assert_image_equal_tofile(im, "Tests/images/imagedraw_default_font_size.png") - with contextlib.nullcontext() if freetype_support else pytest.raises(ImportError): + check(draw_text) + + def draw_textlength() -> None: assert draw.textlength(text, font_size=16) == 216 - with contextlib.nullcontext() if freetype_support else pytest.raises(ImportError): + check(draw_textlength) + + def draw_textbbox() -> None: assert draw.textbbox((0, 0), text, font_size=16) == (0, 3, 216, 19) + check(draw_textbbox) + im = Image.new("RGB", (220, 25)) draw = ImageDraw.Draw(im) - with contextlib.nullcontext() if freetype_support else pytest.raises(ImportError): + + def draw_multiline_text() -> None: draw.multiline_text((0, 0), text, font_size=16) assert_image_equal_tofile(im, "Tests/images/imagedraw_default_font_size.png") - with contextlib.nullcontext() if freetype_support else pytest.raises(ImportError): + check(draw_multiline_text) + + def draw_multiline_textbbox() -> None: assert draw.multiline_textbbox((0, 0), text, font_size=16) == (0, 3, 216, 19) + check(draw_multiline_textbbox) + @pytest.mark.parametrize("bbox", BBOX) def test_same_color_outline(bbox: Coords) -> None: diff --git a/Tests/test_imagefile.py b/Tests/test_imagefile.py index 68b28ef07..bb686bb3b 100644 --- a/Tests/test_imagefile.py +++ b/Tests/test_imagefile.py @@ -305,7 +305,7 @@ class TestPyDecoder(CodecsTest): def test_decode(self) -> None: decoder = ImageFile.PyDecoder(None) with pytest.raises(NotImplementedError): - decoder.decode(None) + decoder.decode(b"") class TestPyEncoder(CodecsTest): diff --git a/Tests/test_imagemorph.py b/Tests/test_imagemorph.py index 32615cf0e..4363f456e 100644 --- a/Tests/test_imagemorph.py +++ b/Tests/test_imagemorph.py @@ -41,11 +41,15 @@ A = string_to_img( def img_to_string(im: Image.Image) -> str: """Turn a (small) binary image into a string representation""" chars = ".1" - width, height = im.size - return "\n".join( - "".join(chars[im.getpixel((c, r)) > 0] for c in range(width)) - for r in range(height) - ) + result = [] + for r in range(im.height): + line = "" + for c in range(im.width): + value = im.getpixel((c, r)) + assert not isinstance(value, tuple) and value is not None + line += chars[value > 0] + result.append(line) + return "\n".join(result) def img_string_normalize(im: str) -> str: diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py index 3598df830..e33e6d4c8 100644 --- a/Tests/test_imageops.py +++ b/Tests/test_imageops.py @@ -259,20 +259,26 @@ def test_colorize_2color() -> None: left = (0, 1) middle = (127, 1) right = (255, 1) + value = im_test.getpixel(left) + assert isinstance(value, tuple) assert_tuple_approx_equal( - im_test.getpixel(left), + value, (255, 0, 0), threshold=1, msg="black test pixel incorrect", ) + value = im_test.getpixel(middle) + assert isinstance(value, tuple) assert_tuple_approx_equal( - im_test.getpixel(middle), + value, (127, 63, 0), threshold=1, msg="mid test pixel incorrect", ) + value = im_test.getpixel(right) + assert isinstance(value, tuple) assert_tuple_approx_equal( - im_test.getpixel(right), + value, (0, 127, 0), threshold=1, msg="white test pixel incorrect", @@ -295,20 +301,26 @@ def test_colorize_2color_offset() -> None: left = (25, 1) middle = (75, 1) right = (125, 1) + value = im_test.getpixel(left) + assert isinstance(value, tuple) assert_tuple_approx_equal( - im_test.getpixel(left), + value, (255, 0, 0), threshold=1, msg="black test pixel incorrect", ) + value = im_test.getpixel(middle) + assert isinstance(value, tuple) assert_tuple_approx_equal( - im_test.getpixel(middle), + value, (127, 63, 0), threshold=1, msg="mid test pixel incorrect", ) + value = im_test.getpixel(right) + assert isinstance(value, tuple) assert_tuple_approx_equal( - im_test.getpixel(right), + value, (0, 127, 0), threshold=1, msg="white test pixel incorrect", @@ -339,29 +351,37 @@ def test_colorize_3color_offset() -> None: middle = (100, 1) right_middle = (150, 1) right = (225, 1) + value = im_test.getpixel(left) + assert isinstance(value, tuple) assert_tuple_approx_equal( - im_test.getpixel(left), + value, (255, 0, 0), threshold=1, msg="black test pixel incorrect", ) + value = im_test.getpixel(left_middle) + assert isinstance(value, tuple) assert_tuple_approx_equal( - im_test.getpixel(left_middle), + value, (127, 0, 127), threshold=1, msg="low-mid test pixel incorrect", ) + value = im_test.getpixel(middle) + assert isinstance(value, tuple) + assert_tuple_approx_equal(value, (0, 0, 255), threshold=1, msg="mid incorrect") + value = im_test.getpixel(right_middle) + assert isinstance(value, tuple) assert_tuple_approx_equal( - im_test.getpixel(middle), (0, 0, 255), threshold=1, msg="mid incorrect" - ) - assert_tuple_approx_equal( - im_test.getpixel(right_middle), + value, (0, 63, 127), threshold=1, msg="high-mid test pixel incorrect", ) + value = im_test.getpixel(right) + assert isinstance(value, tuple) assert_tuple_approx_equal( - im_test.getpixel(right), + value, (0, 127, 0), threshold=1, msg="white test pixel incorrect", @@ -444,6 +464,7 @@ def test_exif_transpose_xml_without_xmp() -> None: del im.info["xmp"] transposed_im = ImageOps.exif_transpose(im) + assert transposed_im is not None assert 0x0112 not in transposed_im.getexif() diff --git a/Tests/test_imagetk.py b/Tests/test_imagetk.py index e5869892e..f84c6c03a 100644 --- a/Tests/test_imagetk.py +++ b/Tests/test_imagetk.py @@ -45,10 +45,12 @@ def test_kw() -> None: # Test "file" im = ImageTk._get_image_from_kw(kw) + assert im is not None assert_image_equal(im, im1) # Test "data" im = ImageTk._get_image_from_kw(kw) + assert im is not None assert_image_equal(im, im2) # Test no relevant entry diff --git a/Tests/test_tiff_ifdrational.py b/Tests/test_tiff_ifdrational.py index ae80b98b8..9d06a9332 100644 --- a/Tests/test_tiff_ifdrational.py +++ b/Tests/test_tiff_ifdrational.py @@ -11,7 +11,11 @@ from PIL.TiffImagePlugin import IFDRational from .helper import hopper, skip_unless_feature -def _test_equal(num, denom, target) -> None: +def _test_equal( + num: float | Fraction | IFDRational, + denom: int, + target: float | Fraction | IFDRational, +) -> None: t = IFDRational(num, denom) assert target == t From 69cca2a10364a6128415815d3fc14df7314db596 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 6 Jul 2024 15:08:35 +1000 Subject: [PATCH 13/30] Added type hints --- src/PIL/PdfParser.py | 274 +++++++++++++++++++++++++++---------------- 1 file changed, 172 insertions(+), 102 deletions(-) diff --git a/src/PIL/PdfParser.py b/src/PIL/PdfParser.py index 622dc7de9..7cb2d241b 100644 --- a/src/PIL/PdfParser.py +++ b/src/PIL/PdfParser.py @@ -8,7 +8,7 @@ import os import re import time import zlib -from typing import TYPE_CHECKING, Any, NamedTuple, Union +from typing import IO, TYPE_CHECKING, Any, NamedTuple, Union # see 7.9.2.2 Text String Type on page 86 and D.3 PDFDocEncoding Character Set @@ -62,7 +62,7 @@ PDFDocEncoding = { } -def decode_text(b): +def decode_text(b: bytes) -> str: if b[: len(codecs.BOM_UTF16_BE)] == codecs.BOM_UTF16_BE: return b[len(codecs.BOM_UTF16_BE) :].decode("utf_16_be") else: @@ -99,7 +99,7 @@ class IndirectReference(IndirectReferenceTuple): assert isinstance(other, IndirectReference) return other.object_id == self.object_id and other.generation == self.generation - def __ne__(self, other): + def __ne__(self, other: object) -> bool: return not (self == other) def __hash__(self) -> int: @@ -112,13 +112,17 @@ class IndirectObjectDef(IndirectReference): class XrefTable: - def __init__(self): - self.existing_entries = {} # object ID => (offset, generation) - self.new_entries = {} # object ID => (offset, generation) + def __init__(self) -> None: + self.existing_entries: dict[int, tuple[int, int]] = ( + {} + ) # object ID => (offset, generation) + self.new_entries: dict[int, tuple[int, int]] = ( + {} + ) # object ID => (offset, generation) self.deleted_entries = {0: 65536} # object ID => generation self.reading_finished = False - def __setitem__(self, key, value): + def __setitem__(self, key: int, value: tuple[int, int]) -> None: if self.reading_finished: self.new_entries[key] = value else: @@ -126,13 +130,13 @@ class XrefTable: if key in self.deleted_entries: del self.deleted_entries[key] - def __getitem__(self, key): + def __getitem__(self, key: int) -> tuple[int, int]: try: return self.new_entries[key] except KeyError: return self.existing_entries[key] - def __delitem__(self, key): + def __delitem__(self, key: int) -> None: if key in self.new_entries: generation = self.new_entries[key][1] + 1 del self.new_entries[key] @@ -146,7 +150,7 @@ class XrefTable: msg = f"object ID {key} cannot be deleted because it doesn't exist" raise IndexError(msg) - def __contains__(self, key): + def __contains__(self, key: int) -> bool: return key in self.existing_entries or key in self.new_entries def __len__(self) -> int: @@ -156,19 +160,19 @@ class XrefTable: | set(self.deleted_entries.keys()) ) - def keys(self): + def keys(self) -> set[int]: return ( set(self.existing_entries.keys()) - set(self.deleted_entries.keys()) ) | set(self.new_entries.keys()) - def write(self, f): + def write(self, f: IO[bytes]) -> int: keys = sorted(set(self.new_entries.keys()) | set(self.deleted_entries.keys())) deleted_keys = sorted(set(self.deleted_entries.keys())) startxref = f.tell() f.write(b"xref\n") while keys: # find a contiguous sequence of object IDs - prev = None + prev: int | None = None for index, key in enumerate(keys): if prev is None or prev + 1 == key: prev = key @@ -178,7 +182,7 @@ class XrefTable: break else: contiguous_keys = keys - keys = None + keys = [] f.write(b"%d %d\n" % (contiguous_keys[0], len(contiguous_keys))) for object_id in contiguous_keys: if object_id in self.new_entries: @@ -202,7 +206,9 @@ class XrefTable: class PdfName: - def __init__(self, name): + name: bytes + + def __init__(self, name: PdfName | bytes | str) -> None: if isinstance(name, PdfName): self.name = name.name elif isinstance(name, bytes): @@ -213,7 +219,7 @@ class PdfName: def name_as_str(self) -> str: return self.name.decode("us-ascii") - def __eq__(self, other): + def __eq__(self, other: object) -> bool: return ( isinstance(other, PdfName) and other.name == self.name ) or other == self.name @@ -225,7 +231,7 @@ class PdfName: return f"{self.__class__.__name__}({repr(self.name)})" @classmethod - def from_pdf_stream(cls, data): + def from_pdf_stream(cls, data: bytes) -> PdfName: return cls(PdfParser.interpret_name(data)) allowed_chars = set(range(33, 127)) - {ord(c) for c in "#%/()<>[]{}"} @@ -252,13 +258,13 @@ else: class PdfDict(_DictBase): - def __setattr__(self, key, value): + def __setattr__(self, key: str, value: Any) -> None: if key == "data": collections.UserDict.__setattr__(self, key, value) else: self[key.encode("us-ascii")] = value - def __getattr__(self, key): + def __getattr__(self, key: str) -> str | time.struct_time: try: value = self[key.encode("us-ascii")] except KeyError as e: @@ -300,7 +306,7 @@ class PdfDict(_DictBase): class PdfBinary: - def __init__(self, data): + def __init__(self, data: list[int] | bytes) -> None: self.data = data def __bytes__(self) -> bytes: @@ -308,27 +314,27 @@ class PdfBinary: class PdfStream: - def __init__(self, dictionary, buf): + def __init__(self, dictionary: PdfDict, buf: bytes) -> None: self.dictionary = dictionary self.buf = buf - def decode(self): + def decode(self) -> bytes: try: - filter = self.dictionary.Filter - except AttributeError: + filter = self.dictionary[b"Filter"] + except KeyError: return self.buf if filter == b"FlateDecode": try: - expected_length = self.dictionary.DL - except AttributeError: - expected_length = self.dictionary.Length + expected_length = self.dictionary[b"DL"] + except KeyError: + expected_length = self.dictionary[b"Length"] return zlib.decompress(self.buf, bufsize=int(expected_length)) else: - msg = f"stream filter {repr(self.dictionary.Filter)} unknown/unsupported" + msg = f"stream filter {repr(filter)} unknown/unsupported" raise NotImplementedError(msg) -def pdf_repr(x): +def pdf_repr(x: Any) -> bytes: if x is True: return b"true" elif x is False: @@ -363,12 +369,19 @@ class PdfParser: Supports PDF up to 1.4 """ - def __init__(self, filename=None, f=None, buf=None, start_offset=0, mode="rb"): + def __init__( + self, + filename: str | None = None, + f: IO[bytes] | None = None, + buf: bytes | bytearray | None = None, + start_offset: int = 0, + mode: str = "rb", + ) -> None: if buf and f: msg = "specify buf or f or filename, but not both buf and f" raise RuntimeError(msg) self.filename = filename - self.buf = buf + self.buf: bytes | bytearray | mmap.mmap | None = buf self.f = f self.start_offset = start_offset self.should_close_buf = False @@ -377,12 +390,16 @@ class PdfParser: self.f = f = open(filename, mode) self.should_close_file = True if f is not None: - self.buf = buf = self.get_buf_from_file(f) + self.buf = self.get_buf_from_file(f) self.should_close_buf = True if not filename and hasattr(f, "name"): self.filename = f.name - self.cached_objects = {} - if buf: + self.cached_objects: dict[IndirectReference, Any] = {} + self.root_ref: IndirectReference | None + self.info_ref: IndirectReference | None + self.pages_ref: IndirectReference | None + self.last_xref_section_offset: int | None + if self.buf: self.read_pdf_info() else: self.file_size_total = self.file_size_this = 0 @@ -390,12 +407,12 @@ class PdfParser: self.root_ref = None self.info = PdfDict() self.info_ref = None - self.page_tree_root = {} - self.pages = [] - self.orig_pages = [] + self.page_tree_root = PdfDict() + self.pages: list[IndirectReference] = [] + self.orig_pages: list[IndirectReference] = [] self.pages_ref = None self.last_xref_section_offset = None - self.trailer_dict = {} + self.trailer_dict: dict[bytes, Any] = {} self.xref_table = XrefTable() self.xref_table.reading_finished = True if f: @@ -412,10 +429,8 @@ class PdfParser: self.seek_end() def close_buf(self) -> None: - try: + if isinstance(self.buf, mmap.mmap): self.buf.close() - except AttributeError: - pass self.buf = None def close(self) -> None: @@ -426,15 +441,19 @@ class PdfParser: self.f = None def seek_end(self) -> None: + assert self.f is not None self.f.seek(0, os.SEEK_END) def write_header(self) -> None: + assert self.f is not None self.f.write(b"%PDF-1.4\n") - def write_comment(self, s): + def write_comment(self, s: str) -> None: + assert self.f is not None self.f.write(f"% {s}\n".encode()) def write_catalog(self) -> IndirectReference: + assert self.f is not None self.del_root() self.root_ref = self.next_object_id(self.f.tell()) self.pages_ref = self.next_object_id(0) @@ -477,7 +496,10 @@ class PdfParser: pages_tree_node_ref = pages_tree_node.get(b"Parent", None) self.orig_pages = [] - def write_xref_and_trailer(self, new_root_ref=None): + def write_xref_and_trailer( + self, new_root_ref: IndirectReference | None = None + ) -> None: + assert self.f is not None if new_root_ref: self.del_root() self.root_ref = new_root_ref @@ -485,7 +507,10 @@ class PdfParser: self.info_ref = self.write_obj(None, self.info) start_xref = self.xref_table.write(self.f) num_entries = len(self.xref_table) - trailer_dict = {b"Root": self.root_ref, b"Size": num_entries} + trailer_dict: dict[str | bytes, Any] = { + b"Root": self.root_ref, + b"Size": num_entries, + } if self.last_xref_section_offset is not None: trailer_dict[b"Prev"] = self.last_xref_section_offset if self.info: @@ -497,16 +522,20 @@ class PdfParser: + b"\nstartxref\n%d\n%%%%EOF" % start_xref ) - def write_page(self, ref, *objs, **dict_obj): - if isinstance(ref, int): - ref = self.pages[ref] + def write_page( + self, ref: int | IndirectReference | None, *objs: Any, **dict_obj: Any + ) -> IndirectReference: + obj_ref = self.pages[ref] if isinstance(ref, int) else ref if "Type" not in dict_obj: dict_obj["Type"] = PdfName(b"Page") if "Parent" not in dict_obj: dict_obj["Parent"] = self.pages_ref - return self.write_obj(ref, *objs, **dict_obj) + return self.write_obj(obj_ref, *objs, **dict_obj) - def write_obj(self, ref, *objs, **dict_obj): + def write_obj( + self, ref: IndirectReference | None, *objs: Any, **dict_obj: Any + ) -> IndirectReference: + assert self.f is not None f = self.f if ref is None: ref = self.next_object_id(f.tell()) @@ -534,7 +563,7 @@ class PdfParser: del self.xref_table[self.root[b"Pages"].object_id] @staticmethod - def get_buf_from_file(f): + def get_buf_from_file(f: IO[bytes]) -> bytes | mmap.mmap: if hasattr(f, "getbuffer"): return f.getbuffer() elif hasattr(f, "getvalue"): @@ -546,10 +575,15 @@ class PdfParser: return b"" def read_pdf_info(self) -> None: + assert self.buf is not None self.file_size_total = len(self.buf) self.file_size_this = self.file_size_total - self.start_offset self.read_trailer() + check_format_condition( + self.trailer_dict.get(b"Root") is not None, "Root is missing" + ) self.root_ref = self.trailer_dict[b"Root"] + assert self.root_ref is not None self.info_ref = self.trailer_dict.get(b"Info", None) self.root = PdfDict(self.read_indirect(self.root_ref)) if self.info_ref is None: @@ -560,12 +594,15 @@ class PdfParser: check_format_condition( self.root[b"Type"] == b"Catalog", "/Type in Root is not /Catalog" ) - check_format_condition(b"Pages" in self.root, "/Pages missing in Root") + check_format_condition( + self.root.get(b"Pages") is not None, "/Pages missing in Root" + ) check_format_condition( isinstance(self.root[b"Pages"], IndirectReference), "/Pages in Root is not an indirect reference", ) self.pages_ref = self.root[b"Pages"] + assert self.pages_ref is not None self.page_tree_root = self.read_indirect(self.pages_ref) self.pages = self.linearize_page_tree(self.page_tree_root) # save the original list of page references @@ -573,7 +610,7 @@ class PdfParser: # and we need to rewrite the pages and their list self.orig_pages = self.pages[:] - def next_object_id(self, offset=None): + def next_object_id(self, offset: int | None = None) -> IndirectReference: try: # TODO: support reuse of deleted objects reference = IndirectReference(max(self.xref_table.keys()) + 1, 0) @@ -623,12 +660,13 @@ class PdfParser: re.DOTALL, ) - def read_trailer(self): + def read_trailer(self) -> None: + assert self.buf is not None search_start_offset = len(self.buf) - 16384 if search_start_offset < self.start_offset: search_start_offset = self.start_offset m = self.re_trailer_end.search(self.buf, search_start_offset) - check_format_condition(m, "trailer end not found") + check_format_condition(m is not None, "trailer end not found") # make sure we found the LAST trailer last_match = m while m: @@ -636,6 +674,7 @@ class PdfParser: m = self.re_trailer_end.search(self.buf, m.start() + 16) if not m: m = last_match + assert m is not None trailer_data = m.group(1) self.last_xref_section_offset = int(m.group(2)) self.trailer_dict = self.interpret_trailer(trailer_data) @@ -644,12 +683,14 @@ class PdfParser: if b"Prev" in self.trailer_dict: self.read_prev_trailer(self.trailer_dict[b"Prev"]) - def read_prev_trailer(self, xref_section_offset): + def read_prev_trailer(self, xref_section_offset: int) -> None: + assert self.buf is not None trailer_offset = self.read_xref_table(xref_section_offset=xref_section_offset) m = self.re_trailer_prev.search( self.buf[trailer_offset : trailer_offset + 16384] ) - check_format_condition(m, "previous trailer not found") + check_format_condition(m is not None, "previous trailer not found") + assert m is not None trailer_data = m.group(1) check_format_condition( int(m.group(2)) == xref_section_offset, @@ -670,7 +711,7 @@ class PdfParser: re_dict_end = re.compile(whitespace_optional + rb">>" + whitespace_optional) @classmethod - def interpret_trailer(cls, trailer_data): + def interpret_trailer(cls, trailer_data: bytes) -> dict[bytes, Any]: trailer = {} offset = 0 while True: @@ -678,14 +719,18 @@ class PdfParser: if not m: m = cls.re_dict_end.match(trailer_data, offset) check_format_condition( - m and m.end() == len(trailer_data), + m is not None and m.end() == len(trailer_data), "name not found in trailer, remaining data: " + repr(trailer_data[offset:]), ) break key = cls.interpret_name(m.group(1)) - value, offset = cls.get_value(trailer_data, m.end()) + assert isinstance(key, bytes) + value, value_offset = cls.get_value(trailer_data, m.end()) trailer[key] = value + if value_offset is None: + break + offset = value_offset check_format_condition( b"Size" in trailer and isinstance(trailer[b"Size"], int), "/Size not in trailer or not an integer", @@ -699,7 +744,7 @@ class PdfParser: re_hashes_in_name = re.compile(rb"([^#]*)(#([0-9a-fA-F]{2}))?") @classmethod - def interpret_name(cls, raw, as_text=False): + def interpret_name(cls, raw: bytes, as_text: bool = False) -> str | bytes: name = b"" for m in cls.re_hashes_in_name.finditer(raw): if m.group(3): @@ -761,7 +806,13 @@ class PdfParser: ) @classmethod - def get_value(cls, data, offset, expect_indirect=None, max_nesting=-1): + def get_value( + cls, + data: bytes | bytearray | mmap.mmap, + offset: int, + expect_indirect: IndirectReference | None = None, + max_nesting: int = -1, + ) -> tuple[Any, int | None]: if max_nesting == 0: return None, None m = cls.re_comment.match(data, offset) @@ -783,11 +834,16 @@ class PdfParser: == IndirectReference(int(m.group(1)), int(m.group(2))), "indirect object definition different than expected", ) - object, offset = cls.get_value(data, m.end(), max_nesting=max_nesting - 1) - if offset is None: + object, object_offset = cls.get_value( + data, m.end(), max_nesting=max_nesting - 1 + ) + if object_offset is None: return object, None - m = cls.re_indirect_def_end.match(data, offset) - check_format_condition(m, "indirect object definition end not found") + m = cls.re_indirect_def_end.match(data, object_offset) + check_format_condition( + m is not None, "indirect object definition end not found" + ) + assert m is not None return object, m.end() check_format_condition( not expect_indirect, "indirect object definition not found" @@ -806,46 +862,53 @@ class PdfParser: m = cls.re_dict_start.match(data, offset) if m: offset = m.end() - result = {} + result: dict[Any, Any] = {} m = cls.re_dict_end.match(data, offset) + current_offset: int | None = offset while not m: - key, offset = cls.get_value(data, offset, max_nesting=max_nesting - 1) - if offset is None: + assert current_offset is not None + key, current_offset = cls.get_value( + data, current_offset, max_nesting=max_nesting - 1 + ) + if current_offset is None: return result, None - value, offset = cls.get_value(data, offset, max_nesting=max_nesting - 1) + value, current_offset = cls.get_value( + data, current_offset, max_nesting=max_nesting - 1 + ) result[key] = value - if offset is None: + if current_offset is None: return result, None - m = cls.re_dict_end.match(data, offset) - offset = m.end() - m = cls.re_stream_start.match(data, offset) + m = cls.re_dict_end.match(data, current_offset) + current_offset = m.end() + m = cls.re_stream_start.match(data, current_offset) if m: - try: - stream_len_str = result.get(b"Length") - stream_len = int(stream_len_str) - except (TypeError, ValueError) as e: - msg = f"bad or missing Length in stream dict ({stream_len_str})" - raise PdfFormatError(msg) from e + stream_len = result.get(b"Length") + if stream_len is None or not isinstance(stream_len, int): + msg = f"bad or missing Length in stream dict ({stream_len})" + raise PdfFormatError(msg) stream_data = data[m.end() : m.end() + stream_len] m = cls.re_stream_end.match(data, m.end() + stream_len) - check_format_condition(m, "stream end not found") - offset = m.end() - result = PdfStream(PdfDict(result), stream_data) - else: - result = PdfDict(result) - return result, offset + check_format_condition(m is not None, "stream end not found") + assert m is not None + current_offset = m.end() + return PdfStream(PdfDict(result), stream_data), current_offset + return PdfDict(result), current_offset m = cls.re_array_start.match(data, offset) if m: offset = m.end() - result = [] + results = [] m = cls.re_array_end.match(data, offset) + current_offset = offset while not m: - value, offset = cls.get_value(data, offset, max_nesting=max_nesting - 1) - result.append(value) - if offset is None: - return result, None - m = cls.re_array_end.match(data, offset) - return result, m.end() + assert current_offset is not None + value, current_offset = cls.get_value( + data, current_offset, max_nesting=max_nesting - 1 + ) + results.append(value) + if current_offset is None: + return results, None + m = cls.re_array_end.match(data, current_offset) + return results, m.end() m = cls.re_null.match(data, offset) if m: return None, m.end() @@ -905,7 +968,9 @@ class PdfParser: } @classmethod - def get_literal_string(cls, data, offset): + def get_literal_string( + cls, data: bytes | bytearray | mmap.mmap, offset: int + ) -> tuple[bytes, int]: nesting_depth = 0 result = bytearray() for m in cls.re_lit_str_token.finditer(data, offset): @@ -941,12 +1006,14 @@ class PdfParser: ) re_xref_entry = re.compile(rb"([0-9]{10}) ([0-9]{5}) ([fn])( \r| \n|\r\n)") - def read_xref_table(self, xref_section_offset): + def read_xref_table(self, xref_section_offset: int) -> int: + assert self.buf is not None subsection_found = False m = self.re_xref_section_start.match( self.buf, xref_section_offset + self.start_offset ) - check_format_condition(m, "xref section start not found") + check_format_condition(m is not None, "xref section start not found") + assert m is not None offset = m.end() while True: m = self.re_xref_subsection_start.match(self.buf, offset) @@ -961,7 +1028,8 @@ class PdfParser: num_objects = int(m.group(2)) for i in range(first_object, first_object + num_objects): m = self.re_xref_entry.match(self.buf, offset) - check_format_condition(m, "xref entry not found") + check_format_condition(m is not None, "xref entry not found") + assert m is not None offset = m.end() is_free = m.group(3) == b"f" if not is_free: @@ -971,13 +1039,14 @@ class PdfParser: self.xref_table[i] = new_entry return offset - def read_indirect(self, ref, max_nesting=-1): + def read_indirect(self, ref: IndirectReference, max_nesting: int = -1) -> Any: offset, generation = self.xref_table[ref[0]] check_format_condition( generation == ref[1], f"expected to find generation {ref[1]} for object ID {ref[0]} in xref " f"table, instead found generation {generation} at offset {offset}", ) + assert self.buf is not None value = self.get_value( self.buf, offset + self.start_offset, @@ -987,14 +1056,15 @@ class PdfParser: self.cached_objects[ref] = value return value - def linearize_page_tree(self, node=None): - if node is None: - node = self.page_tree_root + def linearize_page_tree( + self, node: PdfDict | None = None + ) -> list[IndirectReference]: + page_node = node if node is not None else self.page_tree_root check_format_condition( - node[b"Type"] == b"Pages", "/Type of page tree node is not /Pages" + page_node[b"Type"] == b"Pages", "/Type of page tree node is not /Pages" ) pages = [] - for kid in node[b"Kids"]: + for kid in page_node[b"Kids"]: kid_object = self.read_indirect(kid) if kid_object[b"Type"] == b"Page": pages.append(kid) From 486dac7efcc3fd937a00f4180ef32f4a75686f15 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 6 Jul 2024 19:17:23 +1000 Subject: [PATCH 14/30] Added type hints --- Tests/test_file_gif.py | 2 +- Tests/test_file_libtiff.py | 21 ++++++++++++++++----- Tests/test_file_pdf.py | 2 +- Tests/test_file_tiff.py | 22 +++++++++++++--------- Tests/test_image.py | 2 +- Tests/test_imagegrab.py | 7 ++++--- pyproject.toml | 8 ++++++++ src/PIL/Image.py | 4 ++-- tox.ini | 4 +++- 9 files changed, 49 insertions(+), 23 deletions(-) diff --git a/Tests/test_file_gif.py b/Tests/test_file_gif.py index 79a2ec0ab..85b017d29 100644 --- a/Tests/test_file_gif.py +++ b/Tests/test_file_gif.py @@ -353,7 +353,7 @@ def test_palette_434(tmp_path: Path) -> None: def roundtrip(im: Image.Image, **kwargs: bool) -> Image.Image: out = str(tmp_path / "temp.gif") - im.copy().save(out, **kwargs) + im.copy().save(out, "GIF", **kwargs) reloaded = Image.open(out) return reloaded diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py index 8707311dc..d5dbeeb6f 100644 --- a/Tests/test_file_libtiff.py +++ b/Tests/test_file_libtiff.py @@ -92,11 +92,22 @@ class TestFileLibTiff(LibTiffTestCase): def test_g4_non_disk_file_object(self, tmp_path: Path) -> None: """Testing loading from non-disk non-BytesIO file object""" test_file = "Tests/images/hopper_g4_500.tif" - s = io.BytesIO() with open(test_file, "rb") as f: - s.write(f.read()) - s.seek(0) - r = io.BufferedReader(s) + data = f.read() + + class NonBytesIO(io.RawIOBase): + def read(self, size: int = -1) -> bytes: + nonlocal data + if size == -1: + size = len(data) + result = data[:size] + data = data[size:] + return result + + def readable(self) -> bool: + return True + + r = io.BufferedReader(NonBytesIO()) with Image.open(r) as im: assert im.size == (500, 500) self._assert_noerr(tmp_path, im) @@ -1139,7 +1150,7 @@ class TestFileLibTiff(LibTiffTestCase): arguments: dict[str, str | int] = {"compression": "tiff_adobe_deflate"} if argument: arguments["strip_size"] = 2**18 - im.save(out, **arguments) + im.save(out, "TIFF", **arguments) with Image.open(out) as im: assert isinstance(im, TiffImagePlugin.TiffImageFile) diff --git a/Tests/test_file_pdf.py b/Tests/test_file_pdf.py index 02f07a565..3729ca58b 100644 --- a/Tests/test_file_pdf.py +++ b/Tests/test_file_pdf.py @@ -118,7 +118,7 @@ def test_dpi(params: dict[str, int | tuple[int, int]], tmp_path: Path) -> None: im = hopper() outfile = str(tmp_path / "temp.pdf") - im.save(outfile, **params) + im.save(outfile, "PDF", **params) with open(outfile, "rb") as fp: contents = fp.read() diff --git a/Tests/test_file_tiff.py b/Tests/test_file_tiff.py index 93a9f9e6a..8cad25272 100644 --- a/Tests/test_file_tiff.py +++ b/Tests/test_file_tiff.py @@ -78,6 +78,7 @@ class TestFileTiff: def test_seek_after_close(self) -> None: im = Image.open("Tests/images/multipage.tiff") + assert isinstance(im, TiffImagePlugin.TiffImageFile) im.close() with pytest.raises(ValueError): @@ -424,13 +425,13 @@ class TestFileTiff: def test_load_float(self) -> None: ifd = TiffImagePlugin.ImageFileDirectory_v2() data = b"abcdabcd" - ret = ifd.load_float(data, False) + ret = getattr(ifd, "load_float")(data, False) assert ret == (1.6777999408082104e22, 1.6777999408082104e22) def test_load_double(self) -> None: ifd = TiffImagePlugin.ImageFileDirectory_v2() data = b"abcdefghabcdefgh" - ret = ifd.load_double(data, False) + ret = getattr(ifd, "load_double")(data, False) assert ret == (8.540883223036124e194, 8.540883223036124e194) def test_ifd_tag_type(self) -> None: @@ -599,7 +600,7 @@ class TestFileTiff: def test_with_underscores(self, tmp_path: Path) -> None: kwargs = {"resolution_unit": "inch", "x_resolution": 72, "y_resolution": 36} filename = str(tmp_path / "temp.tif") - hopper("RGB").save(filename, **kwargs) + hopper("RGB").save(filename, "TIFF", **kwargs) with Image.open(filename) as im: # legacy interface assert im.tag[X_RESOLUTION][0][0] == 72 @@ -624,14 +625,17 @@ class TestFileTiff: def test_iptc(self, tmp_path: Path) -> None: # Do not preserve IPTC_NAA_CHUNK by default if type is LONG outfile = str(tmp_path / "temp.tif") - im = hopper() - ifd = TiffImagePlugin.ImageFileDirectory_v2() - ifd[33723] = 1 - ifd.tagtype[33723] = 4 - im.tag_v2 = ifd - im.save(outfile) + with Image.open("Tests/images/hopper.tif") as im: + im.load() + assert isinstance(im, TiffImagePlugin.TiffImageFile) + ifd = TiffImagePlugin.ImageFileDirectory_v2() + ifd[33723] = 1 + ifd.tagtype[33723] = 4 + im.tag_v2 = ifd + im.save(outfile) with Image.open(outfile) as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) assert 33723 not in im.tag_v2 def test_rowsperstrip(self, tmp_path: Path) -> None: diff --git a/Tests/test_image.py b/Tests/test_image.py index 8e6f63fac..4fdc41791 100644 --- a/Tests/test_image.py +++ b/Tests/test_image.py @@ -704,7 +704,7 @@ class TestImage: else: assert new_image.palette is None - _make_new(im, im_p, ImagePalette.ImagePalette(list(range(256)) * 3)) + _make_new(im, im_p, ImagePalette.ImagePalette("RGB")) _make_new(im_p, im, None) _make_new(im, blank_p, ImagePalette.ImagePalette()) _make_new(im, blank_pa, ImagePalette.ImagePalette()) diff --git a/Tests/test_imagegrab.py b/Tests/test_imagegrab.py index 5dfa51697..5cd510751 100644 --- a/Tests/test_imagegrab.py +++ b/Tests/test_imagegrab.py @@ -60,6 +60,8 @@ class TestImageGrab: def test_grabclipboard(self) -> None: if sys.platform == "darwin": subprocess.call(["screencapture", "-cx"]) + + ImageGrab.grabclipboard() elif sys.platform == "win32": p = subprocess.Popen(["powershell", "-command", "-"], stdin=subprocess.PIPE) p.stdin.write( @@ -69,6 +71,8 @@ $bmp = New-Object Drawing.Bitmap 200, 200 [Windows.Forms.Clipboard]::SetImage($bmp)""" ) p.communicate() + + ImageGrab.grabclipboard() else: if not shutil.which("wl-paste") and not shutil.which("xclip"): with pytest.raises( @@ -77,9 +81,6 @@ $bmp = New-Object Drawing.Bitmap 200, 200 r" ImageGrab.grabclipboard\(\) on Linux", ): ImageGrab.grabclipboard() - return - - ImageGrab.grabclipboard() @pytest.mark.skipif(sys.platform != "win32", reason="Windows only") def test_grabclipboard_file(self) -> None: diff --git a/pyproject.toml b/pyproject.toml index 700d5e7fe..cd7248669 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -155,3 +155,11 @@ follow_imports = "silent" warn_redundant_casts = true warn_unreachable = true warn_unused_ignores = true +exclude = [ + '^Tests/oss-fuzz/fuzz_font.py$', + '^Tests/oss-fuzz/fuzz_pillow.py$', + '^Tests/test_qt_image_qapplication.py$', + '^Tests/test_font_pcf_charsets.py$', + '^Tests/test_font_pcf.py$', + '^Tests/test_file_tar.py$', +] diff --git a/src/PIL/Image.py b/src/PIL/Image.py index dbb72ed6a..565abe71d 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -1886,7 +1886,7 @@ class Image: def point( self, - lut: Sequence[float] | Callable[[int], float] | ImagePointHandler, + lut: Sequence[float] | NumpyArray | Callable[[int], float] | ImagePointHandler, mode: str | None = None, ) -> Image: """ @@ -1996,7 +1996,7 @@ class Image: def putdata( self, - data: Sequence[float] | Sequence[Sequence[int]], + data: Sequence[float] | Sequence[Sequence[int]] | NumpyArray, scale: float = 1.0, offset: float = 0.0, ) -> None: diff --git a/tox.ini b/tox.ini index 189415237..c1bc3b17d 100644 --- a/tox.ini +++ b/tox.ini @@ -38,9 +38,11 @@ deps = ipython numpy packaging + pytest types-defusedxml types-olefile + types-setuptools extras = typing commands = - mypy src {posargs} + mypy src Tests {posargs} From 6883018725bbaa5f616c1ec190393efcaef7a2ce Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 6 Jul 2024 22:06:47 +1000 Subject: [PATCH 15/30] Added type hint --- Tests/test_file_pdf.py | 1 + 1 file changed, 1 insertion(+) diff --git a/Tests/test_file_pdf.py b/Tests/test_file_pdf.py index 3729ca58b..1d5001b1a 100644 --- a/Tests/test_file_pdf.py +++ b/Tests/test_file_pdf.py @@ -229,6 +229,7 @@ def test_pdf_append_fails_on_nonexistent_file() -> None: def check_pdf_pages_consistency(pdf: PdfParser.PdfParser) -> None: + assert pdf.pages_ref is not None pages_info = pdf.read_indirect(pdf.pages_ref) assert b"Parent" not in pages_info assert b"Kids" in pages_info From 41eb218a6829ddc58a282d6952d9c0b5af25688d Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sun, 7 Jul 2024 22:04:50 +1000 Subject: [PATCH 16/30] Check GIL, rather than Python version --- .ci/install.sh | 4 ++-- .github/workflows/test.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.ci/install.sh b/.ci/install.sh index e1274f41c..8e65f64c4 100755 --- a/.ci/install.sh +++ b/.ci/install.sh @@ -38,7 +38,7 @@ python3 -m pip install pyroma if [[ $(uname) != CYGWIN* ]]; then # TODO Update condition when NumPy supports free-threading - if [[ "$GHA_PYTHON_VERSION" == "3.13-dev" ]]; then + if [[ "$PYTHON_GIL" == "0" ]]; then python3 -m pip install numpy --index-url https://pypi.anaconda.org/scientific-python-nightly-wheels/simple else python3 -m pip install numpy @@ -48,7 +48,7 @@ if [[ $(uname) != CYGWIN* ]]; then if [[ $GHA_PYTHON_VERSION == 3.* ]]; then sudo apt-get -qq install libegl1 libxcb-cursor0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-shape0 libxkbcommon-x11-0 # TODO Update condition when pyqt6 supports free-threading - if ! [[ "$GHA_PYTHON_VERSION" == "3.13-dev" ]]; then python3 -m pip install pyqt6 ; fi + if ! [[ "$PYTHON_GIL" == "0" ]]; then python3 -m pip install pyqt6 ; fi fi # Pyroma uses non-isolated build and fails with old setuptools diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 702841814..6e63333b0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -67,7 +67,7 @@ jobs: - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 - if: "!endsWith(matrix.python-version, '-dev')" + if: "${{ !matrix.disable-gil }}" with: python-version: ${{ matrix.python-version }} allow-prereleases: true @@ -78,7 +78,7 @@ jobs: - name: Set up Python ${{ matrix.python-version }} (free-threaded) uses: deadsnakes/action@v3.1.0 - if: endsWith(matrix.python-version, '-dev') + if: "${{ matrix.disable-gil }}" with: python-version: ${{ matrix.python-version }} nogil: ${{ matrix.disable-gil }} From 8a05e32336bfbece4aed16a63d009efa55a1b4bb Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 8 Jul 2024 20:09:45 +1000 Subject: [PATCH 17/30] Added type hints --- Tests/test_file_mpo.py | 16 +++++++++---- Tests/test_imagefile.py | 5 +++- Tests/test_imagetk.py | 3 +++ src/PIL/BlpImagePlugin.py | 1 + src/PIL/BmpImagePlugin.py | 44 +++++++++++++++++++++++++----------- src/PIL/ImageFile.py | 18 +++++++-------- src/PIL/ImageTk.py | 24 ++++++++++++++------ src/PIL/Jpeg2KImagePlugin.py | 40 +++++++++++++++++++++++++++----- src/PIL/JpegImagePlugin.py | 18 ++++++++------- src/PIL/MpoImagePlugin.py | 17 ++++++++++---- src/PIL/PngImagePlugin.py | 42 +++++++++++++++++++++++----------- src/PIL/PsdImagePlugin.py | 2 +- src/PIL/WebPImagePlugin.py | 2 +- src/PIL/WmfImagePlugin.py | 2 +- src/PIL/_imagingtk.pyi | 3 +++ 15 files changed, 168 insertions(+), 69 deletions(-) create mode 100644 src/PIL/_imagingtk.pyi diff --git a/Tests/test_file_mpo.py b/Tests/test_file_mpo.py index 39b9c60b7..5402fcb44 100644 --- a/Tests/test_file_mpo.py +++ b/Tests/test_file_mpo.py @@ -2,11 +2,11 @@ from __future__ import annotations import warnings from io import BytesIO -from typing import Any, cast +from typing import Any import pytest -from PIL import Image, MpoImagePlugin +from PIL import Image, ImageFile, MpoImagePlugin from .helper import ( assert_image_equal, @@ -20,11 +20,11 @@ test_files = ["Tests/images/sugarshack.mpo", "Tests/images/frozenpond.mpo"] pytestmark = skip_unless_feature("jpg") -def roundtrip(im: Image.Image, **options: Any) -> MpoImagePlugin.MpoImageFile: +def roundtrip(im: Image.Image, **options: Any) -> ImageFile.ImageFile: out = BytesIO() im.save(out, "MPO", **options) out.seek(0) - return cast(MpoImagePlugin.MpoImageFile, Image.open(out)) + return Image.open(out) @pytest.mark.parametrize("test_file", test_files) @@ -226,6 +226,12 @@ def test_eoferror() -> None: im.seek(n_frames - 1) +def test_adopt_jpeg() -> None: + with Image.open("Tests/images/hopper.jpg") as im: + with pytest.raises(ValueError): + MpoImagePlugin.MpoImageFile.adopt(im) + + def test_ultra_hdr() -> None: with Image.open("Tests/images/ultrahdr.jpg") as im: assert im.format == "JPEG" @@ -275,6 +281,8 @@ def test_save_all() -> None: im_reloaded = roundtrip(im, save_all=True, append_images=[im2]) assert_image_equal(im, im_reloaded) + assert isinstance(im_reloaded, MpoImagePlugin.MpoImageFile) + assert im_reloaded.mpinfo is not None assert im_reloaded.mpinfo[45056] == b"0100" im_reloaded.seek(1) diff --git a/Tests/test_imagefile.py b/Tests/test_imagefile.py index bb686bb3b..b996860ce 100644 --- a/Tests/test_imagefile.py +++ b/Tests/test_imagefile.py @@ -90,6 +90,7 @@ class TestImageFile: data = f.read() with ImageFile.Parser() as p: p.feed(data) + assert p.image is not None assert (48, 48) == p.image.size @skip_unless_feature("webp") @@ -103,6 +104,7 @@ class TestImageFile: assert not p.image p.feed(f.read()) + assert p.image is not None assert (128, 128) == p.image.size @skip_unless_feature("zlib") @@ -393,8 +395,9 @@ class TestPyEncoder(CodecsTest): with pytest.raises(NotImplementedError): encoder.encode_to_pyfd() + fh = BytesIO() with pytest.raises(NotImplementedError): - encoder.encode_to_file(None, None) + encoder.encode_to_file(fh, 0) def test_zero_height(self) -> None: with pytest.raises(UnidentifiedImageError): diff --git a/Tests/test_imagetk.py b/Tests/test_imagetk.py index f84c6c03a..4484dca10 100644 --- a/Tests/test_imagetk.py +++ b/Tests/test_imagetk.py @@ -109,3 +109,6 @@ def test_bitmapimage() -> None: # reloaded = ImageTk.getimage(im_tk) # assert_image_equal(reloaded, im) + + with pytest.raises(ValueError): + ImageTk.BitmapImage() diff --git a/src/PIL/BlpImagePlugin.py b/src/PIL/BlpImagePlugin.py index b9cefafdd..6d71049a9 100644 --- a/src/PIL/BlpImagePlugin.py +++ b/src/PIL/BlpImagePlugin.py @@ -313,6 +313,7 @@ class _BLPBaseDecoder(ImageFile.PyDecoder): self._blp_lengths = struct.unpack("<16I", self._safe_read(16 * 4)) def _safe_read(self, length: int) -> bytes: + assert self.fd is not None return ImageFile._safe_read(self.fd, length) def _read_palette(self) -> list[tuple[int, int, int, int]]: diff --git a/src/PIL/BmpImagePlugin.py b/src/PIL/BmpImagePlugin.py index ef2045337..48bdd9830 100644 --- a/src/PIL/BmpImagePlugin.py +++ b/src/PIL/BmpImagePlugin.py @@ -25,7 +25,7 @@ from __future__ import annotations import os -from typing import IO +from typing import IO, Any from . import Image, ImageFile, ImagePalette from ._binary import i16le as i16 @@ -72,16 +72,20 @@ class BmpImageFile(ImageFile.ImageFile): for k, v in COMPRESSIONS.items(): vars()[k] = v - def _bitmap(self, header=0, offset=0): + def _bitmap(self, header: int = 0, offset: int = 0) -> None: """Read relevant info about the BMP""" read, seek = self.fp.read, self.fp.seek if header: seek(header) # read bmp header size @offset 14 (this is part of the header size) - file_info = {"header_size": i32(read(4)), "direction": -1} + file_info: dict[str, bool | int | tuple[int, ...]] = { + "header_size": i32(read(4)), + "direction": -1, + } # -------------------- If requested, read header at a specific position # read the rest of the bmp header, without its size + assert isinstance(file_info["header_size"], int) header_data = ImageFile._safe_read(self.fp, file_info["header_size"] - 4) # ------------------------------- Windows Bitmap v2, IBM OS/2 Bitmap v1 @@ -92,7 +96,7 @@ class BmpImageFile(ImageFile.ImageFile): file_info["height"] = i16(header_data, 2) file_info["planes"] = i16(header_data, 4) file_info["bits"] = i16(header_data, 6) - file_info["compression"] = self.RAW + file_info["compression"] = self.COMPRESSIONS["RAW"] file_info["palette_padding"] = 3 # --------------------------------------------- Windows Bitmap v3 to v5 @@ -122,8 +126,9 @@ class BmpImageFile(ImageFile.ImageFile): ) file_info["colors"] = i32(header_data, 28) file_info["palette_padding"] = 4 + assert isinstance(file_info["pixels_per_meter"], tuple) self.info["dpi"] = tuple(x / 39.3701 for x in file_info["pixels_per_meter"]) - if file_info["compression"] == self.BITFIELDS: + if file_info["compression"] == self.COMPRESSIONS["BITFIELDS"]: masks = ["r_mask", "g_mask", "b_mask"] if len(header_data) >= 48: if len(header_data) >= 52: @@ -144,6 +149,10 @@ class BmpImageFile(ImageFile.ImageFile): file_info["a_mask"] = 0x0 for mask in masks: file_info[mask] = i32(read(4)) + assert isinstance(file_info["r_mask"], int) + assert isinstance(file_info["g_mask"], int) + assert isinstance(file_info["b_mask"], int) + assert isinstance(file_info["a_mask"], int) file_info["rgb_mask"] = ( file_info["r_mask"], file_info["g_mask"], @@ -164,24 +173,26 @@ class BmpImageFile(ImageFile.ImageFile): self._size = file_info["width"], file_info["height"] # ------- If color count was not found in the header, compute from bits + assert isinstance(file_info["bits"], int) file_info["colors"] = ( file_info["colors"] if file_info.get("colors", 0) else (1 << file_info["bits"]) ) + assert isinstance(file_info["colors"], int) if offset == 14 + file_info["header_size"] and file_info["bits"] <= 8: offset += 4 * file_info["colors"] # ---------------------- Check bit depth for unusual unsupported values - self._mode, raw_mode = BIT2MODE.get(file_info["bits"], (None, None)) - if self.mode is None: + self._mode, raw_mode = BIT2MODE.get(file_info["bits"], ("", "")) + if not self.mode: msg = f"Unsupported BMP pixel depth ({file_info['bits']})" raise OSError(msg) # ---------------- Process BMP with Bitfields compression (not palette) decoder_name = "raw" - if file_info["compression"] == self.BITFIELDS: - SUPPORTED = { + if file_info["compression"] == self.COMPRESSIONS["BITFIELDS"]: + SUPPORTED: dict[int, list[tuple[int, ...]]] = { 32: [ (0xFF0000, 0xFF00, 0xFF, 0x0), (0xFF000000, 0xFF0000, 0xFF00, 0x0), @@ -213,12 +224,14 @@ class BmpImageFile(ImageFile.ImageFile): file_info["bits"] == 32 and file_info["rgba_mask"] in SUPPORTED[file_info["bits"]] ): + assert isinstance(file_info["rgba_mask"], tuple) raw_mode = MASK_MODES[(file_info["bits"], file_info["rgba_mask"])] self._mode = "RGBA" if "A" in raw_mode else self.mode elif ( file_info["bits"] in (24, 16) and file_info["rgb_mask"] in SUPPORTED[file_info["bits"]] ): + assert isinstance(file_info["rgb_mask"], tuple) raw_mode = MASK_MODES[(file_info["bits"], file_info["rgb_mask"])] else: msg = "Unsupported BMP bitfields layout" @@ -226,10 +239,13 @@ class BmpImageFile(ImageFile.ImageFile): else: msg = "Unsupported BMP bitfields layout" raise OSError(msg) - elif file_info["compression"] == self.RAW: + elif file_info["compression"] == self.COMPRESSIONS["RAW"]: if file_info["bits"] == 32 and header == 22: # 32-bit .cur offset raw_mode, self._mode = "BGRA", "RGBA" - elif file_info["compression"] in (self.RLE8, self.RLE4): + elif file_info["compression"] in ( + self.COMPRESSIONS["RLE8"], + self.COMPRESSIONS["RLE4"], + ): decoder_name = "bmp_rle" else: msg = f"Unsupported BMP compression ({file_info['compression']})" @@ -242,6 +258,7 @@ class BmpImageFile(ImageFile.ImageFile): msg = f"Unsupported BMP Palette size ({file_info['colors']})" raise OSError(msg) else: + assert isinstance(file_info["palette_padding"], int) padding = file_info["palette_padding"] palette = read(padding * file_info["colors"]) grayscale = True @@ -269,10 +286,11 @@ class BmpImageFile(ImageFile.ImageFile): # ---------------------------- Finally set the tile data for the plugin self.info["compression"] = file_info["compression"] - args = [raw_mode] + args: list[Any] = [raw_mode] if decoder_name == "bmp_rle": - args.append(file_info["compression"] == self.RLE4) + args.append(file_info["compression"] == self.COMPRESSIONS["RLE4"]) else: + assert isinstance(file_info["width"], int) args.append(((file_info["width"] * file_info["bits"] + 31) >> 3) & (~3)) args.append(file_info["direction"]) self.tile = [ diff --git a/src/PIL/ImageFile.py b/src/PIL/ImageFile.py index 69e7ee548..99d7e73f1 100644 --- a/src/PIL/ImageFile.py +++ b/src/PIL/ImageFile.py @@ -485,7 +485,7 @@ class Parser: self.image = im - def __enter__(self): + def __enter__(self) -> Parser: return self def __exit__(self, *args: object) -> None: @@ -580,7 +580,7 @@ def _encode_tile(im, fp, tile: list[_Tile], bufsize, fh, exc=None): encoder.cleanup() -def _safe_read(fp, size): +def _safe_read(fp: IO[bytes], size: int) -> bytes: """ Reads large blocks in a safe way. Unlike fp.read(n), this function doesn't trust the user. If the requested size is larger than @@ -601,18 +601,18 @@ def _safe_read(fp, size): msg = "Truncated File Read" raise OSError(msg) return data - data = [] + blocks: list[bytes] = [] remaining_size = size while remaining_size > 0: block = fp.read(min(remaining_size, SAFEBLOCK)) if not block: break - data.append(block) + blocks.append(block) remaining_size -= len(block) - if sum(len(d) for d in data) < size: + if sum(len(block) for block in blocks) < size: msg = "Truncated File Read" raise OSError(msg) - return b"".join(data) + return b"".join(blocks) class PyCodecState: @@ -636,7 +636,7 @@ class PyCodec: self.mode = mode self.init(args) - def init(self, args): + def init(self, args) -> None: """ Override to perform codec specific initialization @@ -653,7 +653,7 @@ class PyCodec: """ pass - def setfd(self, fd): + def setfd(self, fd) -> None: """ Called from ImageFile to set the Python file-like object @@ -793,7 +793,7 @@ class PyEncoder(PyCodec): self.fd.write(data) return bytes_consumed, errcode - def encode_to_file(self, fh, bufsize): + def encode_to_file(self, fh: IO[bytes], bufsize: int) -> int: """ :param fh: File handle. :param bufsize: Buffer size. diff --git a/src/PIL/ImageTk.py b/src/PIL/ImageTk.py index 6aa70ced3..6b13e57a0 100644 --- a/src/PIL/ImageTk.py +++ b/src/PIL/ImageTk.py @@ -28,7 +28,7 @@ from __future__ import annotations import tkinter from io import BytesIO -from typing import Any +from typing import TYPE_CHECKING, Any, cast from . import Image, ImageFile @@ -61,7 +61,9 @@ def _get_image_from_kw(kw: dict[str, Any]) -> ImageFile.ImageFile | None: return Image.open(source) -def _pyimagingtkcall(command, photo, id): +def _pyimagingtkcall( + command: str, photo: PhotoImage | tkinter.PhotoImage, id: int +) -> None: tk = photo.tk try: tk.call(command, photo, id) @@ -215,11 +217,14 @@ class BitmapImage: :param image: A PIL image. """ - def __init__(self, image=None, **kw): + def __init__(self, image: Image.Image | None = None, **kw: Any) -> None: # Tk compatibility: file or data if image is None: image = _get_image_from_kw(kw) + if image is None: + msg = "Image is required" + raise ValueError(msg) self.__mode = image.mode self.__size = image.size @@ -278,18 +283,23 @@ def getimage(photo: PhotoImage) -> Image.Image: return im -def _show(image, title): +def _show(image: Image.Image, title: str | None) -> None: """Helper for the Image.show method.""" class UI(tkinter.Label): - def __init__(self, master, im): + def __init__(self, master: tkinter.Toplevel, im: Image.Image) -> None: + self.image: BitmapImage | PhotoImage if im.mode == "1": self.image = BitmapImage(im, foreground="white", master=master) else: self.image = PhotoImage(im, master=master) - super().__init__(master, image=self.image, bg="black", bd=0) + if TYPE_CHECKING: + image = cast(tkinter._Image, self.image) + else: + image = self.image + super().__init__(master, image=image, bg="black", bd=0) - if not tkinter._default_root: + if not getattr(tkinter, "_default_root"): msg = "tkinter not initialized" raise OSError(msg) top = tkinter.Toplevel() diff --git a/src/PIL/Jpeg2KImagePlugin.py b/src/PIL/Jpeg2KImagePlugin.py index 992b9ccaf..eeec41686 100644 --- a/src/PIL/Jpeg2KImagePlugin.py +++ b/src/PIL/Jpeg2KImagePlugin.py @@ -29,7 +29,7 @@ class BoxReader: and to easily step into and read sub-boxes. """ - def __init__(self, fp, length=-1): + def __init__(self, fp: IO[bytes], length: int = -1) -> None: self.fp = fp self.has_length = length >= 0 self.length = length @@ -97,7 +97,7 @@ class BoxReader: return tbox -def _parse_codestream(fp) -> tuple[tuple[int, int], str]: +def _parse_codestream(fp: IO[bytes]) -> tuple[tuple[int, int], str]: """Parse the JPEG 2000 codestream to extract the size and component count from the SIZ marker segment, returning a PIL (size, mode) tuple.""" @@ -137,7 +137,15 @@ def _res_to_dpi(num: int, denom: int, exp: int) -> float | None: return (254 * num * (10**exp)) / (10000 * denom) -def _parse_jp2_header(fp): +def _parse_jp2_header( + fp: IO[bytes], +) -> tuple[ + tuple[int, int], + str, + str | None, + tuple[float, float] | None, + ImagePalette.ImagePalette | None, +]: """Parse the JP2 header box to extract size, component count, color space information, and optionally DPI information, returning a (size, mode, mimetype, dpi) tuple.""" @@ -155,6 +163,7 @@ def _parse_jp2_header(fp): elif tbox == b"ftyp": if reader.read_fields(">4s")[0] == b"jpx ": mimetype = "image/jpx" + assert header is not None size = None mode = None @@ -168,6 +177,9 @@ def _parse_jp2_header(fp): if tbox == b"ihdr": height, width, nc, bpc = header.read_fields(">IIHB") + assert isinstance(height, int) + assert isinstance(width, int) + assert isinstance(bpc, int) size = (width, height) if nc == 1 and (bpc & 0x7F) > 8: mode = "I;16" @@ -185,11 +197,21 @@ def _parse_jp2_header(fp): mode = "CMYK" elif tbox == b"pclr" and mode in ("L", "LA"): ne, npc = header.read_fields(">HB") - bitdepths = header.read_fields(">" + ("B" * npc)) - if max(bitdepths) <= 8: + assert isinstance(ne, int) + assert isinstance(npc, int) + max_bitdepth = 0 + for bitdepth in header.read_fields(">" + ("B" * npc)): + assert isinstance(bitdepth, int) + if bitdepth > max_bitdepth: + max_bitdepth = bitdepth + if max_bitdepth <= 8: palette = ImagePalette.ImagePalette() for i in range(ne): - palette.getcolor(header.read_fields(">" + ("B" * npc))) + color: list[int] = [] + for value in header.read_fields(">" + ("B" * npc)): + assert isinstance(value, int) + color.append(value) + palette.getcolor(tuple(color)) mode = "P" if mode == "L" else "PA" elif tbox == b"res ": res = header.read_boxes() @@ -197,6 +219,12 @@ def _parse_jp2_header(fp): tres = res.next_box_type() if tres == b"resc": vrcn, vrcd, hrcn, hrcd, vrce, hrce = res.read_fields(">HHHHBB") + assert isinstance(vrcn, int) + assert isinstance(vrcd, int) + assert isinstance(hrcn, int) + assert isinstance(hrcd, int) + assert isinstance(vrce, int) + assert isinstance(hrce, int) hres = _res_to_dpi(hrcn, hrcd, hrce) vres = _res_to_dpi(vrcn, vrcd, vrce) if hres is not None and vres is not None: diff --git a/src/PIL/JpegImagePlugin.py b/src/PIL/JpegImagePlugin.py index b15bf06d2..4916727be 100644 --- a/src/PIL/JpegImagePlugin.py +++ b/src/PIL/JpegImagePlugin.py @@ -60,7 +60,7 @@ def Skip(self: JpegImageFile, marker: int) -> None: ImageFile._safe_read(self.fp, n) -def APP(self, marker): +def APP(self: JpegImageFile, marker: int) -> None: # # Application marker. Store these in the APP dictionary. # Also look for well-known application markers. @@ -133,13 +133,14 @@ def APP(self, marker): offset += 4 data = s[offset : offset + size] if code == 0x03ED: # ResolutionInfo - data = { + photoshop[code] = { "XResolution": i32(data, 0) / 65536, "DisplayedUnitsX": i16(data, 4), "YResolution": i32(data, 8) / 65536, "DisplayedUnitsY": i16(data, 12), } - photoshop[code] = data + else: + photoshop[code] = data offset += size offset += offset & 1 # align except struct.error: @@ -338,6 +339,7 @@ class JpegImageFile(ImageFile.ImageFile): # Create attributes self.bits = self.layers = 0 + self._exif_offset = 0 # JPEG specifics (internal) self.layer = [] @@ -498,17 +500,17 @@ class JpegImageFile(ImageFile.ImageFile): ): self.info["dpi"] = 72, 72 - def _getmp(self): + def _getmp(self) -> dict[int, Any] | None: return _getmp(self) -def _getexif(self) -> dict[str, Any] | None: +def _getexif(self: JpegImageFile) -> dict[str, Any] | None: if "exif" not in self.info: return None return self.getexif()._get_merged_dict() -def _getmp(self): +def _getmp(self: JpegImageFile) -> dict[int, Any] | None: # Extract MP information. This method was inspired by the "highly # experimental" _getexif version that's been in use for years now, # itself based on the ImageFileDirectory class in the TIFF plugin. @@ -616,7 +618,7 @@ samplings = { # fmt: on -def get_sampling(im): +def get_sampling(im: Image.Image) -> int: # There's no subsampling when images have only 1 layer # (grayscale images) or when they are CMYK (4 layers), # so set subsampling to the default value. @@ -624,7 +626,7 @@ def get_sampling(im): # NOTE: currently Pillow can't encode JPEG to YCCK format. # If YCCK support is added in the future, subsampling code will have # to be updated (here and in JpegEncode.c) to deal with 4 layers. - if not hasattr(im, "layers") or im.layers in (1, 4): + if not isinstance(im, JpegImageFile) or im.layers in (1, 4): return -1 sampling = im.layer[0][1:3] + im.layer[1][1:3] + im.layer[2][1:3] return samplings.get(sampling, -1) diff --git a/src/PIL/MpoImagePlugin.py b/src/PIL/MpoImagePlugin.py index f21570661..5ed9f56a1 100644 --- a/src/PIL/MpoImagePlugin.py +++ b/src/PIL/MpoImagePlugin.py @@ -22,7 +22,7 @@ from __future__ import annotations import itertools import os import struct -from typing import IO +from typing import IO, Any, cast from . import ( Image, @@ -101,8 +101,11 @@ class MpoImageFile(JpegImagePlugin.JpegImageFile): JpegImagePlugin.JpegImageFile._open(self) self._after_jpeg_open() - def _after_jpeg_open(self, mpheader=None): + def _after_jpeg_open(self, mpheader: dict[int, Any] | None = None) -> None: self.mpinfo = mpheader if mpheader is not None else self._getmp() + if self.mpinfo is None: + msg = "Image appears to be a malformed MPO file" + raise ValueError(msg) self.n_frames = self.mpinfo[0xB001] self.__mpoffsets = [ mpent["DataOffset"] + self.info["mpoffset"] for mpent in self.mpinfo[0xB002] @@ -149,7 +152,10 @@ class MpoImageFile(JpegImagePlugin.JpegImageFile): return self.__frame @staticmethod - def adopt(jpeg_instance, mpheader=None): + def adopt( + jpeg_instance: JpegImagePlugin.JpegImageFile, + mpheader: dict[int, Any] | None = None, + ) -> MpoImageFile: """ Transform the instance of JpegImageFile into an instance of MpoImageFile. @@ -161,8 +167,9 @@ class MpoImageFile(JpegImagePlugin.JpegImageFile): double call to _open. """ jpeg_instance.__class__ = MpoImageFile - jpeg_instance._after_jpeg_open(mpheader) - return jpeg_instance + mpo_instance = cast(MpoImageFile, jpeg_instance) + mpo_instance._after_jpeg_open(mpheader) + return mpo_instance # --------------------------------------------------------------------- diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index 34ea77c5e..fa117d19a 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -230,6 +230,7 @@ class ChunkStream: cids = [] + assert self.fp is not None while True: try: cid, pos, length = self.read() @@ -407,6 +408,7 @@ class PngStream(ChunkStream): def chunk_iCCP(self, pos: int, length: int) -> bytes: # ICC profile + assert self.fp is not None s = ImageFile._safe_read(self.fp, length) # according to PNG spec, the iCCP chunk contains: # Profile name 1-79 bytes (character string) @@ -434,6 +436,7 @@ class PngStream(ChunkStream): def chunk_IHDR(self, pos: int, length: int) -> bytes: # image header + assert self.fp is not None s = ImageFile._safe_read(self.fp, length) if length < 13: if ImageFile.LOAD_TRUNCATED_IMAGES: @@ -471,6 +474,7 @@ class PngStream(ChunkStream): def chunk_PLTE(self, pos: int, length: int) -> bytes: # palette + assert self.fp is not None s = ImageFile._safe_read(self.fp, length) if self.im_mode == "P": self.im_palette = "RGB", s @@ -478,6 +482,7 @@ class PngStream(ChunkStream): def chunk_tRNS(self, pos: int, length: int) -> bytes: # transparency + assert self.fp is not None s = ImageFile._safe_read(self.fp, length) if self.im_mode == "P": if _simple_palette.match(s): @@ -498,6 +503,7 @@ class PngStream(ChunkStream): def chunk_gAMA(self, pos: int, length: int) -> bytes: # gamma setting + assert self.fp is not None s = ImageFile._safe_read(self.fp, length) self.im_info["gamma"] = i32(s) / 100000.0 return s @@ -506,6 +512,7 @@ class PngStream(ChunkStream): # chromaticity, 8 unsigned ints, actual value is scaled by 100,000 # WP x,y, Red x,y, Green x,y Blue x,y + assert self.fp is not None s = ImageFile._safe_read(self.fp, length) raw_vals = struct.unpack(">%dI" % (len(s) // 4), s) self.im_info["chromaticity"] = tuple(elt / 100000.0 for elt in raw_vals) @@ -518,6 +525,7 @@ class PngStream(ChunkStream): # 2 saturation # 3 absolute colorimetric + assert self.fp is not None s = ImageFile._safe_read(self.fp, length) if length < 1: if ImageFile.LOAD_TRUNCATED_IMAGES: @@ -529,6 +537,7 @@ class PngStream(ChunkStream): def chunk_pHYs(self, pos: int, length: int) -> bytes: # pixels per unit + assert self.fp is not None s = ImageFile._safe_read(self.fp, length) if length < 9: if ImageFile.LOAD_TRUNCATED_IMAGES: @@ -546,6 +555,7 @@ class PngStream(ChunkStream): def chunk_tEXt(self, pos: int, length: int) -> bytes: # text + assert self.fp is not None s = ImageFile._safe_read(self.fp, length) try: k, v = s.split(b"\0", 1) @@ -554,17 +564,18 @@ class PngStream(ChunkStream): k = s v = b"" if k: - k = k.decode("latin-1", "strict") + k_str = k.decode("latin-1", "strict") v_str = v.decode("latin-1", "replace") - self.im_info[k] = v if k == "exif" else v_str - self.im_text[k] = v_str + self.im_info[k_str] = v if k == b"exif" else v_str + self.im_text[k_str] = v_str self.check_text_memory(len(v_str)) return s def chunk_zTXt(self, pos: int, length: int) -> bytes: # compressed text + assert self.fp is not None s = ImageFile._safe_read(self.fp, length) try: k, v = s.split(b"\0", 1) @@ -589,16 +600,17 @@ class PngStream(ChunkStream): v = b"" if k: - k = k.decode("latin-1", "strict") - v = v.decode("latin-1", "replace") + k_str = k.decode("latin-1", "strict") + v_str = v.decode("latin-1", "replace") - self.im_info[k] = self.im_text[k] = v - self.check_text_memory(len(v)) + self.im_info[k_str] = self.im_text[k_str] = v_str + self.check_text_memory(len(v_str)) return s def chunk_iTXt(self, pos: int, length: int) -> bytes: # international text + assert self.fp is not None r = s = ImageFile._safe_read(self.fp, length) try: k, r = r.split(b"\0", 1) @@ -627,25 +639,27 @@ class PngStream(ChunkStream): 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") - tk = tk.decode("utf-8", "strict") - v = v.decode("utf-8", "strict") + k_str = k.decode("latin-1", "strict") + lang_str = lang.decode("utf-8", "strict") + tk_str = tk.decode("utf-8", "strict") + v_str = v.decode("utf-8", "strict") except UnicodeError: return s - self.im_info[k] = self.im_text[k] = iTXt(v, lang, tk) - self.check_text_memory(len(v)) + self.im_info[k_str] = self.im_text[k_str] = iTXt(v_str, lang_str, tk_str) + self.check_text_memory(len(v_str)) return s def chunk_eXIf(self, pos: int, length: int) -> bytes: + assert self.fp is not None s = ImageFile._safe_read(self.fp, length) self.im_info["exif"] = b"Exif\x00\x00" + s return s # APNG chunks def chunk_acTL(self, pos: int, length: int) -> bytes: + assert self.fp is not None s = ImageFile._safe_read(self.fp, length) if length < 8: if ImageFile.LOAD_TRUNCATED_IMAGES: @@ -666,6 +680,7 @@ class PngStream(ChunkStream): return s def chunk_fcTL(self, pos: int, length: int) -> bytes: + assert self.fp is not None s = ImageFile._safe_read(self.fp, length) if length < 26: if ImageFile.LOAD_TRUNCATED_IMAGES: @@ -695,6 +710,7 @@ class PngStream(ChunkStream): return s def chunk_fdAT(self, pos: int, length: int) -> bytes: + assert self.fp is not None if length < 4: if ImageFile.LOAD_TRUNCATED_IMAGES: s = ImageFile._safe_read(self.fp, length) diff --git a/src/PIL/PsdImagePlugin.py b/src/PIL/PsdImagePlugin.py index edf698bf0..31dfd4d12 100644 --- a/src/PIL/PsdImagePlugin.py +++ b/src/PIL/PsdImagePlugin.py @@ -185,7 +185,7 @@ def _layerinfo(fp, ct_bytes): # read layerinfo block layers = [] - def read(size): + def read(size: int) -> bytes: return ImageFile._safe_read(fp, size) ct = si16(read(2)) diff --git a/src/PIL/WebPImagePlugin.py b/src/PIL/WebPImagePlugin.py index 530b88c8b..011de9c6a 100644 --- a/src/PIL/WebPImagePlugin.py +++ b/src/PIL/WebPImagePlugin.py @@ -115,7 +115,7 @@ class WebPImageFile(ImageFile.ImageFile): self.__loaded = -1 self.__timestamp = 0 - def _get_next(self): + def _get_next(self) -> tuple[bytes, int, int]: # Get next frame ret = self._decoder.get_next() self.__physical_frame += 1 diff --git a/src/PIL/WmfImagePlugin.py b/src/PIL/WmfImagePlugin.py index 3d5cddcc8..68f8a74f5 100644 --- a/src/PIL/WmfImagePlugin.py +++ b/src/PIL/WmfImagePlugin.py @@ -152,7 +152,7 @@ class WmfStubImageFile(ImageFile.StubImageFile): def _load(self) -> ImageFile.StubHandler | None: return _handler - def load(self, dpi=None): + def load(self, dpi: int | None = None) -> Image.core.PixelAccess | None: if dpi is not None and self._inch is not None: self.info["dpi"] = dpi x0, y0, x1, y1 = self.info["wmf_bbox"] diff --git a/src/PIL/_imagingtk.pyi b/src/PIL/_imagingtk.pyi new file mode 100644 index 000000000..e27843e53 --- /dev/null +++ b/src/PIL/_imagingtk.pyi @@ -0,0 +1,3 @@ +from typing import Any + +def __getattr__(name: str) -> Any: ... From 69f967301d11a5b37495542ba64633b085c9b357 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Tue, 2 Jul 2024 17:48:46 +0300 Subject: [PATCH 18/30] Call PyUnstable_Module_SetGIL() to indicate support of running with GIL disabled --- src/_imaging.c | 4 ++++ src/_imagingcms.c | 4 ++++ src/_imagingft.c | 4 ++++ src/_imagingmath.c | 4 ++++ src/_imagingmorph.c | 4 ++++ src/_imagingtk.c | 5 +++++ src/_webp.c | 4 ++++ 7 files changed, 29 insertions(+) diff --git a/src/_imaging.c b/src/_imaging.c index ddc8d2885..03e10e547 100644 --- a/src/_imaging.c +++ b/src/_imaging.c @@ -4448,5 +4448,9 @@ PyInit__imaging(void) { return NULL; } +#ifdef Py_GIL_DISABLED + PyUnstable_Module_SetGIL(m, Py_MOD_GIL_NOT_USED); +#endif + return m; } diff --git a/src/_imagingcms.c b/src/_imagingcms.c index 590e1b983..628662b30 100644 --- a/src/_imagingcms.c +++ b/src/_imagingcms.c @@ -1538,5 +1538,9 @@ PyInit__imagingcms(void) { PyDateTime_IMPORT; +#ifdef Py_GIL_DISABLED + PyUnstable_Module_SetGIL(m, Py_MOD_GIL_NOT_USED); +#endif + return m; } diff --git a/src/_imagingft.c b/src/_imagingft.c index ba36cc72c..1bef876e1 100644 --- a/src/_imagingft.c +++ b/src/_imagingft.c @@ -1576,5 +1576,9 @@ PyInit__imagingft(void) { return NULL; } +#ifdef Py_GIL_DISABLED + PyUnstable_Module_SetGIL(m, Py_MOD_GIL_NOT_USED); +#endif + return m; } diff --git a/src/_imagingmath.c b/src/_imagingmath.c index 067c165b2..a2ddc91b9 100644 --- a/src/_imagingmath.c +++ b/src/_imagingmath.c @@ -290,5 +290,9 @@ PyInit__imagingmath(void) { return NULL; } +#ifdef Py_GIL_DISABLED + PyUnstable_Module_SetGIL(m, Py_MOD_GIL_NOT_USED); +#endif + return m; } diff --git a/src/_imagingmorph.c b/src/_imagingmorph.c index 8815c2b7e..a95ce75bf 100644 --- a/src/_imagingmorph.c +++ b/src/_imagingmorph.c @@ -269,5 +269,9 @@ PyInit__imagingmorph(void) { m = PyModule_Create(&module_def); +#ifdef Py_GIL_DISABLED + PyUnstable_Module_SetGIL(m, Py_MOD_GIL_NOT_USED); +#endif + return m; } diff --git a/src/_imagingtk.c b/src/_imagingtk.c index efa7fc1b6..c70d044bb 100644 --- a/src/_imagingtk.c +++ b/src/_imagingtk.c @@ -62,5 +62,10 @@ PyInit__imagingtk(void) { Py_DECREF(m); return NULL; } + +#ifdef Py_GIL_DISABLED + PyUnstable_Module_SetGIL(m, Py_MOD_GIL_NOT_USED); +#endif + return m; } diff --git a/src/_webp.c b/src/_webp.c index 0a70e3357..dfa24da41 100644 --- a/src/_webp.c +++ b/src/_webp.c @@ -1005,5 +1005,9 @@ PyInit__webp(void) { return NULL; } +#ifdef Py_GIL_DISABLED + PyUnstable_Module_SetGIL(m, Py_MOD_GIL_NOT_USED); +#endif + return m; } From 377bdc0db1415dcbd664b1b9c467d995500809d2 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Tue, 2 Jul 2024 18:27:09 +0300 Subject: [PATCH 19/30] encode: Replace PyList_GetItem with PyList_GetItemRef --- src/encode.c | 5 +- src/thirdparty/pythoncapi_compat.h | 1360 ++++++++++++++++++++++++++++ 2 files changed, 1363 insertions(+), 2 deletions(-) create mode 100644 src/thirdparty/pythoncapi_compat.h diff --git a/src/encode.c b/src/encode.c index 442b5d04f..72ad3fa07 100644 --- a/src/encode.c +++ b/src/encode.c @@ -25,6 +25,7 @@ #define PY_SSIZE_T_CLEAN #include "Python.h" +#include "thirdparty/pythoncapi_compat.h" #include "libImaging/Imaging.h" #include "libImaging/Gif.h" @@ -671,7 +672,7 @@ PyImaging_LibTiffEncoderNew(PyObject *self, PyObject *args) { tags_size = PyList_Size(tags); TRACE(("tags size: %d\n", (int)tags_size)); for (pos = 0; pos < tags_size; pos++) { - item = PyList_GetItem(tags, pos); + item = PyList_GetItemRef(tags, pos); if (!PyTuple_Check(item) || PyTuple_Size(item) != 2) { PyErr_SetString(PyExc_ValueError, "Invalid tags list"); return NULL; @@ -703,7 +704,7 @@ PyImaging_LibTiffEncoderNew(PyObject *self, PyObject *args) { num_core_tags = sizeof(core_tags) / sizeof(int); for (pos = 0; pos < tags_size; pos++) { - item = PyList_GetItem(tags, pos); + item = PyList_GetItemRef(tags, pos); // We already checked that tags is a 2-tuple list. key = PyTuple_GetItem(item, 0); key_int = (int)PyLong_AsLong(key); diff --git a/src/thirdparty/pythoncapi_compat.h b/src/thirdparty/pythoncapi_compat.h new file mode 100644 index 000000000..51e8c0de7 --- /dev/null +++ b/src/thirdparty/pythoncapi_compat.h @@ -0,0 +1,1360 @@ +// Header file providing new C API functions to old Python versions. +// +// File distributed under the Zero Clause BSD (0BSD) license. +// Copyright Contributors to the pythoncapi_compat project. +// +// Homepage: +// https://github.com/python/pythoncapi_compat +// +// Latest version: +// https://raw.githubusercontent.com/python/pythoncapi_compat/master/pythoncapi_compat.h +// +// SPDX-License-Identifier: 0BSD + +#ifndef PYTHONCAPI_COMPAT +#define PYTHONCAPI_COMPAT + +#ifdef __cplusplus +extern "C" { +#endif + +#include + +// Python 3.11.0b4 added PyFrame_Back() to Python.h +#if PY_VERSION_HEX < 0x030b00B4 && !defined(PYPY_VERSION) +# include "frameobject.h" // PyFrameObject, PyFrame_GetBack() +#endif + + +#ifndef _Py_CAST +# define _Py_CAST(type, expr) ((type)(expr)) +#endif + +// Static inline functions should use _Py_NULL rather than using directly NULL +// to prevent C++ compiler warnings. On C23 and newer and on C++11 and newer, +// _Py_NULL is defined as nullptr. +#if (defined (__STDC_VERSION__) && __STDC_VERSION__ > 201710L) \ + || (defined(__cplusplus) && __cplusplus >= 201103) +# define _Py_NULL nullptr +#else +# define _Py_NULL NULL +#endif + +// Cast argument to PyObject* type. +#ifndef _PyObject_CAST +# define _PyObject_CAST(op) _Py_CAST(PyObject*, op) +#endif + + +// bpo-42262 added Py_NewRef() to Python 3.10.0a3 +#if PY_VERSION_HEX < 0x030A00A3 && !defined(Py_NewRef) +static inline PyObject* _Py_NewRef(PyObject *obj) +{ + Py_INCREF(obj); + return obj; +} +#define Py_NewRef(obj) _Py_NewRef(_PyObject_CAST(obj)) +#endif + + +// bpo-42262 added Py_XNewRef() to Python 3.10.0a3 +#if PY_VERSION_HEX < 0x030A00A3 && !defined(Py_XNewRef) +static inline PyObject* _Py_XNewRef(PyObject *obj) +{ + Py_XINCREF(obj); + return obj; +} +#define Py_XNewRef(obj) _Py_XNewRef(_PyObject_CAST(obj)) +#endif + + +// bpo-39573 added Py_SET_REFCNT() to Python 3.9.0a4 +#if PY_VERSION_HEX < 0x030900A4 && !defined(Py_SET_REFCNT) +static inline void _Py_SET_REFCNT(PyObject *ob, Py_ssize_t refcnt) +{ + ob->ob_refcnt = refcnt; +} +#define Py_SET_REFCNT(ob, refcnt) _Py_SET_REFCNT(_PyObject_CAST(ob), refcnt) +#endif + + +// Py_SETREF() and Py_XSETREF() were added to Python 3.5.2. +// It is excluded from the limited C API. +#if (PY_VERSION_HEX < 0x03050200 && !defined(Py_SETREF)) && !defined(Py_LIMITED_API) +#define Py_SETREF(dst, src) \ + do { \ + PyObject **_tmp_dst_ptr = _Py_CAST(PyObject**, &(dst)); \ + PyObject *_tmp_dst = (*_tmp_dst_ptr); \ + *_tmp_dst_ptr = _PyObject_CAST(src); \ + Py_DECREF(_tmp_dst); \ + } while (0) + +#define Py_XSETREF(dst, src) \ + do { \ + PyObject **_tmp_dst_ptr = _Py_CAST(PyObject**, &(dst)); \ + PyObject *_tmp_dst = (*_tmp_dst_ptr); \ + *_tmp_dst_ptr = _PyObject_CAST(src); \ + Py_XDECREF(_tmp_dst); \ + } while (0) +#endif + + +// bpo-43753 added Py_Is(), Py_IsNone(), Py_IsTrue() and Py_IsFalse() +// to Python 3.10.0b1. +#if PY_VERSION_HEX < 0x030A00B1 && !defined(Py_Is) +# define Py_Is(x, y) ((x) == (y)) +#endif +#if PY_VERSION_HEX < 0x030A00B1 && !defined(Py_IsNone) +# define Py_IsNone(x) Py_Is(x, Py_None) +#endif +#if (PY_VERSION_HEX < 0x030A00B1 || defined(PYPY_VERSION)) && !defined(Py_IsTrue) +# define Py_IsTrue(x) Py_Is(x, Py_True) +#endif +#if (PY_VERSION_HEX < 0x030A00B1 || defined(PYPY_VERSION)) && !defined(Py_IsFalse) +# define Py_IsFalse(x) Py_Is(x, Py_False) +#endif + + +// bpo-39573 added Py_SET_TYPE() to Python 3.9.0a4 +#if PY_VERSION_HEX < 0x030900A4 && !defined(Py_SET_TYPE) +static inline void _Py_SET_TYPE(PyObject *ob, PyTypeObject *type) +{ + ob->ob_type = type; +} +#define Py_SET_TYPE(ob, type) _Py_SET_TYPE(_PyObject_CAST(ob), type) +#endif + + +// bpo-39573 added Py_SET_SIZE() to Python 3.9.0a4 +#if PY_VERSION_HEX < 0x030900A4 && !defined(Py_SET_SIZE) +static inline void _Py_SET_SIZE(PyVarObject *ob, Py_ssize_t size) +{ + ob->ob_size = size; +} +#define Py_SET_SIZE(ob, size) _Py_SET_SIZE((PyVarObject*)(ob), size) +#endif + + +// bpo-40421 added PyFrame_GetCode() to Python 3.9.0b1 +#if PY_VERSION_HEX < 0x030900B1 || defined(PYPY_VERSION) +static inline PyCodeObject* PyFrame_GetCode(PyFrameObject *frame) +{ + assert(frame != _Py_NULL); + assert(frame->f_code != _Py_NULL); + return _Py_CAST(PyCodeObject*, Py_NewRef(frame->f_code)); +} +#endif + +static inline PyCodeObject* _PyFrame_GetCodeBorrow(PyFrameObject *frame) +{ + PyCodeObject *code = PyFrame_GetCode(frame); + Py_DECREF(code); + return code; +} + + +// bpo-40421 added PyFrame_GetBack() to Python 3.9.0b1 +#if PY_VERSION_HEX < 0x030900B1 && !defined(PYPY_VERSION) +static inline PyFrameObject* PyFrame_GetBack(PyFrameObject *frame) +{ + assert(frame != _Py_NULL); + return _Py_CAST(PyFrameObject*, Py_XNewRef(frame->f_back)); +} +#endif + +#if !defined(PYPY_VERSION) +static inline PyFrameObject* _PyFrame_GetBackBorrow(PyFrameObject *frame) +{ + PyFrameObject *back = PyFrame_GetBack(frame); + Py_XDECREF(back); + return back; +} +#endif + + +// bpo-40421 added PyFrame_GetLocals() to Python 3.11.0a7 +#if PY_VERSION_HEX < 0x030B00A7 && !defined(PYPY_VERSION) +static inline PyObject* PyFrame_GetLocals(PyFrameObject *frame) +{ +#if PY_VERSION_HEX >= 0x030400B1 + if (PyFrame_FastToLocalsWithError(frame) < 0) { + return NULL; + } +#else + PyFrame_FastToLocals(frame); +#endif + return Py_NewRef(frame->f_locals); +} +#endif + + +// bpo-40421 added PyFrame_GetGlobals() to Python 3.11.0a7 +#if PY_VERSION_HEX < 0x030B00A7 && !defined(PYPY_VERSION) +static inline PyObject* PyFrame_GetGlobals(PyFrameObject *frame) +{ + return Py_NewRef(frame->f_globals); +} +#endif + + +// bpo-40421 added PyFrame_GetBuiltins() to Python 3.11.0a7 +#if PY_VERSION_HEX < 0x030B00A7 && !defined(PYPY_VERSION) +static inline PyObject* PyFrame_GetBuiltins(PyFrameObject *frame) +{ + return Py_NewRef(frame->f_builtins); +} +#endif + + +// bpo-40421 added PyFrame_GetLasti() to Python 3.11.0b1 +#if PY_VERSION_HEX < 0x030B00B1 && !defined(PYPY_VERSION) +static inline int PyFrame_GetLasti(PyFrameObject *frame) +{ +#if PY_VERSION_HEX >= 0x030A00A7 + // bpo-27129: Since Python 3.10.0a7, f_lasti is an instruction offset, + // not a bytes offset anymore. Python uses 16-bit "wordcode" (2 bytes) + // instructions. + if (frame->f_lasti < 0) { + return -1; + } + return frame->f_lasti * 2; +#else + return frame->f_lasti; +#endif +} +#endif + + +// gh-91248 added PyFrame_GetVar() to Python 3.12.0a2 +#if PY_VERSION_HEX < 0x030C00A2 && !defined(PYPY_VERSION) +static inline PyObject* PyFrame_GetVar(PyFrameObject *frame, PyObject *name) +{ + PyObject *locals, *value; + + locals = PyFrame_GetLocals(frame); + if (locals == NULL) { + return NULL; + } +#if PY_VERSION_HEX >= 0x03000000 + value = PyDict_GetItemWithError(locals, name); +#else + value = _PyDict_GetItemWithError(locals, name); +#endif + Py_DECREF(locals); + + if (value == NULL) { + if (PyErr_Occurred()) { + return NULL; + } +#if PY_VERSION_HEX >= 0x03000000 + PyErr_Format(PyExc_NameError, "variable %R does not exist", name); +#else + PyErr_SetString(PyExc_NameError, "variable does not exist"); +#endif + return NULL; + } + return Py_NewRef(value); +} +#endif + + +// gh-91248 added PyFrame_GetVarString() to Python 3.12.0a2 +#if PY_VERSION_HEX < 0x030C00A2 && !defined(PYPY_VERSION) +static inline PyObject* +PyFrame_GetVarString(PyFrameObject *frame, const char *name) +{ + PyObject *name_obj, *value; +#if PY_VERSION_HEX >= 0x03000000 + name_obj = PyUnicode_FromString(name); +#else + name_obj = PyString_FromString(name); +#endif + if (name_obj == NULL) { + return NULL; + } + value = PyFrame_GetVar(frame, name_obj); + Py_DECREF(name_obj); + return value; +} +#endif + + +// bpo-39947 added PyThreadState_GetInterpreter() to Python 3.9.0a5 +#if PY_VERSION_HEX < 0x030900A5 || defined(PYPY_VERSION) +static inline PyInterpreterState * +PyThreadState_GetInterpreter(PyThreadState *tstate) +{ + assert(tstate != _Py_NULL); + return tstate->interp; +} +#endif + + +// bpo-40429 added PyThreadState_GetFrame() to Python 3.9.0b1 +#if PY_VERSION_HEX < 0x030900B1 && !defined(PYPY_VERSION) +static inline PyFrameObject* PyThreadState_GetFrame(PyThreadState *tstate) +{ + assert(tstate != _Py_NULL); + return _Py_CAST(PyFrameObject *, Py_XNewRef(tstate->frame)); +} +#endif + +#if !defined(PYPY_VERSION) +static inline PyFrameObject* +_PyThreadState_GetFrameBorrow(PyThreadState *tstate) +{ + PyFrameObject *frame = PyThreadState_GetFrame(tstate); + Py_XDECREF(frame); + return frame; +} +#endif + + +// bpo-39947 added PyInterpreterState_Get() to Python 3.9.0a5 +#if PY_VERSION_HEX < 0x030900A5 || defined(PYPY_VERSION) +static inline PyInterpreterState* PyInterpreterState_Get(void) +{ + PyThreadState *tstate; + PyInterpreterState *interp; + + tstate = PyThreadState_GET(); + if (tstate == _Py_NULL) { + Py_FatalError("GIL released (tstate is NULL)"); + } + interp = tstate->interp; + if (interp == _Py_NULL) { + Py_FatalError("no current interpreter"); + } + return interp; +} +#endif + + +// bpo-39947 added PyInterpreterState_Get() to Python 3.9.0a6 +#if 0x030700A1 <= PY_VERSION_HEX && PY_VERSION_HEX < 0x030900A6 && !defined(PYPY_VERSION) +static inline uint64_t PyThreadState_GetID(PyThreadState *tstate) +{ + assert(tstate != _Py_NULL); + return tstate->id; +} +#endif + +// bpo-43760 added PyThreadState_EnterTracing() to Python 3.11.0a2 +#if PY_VERSION_HEX < 0x030B00A2 && !defined(PYPY_VERSION) +static inline void PyThreadState_EnterTracing(PyThreadState *tstate) +{ + tstate->tracing++; +#if PY_VERSION_HEX >= 0x030A00A1 + tstate->cframe->use_tracing = 0; +#else + tstate->use_tracing = 0; +#endif +} +#endif + +// bpo-43760 added PyThreadState_LeaveTracing() to Python 3.11.0a2 +#if PY_VERSION_HEX < 0x030B00A2 && !defined(PYPY_VERSION) +static inline void PyThreadState_LeaveTracing(PyThreadState *tstate) +{ + int use_tracing = (tstate->c_tracefunc != _Py_NULL + || tstate->c_profilefunc != _Py_NULL); + tstate->tracing--; +#if PY_VERSION_HEX >= 0x030A00A1 + tstate->cframe->use_tracing = use_tracing; +#else + tstate->use_tracing = use_tracing; +#endif +} +#endif + + +// bpo-37194 added PyObject_CallNoArgs() to Python 3.9.0a1 +// PyObject_CallNoArgs() added to PyPy 3.9.16-v7.3.11 +#if !defined(PyObject_CallNoArgs) && PY_VERSION_HEX < 0x030900A1 +static inline PyObject* PyObject_CallNoArgs(PyObject *func) +{ + return PyObject_CallFunctionObjArgs(func, NULL); +} +#endif + + +// bpo-39245 made PyObject_CallOneArg() public (previously called +// _PyObject_CallOneArg) in Python 3.9.0a4 +// PyObject_CallOneArg() added to PyPy 3.9.16-v7.3.11 +#if !defined(PyObject_CallOneArg) && PY_VERSION_HEX < 0x030900A4 +static inline PyObject* PyObject_CallOneArg(PyObject *func, PyObject *arg) +{ + return PyObject_CallFunctionObjArgs(func, arg, NULL); +} +#endif + + +// bpo-1635741 added PyModule_AddObjectRef() to Python 3.10.0a3 +#if PY_VERSION_HEX < 0x030A00A3 +static inline int +PyModule_AddObjectRef(PyObject *module, const char *name, PyObject *value) +{ + int res; + + if (!value && !PyErr_Occurred()) { + // PyModule_AddObject() raises TypeError in this case + PyErr_SetString(PyExc_SystemError, + "PyModule_AddObjectRef() must be called " + "with an exception raised if value is NULL"); + return -1; + } + + Py_XINCREF(value); + res = PyModule_AddObject(module, name, value); + if (res < 0) { + Py_XDECREF(value); + } + return res; +} +#endif + + +// bpo-40024 added PyModule_AddType() to Python 3.9.0a5 +#if PY_VERSION_HEX < 0x030900A5 +static inline int PyModule_AddType(PyObject *module, PyTypeObject *type) +{ + const char *name, *dot; + + if (PyType_Ready(type) < 0) { + return -1; + } + + // inline _PyType_Name() + name = type->tp_name; + assert(name != _Py_NULL); + dot = strrchr(name, '.'); + if (dot != _Py_NULL) { + name = dot + 1; + } + + return PyModule_AddObjectRef(module, name, _PyObject_CAST(type)); +} +#endif + + +// bpo-40241 added PyObject_GC_IsTracked() to Python 3.9.0a6. +// bpo-4688 added _PyObject_GC_IS_TRACKED() to Python 2.7.0a2. +#if PY_VERSION_HEX < 0x030900A6 && !defined(PYPY_VERSION) +static inline int PyObject_GC_IsTracked(PyObject* obj) +{ + return (PyObject_IS_GC(obj) && _PyObject_GC_IS_TRACKED(obj)); +} +#endif + +// bpo-40241 added PyObject_GC_IsFinalized() to Python 3.9.0a6. +// bpo-18112 added _PyGCHead_FINALIZED() to Python 3.4.0 final. +#if PY_VERSION_HEX < 0x030900A6 && PY_VERSION_HEX >= 0x030400F0 && !defined(PYPY_VERSION) +static inline int PyObject_GC_IsFinalized(PyObject *obj) +{ + PyGC_Head *gc = _Py_CAST(PyGC_Head*, obj) - 1; + return (PyObject_IS_GC(obj) && _PyGCHead_FINALIZED(gc)); +} +#endif + + +// bpo-39573 added Py_IS_TYPE() to Python 3.9.0a4 +#if PY_VERSION_HEX < 0x030900A4 && !defined(Py_IS_TYPE) +static inline int _Py_IS_TYPE(PyObject *ob, PyTypeObject *type) { + return Py_TYPE(ob) == type; +} +#define Py_IS_TYPE(ob, type) _Py_IS_TYPE(_PyObject_CAST(ob), type) +#endif + + +// bpo-46906 added PyFloat_Pack2() and PyFloat_Unpack2() to Python 3.11a7. +// bpo-11734 added _PyFloat_Pack2() and _PyFloat_Unpack2() to Python 3.6.0b1. +// Python 3.11a2 moved _PyFloat_Pack2() and _PyFloat_Unpack2() to the internal +// C API: Python 3.11a2-3.11a6 versions are not supported. +#if 0x030600B1 <= PY_VERSION_HEX && PY_VERSION_HEX <= 0x030B00A1 && !defined(PYPY_VERSION) +static inline int PyFloat_Pack2(double x, char *p, int le) +{ return _PyFloat_Pack2(x, (unsigned char*)p, le); } + +static inline double PyFloat_Unpack2(const char *p, int le) +{ return _PyFloat_Unpack2((const unsigned char *)p, le); } +#endif + + +// bpo-46906 added PyFloat_Pack4(), PyFloat_Pack8(), PyFloat_Unpack4() and +// PyFloat_Unpack8() to Python 3.11a7. +// Python 3.11a2 moved _PyFloat_Pack4(), _PyFloat_Pack8(), _PyFloat_Unpack4() +// and _PyFloat_Unpack8() to the internal C API: Python 3.11a2-3.11a6 versions +// are not supported. +#if PY_VERSION_HEX <= 0x030B00A1 && !defined(PYPY_VERSION) +static inline int PyFloat_Pack4(double x, char *p, int le) +{ return _PyFloat_Pack4(x, (unsigned char*)p, le); } + +static inline int PyFloat_Pack8(double x, char *p, int le) +{ return _PyFloat_Pack8(x, (unsigned char*)p, le); } + +static inline double PyFloat_Unpack4(const char *p, int le) +{ return _PyFloat_Unpack4((const unsigned char *)p, le); } + +static inline double PyFloat_Unpack8(const char *p, int le) +{ return _PyFloat_Unpack8((const unsigned char *)p, le); } +#endif + + +// gh-92154 added PyCode_GetCode() to Python 3.11.0b1 +#if PY_VERSION_HEX < 0x030B00B1 && !defined(PYPY_VERSION) +static inline PyObject* PyCode_GetCode(PyCodeObject *code) +{ + return Py_NewRef(code->co_code); +} +#endif + + +// gh-95008 added PyCode_GetVarnames() to Python 3.11.0rc1 +#if PY_VERSION_HEX < 0x030B00C1 && !defined(PYPY_VERSION) +static inline PyObject* PyCode_GetVarnames(PyCodeObject *code) +{ + return Py_NewRef(code->co_varnames); +} +#endif + +// gh-95008 added PyCode_GetFreevars() to Python 3.11.0rc1 +#if PY_VERSION_HEX < 0x030B00C1 && !defined(PYPY_VERSION) +static inline PyObject* PyCode_GetFreevars(PyCodeObject *code) +{ + return Py_NewRef(code->co_freevars); +} +#endif + +// gh-95008 added PyCode_GetCellvars() to Python 3.11.0rc1 +#if PY_VERSION_HEX < 0x030B00C1 && !defined(PYPY_VERSION) +static inline PyObject* PyCode_GetCellvars(PyCodeObject *code) +{ + return Py_NewRef(code->co_cellvars); +} +#endif + + +// Py_UNUSED() was added to Python 3.4.0b2. +#if PY_VERSION_HEX < 0x030400B2 && !defined(Py_UNUSED) +# if defined(__GNUC__) || defined(__clang__) +# define Py_UNUSED(name) _unused_ ## name __attribute__((unused)) +# else +# define Py_UNUSED(name) _unused_ ## name +# endif +#endif + + +// gh-105922 added PyImport_AddModuleRef() to Python 3.13.0a1 +#if PY_VERSION_HEX < 0x030D00A0 +static inline PyObject* PyImport_AddModuleRef(const char *name) +{ + return Py_XNewRef(PyImport_AddModule(name)); +} +#endif + + +// gh-105927 added PyWeakref_GetRef() to Python 3.13.0a1 +#if PY_VERSION_HEX < 0x030D0000 +static inline int PyWeakref_GetRef(PyObject *ref, PyObject **pobj) +{ + PyObject *obj; + if (ref != NULL && !PyWeakref_Check(ref)) { + *pobj = NULL; + PyErr_SetString(PyExc_TypeError, "expected a weakref"); + return -1; + } + obj = PyWeakref_GetObject(ref); + if (obj == NULL) { + // SystemError if ref is NULL + *pobj = NULL; + return -1; + } + if (obj == Py_None) { + *pobj = NULL; + return 0; + } + *pobj = Py_NewRef(obj); + return (*pobj != NULL); +} +#endif + + +// bpo-36974 added PY_VECTORCALL_ARGUMENTS_OFFSET to Python 3.8b1 +#ifndef PY_VECTORCALL_ARGUMENTS_OFFSET +# define PY_VECTORCALL_ARGUMENTS_OFFSET (_Py_CAST(size_t, 1) << (8 * sizeof(size_t) - 1)) +#endif + +// bpo-36974 added PyVectorcall_NARGS() to Python 3.8b1 +#if PY_VERSION_HEX < 0x030800B1 +static inline Py_ssize_t PyVectorcall_NARGS(size_t n) +{ + return n & ~PY_VECTORCALL_ARGUMENTS_OFFSET; +} +#endif + + +// gh-105922 added PyObject_Vectorcall() to Python 3.9.0a4 +#if PY_VERSION_HEX < 0x030900A4 +static inline PyObject* +PyObject_Vectorcall(PyObject *callable, PyObject *const *args, + size_t nargsf, PyObject *kwnames) +{ +#if PY_VERSION_HEX >= 0x030800B1 && !defined(PYPY_VERSION) + // bpo-36974 added _PyObject_Vectorcall() to Python 3.8.0b1 + return _PyObject_Vectorcall(callable, args, nargsf, kwnames); +#else + PyObject *posargs = NULL, *kwargs = NULL; + PyObject *res; + Py_ssize_t nposargs, nkwargs, i; + + if (nargsf != 0 && args == NULL) { + PyErr_BadInternalCall(); + goto error; + } + if (kwnames != NULL && !PyTuple_Check(kwnames)) { + PyErr_BadInternalCall(); + goto error; + } + + nposargs = (Py_ssize_t)PyVectorcall_NARGS(nargsf); + if (kwnames) { + nkwargs = PyTuple_GET_SIZE(kwnames); + } + else { + nkwargs = 0; + } + + posargs = PyTuple_New(nposargs); + if (posargs == NULL) { + goto error; + } + if (nposargs) { + for (i=0; i < nposargs; i++) { + PyTuple_SET_ITEM(posargs, i, Py_NewRef(*args)); + args++; + } + } + + if (nkwargs) { + kwargs = PyDict_New(); + if (kwargs == NULL) { + goto error; + } + + for (i = 0; i < nkwargs; i++) { + PyObject *key = PyTuple_GET_ITEM(kwnames, i); + PyObject *value = *args; + args++; + if (PyDict_SetItem(kwargs, key, value) < 0) { + goto error; + } + } + } + else { + kwargs = NULL; + } + + res = PyObject_Call(callable, posargs, kwargs); + Py_DECREF(posargs); + Py_XDECREF(kwargs); + return res; + +error: + Py_DECREF(posargs); + Py_XDECREF(kwargs); + return NULL; +#endif +} +#endif + + +// gh-106521 added PyObject_GetOptionalAttr() and +// PyObject_GetOptionalAttrString() to Python 3.13.0a1 +#if PY_VERSION_HEX < 0x030D00A1 +static inline int +PyObject_GetOptionalAttr(PyObject *obj, PyObject *attr_name, PyObject **result) +{ + // bpo-32571 added _PyObject_LookupAttr() to Python 3.7.0b1 +#if PY_VERSION_HEX >= 0x030700B1 && !defined(PYPY_VERSION) + return _PyObject_LookupAttr(obj, attr_name, result); +#else + *result = PyObject_GetAttr(obj, attr_name); + if (*result != NULL) { + return 1; + } + if (!PyErr_Occurred()) { + return 0; + } + if (PyErr_ExceptionMatches(PyExc_AttributeError)) { + PyErr_Clear(); + return 0; + } + return -1; +#endif +} + +static inline int +PyObject_GetOptionalAttrString(PyObject *obj, const char *attr_name, PyObject **result) +{ + PyObject *name_obj; + int rc; +#if PY_VERSION_HEX >= 0x03000000 + name_obj = PyUnicode_FromString(attr_name); +#else + name_obj = PyString_FromString(attr_name); +#endif + if (name_obj == NULL) { + *result = NULL; + return -1; + } + rc = PyObject_GetOptionalAttr(obj, name_obj, result); + Py_DECREF(name_obj); + return rc; +} +#endif + + +// gh-106307 added PyObject_GetOptionalAttr() and +// PyMapping_GetOptionalItemString() to Python 3.13.0a1 +#if PY_VERSION_HEX < 0x030D00A1 +static inline int +PyMapping_GetOptionalItem(PyObject *obj, PyObject *key, PyObject **result) +{ + *result = PyObject_GetItem(obj, key); + if (*result) { + return 1; + } + if (!PyErr_ExceptionMatches(PyExc_KeyError)) { + return -1; + } + PyErr_Clear(); + return 0; +} + +static inline int +PyMapping_GetOptionalItemString(PyObject *obj, const char *key, PyObject **result) +{ + PyObject *key_obj; + int rc; +#if PY_VERSION_HEX >= 0x03000000 + key_obj = PyUnicode_FromString(key); +#else + key_obj = PyString_FromString(key); +#endif + if (key_obj == NULL) { + *result = NULL; + return -1; + } + rc = PyMapping_GetOptionalItem(obj, key_obj, result); + Py_DECREF(key_obj); + return rc; +} +#endif + +// gh-108511 added PyMapping_HasKeyWithError() and +// PyMapping_HasKeyStringWithError() to Python 3.13.0a1 +#if PY_VERSION_HEX < 0x030D00A1 +static inline int +PyMapping_HasKeyWithError(PyObject *obj, PyObject *key) +{ + PyObject *res; + int rc = PyMapping_GetOptionalItem(obj, key, &res); + Py_XDECREF(res); + return rc; +} + +static inline int +PyMapping_HasKeyStringWithError(PyObject *obj, const char *key) +{ + PyObject *res; + int rc = PyMapping_GetOptionalItemString(obj, key, &res); + Py_XDECREF(res); + return rc; +} +#endif + + +// gh-108511 added PyObject_HasAttrWithError() and +// PyObject_HasAttrStringWithError() to Python 3.13.0a1 +#if PY_VERSION_HEX < 0x030D00A1 +static inline int +PyObject_HasAttrWithError(PyObject *obj, PyObject *attr) +{ + PyObject *res; + int rc = PyObject_GetOptionalAttr(obj, attr, &res); + Py_XDECREF(res); + return rc; +} + +static inline int +PyObject_HasAttrStringWithError(PyObject *obj, const char *attr) +{ + PyObject *res; + int rc = PyObject_GetOptionalAttrString(obj, attr, &res); + Py_XDECREF(res); + return rc; +} +#endif + + +// gh-106004 added PyDict_GetItemRef() and PyDict_GetItemStringRef() +// to Python 3.13.0a1 +#if PY_VERSION_HEX < 0x030D00A1 +static inline int +PyDict_GetItemRef(PyObject *mp, PyObject *key, PyObject **result) +{ +#if PY_VERSION_HEX >= 0x03000000 + PyObject *item = PyDict_GetItemWithError(mp, key); +#else + PyObject *item = _PyDict_GetItemWithError(mp, key); +#endif + if (item != NULL) { + *result = Py_NewRef(item); + return 1; // found + } + if (!PyErr_Occurred()) { + *result = NULL; + return 0; // not found + } + *result = NULL; + return -1; +} + +static inline int +PyDict_GetItemStringRef(PyObject *mp, const char *key, PyObject **result) +{ + int res; +#if PY_VERSION_HEX >= 0x03000000 + PyObject *key_obj = PyUnicode_FromString(key); +#else + PyObject *key_obj = PyString_FromString(key); +#endif + if (key_obj == NULL) { + *result = NULL; + return -1; + } + res = PyDict_GetItemRef(mp, key_obj, result); + Py_DECREF(key_obj); + return res; +} +#endif + + +// gh-106307 added PyModule_Add() to Python 3.13.0a1 +#if PY_VERSION_HEX < 0x030D00A1 +static inline int +PyModule_Add(PyObject *mod, const char *name, PyObject *value) +{ + int res = PyModule_AddObjectRef(mod, name, value); + Py_XDECREF(value); + return res; +} +#endif + + +// gh-108014 added Py_IsFinalizing() to Python 3.13.0a1 +// bpo-1856 added _Py_Finalizing to Python 3.2.1b1. +// _Py_IsFinalizing() was added to PyPy 7.3.0. +#if (0x030201B1 <= PY_VERSION_HEX && PY_VERSION_HEX < 0x030D00A1) \ + && (!defined(PYPY_VERSION_NUM) || PYPY_VERSION_NUM >= 0x7030000) +static inline int Py_IsFinalizing(void) +{ +#if PY_VERSION_HEX >= 0x030700A1 + // _Py_IsFinalizing() was added to Python 3.7.0a1. + return _Py_IsFinalizing(); +#else + return (_Py_Finalizing != NULL); +#endif +} +#endif + + +// gh-108323 added PyDict_ContainsString() to Python 3.13.0a1 +#if PY_VERSION_HEX < 0x030D00A1 +static inline int PyDict_ContainsString(PyObject *op, const char *key) +{ + PyObject *key_obj = PyUnicode_FromString(key); + if (key_obj == NULL) { + return -1; + } + int res = PyDict_Contains(op, key_obj); + Py_DECREF(key_obj); + return res; +} +#endif + + +// gh-108445 added PyLong_AsInt() to Python 3.13.0a1 +#if PY_VERSION_HEX < 0x030D00A1 +static inline int PyLong_AsInt(PyObject *obj) +{ +#ifdef PYPY_VERSION + long value = PyLong_AsLong(obj); + if (value == -1 && PyErr_Occurred()) { + return -1; + } + if (value < (long)INT_MIN || (long)INT_MAX < value) { + PyErr_SetString(PyExc_OverflowError, + "Python int too large to convert to C int"); + return -1; + } + return (int)value; +#else + return _PyLong_AsInt(obj); +#endif +} +#endif + + +// gh-107073 added PyObject_VisitManagedDict() to Python 3.13.0a1 +#if PY_VERSION_HEX < 0x030D00A1 +static inline int +PyObject_VisitManagedDict(PyObject *obj, visitproc visit, void *arg) +{ + PyObject **dict = _PyObject_GetDictPtr(obj); + if (*dict == NULL) { + return -1; + } + Py_VISIT(*dict); + return 0; +} + +static inline void +PyObject_ClearManagedDict(PyObject *obj) +{ + PyObject **dict = _PyObject_GetDictPtr(obj); + if (*dict == NULL) { + return; + } + Py_CLEAR(*dict); +} +#endif + +// gh-108867 added PyThreadState_GetUnchecked() to Python 3.13.0a1 +// Python 3.5.2 added _PyThreadState_UncheckedGet(). +#if PY_VERSION_HEX >= 0x03050200 && PY_VERSION_HEX < 0x030D00A1 +static inline PyThreadState* +PyThreadState_GetUnchecked(void) +{ + return _PyThreadState_UncheckedGet(); +} +#endif + +// gh-110289 added PyUnicode_EqualToUTF8() and PyUnicode_EqualToUTF8AndSize() +// to Python 3.13.0a1 +#if PY_VERSION_HEX < 0x030D00A1 +static inline int +PyUnicode_EqualToUTF8AndSize(PyObject *unicode, const char *str, Py_ssize_t str_len) +{ + Py_ssize_t len; + const void *utf8; + PyObject *exc_type, *exc_value, *exc_tb; + int res; + + // API cannot report errors so save/restore the exception + PyErr_Fetch(&exc_type, &exc_value, &exc_tb); + + // Python 3.3.0a1 added PyUnicode_AsUTF8AndSize() +#if PY_VERSION_HEX >= 0x030300A1 + if (PyUnicode_IS_ASCII(unicode)) { + utf8 = PyUnicode_DATA(unicode); + len = PyUnicode_GET_LENGTH(unicode); + } + else { + utf8 = PyUnicode_AsUTF8AndSize(unicode, &len); + if (utf8 == NULL) { + // Memory allocation failure. The API cannot report error, + // so ignore the exception and return 0. + res = 0; + goto done; + } + } + + if (len != str_len) { + res = 0; + goto done; + } + res = (memcmp(utf8, str, (size_t)len) == 0); +#else + PyObject *bytes = PyUnicode_AsUTF8String(unicode); + if (bytes == NULL) { + // Memory allocation failure. The API cannot report error, + // so ignore the exception and return 0. + res = 0; + goto done; + } + +#if PY_VERSION_HEX >= 0x03000000 + len = PyBytes_GET_SIZE(bytes); + utf8 = PyBytes_AS_STRING(bytes); +#else + len = PyString_GET_SIZE(bytes); + utf8 = PyString_AS_STRING(bytes); +#endif + if (len != str_len) { + Py_DECREF(bytes); + res = 0; + goto done; + } + + res = (memcmp(utf8, str, (size_t)len) == 0); + Py_DECREF(bytes); +#endif + +done: + PyErr_Restore(exc_type, exc_value, exc_tb); + return res; +} + +static inline int +PyUnicode_EqualToUTF8(PyObject *unicode, const char *str) +{ + return PyUnicode_EqualToUTF8AndSize(unicode, str, (Py_ssize_t)strlen(str)); +} +#endif + + +// gh-111138 added PyList_Extend() and PyList_Clear() to Python 3.13.0a2 +#if PY_VERSION_HEX < 0x030D00A2 +static inline int +PyList_Extend(PyObject *list, PyObject *iterable) +{ + return PyList_SetSlice(list, PY_SSIZE_T_MAX, PY_SSIZE_T_MAX, iterable); +} + +static inline int +PyList_Clear(PyObject *list) +{ + return PyList_SetSlice(list, 0, PY_SSIZE_T_MAX, NULL); +} +#endif + +// gh-111262 added PyDict_Pop() and PyDict_PopString() to Python 3.13.0a2 +#if PY_VERSION_HEX < 0x030D00A2 +static inline int +PyDict_Pop(PyObject *dict, PyObject *key, PyObject **result) +{ + PyObject *value; + + if (!PyDict_Check(dict)) { + PyErr_BadInternalCall(); + if (result) { + *result = NULL; + } + return -1; + } + + // bpo-16991 added _PyDict_Pop() to Python 3.5.0b2. + // Python 3.6.0b3 changed _PyDict_Pop() first argument type to PyObject*. + // Python 3.13.0a1 removed _PyDict_Pop(). +#if defined(PYPY_VERSION) || PY_VERSION_HEX < 0x030500b2 || PY_VERSION_HEX >= 0x030D0000 + value = PyObject_CallMethod(dict, "pop", "O", key); +#elif PY_VERSION_HEX < 0x030600b3 + value = _PyDict_Pop(_Py_CAST(PyDictObject*, dict), key, NULL); +#else + value = _PyDict_Pop(dict, key, NULL); +#endif + if (value == NULL) { + if (result) { + *result = NULL; + } + if (PyErr_Occurred() && !PyErr_ExceptionMatches(PyExc_KeyError)) { + return -1; + } + PyErr_Clear(); + return 0; + } + if (result) { + *result = value; + } + else { + Py_DECREF(value); + } + return 1; +} + +static inline int +PyDict_PopString(PyObject *dict, const char *key, PyObject **result) +{ + PyObject *key_obj = PyUnicode_FromString(key); + if (key_obj == NULL) { + if (result != NULL) { + *result = NULL; + } + return -1; + } + + int res = PyDict_Pop(dict, key_obj, result); + Py_DECREF(key_obj); + return res; +} +#endif + + +#if PY_VERSION_HEX < 0x030200A4 +// Python 3.2.0a4 added Py_hash_t type +typedef Py_ssize_t Py_hash_t; +#endif + + +// gh-111545 added Py_HashPointer() to Python 3.13.0a3 +#if PY_VERSION_HEX < 0x030D00A3 +static inline Py_hash_t Py_HashPointer(const void *ptr) +{ +#if PY_VERSION_HEX >= 0x030900A4 && !defined(PYPY_VERSION) + return _Py_HashPointer(ptr); +#else + return _Py_HashPointer(_Py_CAST(void*, ptr)); +#endif +} +#endif + + +// Python 3.13a4 added a PyTime API. +// Use the private API added to Python 3.5. +#if PY_VERSION_HEX < 0x030D00A4 && PY_VERSION_HEX >= 0x03050000 +typedef _PyTime_t PyTime_t; +#define PyTime_MIN _PyTime_MIN +#define PyTime_MAX _PyTime_MAX + +static inline double PyTime_AsSecondsDouble(PyTime_t t) +{ return _PyTime_AsSecondsDouble(t); } + +static inline int PyTime_Monotonic(PyTime_t *result) +{ return _PyTime_GetMonotonicClockWithInfo(result, NULL); } + +static inline int PyTime_Time(PyTime_t *result) +{ return _PyTime_GetSystemClockWithInfo(result, NULL); } + +static inline int PyTime_PerfCounter(PyTime_t *result) +{ +#if PY_VERSION_HEX >= 0x03070000 && !defined(PYPY_VERSION) + return _PyTime_GetPerfCounterWithInfo(result, NULL); +#elif PY_VERSION_HEX >= 0x03070000 + // Call time.perf_counter_ns() and convert Python int object to PyTime_t. + // Cache time.perf_counter_ns() function for best performance. + static PyObject *func = NULL; + if (func == NULL) { + PyObject *mod = PyImport_ImportModule("time"); + if (mod == NULL) { + return -1; + } + + func = PyObject_GetAttrString(mod, "perf_counter_ns"); + Py_DECREF(mod); + if (func == NULL) { + return -1; + } + } + + PyObject *res = PyObject_CallNoArgs(func); + if (res == NULL) { + return -1; + } + long long value = PyLong_AsLongLong(res); + Py_DECREF(res); + + if (value == -1 && PyErr_Occurred()) { + return -1; + } + + Py_BUILD_ASSERT(sizeof(value) >= sizeof(PyTime_t)); + *result = (PyTime_t)value; + return 0; +#else + // Call time.perf_counter() and convert C double to PyTime_t. + // Cache time.perf_counter() function for best performance. + static PyObject *func = NULL; + if (func == NULL) { + PyObject *mod = PyImport_ImportModule("time"); + if (mod == NULL) { + return -1; + } + + func = PyObject_GetAttrString(mod, "perf_counter"); + Py_DECREF(mod); + if (func == NULL) { + return -1; + } + } + + PyObject *res = PyObject_CallNoArgs(func); + if (res == NULL) { + return -1; + } + double d = PyFloat_AsDouble(res); + Py_DECREF(res); + + if (d == -1.0 && PyErr_Occurred()) { + return -1; + } + + // Avoid floor() to avoid having to link to libm + *result = (PyTime_t)(d * 1e9); + return 0; +#endif +} + +#endif + +// gh-111389 added hash constants to Python 3.13.0a5. These constants were +// added first as private macros to Python 3.4.0b1 and PyPy 7.3.9. +#if (!defined(PyHASH_BITS) \ + && ((!defined(PYPY_VERSION) && PY_VERSION_HEX >= 0x030400B1) \ + || (defined(PYPY_VERSION) && PY_VERSION_HEX >= 0x03070000 \ + && PYPY_VERSION_NUM >= 0x07090000))) +# define PyHASH_BITS _PyHASH_BITS +# define PyHASH_MODULUS _PyHASH_MODULUS +# define PyHASH_INF _PyHASH_INF +# define PyHASH_IMAG _PyHASH_IMAG +#endif + + +// gh-111545 added Py_GetConstant() and Py_GetConstantBorrowed() +// to Python 3.13.0a6 +#if PY_VERSION_HEX < 0x030D00A6 && !defined(Py_CONSTANT_NONE) + +#define Py_CONSTANT_NONE 0 +#define Py_CONSTANT_FALSE 1 +#define Py_CONSTANT_TRUE 2 +#define Py_CONSTANT_ELLIPSIS 3 +#define Py_CONSTANT_NOT_IMPLEMENTED 4 +#define Py_CONSTANT_ZERO 5 +#define Py_CONSTANT_ONE 6 +#define Py_CONSTANT_EMPTY_STR 7 +#define Py_CONSTANT_EMPTY_BYTES 8 +#define Py_CONSTANT_EMPTY_TUPLE 9 + +static inline PyObject* Py_GetConstant(unsigned int constant_id) +{ + static PyObject* constants[Py_CONSTANT_EMPTY_TUPLE + 1] = {NULL}; + + if (constants[Py_CONSTANT_NONE] == NULL) { + constants[Py_CONSTANT_NONE] = Py_None; + constants[Py_CONSTANT_FALSE] = Py_False; + constants[Py_CONSTANT_TRUE] = Py_True; + constants[Py_CONSTANT_ELLIPSIS] = Py_Ellipsis; + constants[Py_CONSTANT_NOT_IMPLEMENTED] = Py_NotImplemented; + + constants[Py_CONSTANT_ZERO] = PyLong_FromLong(0); + if (constants[Py_CONSTANT_ZERO] == NULL) { + goto fatal_error; + } + + constants[Py_CONSTANT_ONE] = PyLong_FromLong(1); + if (constants[Py_CONSTANT_ONE] == NULL) { + goto fatal_error; + } + + constants[Py_CONSTANT_EMPTY_STR] = PyUnicode_FromStringAndSize("", 0); + if (constants[Py_CONSTANT_EMPTY_STR] == NULL) { + goto fatal_error; + } + + constants[Py_CONSTANT_EMPTY_BYTES] = PyBytes_FromStringAndSize("", 0); + if (constants[Py_CONSTANT_EMPTY_BYTES] == NULL) { + goto fatal_error; + } + + constants[Py_CONSTANT_EMPTY_TUPLE] = PyTuple_New(0); + if (constants[Py_CONSTANT_EMPTY_TUPLE] == NULL) { + goto fatal_error; + } + // goto dance to avoid compiler warnings about Py_FatalError() + goto init_done; + +fatal_error: + // This case should never happen + Py_FatalError("Py_GetConstant() failed to get constants"); + } + +init_done: + if (constant_id <= Py_CONSTANT_EMPTY_TUPLE) { + return Py_NewRef(constants[constant_id]); + } + else { + PyErr_BadInternalCall(); + return NULL; + } +} + +static inline PyObject* Py_GetConstantBorrowed(unsigned int constant_id) +{ + PyObject *obj = Py_GetConstant(constant_id); + Py_XDECREF(obj); + return obj; +} +#endif + + +// gh-114329 added PyList_GetItemRef() to Python 3.13.0a4 +#if PY_VERSION_HEX < 0x030D00A4 +static inline PyObject * +PyList_GetItemRef(PyObject *op, Py_ssize_t index) +{ + PyObject *item = PyList_GetItem(op, index); + Py_XINCREF(item); + return item; +} +#endif + + +// gh-114329 added PyList_GetItemRef() to Python 3.13.0a4 +#if PY_VERSION_HEX < 0x030D00A4 +static inline int +PyDict_SetDefaultRef(PyObject *d, PyObject *key, PyObject *default_value, + PyObject **result) +{ + PyObject *value; + if (PyDict_GetItemRef(d, key, &value) < 0) { + // get error + if (result) { + *result = NULL; + } + return -1; + } + if (value != NULL) { + // present + if (result) { + *result = value; + } + else { + Py_DECREF(value); + } + return 1; + } + + // missing: set the item + if (PyDict_SetItem(d, key, default_value) < 0) { + // set error + if (result) { + *result = NULL; + } + return -1; + } + if (result) { + *result = Py_NewRef(default_value); + } + return 0; +} +#endif + + +// gh-116560 added PyLong_GetSign() to Python 3.14.0a0 +#if PY_VERSION_HEX < 0x030E00A0 +static inline int PyLong_GetSign(PyObject *obj, int *sign) +{ + if (!PyLong_Check(obj)) { + PyErr_Format(PyExc_TypeError, "expect int, got %s", Py_TYPE(obj)->tp_name); + return -1; + } + + *sign = _PyLong_Sign(obj); + return 0; +} +#endif + + +#ifdef __cplusplus +} +#endif +#endif // PYTHONCAPI_COMPAT From 7c64ae0c73666924fa1e298f1be55abbf6973586 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Tue, 2 Jul 2024 18:46:52 +0300 Subject: [PATCH 20/30] encode: Replace PyDict_GetItem with PyDict_GetItemRef --- src/encode.c | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/encode.c b/src/encode.c index 72ad3fa07..620774f67 100644 --- a/src/encode.c +++ b/src/encode.c @@ -722,7 +722,10 @@ PyImaging_LibTiffEncoderNew(PyObject *self, PyObject *args) { } if (!is_core_tag) { - PyObject *tag_type = PyDict_GetItem(types, key); + PyObject *tag_type; + if (PyDict_GetItemRef(types, key, &tag_type) == 0) { + PyErr_SetString(PyExc_KeyError, "unknown tag type"); + } if (tag_type) { int type_int = PyLong_AsLong(tag_type); if (type_int >= TIFF_BYTE && type_int <= TIFF_DOUBLE) { From 87596bd747f1cfadd2f7ec6beb5bca67e6aeba0a Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Tue, 2 Jul 2024 18:51:15 +0300 Subject: [PATCH 21/30] imagingft: Replace PyDict_GetItem with PyDict_GetItemRef --- src/_imagingft.c | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/_imagingft.c b/src/_imagingft.c index 1bef876e1..468f16884 100644 --- a/src/_imagingft.c +++ b/src/_imagingft.c @@ -20,6 +20,7 @@ #define PY_SSIZE_T_CLEAN #include "Python.h" +#include "thirdparty/pythoncapi_compat.h" #include "libImaging/Imaging.h" #include @@ -1219,7 +1220,7 @@ font_getvarnames(FontObject *self) { } for (j = 0; j < num_namedstyles; j++) { - if (PyList_GetItem(list_names, j) != NULL) { + if (PyList_GetItemRef(list_names, j) != NULL) { continue; } From 40e7f511b33204a23faa0f26140dd7b74c13be97 Mon Sep 17 00:00:00 2001 From: Lysandros Nikolaou Date: Fri, 5 Jul 2024 16:30:48 +0200 Subject: [PATCH 22/30] Don't use PyList_GetItemRef immediately after PyList_New --- src/_imagingft.c | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/_imagingft.c b/src/_imagingft.c index 468f16884..ddcf28f97 100644 --- a/src/_imagingft.c +++ b/src/_imagingft.c @@ -1205,6 +1205,16 @@ font_getvarnames(FontObject *self) { num_namedstyles = master->num_namedstyles; list_names = PyList_New(num_namedstyles); + + int *list_names_filled = PyMem_Malloc(num_namedstyles * sizeof(int)); + if (list_names_filled == NULL) { + return PyErr_NoMemory(); + } + + for (int i = 0; i < num_namedstyles; i++) { + list_names_filled[i] = 0; + } + if (list_names == NULL) { FT_Done_MM_Var(library, master); return NULL; @@ -1220,13 +1230,14 @@ font_getvarnames(FontObject *self) { } for (j = 0; j < num_namedstyles; j++) { - if (PyList_GetItemRef(list_names, j) != NULL) { + if (list_names_filled[j]) { continue; } if (master->namedstyle[j].strid == name.name_id) { list_name = Py_BuildValue("y#", name.string, name.string_len); PyList_SetItem(list_names, j, list_name); + list_names_filled[j] = 1; break; } } From c416f0ea1db1dfe5b3a96a0d24af0f0318883317 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 10 Jul 2024 06:11:11 +1000 Subject: [PATCH 23/30] Build wheels with free threading --- .github/workflows/wheels-test.sh | 6 ++++++ .github/workflows/wheels.yml | 2 ++ 2 files changed, 8 insertions(+) diff --git a/.github/workflows/wheels-test.sh b/.github/workflows/wheels-test.sh index 3fbf3be69..023a33824 100755 --- a/.github/workflows/wheels-test.sh +++ b/.github/workflows/wheels-test.sh @@ -12,8 +12,14 @@ elif [ "${AUDITWHEEL_POLICY::9}" == "musllinux" ]; then else yum install -y fribidi fi + if [ "${AUDITWHEEL_POLICY::9}" != "musllinux" ]; then + # TODO Update condition when NumPy supports free-threading + if [ $(python3 -c "import sysconfig;print(sysconfig.get_config_var('Py_GIL_DISABLED'))") == "1" ]; then + python3 -m pip install numpy --index-url https://pypi.anaconda.org/scientific-python-nightly-wheels/simple + else python3 -m pip install numpy + fi fi if [ ! -d "test-images-main" ]; then diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index f8dff3a3e..e32f14529 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -129,6 +129,7 @@ jobs: env: CIBW_ARCHS: ${{ matrix.cibw_arch }} CIBW_BUILD: ${{ matrix.build }} + CIBW_FREE_THREADED_SUPPORT: True CIBW_MANYLINUX_PYPY_X86_64_IMAGE: ${{ matrix.manylinux }} CIBW_MANYLINUX_X86_64_IMAGE: ${{ matrix.manylinux }} CIBW_PRERELEASE_PYTHONS: True @@ -201,6 +202,7 @@ jobs: CIBW_ARCHS: ${{ matrix.cibw_arch }} CIBW_BEFORE_ALL: "{package}\\winbuild\\build\\build_dep_all.cmd" CIBW_CACHE_PATH: "C:\\cibw" + CIBW_FREE_THREADED_SUPPORT: True CIBW_PRERELEASE_PYTHONS: True CIBW_TEST_SKIP: "*-win_arm64" CIBW_TEST_COMMAND: 'docker run --rm From 5bae9343171d653dcf5dff0669c3665fd97dea3e Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 12 Jul 2024 21:16:56 +1000 Subject: [PATCH 24/30] Added type hints --- Tests/test_file_png.py | 2 +- Tests/test_imagefile.py | 6 +++--- src/PIL/ImageFile.py | 34 +++++++++++++++--------------- src/PIL/ImageSequence.py | 2 +- src/PIL/JpegImagePlugin.py | 4 ++-- src/PIL/PdfImagePlugin.py | 20 +++++++++--------- src/PIL/PngImagePlugin.py | 36 ++++++++++++++++--------------- src/PIL/TiffImagePlugin.py | 43 +++++++++++++++++++------------------- src/PIL/WalImageFile.py | 5 ++++- 9 files changed, 79 insertions(+), 73 deletions(-) diff --git a/Tests/test_file_png.py b/Tests/test_file_png.py index dfe8f9e99..e2913e944 100644 --- a/Tests/test_file_png.py +++ b/Tests/test_file_png.py @@ -41,7 +41,7 @@ MAGIC = PngImagePlugin._MAGIC def chunk(cid: bytes, *data: bytes) -> bytes: test_file = BytesIO() - PngImagePlugin.putchunk(*(test_file, cid) + data) + PngImagePlugin.putchunk(test_file, cid, *data) return test_file.getvalue() diff --git a/Tests/test_imagefile.py b/Tests/test_imagefile.py index b996860ce..44a6e6a42 100644 --- a/Tests/test_imagefile.py +++ b/Tests/test_imagefile.py @@ -127,7 +127,7 @@ class TestImageFile: def test_raise_typeerror(self) -> None: with pytest.raises(TypeError): parser = ImageFile.Parser() - parser.feed(1) + parser.feed(1) # type: ignore[arg-type] def test_negative_stride(self) -> None: with open("Tests/images/raw_negative_stride.bin", "rb") as f: @@ -305,7 +305,7 @@ class TestPyDecoder(CodecsTest): im.load() def test_decode(self) -> None: - decoder = ImageFile.PyDecoder(None) + decoder = ImageFile.PyDecoder("") with pytest.raises(NotImplementedError): decoder.decode(b"") @@ -383,7 +383,7 @@ class TestPyEncoder(CodecsTest): ) def test_encode(self) -> None: - encoder = ImageFile.PyEncoder(None) + encoder = ImageFile.PyEncoder("") with pytest.raises(NotImplementedError): encoder.encode(0) diff --git a/src/PIL/ImageFile.py b/src/PIL/ImageFile.py index 99d7e73f1..6b2953451 100644 --- a/src/PIL/ImageFile.py +++ b/src/PIL/ImageFile.py @@ -86,7 +86,7 @@ def raise_oserror(error: int) -> OSError: raise _get_oserror(error, encoder=False) -def _tilesort(t): +def _tilesort(t) -> int: # sort on offset return t[2] @@ -161,7 +161,7 @@ class ImageFile(Image.Image): return Image.MIME.get(self.format.upper()) return None - def __setstate__(self, state): + def __setstate__(self, state) -> None: self.tile = [] super().__setstate__(state) @@ -333,14 +333,14 @@ class ImageFile(Image.Image): # def load_read(self, read_bytes: int) -> bytes: # pass - def _seek_check(self, frame): + def _seek_check(self, frame: int) -> bool: if ( frame < self._min_frame # Only check upper limit on frames if additional seek operations # are not required to do so or ( not (hasattr(self, "_n_frames") and self._n_frames is None) - and frame >= self.n_frames + self._min_frame + and frame >= getattr(self, "n_frames") + self._min_frame ) ): msg = "attempt to seek outside sequence" @@ -370,7 +370,7 @@ class StubImageFile(ImageFile): msg = "StubImageFile subclass must implement _open" raise NotImplementedError(msg) - def load(self): + def load(self) -> Image.core.PixelAccess | None: loader = self._load() if loader is None: msg = f"cannot find loader for this {self.format} file" @@ -378,7 +378,7 @@ class StubImageFile(ImageFile): image = loader.load(self) assert image is not None # become the other object (!) - self.__class__ = image.__class__ + self.__class__ = image.__class__ # type: ignore[assignment] self.__dict__ = image.__dict__ return image.load() @@ -396,8 +396,8 @@ class Parser: incremental = None image: Image.Image | None = None - data = None - decoder = None + data: bytes | None = None + decoder: Image.core.ImagingDecoder | PyDecoder | None = None offset = 0 finished = 0 @@ -409,7 +409,7 @@ class Parser: """ assert self.data is None, "cannot reuse parsers" - def feed(self, data): + def feed(self, data: bytes) -> None: """ (Consumer) Feed data to the parser. @@ -491,7 +491,7 @@ class Parser: def __exit__(self, *args: object) -> None: self.close() - def close(self): + def close(self) -> Image.Image: """ (Consumer) Close the stream. @@ -525,7 +525,7 @@ class Parser: # -------------------------------------------------------------------- -def _save(im, fp, tile, bufsize=0) -> None: +def _save(im, fp, tile, bufsize: int = 0) -> None: """Helper to save image based on tile list :param im: Image object. @@ -553,7 +553,7 @@ def _save(im, fp, tile, bufsize=0) -> None: fp.flush() -def _encode_tile(im, fp, tile: list[_Tile], bufsize, fh, exc=None): +def _encode_tile(im, fp, tile: list[_Tile], bufsize: int, fh, exc=None) -> None: for encoder_name, extents, offset, args in tile: if offset > 0: fp.seek(offset) @@ -629,18 +629,18 @@ class PyCodecState: class PyCodec: fd: IO[bytes] | None - def __init__(self, mode, *args): - self.im = None + def __init__(self, mode: str, *args: Any) -> None: + self.im: Image.core.ImagingCore | None = None self.state = PyCodecState() self.fd = None self.mode = mode self.init(args) - def init(self, args) -> None: + def init(self, args: tuple[Any, ...]) -> None: """ Override to perform codec specific initialization - :param args: Array of args items from the tile entry + :param args: Tuple of arg items from the tile entry :returns: None """ self.args = args @@ -662,7 +662,7 @@ class PyCodec: """ self.fd = fd - def setimage(self, im, extents: tuple[int, int, int, int] | None = None) -> None: + def setimage(self, im, extents=None): """ Called from ImageFile to set the core output image for the codec diff --git a/src/PIL/ImageSequence.py b/src/PIL/ImageSequence.py index 2c1850276..a6fc340d5 100644 --- a/src/PIL/ImageSequence.py +++ b/src/PIL/ImageSequence.py @@ -33,7 +33,7 @@ class Iterator: :param im: An image object. """ - def __init__(self, im: Image.Image): + def __init__(self, im: Image.Image) -> None: if not hasattr(im, "seek"): msg = "im must have seek method" raise AttributeError(msg) diff --git a/src/PIL/JpegImagePlugin.py b/src/PIL/JpegImagePlugin.py index 4916727be..54f756014 100644 --- a/src/PIL/JpegImagePlugin.py +++ b/src/PIL/JpegImagePlugin.py @@ -827,11 +827,11 @@ def _save_cjpeg(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: ## # Factory for making JPEG and MPO instances -def jpeg_factory(fp=None, filename=None): +def jpeg_factory(fp: IO[bytes] | None = None, filename: str | bytes | None = None): im = JpegImageFile(fp, filename) try: mpheader = im._getmp() - if mpheader[45057] > 1: + if mpheader is not None and mpheader[45057] > 1: for segment, content in im.applist: if segment == "APP1" and b' hdrgm:Version="' in content: # Ultra HDR images are not yet supported diff --git a/src/PIL/PdfImagePlugin.py b/src/PIL/PdfImagePlugin.py index f0da1e047..e0f732199 100644 --- a/src/PIL/PdfImagePlugin.py +++ b/src/PIL/PdfImagePlugin.py @@ -174,12 +174,15 @@ def _write_image(im, filename, existing_pdf, image_refs): return image_ref, procset -def _save(im, fp, filename, save_all=False): +def _save( + im: Image.Image, fp: IO[bytes], filename: str | bytes, save_all: bool = False +) -> None: is_appending = im.encoderinfo.get("append", False) + filename_str = filename.decode() if isinstance(filename, bytes) else filename if is_appending: - existing_pdf = PdfParser.PdfParser(f=fp, filename=filename, mode="r+b") + existing_pdf = PdfParser.PdfParser(f=fp, filename=filename_str, mode="r+b") else: - existing_pdf = PdfParser.PdfParser(f=fp, filename=filename, mode="w+b") + existing_pdf = PdfParser.PdfParser(f=fp, filename=filename_str, mode="w+b") dpi = im.encoderinfo.get("dpi") if dpi: @@ -228,12 +231,7 @@ def _save(im, fp, filename, save_all=False): for im in ims: im_number_of_pages = 1 if save_all: - try: - im_number_of_pages = im.n_frames - except AttributeError: - # Image format does not have n_frames. - # It is a single frame image - pass + im_number_of_pages = getattr(im, "n_frames", 1) number_of_pages += im_number_of_pages for i in range(im_number_of_pages): image_refs.append(existing_pdf.next_object_id(0)) @@ -250,7 +248,9 @@ def _save(im, fp, filename, save_all=False): page_number = 0 for im_sequence in ims: - im_pages = ImageSequence.Iterator(im_sequence) if save_all else [im_sequence] + im_pages: ImageSequence.Iterator | list[Image.Image] = ( + ImageSequence.Iterator(im_sequence) if save_all else [im_sequence] + ) for im in im_pages: image_ref, procset = _write_image(im, filename, existing_pdf, image_refs) diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index fa117d19a..6990b6d05 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -144,7 +144,7 @@ def _safe_zlib_decompress(s): return plaintext -def _crc32(data, seed=0): +def _crc32(data: bytes, seed: int = 0) -> int: return zlib.crc32(data, seed) & 0xFFFFFFFF @@ -191,7 +191,7 @@ class ChunkStream: assert self.queue is not None self.queue.append((cid, pos, length)) - def call(self, cid, pos, length): + def call(self, cid: bytes, pos: int, length: int) -> bytes: """Call the appropriate chunk handler""" logger.debug("STREAM %r %s %s", cid, pos, length) @@ -1091,21 +1091,21 @@ _OUTMODES = { } -def putchunk(fp, cid, *data): +def putchunk(fp: IO[bytes], cid: bytes, *data: bytes) -> None: """Write a PNG chunk (including CRC field)""" - data = b"".join(data) + byte_data = b"".join(data) - fp.write(o32(len(data)) + cid) - fp.write(data) - crc = _crc32(data, _crc32(cid)) + fp.write(o32(len(byte_data)) + cid) + fp.write(byte_data) + crc = _crc32(byte_data, _crc32(cid)) fp.write(o32(crc)) class _idat: # wrap output from the encoder in IDAT chunks - def __init__(self, fp, chunk): + def __init__(self, fp, chunk) -> None: self.fp = fp self.chunk = chunk @@ -1116,7 +1116,7 @@ class _idat: class _fdat: # wrap encoder output in fdAT chunks - def __init__(self, fp, chunk, seq_num): + def __init__(self, fp: IO[bytes], chunk, seq_num: int) -> None: self.fp = fp self.chunk = chunk self.seq_num = seq_num @@ -1259,7 +1259,9 @@ 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, chunk=putchunk, save_all=False): +def _save( + im: Image.Image, fp, filename: str | bytes, chunk=putchunk, save_all: bool = False +) -> None: # save an image to disk (called by the save method) if save_all: @@ -1461,7 +1463,7 @@ def _save(im, fp, filename, chunk=putchunk, save_all=False): # PNG chunk converter -def getchunks(im, **params): +def getchunks(im: Image.Image, **params: Any) -> list[tuple[bytes, bytes, bytes]]: """Return a list of PNG chunks representing this image.""" class collector: @@ -1470,19 +1472,19 @@ def getchunks(im, **params): def write(self, data: bytes) -> None: pass - def append(self, chunk: bytes) -> None: + def append(self, chunk: tuple[bytes, bytes, bytes]) -> None: self.data.append(chunk) - def append(fp, cid, *data): - data = b"".join(data) - crc = o32(_crc32(data, _crc32(cid))) - fp.append((cid, data, crc)) + def append(fp: collector, cid: bytes, *data: bytes) -> None: + byte_data = b"".join(data) + crc = o32(_crc32(byte_data, _crc32(cid))) + fp.append((cid, byte_data, crc)) fp = collector() try: im.encoderinfo = params - _save(im, fp, None, append) + _save(im, fp, "", append) finally: del im.encoderinfo diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index b89144803..253f64852 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -334,12 +334,13 @@ class IFDRational(Rational): __slots__ = ("_numerator", "_denominator", "_val") - def __init__(self, value, denominator=1): + def __init__(self, value, denominator: int = 1) -> None: """ :param value: either an integer numerator, a float/rational/other number, or an IFDRational :param denominator: Optional integer denominator """ + self._val: Fraction | float if isinstance(value, IFDRational): self._numerator = value.numerator self._denominator = value.denominator @@ -636,13 +637,13 @@ class ImageFileDirectory_v2(_IFDv2Base): val = (val,) return val - def __contains__(self, tag): + def __contains__(self, tag: object) -> bool: return tag in self._tags_v2 or tag in self._tagdata - def __setitem__(self, tag, value): + def __setitem__(self, tag, value) -> None: self._setitem(tag, value, self.legacy_api) - def _setitem(self, tag, value, legacy_api): + def _setitem(self, tag, value, legacy_api) -> None: basetypes = (Number, bytes, str) info = TiffTags.lookup(tag, self.group) @@ -758,7 +759,7 @@ class ImageFileDirectory_v2(_IFDv2Base): return data @_register_writer(1) # Basic type, except for the legacy API. - def write_byte(self, data): + def write_byte(self, data) -> bytes: if isinstance(data, IFDRational): data = int(data) if isinstance(data, int): @@ -772,7 +773,7 @@ class ImageFileDirectory_v2(_IFDv2Base): return data.decode("latin-1", "replace") @_register_writer(2) - def write_string(self, value): + def write_string(self, value) -> bytes: # remerge of https://github.com/python-pillow/Pillow/pull/1416 if isinstance(value, int): value = str(value) @@ -790,7 +791,7 @@ class ImageFileDirectory_v2(_IFDv2Base): return tuple(combine(num, denom) for num, denom in zip(vals[::2], vals[1::2])) @_register_writer(5) - def write_rational(self, *values): + def write_rational(self, *values) -> bytes: return b"".join( self._pack("2L", *_limit_rational(frac, 2**32 - 1)) for frac in values ) @@ -800,7 +801,7 @@ class ImageFileDirectory_v2(_IFDv2Base): return data @_register_writer(7) - def write_undefined(self, value): + def write_undefined(self, value) -> bytes: if isinstance(value, IFDRational): value = int(value) if isinstance(value, int): @@ -817,13 +818,13 @@ class ImageFileDirectory_v2(_IFDv2Base): return tuple(combine(num, denom) for num, denom in zip(vals[::2], vals[1::2])) @_register_writer(10) - def write_signed_rational(self, *values): + def write_signed_rational(self, *values) -> bytes: return b"".join( self._pack("2l", *_limit_signed_rational(frac, 2**31 - 1, -(2**31))) for frac in values ) - def _ensure_read(self, fp, size): + def _ensure_read(self, fp: IO[bytes], size: int) -> bytes: ret = fp.read(size) if len(ret) != size: msg = ( @@ -977,7 +978,7 @@ class ImageFileDirectory_v2(_IFDv2Base): return result - def save(self, fp): + def save(self, fp: IO[bytes]) -> int: if fp.tell() == 0: # skip TIFF header on subsequent pages # tiff header -- PIL always starts the first IFD at offset 8 fp.write(self._prefix + self._pack("HL", 42, 8)) @@ -1017,7 +1018,7 @@ class ImageFileDirectory_v1(ImageFileDirectory_v2): .. deprecated:: 3.0.0 """ - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self._legacy_api = True @@ -1029,7 +1030,7 @@ class ImageFileDirectory_v1(ImageFileDirectory_v2): """Dictionary of tag types""" @classmethod - def from_v2(cls, original): + def from_v2(cls, original) -> ImageFileDirectory_v1: """Returns an :py:class:`~PIL.TiffImagePlugin.ImageFileDirectory_v1` instance with the same data as is contained in the original @@ -1063,7 +1064,7 @@ class ImageFileDirectory_v1(ImageFileDirectory_v2): ifd._tags_v2 = dict(self._tags_v2) return ifd - def __contains__(self, tag): + def __contains__(self, tag: object) -> bool: return tag in self._tags_v1 or tag in self._tagdata def __len__(self) -> int: @@ -1072,7 +1073,7 @@ class ImageFileDirectory_v1(ImageFileDirectory_v2): def __iter__(self): return iter(set(self._tagdata) | set(self._tags_v1)) - def __setitem__(self, tag, value): + def __setitem__(self, tag, value) -> None: for legacy_api in (False, True): self._setitem(tag, value, legacy_api) @@ -1122,7 +1123,7 @@ class TiffImageFile(ImageFile.ImageFile): self.tag_v2 = ImageFileDirectory_v2(ifh) # legacy IFD entries will be filled in later - self.ifd = None + self.ifd: ImageFileDirectory_v1 | None = None # setup frame pointers self.__first = self.__next = self.tag_v2.next @@ -1343,7 +1344,7 @@ class TiffImageFile(ImageFile.ImageFile): return Image.Image.load(self) - def _setup(self): + def _setup(self) -> None: """Setup this image object based on current tags""" if 0xBC01 in self.tag_v2: @@ -1537,13 +1538,13 @@ class TiffImageFile(ImageFile.ImageFile): # adjust stride width accordingly stride /= bps_count - a = (tile_rawmode, int(stride), 1) + args = (tile_rawmode, int(stride), 1) self.tile.append( ( self._compression, (x, y, min(x + w, xsize), min(y + h, ysize)), offset, - a, + args, ) ) x = x + w @@ -1938,7 +1939,7 @@ class AppendingTiffWriter: 521, # JPEGACTables } - def __init__(self, fn, new=False): + def __init__(self, fn, new: bool = False) -> None: if hasattr(fn, "read"): self.f = fn self.close_fp = False @@ -2015,7 +2016,7 @@ class AppendingTiffWriter: def tell(self) -> int: return self.f.tell() - self.offsetOfNewPage - def seek(self, offset, whence=io.SEEK_SET): + def seek(self, offset: int, whence=io.SEEK_SET) -> int: if whence == os.SEEK_SET: offset += self.offsetOfNewPage diff --git a/src/PIL/WalImageFile.py b/src/PIL/WalImageFile.py index 895d5616a..ec5c74900 100644 --- a/src/PIL/WalImageFile.py +++ b/src/PIL/WalImageFile.py @@ -24,8 +24,11 @@ and has been tested with a few sample files found using google. """ from __future__ import annotations +from typing import IO + from . import Image, ImageFile from ._binary import i32le as i32 +from ._typing import StrOrBytesPath class WalImageFile(ImageFile.ImageFile): @@ -58,7 +61,7 @@ class WalImageFile(ImageFile.ImageFile): return Image.Image.load(self) -def open(filename): +def open(filename: StrOrBytesPath | IO[bytes]) -> WalImageFile: """ Load texture from a Quake2 WAL texture file. From f5313db9ce5f800de29cb40af12c0722ecbe7173 Mon Sep 17 00:00:00 2001 From: Lysandros Nikolaou Date: Sat, 13 Jul 2024 11:00:57 +0200 Subject: [PATCH 25/30] Add necessary PyMem_Free and fix PyDict_GetItemRef call --- src/_imagingft.c | 2 +- src/encode.c | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/_imagingft.c b/src/_imagingft.c index ddcf28f97..eb04945f0 100644 --- a/src/_imagingft.c +++ b/src/_imagingft.c @@ -1243,8 +1243,8 @@ font_getvarnames(FontObject *self) { } } + PyMem_Free(list_names_filled); FT_Done_MM_Var(library, master); - return list_names; } diff --git a/src/encode.c b/src/encode.c index 620774f67..82aed7783 100644 --- a/src/encode.c +++ b/src/encode.c @@ -723,8 +723,8 @@ PyImaging_LibTiffEncoderNew(PyObject *self, PyObject *args) { if (!is_core_tag) { PyObject *tag_type; - if (PyDict_GetItemRef(types, key, &tag_type) == 0) { - PyErr_SetString(PyExc_KeyError, "unknown tag type"); + if (PyDict_GetItemRef(types, key, &tag_type) < 0) { + return NULL; // Exception has been already set } if (tag_type) { int type_int = PyLong_AsLong(tag_type); From 9c576d63c3e79964d12ebe07f43144db735cdd35 Mon Sep 17 00:00:00 2001 From: Lysandros Nikolaou Date: Sat, 13 Jul 2024 12:24:02 +0200 Subject: [PATCH 26/30] Fix refcounts after porting to GetItemRef & better error checking --- src/_imaging.c | 5 +++++ src/_imagingft.c | 35 ++++++++++++++++++++++++++++------- src/encode.c | 16 ++++++++++++++-- src/path.c | 12 +++++++++--- 4 files changed, 56 insertions(+), 12 deletions(-) diff --git a/src/_imaging.c b/src/_imaging.c index 03e10e547..ac6310a44 100644 --- a/src/_imaging.c +++ b/src/_imaging.c @@ -2251,6 +2251,11 @@ _getcolors(ImagingObject *self, PyObject *args) { ImagingColorItem *v = &items[i]; PyObject *item = Py_BuildValue( "iN", v->count, getpixel(self->image, self->access, v->x, v->y)); + if (item == NULL) { + Py_DECREF(out); + free(items); + return NULL; + } PyList_SetItem(out, i, item); } } diff --git a/src/_imagingft.c b/src/_imagingft.c index eb04945f0..6c2885c61 100644 --- a/src/_imagingft.c +++ b/src/_imagingft.c @@ -1205,9 +1205,15 @@ font_getvarnames(FontObject *self) { num_namedstyles = master->num_namedstyles; list_names = PyList_New(num_namedstyles); + if (list_names == NULL) { + FT_Done_MM_Var(library, master); + return NULL; + } int *list_names_filled = PyMem_Malloc(num_namedstyles * sizeof(int)); if (list_names_filled == NULL) { + Py_DECREF(list_names); + FT_Done_MM_Var(library, master); return PyErr_NoMemory(); } @@ -1215,15 +1221,11 @@ font_getvarnames(FontObject *self) { list_names_filled[i] = 0; } - if (list_names == NULL) { - FT_Done_MM_Var(library, master); - return NULL; - } - name_count = FT_Get_Sfnt_Name_Count(self->face); for (i = 0; i < name_count; i++) { error = FT_Get_Sfnt_Name(self->face, i, &name); if (error) { + PyMem_Free(list_names_filled); Py_DECREF(list_names); FT_Done_MM_Var(library, master); return geterror(error); @@ -1236,6 +1238,12 @@ font_getvarnames(FontObject *self) { if (master->namedstyle[j].strid == name.name_id) { list_name = Py_BuildValue("y#", name.string, name.string_len); + if (list_name == NULL) { + PyMem_Free(list_names_filled); + Py_DECREF(list_names); + FT_Done_MM_Var(library, master); + return NULL; + } PyList_SetItem(list_names, j, list_name); list_names_filled[j] = 1; break; @@ -1301,9 +1309,15 @@ font_getvaraxes(FontObject *self) { if (name.name_id == axis.strid) { axis_name = Py_BuildValue("y#", name.string, name.string_len); + if (axis_name == NULL) { + Py_DECREF(list_axis); + Py_DECREF(list_axes); + FT_Done_MM_Var(library, master); + return NULL; + } PyDict_SetItemString( list_axis, "name", axis_name ? axis_name : Py_None); - Py_XDECREF(axis_name); + Py_DECREF(axis_name); break; } } @@ -1357,7 +1371,12 @@ font_setvaraxes(FontObject *self, PyObject *args) { return PyErr_NoMemory(); } for (i = 0; i < num_coords; i++) { - item = PyList_GET_ITEM(axes, i); + item = PyList_GetItemRef(axes, i); + if (item == NULL) { + free(coords); + return NULL; + } + if (PyFloat_Check(item)) { coord = PyFloat_AS_DOUBLE(item); } else if (PyLong_Check(item)) { @@ -1365,10 +1384,12 @@ font_setvaraxes(FontObject *self, PyObject *args) { } else if (PyNumber_Check(item)) { coord = PyFloat_AsDouble(item); } else { + Py_DECREF(item); free(coords); PyErr_SetString(PyExc_TypeError, "list must contain numbers"); return NULL; } + Py_DECREF(item); coords[i] = coord * 65536; } diff --git a/src/encode.c b/src/encode.c index 82aed7783..2c95b7ebc 100644 --- a/src/encode.c +++ b/src/encode.c @@ -673,10 +673,16 @@ PyImaging_LibTiffEncoderNew(PyObject *self, PyObject *args) { TRACE(("tags size: %d\n", (int)tags_size)); for (pos = 0; pos < tags_size; pos++) { item = PyList_GetItemRef(tags, pos); + if (item == NULL) { + return NULL; + } + if (!PyTuple_Check(item) || PyTuple_Size(item) != 2) { + Py_DECREF(item); PyErr_SetString(PyExc_ValueError, "Invalid tags list"); return NULL; } + Py_DECREF(item); } pos = 0; } @@ -705,10 +711,16 @@ PyImaging_LibTiffEncoderNew(PyObject *self, PyObject *args) { num_core_tags = sizeof(core_tags) / sizeof(int); for (pos = 0; pos < tags_size; pos++) { item = PyList_GetItemRef(tags, pos); + if (item == NULL) { + return NULL; + } + // We already checked that tags is a 2-tuple list. - key = PyTuple_GetItem(item, 0); + key = PyTuple_GET_ITEM(item, 0); key_int = (int)PyLong_AsLong(key); - value = PyTuple_GetItem(item, 1); + value = PyTuple_GET_ITEM(item, 1); + Py_DECREF(item); + status = 0; is_core_tag = 0; is_var_length = 0; diff --git a/src/path.c b/src/path.c index 6bc90abed..f8a99eb5b 100644 --- a/src/path.c +++ b/src/path.c @@ -179,14 +179,21 @@ PyPath_Flatten(PyObject *data, double **pxy) { } \ free(xy); \ return -1; \ + } \ + if (decref) { \ + Py_DECREF(op); \ } /* Copy table to path array */ if (PyList_Check(data)) { for (i = 0; i < n; i++) { double x, y; - PyObject *op = PyList_GET_ITEM(data, i); - assign_item_to_array(op, 0); + PyObject *op = PyList_GetItemRef(data, i); + if (op == NULL) { + free(xy); + return -1; + } + assign_item_to_array(op, 1); } } else if (PyTuple_Check(data)) { for (i = 0; i < n; i++) { @@ -209,7 +216,6 @@ PyPath_Flatten(PyObject *data, double **pxy) { } } assign_item_to_array(op, 1); - Py_DECREF(op); } } From 8854e4677e0d5d07bfa3d8725e85b681f81a704a Mon Sep 17 00:00:00 2001 From: Lysandros Nikolaou Date: Sat, 13 Jul 2024 12:34:17 +0200 Subject: [PATCH 27/30] Add include --- src/path.c | 1 + 1 file changed, 1 insertion(+) diff --git a/src/path.c b/src/path.c index f8a99eb5b..bd6ad2259 100644 --- a/src/path.c +++ b/src/path.c @@ -26,6 +26,7 @@ */ #include "Python.h" +#include "thirdparty/pythoncapi_compat.h" #include "libImaging/Imaging.h" #include From 01529d8b0979597902d26fcf4c0dc4f3da2a667e Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 15 Jul 2024 19:23:36 +1000 Subject: [PATCH 28/30] Added type hints --- Tests/test_imagewin.py | 3 ++ src/PIL/EpsImagePlugin.py | 2 +- src/PIL/IcoImagePlugin.py | 84 ++++++++++++++++++++------------------ src/PIL/Image.py | 14 +++---- src/PIL/ImageFile.py | 6 ++- src/PIL/ImageQt.py | 11 +++-- src/PIL/ImageWin.py | 28 ++++++++----- src/PIL/IptcImagePlugin.py | 16 ++++---- src/PIL/JpegImagePlugin.py | 10 +++-- src/PIL/PngImagePlugin.py | 56 ++++++++++++++++--------- 10 files changed, 138 insertions(+), 92 deletions(-) diff --git a/Tests/test_imagewin.py b/Tests/test_imagewin.py index b43c31b52..a836bb90b 100644 --- a/Tests/test_imagewin.py +++ b/Tests/test_imagewin.py @@ -57,6 +57,9 @@ class TestImageWinDib: # Assert assert dib.size == (128, 128) + with pytest.raises(ValueError): + ImageWin.Dib(mode) + def test_dib_paste(self) -> None: # Arrange im = hopper() diff --git a/src/PIL/EpsImagePlugin.py b/src/PIL/EpsImagePlugin.py index 59bb8594d..7a73d1f69 100644 --- a/src/PIL/EpsImagePlugin.py +++ b/src/PIL/EpsImagePlugin.py @@ -65,7 +65,7 @@ def has_ghostscript() -> bool: return gs_binary is not False -def Ghostscript(tile, size, fp, scale=1, transparency=False): +def Ghostscript(tile, size, fp, scale=1, transparency: bool = False) -> Image.Image: """Render an image using Ghostscript""" global gs_binary if not has_ghostscript(): diff --git a/src/PIL/IcoImagePlugin.py b/src/PIL/IcoImagePlugin.py index 650f5e4f1..8be1bd316 100644 --- a/src/PIL/IcoImagePlugin.py +++ b/src/PIL/IcoImagePlugin.py @@ -25,7 +25,7 @@ from __future__ import annotations import warnings from io import BytesIO from math import ceil, log -from typing import IO +from typing import IO, NamedTuple from . import BmpImagePlugin, Image, ImageFile, PngImagePlugin from ._binary import i16le as i16 @@ -119,8 +119,22 @@ def _accept(prefix: bytes) -> bool: return prefix[:4] == _MAGIC +class IconHeader(NamedTuple): + width: int + height: int + nb_color: int + reserved: int + planes: int + bpp: int + size: int + offset: int + dim: tuple[int, int] + square: int + color_depth: int + + class IcoFile: - def __init__(self, buf) -> None: + def __init__(self, buf: IO[bytes]) -> None: """ Parse image from file-like object containing ico file data """ @@ -141,51 +155,44 @@ class IcoFile: for i in range(self.nb_items): s = buf.read(16) - icon_header = { - "width": s[0], - "height": s[1], - "nb_color": s[2], # No. of colors in image (0 if >=8bpp) - "reserved": s[3], - "planes": i16(s, 4), - "bpp": i16(s, 6), - "size": i32(s, 8), - "offset": i32(s, 12), - } - # See Wikipedia - for j in ("width", "height"): - if not icon_header[j]: - icon_header[j] = 256 + width = s[0] or 256 + height = s[1] or 256 - # See Wikipedia notes about color depth. - # We need this just to differ images with equal sizes - icon_header["color_depth"] = ( - icon_header["bpp"] - or ( - icon_header["nb_color"] != 0 - and ceil(log(icon_header["nb_color"], 2)) - ) - or 256 + # No. of colors in image (0 if >=8bpp) + nb_color = s[2] + bpp = i16(s, 6) + icon_header = IconHeader( + width=width, + height=height, + nb_color=nb_color, + reserved=s[3], + planes=i16(s, 4), + bpp=i16(s, 6), + size=i32(s, 8), + offset=i32(s, 12), + dim=(width, height), + square=width * height, + # See Wikipedia notes about color depth. + # We need this just to differ images with equal sizes + color_depth=bpp or (nb_color != 0 and ceil(log(nb_color, 2))) or 256, ) - icon_header["dim"] = (icon_header["width"], icon_header["height"]) - icon_header["square"] = icon_header["width"] * icon_header["height"] - self.entry.append(icon_header) - self.entry = sorted(self.entry, key=lambda x: x["color_depth"]) + self.entry = sorted(self.entry, key=lambda x: x.color_depth) # ICO images are usually squares - self.entry = sorted(self.entry, key=lambda x: x["square"], reverse=True) + self.entry = sorted(self.entry, key=lambda x: x.square, reverse=True) def sizes(self) -> set[tuple[int, int]]: """ - Get a list of all available icon sizes and color depths. + Get a set of all available icon sizes and color depths. """ - return {(h["width"], h["height"]) for h in self.entry} + return {(h.width, h.height) for h in self.entry} def getentryindex(self, size: tuple[int, int], bpp: int | bool = False) -> int: for i, h in enumerate(self.entry): - if size == h["dim"] and (bpp is False or bpp == h["color_depth"]): + if size == h.dim and (bpp is False or bpp == h.color_depth): return i return 0 @@ -202,9 +209,9 @@ class IcoFile: header = self.entry[idx] - self.buf.seek(header["offset"]) + self.buf.seek(header.offset) data = self.buf.read(8) - self.buf.seek(header["offset"]) + self.buf.seek(header.offset) im: Image.Image if data[:8] == PngImagePlugin._MAGIC: @@ -222,8 +229,7 @@ class IcoFile: im.tile[0] = d, (0, 0) + im.size, o, a # figure out where AND mask image starts - bpp = header["bpp"] - if 32 == bpp: + if 32 == header.bpp: # 32-bit color depth icon image allows semitransparent areas # PIL's DIB format ignores transparency bits, recover them. # The DIB is packed in BGRX byte order where X is the alpha @@ -253,7 +259,7 @@ class IcoFile: # padded row size * height / bits per char total_bytes = int((w * im.size[1]) / 8) - and_mask_offset = header["offset"] + header["size"] - total_bytes + and_mask_offset = header.offset + header.size - total_bytes self.buf.seek(and_mask_offset) mask_data = self.buf.read(total_bytes) @@ -307,7 +313,7 @@ class IcoImageFile(ImageFile.ImageFile): def _open(self) -> None: self.ico = IcoFile(self.fp) self.info["sizes"] = self.ico.sizes() - self.size = self.ico.entry[0]["dim"] + self.size = self.ico.entry[0].dim self.load() @property diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 565abe71d..9d901e028 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -3286,7 +3286,7 @@ def fromarray(obj: SupportsArrayInterface, mode: str | None = None) -> Image: return frombuffer(mode, size, obj, "raw", rawmode, 0, 1) -def fromqimage(im): +def fromqimage(im) -> ImageFile.ImageFile: """Creates an image instance from a QImage image""" from . import ImageQt @@ -3296,7 +3296,7 @@ def fromqimage(im): return ImageQt.fromqimage(im) -def fromqpixmap(im): +def fromqpixmap(im) -> ImageFile.ImageFile: """Creates an image instance from a QPixmap image""" from . import ImageQt @@ -3867,7 +3867,7 @@ class Exif(_ExifBase): # returns a dict with any single item tuples/lists as individual values return {k: self._fixup(v) for k, v in src_dict.items()} - def _get_ifd_dict(self, offset, group=None): + def _get_ifd_dict(self, offset: int, group=None): try: # an offset pointer to the location of the nested embedded IFD. # It should be a long, but may be corrupted. @@ -3881,7 +3881,7 @@ class Exif(_ExifBase): info.load(self.fp) return self._fixup_dict(info) - def _get_head(self): + def _get_head(self) -> bytes: version = b"\x2B" if self.bigtiff else b"\x2A" if self.endian == "<": head = b"II" + version + b"\x00" + o32le(8) @@ -4102,16 +4102,16 @@ class Exif(_ExifBase): keys.update(self._info) return len(keys) - def __getitem__(self, tag): + def __getitem__(self, tag: int): if self._info is not None and tag not in self._data and tag in self._info: self._data[tag] = self._fixup(self._info[tag]) del self._info[tag] return self._data[tag] - def __contains__(self, tag) -> bool: + def __contains__(self, tag: object) -> bool: return tag in self._data or (self._info is not None and tag in self._info) - def __setitem__(self, tag, value) -> None: + def __setitem__(self, tag: int, value) -> None: if self._info is not None and tag in self._info: del self._info[tag] self._data[tag] = value diff --git a/src/PIL/ImageFile.py b/src/PIL/ImageFile.py index 6b2953451..e4a7dba44 100644 --- a/src/PIL/ImageFile.py +++ b/src/PIL/ImageFile.py @@ -553,7 +553,9 @@ def _save(im, fp, tile, bufsize: int = 0) -> None: fp.flush() -def _encode_tile(im, fp, tile: list[_Tile], bufsize: int, fh, exc=None) -> None: +def _encode_tile( + im, fp: IO[bytes], tile: list[_Tile], bufsize: int, fh, exc=None +) -> None: for encoder_name, extents, offset, args in tile: if offset > 0: fp.seek(offset) @@ -653,7 +655,7 @@ class PyCodec: """ pass - def setfd(self, fd) -> None: + def setfd(self, fd: IO[bytes]) -> None: """ Called from ImageFile to set the Python file-like object diff --git a/src/PIL/ImageQt.py b/src/PIL/ImageQt.py index 35a37760c..346fe49d3 100644 --- a/src/PIL/ImageQt.py +++ b/src/PIL/ImageQt.py @@ -19,11 +19,14 @@ from __future__ import annotations import sys from io import BytesIO -from typing import Callable +from typing import TYPE_CHECKING, Callable from . import Image from ._util import is_path +if TYPE_CHECKING: + from . import ImageFile + qt_version: str | None qt_versions = [ ["6", "PyQt6"], @@ -90,11 +93,11 @@ def fromqimage(im): return Image.open(b) -def fromqpixmap(im): +def fromqpixmap(im) -> ImageFile.ImageFile: return fromqimage(im) -def align8to32(bytes, width, mode): +def align8to32(bytes: bytes, width: int, mode: str) -> bytes: """ converts each scanline of data from 8 bit to 32 bit aligned """ @@ -172,7 +175,7 @@ def _toqclass_helper(im): if qt_is_installed: class ImageQt(QImage): - def __init__(self, im): + def __init__(self, im) -> None: """ An PIL image wrapper for Qt. This is a subclass of PyQt's QImage class. diff --git a/src/PIL/ImageWin.py b/src/PIL/ImageWin.py index 978c5a9d1..4f9956087 100644 --- a/src/PIL/ImageWin.py +++ b/src/PIL/ImageWin.py @@ -70,11 +70,14 @@ class Dib: """ def __init__( - self, image: Image.Image | str, size: tuple[int, int] | list[int] | None = None + self, image: Image.Image | str, size: tuple[int, int] | None = None ) -> None: if isinstance(image, str): mode = image image = "" + if size is None: + msg = "If first argument is mode, size is required" + raise ValueError(msg) else: mode = image.mode size = image.size @@ -105,7 +108,12 @@ class Dib: result = self.image.expose(handle) return result - def draw(self, handle, dst, src=None): + def draw( + self, + handle, + dst: tuple[int, int, int, int], + src: tuple[int, int, int, int] | None = None, + ): """ Same as expose, but allows you to specify where to draw the image, and what part of it to draw. @@ -115,7 +123,7 @@ class Dib: the destination have different sizes, the image is resized as necessary. """ - if not src: + if src is None: src = (0, 0) + self.size if isinstance(handle, HWND): dc = self.image.getdc(handle) @@ -202,22 +210,22 @@ class Window: title, self.__dispatcher, width or 0, height or 0 ) - def __dispatcher(self, action, *args): + def __dispatcher(self, action: str, *args): return getattr(self, f"ui_handle_{action}")(*args) - def ui_handle_clear(self, dc, x0, y0, x1, y1): + def ui_handle_clear(self, dc, x0, y0, x1, y1) -> None: pass - def ui_handle_damage(self, x0, y0, x1, y1): + def ui_handle_damage(self, x0, y0, x1, y1) -> None: pass def ui_handle_destroy(self) -> None: pass - def ui_handle_repair(self, dc, x0, y0, x1, y1): + def ui_handle_repair(self, dc, x0, y0, x1, y1) -> None: pass - def ui_handle_resize(self, width, height): + def ui_handle_resize(self, width, height) -> None: pass def mainloop(self) -> None: @@ -227,12 +235,12 @@ class Window: class ImageWindow(Window): """Create an image window which displays the given image.""" - def __init__(self, image, title="PIL"): + def __init__(self, image, title: str = "PIL") -> None: if not isinstance(image, Dib): image = Dib(image) self.image = image width, height = image.size super().__init__(title, width=width, height=height) - def ui_handle_repair(self, dc, x0, y0, x1, y1): + def ui_handle_repair(self, dc, x0, y0, x1, y1) -> None: self.image.draw(dc, (x0, y0, x1, y1)) diff --git a/src/PIL/IptcImagePlugin.py b/src/PIL/IptcImagePlugin.py index a04616fbd..16a18ddfa 100644 --- a/src/PIL/IptcImagePlugin.py +++ b/src/PIL/IptcImagePlugin.py @@ -18,6 +18,7 @@ from __future__ import annotations from collections.abc import Sequence from io import BytesIO +from typing import cast from . import Image, ImageFile from ._binary import i16be as i16 @@ -184,7 +185,7 @@ Image.register_open(IptcImageFile.format, IptcImageFile) Image.register_extension(IptcImageFile.format, ".iim") -def getiptcinfo(im): +def getiptcinfo(im: ImageFile.ImageFile): """ Get IPTC information from TIFF, JPEG, or IPTC file. @@ -221,16 +222,17 @@ def getiptcinfo(im): class FakeImage: pass - im = FakeImage() - im.__class__ = IptcImageFile + fake_im = FakeImage() + fake_im.__class__ = IptcImageFile # type: ignore[assignment] + iptc_im = cast(IptcImageFile, fake_im) # parse the IPTC information chunk - im.info = {} - im.fp = BytesIO(data) + iptc_im.info = {} + iptc_im.fp = BytesIO(data) try: - im._open() + iptc_im._open() except (IndexError, KeyError): pass # expected failure - return im.info + return iptc_im.info diff --git a/src/PIL/JpegImagePlugin.py b/src/PIL/JpegImagePlugin.py index 54f756014..af24faa5d 100644 --- a/src/PIL/JpegImagePlugin.py +++ b/src/PIL/JpegImagePlugin.py @@ -685,7 +685,11 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: raise ValueError(msg) subsampling = get_sampling(im) - def validate_qtables(qtables): + def validate_qtables( + qtables: ( + str | tuple[list[int], ...] | list[list[int]] | dict[int, list[int]] | None + ) + ) -> list[list[int]] | None: if qtables is None: return qtables if isinstance(qtables, str): @@ -715,12 +719,12 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if len(table) != 64: msg = "Invalid quantization table" raise TypeError(msg) - table = array.array("H", table) + table_array = array.array("H", table) except TypeError as e: msg = "Invalid quantization table" raise ValueError(msg) from e else: - qtables[idx] = list(table) + qtables[idx] = list(table_array) return qtables if qtables == "keep": diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index 6990b6d05..247f908ed 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, TYPE_CHECKING, Any, NoReturn +from typing import IO, TYPE_CHECKING, Any, NamedTuple, NoReturn from . import Image, ImageChops, ImageFile, ImagePalette, ImageSequence from ._binary import i16be as i16 @@ -1126,7 +1126,21 @@ class _fdat: self.seq_num += 1 -def _write_multiple_frames(im, fp, chunk, mode, rawmode, default_image, append_images): +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], + chunk, + mode: str, + rawmode: str, + default_image: Image.Image | None, + append_images: list[Image.Image], +) -> Image.Image | None: duration = im.encoderinfo.get("duration") loop = im.encoderinfo.get("loop", im.info.get("loop", 0)) disposal = im.encoderinfo.get("disposal", im.info.get("disposal", Disposal.OP_NONE)) @@ -1137,7 +1151,7 @@ def _write_multiple_frames(im, fp, chunk, mode, rawmode, default_image, append_i else: chain = itertools.chain([im], append_images) - im_frames = [] + im_frames: list[_Frame] = [] frame_count = 0 for im_seq in chain: for im_frame in ImageSequence.Iterator(im_seq): @@ -1158,24 +1172,24 @@ def _write_multiple_frames(im, fp, chunk, mode, rawmode, default_image, append_i if im_frames: previous = im_frames[-1] - prev_disposal = previous["encoderinfo"].get("disposal") - prev_blend = previous["encoderinfo"].get("blend") + prev_disposal = previous.encoderinfo.get("disposal") + prev_blend = previous.encoderinfo.get("blend") if prev_disposal == Disposal.OP_PREVIOUS and len(im_frames) < 2: prev_disposal = Disposal.OP_BACKGROUND if prev_disposal == Disposal.OP_BACKGROUND: - base_im = previous["im"].copy() + base_im = previous.im.copy() dispose = Image.core.fill("RGBA", im.size, (0, 0, 0, 0)) - bbox = previous["bbox"] + bbox = previous.bbox if bbox: dispose = dispose.crop(bbox) else: bbox = (0, 0) + im.size base_im.paste(dispose, bbox) elif prev_disposal == Disposal.OP_PREVIOUS: - base_im = im_frames[-2]["im"] + base_im = im_frames[-2].im else: - base_im = previous["im"] + base_im = previous.im delta = ImageChops.subtract_modulo( im_frame.convert("RGBA"), base_im.convert("RGBA") ) @@ -1186,14 +1200,14 @@ def _write_multiple_frames(im, fp, chunk, mode, rawmode, default_image, append_i and prev_blend == encoderinfo.get("blend") and "duration" in encoderinfo ): - previous["encoderinfo"]["duration"] += encoderinfo["duration"] + previous.encoderinfo["duration"] += encoderinfo["duration"] continue else: bbox = None - im_frames.append({"im": im_frame, "bbox": bbox, "encoderinfo": encoderinfo}) + im_frames.append(_Frame(im_frame, bbox, encoderinfo)) if len(im_frames) == 1 and not default_image: - return im_frames[0]["im"] + return im_frames[0].im # animation control chunk( @@ -1211,14 +1225,14 @@ def _write_multiple_frames(im, fp, chunk, mode, rawmode, default_image, append_i seq_num = 0 for frame, frame_data in enumerate(im_frames): - im_frame = frame_data["im"] - if not frame_data["bbox"]: + im_frame = frame_data.im + if not frame_data.bbox: bbox = (0, 0) + im_frame.size else: - bbox = frame_data["bbox"] + bbox = frame_data.bbox im_frame = im_frame.crop(bbox) size = im_frame.size - encoderinfo = frame_data["encoderinfo"] + encoderinfo = frame_data.encoderinfo frame_duration = int(round(encoderinfo.get("duration", 0))) frame_disposal = encoderinfo.get("disposal", disposal) frame_blend = encoderinfo.get("blend", blend) @@ -1253,6 +1267,7 @@ def _write_multiple_frames(im, fp, chunk, mode, rawmode, default_image, append_i [("zip", (0, 0) + im_frame.size, 0, rawmode)], ) seq_num = fdat_chunks.seq_num + return None def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: @@ -1437,12 +1452,15 @@ def _save( exif = exif[6:] chunk(fp, b"eXIf", exif) + single_im: Image.Image | None = im if save_all: - im = _write_multiple_frames( + single_im = _write_multiple_frames( im, fp, chunk, mode, rawmode, default_image, append_images ) - if im: - ImageFile._save(im, _idat(fp, chunk), [("zip", (0, 0) + im.size, 0, rawmode)]) + if single_im: + ImageFile._save( + single_im, _idat(fp, chunk), [("zip", (0, 0) + single_im.size, 0, rawmode)] + ) if info: for info_chunk in info.chunks: From 76e5e12f9855e59eaf393c40e68f4e737f1175fd Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 15 Jul 2024 20:48:39 +1000 Subject: [PATCH 29/30] Simplified code --- src/_imagingft.c | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/_imagingft.c b/src/_imagingft.c index 6c2885c61..c6d20fe45 100644 --- a/src/_imagingft.c +++ b/src/_imagingft.c @@ -1315,8 +1315,7 @@ font_getvaraxes(FontObject *self) { FT_Done_MM_Var(library, master); return NULL; } - PyDict_SetItemString( - list_axis, "name", axis_name ? axis_name : Py_None); + PyDict_SetItemString(list_axis, "name", axis_name); Py_DECREF(axis_name); break; } From 3eeef83517b12d4e7af826b3aa0f6ebc82faf1fa Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Tue, 16 Jul 2024 16:40:17 +1000 Subject: [PATCH 30/30] Updated condition Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> --- src/PIL/IcoImagePlugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PIL/IcoImagePlugin.py b/src/PIL/IcoImagePlugin.py index 8be1bd316..c891024f5 100644 --- a/src/PIL/IcoImagePlugin.py +++ b/src/PIL/IcoImagePlugin.py @@ -229,7 +229,7 @@ class IcoFile: im.tile[0] = d, (0, 0) + im.size, o, a # figure out where AND mask image starts - if 32 == header.bpp: + if header.bpp == 32: # 32-bit color depth icon image allows semitransparent areas # PIL's DIB format ignores transparency bits, recover them. # The DIB is packed in BGRX byte order where X is the alpha