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/.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 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 abfeaa77f..000000000 --- a/.github/workflows/test-cygwin.yml +++ /dev/null @@ -1,154 +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@v5 - 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: 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: - 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 6d8acc44f..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", "pypy3.10", "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 b4b516228..c075f04d7 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", @@ -50,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/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh index 6b5aedb69..d58c65126 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 @@ -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/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 520c25b46..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 @@ -18,11 +21,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 +47,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") @@ -112,6 +107,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() 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_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_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/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 55a4a87fb..5fd7caa7c 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) @@ -208,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] @@ -690,3 +694,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/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_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/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/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 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/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 = 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/installation/platform-support.rst b/docs/installation/platform-support.rst index c2227f1d2..5cf0276d1 100644 --- a/docs/installation/platform-support.rst +++ b/docs/installation/platform-support.rst @@ -19,13 +19,13 @@ These platforms are built and tested for every change. +==================================+============================+=====================+ | Alpine | 3.12 | x86-64 | +----------------------------------+----------------------------+---------------------+ -| Amazon Linux 2 | 3.9 | x86-64 | +| Amazon Linux 2 | 3.10 | x86-64 | +----------------------------------+----------------------------+---------------------+ -| Amazon Linux 2023 | 3.9 | x86-64 | +| Amazon Linux 2023 | 3.11 | x86-64 | +----------------------------------+----------------------------+---------------------+ | Arch | 3.13 | x86-64 | +----------------------------------+----------------------------+---------------------+ -| CentOS Stream 9 | 3.9 | x86-64 | +| CentOS Stream 9 | 3.10 | x86-64 | +----------------------------------+----------------------------+---------------------+ | CentOS Stream 10 | 3.12 | x86-64 | +----------------------------------+----------------------------+---------------------+ @@ -37,27 +37,25 @@ These platforms are built and tested for every change. +----------------------------------+----------------------------+---------------------+ | Gentoo | 3.12 | x86-64 | +----------------------------------+----------------------------+---------------------+ -| macOS 13 Ventura | 3.9 | x86-64 | +| macOS 13 Ventura | 3.10 | x86-64 | +----------------------------------+----------------------------+---------------------+ -| macOS 14 Sonoma | 3.10, 3.11, 3.12, 3.13, | arm64 | -| | 3.14, PyPy3 | | +| macOS 14 Sonoma | 3.11, 3.12, 3.13, 3.14 | arm64 | +| | PyPy3 | | +----------------------------------+----------------------------+---------------------+ | Ubuntu Linux 22.04 LTS (Jammy) | 3.10 | x86-64 | +----------------------------------+----------------------------+---------------------+ -| Ubuntu Linux 24.04 LTS (Noble) | 3.9, 3.10, 3.11, | x86-64 | -| | 3.12, 3.13, 3.14, PyPy3 | | +| Ubuntu Linux 24.04 LTS (Noble) | 3.10, 3.11, 3.12, 3.13, | x86-64 | +| | 3.14, PyPy3 | | | +----------------------------+---------------------+ | | 3.12 | arm64v8, ppc64le, | | | | s390x | +----------------------------------+----------------------------+---------------------+ -| Windows Server 2022 | 3.9 | x86 | +| Windows Server 2022 | 3.10 | x86 | | +----------------------------+---------------------+ -| | 3.10, 3.11, 3.12, 3.13, | x86-64 | -| | 3.14, PyPy3 | | +| | 3.11, 3.12, 3.13, 3.14, | x86-64 | +| | PyPy3 | | | +----------------------------+---------------------+ | | 3.12 (MinGW) | x86-64 | -| +----------------------------+---------------------+ -| | 3.9 (Cygwin) | x86-64 | +----------------------------------+----------------------------+---------------------+ diff --git a/docs/reference/internal_modules.rst b/docs/reference/internal_modules.rst index 19f78864d..41a8837b3 100644 --- a/docs/reference/internal_modules.rst +++ b/docs/reference/internal_modules.rst @@ -53,11 +53,6 @@ on some Python versions. An object that supports the read method. -.. py:data:: TypeGuard - :value: typing.TypeGuard - - See :py:obj:`typing.TypeGuard`. - :mod:`~PIL._util` module ------------------------ diff --git a/docs/releasenotes/12.0.0.rst b/docs/releasenotes/12.0.0.rst index 68b664443..e21c243ea 100644 --- a/docs/releasenotes/12.0.0.rst +++ b/docs/releasenotes/12.0.0.rst @@ -17,6 +17,12 @@ TODO Backwards incompatible changes ============================== +Python 3.9 +^^^^^^^^^^ + +Pillow has dropped support for Python 3.9, +which reached end-of-life in October 2025. + ImageFile.raise_oserror ^^^^^^^^^^^^^^^^^^^^^^^ @@ -110,10 +116,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 =========== @@ -134,7 +142,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..137726a1c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,15 +20,15 @@ license-files = [ "LICENSE" ] authors = [ { name = "Jeffrey A. Clark", email = "aclark@aclark.net" }, ] -requires-python = ">=3.9" +requires-python = ">=3.10" classifiers = [ "Development Status :: 6 - Mature", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "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", @@ -67,7 +67,7 @@ optional-dependencies.tests = [ "markdown2", "olefile", "packaging", - "pyroma", + "pyroma>=5", "pytest", "pytest-cov", "pytest-timeout", @@ -75,9 +75,6 @@ optional-dependencies.tests = [ "trove-classifiers>=2024.10.12", ] -optional-dependencies.typing = [ - "typing-extensions; python_version<'3.10'", -] optional-dependencies.xmp = [ "defusedxml", ] @@ -188,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", @@ -206,7 +203,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" @@ -215,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/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/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 diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index 852e02698..f1b5dd4f3 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -34,18 +34,21 @@ from __future__ import annotations import math import struct from collections.abc import Sequence -from types import ModuleType -from typing import Any, AnyStr, Callable, cast +from typing import cast from . import Image, ImageColor, ImageText -from ._typing import Coords, _Ink - -# experimental access to the outline API -Outline: Callable[[], Image.core._Outline] = Image.core.outline 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, _Ink + +# experimental access to the outline API +Outline: Callable[[], Image.core._Outline] = Image.core.outline """ 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/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/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/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/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/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", 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 685c425d5..a941f8980 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: @@ -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): @@ -26,21 +26,10 @@ 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 +_Ink = float | tuple[int, ...] | str - -_Ink = Union[float, tuple[int, ...], str] - -Coords = Union[Sequence[float], Sequence[Sequence[float]]] +Coords = Sequence[float] | Sequence[Sequence[float]] _T_co = TypeVar("_T_co", covariant=True) @@ -50,7 +39,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/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); 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} 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",