From 2059e060051acd2024360834da435a266d9dc665 Mon Sep 17 00:00:00 2001 From: wiredfool Date: Fri, 30 May 2025 09:56:47 +0100 Subject: [PATCH 01/41] Add parallel compile from pybind11 --- pyproject.toml | 1 + setup.py | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 683ab24ef..ae4b70990 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,7 @@ [build-system] build-backend = "backend" requires = [ + "pybind11", "setuptools>=77", ] backend-path = [ diff --git a/setup.py b/setup.py index ab36c6b17..ec6b47b1c 100644 --- a/setup.py +++ b/setup.py @@ -18,9 +18,12 @@ import warnings from collections.abc import Iterator from typing import Any +from pybind11.setup_helpers import ParallelCompile from setuptools import Extension, setup from setuptools.command.build_ext import build_ext +ParallelCompile("MAX_CONCURRENCY", default=0).install() + def get_version() -> str: version_file = "src/PIL/_version.py" @@ -1048,12 +1051,12 @@ ext_modules = [ Extension("PIL._imagingmorph", ["src/_imagingmorph.c"]), ] - # parse configuration from _custom_build/backend.py while sys.argv[-1].startswith("--pillow-configuration="): _, key, value = sys.argv.pop().split("=", 2) configuration.setdefault(key, []).append(value) + try: setup( cmdclass={"build_ext": pil_build_ext}, From b931402046f840bedc09b3c2b0c4039ac28531dc Mon Sep 17 00:00:00 2001 From: Eric Soroos Date: Sat, 31 May 2025 15:14:17 +0200 Subject: [PATCH 02/41] add pybind11 elsewhere so mypy can find it --- .ci/requirements-mypy.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/.ci/requirements-mypy.txt b/.ci/requirements-mypy.txt index 86ac2e0b2..645605aa6 100644 --- a/.ci/requirements-mypy.txt +++ b/.ci/requirements-mypy.txt @@ -5,6 +5,7 @@ ipython numpy packaging pyarrow-stubs +pybind11 pytest sphinx types-atheris From 2316c930f9b2985d27894f043f5f2e4787543dca Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 19 Jun 2025 22:46:09 +1000 Subject: [PATCH 03/41] Removed default argument --- setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index ec6b47b1c..233811192 100644 --- a/setup.py +++ b/setup.py @@ -22,7 +22,7 @@ from pybind11.setup_helpers import ParallelCompile from setuptools import Extension, setup from setuptools.command.build_ext import build_ext -ParallelCompile("MAX_CONCURRENCY", default=0).install() +ParallelCompile("MAX_CONCURRENCY").install() def get_version() -> str: @@ -1051,12 +1051,12 @@ ext_modules = [ Extension("PIL._imagingmorph", ["src/_imagingmorph.c"]), ] + # parse configuration from _custom_build/backend.py while sys.argv[-1].startswith("--pillow-configuration="): _, key, value = sys.argv.pop().split("=", 2) configuration.setdefault(key, []).append(value) - try: setup( cmdclass={"build_ext": pil_build_ext}, From ecd264fffc680ab05da6a71ff4466c774185ab90 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 21 Jun 2025 22:22:46 +1000 Subject: [PATCH 04/41] Use "parallel" config setting and 4 as defaults --- setup.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/setup.py b/setup.py index 1134879be..6c2180ebd 100644 --- a/setup.py +++ b/setup.py @@ -21,7 +21,15 @@ from pybind11.setup_helpers import ParallelCompile from setuptools import Extension, setup from setuptools.command.build_ext import build_ext -ParallelCompile("MAX_CONCURRENCY").install() +configuration: dict[str, list[str]] = {} + +# parse configuration from _custom_build/backend.py +while sys.argv[-1].startswith("--pillow-configuration="): + _, key, value = sys.argv.pop().split("=", 2) + configuration.setdefault(key, []).append(value) + +default = int(configuration.get("parallel", ["4"])[-1]) +ParallelCompile("MAX_CONCURRENCY", default).install() def get_version() -> str: @@ -30,9 +38,6 @@ def get_version() -> str: return f.read().split('"')[1] -configuration: dict[str, list[str]] = {} - - PILLOW_VERSION = get_version() AVIF_ROOT = None FREETYPE_ROOT = None @@ -1047,11 +1052,6 @@ ext_modules = [ ] -# parse configuration from _custom_build/backend.py -while sys.argv[-1].startswith("--pillow-configuration="): - _, key, value = sys.argv.pop().split("=", 2) - configuration.setdefault(key, []).append(value) - try: setup( cmdclass={"build_ext": pil_build_ext}, From 23ed906b622e25466981fa7c2b80f2a1da612661 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 25 Jun 2025 22:00:36 +1000 Subject: [PATCH 05/41] Removed default limit of 4 --- docs/installation/building-from-source.rst | 5 ++--- setup.py | 6 ++---- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/docs/installation/building-from-source.rst b/docs/installation/building-from-source.rst index 8988a92ce..4c114a5e2 100644 --- a/docs/installation/building-from-source.rst +++ b/docs/installation/building-from-source.rst @@ -276,10 +276,9 @@ Build options * Config setting: ``-C parallel=n``. Can also be given with environment variable: ``MAX_CONCURRENCY=n``. Pillow can use - multiprocessing to build the extension. Setting ``-C parallel=n`` + multiprocessing to build the extensions. Setting ``-C parallel=n`` sets the number of CPUs to use to ``n``, or can disable parallel building by - using a setting of 1. By default, it uses 4 CPUs, or if 4 are not - available, as many as are present. + using a setting of 1. By default, it uses as many CPUs as are present. * Config settings: ``-C zlib=disable``, ``-C jpeg=disable``, ``-C tiff=disable``, ``-C freetype=disable``, ``-C raqm=disable``, diff --git a/setup.py b/setup.py index 6c2180ebd..aee1b04eb 100644 --- a/setup.py +++ b/setup.py @@ -28,7 +28,7 @@ while sys.argv[-1].startswith("--pillow-configuration="): _, key, value = sys.argv.pop().split("=", 2) configuration.setdefault(key, []).append(value) -default = int(configuration.get("parallel", ["4"])[-1]) +default = int(configuration.get("parallel", ["0"])[-1]) ParallelCompile("MAX_CONCURRENCY", default).install() @@ -394,9 +394,7 @@ class pil_build_ext(build_ext): cpu_count = os.cpu_count() if cpu_count is not None: try: - self.parallel = int( - os.environ.get("MAX_CONCURRENCY", min(4, cpu_count)) - ) + self.parallel = int(os.environ.get("MAX_CONCURRENCY", cpu_count)) except TypeError: pass for x in self.feature: From 756dd04705be059136a77ba473e1e708a52711fa Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 7 Jul 2025 19:09:39 +1000 Subject: [PATCH 06/41] Removed reference to libtiff 3.x --- docs/installation/building-from-source.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/installation/building-from-source.rst b/docs/installation/building-from-source.rst index 8988a92ce..45cf5295c 100644 --- a/docs/installation/building-from-source.rst +++ b/docs/installation/building-from-source.rst @@ -44,7 +44,7 @@ Many of Pillow's features require external libraries: * **libtiff** provides compressed TIFF functionality - * Pillow has been tested with libtiff versions **3.x** and **4.0-4.7.0** + * Pillow has been tested with libtiff versions **4.0-4.7.0** * **libfreetype** provides type related services From 14b0cebfc1c1acb0de44520c63de7294be1d59a5 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 7 Jul 2025 17:16:48 +0000 Subject: [PATCH 07/41] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.12.0 → v0.12.2](https://github.com/astral-sh/ruff-pre-commit/compare/v0.12.0...v0.12.2) - [github.com/PyCQA/bandit: 1.8.5 → 1.8.6](https://github.com/PyCQA/bandit/compare/1.8.5...1.8.6) - [github.com/pre-commit/mirrors-clang-format: v20.1.6 → v20.1.7](https://github.com/pre-commit/mirrors-clang-format/compare/v20.1.6...v20.1.7) - [github.com/python-jsonschema/check-jsonschema: 0.33.1 → 0.33.2](https://github.com/python-jsonschema/check-jsonschema/compare/0.33.1...0.33.2) - [github.com/woodruffw/zizmor-pre-commit: v1.9.0 → v1.11.0](https://github.com/woodruffw/zizmor-pre-commit/compare/v1.9.0...v1.11.0) --- .pre-commit-config.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d5fd964f1..75c7d3632 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.12.0 + rev: v0.12.2 hooks: - id: ruff-check args: [--exit-non-zero-on-fix] @@ -11,7 +11,7 @@ repos: - id: black - repo: https://github.com/PyCQA/bandit - rev: 1.8.5 + rev: 1.8.6 hooks: - id: bandit args: [--severity-level=high] @@ -24,7 +24,7 @@ repos: exclude: (Makefile$|\.bat$|\.cmake$|\.eps$|\.fits$|\.gd$|\.opt$|\.patch$) - repo: https://github.com/pre-commit/mirrors-clang-format - rev: v20.1.6 + rev: v20.1.7 hooks: - id: clang-format types: [c] @@ -51,14 +51,14 @@ repos: exclude: ^.github/.*TEMPLATE|^Tests/(fonts|images)/|\.patch$ - repo: https://github.com/python-jsonschema/check-jsonschema - rev: 0.33.1 + rev: 0.33.2 hooks: - id: check-github-workflows - id: check-readthedocs - id: check-renovate - repo: https://github.com/woodruffw/zizmor-pre-commit - rev: v1.9.0 + rev: v1.11.0 hooks: - id: zizmor From 4cfef00574803a64fbab26d2400fe1f39521cbbc Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 5 Jul 2025 16:33:22 +1000 Subject: [PATCH 08/41] Added "Colors" to concepts --- docs/handbook/concepts.rst | 22 ++++++++++++++++++++++ docs/reference/ImageDraw.rst | 4 +--- docs/reference/PixelAccess.rst | 2 +- src/PIL/Image.py | 24 +++++++++++++----------- 4 files changed, 37 insertions(+), 15 deletions(-) diff --git a/docs/handbook/concepts.rst b/docs/handbook/concepts.rst index c9d3f5e91..46f612be3 100644 --- a/docs/handbook/concepts.rst +++ b/docs/handbook/concepts.rst @@ -101,6 +101,28 @@ Palette The palette mode (``P``) uses a color palette to define the actual color for each pixel. +.. _colors: + +Colors +------ + +To specify colors, you can use tuples with a value for each channel in the image, e.g. +``Image.new("RGB", (1, 1), (255, 0, 0))``. + +If an image has a single channel, you can use a single number instead, e.g. +``Image.new("L", (1, 1), 255)``. For "F" mode images, floating point values are also +accepted. In the case of "P" mode images, these will be indexes for the color palette. + +If a single value is used for an image with more than one channel, it will still be +parsed:: + + >>> from PIL import Image + >>> im = Image.new("RGBA", (1, 1), 0x04030201) + >>> im.getpixel((0, 0)) + (1, 2, 3, 4) + +Some methods accept other forms, such as color names. See :ref:`color-names`. + Info ---- diff --git a/docs/reference/ImageDraw.rst b/docs/reference/ImageDraw.rst index 6e73233a1..4a2223a40 100644 --- a/docs/reference/ImageDraw.rst +++ b/docs/reference/ImageDraw.rst @@ -45,9 +45,7 @@ Colors ^^^^^^ To specify colors, you can use numbers or tuples just as you would use with -:py:meth:`PIL.Image.new` or :py:meth:`PIL.Image.Image.putpixel`. For “1”, -“L”, and “I” images, use integers. For “RGB” images, use a 3-tuple containing -integer values. For “F” images, use integer or floating point values. +:py:meth:`PIL.Image.new`. See :ref:`colors` for more information. For palette images (mode “P”), use integers as color indexes. In 1.1.4 and later, you can also use RGB 3-tuples or color names (see below). The drawing diff --git a/docs/reference/PixelAccess.rst b/docs/reference/PixelAccess.rst index 9d7cf83b6..e4af94b9f 100644 --- a/docs/reference/PixelAccess.rst +++ b/docs/reference/PixelAccess.rst @@ -59,7 +59,7 @@ Access using negative indexes is also possible. :: Modifies the pixel at x,y. The color is given as a single numerical value for single band images, and a tuple for - multi-band images. + multi-band images. See :ref:`colors` for more information. :param xy: The pixel coordinate, given as (x, y). :param color: The pixel value according to its mode, diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 59168f5e3..262b5478b 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -1730,9 +1730,10 @@ class Image: details). Instead of an image, the source can be a integer or tuple - containing pixel values. The method then fills the region - with the given color. When creating RGB images, you can - also use color strings as supported by the ImageColor module. + containing pixel values. The method then fills the region + with the given color. When creating RGB images, you can + also use color strings as supported by the ImageColor module. See + :ref:`colors` for more information. If a mask is given, this method updates only the regions indicated by the mask. You can use either "1", "L", "LA", "RGBA" @@ -1988,7 +1989,8 @@ class Image: sequence ends. The scale and offset values are used to adjust the sequence values: **pixel = value*scale + offset**. - :param data: A flattened sequence object. + :param data: A flattened sequence object. See :ref:`colors` for more + information about values. :param scale: An optional scale value. The default is 1.0. :param offset: An optional offset value. The default is 0.0. """ @@ -2047,7 +2049,7 @@ class Image: Modifies the pixel at the given position. The color is given as a single numerical value for single-band images, and a tuple for multi-band images. In addition to this, RGB and RGBA tuples are - accepted for P and PA images. + accepted for P and PA images. See :ref:`colors` for more information. Note that this method is relatively slow. For more extensive changes, use :py:meth:`~PIL.Image.Image.paste` or the :py:mod:`~PIL.ImageDraw` @@ -3055,12 +3057,12 @@ def new( :param mode: The mode to use for the new image. See: :ref:`concept-modes`. :param size: A 2-tuple, containing (width, height) in pixels. - :param color: What color to use for the image. Default is black. - If given, this should be a single integer or floating point value - for single-band modes, and a tuple for multi-band modes (one value - per band). When creating RGB or HSV images, you can also use color - strings as supported by the ImageColor module. If the color is - None, the image is not initialised. + :param color: What color to use for the image. Default is black. If given, + this should be a single integer or floating point value for single-band + modes, and a tuple for multi-band modes (one value per band). When + creating RGB or HSV images, you can also use color strings as supported + by the ImageColor module. See :ref:`colors` for more information. If the + color is None, the image is not initialised. :returns: An :py:class:`~PIL.Image.Image` object. """ From e88f3120291cc208d8d1b46e3766fdbc1cfada82 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 5 Jul 2025 12:57:07 +1000 Subject: [PATCH 09/41] Fix unclosed file warning --- Tests/test_file_libtiff.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py index c245a5a9b..958e2749f 100644 --- a/Tests/test_file_libtiff.py +++ b/Tests/test_file_libtiff.py @@ -873,8 +873,8 @@ class TestFileLibTiff(LibTiffTestCase): assert im.mode == "RGB" assert im.size == (128, 128) assert im.format == "TIFF" - im2 = hopper() - assert_image_similar(im, im2, 5) + with hopper() as im2: + assert_image_similar(im, im2, 5) except OSError: captured = capfd.readouterr() if "LZMA compression support is not configured" in captured.err: From dc7d646db03bb34abd493a79ec2ceb78ec778265 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 1 Jul 2025 22:52:23 +1000 Subject: [PATCH 10/41] Use correct bands for 2 band histograms --- Tests/test_image_histogram.py | 3 +++ src/libImaging/Histo.c | 14 +++++++++----- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/Tests/test_image_histogram.py b/Tests/test_image_histogram.py index dbd55d4c2..436eb78a2 100644 --- a/Tests/test_image_histogram.py +++ b/Tests/test_image_histogram.py @@ -10,9 +10,12 @@ def test_histogram() -> None: assert histogram("1") == (256, 0, 10994) assert histogram("L") == (256, 0, 662) + assert histogram("LA") == (512, 0, 16384) + assert histogram("La") == (512, 0, 16384) assert histogram("I") == (256, 0, 662) assert histogram("F") == (256, 0, 662) assert histogram("P") == (256, 0, 1551) + assert histogram("PA") == (512, 0, 16384) assert histogram("RGB") == (768, 4, 675) assert histogram("RGBA") == (1024, 0, 16384) assert histogram("CMYK") == (1024, 0, 16384) diff --git a/src/libImaging/Histo.c b/src/libImaging/Histo.c index c5a547a64..87c09d3d4 100644 --- a/src/libImaging/Histo.c +++ b/src/libImaging/Histo.c @@ -132,11 +132,15 @@ ImagingGetHistogram(Imaging im, Imaging imMask, void *minmax) { ImagingSectionEnter(&cookie); for (y = 0; y < im->ysize; y++) { UINT8 *in = (UINT8 *)im->image[y]; - for (x = 0; x < im->xsize; x++) { - h->histogram[(*in++)]++; - h->histogram[(*in++) + 256]++; - h->histogram[(*in++) + 512]++; - h->histogram[(*in++) + 768]++; + for (x = 0; x < im->xsize; x++, in += 4) { + h->histogram[*in]++; + if (im->bands == 2) { + h->histogram[*(in + 3) + 256]++; + } else { + h->histogram[*(in + 1) + 256]++; + h->histogram[*(in + 2) + 512]++; + h->histogram[*(in + 3) + 768]++; + } } } ImagingSectionLeave(&cookie); From 06f5cd1ddecea64d44f417bba539dc0b30734ea4 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Tue, 8 Jul 2025 11:31:03 +1000 Subject: [PATCH 11/41] Restored manylinux2014 wheels (#9059) --- .github/workflows/wheels.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 52a3f2cdb..5cc4f0355 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -77,22 +77,22 @@ jobs: platform: linux os: ubuntu-latest cibw_arch: x86_64 + manylinux: "manylinux2014" - name: "manylinux_2_28 x86_64" platform: linux os: ubuntu-latest cibw_arch: x86_64 build: "*manylinux*" - manylinux: "manylinux_2_28" - name: "manylinux2014 and musllinux aarch64" platform: linux os: ubuntu-24.04-arm cibw_arch: aarch64 + manylinux: "manylinux2014" - name: "manylinux_2_28 aarch64" platform: linux os: ubuntu-24.04-arm cibw_arch: aarch64 build: "*manylinux*" - manylinux: "manylinux_2_28" - name: "iOS arm64 device" platform: ios os: macos-latest From 2195faf0dc739f4d46f5d77a4a323b5358f079af Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 8 Jul 2025 13:44:13 +1000 Subject: [PATCH 12/41] Update dependency cibuildwheel to v3.0.1 (#9075) --- .ci/requirements-cibw.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.ci/requirements-cibw.txt b/.ci/requirements-cibw.txt index 520b6e320..e1eb52eb8 100644 --- a/.ci/requirements-cibw.txt +++ b/.ci/requirements-cibw.txt @@ -1 +1 @@ -cibuildwheel==3.0.0 +cibuildwheel==3.0.1 From c9cf688ee7ef50dc1bd4531f19514508ae68a8e5 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 8 Jul 2025 21:10:26 +1000 Subject: [PATCH 13/41] Removed ImageDraw.getdraw hints deprecation section --- docs/deprecations.rst | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/docs/deprecations.rst b/docs/deprecations.rst index 236554565..4e65dc807 100644 --- a/docs/deprecations.rst +++ b/docs/deprecations.rst @@ -12,13 +12,6 @@ Deprecated features Below are features which are considered deprecated. Where appropriate, a :py:exc:`DeprecationWarning` is issued. -ImageDraw.getdraw hints parameter -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. deprecated:: 10.4.0 - -The ``hints`` parameter in :py:meth:`~PIL.ImageDraw.getdraw()` has been deprecated. - ExifTags.IFD.Makernote ^^^^^^^^^^^^^^^^^^^^^^ @@ -186,6 +179,7 @@ ICNS (width, height, scale) sizes ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. deprecated:: 11.0.0 +.. versionremoved:: 12.0.0 Setting an ICNS image size to ``(width, height, scale)`` before loading has been removed. Instead, ``load(scale)`` can be used. From cbd47d8609e3306cb4b20ba2b04b32c176c88e43 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 8 Jul 2025 23:07:07 +1000 Subject: [PATCH 14/41] Removed handling of deprecated WebP features --- src/PIL/features.py | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/src/PIL/features.py b/src/PIL/features.py index 984f7532c..ff32c2510 100644 --- a/src/PIL/features.py +++ b/src/PIL/features.py @@ -9,7 +9,6 @@ from typing import IO import PIL from . import Image -from ._deprecate import deprecate modules = { "pil": ("PIL._imaging", "PILLOW_VERSION"), @@ -120,7 +119,7 @@ def get_supported_codecs() -> list[str]: return [f for f in codecs if check_codec(f)] -features: dict[str, tuple[str, str | bool, str | None]] = { +features: dict[str, tuple[str, str, str | None]] = { "raqm": ("PIL._imagingft", "HAVE_RAQM", "raqm_version"), "fribidi": ("PIL._imagingft", "HAVE_FRIBIDI", "fribidi_version"), "harfbuzz": ("PIL._imagingft", "HAVE_HARFBUZZ", "harfbuzz_version"), @@ -146,12 +145,8 @@ def check_feature(feature: str) -> bool | None: module, flag, ver = features[feature] - if isinstance(flag, bool): - deprecate(f'check_feature("{feature}")', 12) try: imported_module = __import__(module, fromlist=["PIL"]) - if isinstance(flag, bool): - return flag return getattr(imported_module, flag) except ModuleNotFoundError: return None @@ -181,17 +176,7 @@ def get_supported_features() -> list[str]: """ :returns: A list of all supported features. """ - supported_features = [] - for f, (module, flag, _) in features.items(): - if flag is True: - for feature, (feature_module, _) in modules.items(): - if feature_module == module: - if check_module(feature): - supported_features.append(f) - break - elif check_feature(f): - supported_features.append(f) - return supported_features + return [f for f in features if check_feature(f)] def check(feature: str) -> bool | None: From 31e6c716ac0141ca03aed750b8b326183a45b0fb Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 9 Jul 2025 22:26:25 +1000 Subject: [PATCH 15/41] Improved features test coverage --- Tests/test_features.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/Tests/test_features.py b/Tests/test_features.py index 520c25b46..ddca99344 100644 --- a/Tests/test_features.py +++ b/Tests/test_features.py @@ -112,6 +112,25 @@ def test_unsupported_module() -> None: features.version_module(module) +def test_unsupported_feature() -> None: + # Arrange + feature = "unsupported_feature" + # Act / Assert + with pytest.raises(ValueError): + features.check_feature(feature) + with pytest.raises(ValueError): + features.version_feature(feature) + + +def test_unsupported_version() -> None: + assert features.version("unsupported_version") is None + + +def test_modulenotfound(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(features, "features", {"test": ("PIL._test", "", "")}) + assert features.check_feature("test") is None + + @pytest.mark.parametrize("supported_formats", (True, False)) def test_pilinfo(supported_formats: bool) -> None: buf = io.StringIO() From 2af930b2f72a86f30e79b5abc6f6791362411206 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Thu, 10 Jul 2025 12:07:38 +0800 Subject: [PATCH 16/41] Ensure dynamic libjpeg libraries are not linked. --- .github/workflows/wheels-dependencies.sh | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh index d761d93b6..2c38dc609 100755 --- a/.github/workflows/wheels-dependencies.sh +++ b/.github/workflows/wheels-dependencies.sh @@ -60,7 +60,7 @@ if [[ "$CIBW_PLATFORM" == "ios" ]]; then # on using the Xcode builder, which isn't very helpful for most of Pillow's # dependencies. Therefore, we lean on the OSX configurations, plus CC, CFLAGS # etc. to ensure the right sysroot is selected. - HOST_CMAKE_FLAGS="-DCMAKE_SYSTEM_NAME=$CMAKE_SYSTEM_NAME -DCMAKE_SYSTEM_PROCESSOR=$GNU_ARCH -DCMAKE_OSX_DEPLOYMENT_TARGET=$IPHONEOS_DEPLOYMENT_TARGET -DCMAKE_OSX_SYSROOT=$IOS_SDK_PATH -DBUILD_SHARED_LIBS=NO" + HOST_CMAKE_FLAGS="-DCMAKE_SYSTEM_NAME=$CMAKE_SYSTEM_NAME -DCMAKE_SYSTEM_PROCESSOR=$GNU_ARCH -DCMAKE_OSX_DEPLOYMENT_TARGET=$IPHONEOS_DEPLOYMENT_TARGET -DCMAKE_OSX_SYSROOT=$IOS_SDK_PATH -DBUILD_SHARED_LIBS=NO -DENABLE_SHARED=NO" # Meson needs to be pointed at a cross-platform configuration file # This will be generated once CC etc. have been evaluated. @@ -380,6 +380,15 @@ fi wrap_wheel_builder build +# A safety catch for iOS. iOS can't use dynamic libraries, but clang will prefer +# to link dynamic libraries to static libraries. The only way to reliably +# prevent this is to not have dynamic libraries available in the first place. +# The build process *shouldn't* generate any dylibs... but just in case, purge +# any dylibs that *have* been installed into the build prefix directory. +if [[ -n "$IOS_SDK" ]]; then + find "$BUILD_PREFIX" -name "*.dylib" -exec rm -rf {} \; +fi + # Return to the project root to finish the build popd > /dev/null From 6c12d188db46ea8cfb19024bd55c352a2aaa3a03 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 10 Jul 2025 22:33:31 +1000 Subject: [PATCH 17/41] Updated libwebp to 1.6.0 --- .github/workflows/wheels-dependencies.sh | 4 +-- depends/install_webp.sh | 2 +- patches/iOS/libwebp-1.5.0.tar.gz.patch | 42 ------------------------ winbuild/build_prepare.py | 2 +- 4 files changed, 4 insertions(+), 46 deletions(-) delete mode 100644 patches/iOS/libwebp-1.5.0.tar.gz.patch diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh index 2c38dc609..6d52ca989 100755 --- a/.github/workflows/wheels-dependencies.sh +++ b/.github/workflows/wheels-dependencies.sh @@ -103,7 +103,7 @@ TIFF_VERSION=4.7.0 LCMS2_VERSION=2.17 ZLIB_VERSION=1.3.1 ZLIB_NG_VERSION=2.2.4 -LIBWEBP_VERSION=1.5.0 # Patched; next release won't need patching. See patch file. +LIBWEBP_VERSION=1.6.0 BZIP2_VERSION=1.0.8 LIBXCB_VERSION=1.17.0 BROTLI_VERSION=1.1.0 # Patched; next release won't need patching. See patch file. @@ -282,7 +282,7 @@ function build { fi CFLAGS="$CFLAGS $webp_cflags" build_simple libwebp $LIBWEBP_VERSION \ https://storage.googleapis.com/downloads.webmproject.org/releases/webp tar.gz \ - --enable-libwebpmux --enable-libwebpdemux + --enable-libwebpmux --enable-libwebpdemux --disable-libwebpexamples build_brotli diff --git a/depends/install_webp.sh b/depends/install_webp.sh index 9d2977715..d7f3cd2f5 100755 --- a/depends/install_webp.sh +++ b/depends/install_webp.sh @@ -1,7 +1,7 @@ #!/bin/bash # install webp -archive=libwebp-1.5.0 +archive=libwebp-1.6.0 ./download-and-extract.sh $archive https://raw.githubusercontent.com/python-pillow/pillow-depends/main/$archive.tar.gz diff --git a/patches/iOS/libwebp-1.5.0.tar.gz.patch b/patches/iOS/libwebp-1.5.0.tar.gz.patch deleted file mode 100644 index fefb72b68..000000000 --- a/patches/iOS/libwebp-1.5.0.tar.gz.patch +++ /dev/null @@ -1,42 +0,0 @@ -# libwebp example binaries require dependencies that aren't available for iOS builds. -# There's also no easy way to invoke the build to *exclude* the example builds. -# Since we don't need the examples anyway, remove them from the Makefile. -# -# As a point of reference, libwebp provides an XCFramework build script that involves -# 7 separate invocations of make to avoid building the examples. Patching the Makefile -# to remove the examples is a simpler approach, and one that is more compatible with -# the existing multibuild infrastructure. -# -# In the next release, it should be possible to pass --disable-libwebpexamples -# instead of applying this patch. -# -diff -ur libwebp-1.5.0-orig/Makefile.am libwebp-1.5.0/Makefile.am ---- libwebp-1.5.0-orig/Makefile.am 2024-12-20 09:17:50 -+++ libwebp-1.5.0/Makefile.am 2025-01-09 11:24:17 -@@ -5,5 +5,3 @@ - if BUILD_EXTRAS - SUBDIRS += extras - endif -- --SUBDIRS += examples -diff -ur libwebp-1.5.0-orig/Makefile.in libwebp-1.5.0/Makefile.in ---- libwebp-1.5.0-orig/Makefile.in 2024-12-20 09:52:53 -+++ libwebp-1.5.0/Makefile.in 2025-01-09 11:24:17 -@@ -156,7 +156,7 @@ - unique=`for i in $$list; do \ - if test -f "$$i"; then echo $$i; else echo $(srcdir)/$$i; fi; \ - done | $(am__uniquify_input)` --DIST_SUBDIRS = sharpyuv src imageio man extras examples -+DIST_SUBDIRS = sharpyuv src imageio man extras - am__DIST_COMMON = $(srcdir)/Makefile.in \ - $(top_srcdir)/src/webp/config.h.in AUTHORS COPYING ChangeLog \ - NEWS README.md ar-lib compile config.guess config.sub \ -@@ -351,7 +351,7 @@ - top_srcdir = @top_srcdir@ - webp_libname_prefix = @webp_libname_prefix@ - ACLOCAL_AMFLAGS = -I m4 --SUBDIRS = sharpyuv src imageio man $(am__append_1) examples -+SUBDIRS = sharpyuv src imageio man $(am__append_1) - EXTRA_DIST = COPYING autogen.sh - all: all-recursive - diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index 187d07b20..6b2d41a7e 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -122,7 +122,7 @@ V = { "LIBAVIF": "1.3.0", "LIBIMAGEQUANT": "4.3.4", "LIBPNG": "1.6.49", - "LIBWEBP": "1.5.0", + "LIBWEBP": "1.6.0", "OPENJPEG": "2.5.3", "TIFF": "4.7.0", "XZ": "5.8.1", From 50dde1c125f0f3c1714c64fa6a049b1123e0a0cd Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 10 Jul 2025 23:19:16 +1000 Subject: [PATCH 18/41] Remove unused _save_cjpeg --- Tests/helper.py | 10 ---------- Tests/test_file_jpeg.py | 9 --------- Tests/test_shell_injection.py | 7 +------ src/PIL/JpegImagePlugin.py | 10 ---------- winbuild/build_prepare.py | 5 ++--- 5 files changed, 3 insertions(+), 38 deletions(-) diff --git a/Tests/helper.py b/Tests/helper.py index 34e4d6e75..df99f5f55 100644 --- a/Tests/helper.py +++ b/Tests/helper.py @@ -291,16 +291,6 @@ def djpeg_available() -> bool: return False -def cjpeg_available() -> bool: - if shutil.which("cjpeg"): - try: - subprocess.check_call(["cjpeg", "-version"]) - return True - except subprocess.CalledProcessError: # pragma: no cover - return False - return False - - def netpbm_available() -> bool: return bool(shutil.which("ppmquant") and shutil.which("ppmtogif")) diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py index 08e879807..51d518ae5 100644 --- a/Tests/test_file_jpeg.py +++ b/Tests/test_file_jpeg.py @@ -26,7 +26,6 @@ from .helper import ( assert_image_equal_tofile, assert_image_similar, assert_image_similar_tofile, - cjpeg_available, djpeg_available, hopper, is_win32, @@ -731,14 +730,6 @@ class TestFileJpeg: img.load_djpeg() assert_image_similar_tofile(img, TEST_FILE, 5) - @pytest.mark.skipif(not cjpeg_available(), reason="cjpeg not available") - def test_save_cjpeg(self, tmp_path: Path) -> None: - with Image.open(TEST_FILE) as img: - tempfile = str(tmp_path / "temp.jpg") - JpegImagePlugin._save_cjpeg(img, BytesIO(), tempfile) - # Default save quality is 75%, so a tiny bit of difference is alright - assert_image_similar_tofile(img, tempfile, 17) - def test_no_duplicate_0x1001_tag(self) -> None: # Arrange tag_ids = {v: k for k, v in ExifTags.TAGS.items()} diff --git a/Tests/test_shell_injection.py b/Tests/test_shell_injection.py index 03e92b5b9..38d46f312 100644 --- a/Tests/test_shell_injection.py +++ b/Tests/test_shell_injection.py @@ -9,7 +9,7 @@ import pytest from PIL import GifImagePlugin, Image, JpegImagePlugin -from .helper import cjpeg_available, djpeg_available, is_win32, netpbm_available +from .helper import djpeg_available, is_win32, netpbm_available TEST_JPG = "Tests/images/hopper.jpg" TEST_GIF = "Tests/images/hopper.gif" @@ -42,11 +42,6 @@ class TestShellInjection: assert isinstance(im, JpegImagePlugin.JpegImageFile) im.load_djpeg() - @pytest.mark.skipif(not cjpeg_available(), reason="cjpeg not available") - def test_save_cjpeg_filename(self, tmp_path: Path) -> None: - with Image.open(TEST_JPG) as im: - self.assert_save_filename_check(tmp_path, im, JpegImagePlugin._save_cjpeg) - @pytest.mark.skipif(not netpbm_available(), reason="Netpbm not available") def test_save_netpbm_filename_bmp_mode(self, tmp_path: Path) -> None: with Image.open(TEST_GIF) as im: diff --git a/src/PIL/JpegImagePlugin.py b/src/PIL/JpegImagePlugin.py index 082f3551a..efe8eff3b 100644 --- a/src/PIL/JpegImagePlugin.py +++ b/src/PIL/JpegImagePlugin.py @@ -845,16 +845,6 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: ) -def _save_cjpeg(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: - # ALTERNATIVE: handle JPEGs via the IJG command line utilities. - tempfile = im._dump() - subprocess.check_call(["cjpeg", "-outfile", filename, tempfile]) - try: - os.unlink(tempfile) - except OSError: - pass - - ## # Factory for making JPEG and MPO instances def jpeg_factory( diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index 187d07b20..84d103c08 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -149,18 +149,17 @@ DEPS: dict[str, dict[str, Any]] = { }, "build": [ *cmds_cmake( - ("jpeg-static", "cjpeg-static", "djpeg-static"), + ("jpeg-static", "djpeg-static"), "-DENABLE_SHARED:BOOL=FALSE", "-DWITH_JPEG8:BOOL=TRUE", "-DWITH_CRT_DLL:BOOL=TRUE", ), cmd_copy("jpeg-static.lib", "libjpeg.lib"), - cmd_copy("cjpeg-static.exe", "cjpeg.exe"), cmd_copy("djpeg-static.exe", "djpeg.exe"), ], "headers": ["jconfig.h", r"src\j*.h"], "libs": ["libjpeg.lib"], - "bins": ["cjpeg.exe", "djpeg.exe"], + "bins": ["djpeg.exe"], }, "zlib": { "url": f"https://github.com/zlib-ng/zlib-ng/archive/refs/tags/{V['ZLIBNG']}.tar.gz", From d88986a184ceb32a7eb919e3b21f950c564da35f Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Fri, 11 Jul 2025 12:53:43 +1000 Subject: [PATCH 19/41] Link transitive dependencies Co-authored-by: Russell Keith-Magee --- .github/workflows/wheels-dependencies.sh | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh index 6d52ca989..4296ba292 100755 --- a/.github/workflows/wheels-dependencies.sh +++ b/.github/workflows/wheels-dependencies.sh @@ -280,8 +280,11 @@ function build { if [[ -n "$IS_MACOS" ]]; then webp_cflags="$webp_cflags -Wl,-headerpad_max_install_names" fi - CFLAGS="$CFLAGS $webp_cflags" build_simple libwebp $LIBWEBP_VERSION \ - https://storage.googleapis.com/downloads.webmproject.org/releases/webp tar.gz \ + webp_ldflags="" + if [[ -n "$IOS_SDK" ]]; then + webp_ldflags="$webp_ldflags -llzma -lz" + fi + CFLAGS="$CFLAGS $webp_cflags" LDFLAGS="$LDFLAGS $webp_ldflags" build_simple libwebp $LIBWEBP_VERSION \ --enable-libwebpmux --enable-libwebpdemux --disable-libwebpexamples build_brotli From 722c130b316443be7cc561d716711d1d39d704f7 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Fri, 11 Jul 2025 13:12:38 +1000 Subject: [PATCH 20/41] Restored URL Co-authored-by: Russell Keith-Magee --- .github/workflows/wheels-dependencies.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh index 4296ba292..e83012fd6 100755 --- a/.github/workflows/wheels-dependencies.sh +++ b/.github/workflows/wheels-dependencies.sh @@ -285,6 +285,7 @@ function build { webp_ldflags="$webp_ldflags -llzma -lz" fi CFLAGS="$CFLAGS $webp_cflags" LDFLAGS="$LDFLAGS $webp_ldflags" build_simple libwebp $LIBWEBP_VERSION \ + https://storage.googleapis.com/downloads.webmproject.org/releases/webp tar.gz \ --enable-libwebpmux --enable-libwebpdemux --disable-libwebpexamples build_brotli From 985544d55715f2a5dfc539fdd09fb9bb7738694c Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 11 Jul 2025 13:28:08 +1000 Subject: [PATCH 21/41] Do not disable libwebpexamples --- .github/workflows/wheels-dependencies.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh index e83012fd6..6b5aedb69 100755 --- a/.github/workflows/wheels-dependencies.sh +++ b/.github/workflows/wheels-dependencies.sh @@ -286,7 +286,7 @@ function build { fi CFLAGS="$CFLAGS $webp_cflags" LDFLAGS="$LDFLAGS $webp_ldflags" build_simple libwebp $LIBWEBP_VERSION \ https://storage.googleapis.com/downloads.webmproject.org/releases/webp tar.gz \ - --enable-libwebpmux --enable-libwebpdemux --disable-libwebpexamples + --enable-libwebpmux --enable-libwebpdemux build_brotli From a8bb7579dc3dd5c24dcecc17832dc1ea5b2249a8 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 11 Jul 2025 21:06:30 +1000 Subject: [PATCH 22/41] Improved ImageMath test coverage --- Tests/test_imagemath_lambda_eval.py | 32 ++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/Tests/test_imagemath_lambda_eval.py b/Tests/test_imagemath_lambda_eval.py index 26c04b9a0..ce2a32ae8 100644 --- a/Tests/test_imagemath_lambda_eval.py +++ b/Tests/test_imagemath_lambda_eval.py @@ -2,7 +2,9 @@ from __future__ import annotations from typing import Any -from PIL import Image, ImageMath +import pytest + +from PIL import Image, ImageMath, _imagingmath def pixel(im: Image.Image | int) -> str | int: @@ -498,3 +500,31 @@ def test_logical_not_equal() -> None: ) == "I 1" ) + + +def test_reflected_operands() -> None: + assert pixel(ImageMath.lambda_eval(lambda args: 1 + args["A"], **images)) == "I 2" + assert pixel(ImageMath.lambda_eval(lambda args: 1 - args["A"], **images)) == "I 0" + assert pixel(ImageMath.lambda_eval(lambda args: 1 * args["A"], **images)) == "I 1" + assert pixel(ImageMath.lambda_eval(lambda args: 1 / args["A"], **images)) == "I 1" + assert pixel(ImageMath.lambda_eval(lambda args: 1 % args["A"], **images)) == "I 0" + assert pixel(ImageMath.lambda_eval(lambda args: 1 ** args["A"], **images)) == "I 1" + assert pixel(ImageMath.lambda_eval(lambda args: 1 & args["A"], **images)) == "I 1" + assert pixel(ImageMath.lambda_eval(lambda args: 1 | args["A"], **images)) == "I 1" + assert pixel(ImageMath.lambda_eval(lambda args: 1 ^ args["A"], **images)) == "I 0" + + +def test_unsupported_mode() -> None: + im = Image.new("RGB", (1, 1)) + with pytest.raises(ValueError, match="unsupported mode: RGB"): + ImageMath.lambda_eval(lambda args: args["im"] + 1, im=im) + + +def test_bad_operand_type(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delattr(_imagingmath, "abs_I") + with pytest.raises(TypeError, match="bad operand type for 'abs'"): + ImageMath.lambda_eval(lambda args: abs(args["I"]), I=I) + + monkeypatch.delattr(_imagingmath, "max_F") + with pytest.raises(TypeError, match="bad operand type for 'max'"): + ImageMath.lambda_eval(lambda args: args["max"](args["I"], args["F"]), I=I, F=F) From d85fa7a2471b16bc547a46542f18f218b19a1c6b Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sun, 13 Jul 2025 16:13:44 +1000 Subject: [PATCH 23/41] Improved WmfImagePlugin test coverage --- Tests/test_file_wmf.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Tests/test_file_wmf.py b/Tests/test_file_wmf.py index dcf5f000f..906080d15 100644 --- a/Tests/test_file_wmf.py +++ b/Tests/test_file_wmf.py @@ -44,6 +44,18 @@ def test_load_zero_inch() -> None: pass +def test_load_unsupported_wmf() -> None: + b = BytesIO(b"\xd7\xcd\xc6\x9a\x00\x00" + b"\x01" * 10) + with pytest.raises(SyntaxError, match="Unsupported WMF file format"): + WmfImagePlugin.WmfStubImageFile(b) + + +def test_load_unsupported() -> None: + b = BytesIO(b"\x01\x00\x00\x00") + with pytest.raises(SyntaxError, match="Unsupported file format"): + WmfImagePlugin.WmfStubImageFile(b) + + def test_render() -> None: with open("Tests/images/drawing.emf", "rb") as fp: data = fp.read() From 7516805121cc1da1d00cd6f0a22f64e0232c3541 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 14 Jul 2025 19:29:27 +1000 Subject: [PATCH 24/41] Improved DDS test coverage --- Tests/images/unimplemented_pixel_format.dds | Bin 0 -> 132 bytes Tests/test_file_dds.py | 19 +++++++++++++------ 2 files changed, 13 insertions(+), 6 deletions(-) create mode 100644 Tests/images/unimplemented_pixel_format.dds diff --git a/Tests/images/unimplemented_pixel_format.dds b/Tests/images/unimplemented_pixel_format.dds new file mode 100644 index 0000000000000000000000000000000000000000..9092df8b1b5acda7e115b9ceaf6241d0f294dd1b GIT binary patch literal 132 rcmZ>930A0KU|?Vu;9_841TsJvNWhsOE|EY1sE!4nS^-SSSfCI9+g<`M literal 0 HcmV?d00001 diff --git a/Tests/test_file_dds.py b/Tests/test_file_dds.py index 5c7a943b1..116dfa59c 100644 --- a/Tests/test_file_dds.py +++ b/Tests/test_file_dds.py @@ -380,21 +380,28 @@ def test_palette() -> None: assert_image_equal_tofile(im, "Tests/images/transparent.gif") +def test_unsupported_header_size() -> None: + with pytest.raises(OSError, match="Unsupported header size 0"): + with Image.open(BytesIO(b"DDS " + b"\x00" * 4)): + pass + + def test_unsupported_bitcount() -> None: - with pytest.raises(OSError): + with pytest.raises(OSError, match="Unsupported bitcount 24 for 131072"): with Image.open("Tests/images/unsupported_bitcount.dds"): pass @pytest.mark.parametrize( - "test_file", + "test_file, message", ( - "Tests/images/unimplemented_dxgi_format.dds", - "Tests/images/unimplemented_pfflags.dds", + ("Tests/images/unimplemented_dxgi_format.dds", "Unimplemented DXGI format 93"), + ("Tests/images/unimplemented_pixel_format.dds", "Unimplemented pixel format 0"), + ("Tests/images/unimplemented_pfflags.dds", "Unknown pixel format flags 8"), ), ) -def test_not_implemented(test_file: str) -> None: - with pytest.raises(NotImplementedError): +def test_not_implemented(test_file: str, message: str) -> None: + with pytest.raises(NotImplementedError, match=message): with Image.open(test_file): pass From 638eb1b9992804ca21577b5933d476b7bdbeb5d6 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 15 Jul 2025 13:23:40 +1000 Subject: [PATCH 25/41] Update dependency mypy to v1.17.0 (#9092) --- .ci/requirements-mypy.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.ci/requirements-mypy.txt b/.ci/requirements-mypy.txt index 44b5badab..e81f527b8 100644 --- a/.ci/requirements-mypy.txt +++ b/.ci/requirements-mypy.txt @@ -1,4 +1,4 @@ -mypy==1.16.1 +mypy==1.17.0 IceSpringPySideStubs-PyQt6 IceSpringPySideStubs-PySide6 ipython From 91bbeb5dcb47ce6d3b5b1c9969c982910ebee56b Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 16 Jul 2025 13:54:13 +1000 Subject: [PATCH 26/41] Revert iOS change until the test runs again --- Tests/test_pyroma.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Tests/test_pyroma.py b/Tests/test_pyroma.py index a161d3f05..c2f7fe22e 100644 --- a/Tests/test_pyroma.py +++ b/Tests/test_pyroma.py @@ -1,7 +1,5 @@ from __future__ import annotations -from importlib.metadata import metadata - import pytest from PIL import __version__ @@ -11,7 +9,7 @@ pyroma = pytest.importorskip("pyroma", reason="Pyroma not installed") def test_pyroma() -> None: # Arrange - data = pyroma.projectdata.map_metadata_keys(metadata("Pillow")) + data = pyroma.projectdata.get_data(".") # Act rating = pyroma.ratings.rate(data) From a426eb55afcb9e8a069d6aba21405c0d89f69bec Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 16 Jul 2025 13:40:22 +1000 Subject: [PATCH 27/41] Remove file after test completion --- Tests/test_image_access.py | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/Tests/test_image_access.py b/Tests/test_image_access.py index b3de5c13d..2609b1e34 100644 --- a/Tests/test_image_access.py +++ b/Tests/test_image_access.py @@ -276,10 +276,11 @@ class TestEmbeddable: except Exception: pytest.skip("Compiler could not be initialized") - with open("embed_pil.c", "w", encoding="utf-8") as fh: - home = sys.prefix.replace("\\", "\\\\") - fh.write( - f""" + try: + with open("embed_pil.c", "w", encoding="utf-8") as fh: + home = sys.prefix.replace("\\", "\\\\") + fh.write( + f""" #include "Python.h" int main(int argc, char* argv[]) @@ -301,17 +302,19 @@ int main(int argc, char* argv[]) return 0; }} """ - ) + ) - objects = compiler.compile(["embed_pil.c"]) - compiler.link_executable(objects, "embed_pil") + objects = compiler.compile(["embed_pil.c"]) + compiler.link_executable(objects, "embed_pil") - env = os.environ.copy() - env["PATH"] = sys.prefix + ";" + env["PATH"] + env = os.environ.copy() + env["PATH"] = sys.prefix + ";" + env["PATH"] - # Do not display the Windows Error Reporting dialog - getattr(ctypes, "windll").kernel32.SetErrorMode(0x0002) + # Do not display the Windows Error Reporting dialog + getattr(ctypes, "windll").kernel32.SetErrorMode(0x0002) - process = subprocess.Popen(["embed_pil.exe"], env=env) - process.communicate() - assert process.returncode == 0 + process = subprocess.Popen(["embed_pil.exe"], env=env) + process.communicate() + assert process.returncode == 0 + finally: + os.remove("embed_pil.c") From a39d14648bdbd6638e7097167c4b8c2964ce3752 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 16 Jul 2025 13:39:19 +1000 Subject: [PATCH 28/41] Updated manifest --- MANIFEST.in | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/MANIFEST.in b/MANIFEST.in index 95a6b1b92..6623f227d 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -13,6 +13,7 @@ include LICENSE include Makefile include tox.ini graft Tests +graft Tests/images graft checks graft patches graft src @@ -28,8 +29,19 @@ exclude .editorconfig exclude .readthedocs.yml exclude codecov.yml exclude renovate.json +exclude Tests/images/README.md +exclude Tests/images/crash*.tif +exclude Tests/images/string_dimension.tiff global-exclude .git* global-exclude *.pyc global-exclude *.so prune .ci prune wheels +prune winbuild/build +prune winbuild/depends +prune Tests/errors +prune Tests/images/jpeg2000 +prune Tests/images/msp +prune Tests/images/picins +prune Tests/images/sunraster +prune Tests/test-images From f4d86e4f44dfe799b0a2d6484fa53945ab89220e Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 24 Jul 2025 07:27:39 +1000 Subject: [PATCH 29/41] Use teardown_method --- Tests/test_image_access.py | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/Tests/test_image_access.py b/Tests/test_image_access.py index 2609b1e34..a847264d2 100644 --- a/Tests/test_image_access.py +++ b/Tests/test_image_access.py @@ -276,11 +276,10 @@ class TestEmbeddable: except Exception: pytest.skip("Compiler could not be initialized") - try: - with open("embed_pil.c", "w", encoding="utf-8") as fh: - home = sys.prefix.replace("\\", "\\\\") - fh.write( - f""" + with open("embed_pil.c", "w", encoding="utf-8") as fh: + home = sys.prefix.replace("\\", "\\\\") + fh.write( + f""" #include "Python.h" int main(int argc, char* argv[]) @@ -302,19 +301,20 @@ int main(int argc, char* argv[]) return 0; }} """ - ) + ) - objects = compiler.compile(["embed_pil.c"]) - compiler.link_executable(objects, "embed_pil") + objects = compiler.compile(["embed_pil.c"]) + compiler.link_executable(objects, "embed_pil") - env = os.environ.copy() - env["PATH"] = sys.prefix + ";" + env["PATH"] + env = os.environ.copy() + env["PATH"] = sys.prefix + ";" + env["PATH"] - # Do not display the Windows Error Reporting dialog - getattr(ctypes, "windll").kernel32.SetErrorMode(0x0002) + # Do not display the Windows Error Reporting dialog + getattr(ctypes, "windll").kernel32.SetErrorMode(0x0002) - process = subprocess.Popen(["embed_pil.exe"], env=env) - process.communicate() - assert process.returncode == 0 - finally: - os.remove("embed_pil.c") + process = subprocess.Popen(["embed_pil.exe"], env=env) + process.communicate() + assert process.returncode == 0 + + def teardown_method(self) -> None: + os.remove("embed_pil.c") From 63163d065d632cb75466d554fb1d6ea27cc43577 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 17 Jul 2025 19:59:47 +1000 Subject: [PATCH 30/41] Removed WebP feature handling --- Tests/test_features.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/Tests/test_features.py b/Tests/test_features.py index 520c25b46..7af3fffea 100644 --- a/Tests/test_features.py +++ b/Tests/test_features.py @@ -18,11 +18,7 @@ def test_check() -> None: for codec in features.codecs: assert features.check_codec(codec) == features.check(codec) for feature in features.features: - if "webp" in feature: - with pytest.warns(DeprecationWarning, match="webp"): - assert features.check_feature(feature) == features.check(feature) - else: - assert features.check_feature(feature) == features.check(feature) + assert features.check_feature(feature) == features.check(feature) def test_version() -> None: @@ -48,11 +44,7 @@ def test_version() -> None: for codec in features.codecs: test(codec, features.version_codec) for feature in features.features: - if "webp" in feature: - with pytest.warns(DeprecationWarning, match="webp"): - test(feature, features.version_feature) - else: - test(feature, features.version_feature) + test(feature, features.version_feature) @skip_unless_feature("libjpeg_turbo") From ec6d5efe4d02dc6d68e569abfd7523e21a89539f Mon Sep 17 00:00:00 2001 From: Luke Granger-Brown Date: Sat, 26 Jul 2025 08:33:11 +0100 Subject: [PATCH 31/41] Deprecate ImageCmsProfile product_name and product_info (#8995) Co-authored-by: Andrew Murray --- Tests/test_imagecms.py | 14 ++++++++++++++ docs/deprecations.rst | 9 +++++++++ docs/releasenotes/12.0.0.rst | 8 +++++--- src/PIL/ImageCms.py | 14 ++++++++++---- 4 files changed, 38 insertions(+), 7 deletions(-) diff --git a/Tests/test_imagecms.py b/Tests/test_imagecms.py index 55a4a87fb..8b5d88ac8 100644 --- a/Tests/test_imagecms.py +++ b/Tests/test_imagecms.py @@ -690,3 +690,17 @@ def test_cmyk_lab() -> None: im = Image.new("CMYK", (1, 1)) converted_im = im.convert("LAB") assert converted_im.getpixel((0, 0)) == (255, 128, 128) + + +def test_deprecation() -> None: + profile = ImageCmsProfile(ImageCms.createProfile("sRGB")) + with pytest.warns( + DeprecationWarning, match="ImageCms.ImageCmsProfile.product_name" + ): + profile.product_name + with pytest.warns( + DeprecationWarning, match="ImageCms.ImageCmsProfile.product_info" + ): + profile.product_info + with pytest.raises(AttributeError): + profile.this_attribute_does_not_exist diff --git a/docs/deprecations.rst b/docs/deprecations.rst index 4e65dc807..3f95cf7f5 100644 --- a/docs/deprecations.rst +++ b/docs/deprecations.rst @@ -52,6 +52,15 @@ another mode before saving:: im = Image.new("I", (1, 1)) im.convert("I;16").save("out.png") +ImageCms.ImageCmsProfile.product_name and .product_info +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. deprecated:: 12.0.0 + +``ImageCms.ImageCmsProfile.product_name`` and the corresponding +``.product_info`` attributes have been deprecated, and will be removed in +Pillow 13 (2026-10-15). They have been set to ``None`` since Pillow 2.3.0. + Removed features ---------------- diff --git a/docs/releasenotes/12.0.0.rst b/docs/releasenotes/12.0.0.rst index 68b664443..6c0cd4dba 100644 --- a/docs/releasenotes/12.0.0.rst +++ b/docs/releasenotes/12.0.0.rst @@ -110,10 +110,12 @@ vulnerability introduced in FreeType 2.6 (:cve:`2020-15999`). Deprecations ============ -TODO -^^^^ +ImageCms.ImageCmsProfile.product_name and .product_info +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -TODO +``ImageCms.ImageCmsProfile.product_name`` and the corresponding +``.product_info`` attributes have been deprecated, and will be removed in +Pillow 13 (2026-10-15). They have been set to ``None`` since Pillow 2.3.0. API changes =========== diff --git a/src/PIL/ImageCms.py b/src/PIL/ImageCms.py index d3555694a..513e28acf 100644 --- a/src/PIL/ImageCms.py +++ b/src/PIL/ImageCms.py @@ -23,9 +23,10 @@ import operator import sys from enum import IntEnum, IntFlag from functools import reduce -from typing import Literal, SupportsFloat, SupportsInt, Union +from typing import Any, Literal, SupportsFloat, SupportsInt, Union from . import Image +from ._deprecate import deprecate from ._typing import SupportsRead try: @@ -233,9 +234,7 @@ class ImageCmsProfile: low-level profile object """ - self.filename = None - self.product_name = None # profile.product_name - self.product_info = None # profile.product_info + self.filename: str | None = None if isinstance(profile, str): if sys.platform == "win32": @@ -256,6 +255,13 @@ class ImageCmsProfile: msg = "Invalid type for Profile" # type: ignore[unreachable] raise TypeError(msg) + def __getattr__(self, name: str) -> Any: + if name in ("product_name", "product_info"): + deprecate(f"ImageCms.ImageCmsProfile.{name}", 13) + return None + msg = f"'{self.__class__.__name__}' object has no attribute '{name}'" + raise AttributeError(msg) + def tobytes(self) -> bytes: """ Returns the profile in a format suitable for embedding in From 7dbcb32cbe524a8ec4c12f21c762cd7153b2b03b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 26 Jul 2025 19:32:57 +1000 Subject: [PATCH 32/41] Update cygwin/cygwin-install-action action to v6 (#9108) Co-authored-by: Andrew Murray --- .github/workflows/test-cygwin.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/workflows/test-cygwin.yml b/.github/workflows/test-cygwin.yml index abfeaa77f..581cd6370 100644 --- a/.github/workflows/test-cygwin.yml +++ b/.github/workflows/test-cygwin.yml @@ -52,7 +52,7 @@ jobs: persist-credentials: false - name: Install Cygwin - uses: cygwin/cygwin-install-action@v5 + uses: cygwin/cygwin-install-action@v6 with: packages: > gcc-g++ @@ -89,10 +89,6 @@ jobs: with: dirs: 'C:\cygwin\bin;C:\cygwin\lib\lapack' - - name: Select Python version - run: | - ln -sf c:/cygwin/bin/python3.${{ matrix.python-minor-version }} c:/cygwin/bin/python3 - - name: pip cache uses: actions/cache@v4 with: From 53b6d57b730a68ea58680483f8628c5e25301a1e Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 26 Jul 2025 19:39:54 +1000 Subject: [PATCH 33/41] Drop support for PyPy3.10 --- .github/workflows/test-windows.yml | 2 +- .github/workflows/test.yml | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml index 6d8acc44f..766c506e7 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.yml @@ -35,7 +35,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["pypy3.11", "pypy3.10", "3.10", "3.11", "3.12", ">=3.13.5", "3.14"] + python-version: ["pypy3.11", "3.10", "3.11", "3.12", ">=3.13.5", "3.14"] architecture: ["x64"] include: # Test the oldest Python on 32-bit diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b4b516228..d18023dbc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -42,7 +42,6 @@ jobs: ] python-version: [ "pypy3.11", - "pypy3.10", "3.14t", "3.14", "3.13t", From 98d38a3bffe572459939cbb6ab730229b4a5a833 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Mon, 28 Jul 2025 18:52:06 +1000 Subject: [PATCH 34/41] Updated libpng to 1.6.50 (#9058) --- .github/workflows/wheels-dependencies.sh | 2 +- winbuild/build_prepare.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh index 6b5aedb69..4519271b9 100755 --- a/.github/workflows/wheels-dependencies.sh +++ b/.github/workflows/wheels-dependencies.sh @@ -95,7 +95,7 @@ ARCHIVE_SDIR=pillow-depends-main # you change those versions, ensure the patch is also updated. FREETYPE_VERSION=2.13.3 HARFBUZZ_VERSION=11.2.1 -LIBPNG_VERSION=1.6.49 +LIBPNG_VERSION=1.6.50 JPEGTURBO_VERSION=3.1.1 OPENJPEG_VERSION=2.5.3 XZ_VERSION=5.8.1 diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index 2307fc8b2..fbff0daf2 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -121,7 +121,7 @@ V = { "LCMS2": "2.17", "LIBAVIF": "1.3.0", "LIBIMAGEQUANT": "4.3.4", - "LIBPNG": "1.6.49", + "LIBPNG": "1.6.50", "LIBWEBP": "1.6.0", "OPENJPEG": "2.5.3", "TIFF": "4.7.0", From bae97e1a2b75a1e3c01efc168d10b8d7ecdf3392 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 30 Jul 2025 21:50:45 +1000 Subject: [PATCH 35/41] Update dependency cibuildwheel to v3.1.2 (#9118) --- .ci/requirements-cibw.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.ci/requirements-cibw.txt b/.ci/requirements-cibw.txt index e1eb52eb8..823671828 100644 --- a/.ci/requirements-cibw.txt +++ b/.ci/requirements-cibw.txt @@ -1 +1 @@ -cibuildwheel==3.0.1 +cibuildwheel==3.1.2 From ba5f81fb6b4bd143b2ceba6875b33870eaa366ce Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Wed, 30 Jul 2025 15:23:39 +0300 Subject: [PATCH 36/41] Add support for Python 3.14 (#9120) Co-authored-by: Andrew Murray <3112309+radarhere@users.noreply.github.com> --- docs/installation/newer-versions.csv | 19 ++++++++++--------- docs/releasenotes/12.0.0.rst | 10 +++++++--- pyproject.toml | 3 ++- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/docs/installation/newer-versions.csv b/docs/installation/newer-versions.csv index 19816af58..e948dd540 100644 --- a/docs/installation/newer-versions.csv +++ b/docs/installation/newer-versions.csv @@ -1,9 +1,10 @@ -Python,3.13,3.12,3.11,3.10,3.9,3.8,3.7,3.6,3.5 -Pillow >= 11,Yes,Yes,Yes,Yes,Yes,,,, -Pillow 10.1 - 10.4,,Yes,Yes,Yes,Yes,Yes,,, -Pillow 10.0,,,Yes,Yes,Yes,Yes,,, -Pillow 9.3 - 9.5,,,Yes,Yes,Yes,Yes,Yes,, -Pillow 9.0 - 9.2,,,,Yes,Yes,Yes,Yes,, -Pillow 8.3.2 - 8.4,,,,Yes,Yes,Yes,Yes,Yes, -Pillow 8.0 - 8.3.1,,,,,Yes,Yes,Yes,Yes, -Pillow 7.0 - 7.2,,,,,,Yes,Yes,Yes,Yes +Python,3.14,3.13,3.12,3.11,3.10,3.9,3.8,3.7,3.6,3.5 +Pillow 12,Yes,Yes,Yes,Yes,Yes,,,,, +Pillow 11,,Yes,Yes,Yes,Yes,Yes,,,, +Pillow 10.1 - 10.4,,,Yes,Yes,Yes,Yes,Yes,,, +Pillow 10.0,,,,Yes,Yes,Yes,Yes,,, +Pillow 9.3 - 9.5,,,,Yes,Yes,Yes,Yes,Yes,, +Pillow 9.0 - 9.2,,,,,Yes,Yes,Yes,Yes,, +Pillow 8.3.2 - 8.4,,,,,Yes,Yes,Yes,Yes,Yes, +Pillow 8.0 - 8.3.1,,,,,,Yes,Yes,Yes,Yes, +Pillow 7.0 - 7.2,,,,,,,Yes,Yes,Yes,Yes diff --git a/docs/releasenotes/12.0.0.rst b/docs/releasenotes/12.0.0.rst index 6c0cd4dba..46cf64cf1 100644 --- a/docs/releasenotes/12.0.0.rst +++ b/docs/releasenotes/12.0.0.rst @@ -136,7 +136,11 @@ TODO Other changes ============= -TODO -^^^^ +Python 3.14 +^^^^^^^^^^^ -TODO +Pillow 11.3.0 had wheels built against Python 3.14 beta, available as a preview to help +others prepare for 3.14, and to ensure Pillow could be used immediately at the release +of 3.14.0 final (2025-10-07, :pep:`745`). + +Pillow 12.0.0 now officially supports Python 3.14. diff --git a/pyproject.toml b/pyproject.toml index 4e8623118..3693ddb8d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Multimedia :: Graphics", @@ -206,7 +207,7 @@ lint.isort.required-imports = [ ] [tool.pyproject-fmt] -max_supported_python = "3.13" +max_supported_python = "3.14" [tool.pytest.ini_options] addopts = "-ra --color=auto" From 98d6c3bf8818849e2414ef4de8c9e02b03de3886 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Fri, 1 Aug 2025 08:22:28 +0800 Subject: [PATCH 37/41] Restore pyroma test for iOS (#9116) Co-authored-by: Andrew Murray --- Tests/test_image_access.py | 6 +++++- Tests/test_pyroma.py | 25 ++++++++++++++++++++++++- pyproject.toml | 2 +- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/Tests/test_image_access.py b/Tests/test_image_access.py index a847264d2..07c12594a 100644 --- a/Tests/test_image_access.py +++ b/Tests/test_image_access.py @@ -317,4 +317,8 @@ int main(int argc, char* argv[]) assert process.returncode == 0 def teardown_method(self) -> None: - os.remove("embed_pil.c") + try: + os.remove("embed_pil.c") + except FileNotFoundError: + # If the test was skipped or failed, the file won't exist + pass diff --git a/Tests/test_pyroma.py b/Tests/test_pyroma.py index c2f7fe22e..35f3fd076 100644 --- a/Tests/test_pyroma.py +++ b/Tests/test_pyroma.py @@ -1,5 +1,7 @@ from __future__ import annotations +from importlib.metadata import metadata + import pytest from PIL import __version__ @@ -7,9 +9,30 @@ from PIL import __version__ pyroma = pytest.importorskip("pyroma", reason="Pyroma not installed") +def map_metadata_keys(metadata): + # Convert installed wheel metadata into canonical Core Metadata 2.4 format. + # This was a utility method in pyroma 4.3.3; it was removed in 5.0. + # This implementation is constructed from the relevant logic from + # Pyroma 5.0's `build_metadata()` implementation. This has been submitted + # upstream to Pyroma as https://github.com/regebro/pyroma/pull/116, + # so it may be possible to simplify this test in future. + data = {} + for key in set(metadata.keys()): + value = metadata.get_all(key) + key = pyroma.projectdata.normalize(key) + + if len(value) == 1: + value = value[0] + if value.strip() == "UNKNOWN": + continue + + data[key] = value + return data + + def test_pyroma() -> None: # Arrange - data = pyroma.projectdata.get_data(".") + data = map_metadata_keys(metadata("Pillow")) # Act rating = pyroma.ratings.rate(data) diff --git a/pyproject.toml b/pyproject.toml index 3693ddb8d..4980a9cb8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,7 +68,7 @@ optional-dependencies.tests = [ "markdown2", "olefile", "packaging", - "pyroma", + "pyroma>=5", "pytest", "pytest-cov", "pytest-timeout", From 27a7582b3541ad92df9900c2a9edcfe91c44a313 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 2 Aug 2025 11:40:35 +1000 Subject: [PATCH 38/41] Moved imports into TYPE_CHECKING --- Tests/test_imagecms.py | 5 ++++- src/PIL/GimpPaletteFile.py | 5 ++++- src/PIL/Image.py | 11 ++++++++--- src/PIL/Jpeg2KImagePlugin.py | 8 ++++++-- src/PIL/JpegImagePlugin.py | 3 ++- src/PIL/PngImagePlugin.py | 6 ++++-- src/PIL/WebPImagePlugin.py | 4 +++- 7 files changed, 31 insertions(+), 11 deletions(-) diff --git a/Tests/test_imagecms.py b/Tests/test_imagecms.py index 8b5d88ac8..46c1baa2d 100644 --- a/Tests/test_imagecms.py +++ b/Tests/test_imagecms.py @@ -7,7 +7,7 @@ import shutil import sys from io import BytesIO from pathlib import Path -from typing import Any, Literal, cast +from typing import Literal, cast import pytest @@ -31,6 +31,9 @@ except ImportError: # Skipped via setup_module() pass +TYPE_CHECKING = False +if TYPE_CHECKING: + from typing import Any SRGB = "Tests/icc/sRGB_IEC61966-2-1_black_scaled.icc" HAVE_PROFILE = os.path.exists(SRGB) diff --git a/src/PIL/GimpPaletteFile.py b/src/PIL/GimpPaletteFile.py index 379ffd739..016257d3d 100644 --- a/src/PIL/GimpPaletteFile.py +++ b/src/PIL/GimpPaletteFile.py @@ -17,7 +17,10 @@ from __future__ import annotations import re from io import BytesIO -from typing import IO + +TYPE_CHECKING = False +if TYPE_CHECKING: + from typing import IO class GimpPaletteFile: diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 262b5478b..b7c185e0d 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -38,10 +38,9 @@ import struct import sys import tempfile import warnings -from collections.abc import Callable, Iterator, MutableMapping, Sequence +from collections.abc import MutableMapping from enum import IntEnum -from types import ModuleType -from typing import IO, Any, Literal, Protocol, cast +from typing import IO, Protocol, cast # VERSION was removed in Pillow 6.0.0. # PILLOW_VERSION was removed in Pillow 9.0.0. @@ -64,6 +63,12 @@ try: except ImportError: ElementTree = None +TYPE_CHECKING = False +if TYPE_CHECKING: + from collections.abc import Callable, Iterator, Sequence + from types import ModuleType + from typing import Any, Literal + logger = logging.getLogger(__name__) diff --git a/src/PIL/Jpeg2KImagePlugin.py b/src/PIL/Jpeg2KImagePlugin.py index e0f4ecae5..4c85dd4e2 100644 --- a/src/PIL/Jpeg2KImagePlugin.py +++ b/src/PIL/Jpeg2KImagePlugin.py @@ -18,11 +18,15 @@ from __future__ import annotations import io import os import struct -from collections.abc import Callable -from typing import IO, cast +from typing import cast from . import Image, ImageFile, ImagePalette, _binary +TYPE_CHECKING = False +if TYPE_CHECKING: + from collections.abc import Callable + from typing import IO + class BoxReader: """ diff --git a/src/PIL/JpegImagePlugin.py b/src/PIL/JpegImagePlugin.py index efe8eff3b..0d110035e 100644 --- a/src/PIL/JpegImagePlugin.py +++ b/src/PIL/JpegImagePlugin.py @@ -42,7 +42,6 @@ import subprocess import sys import tempfile import warnings -from typing import IO, Any from . import Image, ImageFile from ._binary import i16be as i16 @@ -53,6 +52,8 @@ from .JpegPresets import presets TYPE_CHECKING = False if TYPE_CHECKING: + from typing import IO, Any + from .MpoImagePlugin import MpoImageFile # diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index 1b9a89aef..d0f22f812 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -38,9 +38,8 @@ import re import struct import warnings import zlib -from collections.abc import Callable from enum import IntEnum -from typing import IO, Any, NamedTuple, NoReturn, cast +from typing import IO, NamedTuple, cast from . import Image, ImageChops, ImageFile, ImagePalette, ImageSequence from ._binary import i16be as i16 @@ -53,6 +52,9 @@ from ._util import DeferredError TYPE_CHECKING = False if TYPE_CHECKING: + from collections.abc import Callable + from typing import Any, NoReturn + from . import _imaging logger = logging.getLogger(__name__) diff --git a/src/PIL/WebPImagePlugin.py b/src/PIL/WebPImagePlugin.py index 1716a18cc..2847fed20 100644 --- a/src/PIL/WebPImagePlugin.py +++ b/src/PIL/WebPImagePlugin.py @@ -1,7 +1,6 @@ from __future__ import annotations from io import BytesIO -from typing import IO, Any from . import Image, ImageFile @@ -12,6 +11,9 @@ try: except ImportError: SUPPORTED = False +TYPE_CHECKING = False +if TYPE_CHECKING: + from typing import IO, Any _VP8_MODES_BY_IDENTIFIER = { b"VP8 ": "RGB", From ae6bb29b8207023c704490405c254808b04643dd Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 2 Aug 2025 18:35:16 +1000 Subject: [PATCH 39/41] Removed support for NumPy 1.20 when type checking --- src/PIL/_typing.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/PIL/_typing.py b/src/PIL/_typing.py index 373938e71..e94045260 100644 --- a/src/PIL/_typing.py +++ b/src/PIL/_typing.py @@ -12,8 +12,8 @@ if TYPE_CHECKING: try: import numpy.typing as npt - NumpyArray = npt.NDArray[Any] # requires numpy>=1.21 - except (ImportError, AttributeError): + NumpyArray = npt.NDArray[Any] + except ImportError: pass if sys.version_info >= (3, 13): From 2ab301dcc95bee3b655aa0a2299907271b7a435a Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Sat, 2 Aug 2025 15:02:20 +0300 Subject: [PATCH 40/41] Drop support for Python 3.9 (#9119) Co-authored-by: Andrew Murray Co-authored-by: Andrew Murray <3112309+radarhere@users.noreply.github.com> --- .ci/install.sh | 67 +++++------ .github/mergify.yml | 1 - .github/workflows/test-cygwin.yml | 150 ------------------------- .github/workflows/test-windows.yml | 4 +- .github/workflows/test.yml | 11 +- README.md | 3 - Tests/helper.py | 9 +- Tests/test_features.py | 5 +- Tests/test_format_hsv.py | 5 +- Tests/test_image_transform.py | 5 +- Tests/test_imagechops.py | 6 +- Tests/test_imagecms.py | 7 +- Tests/test_imagedraw.py | 9 +- Tests/test_qt_image_qapplication.py | 42 +++---- Tests/test_qt_image_toqimage.py | 8 +- Tests/test_shell_injection.py | 8 +- checks/check_imaging_leaks.py | 3 +- docs/index.rst | 4 - docs/installation/platform-support.rst | 24 ++-- docs/reference/internal_modules.rst | 5 - docs/releasenotes/12.0.0.rst | 6 + pyproject.toml | 10 +- src/PIL/GifImagePlugin.py | 6 +- src/PIL/GimpGradientFile.py | 6 +- src/PIL/ImageDraw.py | 19 ++-- src/PIL/ImageFilter.py | 7 +- src/PIL/ImageMath.py | 8 +- src/PIL/ImageQt.py | 21 ++-- src/PIL/ImageSequence.py | 6 +- src/PIL/PcfFontFile.py | 6 +- src/PIL/PdfParser.py | 17 +-- src/PIL/TiffImagePlugin.py | 10 +- src/PIL/_imagingcms.pyi | 8 +- src/PIL/_imagingft.pyi | 3 +- src/PIL/_typing.py | 19 +--- src/PIL/_util.py | 7 +- tox.ini | 4 +- 37 files changed, 196 insertions(+), 343 deletions(-) delete mode 100644 .github/workflows/test-cygwin.yml diff --git a/.ci/install.sh b/.ci/install.sh index acb84f046..2178c6646 100755 --- a/.ci/install.sh +++ b/.ci/install.sh @@ -13,24 +13,21 @@ aptget_update() return 1 fi } -if [[ $(uname) != CYGWIN* ]]; then - aptget_update || aptget_update retry || aptget_update retry -fi +aptget_update || aptget_update retry || aptget_update retry set -e -if [[ $(uname) != CYGWIN* ]]; then - sudo apt-get -qq install libfreetype6-dev liblcms2-dev libtiff-dev python3-tk\ - ghostscript libjpeg-turbo8-dev libopenjp2-7-dev\ - cmake meson imagemagick libharfbuzz-dev libfribidi-dev\ - sway wl-clipboard libopenblas-dev nasm -fi +sudo apt-get -qq install libfreetype6-dev liblcms2-dev libtiff-dev python3-tk\ + ghostscript libjpeg-turbo8-dev libopenjp2-7-dev\ + cmake meson imagemagick libharfbuzz-dev libfribidi-dev\ + sway wl-clipboard libopenblas-dev nasm python3 -m pip install --upgrade pip python3 -m pip install --upgrade wheel python3 -m pip install coverage python3 -m pip install defusedxml python3 -m pip install ipython +python3 -m pip install numpy python3 -m pip install olefile python3 -m pip install -U pytest python3 -m pip install -U pytest-cov @@ -40,36 +37,24 @@ python3 -m pip install pyroma # fails on beta 3.14 and PyPy python3 -m pip install --only-binary=:all: pyarrow || true -if [[ $(uname) != CYGWIN* ]]; then - python3 -m pip install numpy - - # 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 - # TODO Update condition when pyqt6 supports free-threading - if ! [[ "$PYTHON_GIL" == "0" ]]; then python3 -m pip install pyqt6 ; fi - fi - - # Pyroma uses non-isolated build and fails with old setuptools - if [[ $GHA_PYTHON_VERSION == 3.9 ]]; then - # To match pyproject.toml - python3 -m pip install "setuptools>=77" - fi - - # webp - pushd depends && ./install_webp.sh && popd - - # libimagequant - pushd depends && ./install_imagequant.sh && popd - - # raqm - pushd depends && ./install_raqm.sh && popd - - # libavif - pushd depends && ./install_libavif.sh && popd - - # extra test images - pushd depends && ./install_extra_test_images.sh && popd -else - cd depends && ./install_extra_test_images.sh && cd .. +# 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 + # TODO Update condition when pyqt6 supports free-threading + if ! [[ "$PYTHON_GIL" == "0" ]]; then python3 -m pip install pyqt6 ; fi fi + +# webp +pushd depends && ./install_webp.sh && popd + +# libimagequant +pushd depends && ./install_imagequant.sh && popd + +# raqm +pushd depends && ./install_raqm.sh && popd + +# libavif +pushd depends && ./install_libavif.sh && popd + +# extra test images +pushd depends && ./install_extra_test_images.sh && popd diff --git a/.github/mergify.yml b/.github/mergify.yml index 9bb089615..14222db10 100644 --- a/.github/mergify.yml +++ b/.github/mergify.yml @@ -8,7 +8,6 @@ pull_request_rules: - status-success=Docker Test Successful - status-success=Windows Test Successful - status-success=MinGW - - status-success=Cygwin Test Successful actions: merge: method: merge diff --git a/.github/workflows/test-cygwin.yml b/.github/workflows/test-cygwin.yml deleted file mode 100644 index 581cd6370..000000000 --- a/.github/workflows/test-cygwin.yml +++ /dev/null @@ -1,150 +0,0 @@ -name: Test Cygwin - -on: - push: - branches: - - "**" - paths-ignore: - - ".github/workflows/docs.yml" - - ".github/workflows/wheels*" - - ".gitmodules" - - "docs/**" - - "wheels/**" - pull_request: - paths-ignore: - - ".github/workflows/docs.yml" - - ".github/workflows/wheels*" - - ".gitmodules" - - "docs/**" - - "wheels/**" - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -env: - COVERAGE_CORE: sysmon - -jobs: - build: - runs-on: windows-latest - strategy: - fail-fast: false - matrix: - python-minor-version: [9] - - timeout-minutes: 40 - - name: Python 3.${{ matrix.python-minor-version }} - - steps: - - name: Fix line endings - run: | - git config --global core.autocrlf input - - - name: Checkout Pillow - uses: actions/checkout@v4 - with: - persist-credentials: false - - - name: Install Cygwin - uses: cygwin/cygwin-install-action@v6 - with: - packages: > - gcc-g++ - ghostscript - git - ImageMagick - jpeg - libfreetype-devel - libimagequant-devel - libjpeg-devel - liblapack-devel - liblcms2-devel - libopenjp2-devel - libraqm-devel - libtiff-devel - libwebp-devel - libxcb-devel - libxcb-xinerama0 - make - netpbm - perl - python3${{ matrix.python-minor-version }}-cython - python3${{ matrix.python-minor-version }}-devel - python3${{ matrix.python-minor-version }}-ipython - python3${{ matrix.python-minor-version }}-numpy - python3${{ matrix.python-minor-version }}-sip - python3${{ matrix.python-minor-version }}-tkinter - wget - xorg-server-extra - zlib-devel - - - name: Add Lapack to PATH - uses: egor-tensin/cleanup-path@v4 - with: - dirs: 'C:\cygwin\bin;C:\cygwin\lib\lapack' - - - name: pip cache - uses: actions/cache@v4 - with: - path: 'C:\cygwin\home\runneradmin\.cache\pip' - key: ${{ runner.os }}-cygwin-pip3.${{ matrix.python-minor-version }}-${{ hashFiles('.ci/install.sh') }} - restore-keys: | - ${{ runner.os }}-cygwin-pip3.${{ matrix.python-minor-version }}- - - - name: Build system information - run: | - dash.exe -c "python3 .github/workflows/system-info.py" - - - name: Install dependencies - run: | - bash.exe .ci/install.sh - - - name: Build - shell: bash.exe -eo pipefail -o igncr "{0}" - run: | - .ci/build.sh - - - name: Test - run: | - bash.exe xvfb-run -s '-screen 0 1024x768x24' .ci/test.sh - - - name: Prepare to upload errors - if: failure() - run: | - dash.exe -c "mkdir -p Tests/errors" - - - name: Upload errors - uses: actions/upload-artifact@v4 - if: failure() - with: - name: errors - path: Tests/errors - - - name: After success - run: | - bash.exe .ci/after_success.sh - rm C:\cygwin\bin\bash.EXE - - - name: Upload coverage - uses: codecov/codecov-action@v5 - with: - files: ./coverage.xml - flags: GHA_Cygwin - name: Cygwin Python 3.${{ matrix.python-minor-version }} - token: ${{ secrets.CODECOV_ORG_TOKEN }} - - success: - permissions: - contents: none - needs: build - runs-on: ubuntu-latest - name: Cygwin Test Successful - steps: - - name: Success - run: echo Cygwin Test Successful diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml index 766c506e7..c80bb6eb6 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.yml @@ -35,11 +35,11 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["pypy3.11", "3.10", "3.11", "3.12", ">=3.13.5", "3.14"] + python-version: ["pypy3.11", "3.11", "3.12", "3.13", "3.14"] architecture: ["x64"] include: # Test the oldest Python on 32-bit - - { python-version: "3.9", architecture: "x86" } + - { python-version: "3.10", architecture: "x86" } timeout-minutes: 45 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d18023dbc..c075f04d7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -49,18 +49,17 @@ jobs: "3.12", "3.11", "3.10", - "3.9", ] include: - - { python-version: "3.11", PYTHONOPTIMIZE: 1, REVERSE: "--reverse" } - - { python-version: "3.10", PYTHONOPTIMIZE: 2 } + - { python-version: "3.12", PYTHONOPTIMIZE: 1, REVERSE: "--reverse" } + - { python-version: "3.11", PYTHONOPTIMIZE: 2 } # Free-threaded - { python-version: "3.14t", disable-gil: true } - { python-version: "3.13t", disable-gil: true } - # M1 only available for 3.10+ - - { os: "macos-13", python-version: "3.9" } + # Intel + - { os: "macos-13", python-version: "3.10" } exclude: - - { os: "macos-latest", python-version: "3.9" } + - { os: "macos-latest", python-version: "3.10" } runs-on: ${{ matrix.os }} name: ${{ matrix.os }} Python ${{ matrix.python-version }} diff --git a/README.md b/README.md index 365d356a0..8585ef6cb 100644 --- a/README.md +++ b/README.md @@ -36,9 +36,6 @@ As of 2019, Pillow development is GitHub Actions build status (Test MinGW) - GitHub Actions build status (Test Cygwin) GitHub Actions build status (Test Docker) diff --git a/Tests/helper.py b/Tests/helper.py index df99f5f55..e0dc8a9d4 100644 --- a/Tests/helper.py +++ b/Tests/helper.py @@ -10,17 +10,20 @@ import shutil import subprocess import sys import tempfile -from collections.abc import Sequence from functools import lru_cache from io import BytesIO -from pathlib import Path -from typing import Any, Callable import pytest from packaging.version import parse as parse_version from PIL import Image, ImageFile, ImageMath, features +TYPE_CHECKING = False +if TYPE_CHECKING: + from collections.abc import Callable, Sequence + from pathlib import Path + from typing import Any + logger = logging.getLogger(__name__) uploader = None diff --git a/Tests/test_features.py b/Tests/test_features.py index d9212daee..93d803fc1 100644 --- a/Tests/test_features.py +++ b/Tests/test_features.py @@ -2,7 +2,6 @@ from __future__ import annotations import io import re -from typing import Callable import pytest @@ -10,6 +9,10 @@ from PIL import features from .helper import skip_unless_feature +TYPE_CHECKING = False +if TYPE_CHECKING: + from collections.abc import Callable + def test_check() -> None: # Check the correctness of the convenience function diff --git a/Tests/test_format_hsv.py b/Tests/test_format_hsv.py index 9cbf18566..861eccc11 100644 --- a/Tests/test_format_hsv.py +++ b/Tests/test_format_hsv.py @@ -2,12 +2,15 @@ from __future__ import annotations import colorsys import itertools -from typing import Callable from PIL import Image from .helper import assert_image_similar, hopper +TYPE_CHECKING = False +if TYPE_CHECKING: + from collections.abc import Callable + def int_to_float(i: int) -> float: return i / 255 diff --git a/Tests/test_image_transform.py b/Tests/test_image_transform.py index 0429eb99d..7cf52ddba 100644 --- a/Tests/test_image_transform.py +++ b/Tests/test_image_transform.py @@ -1,7 +1,6 @@ from __future__ import annotations import math -from typing import Callable import pytest @@ -9,6 +8,10 @@ from PIL import Image, ImageTransform from .helper import assert_image_equal, assert_image_similar, hopper +TYPE_CHECKING = False +if TYPE_CHECKING: + from collections.abc import Callable + class TestImageTransform: def test_sanity(self) -> None: diff --git a/Tests/test_imagechops.py b/Tests/test_imagechops.py index 4309214f5..61812ca7d 100644 --- a/Tests/test_imagechops.py +++ b/Tests/test_imagechops.py @@ -1,11 +1,13 @@ from __future__ import annotations -from typing import Callable - from PIL import Image, ImageChops from .helper import assert_image_equal, hopper +TYPE_CHECKING = False +if TYPE_CHECKING: + from collections.abc import Callable + BLACK = (0, 0, 0) BROWN = (127, 64, 0) CYAN = (0, 255, 255) diff --git a/Tests/test_imagecms.py b/Tests/test_imagecms.py index 46c1baa2d..5fd7caa7c 100644 --- a/Tests/test_imagecms.py +++ b/Tests/test_imagecms.py @@ -211,9 +211,10 @@ def test_exceptions() -> None: ImageCms.getProfileName(None) # type: ignore[arg-type] skip_missing() - # Python <= 3.9: "an integer is required (got type NoneType)" - # Python > 3.9: "'NoneType' object cannot be interpreted as an integer" - with pytest.raises(ImageCms.PyCMSError, match="integer"): + with pytest.raises( + ImageCms.PyCMSError, + match="'NoneType' object cannot be interpreted as an integer", + ): ImageCms.isIntentSupported(SRGB, None, None) # type: ignore[arg-type] diff --git a/Tests/test_imagedraw.py b/Tests/test_imagedraw.py index e1dcbc52c..406d965b4 100644 --- a/Tests/test_imagedraw.py +++ b/Tests/test_imagedraw.py @@ -1,13 +1,10 @@ from __future__ import annotations import os.path -from collections.abc import Sequence -from typing import Callable import pytest from PIL import Image, ImageColor, ImageDraw, ImageFont, features -from PIL._typing import Coords from .helper import ( assert_image_equal, @@ -17,6 +14,12 @@ from .helper import ( skip_unless_feature, ) +TYPE_CHECKING = False +if TYPE_CHECKING: + from collections.abc import Callable, Sequence + + from PIL._typing import Coords + BLACK = (0, 0, 0) WHITE = (255, 255, 255) GRAY = (190, 190, 190) diff --git a/Tests/test_qt_image_qapplication.py b/Tests/test_qt_image_qapplication.py index 82a3e0741..b31e2a4ef 100644 --- a/Tests/test_qt_image_qapplication.py +++ b/Tests/test_qt_image_qapplication.py @@ -1,8 +1,5 @@ from __future__ import annotations -from pathlib import Path -from typing import Union - import pytest from PIL import Image, ImageQt @@ -11,18 +8,8 @@ from .helper import assert_image_equal_tofile, assert_image_similar, hopper TYPE_CHECKING = False if TYPE_CHECKING: - import PyQt6 - import PySide6 + from pathlib import Path - QApplication = Union[PyQt6.QtWidgets.QApplication, PySide6.QtWidgets.QApplication] - QHBoxLayout = Union[PyQt6.QtWidgets.QHBoxLayout, PySide6.QtWidgets.QHBoxLayout] - QImage = Union[PyQt6.QtGui.QImage, PySide6.QtGui.QImage] - QLabel = Union[PyQt6.QtWidgets.QLabel, PySide6.QtWidgets.QLabel] - QPainter = Union[PyQt6.QtGui.QPainter, PySide6.QtGui.QPainter] - QPixmap = Union[PyQt6.QtGui.QPixmap, PySide6.QtGui.QPixmap] - QPoint = Union[PyQt6.QtCore.QPoint, PySide6.QtCore.QPoint] - QRegion = Union[PyQt6.QtGui.QRegion, PySide6.QtGui.QRegion] - QWidget = Union[PyQt6.QtWidgets.QWidget, PySide6.QtWidgets.QWidget] if ImageQt.qt_is_installed: from PIL.ImageQt import QPixmap @@ -32,11 +19,16 @@ if ImageQt.qt_is_installed: from PyQt6.QtGui import QImage, QPainter, QRegion from PyQt6.QtWidgets import QApplication, QHBoxLayout, QLabel, QWidget elif ImageQt.qt_version == "side6": - from PySide6.QtCore import QPoint - from PySide6.QtGui import QImage, QPainter, QRegion - from PySide6.QtWidgets import QApplication, QHBoxLayout, QLabel, QWidget + from PySide6.QtCore import QPoint # type: ignore[assignment] + from PySide6.QtGui import QImage, QPainter, QRegion # type: ignore[assignment] + from PySide6.QtWidgets import ( # type: ignore[assignment] + QApplication, + QHBoxLayout, + QLabel, + QWidget, + ) - class Example(QWidget): # type: ignore[misc] + class Example(QWidget): def __init__(self) -> None: super().__init__() @@ -47,9 +39,9 @@ if ImageQt.qt_is_installed: pixmap1 = getattr(ImageQt.QPixmap, "fromImage")(qimage) # hbox - QHBoxLayout(self) # type: ignore[operator] + QHBoxLayout(self) - lbl = QLabel(self) # type: ignore[operator] + lbl = QLabel(self) # Segfault in the problem lbl.setPixmap(pixmap1.copy()) @@ -63,7 +55,7 @@ def roundtrip(expected: Image.Image) -> None: @pytest.mark.skipif(not ImageQt.qt_is_installed, reason="Qt bindings are not installed") def test_sanity(tmp_path: Path) -> None: # Segfault test - app: QApplication | None = QApplication([]) # type: ignore[operator] + app: QApplication | None = QApplication([]) ex = Example() assert app # Silence warning assert ex # Silence warning @@ -84,11 +76,11 @@ def test_sanity(tmp_path: Path) -> None: imageqt = ImageQt.ImageQt(im) data = getattr(QPixmap, "fromImage")(imageqt) qt_format = getattr(QImage, "Format") if ImageQt.qt_version == "6" else QImage - qimage = QImage(128, 128, getattr(qt_format, "Format_ARGB32")) # type: ignore[operator] - painter = QPainter(qimage) # type: ignore[operator] - image_label = QLabel() # type: ignore[operator] + qimage = QImage(128, 128, getattr(qt_format, "Format_ARGB32")) + painter = QPainter(qimage) + image_label = QLabel() image_label.setPixmap(data) - image_label.render(painter, QPoint(0, 0), QRegion(0, 0, 128, 128)) # type: ignore[operator] + image_label.render(painter, QPoint(0, 0), QRegion(0, 0, 128, 128)) painter.end() rendered_tempfile = str(tmp_path / f"temp_rendered_{mode}.png") qimage.save(rendered_tempfile) diff --git a/Tests/test_qt_image_toqimage.py b/Tests/test_qt_image_toqimage.py index 8cb7ffb9b..0004b5521 100644 --- a/Tests/test_qt_image_toqimage.py +++ b/Tests/test_qt_image_toqimage.py @@ -1,13 +1,15 @@ from __future__ import annotations -from pathlib import Path - import pytest from PIL import ImageQt from .helper import assert_image_equal, assert_image_equal_tofile, hopper +TYPE_CHECKING = False +if TYPE_CHECKING: + from pathlib import Path + pytestmark = pytest.mark.skipif( not ImageQt.qt_is_installed, reason="Qt bindings are not installed" ) @@ -21,7 +23,7 @@ def test_sanity(mode: str, tmp_path: Path) -> None: src = hopper(mode) data = ImageQt.toqimage(src) - assert isinstance(data, QImage) # type: ignore[arg-type, misc] + assert isinstance(data, QImage) assert not data.isNull() # reload directly from the qimage diff --git a/Tests/test_shell_injection.py b/Tests/test_shell_injection.py index 38d46f312..465517bb6 100644 --- a/Tests/test_shell_injection.py +++ b/Tests/test_shell_injection.py @@ -2,8 +2,6 @@ from __future__ import annotations import shutil from io import BytesIO -from pathlib import Path -from typing import IO, Callable import pytest @@ -11,6 +9,12 @@ from PIL import GifImagePlugin, Image, JpegImagePlugin from .helper import djpeg_available, is_win32, netpbm_available +TYPE_CHECKING = False +if TYPE_CHECKING: + from collections.abc import Callable + from pathlib import Path + from typing import IO + TEST_JPG = "Tests/images/hopper.jpg" TEST_GIF = "Tests/images/hopper.gif" diff --git a/checks/check_imaging_leaks.py b/checks/check_imaging_leaks.py index 231789ca0..a1d59ed9c 100755 --- a/checks/check_imaging_leaks.py +++ b/checks/check_imaging_leaks.py @@ -1,7 +1,8 @@ #!/usr/bin/env python3 from __future__ import annotations -from typing import Any, Callable +from collections.abc import Callable +from typing import Any import pytest diff --git a/docs/index.rst b/docs/index.rst index 689088d48..ee51621ac 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -29,10 +29,6 @@ Pillow for enterprise is available via the Tidelift Subscription. `Learn more =2024.10.12", ] -optional-dependencies.typing = [ - "typing-extensions; python_version<'3.10'", -] optional-dependencies.xmp = [ "defusedxml", ] @@ -189,8 +185,8 @@ lint.ignore = [ "PT011", # pytest-raises-too-broad "PT012", # pytest-raises-with-multiple-statements "PT017", # pytest-assert-in-except - "PYI026", # flake8-pyi: typing.TypeAlias added in Python 3.10 "PYI034", # flake8-pyi: typing.Self added in Python 3.11 + "UP038", # pyupgrade: deprecated rule ] lint.per-file-ignores."Tests/oss-fuzz/fuzz_font.py" = [ "I002", @@ -216,7 +212,7 @@ testpaths = [ ] [tool.mypy] -python_version = "3.9" +python_version = "3.10" pretty = true disallow_any_generics = true enable_error_code = "ignore-without-code" diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py index b03aa7f15..58c460ef3 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -31,7 +31,7 @@ import os import subprocess from enum import IntEnum from functools import cached_property -from typing import IO, Any, Literal, NamedTuple, Union, cast +from typing import Any, NamedTuple, cast from . import ( Image, @@ -49,6 +49,8 @@ from ._util import DeferredError TYPE_CHECKING = False if TYPE_CHECKING: + from typing import IO, Literal + from . import _imaging from ._typing import Buffer @@ -535,7 +537,7 @@ def _normalize_mode(im: Image.Image) -> Image.Image: return im.convert("L") -_Palette = Union[bytes, bytearray, list[int], ImagePalette.ImagePalette] +_Palette = bytes | bytearray | list[int] | ImagePalette.ImagePalette def _normalize_palette( diff --git a/src/PIL/GimpGradientFile.py b/src/PIL/GimpGradientFile.py index ec62f8e4e..5f2691882 100644 --- a/src/PIL/GimpGradientFile.py +++ b/src/PIL/GimpGradientFile.py @@ -21,10 +21,14 @@ See the GIMP distribution for more information.) from __future__ import annotations from math import log, pi, sin, sqrt -from typing import IO, Callable from ._binary import o8 +TYPE_CHECKING = False +if TYPE_CHECKING: + from collections.abc import Callable + from typing import IO + EPSILON = 1e-10 """""" # Enable auto-doc for data member diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index e95fa91f8..ed46899b4 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -34,20 +34,23 @@ from __future__ import annotations import math import struct from collections.abc import Sequence -from types import ModuleType -from typing import Any, AnyStr, Callable, Union, cast +from typing import cast from . import Image, ImageColor -from ._typing import Coords + +TYPE_CHECKING = False +if TYPE_CHECKING: + from collections.abc import Callable + from types import ModuleType + from typing import Any, AnyStr + + from . import ImageDraw2, ImageFont + from ._typing import Coords # experimental access to the outline API Outline: Callable[[], Image.core._Outline] = Image.core.outline -TYPE_CHECKING = False -if TYPE_CHECKING: - from . import ImageDraw2, ImageFont - -_Ink = Union[float, tuple[int, ...], str] +_Ink = float | tuple[int, ...] | str """ A simple 2D drawing interface for PIL images. diff --git a/src/PIL/ImageFilter.py b/src/PIL/ImageFilter.py index b9ed54ab2..9326eeeda 100644 --- a/src/PIL/ImageFilter.py +++ b/src/PIL/ImageFilter.py @@ -19,11 +19,14 @@ from __future__ import annotations import abc import functools from collections.abc import Sequence -from types import ModuleType -from typing import Any, Callable, cast +from typing import cast TYPE_CHECKING = False if TYPE_CHECKING: + from collections.abc import Callable + from types import ModuleType + from typing import Any + from . import _imaging from ._typing import NumpyArray diff --git a/src/PIL/ImageMath.py b/src/PIL/ImageMath.py index d2504b1ae..dfdc50c05 100644 --- a/src/PIL/ImageMath.py +++ b/src/PIL/ImageMath.py @@ -17,11 +17,15 @@ from __future__ import annotations import builtins -from types import CodeType -from typing import Any, Callable from . import Image, _imagingmath +TYPE_CHECKING = False +if TYPE_CHECKING: + from collections.abc import Callable + from types import CodeType + from typing import Any + class _Operand: """Wraps an image operand, providing standard operators""" diff --git a/src/PIL/ImageQt.py b/src/PIL/ImageQt.py index df7a57b65..af4d0742d 100644 --- a/src/PIL/ImageQt.py +++ b/src/PIL/ImageQt.py @@ -19,23 +19,18 @@ from __future__ import annotations import sys from io import BytesIO -from typing import Any, Callable, Union from . import Image from ._util import is_path TYPE_CHECKING = False if TYPE_CHECKING: - import PyQt6 - import PySide6 + from collections.abc import Callable + from typing import Any from . import ImageFile QBuffer: type - QByteArray = Union[PyQt6.QtCore.QByteArray, PySide6.QtCore.QByteArray] - QIODevice = Union[PyQt6.QtCore.QIODevice, PySide6.QtCore.QIODevice] - QImage = Union[PyQt6.QtGui.QImage, PySide6.QtGui.QImage] - QPixmap = Union[PyQt6.QtGui.QPixmap, PySide6.QtGui.QPixmap] qt_version: str | None qt_versions = [ @@ -49,11 +44,15 @@ for version, qt_module in qt_versions: try: qRgba: Callable[[int, int, int, int], int] if qt_module == "PyQt6": - from PyQt6.QtCore import QBuffer, QIODevice + from PyQt6.QtCore import QBuffer, QByteArray, QIODevice from PyQt6.QtGui import QImage, QPixmap, qRgba elif qt_module == "PySide6": - from PySide6.QtCore import QBuffer, QIODevice - from PySide6.QtGui import QImage, QPixmap, qRgba + from PySide6.QtCore import ( # type: ignore[assignment] + QBuffer, + QByteArray, + QIODevice, + ) + from PySide6.QtGui import QImage, QPixmap, qRgba # type: ignore[assignment] except (ImportError, RuntimeError): continue qt_is_installed = True @@ -183,7 +182,7 @@ def _toqclass_helper(im: Image.Image | str | QByteArray) -> dict[str, Any]: if qt_is_installed: - class ImageQt(QImage): # type: ignore[misc] + class ImageQt(QImage): def __init__(self, im: Image.Image | str | QByteArray) -> None: """ An PIL image wrapper for Qt. This is a subclass of PyQt's QImage diff --git a/src/PIL/ImageSequence.py b/src/PIL/ImageSequence.py index a6fc340d5..361be4897 100644 --- a/src/PIL/ImageSequence.py +++ b/src/PIL/ImageSequence.py @@ -16,10 +16,12 @@ ## from __future__ import annotations -from typing import Callable - from . import Image +TYPE_CHECKING = False +if TYPE_CHECKING: + from collections.abc import Callable + class Iterator: """ diff --git a/src/PIL/PcfFontFile.py b/src/PIL/PcfFontFile.py index 0d1968b14..a00e9b919 100644 --- a/src/PIL/PcfFontFile.py +++ b/src/PIL/PcfFontFile.py @@ -18,7 +18,6 @@ from __future__ import annotations import io -from typing import BinaryIO, Callable from . import FontFile, Image from ._binary import i8 @@ -27,6 +26,11 @@ from ._binary import i16le as l16 from ._binary import i32be as b32 from ._binary import i32le as l32 +TYPE_CHECKING = False +if TYPE_CHECKING: + from collections.abc import Callable + from typing import BinaryIO + # -------------------------------------------------------------------- # declarations diff --git a/src/PIL/PdfParser.py b/src/PIL/PdfParser.py index 73d8c21c0..2c9031469 100644 --- a/src/PIL/PdfParser.py +++ b/src/PIL/PdfParser.py @@ -8,7 +8,15 @@ import os import re import time import zlib -from typing import IO, Any, NamedTuple, Union +from typing import Any, NamedTuple + +TYPE_CHECKING = False +if TYPE_CHECKING: + from typing import IO + + _DictBase = collections.UserDict[str | bytes, Any] +else: + _DictBase = collections.UserDict # see 7.9.2.2 Text String Type on page 86 and D.3 PDFDocEncoding Character Set @@ -251,13 +259,6 @@ class PdfArray(list[Any]): return b"[ " + b" ".join(pdf_repr(x) for x in self) + b" ]" -TYPE_CHECKING = False -if TYPE_CHECKING: - _DictBase = collections.UserDict[Union[str, bytes], Any] -else: - _DictBase = collections.UserDict - - class PdfDict(_DictBase): def __setattr__(self, key: str, value: Any) -> None: if key == "data": diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index c1850f084..c1741284b 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -47,22 +47,24 @@ import math import os import struct import warnings -from collections.abc import Iterator, MutableMapping +from collections.abc import Callable, MutableMapping from fractions import Fraction from numbers import Number, Rational -from typing import IO, Any, Callable, NoReturn, cast +from typing import IO, Any, cast from . import ExifTags, Image, ImageFile, ImageOps, ImagePalette, TiffTags from ._binary import i16be as i16 from ._binary import i32be as i32 from ._binary import o8 -from ._typing import StrOrBytesPath from ._util import DeferredError, is_path from .TiffTags import TYPES TYPE_CHECKING = False if TYPE_CHECKING: - from ._typing import Buffer, IntegralLike + from collections.abc import Iterator + from typing import NoReturn + + from ._typing import Buffer, IntegralLike, StrOrBytesPath logger = logging.getLogger(__name__) diff --git a/src/PIL/_imagingcms.pyi b/src/PIL/_imagingcms.pyi index ddcf93ab1..4fc0d60ab 100644 --- a/src/PIL/_imagingcms.pyi +++ b/src/PIL/_imagingcms.pyi @@ -1,14 +1,14 @@ import datetime import sys -from typing import Literal, SupportsFloat, TypedDict +from typing import Literal, SupportsFloat, TypeAlias, TypedDict from ._typing import CapsuleType littlecms_version: str | None -_Tuple3f = tuple[float, float, float] -_Tuple2x3f = tuple[_Tuple3f, _Tuple3f] -_Tuple3x3f = tuple[_Tuple3f, _Tuple3f, _Tuple3f] +_Tuple3f: TypeAlias = tuple[float, float, float] +_Tuple2x3f: TypeAlias = tuple[_Tuple3f, _Tuple3f] +_Tuple3x3f: TypeAlias = tuple[_Tuple3f, _Tuple3f, _Tuple3f] class _IccMeasurementCondition(TypedDict): observer: int diff --git a/src/PIL/_imagingft.pyi b/src/PIL/_imagingft.pyi index 1cb1429d6..2136810ba 100644 --- a/src/PIL/_imagingft.pyi +++ b/src/PIL/_imagingft.pyi @@ -1,4 +1,5 @@ -from typing import Any, Callable +from collections.abc import Callable +from typing import Any from . import ImageFont, _imaging diff --git a/src/PIL/_typing.py b/src/PIL/_typing.py index e94045260..979147e0c 100644 --- a/src/PIL/_typing.py +++ b/src/PIL/_typing.py @@ -3,7 +3,7 @@ from __future__ import annotations import os import sys from collections.abc import Sequence -from typing import Any, Protocol, TypeVar, Union +from typing import Any, Protocol, TypeVar TYPE_CHECKING = False if TYPE_CHECKING: @@ -26,19 +26,8 @@ if sys.version_info >= (3, 12): else: Buffer = Any -if sys.version_info >= (3, 10): - from typing import TypeGuard -else: - try: - from typing_extensions import TypeGuard - except ImportError: - class TypeGuard: # type: ignore[no-redef] - def __class_getitem__(cls, item: Any) -> type[bool]: - return bool - - -Coords = Union[Sequence[float], Sequence[Sequence[float]]] +Coords = Sequence[float] | Sequence[Sequence[float]] _T_co = TypeVar("_T_co", covariant=True) @@ -48,7 +37,7 @@ class SupportsRead(Protocol[_T_co]): def read(self, length: int = ..., /) -> _T_co: ... -StrOrBytesPath = Union[str, bytes, os.PathLike[str], os.PathLike[bytes]] +StrOrBytesPath = str | bytes | os.PathLike[str] | os.PathLike[bytes] -__all__ = ["Buffer", "IntegralLike", "StrOrBytesPath", "SupportsRead", "TypeGuard"] +__all__ = ["Buffer", "IntegralLike", "StrOrBytesPath", "SupportsRead"] diff --git a/src/PIL/_util.py b/src/PIL/_util.py index 8ef0d36f7..b1fa6a0f3 100644 --- a/src/PIL/_util.py +++ b/src/PIL/_util.py @@ -1,9 +1,12 @@ from __future__ import annotations import os -from typing import Any, NoReturn -from ._typing import StrOrBytesPath, TypeGuard +TYPE_CHECKING = False +if TYPE_CHECKING: + from typing import Any, NoReturn, TypeGuard + + from ._typing import StrOrBytesPath def is_path(f: Any) -> TypeGuard[StrOrBytesPath]: diff --git a/tox.ini b/tox.ini index 967d4b537..8933945b1 100644 --- a/tox.ini +++ b/tox.ini @@ -3,7 +3,7 @@ requires = tox>=4.2 env_list = lint - py{py3, 314, 313, 312, 311, 310, 39} + py{py3, 314, 313, 312, 311, 310} [testenv] deps = @@ -29,7 +29,5 @@ commands = skip_install = true deps = -r .ci/requirements-mypy.txt -extras = - typing commands = mypy conftest.py selftest.py setup.py docs src winbuild Tests {posargs} From 148e1ac914c411925df2de1972d88f7a01ccde9e Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Sat, 2 Aug 2025 20:10:55 +0800 Subject: [PATCH 41/41] Add libavif support for iOS (#9117) Co-authored-by: Andrew Murray --- .github/workflows/wheels-dependencies.sh | 39 +++++++++++++++++------- checks/check_wheel.py | 3 +- 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh index 4519271b9..d58c65126 100755 --- a/.github/workflows/wheels-dependencies.sh +++ b/.github/workflows/wheels-dependencies.sh @@ -186,30 +186,43 @@ function build_libavif { python3 -m pip install meson ninja - if [[ "$PLAT" == "x86_64" ]] || [ -n "$SANITIZER" ]; then + if ([[ "$PLAT" == "x86_64" ]] && [[ -z "$IOS_SDK" ]]) || [ -n "$SANITIZER" ]; then build_simple nasm 2.16.03 https://www.nasm.us/pub/nasm/releasebuilds/2.16.03 fi local build_type=MinSizeRel + local build_shared=ON local lto=ON local libavif_cmake_flags - if [ -n "$IS_MACOS" ]; then + if [[ -n "$IS_MACOS" ]]; then lto=OFF libavif_cmake_flags=( -DCMAKE_C_FLAGS_MINSIZEREL="-Oz -DNDEBUG -flto" \ -DCMAKE_CXX_FLAGS_MINSIZEREL="-Oz -DNDEBUG -flto" \ -DCMAKE_SHARED_LINKER_FLAGS_INIT="-Wl,-S,-x,-dead_strip_dylibs" \ ) + if [[ -n "$IOS_SDK" ]]; then + build_shared=OFF + fi else if [[ "$MB_ML_VER" == 2014 ]] && [[ "$PLAT" == "x86_64" ]]; then build_type=Release fi libavif_cmake_flags=(-DCMAKE_SHARED_LINKER_FLAGS_INIT="-Wl,--strip-all,-z,relro,-z,now") fi + if [[ -n "$IOS_SDK" ]] && [[ "$PLAT" == "x86_64" ]]; then + libavif_cmake_flags+=(-DAOM_TARGET_CPU=generic) + else + libavif_cmake_flags+=( + -DAVIF_CODEC_AOM_DECODE=OFF \ + -DAVIF_CODEC_DAV1D=LOCAL + ) + fi local out_dir=$(fetch_unpack https://github.com/AOMediaCodec/libavif/archive/refs/tags/v$LIBAVIF_VERSION.tar.gz libavif-$LIBAVIF_VERSION.tar.gz) + # CONFIG_AV1_HIGHBITDEPTH=0 is a flag for libaom (included as a subproject # of libavif) that disables support for encoding high bit depth images. (cd $out_dir \ @@ -217,20 +230,27 @@ function build_libavif { -DCMAKE_INSTALL_PREFIX=$BUILD_PREFIX \ -DCMAKE_INSTALL_LIBDIR=$BUILD_PREFIX/lib \ -DCMAKE_INSTALL_NAME_DIR=$BUILD_PREFIX/lib \ - -DBUILD_SHARED_LIBS=ON \ + -DBUILD_SHARED_LIBS=$build_shared \ -DAVIF_LIBSHARPYUV=LOCAL \ -DAVIF_LIBYUV=LOCAL \ -DAVIF_CODEC_AOM=LOCAL \ -DCONFIG_AV1_HIGHBITDEPTH=0 \ - -DAVIF_CODEC_AOM_DECODE=OFF \ - -DAVIF_CODEC_DAV1D=LOCAL \ -DCMAKE_INTERPROCEDURAL_OPTIMIZATION=$lto \ -DCMAKE_C_VISIBILITY_PRESET=hidden \ -DCMAKE_CXX_VISIBILITY_PRESET=hidden \ -DCMAKE_BUILD_TYPE=$build_type \ "${libavif_cmake_flags[@]}" \ - . \ - && make install) + $HOST_CMAKE_FLAGS . ) + + if [[ -n "$IOS_SDK" ]]; then + # libavif's CMake configuration generates a meson cross file... but it + # doesn't work for iOS cross-compilation. Copy in Pillow-generated + # meson-cross config to replace the cmake-generated version. + cp $WORKDIR/meson-cross.txt $out_dir/crossfile-apple.meson + fi + + (cd $out_dir && make install) + touch libavif-stamp } @@ -268,10 +288,7 @@ function build { build_tiff fi - if [[ -z "$IOS_SDK" ]]; then - # Short term workaround; don't build libavif on iOS - build_libavif - fi + build_libavif build_libpng build_lcms2 build_openjpeg diff --git a/checks/check_wheel.py b/checks/check_wheel.py index 3d806eb71..937722c4b 100644 --- a/checks/check_wheel.py +++ b/checks/check_wheel.py @@ -25,8 +25,7 @@ def test_wheel_modules() -> None: elif sys.platform == "ios": # tkinter is not available on iOS - # libavif is not available on iOS (for now) - expected_modules -= {"tkinter", "avif"} + expected_modules.remove("tkinter") assert set(features.get_supported_modules()) == expected_modules