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 823671828..d87d7956f 100644
--- a/.ci/requirements-cibw.txt
+++ b/.ci/requirements-cibw.txt
@@ -1 +1 @@
-cibuildwheel==3.1.2
+cibuildwheel==3.1.4
diff --git a/.ci/requirements-mypy.txt b/.ci/requirements-mypy.txt
index 99eac6027..bd9563800 100644
--- a/.ci/requirements-mypy.txt
+++ b/.ci/requirements-mypy.txt
@@ -1,4 +1,4 @@
-mypy==1.17.0
+mypy==1.17.1
IceSpringPySideStubs-PyQt6
IceSpringPySideStubs-PySide6
ipython
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/docs.yml b/.github/workflows/docs.yml
index 626824f38..761dc1125 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -32,7 +32,7 @@ jobs:
name: Docs
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
with:
persist-credentials: false
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 8e789a734..9827ef1cd 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -20,7 +20,7 @@ jobs:
name: Lint
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
with:
persist-credentials: false
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-docker.yml b/.github/workflows/test-docker.yml
index 0b90732eb..30e5c494d 100644
--- a/.github/workflows/test-docker.yml
+++ b/.github/workflows/test-docker.yml
@@ -47,6 +47,8 @@ jobs:
centos-stream-10-amd64,
debian-12-bookworm-x86,
debian-12-bookworm-amd64,
+ debian-13-trixie-x86,
+ debian-13-trixie-amd64,
fedora-41-amd64,
fedora-42-amd64,
gentoo,
@@ -66,7 +68,7 @@ jobs:
name: ${{ matrix.docker }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
with:
persist-credentials: false
diff --git a/.github/workflows/test-mingw.yml b/.github/workflows/test-mingw.yml
index 5a83c16c3..6c4206083 100644
--- a/.github/workflows/test-mingw.yml
+++ b/.github/workflows/test-mingw.yml
@@ -45,7 +45,7 @@ jobs:
steps:
- name: Checkout Pillow
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
with:
persist-credentials: false
diff --git a/.github/workflows/test-valgrind-memory.yml b/.github/workflows/test-valgrind-memory.yml
index e6a5f6e77..0f36fe30d 100644
--- a/.github/workflows/test-valgrind-memory.yml
+++ b/.github/workflows/test-valgrind-memory.yml
@@ -41,7 +41,7 @@ jobs:
name: ${{ matrix.docker }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
with:
persist-credentials: false
diff --git a/.github/workflows/test-valgrind.yml b/.github/workflows/test-valgrind.yml
index 8818b3b23..30caa0d4e 100644
--- a/.github/workflows/test-valgrind.yml
+++ b/.github/workflows/test-valgrind.yml
@@ -39,7 +39,7 @@ jobs:
name: ${{ matrix.docker }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
with:
persist-credentials: false
diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml
index 766c506e7..d55a8e5f5 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
@@ -47,19 +47,19 @@ jobs:
steps:
- name: Checkout Pillow
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
with:
persist-credentials: false
- name: Checkout cached dependencies
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
with:
persist-credentials: false
repository: python-pillow/pillow-depends
path: winbuild\depends
- name: Checkout extra test images
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
with:
persist-credentials: false
repository: python-pillow/test-images
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index d18023dbc..b17d08892 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -49,24 +49,23 @@ 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 }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
with:
persist-credentials: false
@@ -112,7 +111,7 @@ jobs:
GHA_PYTHON_VERSION: ${{ matrix.python-version }}
- name: Register gcc problem matcher
- if: "matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13'"
+ if: "matrix.os == 'ubuntu-latest' && matrix.python-version == '3.14'"
run: echo "::add-matcher::.github/problem-matchers/gcc.json"
- name: Build
diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh
index 4519271b9..c79cd2f17 100755
--- a/.github/workflows/wheels-dependencies.sh
+++ b/.github/workflows/wheels-dependencies.sh
@@ -94,7 +94,7 @@ ARCHIVE_SDIR=pillow-depends-main
# annotations have a source code patch that is required for some platforms. If
# you change those versions, ensure the patch is also updated.
FREETYPE_VERSION=2.13.3
-HARFBUZZ_VERSION=11.2.1
+HARFBUZZ_VERSION=11.3.3
LIBPNG_VERSION=1.6.50
JPEGTURBO_VERSION=3.1.1
OPENJPEG_VERSION=2.5.3
@@ -165,7 +165,7 @@ function build_brotli {
local out_dir=$(fetch_unpack https://github.com/google/brotli/archive/v$BROTLI_VERSION.tar.gz brotli-$BROTLI_VERSION.tar.gz)
(cd $out_dir \
&& cmake -DCMAKE_INSTALL_PREFIX=$BUILD_PREFIX -DCMAKE_INSTALL_LIBDIR=$BUILD_PREFIX/lib -DCMAKE_INSTALL_NAME_DIR=$BUILD_PREFIX/lib $HOST_CMAKE_FLAGS . \
- && make install)
+ && make -j4 install)
touch brotli-stamp
}
@@ -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 -j4 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/.github/workflows/wheels.yml b/.github/workflows/wheels.yml
index 5cc4f0355..24e78f965 100644
--- a/.github/workflows/wheels.yml
+++ b/.github/workflows/wheels.yml
@@ -99,14 +99,14 @@ jobs:
cibw_arch: arm64_iphoneos
- name: "iOS arm64 simulator"
platform: ios
- os: macos-latest
+ os: macos-14
cibw_arch: arm64_iphonesimulator
- name: "iOS x86_64 simulator"
platform: ios
os: macos-13
cibw_arch: x86_64_iphonesimulator
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
with:
persist-credentials: false
submodules: true
@@ -153,12 +153,12 @@ jobs:
- cibw_arch: ARM64
os: windows-11-arm
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
with:
persist-credentials: false
- name: Checkout extra test images
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
with:
persist-credentials: false
repository: python-pillow/test-images
@@ -234,7 +234,7 @@ jobs:
if: github.event_name != 'schedule'
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
with:
persist-credentials: false
@@ -256,7 +256,7 @@ jobs:
runs-on: ubuntu-latest
name: Upload wheels to scientific-python-nightly-wheels
steps:
- - uses: actions/download-artifact@v4
+ - uses: actions/download-artifact@v5
with:
pattern: dist-*
path: dist
@@ -278,7 +278,7 @@ jobs:
permissions:
id-token: write
steps:
- - uses: actions/download-artifact@v4
+ - uses: actions/download-artifact@v5
with:
pattern: dist-*
path: dist
diff --git a/.github/zizmor.yml b/.github/zizmor.yml
index 5bdc48c30..b56709781 100644
--- a/.github/zizmor.yml
+++ b/.github/zizmor.yml
@@ -1,5 +1,5 @@
# Configuration for the zizmor static analysis tool, run via pre-commit in CI
-# https://woodruffw.github.io/zizmor/configuration/
+# https://docs.zizmor.sh/configuration/
rules:
unpinned-uses:
config:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 75c7d3632..2be509d54 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.2
+ rev: v0.12.7
hooks:
- id: ruff-check
args: [--exit-non-zero-on-fix]
@@ -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.7
+ rev: v20.1.8
hooks:
- id: clang-format
types: [c]
@@ -57,7 +57,7 @@ repos:
- id: check-readthedocs
- id: check-renovate
- - repo: https://github.com/woodruffw/zizmor-pre-commit
+ - repo: https://github.com/zizmorcore/zizmor-pre-commit
rev: v1.11.0
hooks:
- id: zizmor
@@ -79,7 +79,7 @@ repos:
additional_dependencies: [trove-classifiers>=2024.10.12]
- repo: https://github.com/tox-dev/tox-ini-fmt
- rev: 1.5.0
+ rev: 1.6.0
hooks:
- id: tox-ini-fmt
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
-
diff --git a/Tests/helper.py b/Tests/helper.py
index df99f5f55..dbdd30b42 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
@@ -172,6 +175,14 @@ def skip_unless_feature(feature: str) -> pytest.MarkDecorator:
return pytest.mark.skipif(not features.check(feature), reason=reason)
+def has_feature_version(feature: str, required: str) -> bool:
+ version = features.version(feature)
+ assert version is not None
+ version_required = parse_version(required)
+ version_available = parse_version(version)
+ return version_available >= version_required
+
+
def skip_unless_feature_version(
feature: str, required: str, reason: str | None = None
) -> pytest.MarkDecorator:
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_file_gbr.py b/Tests/test_file_gbr.py
index 1b834cd3c..b8851d82b 100644
--- a/Tests/test_file_gbr.py
+++ b/Tests/test_file_gbr.py
@@ -1,8 +1,10 @@
from __future__ import annotations
+from io import BytesIO
+
import pytest
-from PIL import GbrImagePlugin, Image
+from PIL import GbrImagePlugin, Image, _binary
from .helper import assert_image_equal_tofile
@@ -31,8 +33,49 @@ def test_multiple_load_operations() -> None:
assert_image_equal_tofile(im, "Tests/images/gbr.png")
-def test_invalid_file() -> None:
- invalid_file = "Tests/images/flower.jpg"
+def create_gbr_image(info: dict[str, int] = {}, magic_number=b"") -> BytesIO:
+ return BytesIO(
+ b"".join(
+ _binary.o32be(i)
+ for i in [
+ info.get("header_size", 20),
+ info.get("version", 1),
+ info.get("width", 1),
+ info.get("height", 1),
+ info.get("color_depth", 1),
+ ]
+ )
+ + magic_number
+ )
- with pytest.raises(SyntaxError):
+
+def test_invalid_file() -> None:
+ for f in [
+ create_gbr_image({"header_size": 0}),
+ create_gbr_image({"width": 0}),
+ create_gbr_image({"height": 0}),
+ ]:
+ with pytest.raises(SyntaxError, match="not a GIMP brush"):
+ GbrImagePlugin.GbrImageFile(f)
+
+ invalid_file = "Tests/images/flower.jpg"
+ with pytest.raises(SyntaxError, match="Unsupported GIMP brush version"):
GbrImagePlugin.GbrImageFile(invalid_file)
+
+
+def test_unsupported_gimp_brush() -> None:
+ f = create_gbr_image({"color_depth": 2})
+ with pytest.raises(SyntaxError, match="Unsupported GIMP brush color depth: 2"):
+ GbrImagePlugin.GbrImageFile(f)
+
+
+def test_bad_magic_number() -> None:
+ f = create_gbr_image({"version": 2}, magic_number=b"badm")
+ with pytest.raises(SyntaxError, match="not a GIMP brush, bad magic number"):
+ GbrImagePlugin.GbrImageFile(f)
+
+
+def test_L() -> None:
+ f = create_gbr_image()
+ with Image.open(f) as im:
+ assert im.mode == "L"
diff --git a/Tests/test_file_pcd.py b/Tests/test_file_pcd.py
index 81a316fc1..9bf1a75f0 100644
--- a/Tests/test_file_pcd.py
+++ b/Tests/test_file_pcd.py
@@ -1,10 +1,15 @@
from __future__ import annotations
+from io import BytesIO
+
+import pytest
+
from PIL import Image
def test_load_raw() -> None:
with Image.open("Tests/images/hopper.pcd") as im:
+ assert im.size == (768, 512)
im.load() # should not segfault.
# Note that this image was created with a resized hopper
@@ -15,3 +20,13 @@ def test_load_raw() -> None:
# target = hopper().resize((768,512))
# assert_image_similar(im, target, 10)
+
+
+@pytest.mark.parametrize("orientation", (1, 3))
+def test_rotated(orientation: int) -> None:
+ with open("Tests/images/hopper.pcd", "rb") as fp:
+ data = bytearray(fp.read())
+ data[2048 + 1538] = orientation
+ f = BytesIO(data)
+ with Image.open(f) as im:
+ assert im.size == (512, 768)
diff --git a/Tests/test_file_webp_animated.py b/Tests/test_file_webp_animated.py
index 503761374..600448fb9 100644
--- a/Tests/test_file_webp_animated.py
+++ b/Tests/test_file_webp_animated.py
@@ -4,13 +4,13 @@ from collections.abc import Generator
from pathlib import Path
import pytest
-from packaging.version import parse as parse_version
-from PIL import GifImagePlugin, Image, WebPImagePlugin, features
+from PIL import GifImagePlugin, Image, WebPImagePlugin
from .helper import (
assert_image_equal,
assert_image_similar,
+ has_feature_version,
is_big_endian,
skip_unless_feature,
)
@@ -53,11 +53,8 @@ def test_write_animation_L(tmp_path: Path) -> None:
im.load()
assert_image_similar(im, orig.convert("RGBA"), 32.9)
- if is_big_endian():
- version = features.version_module("webp")
- assert version is not None
- if parse_version(version) < parse_version("1.2.2"):
- pytest.skip("Fails with libwebp earlier than 1.2.2")
+ if is_big_endian() and not has_feature_version("webp", "1.2.2"):
+ pytest.skip("Fails with libwebp earlier than 1.2.2")
orig.seek(orig.n_frames - 1)
im.seek(im.n_frames - 1)
orig.load()
@@ -81,11 +78,8 @@ def test_write_animation_RGB(tmp_path: Path) -> None:
assert_image_equal(im, frame1.convert("RGBA"))
# Compare second frame to original
- if is_big_endian():
- version = features.version_module("webp")
- assert version is not None
- if parse_version(version) < parse_version("1.2.2"):
- pytest.skip("Fails with libwebp earlier than 1.2.2")
+ if is_big_endian() and not has_feature_version("webp", "1.2.2"):
+ pytest.skip("Fails with libwebp earlier than 1.2.2")
im.seek(1)
im.load()
assert_image_equal(im, frame2.convert("RGBA"))
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.py b/Tests/test_image.py
index 83b027aa2..be7ca6a6f 100644
--- a/Tests/test_image.py
+++ b/Tests/test_image.py
@@ -388,6 +388,37 @@ class TestImage:
assert img_colors is not None
assert sorted(img_colors) == expected_colors
+ def test_alpha_composite_la(self) -> None:
+ # Arrange
+ expected_colors = sorted(
+ [
+ (3300, (255, 255)),
+ (1156, (170, 192)),
+ (1122, (128, 255)),
+ (1089, (0, 0)),
+ (1122, (255, 128)),
+ (1122, (0, 128)),
+ (1089, (0, 255)),
+ ]
+ )
+
+ dst = Image.new("LA", size=(100, 100), color=(0, 255))
+ draw = ImageDraw.Draw(dst)
+ draw.rectangle((0, 33, 100, 66), fill=(0, 128))
+ draw.rectangle((0, 67, 100, 100), fill=(0, 0))
+ src = Image.new("LA", size=(100, 100), color=(255, 255))
+ draw = ImageDraw.Draw(src)
+ draw.rectangle((33, 0, 66, 100), fill=(255, 128))
+ draw.rectangle((67, 0, 100, 100), fill=(255, 0))
+
+ # Act
+ img = Image.alpha_composite(dst, src)
+
+ # Assert
+ img_colors = img.getcolors()
+ assert img_colors is not None
+ assert sorted(img_colors) == expected_colors
+
def test_alpha_inplace(self) -> None:
src = Image.new("RGBA", (128, 128), "blue")
@@ -922,6 +953,17 @@ class TestImage:
reloaded_exif.load(exif.tobytes())
assert reloaded_exif.get_ifd(0x8769) == exif.get_ifd(0x8769)
+ def test_delete_ifd_tag(self) -> None:
+ with Image.open("Tests/images/flower.jpg") as im:
+ exif = im.getexif()
+ exif.get_ifd(0x8769)
+ assert 0x8769 in exif
+ del exif[0x8769]
+
+ reloaded_exif = Image.Exif()
+ reloaded_exif.load(exif.tobytes())
+ assert 0x8769 not in reloaded_exif
+
def test_exif_load_from_fp(self) -> None:
with Image.open("Tests/images/flower.jpg") as im:
data = im.info["exif"]
diff --git a/Tests/test_image_quantize.py b/Tests/test_image_quantize.py
index 6d313cb8c..d847c7440 100644
--- a/Tests/test_image_quantize.py
+++ b/Tests/test_image_quantize.py
@@ -1,11 +1,16 @@
from __future__ import annotations
import pytest
-from packaging.version import parse as parse_version
-from PIL import Image, features
+from PIL import Image
-from .helper import assert_image_similar, hopper, is_ppc64le, skip_unless_feature
+from .helper import (
+ assert_image_similar,
+ has_feature_version,
+ hopper,
+ is_ppc64le,
+ skip_unless_feature,
+)
def test_sanity() -> None:
@@ -23,11 +28,8 @@ def test_sanity() -> None:
@skip_unless_feature("libimagequant")
def test_libimagequant_quantize() -> None:
image = hopper()
- if is_ppc64le():
- version = features.version_feature("libimagequant")
- assert version is not None
- if parse_version(version) < parse_version("4"):
- pytest.skip("Fails with libimagequant earlier than 4.0.0 on ppc64le")
+ if is_ppc64le() and not has_feature_version("libimagequant", "4"):
+ pytest.skip("Fails with libimagequant earlier than 4.0.0 on ppc64le")
converted = image.quantize(100, Image.Quantize.LIBIMAGEQUANT)
assert converted.mode == "P"
assert_image_similar(converted.convert("RGB"), image, 15)
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 8b5d88ac8..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]
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_imagefontctl.py b/Tests/test_imagefontctl.py
index 5954de874..95af3fda8 100644
--- a/Tests/test_imagefontctl.py
+++ b/Tests/test_imagefontctl.py
@@ -7,6 +7,7 @@ from PIL import Image, ImageDraw, ImageFont
from .helper import (
assert_image_equal_tofile,
assert_image_similar_tofile,
+ has_feature_version,
skip_unless_feature,
)
@@ -104,11 +105,9 @@ def test_text_direction_ttb() -> None:
im = Image.new(mode="RGB", size=(100, 300))
draw = ImageDraw.Draw(im)
- try:
- draw.text((0, 0), "English あい", font=ttf, fill=500, direction="ttb")
- except ValueError as ex:
- if str(ex) == "libraqm 0.7 or greater required for 'ttb' direction":
- pytest.skip("libraqm 0.7 or greater not available")
+ if not has_feature_version("raqm", "0.7"):
+ pytest.skip("libraqm 0.7 or greater not available")
+ draw.text((0, 0), "English あい", font=ttf, fill=500, direction="ttb")
target = "Tests/images/test_direction_ttb.png"
assert_image_similar_tofile(im, target, 2.8)
@@ -119,19 +118,17 @@ def test_text_direction_ttb_stroke() -> None:
im = Image.new(mode="RGB", size=(100, 300))
draw = ImageDraw.Draw(im)
- try:
- draw.text(
- (27, 27),
- "あい",
- font=ttf,
- fill=500,
- direction="ttb",
- stroke_width=2,
- stroke_fill="#0f0",
- )
- except ValueError as ex:
- if str(ex) == "libraqm 0.7 or greater required for 'ttb' direction":
- pytest.skip("libraqm 0.7 or greater not available")
+ if not has_feature_version("raqm", "0.7"):
+ pytest.skip("libraqm 0.7 or greater not available")
+ draw.text(
+ (27, 27),
+ "あい",
+ font=ttf,
+ fill=500,
+ direction="ttb",
+ stroke_width=2,
+ stroke_fill="#0f0",
+ )
target = "Tests/images/test_direction_ttb_stroke.png"
assert_image_similar_tofile(im, target, 19.4)
@@ -219,14 +216,9 @@ def test_getlength(
im = Image.new(mode, (1, 1), 0)
d = ImageDraw.Draw(im)
- try:
- assert d.textlength(text, ttf, direction) == expected
- except ValueError as ex:
- if (
- direction == "ttb"
- and str(ex) == "libraqm 0.7 or greater required for 'ttb' direction"
- ):
- pytest.skip("libraqm 0.7 or greater not available")
+ if direction == "ttb" and not has_feature_version("raqm", "0.7"):
+ pytest.skip("libraqm 0.7 or greater not available")
+ assert d.textlength(text, ttf, direction) == expected
@pytest.mark.parametrize("mode", ("L", "1"))
@@ -242,17 +234,12 @@ def test_getlength_combine(mode: str, direction: str, text: str) -> None:
ttf = ImageFont.truetype("Tests/fonts/NotoSans-Regular.ttf", 48)
- try:
- target = ttf.getlength("ii", mode, direction)
- actual = ttf.getlength(text, mode, direction)
+ if direction == "ttb" and not has_feature_version("raqm", "0.7"):
+ pytest.skip("libraqm 0.7 or greater not available")
+ target = ttf.getlength("ii", mode, direction)
+ actual = ttf.getlength(text, mode, direction)
- assert actual == target
- except ValueError as ex:
- if (
- direction == "ttb"
- and str(ex) == "libraqm 0.7 or greater required for 'ttb' direction"
- ):
- pytest.skip("libraqm 0.7 or greater not available")
+ assert actual == target
@pytest.mark.parametrize("anchor", ("lt", "mm", "rb", "sm"))
@@ -265,11 +252,9 @@ def test_anchor_ttb(anchor: str) -> None:
d = ImageDraw.Draw(im)
d.line(((0, 200), (200, 200)), "gray")
d.line(((100, 0), (100, 400)), "gray")
- try:
- d.text((100, 200), text, fill="black", anchor=anchor, direction="ttb", font=f)
- except ValueError as ex:
- if str(ex) == "libraqm 0.7 or greater required for 'ttb' direction":
- pytest.skip("libraqm 0.7 or greater not available")
+ if not has_feature_version("raqm", "0.7"):
+ pytest.skip("libraqm 0.7 or greater not available")
+ d.text((100, 200), text, fill="black", anchor=anchor, direction="ttb", font=f)
assert_image_similar_tofile(im, path, 1) # fails at 5
@@ -310,10 +295,12 @@ combine_tests = (
# this tests various combining characters for anchor alignment and clipping
@pytest.mark.parametrize(
- "name, text, anchor, dir, epsilon", combine_tests, ids=[r[0] for r in combine_tests]
+ "name, text, anchor, direction, epsilon",
+ combine_tests,
+ ids=[r[0] for r in combine_tests],
)
def test_combine(
- name: str, text: str, dir: str | None, anchor: str | None, epsilon: float
+ name: str, text: str, direction: str | None, anchor: str | None, epsilon: float
) -> None:
path = f"Tests/images/test_combine_{name}.png"
f = ImageFont.truetype("Tests/fonts/NotoSans-Regular.ttf", 48)
@@ -322,11 +309,9 @@ def test_combine(
d = ImageDraw.Draw(im)
d.line(((0, 200), (400, 200)), "gray")
d.line(((200, 0), (200, 400)), "gray")
- try:
- d.text((200, 200), text, fill="black", anchor=anchor, direction=dir, font=f)
- except ValueError as ex:
- if str(ex) == "libraqm 0.7 or greater required for 'ttb' direction":
- pytest.skip("libraqm 0.7 or greater not available")
+ if direction == "ttb" and not has_feature_version("raqm", "0.7"):
+ pytest.skip("libraqm 0.7 or greater not available")
+ d.text((200, 200), text, fill="black", anchor=anchor, direction=direction, font=f)
assert_image_similar_tofile(im, path, epsilon)
diff --git a/Tests/test_imagestat.py b/Tests/test_imagestat.py
index 0dfbc5a2a..0baab7ce2 100644
--- a/Tests/test_imagestat.py
+++ b/Tests/test_imagestat.py
@@ -57,3 +57,13 @@ def test_constant() -> None:
assert st.rms[0] == 128
assert st.var[0] == 0
assert st.stddev[0] == 0
+
+
+def test_zero_count() -> None:
+ im = Image.new("L", (0, 0))
+
+ st = ImageStat.Stat(im)
+
+ assert st.mean == [0]
+ assert st.rms == [0]
+ assert st.var == [0]
diff --git a/Tests/test_numpy.py b/Tests/test_numpy.py
index ef54deeeb..f6acb3aff 100644
--- a/Tests/test_numpy.py
+++ b/Tests/test_numpy.py
@@ -28,15 +28,13 @@ def test_numpy_to_image() -> None:
a = numpy.array(data, dtype=dtype)
a.shape = TEST_IMAGE_SIZE
i = Image.fromarray(a)
- if list(i.getdata()) != data:
- print("data mismatch for", dtype)
+ assert list(i.getdata()) == data
else:
data = list(range(100))
a = numpy.array([[x] * bands for x in data], dtype=dtype)
a.shape = TEST_IMAGE_SIZE[0], TEST_IMAGE_SIZE[1], bands
i = Image.fromarray(a)
- if list(i.getchannel(0).getdata()) != list(range(100)):
- print("data mismatch for", dtype)
+ assert list(i.getchannel(0).getdata()) == list(range(100))
return i
# Check supported 1-bit integer formats
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..e9f202f3d 100755
--- a/checks/check_imaging_leaks.py
+++ b/checks/check_imaging_leaks.py
@@ -1,18 +1,20 @@
#!/usr/bin/env python3
from __future__ import annotations
-from typing import Any, Callable
+import sys
+from collections.abc import Callable
+from typing import Any
import pytest
from PIL import Image
-from .helper import is_win32
-
min_iterations = 100
max_iterations = 10000
-pytestmark = pytest.mark.skipif(is_win32(), reason="requires Unix or macOS")
+pytestmark = pytest.mark.skipif(
+ sys.platform.startswith("win32"), reason="requires Unix or macOS"
+)
def _get_mem_usage() -> float:
diff --git a/checks/check_j2k_leaks.py b/checks/check_j2k_leaks.py
index bbe35b591..7103d502e 100644
--- a/checks/check_j2k_leaks.py
+++ b/checks/check_j2k_leaks.py
@@ -1,12 +1,11 @@
from __future__ import annotations
+import sys
from io import BytesIO
import pytest
-from PIL import Image
-
-from .helper import is_win32, skip_unless_feature
+from PIL import Image, features
# Limits for testing the leak
mem_limit = 1024 * 1048576
@@ -15,8 +14,10 @@ iterations = int((mem_limit / stack_size) * 2)
test_file = "Tests/images/rgb_trns_ycbc.jp2"
pytestmark = [
- pytest.mark.skipif(is_win32(), reason="requires Unix or macOS"),
- skip_unless_feature("jpg_2000"),
+ pytest.mark.skipif(
+ sys.platform.startswith("win32"), reason="requires Unix or macOS"
+ ),
+ pytest.mark.skipif(not features.check("jpg_2000"), reason="jpg_2000 not available"),
]
diff --git a/checks/check_jpeg_leaks.py b/checks/check_jpeg_leaks.py
index 2f42ad734..2c27ce1d5 100644
--- a/checks/check_jpeg_leaks.py
+++ b/checks/check_jpeg_leaks.py
@@ -1,10 +1,11 @@
from __future__ import annotations
+import sys
from io import BytesIO
import pytest
-from .helper import hopper, is_win32
+from PIL import Image
iterations = 5000
@@ -18,7 +19,9 @@ valgrind --tool=massif python test-installed.py -s -v checks/check_jpeg_leaks.py
"""
-pytestmark = pytest.mark.skipif(is_win32(), reason="requires Unix or macOS")
+pytestmark = pytest.mark.skipif(
+ sys.platform.startswith("win32"), reason="requires Unix or macOS"
+)
"""
pre patch:
@@ -112,10 +115,10 @@ standard_chrominance_qtable = (
),
)
def test_qtables_leak(qtables: tuple[tuple[int, ...]] | list[tuple[int, ...]]) -> None:
- im = hopper("RGB")
- for _ in range(iterations):
- test_output = BytesIO()
- im.save(test_output, "JPEG", qtables=qtables)
+ with Image.open("Tests/images/hopper.ppm") as im:
+ for _ in range(iterations):
+ test_output = BytesIO()
+ im.save(test_output, "JPEG", qtables=qtables)
def test_exif_leak() -> None:
@@ -173,12 +176,12 @@ def test_exif_leak() -> None:
0 +----------------------------------------------------------------------->Gi
0 11.33
"""
- im = hopper("RGB")
exif = b"12345678" * 4096
- for _ in range(iterations):
- test_output = BytesIO()
- im.save(test_output, "JPEG", exif=exif)
+ with Image.open("Tests/images/hopper.ppm") as im:
+ for _ in range(iterations):
+ test_output = BytesIO()
+ im.save(test_output, "JPEG", exif=exif)
def test_base_save() -> None:
@@ -207,8 +210,7 @@ def test_base_save() -> None:
| :@ @@ @ # : : :: :: @:: :::: :::: :::: : : : : : : :::::::::::: :::@:::
0 +----------------------------------------------------------------------->Gi
0 7.882"""
- im = hopper("RGB")
-
- for _ in range(iterations):
- test_output = BytesIO()
- im.save(test_output, "JPEG")
+ with Image.open("Tests/images/hopper.ppm") as im:
+ for _ in range(iterations):
+ test_output = BytesIO()
+ im.save(test_output, "JPEG")
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/depends/install_imagequant.sh b/depends/install_imagequant.sh
index 88756f8f9..357214f1f 100755
--- a/depends/install_imagequant.sh
+++ b/depends/install_imagequant.sh
@@ -2,7 +2,7 @@
# install libimagequant
archive_name=libimagequant
-archive_version=4.3.4
+archive_version=4.4.0
archive=$archive_name-$archive_version
diff --git a/docs/handbook/third-party-plugins.rst b/docs/handbook/third-party-plugins.rst
index a189a5773..1c7dfb5e9 100644
--- a/docs/handbook/third-party-plugins.rst
+++ b/docs/handbook/third-party-plugins.rst
@@ -11,7 +11,7 @@ Here is a list of PyPI projects that offer additional plugins:
* :pypi:`heif-image-plugin`: Simple HEIF/HEIC images plugin, based on the pyheif library.
* :pypi:`jxlpy`: Introduces reading and writing support for JPEG XL.
* :pypi:`pillow-heif`: Python bindings to libheif for working with HEIF images.
-* :pypi:`pillow-jpls`: Plugin for the JPEG-LS codec, based on the Charls JPEG-LS implemetation. Python bindings implemented using pybind11.
+* :pypi:`pillow-jpls`: Plugin for the JPEG-LS codec, based on the Charls JPEG-LS implementation. Python bindings implemented using pybind11.
* :pypi:`pillow-jxl-plugin`: Plugin for JPEG-XL, using Rust for bindings.
* :pypi:`pillow-mbm`: Adds support for KSP's proprietary MBM texture format.
* :pypi:`pillow-svg`: Implements basic SVG read support. Supports basic paths, shapes, and text.
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 =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",
@@ -76,9 +75,6 @@ optional-dependencies.tests = [
"trove-classifiers>=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/FliImagePlugin.py b/src/PIL/FliImagePlugin.py
index 7c5bfeefa..ccb8a5953 100644
--- a/src/PIL/FliImagePlugin.py
+++ b/src/PIL/FliImagePlugin.py
@@ -30,7 +30,7 @@ from ._util import DeferredError
def _accept(prefix: bytes) -> bool:
return (
- len(prefix) >= 6
+ len(prefix) >= 16
and i16(prefix, 4) in [0xAF11, 0xAF12]
and i16(prefix, 14) in [0, 3] # flags
)
diff --git a/src/PIL/GbrImagePlugin.py b/src/PIL/GbrImagePlugin.py
index f319d7e84..d69295363 100644
--- a/src/PIL/GbrImagePlugin.py
+++ b/src/PIL/GbrImagePlugin.py
@@ -54,7 +54,7 @@ class GbrImageFile(ImageFile.ImageFile):
width = i32(self.fp.read(4))
height = i32(self.fp.read(4))
color_depth = i32(self.fp.read(4))
- if width <= 0 or height <= 0:
+ if width == 0 or height == 0:
msg = "not a GIMP brush"
raise SyntaxError(msg)
if color_depth not in (1, 4):
@@ -71,7 +71,7 @@ class GbrImageFile(ImageFile.ImageFile):
raise SyntaxError(msg)
self.info["spacing"] = i32(self.fp.read(4))
- comment = self.fp.read(comment_length)[:-1]
+ self.info["comment"] = self.fp.read(comment_length)[:-1]
if color_depth == 1:
self._mode = "L"
@@ -80,8 +80,6 @@ class GbrImageFile(ImageFile.ImageFile):
self._size = width, height
- self.info["comment"] = comment
-
# Image might not be small
Image._decompression_bomb_check(self.size)
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/GribStubImagePlugin.py b/src/PIL/GribStubImagePlugin.py
index 439fc5a3e..dfa798893 100644
--- a/src/PIL/GribStubImagePlugin.py
+++ b/src/PIL/GribStubImagePlugin.py
@@ -33,7 +33,7 @@ def register_handler(handler: ImageFile.StubHandler | None) -> None:
def _accept(prefix: bytes) -> bool:
- return prefix.startswith(b"GRIB") and prefix[7] == 1
+ return len(prefix) >= 8 and prefix.startswith(b"GRIB") and prefix[7] == 1
class GribStubImageFile(ImageFile.StubImageFile):
diff --git a/src/PIL/Image.py b/src/PIL/Image.py
index 262b5478b..354118a87 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__)
@@ -98,7 +103,6 @@ try:
raise ImportError(msg)
except ImportError as v:
- core = DeferredError.new(ImportError("The _imaging C module is not installed."))
# Explanations for ways that we know we might have an import error
if str(v).startswith("Module use of python"):
# The _imaging C module is present, but not compiled for
@@ -3566,9 +3570,8 @@ def alpha_composite(im1: Image, im2: Image) -> Image:
"""
Alpha composite im2 over im1.
- :param im1: The first image. Must have mode RGBA.
- :param im2: The second image. Must have mode RGBA, and the same size as
- the first image.
+ :param im1: The first image. Must have mode RGBA or LA.
+ :param im2: The second image. Must have the same mode and size as the first image.
:returns: An :py:class:`~PIL.Image.Image` object.
"""
@@ -4215,6 +4218,8 @@ class Exif(_ExifBase):
del self._info[tag]
else:
del self._data[tag]
+ if tag in self._ifds:
+ del self._ifds[tag]
def __iter__(self) -> Iterator[int]:
keys = set(self._data)
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/ImageFile.py b/src/PIL/ImageFile.py
index 27b27127e..e33b846d4 100644
--- a/src/PIL/ImageFile.py
+++ b/src/PIL/ImageFile.py
@@ -46,6 +46,18 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
MAXBLOCK = 65536
+"""
+By default, Pillow processes image data in blocks. This helps to prevent excessive use
+of resources. Codecs may disable this behaviour with ``_pulls_fd`` or ``_pushes_fd``.
+
+When reading an image, this is the number of bytes to read at once.
+
+When writing an image, this is the number of bytes to write at once.
+If the image width times 4 is greater, then that will be used instead.
+Plugins may also set a greater number.
+
+User code may set this to another number.
+"""
SAFEBLOCK = 1024 * 1024
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/ImageFont.py b/src/PIL/ImageFont.py
index bf3f471f5..a2bf9ccf9 100644
--- a/src/PIL/ImageFont.py
+++ b/src/PIL/ImageFont.py
@@ -671,11 +671,7 @@ class FreeTypeFont:
:returns: A list of the named styles in a variation font.
:exception OSError: If the font is not a variation font.
"""
- try:
- names = self.font.getvarnames()
- except AttributeError as e:
- msg = "FreeType 2.9.1 or greater is required"
- raise NotImplementedError(msg) from e
+ names = self.font.getvarnames()
return [name.replace(b"\x00", b"") for name in names]
def set_variation_by_name(self, name: str | bytes) -> None:
@@ -702,11 +698,7 @@ class FreeTypeFont:
:returns: A list of the axes in a variation font.
:exception OSError: If the font is not a variation font.
"""
- try:
- axes = self.font.getvaraxes()
- except AttributeError as e:
- msg = "FreeType 2.9.1 or greater is required"
- raise NotImplementedError(msg) from e
+ axes = self.font.getvaraxes()
for axis in axes:
if axis["name"]:
axis["name"] = axis["name"].replace(b"\x00", b"")
@@ -717,11 +709,7 @@ class FreeTypeFont:
:param axes: A list of values for each axis.
:exception OSError: If the font is not a variation font.
"""
- try:
- self.font.setvaraxes(axes)
- except AttributeError as e:
- msg = "FreeType 2.9.1 or greater is required"
- raise NotImplementedError(msg) from e
+ self.font.setvaraxes(axes)
class TransposedFont:
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/ImageStat.py b/src/PIL/ImageStat.py
index 8bc504526..3a1044ba4 100644
--- a/src/PIL/ImageStat.py
+++ b/src/PIL/ImageStat.py
@@ -120,7 +120,7 @@ class Stat:
@cached_property
def mean(self) -> list[float]:
"""Average (arithmetic mean) pixel level for each band in the image."""
- return [self.sum[i] / self.count[i] for i in self.bands]
+ return [self.sum[i] / self.count[i] if self.count[i] else 0 for i in self.bands]
@cached_property
def median(self) -> list[int]:
@@ -141,13 +141,20 @@ class Stat:
@cached_property
def rms(self) -> list[float]:
"""RMS (root-mean-square) for each band in the image."""
- return [math.sqrt(self.sum2[i] / self.count[i]) for i in self.bands]
+ return [
+ math.sqrt(self.sum2[i] / self.count[i]) if self.count[i] else 0
+ for i in self.bands
+ ]
@cached_property
def var(self) -> list[float]:
"""Variance for each band in the image."""
return [
- (self.sum2[i] - (self.sum[i] ** 2.0) / self.count[i]) / self.count[i]
+ (
+ (self.sum2[i] - (self.sum[i] ** 2.0) / self.count[i]) / self.count[i]
+ if self.count[i]
+ else 0
+ )
for i in self.bands
]
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/PcdImagePlugin.py b/src/PIL/PcdImagePlugin.py
index 3aa249988..7f9ab525c 100644
--- a/src/PIL/PcdImagePlugin.py
+++ b/src/PIL/PcdImagePlugin.py
@@ -32,7 +32,7 @@ class PcdImageFile(ImageFile.ImageFile):
assert self.fp is not None
self.fp.seek(2048)
- s = self.fp.read(2048)
+ s = self.fp.read(1539)
if not s.startswith(b"PCD_"):
msg = "not a PCD file"
@@ -46,14 +46,13 @@ class PcdImageFile(ImageFile.ImageFile):
self.tile_post_rotate = -90
self._mode = "RGB"
- self._size = 768, 512 # FIXME: not correct for rotated images!
+ self._size = (512, 768) if orientation in (1, 3) else (768, 512)
self.tile = [ImageFile._Tile("pcd", (0, 0) + self.size, 96 * 2048)]
def load_end(self) -> None:
if self.tile_post_rotate:
# Handle rotated PCDs
self.im = self.im.rotate(self.tile_post_rotate)
- self._size = self.im.size
#
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/PcxImagePlugin.py b/src/PIL/PcxImagePlugin.py
index 458d586c4..6b16d5385 100644
--- a/src/PIL/PcxImagePlugin.py
+++ b/src/PIL/PcxImagePlugin.py
@@ -39,7 +39,7 @@ logger = logging.getLogger(__name__)
def _accept(prefix: bytes) -> bool:
- return prefix[0] == 10 and prefix[1] in [0, 2, 3, 5]
+ return len(prefix) >= 2 and prefix[0] == 10 and prefix[1] in [0, 2, 3, 5]
##
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/PpmImagePlugin.py b/src/PIL/PpmImagePlugin.py
index db34d107a..307bc97ff 100644
--- a/src/PIL/PpmImagePlugin.py
+++ b/src/PIL/PpmImagePlugin.py
@@ -47,7 +47,7 @@ MODES = {
def _accept(prefix: bytes) -> bool:
- return prefix.startswith(b"P") and prefix[1] in b"0123456fy"
+ return len(prefix) >= 2 and prefix.startswith(b"P") and prefix[1] in b"0123456fy"
##
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/WmfImagePlugin.py b/src/PIL/WmfImagePlugin.py
index d569cb4b8..de714d337 100644
--- a/src/PIL/WmfImagePlugin.py
+++ b/src/PIL/WmfImagePlugin.py
@@ -80,7 +80,7 @@ class WmfStubImageFile(ImageFile.StubImageFile):
format_description = "Windows Metafile"
def _open(self) -> None:
- # check placable header
+ # check placeable header
s = self.fp.read(44)
if s.startswith(b"\xd7\xcd\xc6\x9a\x00\x00"):
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 373938e71..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:
@@ -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,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/src/_imagingft.c b/src/_imagingft.c
index a38ea507a..d0af25b30 100644
--- a/src/_imagingft.c
+++ b/src/_imagingft.c
@@ -1231,8 +1231,6 @@ glyph_error:
return NULL;
}
-#if FREETYPE_MAJOR > 2 || (FREETYPE_MAJOR == 2 && FREETYPE_MINOR > 9) || \
- (FREETYPE_MAJOR == 2 && FREETYPE_MINOR == 9 && FREETYPE_PATCH == 1)
static PyObject *
font_getvarnames(FontObject *self) {
int error;
@@ -1442,7 +1440,6 @@ font_setvaraxes(FontObject *self, PyObject *args) {
Py_RETURN_NONE;
}
-#endif
static void
font_dealloc(FontObject *self) {
@@ -1461,13 +1458,10 @@ static PyMethodDef font_methods[] = {
{"render", (PyCFunction)font_render, METH_VARARGS},
{"getsize", (PyCFunction)font_getsize, METH_VARARGS},
{"getlength", (PyCFunction)font_getlength, METH_VARARGS},
-#if FREETYPE_MAJOR > 2 || (FREETYPE_MAJOR == 2 && FREETYPE_MINOR > 9) || \
- (FREETYPE_MAJOR == 2 && FREETYPE_MINOR == 9 && FREETYPE_PATCH == 1)
{"getvarnames", (PyCFunction)font_getvarnames, METH_NOARGS},
{"getvaraxes", (PyCFunction)font_getvaraxes, METH_NOARGS},
{"setvarname", (PyCFunction)font_setvarname, METH_VARARGS},
{"setvaraxes", (PyCFunction)font_setvaraxes, METH_VARARGS},
-#endif
{NULL, NULL}
};
diff --git a/src/libImaging/AlphaComposite.c b/src/libImaging/AlphaComposite.c
index 8d6ee8862..280277e83 100644
--- a/src/libImaging/AlphaComposite.c
+++ b/src/libImaging/AlphaComposite.c
@@ -25,13 +25,12 @@ ImagingAlphaComposite(Imaging imDst, Imaging imSrc) {
int x, y;
/* Check arguments */
- if (!imDst || !imSrc || imDst->mode != IMAGING_MODE_RGBA ||
- imDst->type != IMAGING_TYPE_UINT8 || imDst->bands != 4) {
+ if (!imDst || !imSrc ||
+ (imDst->mode != IMAGING_MODE_RGBA && imDst->mode != IMAGING_MODE_LA)) {
return ImagingError_ModeError();
}
- if (imDst->mode != imSrc->mode || imDst->type != imSrc->type ||
- imDst->bands != imSrc->bands || imDst->xsize != imSrc->xsize ||
+ if (imDst->mode != imSrc->mode || imDst->xsize != imSrc->xsize ||
imDst->ysize != imSrc->ysize) {
return ImagingError_Mismatch();
}
diff --git a/tox.ini b/tox.ini
index 967d4b537..d58fd67b6 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}
+ mypy conftest.py selftest.py setup.py checks docs src winbuild Tests {posargs}
diff --git a/wheels/multibuild b/wheels/multibuild
index 42d761728..647393271 160000
--- a/wheels/multibuild
+++ b/wheels/multibuild
@@ -1 +1 @@
-Subproject commit 42d761728d141d8462cd9943f4329f12fe62b155
+Subproject commit 64739327166fcad1fa41ad9b23fa910fa244c84f
diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py
index fbff0daf2..5633519dd 100644
--- a/winbuild/build_prepare.py
+++ b/winbuild/build_prepare.py
@@ -116,11 +116,11 @@ V = {
"BROTLI": "1.1.0",
"FREETYPE": "2.13.3",
"FRIBIDI": "1.0.16",
- "HARFBUZZ": "11.2.1",
+ "HARFBUZZ": "11.3.3",
"JPEGTURBO": "3.1.1",
"LCMS2": "2.17",
"LIBAVIF": "1.3.0",
- "LIBIMAGEQUANT": "4.3.4",
+ "LIBIMAGEQUANT": "4.4.0",
"LIBPNG": "1.6.50",
"LIBWEBP": "1.6.0",
"OPENJPEG": "2.5.3",