Merge branch 'main' into vtf-support

This commit is contained in:
Andrew Murray 2024-05-22 20:15:21 +10:00 committed by GitHub
commit 1b567c5f66
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
479 changed files with 11150 additions and 6746 deletions

View File

@ -1,3 +1,10 @@
skip_commits:
files:
- ".github/**/*"
- ".gitmodules"
- "docs/**/*"
- "wheels/**/*"
version: '{build}' version: '{build}'
clone_folder: c:\pillow clone_folder: c:\pillow
init: init:
@ -6,6 +13,7 @@ init:
# Uncomment previous line to get RDP access during the build. # Uncomment previous line to get RDP access during the build.
environment: environment:
COVERAGE_CORE: sysmon
EXECUTABLE: python.exe EXECUTABLE: python.exe
TEST_OPTIONS: TEST_OPTIONS:
DEPLOY: YES DEPLOY: YES
@ -14,7 +22,7 @@ environment:
ARCHITECTURE: x86 ARCHITECTURE: x86
APPVEYOR_BUILD_WORKER_IMAGE: Visual Studio 2022 APPVEYOR_BUILD_WORKER_IMAGE: Visual Studio 2022
- PYTHON: C:/Python38-x64 - PYTHON: C:/Python38-x64
ARCHITECTURE: x64 ARCHITECTURE: AMD64
APPVEYOR_BUILD_WORKER_IMAGE: Visual Studio 2017 APPVEYOR_BUILD_WORKER_IMAGE: Visual Studio 2017
@ -26,7 +34,7 @@ install:
- xcopy /S /Y c:\test-images-main\* c:\pillow\tests\images - xcopy /S /Y c:\test-images-main\* c:\pillow\tests\images
- curl -fsSL -o nasm-win64.zip https://raw.githubusercontent.com/python-pillow/pillow-depends/main/nasm-2.16.01-win64.zip - curl -fsSL -o nasm-win64.zip https://raw.githubusercontent.com/python-pillow/pillow-depends/main/nasm-2.16.01-win64.zip
- 7z x nasm-win64.zip -oc:\ - 7z x nasm-win64.zip -oc:\
- choco install ghostscript --version=10.0.0.20230317 - choco install ghostscript --version=10.3.0
- path c:\nasm-2.16.01;C:\Program Files\gs\gs10.00.0\bin;%PATH% - path c:\nasm-2.16.01;C:\Program Files\gs\gs10.00.0\bin;%PATH%
- cd c:\pillow\winbuild\ - cd c:\pillow\winbuild\
- ps: | - ps: |

View File

@ -1 +1 @@
cibuildwheel==2.16.2 cibuildwheel==2.18.1

View File

@ -0,0 +1 @@
mypy==1.10.0

View File

@ -9,6 +9,7 @@ BinPackParameters: false
BreakBeforeBraces: Attach BreakBeforeBraces: Attach
ColumnLimit: 88 ColumnLimit: 88
DerivePointerAlignment: false DerivePointerAlignment: false
IndentGotoLabels: false
IndentWidth: 4 IndentWidth: 4
Language: Cpp Language: Cpp
PointerAlignment: Right PointerAlignment: Right

View File

@ -2,15 +2,19 @@
[report] [report]
# Regexes for lines to exclude from consideration # Regexes for lines to exclude from consideration
exclude_lines = exclude_also =
# Have to re-enable the standard pragma: # Don't complain if non-runnable code isn't run
pragma: no cover
# Don't complain if non-runnable code isn't run:
if 0: if 0:
if __name__ == .__main__.: if __name__ == .__main__.:
# Don't complain about debug code # Don't complain about debug code
if DEBUG: if DEBUG:
# Don't complain about compatibility code for missing optional dependencies
except ImportError
if TYPE_CHECKING:
@abc.abstractmethod
# Empty bodies in protocols or abstract methods
^\s*def [a-zA-Z0-9_]+\(.*\)(\s*->.*)?:\s*\.\.\.(\s*#.*)?$
^\s*\.\.\.(\s*#.*)?$
[run] [run]
omit = omit =

2
.github/FUNDING.yml vendored
View File

@ -1 +1 @@
tidelift: "pypi/Pillow" tidelift: "pypi/pillow"

View File

@ -48,6 +48,21 @@ Thank you.
* Python: * Python:
* Pillow: * Pillow:
```text
Please paste here the output of running:
python3 -m PIL.report
or
python3 -m PIL --report
Or the output of the following Python code:
from PIL import report
# or
from PIL import features
features.pilinfo(supported_formats=False)
```
<!-- <!--
Please include **code** that reproduces the issue and whenever possible, an **image** that demonstrates the issue. Please upload images to GitHub, not to third-party file hosting sites. If necessary, add the image to a zip or tar archive. Please include **code** that reproduces the issue and whenever possible, an **image** that demonstrates the issue. Please upload images to GitHub, not to third-party file hosting sites. If necessary, add the image to a zip or tar archive.

18
.github/problem-matchers/gcc.json vendored Normal file
View File

@ -0,0 +1,18 @@
{
"__comment": "Based on vscode-cpptools' Extension/package.json gcc rule",
"problemMatcher": [
{
"owner": "gcc-problem-matcher",
"pattern": [
{
"regexp": "^\\s*(.*):(\\d+):(\\d+):\\s+(?:fatal\\s+)?(warning|error):\\s+(.*)$",
"file": 1,
"line": 2,
"column": 3,
"severity": 4,
"message": 5
}
]
}
]
}

View File

@ -13,6 +13,8 @@ categories:
label: "Removal" label: "Removal"
- title: "Testing" - title: "Testing"
label: "Testing" label: "Testing"
- title: "Type hints"
label: "Type hints"
exclude-labels: exclude-labels:
- "changelog: skip" - "changelog: skip"

View File

@ -7,10 +7,12 @@ on:
paths: paths:
- ".github/workflows/docs.yml" - ".github/workflows/docs.yml"
- "docs/**" - "docs/**"
- "src/PIL/**"
pull_request: pull_request:
paths: paths:
- ".github/workflows/docs.yml" - ".github/workflows/docs.yml"
- "docs/**" - "docs/**"
- "src/PIL/**"
workflow_dispatch: workflow_dispatch:
permissions: permissions:
@ -37,16 +39,26 @@ jobs:
with: with:
python-version: "3.x" python-version: "3.x"
cache: pip cache: pip
cache-dependency-path: ".ci/*.sh" cache-dependency-path: |
".ci/*.sh"
"pyproject.toml"
- name: Build system information - name: Build system information
run: python3 .github/workflows/system-info.py run: python3 .github/workflows/system-info.py
- name: Cache libimagequant
uses: actions/cache@v4
id: cache-libimagequant
with:
path: ~/cache-libimagequant
key: ${{ runner.os }}-libimagequant-${{ hashFiles('depends/install_imagequant.sh') }}
- name: Install Linux dependencies - name: Install Linux dependencies
run: | run: |
.ci/install.sh .ci/install.sh
env: env:
GHA_PYTHON_VERSION: "3.x" GHA_PYTHON_VERSION: "3.x"
GHA_LIBIMAGEQUANT_CACHE_HIT: ${{ steps.cache-libimagequant.outputs.cache-hit }}
- name: Build - name: Build
run: | run: |

View File

@ -23,7 +23,7 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: pre-commit cache - name: pre-commit cache
uses: actions/cache@v3 uses: actions/cache@v4
with: with:
path: ~/.cache/pre-commit path: ~/.cache/pre-commit
key: lint-pre-commit-${{ hashFiles('**/.pre-commit-config.yaml') }} key: lint-pre-commit-${{ hashFiles('**/.pre-commit-config.yaml') }}

View File

@ -2,7 +2,16 @@
set -e set -e
brew install libtiff libjpeg openjpeg libimagequant webp little-cms2 freetype libraqm brew install \
freetype \
ghostscript \
libimagequant \
libjpeg \
libraqm \
libtiff \
little-cms2 \
openjpeg \
webp
export PKG_CONFIG_PATH="/usr/local/opt/openblas/lib/pkgconfig" export PKG_CONFIG_PATH="/usr/local/opt/openblas/lib/pkgconfig"
# TODO Update condition when cffi supports 3.13 # TODO Update condition when cffi supports 3.13

View File

@ -23,6 +23,6 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
# Drafts your next release notes as pull requests are merged into "main" # Drafts your next release notes as pull requests are merged into "main"
- uses: release-drafter/release-drafter@v5 - uses: release-drafter/release-drafter@v6
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@ -6,6 +6,7 @@ This sort of info is missing from GitHub Actions.
Requested here: Requested here:
https://github.com/actions/virtual-environments/issues/79 https://github.com/actions/virtual-environments/issues/79
""" """
from __future__ import annotations from __future__ import annotations
import os import os

View File

@ -8,7 +8,6 @@ on:
- ".github/workflows/docs.yml" - ".github/workflows/docs.yml"
- ".github/workflows/wheels*" - ".github/workflows/wheels*"
- ".gitmodules" - ".gitmodules"
- ".travis.yml"
- "docs/**" - "docs/**"
- "wheels/**" - "wheels/**"
pull_request: pull_request:
@ -16,7 +15,6 @@ on:
- ".github/workflows/docs.yml" - ".github/workflows/docs.yml"
- ".github/workflows/wheels*" - ".github/workflows/wheels*"
- ".gitmodules" - ".gitmodules"
- ".travis.yml"
- "docs/**" - "docs/**"
- "wheels/**" - "wheels/**"
workflow_dispatch: workflow_dispatch:
@ -28,6 +26,9 @@ concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
env:
COVERAGE_CORE: sysmon
jobs: jobs:
build: build:
runs-on: windows-latest runs-on: windows-latest
@ -51,10 +52,10 @@ jobs:
- name: Install Cygwin - name: Install Cygwin
uses: cygwin/cygwin-install-action@v4 uses: cygwin/cygwin-install-action@v4
with: with:
platform: x86_64
packages: > packages: >
gcc-g++ gcc-g++
ghostscript ghostscript
git
ImageMagick ImageMagick
jpeg jpeg
libfreetype-devel libfreetype-devel
@ -82,7 +83,7 @@ jobs:
zlib-devel zlib-devel
- name: Add Lapack to PATH - name: Add Lapack to PATH
uses: egor-tensin/cleanup-path@v3 uses: egor-tensin/cleanup-path@v4
with: with:
dirs: 'C:\cygwin\bin;C:\cygwin\lib\lapack' dirs: 'C:\cygwin\bin;C:\cygwin\lib\lapack'
@ -90,19 +91,13 @@ jobs:
run: | run: |
ln -sf c:/cygwin/bin/python3.${{ matrix.python-minor-version }} c:/cygwin/bin/python3 ln -sf c:/cygwin/bin/python3.${{ matrix.python-minor-version }} c:/cygwin/bin/python3
- name: Get latest NumPy version
id: latest-numpy
shell: bash.exe -eo pipefail -o igncr "{0}"
run: |
python3 -m pip list --outdated | grep numpy | sed -r 's/ +/ /g' | cut -d ' ' -f 3 | sed 's/^/version=/' >> $GITHUB_OUTPUT
- name: pip cache - name: pip cache
uses: actions/cache@v3 uses: actions/cache@v4
with: with:
path: 'C:\cygwin\home\runneradmin\.cache\pip' path: 'C:\cygwin\home\runneradmin\.cache\pip'
key: ${{ runner.os }}-cygwin-pip3.${{ matrix.python-minor-version }}-numpy${{ steps.latest-numpy.outputs.version }}-${{ hashFiles('.ci/install.sh') }} key: ${{ runner.os }}-cygwin-pip3.${{ matrix.python-minor-version }}-${{ hashFiles('.ci/install.sh') }}
restore-keys: | restore-keys: |
${{ runner.os }}-cygwin-pip3.${{ matrix.python-minor-version }}-numpy${{ steps.latest-numpy.outputs.version }}- ${{ runner.os }}-cygwin-pip3.${{ matrix.python-minor-version }}-
- name: Build system information - name: Build system information
run: | run: |
@ -112,11 +107,6 @@ jobs:
run: | run: |
bash.exe .ci/install.sh bash.exe .ci/install.sh
- name: Upgrade NumPy
shell: dash.exe -l "{0}"
run: |
python3 -m pip install -U "numpy<1.26"
- name: Build - name: Build
shell: bash.exe -eo pipefail -o igncr "{0}" shell: bash.exe -eo pipefail -o igncr "{0}"
run: | run: |
@ -143,11 +133,12 @@ jobs:
bash.exe .ci/after_success.sh bash.exe .ci/after_success.sh
- name: Upload coverage - name: Upload coverage
uses: codecov/codecov-action@v3 uses: codecov/codecov-action@v4
with: with:
file: ./coverage.xml file: ./coverage.xml
flags: GHA_Cygwin flags: GHA_Cygwin
name: Cygwin Python 3.${{ matrix.python-minor-version }} name: Cygwin Python 3.${{ matrix.python-minor-version }}
token: ${{ secrets.CODECOV_ORG_TOKEN }}
success: success:
permissions: permissions:

View File

@ -8,7 +8,6 @@ on:
- ".github/workflows/docs.yml" - ".github/workflows/docs.yml"
- ".github/workflows/wheels*" - ".github/workflows/wheels*"
- ".gitmodules" - ".gitmodules"
- ".travis.yml"
- "docs/**" - "docs/**"
- "wheels/**" - "wheels/**"
pull_request: pull_request:
@ -16,7 +15,6 @@ on:
- ".github/workflows/docs.yml" - ".github/workflows/docs.yml"
- ".github/workflows/wheels*" - ".github/workflows/wheels*"
- ".gitmodules" - ".gitmodules"
- ".travis.yml"
- "docs/**" - "docs/**"
- "wheels/**" - "wheels/**"
workflow_dispatch: workflow_dispatch:
@ -38,32 +36,31 @@ jobs:
docker: [ docker: [
# Run slower jobs first to give them a headstart and reduce waiting time # Run slower jobs first to give them a headstart and reduce waiting time
ubuntu-22.04-jammy-arm64v8, ubuntu-22.04-jammy-arm64v8,
ubuntu-22.04-jammy-ppc64le, ubuntu-24.04-noble-ppc64le,
ubuntu-22.04-jammy-s390x, ubuntu-24.04-noble-s390x,
# Then run the remainder # Then run the remainder
alpine, alpine,
amazon-2-amd64, amazon-2-amd64,
amazon-2023-amd64, amazon-2023-amd64,
arch, arch,
centos-7-amd64,
centos-stream-8-amd64,
centos-stream-9-amd64, centos-stream-9-amd64,
debian-11-bullseye-amd64, debian-11-bullseye-amd64,
debian-12-bookworm-x86, debian-12-bookworm-x86,
debian-12-bookworm-amd64, debian-12-bookworm-amd64,
fedora-38-amd64,
fedora-39-amd64, fedora-39-amd64,
fedora-40-amd64,
gentoo, gentoo,
ubuntu-20.04-focal-amd64, ubuntu-20.04-focal-amd64,
ubuntu-22.04-jammy-amd64, ubuntu-22.04-jammy-amd64,
ubuntu-24.04-noble-amd64,
] ]
dockerTag: [main] dockerTag: [main]
include: include:
- docker: "ubuntu-22.04-jammy-arm64v8" - docker: "ubuntu-22.04-jammy-arm64v8"
qemu-arch: "aarch64" qemu-arch: "aarch64"
- docker: "ubuntu-22.04-jammy-ppc64le" - docker: "ubuntu-24.04-noble-ppc64le"
qemu-arch: "ppc64le" qemu-arch: "ppc64le"
- docker: "ubuntu-22.04-jammy-s390x" - docker: "ubuntu-24.04-noble-s390x"
qemu-arch: "s390x" qemu-arch: "s390x"
name: ${{ matrix.docker }} name: ${{ matrix.docker }}
@ -85,8 +82,8 @@ jobs:
- name: Docker build - name: Docker build
run: | run: |
# The Pillow user in the docker container is UID 1000 # The Pillow user in the docker container is UID 1001
sudo chown -R 1000 $GITHUB_WORKSPACE sudo chown -R 1001 $GITHUB_WORKSPACE
docker run --name pillow_container -v $GITHUB_WORKSPACE:/Pillow pythonpillow/${{ matrix.docker }}:${{ matrix.dockerTag }} docker run --name pillow_container -v $GITHUB_WORKSPACE:/Pillow pythonpillow/${{ matrix.docker }}:${{ matrix.dockerTag }}
sudo chown -R runner $GITHUB_WORKSPACE sudo chown -R runner $GITHUB_WORKSPACE
@ -103,11 +100,12 @@ jobs:
MATRIX_DOCKER: ${{ matrix.docker }} MATRIX_DOCKER: ${{ matrix.docker }}
- name: Upload coverage - name: Upload coverage
uses: codecov/codecov-action@v3 uses: codecov/codecov-action@v4
with: with:
flags: GHA_Docker flags: GHA_Docker
name: ${{ matrix.docker }} name: ${{ matrix.docker }}
gcov: true gcov: true
token: ${{ secrets.CODECOV_ORG_TOKEN }}
success: success:
permissions: permissions:

View File

@ -8,7 +8,6 @@ on:
- ".github/workflows/docs.yml" - ".github/workflows/docs.yml"
- ".github/workflows/wheels*" - ".github/workflows/wheels*"
- ".gitmodules" - ".gitmodules"
- ".travis.yml"
- "docs/**" - "docs/**"
- "wheels/**" - "wheels/**"
pull_request: pull_request:
@ -16,7 +15,6 @@ on:
- ".github/workflows/docs.yml" - ".github/workflows/docs.yml"
- ".github/workflows/wheels*" - ".github/workflows/wheels*"
- ".gitmodules" - ".gitmodules"
- ".travis.yml"
- "docs/**" - "docs/**"
- "wheels/**" - "wheels/**"
workflow_dispatch: workflow_dispatch:
@ -28,6 +26,9 @@ concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
env:
COVERAGE_CORE: sysmon
jobs: jobs:
build: build:
runs-on: windows-latest runs-on: windows-latest
@ -66,10 +67,10 @@ jobs:
mingw-w64-x86_64-python3-cffi \ mingw-w64-x86_64-python3-cffi \
mingw-w64-x86_64-python3-numpy \ mingw-w64-x86_64-python3-numpy \
mingw-w64-x86_64-python3-olefile \ mingw-w64-x86_64-python3-olefile \
mingw-w64-x86_64-python3-pip \
mingw-w64-x86_64-python3-setuptools \ mingw-w64-x86_64-python3-setuptools \
mingw-w64-x86_64-python-pyqt6 mingw-w64-x86_64-python-pyqt6
python3 -m ensurepip
python3 -m pip install pyroma pytest pytest-cov pytest-timeout python3 -m pip install pyroma pytest pytest-cov pytest-timeout
pushd depends && ./install_extra_test_images.sh && popd pushd depends && ./install_extra_test_images.sh && popd
@ -84,8 +85,9 @@ jobs:
python3 -m pytest -vx --cov PIL --cov Tests --cov-report term --cov-report xml Tests python3 -m pytest -vx --cov PIL --cov Tests --cov-report term --cov-report xml Tests
- name: Upload coverage - name: Upload coverage
uses: codecov/codecov-action@v3 uses: codecov/codecov-action@v4
with: with:
file: ./coverage.xml file: ./coverage.xml
flags: GHA_Windows flags: GHA_Windows
name: "MSYS2 MinGW" name: "MSYS2 MinGW"
token: ${{ secrets.CODECOV_ORG_TOKEN }}

View File

@ -50,7 +50,7 @@ jobs:
- name: Build and Run Valgrind - name: Build and Run Valgrind
run: | run: |
# The Pillow user in the docker container is UID 1000 # The Pillow user in the docker container is UID 1001
sudo chown -R 1000 $GITHUB_WORKSPACE sudo chown -R 1001 $GITHUB_WORKSPACE
docker run --name pillow_container -e "PILLOW_VALGRIND_TEST=true" -v $GITHUB_WORKSPACE:/Pillow pythonpillow/${{ matrix.docker }}:${{ matrix.dockerTag }} docker run --name pillow_container -e "PILLOW_VALGRIND_TEST=true" -v $GITHUB_WORKSPACE:/Pillow pythonpillow/${{ matrix.docker }}:${{ matrix.dockerTag }}
sudo chown -R runner $GITHUB_WORKSPACE sudo chown -R runner $GITHUB_WORKSPACE

View File

@ -2,11 +2,12 @@ name: Test Windows
on: on:
push: push:
branches:
- "**"
paths-ignore: paths-ignore:
- ".github/workflows/docs.yml" - ".github/workflows/docs.yml"
- ".github/workflows/wheels*" - ".github/workflows/wheels*"
- ".gitmodules" - ".gitmodules"
- ".travis.yml"
- "docs/**" - "docs/**"
- "wheels/**" - "wheels/**"
pull_request: pull_request:
@ -14,7 +15,6 @@ on:
- ".github/workflows/docs.yml" - ".github/workflows/docs.yml"
- ".github/workflows/wheels*" - ".github/workflows/wheels*"
- ".gitmodules" - ".gitmodules"
- ".travis.yml"
- "docs/**" - "docs/**"
- "wheels/**" - "wheels/**"
workflow_dispatch: workflow_dispatch:
@ -26,6 +26,9 @@ concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
env:
COVERAGE_CORE: sysmon
jobs: jobs:
build: build:
runs-on: windows-latest runs-on: windows-latest
@ -66,8 +69,16 @@ jobs:
- name: Print build system information - name: Print build system information
run: python3 .github/workflows/system-info.py run: python3 .github/workflows/system-info.py
- name: python3 -m pip install pytest pytest-cov pytest-timeout defusedxml olefile pyroma - name: Install Python dependencies
run: python3 -m pip install pytest pytest-cov pytest-timeout defusedxml olefile pyroma run: >
python3 -m pip install
coverage>=7.4.2
defusedxml
olefile
pyroma
pytest
pytest-cov
pytest-timeout
- name: Install dependencies - name: Install dependencies
id: install id: install
@ -75,7 +86,7 @@ jobs:
choco install nasm --no-progress choco install nasm --no-progress
echo "C:\Program Files\NASM" >> $env:GITHUB_PATH echo "C:\Program Files\NASM" >> $env:GITHUB_PATH
choco install ghostscript --version=10.0.0.20230317 --no-progress choco install ghostscript --version=10.3.0 --no-progress
echo "C:\Program Files\gs\gs10.00.0\bin" >> $env:GITHUB_PATH echo "C:\Program Files\gs\gs10.00.0\bin" >> $env:GITHUB_PATH
# Install extra test images # Install extra test images
@ -89,7 +100,7 @@ jobs:
- name: Cache build - name: Cache build
id: build-cache id: build-cache
uses: actions/cache@v3 uses: actions/cache@v4
with: with:
path: winbuild\build path: winbuild\build
key: key:
@ -202,11 +213,12 @@ jobs:
shell: pwsh shell: pwsh
- name: Upload coverage - name: Upload coverage
uses: codecov/codecov-action@v3 uses: codecov/codecov-action@v4
with: with:
file: ./coverage.xml file: ./coverage.xml
flags: GHA_Windows flags: GHA_Windows
name: ${{ runner.os }} Python ${{ matrix.python-version }} name: ${{ runner.os }} Python ${{ matrix.python-version }}
token: ${{ secrets.CODECOV_ORG_TOKEN }}
success: success:
permissions: permissions:

View File

@ -8,7 +8,6 @@ on:
- ".github/workflows/docs.yml" - ".github/workflows/docs.yml"
- ".github/workflows/wheels*" - ".github/workflows/wheels*"
- ".gitmodules" - ".gitmodules"
- ".travis.yml"
- "docs/**" - "docs/**"
- "wheels/**" - "wheels/**"
pull_request: pull_request:
@ -16,7 +15,6 @@ on:
- ".github/workflows/docs.yml" - ".github/workflows/docs.yml"
- ".github/workflows/wheels*" - ".github/workflows/wheels*"
- ".gitmodules" - ".gitmodules"
- ".travis.yml"
- "docs/**" - "docs/**"
- "wheels/**" - "wheels/**"
workflow_dispatch: workflow_dispatch:
@ -28,6 +26,10 @@ concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
env:
COVERAGE_CORE: sysmon
FORCE_COLOR: 1
jobs: jobs:
build: build:
@ -35,7 +37,7 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
os: [ os: [
"macos-latest", "macos-14",
"ubuntu-latest", "ubuntu-latest",
] ]
python-version: [ python-version: [
@ -49,11 +51,21 @@ jobs:
"3.8", "3.8",
] ]
include: include:
- python-version: "3.9" - python-version: "3.11"
PYTHONOPTIMIZE: 1 PYTHONOPTIMIZE: 1
REVERSE: "--reverse" REVERSE: "--reverse"
- python-version: "3.8" - python-version: "3.10"
PYTHONOPTIMIZE: 2 PYTHONOPTIMIZE: 2
# M1 only available for 3.10+
- os: "macos-13"
python-version: "3.9"
- os: "macos-13"
python-version: "3.8"
exclude:
- os: "macos-14"
python-version: "3.9"
- os: "macos-14"
python-version: "3.8"
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
name: ${{ matrix.os }} Python ${{ matrix.python-version }} name: ${{ matrix.os }} Python ${{ matrix.python-version }}
@ -67,17 +79,28 @@ jobs:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
allow-prereleases: true allow-prereleases: true
cache: pip cache: pip
cache-dependency-path: ".ci/*.sh" cache-dependency-path: |
".ci/*.sh"
"pyproject.toml"
- name: Build system information - name: Build system information
run: python3 .github/workflows/system-info.py run: python3 .github/workflows/system-info.py
- name: Cache libimagequant
if: startsWith(matrix.os, 'ubuntu')
uses: actions/cache@v4
id: cache-libimagequant
with:
path: ~/cache-libimagequant
key: ${{ runner.os }}-libimagequant-${{ hashFiles('depends/install_imagequant.sh') }}
- name: Install Linux dependencies - name: Install Linux dependencies
if: startsWith(matrix.os, 'ubuntu') if: startsWith(matrix.os, 'ubuntu')
run: | run: |
.ci/install.sh .ci/install.sh
env: env:
GHA_PYTHON_VERSION: ${{ matrix.python-version }} GHA_PYTHON_VERSION: ${{ matrix.python-version }}
GHA_LIBIMAGEQUANT_CACHE_HIT: ${{ steps.cache-libimagequant.outputs.cache-hit }}
- name: Install macOS dependencies - name: Install macOS dependencies
if: startsWith(matrix.os, 'macOS') if: startsWith(matrix.os, 'macOS')
@ -86,6 +109,10 @@ jobs:
env: env:
GHA_PYTHON_VERSION: ${{ matrix.python-version }} GHA_PYTHON_VERSION: ${{ matrix.python-version }}
- name: Register gcc problem matcher
if: "matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12'"
run: echo "::add-matcher::.github/problem-matchers/gcc.json"
- name: Build - name: Build
run: | run: |
.ci/build.sh .ci/build.sh
@ -123,11 +150,12 @@ jobs:
.ci/after_success.sh .ci/after_success.sh
- name: Upload coverage - name: Upload coverage
uses: codecov/codecov-action@v3 uses: codecov/codecov-action@v4
with: with:
flags: ${{ matrix.os == 'macos-latest' && 'GHA_macOS' || 'GHA_Ubuntu' }} flags: ${{ matrix.os == 'ubuntu-latest' && 'GHA_Ubuntu' || 'GHA_macOS' }}
name: ${{ matrix.os }} Python ${{ matrix.python-version }} name: ${{ matrix.os }} Python ${{ matrix.python-version }}
gcov: true gcov: true
token: ${{ secrets.CODECOV_ORG_TOKEN }}
success: success:
permissions: permissions:

View File

@ -16,31 +16,31 @@ ARCHIVE_SDIR=pillow-depends-main
# Package versions for fresh source builds # Package versions for fresh source builds
FREETYPE_VERSION=2.13.2 FREETYPE_VERSION=2.13.2
HARFBUZZ_VERSION=8.3.0 HARFBUZZ_VERSION=8.4.0
LIBPNG_VERSION=1.6.40 LIBPNG_VERSION=1.6.43
JPEGTURBO_VERSION=3.0.1 JPEGTURBO_VERSION=3.0.2
OPENJPEG_VERSION=2.5.0 OPENJPEG_VERSION=2.5.2
XZ_VERSION=5.4.5 XZ_VERSION=5.4.5
TIFF_VERSION=4.6.0 TIFF_VERSION=4.6.0
LCMS2_VERSION=2.16 LCMS2_VERSION=2.16
if [[ -n "$IS_MACOS" ]]; then if [[ -n "$IS_MACOS" ]]; then
GIFLIB_VERSION=5.1.4 GIFLIB_VERSION=5.2.2
else else
GIFLIB_VERSION=5.2.1 GIFLIB_VERSION=5.2.1
fi fi
if [[ -n "$IS_MACOS" ]] || [[ "$MB_ML_VER" != 2014 ]]; then if [[ -n "$IS_MACOS" ]] || [[ "$MB_ML_VER" != 2014 ]]; then
ZLIB_VERSION=1.3 ZLIB_VERSION=1.3.1
else else
ZLIB_VERSION=1.2.8 ZLIB_VERSION=1.2.8
fi fi
LIBWEBP_VERSION=1.3.2 LIBWEBP_VERSION=1.3.2
BZIP2_VERSION=1.0.8 BZIP2_VERSION=1.0.8
LIBXCB_VERSION=1.16 LIBXCB_VERSION=1.16.1
BROTLI_VERSION=1.1.0 BROTLI_VERSION=1.1.0
if [[ -n "$IS_MACOS" ]] && [[ "$CIBW_ARCHS" == "x86_64" ]]; then if [[ -n "$IS_MACOS" ]] && [[ "$CIBW_ARCHS" == "x86_64" ]]; then
function build_openjpeg { function build_openjpeg {
local out_dir=$(fetch_unpack https://github.com/uclouvain/openjpeg/archive/v${OPENJPEG_VERSION}.tar.gz openjpeg-2.5.0.tar.gz) local out_dir=$(fetch_unpack https://github.com/uclouvain/openjpeg/archive/v${OPENJPEG_VERSION}.tar.gz openjpeg-${OPENJPEG_VERSION}.tar.gz)
(cd $out_dir \ (cd $out_dir \
&& cmake -DCMAKE_INSTALL_PREFIX=$BUILD_PREFIX -DCMAKE_INSTALL_NAME_DIR=$BUILD_PREFIX/lib . \ && cmake -DCMAKE_INSTALL_PREFIX=$BUILD_PREFIX -DCMAKE_INSTALL_NAME_DIR=$BUILD_PREFIX/lib . \
&& make install) && make install)
@ -62,7 +62,7 @@ function build_brotli {
function build { function build {
if [[ -n "$IS_MACOS" ]] && [[ "$CIBW_ARCHS" == "arm64" ]]; then if [[ -n "$IS_MACOS" ]] && [[ "$CIBW_ARCHS" == "arm64" ]]; then
export BUILD_PREFIX="/usr/local" sudo chown -R runner /usr/local
fi fi
build_xz build_xz
if [ -z "$IS_ALPINE" ] && [ -z "$IS_MACOS" ]; then if [ -z "$IS_ALPINE" ] && [ -z "$IS_MACOS" ]; then
@ -72,13 +72,11 @@ function build {
build_simple xcb-proto 1.16.0 https://xorg.freedesktop.org/archive/individual/proto build_simple xcb-proto 1.16.0 https://xorg.freedesktop.org/archive/individual/proto
if [ -n "$IS_MACOS" ]; then if [ -n "$IS_MACOS" ]; then
if [[ "$CIBW_ARCHS" == "arm64" ]]; then build_simple xorgproto 2024.1 https://www.x.org/pub/individual/proto
build_simple xorgproto 2023.2 https://www.x.org/pub/individual/proto
build_simple libXau 1.0.11 https://www.x.org/pub/individual/lib build_simple libXau 1.0.11 https://www.x.org/pub/individual/lib
build_simple libpthread-stubs 0.5 https://xcb.freedesktop.org/dist build_simple libpthread-stubs 0.5 https://xcb.freedesktop.org/dist
if [ -f /Library/Frameworks/Python.framework/Versions/Current/share/pkgconfig/xcb-proto.pc ]; then if [[ "$CIBW_ARCHS" == "arm64" ]]; then
cp /Library/Frameworks/Python.framework/Versions/Current/share/pkgconfig/xcb-proto.pc /Library/Frameworks/Python.framework/Versions/Current/lib/pkgconfig/xcb-proto.pc cp /usr/local/share/pkgconfig/xcb-proto.pc /usr/local/lib/pkgconfig
fi
fi fi
else else
sed s/\${pc_sysrootdir\}// /usr/local/share/pkgconfig/xcb-proto.pc > /usr/local/lib/pkgconfig/xcb-proto.pc sed s/\${pc_sysrootdir\}// /usr/local/share/pkgconfig/xcb-proto.pc > /usr/local/lib/pkgconfig/xcb-proto.pc
@ -89,12 +87,10 @@ function build {
build_tiff build_tiff
build_libpng build_libpng
build_lcms2 build_lcms2
if [[ -n "$IS_MACOS" ]] && [[ "$CIBW_ARCHS" == "arm64" ]]; then
for dylib in libjpeg.dylib libtiff.dylib liblcms2.dylib; do
cp $BUILD_PREFIX/lib/$dylib /opt/arm64-builds/lib
done
fi
build_openjpeg build_openjpeg
if [ -f /usr/local/lib64/libopenjp2.so ]; then
cp /usr/local/lib64/libopenjp2.so /usr/local/lib
fi
ORIGINAL_CFLAGS=$CFLAGS ORIGINAL_CFLAGS=$CFLAGS
CFLAGS="$CFLAGS -O3 -DNDEBUG" CFLAGS="$CFLAGS -O3 -DNDEBUG"
@ -130,14 +126,19 @@ curl -fsSL -o pillow-depends-main.zip https://github.com/python-pillow/pillow-de
untar pillow-depends-main.zip untar pillow-depends-main.zip
if [[ -n "$IS_MACOS" ]]; then if [[ -n "$IS_MACOS" ]]; then
# webp, libtiff, libxcb cause a conflict with building webp, libtiff, libxcb # libtiff and libxcb cause a conflict with building libtiff and libxcb
# libxdmcp causes an issue on macOS < 11 # libxau and libxdmcp cause an issue on macOS < 11
# if php is installed, brew tries to reinstall these after installing openblas
# remove cairo to fix building harfbuzz on arm64 # remove cairo to fix building harfbuzz on arm64
# remove lcms2 and libpng to fix building openjpeg on arm64 # remove lcms2 and libpng to fix building openjpeg on arm64
# remove zstd to avoid inclusion on x86_64 # remove jpeg-turbo to avoid inclusion on arm64
# remove webp and zstd to avoid inclusion on x86_64
# curl from brew requires zstd, use system curl # curl from brew requires zstd, use system curl
brew remove --ignore-dependencies webp libpng libtiff libxcb libxdmcp curl php cairo lcms2 ghostscript zstd brew remove --ignore-dependencies libpng libtiff libxcb libxau libxdmcp curl cairo lcms2 zstd
if [[ "$CIBW_ARCHS" == "arm64" ]]; then
brew remove --ignore-dependencies jpeg-turbo
else
brew remove --ignore-dependencies webp
fi
brew install pkg-config brew install pkg-config
fi fi

View File

@ -4,6 +4,9 @@ set -e
if [[ "$OSTYPE" == "darwin"* ]]; then if [[ "$OSTYPE" == "darwin"* ]]; then
brew install fribidi brew install fribidi
export PKG_CONFIG_PATH="/usr/local/opt/openblas/lib/pkgconfig" export PKG_CONFIG_PATH="/usr/local/opt/openblas/lib/pkgconfig"
if [ -f /opt/homebrew/lib/libfribidi.dylib ]; then
sudo cp /opt/homebrew/lib/libfribidi.dylib /usr/local/lib
fi
elif [ "${AUDITWHEEL_POLICY::9}" == "musllinux" ]; then elif [ "${AUDITWHEEL_POLICY::9}" == "musllinux" ]; then
apk add curl fribidi apk add curl fribidi
else else

View File

@ -5,6 +5,7 @@ on:
paths: paths:
- ".ci/requirements-cibw.txt" - ".ci/requirements-cibw.txt"
- ".github/workflows/wheel*" - ".github/workflows/wheel*"
- "setup.py"
- "wheels/*" - "wheels/*"
- "winbuild/build_prepare.py" - "winbuild/build_prepare.py"
- "winbuild/fribidi.cmake" - "winbuild/fribidi.cmake"
@ -14,6 +15,7 @@ on:
paths: paths:
- ".ci/requirements-cibw.txt" - ".ci/requirements-cibw.txt"
- ".github/workflows/wheel*" - ".github/workflows/wheel*"
- "setup.py"
- "wheels/*" - "wheels/*"
- "winbuild/build_prepare.py" - "winbuild/build_prepare.py"
- "winbuild/fribidi.cmake" - "winbuild/fribidi.cmake"
@ -30,7 +32,64 @@ env:
FORCE_COLOR: 1 FORCE_COLOR: 1
jobs: jobs:
build: build-1-QEMU-emulated-wheels:
name: aarch64 ${{ matrix.python-version }} ${{ matrix.spec }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version:
- pp39
- pp310
- cp38
- cp39
- cp310
- cp311
- cp312
spec:
- manylinux2014
- manylinux_2_28
- musllinux
exclude:
- { python-version: pp39, spec: musllinux }
- { python-version: pp310, spec: musllinux }
steps:
- uses: actions/checkout@v4
with:
submodules: true
- uses: actions/setup-python@v5
with:
python-version: "3.x"
# https://github.com/docker/setup-qemu-action
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Install cibuildwheel
run: |
python3 -m pip install -r .ci/requirements-cibw.txt
- name: Build wheels
run: |
python3 -m cibuildwheel --output-dir wheelhouse
env:
# Build only the currently selected Linux architecture (so we can
# parallelise for speed).
CIBW_ARCHS: "aarch64"
# Likewise, select only one Python version per job to speed this up.
CIBW_BUILD: "${{ matrix.python-version }}-${{ matrix.spec == 'musllinux' && 'musllinux' || 'manylinux' }}*"
# Extra options for manylinux.
CIBW_MANYLINUX_AARCH64_IMAGE: ${{ matrix.spec }}
CIBW_MANYLINUX_PYPY_AARCH64_IMAGE: ${{ matrix.spec }}
- uses: actions/upload-artifact@v4
with:
name: dist-qemu-${{ matrix.python-version }}-${{ matrix.spec }}
path: ./wheelhouse/*.whl
build-2-native-wheels:
name: ${{ matrix.name }} name: ${{ matrix.name }}
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
strategy: strategy:
@ -38,19 +97,19 @@ jobs:
matrix: matrix:
include: include:
- name: "macOS x86_64" - name: "macOS x86_64"
os: macos-latest os: macos-13
archs: x86_64 cibw_arch: x86_64
macosx_deployment_target: "10.10" macosx_deployment_target: "10.10"
- name: "macOS arm64" - name: "macOS arm64"
os: macos-latest os: macos-14
archs: arm64 cibw_arch: arm64
macosx_deployment_target: "11.0" macosx_deployment_target: "11.0"
- name: "manylinux2014 and musllinux x86_64" - name: "manylinux2014 and musllinux x86_64"
os: ubuntu-latest os: ubuntu-latest
archs: x86_64 cibw_arch: x86_64
- name: "manylinux_2_28 x86_64" - name: "manylinux_2_28 x86_64"
os: ubuntu-latest os: ubuntu-latest
archs: x86_64 cibw_arch: x86_64
build: "*manylinux*" build: "*manylinux*"
manylinux: "manylinux_2_28" manylinux: "manylinux_2_28"
steps: steps:
@ -62,37 +121,37 @@ jobs:
with: with:
python-version: "3.x" python-version: "3.x"
- name: Build wheels - name: Install cibuildwheel
run: | run: |
python3 -m pip install -r .ci/requirements-cibw.txt python3 -m pip install -r .ci/requirements-cibw.txt
- name: Build wheels
run: |
python3 -m cibuildwheel --output-dir wheelhouse python3 -m cibuildwheel --output-dir wheelhouse
env: env:
CIBW_ARCHS: ${{ matrix.archs }} CIBW_ARCHS: ${{ matrix.cibw_arch }}
CIBW_BUILD: ${{ matrix.build }} CIBW_BUILD: ${{ matrix.build }}
CIBW_MANYLINUX_PYPY_X86_64_IMAGE: ${{ matrix.manylinux }} CIBW_MANYLINUX_PYPY_X86_64_IMAGE: ${{ matrix.manylinux }}
CIBW_MANYLINUX_X86_64_IMAGE: ${{ matrix.manylinux }} CIBW_MANYLINUX_X86_64_IMAGE: ${{ matrix.manylinux }}
CIBW_SKIP: pp38-* CIBW_SKIP: pp38-*
CIBW_TEST_SKIP: "*-macosx_arm64" CIBW_TEST_SKIP: cp38-macosx_arm64
MACOSX_DEPLOYMENT_TARGET: ${{ matrix.macosx_deployment_target }} MACOSX_DEPLOYMENT_TARGET: ${{ matrix.macosx_deployment_target }}
- uses: actions/upload-artifact@v3 - uses: actions/upload-artifact@v4
with: with:
name: dist name: dist-${{ matrix.os }}-${{ matrix.cibw_arch }}${{ matrix.manylinux && format('-{0}', matrix.manylinux) }}
path: ./wheelhouse/*.whl path: ./wheelhouse/*.whl
windows: windows:
name: Windows ${{ matrix.arch }} name: Windows ${{ matrix.cibw_arch }}
runs-on: windows-latest runs-on: windows-latest
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
include: include:
- arch: x86 - cibw_arch: x86
cibw_arch: x86 - cibw_arch: AMD64
- arch: x64 - cibw_arch: ARM64
cibw_arch: AMD64
- arch: ARM64
cibw_arch: ARM64
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -106,6 +165,10 @@ jobs:
with: with:
python-version: "3.x" python-version: "3.x"
- name: Install cibuildwheel
run: |
python.exe -m pip install -r .ci/requirements-cibw.txt
- name: Prepare for build - name: Prepare for build
run: | run: |
choco install nasm --no-progress choco install nasm --no-progress
@ -114,12 +177,7 @@ jobs:
# Install extra test images # Install extra test images
xcopy /S /Y Tests\test-images\* Tests\images xcopy /S /Y Tests\test-images\* Tests\images
& python.exe -m pip install -r .ci/requirements-cibw.txt & python.exe winbuild\build_prepare.py -v --no-imagequant --architecture=${{ matrix.cibw_arch }}
# Cannot cross-compile FriBiDi (only used for tests)
$FLAGS = ("--no-imagequant", "--architecture=${{ matrix.arch }}")
if ('${{ matrix.arch }}' -eq 'ARM64') { $FLAGS += "--no-fribidi" }
& python.exe winbuild\build_prepare.py -v @FLAGS
shell: pwsh shell: pwsh
- name: Build wheels - name: Build wheels
@ -146,6 +204,7 @@ jobs:
CIBW_ARCHS: ${{ matrix.cibw_arch }} CIBW_ARCHS: ${{ matrix.cibw_arch }}
CIBW_BEFORE_ALL: "{package}\\winbuild\\build\\build_dep_all.cmd" CIBW_BEFORE_ALL: "{package}\\winbuild\\build\\build_dep_all.cmd"
CIBW_CACHE_PATH: "C:\\cibw" CIBW_CACHE_PATH: "C:\\cibw"
CIBW_SKIP: pp38-*
CIBW_TEST_SKIP: "*-win_arm64" CIBW_TEST_SKIP: "*-win_arm64"
CIBW_TEST_COMMAND: 'docker run --rm CIBW_TEST_COMMAND: 'docker run --rm
-v {project}:C:\pillow -v {project}:C:\pillow
@ -157,24 +216,16 @@ jobs:
shell: cmd shell: cmd
- name: Upload wheels - name: Upload wheels
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: dist name: dist-windows-${{ matrix.cibw_arch }}
path: ./wheelhouse/*.whl path: ./wheelhouse/*.whl
- name: Prepare to upload FriBiDi
if: "matrix.arch != 'ARM64'"
run: |
mkdir fribidi\${{ matrix.arch }}
copy winbuild\build\bin\fribidi* fribidi\${{ matrix.arch }}
shell: cmd
- name: Upload fribidi.dll - name: Upload fribidi.dll
if: "matrix.arch != 'ARM64'" uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3
with: with:
name: fribidi name: fribidi-windows-${{ matrix.cibw_arch }}
path: fribidi\* path: winbuild\build\bin\fribidi*
sdist: sdist:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -190,17 +241,26 @@ jobs:
- run: make sdist - run: make sdist
- uses: actions/upload-artifact@v3 - uses: actions/upload-artifact@v4
with: with:
name: dist name: dist-sdist
path: dist/*.tar.gz path: dist/*.tar.gz
success: pypi-publish:
permissions: if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
contents: none needs: [build-1-QEMU-emulated-wheels, build-2-native-wheels, windows, sdist]
needs: [build, windows, sdist]
runs-on: ubuntu-latest runs-on: ubuntu-latest
name: Wheels Successful name: Upload release to PyPI
environment:
name: release-pypi
url: https://pypi.org/p/Pillow
permissions:
id-token: write
steps: steps:
- name: Success - uses: actions/download-artifact@v4
run: echo Wheels Successful with:
pattern: dist-*
path: dist
merge-multiple: true
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1

View File

@ -1,37 +1,45 @@
repos: repos:
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.1.7 rev: v0.4.3
hooks: hooks:
- id: ruff - id: ruff
args: [--fix, --exit-non-zero-on-fix] args: [--exit-non-zero-on-fix]
- repo: https://github.com/psf/black-pre-commit-mirror - repo: https://github.com/psf/black-pre-commit-mirror
rev: 23.12.0 rev: 24.4.2
hooks: hooks:
- id: black - id: black
- repo: https://github.com/PyCQA/bandit - repo: https://github.com/PyCQA/bandit
rev: 1.7.6 rev: 1.7.8
hooks: hooks:
- id: bandit - id: bandit
args: [--severity-level=high] args: [--severity-level=high]
files: ^src/ files: ^src/
- repo: https://github.com/Lucas-C/pre-commit-hooks - repo: https://github.com/Lucas-C/pre-commit-hooks
rev: v1.5.4 rev: v1.5.5
hooks: hooks:
- id: remove-tabs - id: remove-tabs
exclude: (Makefile$|\.bat$|\.cmake$|\.eps$|\.fits$|\.gd$|\.opt$) exclude: (Makefile$|\.bat$|\.cmake$|\.eps$|\.fits$|\.gd$|\.opt$)
- repo: https://github.com/pre-commit/mirrors-clang-format
rev: v18.1.4
hooks:
- id: clang-format
types: [c]
exclude: ^src/thirdparty/
- repo: https://github.com/pre-commit/pygrep-hooks - repo: https://github.com/pre-commit/pygrep-hooks
rev: v1.10.0 rev: v1.10.0
hooks: hooks:
- id: rst-backticks - id: rst-backticks
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0 rev: v4.6.0
hooks: hooks:
- id: check-executables-have-shebangs - id: check-executables-have-shebangs
- id: check-shebang-scripts-are-executable
- id: check-merge-conflict - id: check-merge-conflict
- id: check-json - id: check-json
- id: check-toml - id: check-toml
@ -41,18 +49,25 @@ repos:
- id: trailing-whitespace - id: trailing-whitespace
exclude: ^.github/.*TEMPLATE|^Tests/(fonts|images)/ exclude: ^.github/.*TEMPLATE|^Tests/(fonts|images)/
- repo: https://github.com/python-jsonschema/check-jsonschema
rev: 0.28.2
hooks:
- id: check-github-workflows
- id: check-readthedocs
- id: check-renovate
- repo: https://github.com/sphinx-contrib/sphinx-lint - repo: https://github.com/sphinx-contrib/sphinx-lint
rev: v0.9.1 rev: v0.9.1
hooks: hooks:
- id: sphinx-lint - id: sphinx-lint
- repo: https://github.com/tox-dev/pyproject-fmt - repo: https://github.com/tox-dev/pyproject-fmt
rev: 1.5.3 rev: 1.8.0
hooks: hooks:
- id: pyproject-fmt - id: pyproject-fmt
- repo: https://github.com/abravalheri/validate-pyproject - repo: https://github.com/abravalheri/validate-pyproject
rev: v0.15 rev: v0.16
hooks: hooks:
- id: validate-pyproject - id: validate-pyproject
@ -61,5 +76,10 @@ repos:
hooks: hooks:
- id: tox-ini-fmt - id: tox-ini-fmt
- repo: meta
hooks:
- id: check-hooks-apply
- id: check-useless-excludes
ci: ci:
autoupdate_schedule: monthly autoupdate_schedule: monthly

View File

@ -6,6 +6,10 @@ build:
os: ubuntu-22.04 os: ubuntu-22.04
tools: tools:
python: "3" python: "3"
jobs:
post_checkout:
- git remote add upstream https://github.com/python-pillow/Pillow.git # For forks
- git fetch upstream --tags
python: python:
install: install:

View File

@ -1,52 +0,0 @@
if: tag IS present OR type = api
env:
global:
- CIBW_ARCHS=aarch64
- CIBW_SKIP=pp38-*
language: python
# Default Python version is usually 3.6
python: "3.12"
dist: jammy
services: docker
jobs:
include:
- name: "manylinux2014 aarch64"
os: linux
arch: arm64
env:
- CIBW_BUILD="*manylinux*"
- CIBW_MANYLINUX_AARCH64_IMAGE=manylinux2014
- CIBW_MANYLINUX_PYPY_AARCH64_IMAGE=manylinux2014
- name: "manylinux_2_28 aarch64"
os: linux
arch: arm64
env:
- CIBW_BUILD="*manylinux*"
- CIBW_MANYLINUX_AARCH64_IMAGE=manylinux_2_28
- CIBW_MANYLINUX_PYPY_AARCH64_IMAGE=manylinux_2_28
- name: "musllinux aarch64"
os: linux
arch: arm64
env:
- CIBW_BUILD="*musllinux*"
install:
- python3 -m pip install -r .ci/requirements-cibw.txt
script:
- python3 -m cibuildwheel --output-dir wheelhouse
- ls -l "${TRAVIS_BUILD_DIR}/wheelhouse/"
# Upload wheels to GitHub Releases
deploy:
provider: releases
api_key: $GITHUB_RELEASE_TOKEN
file_glob: true
file: "${TRAVIS_BUILD_DIR}/wheelhouse/*.whl"
on:
repo: python-pillow/Pillow
tags: true
skip_cleanup: true

View File

@ -2,9 +2,213 @@
Changelog (Pillow) Changelog (Pillow)
================== ==================
10.2.0 (unreleased) 10.4.0 (unreleased)
------------------- -------------------
- Deprecate BGR;15, BGR;16 and BGR;24 modes #7978
[radarhere, hugovk]
- Fix ImagingAccess for I;16N on big-endian #7921
[Yay295, radarhere]
- Support reading P mode TIFF images with padding #7996
[radarhere]
- Deprecate support for libtiff < 4 #7998
[radarhere, hugovk]
- Corrected ImageShow UnixViewer command #7987
[radarhere]
- Use functools.cached_property in ImageStat #7952
[nulano, hugovk, radarhere]
- Add support for reading BITMAPV2INFOHEADER and BITMAPV3INFOHEADER #7956
[Cirras, radarhere]
- Support reading CMYK JPEG2000 images #7947
[radarhere]
10.3.0 (2024-04-01)
-------------------
- CVE-2024-28219: Use ``strncpy`` to avoid buffer overflow #7928
[radarhere, hugovk]
- Deprecate ``eval()``, replacing it with ``lambda_eval()`` and ``unsafe_eval()`` #7927
[radarhere, hugovk]
- Raise ``ValueError`` if seeking to greater than offset-sized integer in TIFF #7883
[radarhere]
- Add ``--report`` argument to ``__main__.py`` to omit supported formats #7818
[nulano, radarhere, hugovk]
- Added RGB to I;16, I;16L, I;16B and I;16N conversion #7918, #7920
[radarhere]
- Fix editable installation with custom build backend and configuration options #7658
[nulano, radarhere]
- Fix putdata() for I;16N on big-endian #7209
[Yay295, hugovk, radarhere]
- Determine MPO size from markers, not EXIF data #7884
[radarhere]
- Improved conversion from RGB to RGBa, LA and La #7888
[radarhere]
- Support FITS images with GZIP_1 compression #7894
[radarhere]
- Use I;16 mode for 9-bit JPEG 2000 images #7900
[scaramallion, radarhere]
- Raise ValueError if kmeans is negative #7891
[radarhere]
- Remove TIFF tag OSUBFILETYPE when saving using libtiff #7893
[radarhere]
- Raise ValueError for negative values when loading P1-P3 PPM images #7882
[radarhere]
- Added reading of JPEG2000 palettes #7870
[radarhere]
- Added alpha_quality argument when saving WebP images #7872
[radarhere]
- Fixed joined corners for ImageDraw rounded_rectangle() non-integer dimensions #7881
[radarhere]
- Stop reading EPS image at EOF marker #7753
[radarhere]
- PSD layer co-ordinates may be negative #7706
[radarhere]
- Use subprocess with CREATE_NO_WINDOW flag in ImageShow WindowsViewer #7791
[radarhere]
- When saving GIF frame that restores to background color, do not fill identical pixels #7788
[radarhere]
- Fixed reading PNG iCCP compression method #7823
[radarhere]
- Allow writing IFDRational to UNDEFINED tag #7840
[radarhere]
- Fix logged tag name when loading Exif data #7842
[radarhere]
- Use maximum frame size in IHDR chunk when saving APNG images #7821
[radarhere]
- Prevent opening P TGA images without a palette #7797
[radarhere]
- Use palette when loading ICO images #7798
[radarhere]
- Use consistent arguments for load_read and load_seek #7713
[radarhere]
- Turn off nullability warnings for macOS SDK #7827
[radarhere]
- Fix shift-sign issue in Convert.c #7838
[r-barnes, radarhere]
- Open 16-bit grayscale PNGs as I;16 #7849
[radarhere]
- Handle truncated chunks at the end of PNG images #7709
[lajiyuan, radarhere]
- Match mask size to pasted image size in GifImagePlugin #7779
[radarhere]
- Release GIL while calling ``WebPAnimDecoderGetNext`` #7782
[evanmiller, radarhere]
- Fixed reading FLI/FLC images with a prefix chunk #7804
[twolife]
- Update wl-paste handling and return None for some errors in grabclipboard() on Linux #7745
[nik012003, radarhere]
- Remove execute bit from ``setup.py`` #7760
[hugovk]
- Do not support using test-image-results to upload images after test failures #7739
[radarhere]
- Changed ImageMath.ops to be static #7721
[radarhere]
- Fix APNG info after seeking backwards more than twice #7701
[esoma, radarhere]
- Deprecate ImageCms constants and versions() function #7702
[nulano, radarhere]
- Added PerspectiveTransform #7699
[radarhere]
- Add support for reading and writing grayscale PFM images #7696
[nulano, hugovk]
- Add LCMS2 flags to ImageCms #7676
[nulano, radarhere, hugovk]
- Rename x64 to AMD64 in winbuild #7693
[nulano]
10.2.0 (2024-01-02)
-------------------
- Add ``keep_rgb`` option when saving JPEG to prevent conversion of RGB colorspace #7553
[bgilbert, radarhere]
- Trim glyph size in ImageFont.getmask() #7669, #7672
[radarhere, nulano]
- Deprecate IptcImagePlugin helpers #7664
[nulano, hugovk, radarhere]
- Allow uncompressed TIFF images to be saved in chunks #7650
[radarhere]
- Concatenate multiple JPEG EXIF markers #7496
[radarhere]
- Changed IPTC tile tuple to match other plugins #7661
[radarhere]
- Do not assign new fp attribute when exiting context manager #7566
[radarhere]
- Support arbitrary masks for uncompressed RGB DDS images #7589
[radarhere, akx]
- Support setting ROWSPERSTRIP tag #7654
[radarhere]
- Apply ImageFont.MAX_STRING_LENGTH to ImageFont.getmask() #7662
[radarhere]
- Optimise ``ImageColor`` using ``functools.lru_cache`` #7657
[hugovk]
- Restricted environment keys for ImageMath.eval() #7655
[wiredfool, radarhere]
- Optimise ``ImageMode.getmode`` using ``functools.lru_cache`` #7641
[hugovk, radarhere]
- Fix incorrect color blending for overlapping glyphs #7497 - Fix incorrect color blending for overlapping glyphs #7497
[ZachNagengast, nulano, radarhere] [ZachNagengast, nulano, radarhere]
@ -4169,7 +4373,7 @@ Changelog (Pillow)
- Documentation changes, URL update, transpose, release checklist - Documentation changes, URL update, transpose, release checklist
[radarhere] [radarhere]
- Fixed saving to nonexistant files specified by pathlib.Path objects #1748 (fixes #1747) - Fixed saving to nonexistent files specified by pathlib.Path objects #1748 (fixes #1747)
[radarhere] [radarhere]
- Round Image.crop arguments to the nearest integer #1745 (fixes #1744) - Round Image.crop arguments to the nearest integer #1745 (fixes #1744)
@ -7380,7 +7584,7 @@ The test suite includes 400 individual tests.
- A handbook is available (distributed separately). - A handbook is available (distributed separately).
- The coordinate system is changed so that (0,0) is now located - The coordinate system is changed so that (0,0) is now located
in the upper left corner. This is in compliancy with ISO 12087 in the upper left corner. This is in compliance with ISO 12087
and 90% of all other image processing and graphics libraries. and 90% of all other image processing and graphics libraries.
- Modes "1" (bilevel) and "P" (palette) have been introduced. Note - Modes "1" (bilevel) and "P" (palette) have been introduced. Note

View File

@ -1,11 +1,11 @@
The Python Imaging Library (PIL) is The Python Imaging Library (PIL) is
Copyright © 1997-2011 by Secret Labs AB Copyright © 1997-2011 by Secret Labs AB
Copyright © 1995-2011 by Fredrik Lundh Copyright © 1995-2011 by Fredrik Lundh and contributors
Pillow is the friendly PIL fork. It is Pillow is the friendly PIL fork. It is
Copyright © 2010-2023 by Jeffrey A. Clark (Alex) and contributors. Copyright © 2010-2024 by Jeffrey A. Clark and contributors
Like PIL, Pillow is licensed under the open source HPND License: Like PIL, Pillow is licensed under the open source HPND License:

View File

@ -2,7 +2,6 @@
.PHONY: clean .PHONY: clean
clean: clean:
python3 setup.py clean
rm src/PIL/*.so || true rm src/PIL/*.so || true
rm -r build || true rm -r build || true
find . -name __pycache__ | xargs rm -r || true find . -name __pycache__ | xargs rm -r || true
@ -78,8 +77,6 @@ release-test:
python3 selftest.py python3 selftest.py
python3 -m pytest Tests python3 -m pytest Tests
python3 -m pip install . python3 -m pip install .
-rm dist/*.egg
-rmdir dist
python3 -m pytest -qq python3 -m pytest -qq
python3 -m check_manifest python3 -m check_manifest
python3 -m pyroma . python3 -m pyroma .

View File

@ -6,9 +6,9 @@
## Python Imaging Library (Fork) ## Python Imaging Library (Fork)
Pillow is the friendly PIL fork by [Jeffrey A. Clark (Alex) and Pillow is the friendly PIL fork by [Jeffrey A. Clark and
contributors](https://github.com/python-pillow/Pillow/graphs/contributors). contributors](https://github.com/python-pillow/Pillow/graphs/contributors).
PIL is the Python Imaging Library by Fredrik Lundh and Contributors. PIL is the Python Imaging Library by Fredrik Lundh and contributors.
As of 2019, Pillow development is As of 2019, Pillow development is
[supported by Tidelift](https://tidelift.com/subscription/pkg/pypi-pillow?utm_source=pypi-pillow&utm_medium=readme&utm_campaign=enterprise). [supported by Tidelift](https://tidelift.com/subscription/pkg/pypi-pillow?utm_source=pypi-pillow&utm_medium=readme&utm_campaign=enterprise).
@ -48,9 +48,6 @@ As of 2019, Pillow development is
<a href="https://github.com/python-pillow/Pillow/actions/workflows/wheels.yml"><img <a href="https://github.com/python-pillow/Pillow/actions/workflows/wheels.yml"><img
alt="GitHub Actions build status (Wheels)" alt="GitHub Actions build status (Wheels)"
src="https://github.com/python-pillow/Pillow/workflows/Wheels/badge.svg"></a> src="https://github.com/python-pillow/Pillow/workflows/Wheels/badge.svg"></a>
<a href="https://app.travis-ci.com/github/python-pillow/Pillow"><img
alt="Travis CI wheels build status (aarch64)"
src="https://img.shields.io/travis/com/python-pillow/Pillow/main.svg?label=aarch64%20wheels"></a>
<a href="https://app.codecov.io/gh/python-pillow/Pillow"><img <a href="https://app.codecov.io/gh/python-pillow/Pillow"><img
alt="Code coverage" alt="Code coverage"
src="https://codecov.io/gh/python-pillow/Pillow/branch/main/graph/badge.svg"></a> src="https://codecov.io/gh/python-pillow/Pillow/branch/main/graph/badge.svg"></a>
@ -67,11 +64,11 @@ As of 2019, Pillow development is
src="https://zenodo.org/badge/17549/python-pillow/Pillow.svg"></a> src="https://zenodo.org/badge/17549/python-pillow/Pillow.svg"></a>
<a href="https://tidelift.com/subscription/pkg/pypi-pillow?utm_source=pypi-pillow&utm_medium=badge"><img <a href="https://tidelift.com/subscription/pkg/pypi-pillow?utm_source=pypi-pillow&utm_medium=badge"><img
alt="Tidelift" alt="Tidelift"
src="https://tidelift.com/badges/package/pypi/Pillow?style=flat"></a> src="https://tidelift.com/badges/package/pypi/pillow?style=flat"></a>
<a href="https://pypi.org/project/Pillow/"><img <a href="https://pypi.org/project/pillow/"><img
alt="Newest PyPI version" alt="Newest PyPI version"
src="https://img.shields.io/pypi/v/pillow.svg"></a> src="https://img.shields.io/pypi/v/pillow.svg"></a>
<a href="https://pypi.org/project/Pillow/"><img <a href="https://pypi.org/project/pillow/"><img
alt="Number of PyPI downloads" alt="Number of PyPI downloads"
src="https://img.shields.io/pypi/dm/pillow.svg"></a> src="https://img.shields.io/pypi/dm/pillow.svg"></a>
<a href="https://www.bestpractices.dev/projects/6331"><img <a href="https://www.bestpractices.dev/projects/6331"><img
@ -85,9 +82,6 @@ As of 2019, Pillow development is
<a href="https://gitter.im/python-pillow/Pillow?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge"><img <a href="https://gitter.im/python-pillow/Pillow?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge"><img
alt="Join the chat at https://gitter.im/python-pillow/Pillow" alt="Join the chat at https://gitter.im/python-pillow/Pillow"
src="https://badges.gitter.im/python-pillow/Pillow.svg"></a> src="https://badges.gitter.im/python-pillow/Pillow.svg"></a>
<a href="https://twitter.com/PythonPillow"><img
alt="Follow on https://twitter.com/PythonPillow"
src="https://img.shields.io/badge/tweet-on%20Twitter-00aced.svg"></a>
<a href="https://fosstodon.org/@pillow"><img <a href="https://fosstodon.org/@pillow"><img
alt="Follow on https://fosstodon.org/@pillow" alt="Follow on https://fosstodon.org/@pillow"
src="https://img.shields.io/badge/publish-on%20Mastodon-595aff.svg" src="https://img.shields.io/badge/publish-on%20Mastodon-595aff.svg"
@ -107,7 +101,7 @@ The core image library is designed for fast access to data stored in a few basic
## More Information ## More Information
- [Documentation](https://pillow.readthedocs.io/) - [Documentation](https://pillow.readthedocs.io/)
- [Installation](https://pillow.readthedocs.io/en/latest/installation.html) - [Installation](https://pillow.readthedocs.io/en/latest/installation/basic-installation.html)
- [Handbook](https://pillow.readthedocs.io/en/latest/handbook/index.html) - [Handbook](https://pillow.readthedocs.io/en/latest/handbook/index.html)
- [Contribute](https://github.com/python-pillow/Pillow/blob/main/.github/CONTRIBUTING.md) - [Contribute](https://github.com/python-pillow/Pillow/blob/main/.github/CONTRIBUTING.md)
- [Issues](https://github.com/python-pillow/Pillow/issues) - [Issues](https://github.com/python-pillow/Pillow/issues)

View File

@ -10,7 +10,7 @@ Released quarterly on January 2nd, April 1st, July 1st and October 15th.
* [ ] Open a release ticket e.g. https://github.com/python-pillow/Pillow/issues/3154 * [ ] Open a release ticket e.g. https://github.com/python-pillow/Pillow/issues/3154
* [ ] Develop and prepare release in `main` branch. * [ ] Develop and prepare release in `main` branch.
* [ ] Check [GitHub Actions](https://github.com/python-pillow/Pillow/actions) and [AppVeyor](https://ci.appveyor.com/project/python-pillow/Pillow) to confirm passing tests in `main` branch. * [ ] Check [GitHub Actions](https://github.com/python-pillow/Pillow/actions) and [AppVeyor](https://ci.appveyor.com/project/python-pillow/Pillow) to confirm passing tests in `main` branch.
* [ ] Check that all of the wheel builds pass the tests in the [GitHub Actions "Wheels" workflow](https://github.com/python-pillow/Pillow/actions/workflows/wheels.yml) and [Travis CI](https://app.travis-ci.com/github/python-pillow/pillow) jobs by manually triggering them. * [ ] Check that all the wheel builds pass the tests in the [GitHub Actions "Wheels" workflow](https://github.com/python-pillow/Pillow/actions/workflows/wheels.yml) jobs by manually triggering them.
* [ ] In compliance with [PEP 440](https://peps.python.org/pep-0440/), update version identifier in `src/PIL/_version.py` * [ ] In compliance with [PEP 440](https://peps.python.org/pep-0440/), update version identifier in `src/PIL/_version.py`
* [ ] Update `CHANGES.rst`. * [ ] Update `CHANGES.rst`.
* [ ] Run pre-release check via `make release-test` in a freshly cloned repo. * [ ] Run pre-release check via `make release-test` in a freshly cloned repo.
@ -20,13 +20,10 @@ Released quarterly on January 2nd, April 1st, July 1st and October 15th.
git tag 5.2.0 git tag 5.2.0
git push --tags git push --tags
``` ```
* [ ] Create [source and binary distributions](https://github.com/python-pillow/Pillow/blob/main/RELEASING.md#source-and-binary-distributions) * [ ] Check the [GitHub Actions "Wheels" workflow](https://github.com/python-pillow/Pillow/actions/workflows/wheels.yml)
* [ ] Check and upload all source and binary distributions e.g.: has passed, including the "Upload release to PyPI" job. This will have been triggered
```bash by the new tag.
python3 -m twine check --strict dist/* * [ ] Publish the [release on GitHub](https://github.com/python-pillow/Pillow/releases).
python3 -m twine upload dist/Pillow-5.2.0*
```
* [ ] Publish the [release on GitHub](https://github.com/python-pillow/Pillow/releases)
* [ ] In compliance with [PEP 440](https://peps.python.org/pep-0440/), * [ ] In compliance with [PEP 440](https://peps.python.org/pep-0440/),
increment and append `.dev0` to version identifier in `src/PIL/_version.py` and then: increment and append `.dev0` to version identifier in `src/PIL/_version.py` and then:
```bash ```bash
@ -55,12 +52,9 @@ Released as needed for security, installation or critical bug fixes.
```bash ```bash
make sdist make sdist
``` ```
* [ ] Create [source and binary distributions](https://github.com/python-pillow/Pillow/blob/main/RELEASING.md#source-and-binary-distributions) * [ ] Check the [GitHub Actions "Wheels" workflow](https://github.com/python-pillow/Pillow/actions/workflows/wheels.yml)
* [ ] Check and upload all source and binary distributions e.g.: has passed, including the "Upload release to PyPI" job. This will have been triggered
```bash by the new tag.
python3 -m twine check --strict dist/*
python3 -m twine upload dist/Pillow-5.2.1*
```
* [ ] Publish the [release on GitHub](https://github.com/python-pillow/Pillow/releases) and then: * [ ] Publish the [release on GitHub](https://github.com/python-pillow/Pillow/releases) and then:
```bash ```bash
git push git push
@ -82,30 +76,17 @@ Released as needed privately to individual vendors for critical security-related
git tag 2.5.3 git tag 2.5.3
git push origin --tags git push origin --tags
``` ```
* [ ] Create and check source distribution: * [ ] Check the [GitHub Actions "Wheels" workflow](https://github.com/python-pillow/Pillow/actions/workflows/wheels.yml)
```bash has passed, including the "Upload release to PyPI" job. This will have been triggered
make sdist by the new tag.
```
* [ ] Create [source and binary distributions](https://github.com/python-pillow/Pillow/blob/main/RELEASING.md#source-and-binary-distributions)
* [ ] Publish the [release on GitHub](https://github.com/python-pillow/Pillow/releases) and then: * [ ] Publish the [release on GitHub](https://github.com/python-pillow/Pillow/releases) and then:
```bash ```bash
git push origin 2.5.x git push origin 2.5.x
``` ```
## Source and Binary Distributions
* [ ] Download sdist and wheels from the [GitHub Actions "Wheels" workflow](https://github.com/python-pillow/Pillow/actions/workflows/wheels.yml)
and copy into `dist/`. For example using [GitHub CLI](https://github.com/cli/cli):
```bash
gh run download --dir dist
# select dist
```
* [ ] Download the Linux aarch64 wheels created by Travis CI from [GitHub releases](https://github.com/python-pillow/Pillow/releases)
and copy into `dist`.
## Publicize Release ## Publicize Release
* [ ] Announce release availability via [Twitter](https://twitter.com/pythonpillow) and [Mastodon](https://fosstodon.org/@pillow) e.g. https://twitter.com/PythonPillow/status/1013789184354603010 * [ ] Announce release availability via [Mastodon](https://fosstodon.org/@pillow) e.g. https://fosstodon.org/@pillow/110639450470725321
## Documentation ## Documentation

View File

@ -1,4 +1,5 @@
from __future__ import annotations from __future__ import annotations
import time import time
from PIL import PyAccess from PIL import PyAccess
@ -8,21 +9,21 @@ from .helper import hopper
# Not running this test by default. No DOS against CI. # Not running this test by default. No DOS against CI.
def iterate_get(size, access): def iterate_get(size, access) -> None:
(w, h) = size (w, h) = size
for x in range(w): for x in range(w):
for y in range(h): for y in range(h):
access[(x, y)] access[(x, y)]
def iterate_set(size, access): def iterate_set(size, access) -> None:
(w, h) = size (w, h) = size
for x in range(w): for x in range(w):
for y in range(h): for y in range(h):
access[(x, y)] = (x % 256, y % 256, 0) access[(x, y)] = (x % 256, y % 256, 0)
def timer(func, label, *args): def timer(func, label, *args) -> None:
iterations = 5000 iterations = 5000
starttime = time.time() starttime = time.time()
for x in range(iterations): for x in range(iterations):
@ -31,13 +32,12 @@ def timer(func, label, *args):
break break
endtime = time.time() endtime = time.time()
print( print(
"{}: completed {} iterations in {:.4f}s, {:.6f}s per iteration".format( f"{label}: completed {x + 1} iterations in {endtime - starttime:.4f}s, "
label, x + 1, endtime - starttime, (endtime - starttime) / (x + 1.0) f"{(endtime - starttime) / (x + 1.0):.6f}s per iteration"
)
) )
def test_direct(): def test_direct() -> None:
im = hopper() im = hopper()
im.load() im.load()
# im = Image.new("RGB", (2000, 2000), (1, 3, 2)) # im = Image.new("RGB", (2000, 2000), (1, 3, 2))

View File

@ -1,4 +1,3 @@
#!/usr/bin/env python3
from __future__ import annotations from __future__ import annotations
from PIL import Image from PIL import Image

View File

@ -1,10 +1,11 @@
from __future__ import annotations from __future__ import annotations
from PIL import Image from PIL import Image
TEST_FILE = "Tests/images/fli_overflow.fli" TEST_FILE = "Tests/images/fli_overflow.fli"
def test_fli_overflow(): def test_fli_overflow() -> None:
# this should not crash with a malloc error or access violation # this should not crash with a malloc error or access violation
with Image.open(TEST_FILE) as im: with Image.open(TEST_FILE) as im:
im.load() im.load()

View File

@ -1,5 +1,8 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from __future__ import annotations from __future__ import annotations
from typing import Any, Callable
import pytest import pytest
from PIL import Image from PIL import Image
@ -12,31 +15,37 @@ max_iterations = 10000
pytestmark = pytest.mark.skipif(is_win32(), reason="requires Unix or macOS") pytestmark = pytest.mark.skipif(is_win32(), reason="requires Unix or macOS")
def _get_mem_usage(): def _get_mem_usage() -> float:
from resource import RUSAGE_SELF, getpagesize, getrusage from resource import RUSAGE_SELF, getpagesize, getrusage
mem = getrusage(RUSAGE_SELF).ru_maxrss mem = getrusage(RUSAGE_SELF).ru_maxrss
return mem * getpagesize() / 1024 / 1024 return mem * getpagesize() / 1024 / 1024
def _test_leak(min_iterations, max_iterations, fn, *args, **kwargs): def _test_leak(
min_iterations: int,
max_iterations: int,
fn: Callable[..., Image.Image | None],
*args: Any,
) -> None:
mem_limit = None mem_limit = None
for i in range(max_iterations): for i in range(max_iterations):
fn(*args, **kwargs) fn(*args)
mem = _get_mem_usage() mem = _get_mem_usage()
if i < min_iterations: if i < min_iterations:
mem_limit = mem + 1 mem_limit = mem + 1
continue continue
msg = f"memory usage limit exceeded after {i + 1} iterations" msg = f"memory usage limit exceeded after {i + 1} iterations"
assert mem_limit is not None
assert mem <= mem_limit, msg assert mem <= mem_limit, msg
def test_leak_putdata(): def test_leak_putdata() -> None:
im = Image.new("RGB", (25, 25)) im = Image.new("RGB", (25, 25))
_test_leak(min_iterations, max_iterations, im.putdata, im.getdata()) _test_leak(min_iterations, max_iterations, im.putdata, im.getdata())
def test_leak_getlist(): def test_leak_getlist() -> None:
im = Image.new("P", (25, 25)) im = Image.new("P", (25, 25))
_test_leak( _test_leak(
min_iterations, min_iterations,

View File

@ -1,4 +1,5 @@
from __future__ import annotations from __future__ import annotations
from io import BytesIO from io import BytesIO
import pytest import pytest
@ -19,7 +20,7 @@ pytestmark = [
] ]
def test_leak_load(): def test_leak_load() -> None:
from resource import RLIMIT_AS, RLIMIT_STACK, setrlimit from resource import RLIMIT_AS, RLIMIT_STACK, setrlimit
setrlimit(RLIMIT_STACK, (stack_size, stack_size)) setrlimit(RLIMIT_STACK, (stack_size, stack_size))
@ -29,7 +30,7 @@ def test_leak_load():
im.load() im.load()
def test_leak_save(): def test_leak_save() -> None:
from resource import RLIMIT_AS, RLIMIT_STACK, setrlimit from resource import RLIMIT_AS, RLIMIT_STACK, setrlimit
setrlimit(RLIMIT_STACK, (stack_size, stack_size)) setrlimit(RLIMIT_STACK, (stack_size, stack_size))

View File

@ -1,10 +1,13 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path
import pytest import pytest
from PIL import Image from PIL import Image
def test_j2k_overflow(tmp_path): def test_j2k_overflow(tmp_path: Path) -> None:
im = Image.new("RGBA", (1024, 131584)) im = Image.new("RGBA", (1024, 131584))
target = str(tmp_path / "temp.jpc") target = str(tmp_path / "temp.jpc")
with pytest.raises(OSError): with pytest.raises(OSError):

3
Tests/check_jp2_overflow.py Executable file → Normal file
View File

@ -1,5 +1,3 @@
#!/usr/bin/env python3
# Reproductions/tests for OOB read errors in FliDecode.c # Reproductions/tests for OOB read errors in FliDecode.c
# When run in python, all of these images should fail for # When run in python, all of these images should fail for
@ -14,7 +12,6 @@
# version. # version.
from __future__ import annotations from __future__ import annotations
from PIL import Image from PIL import Image
repro = ("00r0_gray_l.jp2", "00r1_graya_la.jp2") repro = ("00r0_gray_l.jp2", "00r1_graya_la.jp2")

View File

@ -1,4 +1,5 @@
from __future__ import annotations from __future__ import annotations
from io import BytesIO from io import BytesIO
import pytest import pytest
@ -110,14 +111,14 @@ standard_chrominance_qtable = (
[standard_l_qtable, standard_chrominance_qtable], [standard_l_qtable, standard_chrominance_qtable],
), ),
) )
def test_qtables_leak(qtables): def test_qtables_leak(qtables: tuple[tuple[int, ...]] | list[tuple[int, ...]]) -> None:
im = hopper("RGB") im = hopper("RGB")
for _ in range(iterations): for _ in range(iterations):
test_output = BytesIO() test_output = BytesIO()
im.save(test_output, "JPEG", qtables=qtables) im.save(test_output, "JPEG", qtables=qtables)
def test_exif_leak(): def test_exif_leak() -> None:
""" """
pre patch: pre patch:
@ -180,7 +181,7 @@ def test_exif_leak():
im.save(test_output, "JPEG", exif=exif) im.save(test_output, "JPEG", exif=exif)
def test_base_save(): def test_base_save() -> None:
""" """
base case: base case:
MB MB

View File

@ -1,5 +1,8 @@
from __future__ import annotations from __future__ import annotations
import sys import sys
from pathlib import Path
from types import ModuleType
import pytest import pytest
@ -15,6 +18,7 @@ from PIL import Image
# 2.7 and 3.2. # 2.7 and 3.2.
numpy: ModuleType | None
try: try:
import numpy import numpy
except ImportError: except ImportError:
@ -27,23 +31,24 @@ XDIM = 48000
pytestmark = pytest.mark.skipif(sys.maxsize <= 2**32, reason="requires 64-bit system") pytestmark = pytest.mark.skipif(sys.maxsize <= 2**32, reason="requires 64-bit system")
def _write_png(tmp_path, xdim, ydim): def _write_png(tmp_path: Path, xdim: int, ydim: int) -> None:
f = str(tmp_path / "temp.png") f = str(tmp_path / "temp.png")
im = Image.new("L", (xdim, ydim), 0) im = Image.new("L", (xdim, ydim), 0)
im.save(f) im.save(f)
def test_large(tmp_path): def test_large(tmp_path: Path) -> None:
"""succeeded prepatch""" """succeeded prepatch"""
_write_png(tmp_path, XDIM, YDIM) _write_png(tmp_path, XDIM, YDIM)
def test_2gpx(tmp_path): def test_2gpx(tmp_path: Path) -> None:
"""failed prepatch""" """failed prepatch"""
_write_png(tmp_path, XDIM, XDIM) _write_png(tmp_path, XDIM, XDIM)
@pytest.mark.skipif(numpy is None, reason="Numpy is not installed") @pytest.mark.skipif(numpy is None, reason="Numpy is not installed")
def test_size_greater_than_int(): def test_size_greater_than_int() -> None:
assert numpy is not None
arr = numpy.ndarray(shape=(16394, 16394)) arr = numpy.ndarray(shape=(16394, 16394))
Image.fromarray(arr) Image.fromarray(arr)

View File

@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
import sys import sys
from pathlib import Path
import pytest import pytest
@ -23,7 +25,7 @@ XDIM = 48000
pytestmark = pytest.mark.skipif(sys.maxsize <= 2**32, reason="requires 64-bit system") pytestmark = pytest.mark.skipif(sys.maxsize <= 2**32, reason="requires 64-bit system")
def _write_png(tmp_path, xdim, ydim): def _write_png(tmp_path: Path, xdim: int, ydim: int) -> None:
dtype = np.uint8 dtype = np.uint8
a = np.zeros((xdim, ydim), dtype=dtype) a = np.zeros((xdim, ydim), dtype=dtype)
f = str(tmp_path / "temp.png") f = str(tmp_path / "temp.png")
@ -31,11 +33,11 @@ def _write_png(tmp_path, xdim, ydim):
im.save(f) im.save(f)
def test_large(tmp_path): def test_large(tmp_path: Path) -> None:
"""succeeded prepatch""" """succeeded prepatch"""
_write_png(tmp_path, XDIM, YDIM) _write_png(tmp_path, XDIM, YDIM)
def test_2gpx(tmp_path): def test_2gpx(tmp_path: Path) -> None:
"""failed prepatch""" """failed prepatch"""
_write_png(tmp_path, XDIM, XDIM) _write_png(tmp_path, XDIM, XDIM)

View File

@ -1,4 +1,5 @@
from __future__ import annotations from __future__ import annotations
import pytest import pytest
from PIL import Image from PIL import Image
@ -6,7 +7,7 @@ from PIL import Image
TEST_FILE = "Tests/images/libtiff_segfault.tif" TEST_FILE = "Tests/images/libtiff_segfault.tif"
def test_libtiff_segfault(): def test_libtiff_segfault() -> None:
"""This test should not segfault. It will on Pillow <= 3.1.0 and """This test should not segfault. It will on Pillow <= 3.1.0 and
libtiff >= 4.0.0 libtiff >= 4.0.0
""" """

View File

@ -1,4 +1,5 @@
from __future__ import annotations from __future__ import annotations
import zlib import zlib
from io import BytesIO from io import BytesIO
@ -7,7 +8,7 @@ from PIL import Image, ImageFile, PngImagePlugin
TEST_FILE = "Tests/images/png_decompression_dos.png" TEST_FILE = "Tests/images/png_decompression_dos.png"
def test_ignore_dos_text(): def test_ignore_dos_text() -> None:
ImageFile.LOAD_TRUNCATED_IMAGES = True ImageFile.LOAD_TRUNCATED_IMAGES = True
try: try:
@ -16,6 +17,7 @@ def test_ignore_dos_text():
finally: finally:
ImageFile.LOAD_TRUNCATED_IMAGES = False ImageFile.LOAD_TRUNCATED_IMAGES = False
assert isinstance(im, PngImagePlugin.PngImageFile)
for s in im.text.values(): for s in im.text.values():
assert len(s) < 1024 * 1024, "Text chunk larger than 1M" assert len(s) < 1024 * 1024, "Text chunk larger than 1M"
@ -23,7 +25,7 @@ def test_ignore_dos_text():
assert len(s) < 1024 * 1024, "Text chunk larger than 1M" assert len(s) < 1024 * 1024, "Text chunk larger than 1M"
def test_dos_text(): def test_dos_text() -> None:
try: try:
im = Image.open(TEST_FILE) im = Image.open(TEST_FILE)
im.load() im.load()
@ -31,11 +33,12 @@ def test_dos_text():
assert msg, "Decompressed Data Too Large" assert msg, "Decompressed Data Too Large"
return return
assert isinstance(im, PngImagePlugin.PngImageFile)
for s in im.text.values(): for s in im.text.values():
assert len(s) < 1024 * 1024, "Text chunk larger than 1M" assert len(s) < 1024 * 1024, "Text chunk larger than 1M"
def test_dos_total_memory(): def test_dos_total_memory() -> None:
im = Image.new("L", (1, 1)) im = Image.new("L", (1, 1))
compressed_data = zlib.compress(b"a" * 1024 * 1023) compressed_data = zlib.compress(b"a" * 1024 * 1023)
@ -52,10 +55,11 @@ def test_dos_total_memory():
try: try:
im2 = Image.open(b) im2 = Image.open(b)
except ValueError as msg: except ValueError as msg:
assert "Too much memory" in msg assert "Too much memory" in str(msg)
return return
total_len = 0 total_len = 0
assert isinstance(im2, PngImagePlugin.PngImageFile)
for txt in im2.text.values(): for txt in im2.text.values():
total_len += len(txt) total_len += len(txt)
assert total_len < 64 * 1024 * 1024, "Total text chunks greater than 64M" assert total_len < 64 * 1024 * 1024, "Total text chunks greater than 64M"

View File

@ -1,4 +1,5 @@
from __future__ import annotations from __future__ import annotations
import sys import sys
from pathlib import Path from pathlib import Path

View File

@ -1,10 +1,11 @@
from __future__ import annotations from __future__ import annotations
import sys import sys
from PIL import features from PIL import features
def test_wheel_modules(): def test_wheel_modules() -> None:
expected_modules = {"pil", "tkinter", "freetype2", "littlecms2", "webp"} expected_modules = {"pil", "tkinter", "freetype2", "littlecms2", "webp"}
# tkinter is not available in cibuildwheel installed CPython on Windows # tkinter is not available in cibuildwheel installed CPython on Windows
@ -18,13 +19,13 @@ def test_wheel_modules():
assert set(features.get_supported_modules()) == expected_modules assert set(features.get_supported_modules()) == expected_modules
def test_wheel_codecs(): def test_wheel_codecs() -> None:
expected_codecs = {"jpg", "jpg_2000", "zlib", "libtiff"} expected_codecs = {"jpg", "jpg_2000", "zlib", "libtiff"}
assert set(features.get_supported_codecs()) == expected_codecs assert set(features.get_supported_codecs()) == expected_codecs
def test_wheel_features(): def test_wheel_features() -> None:
expected_features = { expected_features = {
"webp_anim", "webp_anim",
"webp_mux", "webp_mux",

View File

@ -1,8 +1,11 @@
from __future__ import annotations from __future__ import annotations
import io import io
import pytest
def pytest_report_header(config):
def pytest_report_header(config: pytest.Config) -> str:
try: try:
from PIL import features from PIL import features
@ -13,7 +16,7 @@ def pytest_report_header(config):
return f"pytest_report_header failed: {e}" return f"pytest_report_header failed: {e}"
def pytest_configure(config): def pytest_configure(config: pytest.Config) -> None:
config.addinivalue_line( config.addinivalue_line(
"markers", "markers",
"pil_noop_mark: A conditional mark where nothing special happens", "pil_noop_mark: A conditional mark where nothing special happens",

View File

@ -1,5 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from __future__ import annotations from __future__ import annotations
import base64 import base64
import os import os

View File

@ -1,6 +1,7 @@
""" """
Helper functions. Helper functions.
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
@ -10,7 +11,9 @@ import subprocess
import sys import sys
import sysconfig import sysconfig
import tempfile import tempfile
from functools import lru_cache
from io import BytesIO from io import BytesIO
from typing import Any, Callable, Sequence
import pytest import pytest
from packaging.version import parse as parse_version from packaging.version import parse as parse_version
@ -19,42 +22,31 @@ from PIL import Image, ImageMath, features
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
uploader = None
HAS_UPLOADER = False
if os.environ.get("SHOW_ERRORS"): if os.environ.get("SHOW_ERRORS"):
# local img.show for errors. uploader = "show"
HAS_UPLOADER = True elif "GITHUB_ACTIONS" in os.environ:
uploader = "github_actions"
class test_image_results:
@staticmethod def upload(a: Image.Image, b: Image.Image) -> str | None:
def upload(a, b): if uploader == "show":
# local img.show for errors.
a.show() a.show()
b.show() b.show()
elif uploader == "github_actions":
elif "GITHUB_ACTIONS" in os.environ:
HAS_UPLOADER = True
class test_image_results:
@staticmethod
def upload(a, b):
dir_errors = os.path.join(os.path.dirname(__file__), "errors") dir_errors = os.path.join(os.path.dirname(__file__), "errors")
os.makedirs(dir_errors, exist_ok=True) os.makedirs(dir_errors, exist_ok=True)
tmpdir = tempfile.mkdtemp(dir=dir_errors) tmpdir = tempfile.mkdtemp(dir=dir_errors)
a.save(os.path.join(tmpdir, "a.png")) a.save(os.path.join(tmpdir, "a.png"))
b.save(os.path.join(tmpdir, "b.png")) b.save(os.path.join(tmpdir, "b.png"))
return tmpdir return tmpdir
return None
else:
try:
import test_image_results
HAS_UPLOADER = True
except ImportError:
pass
def convert_to_comparable(a, b): def convert_to_comparable(
a: Image.Image, b: Image.Image
) -> tuple[Image.Image, Image.Image]:
new_a, new_b = a, b new_a, new_b = a, b
if a.mode == "P": if a.mode == "P":
new_a = Image.new("L", a.size) new_a = Image.new("L", a.size)
@ -67,14 +59,18 @@ def convert_to_comparable(a, b):
return new_a, new_b return new_a, new_b
def assert_deep_equal(a, b, msg=None): def assert_deep_equal(
a: Sequence[Any], b: Sequence[Any], msg: str | None = None
) -> None:
try: try:
assert len(a) == len(b), msg or f"got length {len(a)}, expected {len(b)}" assert len(a) == len(b), msg or f"got length {len(a)}, expected {len(b)}"
except Exception: except Exception:
assert a == b, msg assert a == b, msg
def assert_image(im, mode, size, msg=None): def assert_image(
im: Image.Image, mode: str, size: tuple[int, int], msg: str | None = None
) -> None:
if mode is not None: if mode is not None:
assert im.mode == mode, ( assert im.mode == mode, (
msg or f"got mode {repr(im.mode)}, expected {repr(mode)}" msg or f"got mode {repr(im.mode)}, expected {repr(mode)}"
@ -86,13 +82,13 @@ def assert_image(im, mode, size, msg=None):
) )
def assert_image_equal(a, b, msg=None): def assert_image_equal(a: Image.Image, b: Image.Image, msg: str | None = None) -> None:
assert a.mode == b.mode, msg or f"got mode {repr(a.mode)}, expected {repr(b.mode)}" assert a.mode == b.mode, msg or f"got mode {repr(a.mode)}, expected {repr(b.mode)}"
assert a.size == b.size, msg or f"got size {repr(a.size)}, expected {repr(b.size)}" assert a.size == b.size, msg or f"got size {repr(a.size)}, expected {repr(b.size)}"
if a.tobytes() != b.tobytes(): if a.tobytes() != b.tobytes():
if HAS_UPLOADER:
try: try:
url = test_image_results.upload(a, b) url = upload(a, b)
if url:
logger.error("URL for test images: %s", url) logger.error("URL for test images: %s", url)
except Exception: except Exception:
pass pass
@ -100,14 +96,18 @@ def assert_image_equal(a, b, msg=None):
pytest.fail(msg or "got different content") pytest.fail(msg or "got different content")
def assert_image_equal_tofile(a, filename, msg=None, mode=None): def assert_image_equal_tofile(
a: Image.Image, filename: str, msg: str | None = None, mode: str | None = None
) -> None:
with Image.open(filename) as img: with Image.open(filename) as img:
if mode: if mode:
img = img.convert(mode) img = img.convert(mode)
assert_image_equal(a, img, msg) assert_image_equal(a, img, msg)
def assert_image_similar(a, b, epsilon, msg=None): def assert_image_similar(
a: Image.Image, b: Image.Image, epsilon: float, msg: str | None = None
) -> None:
assert a.mode == b.mode, msg or f"got mode {repr(a.mode)}, expected {repr(b.mode)}" assert a.mode == b.mode, msg or f"got mode {repr(a.mode)}, expected {repr(b.mode)}"
assert a.size == b.size, msg or f"got size {repr(a.size)}, expected {repr(b.size)}" assert a.size == b.size, msg or f"got size {repr(a.size)}, expected {repr(b.size)}"
@ -115,7 +115,9 @@ def assert_image_similar(a, b, epsilon, msg=None):
diff = 0 diff = 0
for ach, bch in zip(a.split(), b.split()): for ach, bch in zip(a.split(), b.split()):
chdiff = ImageMath.eval("abs(a - b)", a=ach, b=bch).convert("L") chdiff = ImageMath.lambda_eval(
lambda args: abs(args["a"] - args["b"]), a=ach, b=bch
).convert("L")
diff += sum(i * num for i, num in enumerate(chdiff.histogram())) diff += sum(i * num for i, num in enumerate(chdiff.histogram()))
ave_diff = diff / (a.size[0] * a.size[1]) ave_diff = diff / (a.size[0] * a.size[1])
@ -125,55 +127,68 @@ def assert_image_similar(a, b, epsilon, msg=None):
+ f" average pixel value difference {ave_diff:.4f} > epsilon {epsilon:.4f}" + f" average pixel value difference {ave_diff:.4f} > epsilon {epsilon:.4f}"
) )
except Exception as e: except Exception as e:
if HAS_UPLOADER:
try: try:
url = test_image_results.upload(a, b) url = upload(a, b)
if url:
logger.exception("URL for test images: %s", url) logger.exception("URL for test images: %s", url)
except Exception: except Exception:
pass pass
raise e raise e
def assert_image_similar_tofile(a, filename, epsilon, msg=None, mode=None): def assert_image_similar_tofile(
a: Image.Image,
filename: str,
epsilon: float,
msg: str | None = None,
mode: str | None = None,
) -> None:
with Image.open(filename) as img: with Image.open(filename) as img:
if mode: if mode:
img = img.convert(mode) img = img.convert(mode)
assert_image_similar(a, img, epsilon, msg) assert_image_similar(a, img, epsilon, msg)
def assert_all_same(items, msg=None): def assert_all_same(items: Sequence[Any], msg: str | None = None) -> None:
assert items.count(items[0]) == len(items), msg assert items.count(items[0]) == len(items), msg
def assert_not_all_same(items, msg=None): def assert_not_all_same(items: Sequence[Any], msg: str | None = None) -> None:
assert items.count(items[0]) != len(items), msg assert items.count(items[0]) != len(items), msg
def assert_tuple_approx_equal(actuals, targets, threshold, msg): def assert_tuple_approx_equal(
actuals: Sequence[int], targets: tuple[int, ...], threshold: int, msg: str
) -> None:
"""Tests if actuals has values within threshold from targets""" """Tests if actuals has values within threshold from targets"""
value = True
for i, target in enumerate(targets): for i, target in enumerate(targets):
value *= target - threshold <= actuals[i] <= target + threshold if not (target - threshold <= actuals[i] <= target + threshold):
pytest.fail(msg + ": " + repr(actuals) + " != " + repr(targets))
assert value, msg + ": " + repr(actuals) + " != " + repr(targets)
def skip_unless_feature(feature): def skip_unless_feature(feature: str) -> pytest.MarkDecorator:
reason = f"{feature} not available" reason = f"{feature} not available"
return pytest.mark.skipif(not features.check(feature), reason=reason) return pytest.mark.skipif(not features.check(feature), reason=reason)
def skip_unless_feature_version(feature, version_required, reason=None): def skip_unless_feature_version(
feature: str, required: str, reason: str | None = None
) -> pytest.MarkDecorator:
if not features.check(feature): if not features.check(feature):
return pytest.mark.skip(f"{feature} not available") return pytest.mark.skip(f"{feature} not available")
if reason is None: if reason is None:
reason = f"{feature} is older than {version_required}" reason = f"{feature} is older than {required}"
version_required = parse_version(version_required) version_required = parse_version(required)
version_available = parse_version(features.version(feature)) version_available = parse_version(features.version(feature))
return pytest.mark.skipif(version_available < version_required, reason=reason) return pytest.mark.skipif(version_available < version_required, reason=reason)
def mark_if_feature_version(mark, feature, version_blacklist, reason=None): def mark_if_feature_version(
mark: pytest.MarkDecorator,
feature: str,
version_blacklist: str,
reason: str | None = None,
) -> pytest.MarkDecorator:
if not features.check(feature): if not features.check(feature):
return pytest.mark.pil_noop_mark() return pytest.mark.pil_noop_mark()
if reason is None: if reason is None:
@ -194,7 +209,7 @@ class PillowLeakTestCase:
iterations = 100 # count iterations = 100 # count
mem_limit = 512 # k mem_limit = 512 # k
def _get_mem_usage(self): def _get_mem_usage(self) -> float:
""" """
Gets the RUSAGE memory usage, returns in K. Encapsulates the difference Gets the RUSAGE memory usage, returns in K. Encapsulates the difference
between macOS and Linux rss reporting between macOS and Linux rss reporting
@ -216,7 +231,7 @@ class PillowLeakTestCase:
# This is the maximum resident set size used (in kilobytes). # This is the maximum resident set size used (in kilobytes).
return mem # Kb return mem # Kb
def _test_leak(self, core): def _test_leak(self, core: Callable[[], None]) -> None:
start_mem = self._get_mem_usage() start_mem = self._get_mem_usage()
for cycle in range(self.iterations): for cycle in range(self.iterations):
core() core()
@ -228,60 +243,75 @@ class PillowLeakTestCase:
# helpers # helpers
def fromstring(data): def fromstring(data: bytes) -> Image.Image:
return Image.open(BytesIO(data)) return Image.open(BytesIO(data))
def tostring(im, string_format, **options): def tostring(im: Image.Image, string_format: str, **options: Any) -> bytes:
out = BytesIO() out = BytesIO()
im.save(out, string_format, **options) im.save(out, string_format, **options)
return out.getvalue() return out.getvalue()
def hopper(mode=None, cache={}): def hopper(mode: str | None = None) -> Image.Image:
# Use caching to reduce reading from disk, but return a copy
# so that the cached image isn't modified by the tests
# (for fast, isolated, repeatable tests).
if mode is None: if mode is None:
# Always return fresh not-yet-loaded version of image. # Always return fresh not-yet-loaded version of image.
# Operations on not-yet-loaded images is separate class of errors # Operations on not-yet-loaded images are a separate class of errors
# what we should catch. # that we should catch.
return Image.open("Tests/images/hopper.ppm") return Image.open("Tests/images/hopper.ppm")
# Use caching to reduce reading from disk but so an original copy is
# returned each time and the cached image isn't modified by tests return _cached_hopper(mode).copy()
# (for fast, isolated, repeatable tests).
im = cache.get(mode)
if im is None: @lru_cache
def _cached_hopper(mode: str) -> Image.Image:
if mode == "F": if mode == "F":
im = hopper("L").convert(mode) im = hopper("L")
elif mode[:4] == "I;16":
im = hopper("I").convert(mode)
else: else:
im = hopper().convert(mode) im = hopper()
cache[mode] = im if mode.startswith("BGR;"):
return im.copy() with pytest.warns(DeprecationWarning):
im = im.convert(mode)
else:
try:
im = im.convert(mode)
except ImportError:
if mode == "LAB":
im = Image.open("Tests/images/hopper.Lab.tif")
else:
raise
return im
def djpeg_available(): def djpeg_available() -> bool:
if shutil.which("djpeg"): if shutil.which("djpeg"):
try: try:
subprocess.check_call(["djpeg", "-version"]) subprocess.check_call(["djpeg", "-version"])
return True return True
except subprocess.CalledProcessError: # pragma: no cover except subprocess.CalledProcessError: # pragma: no cover
return False return False
return False
def cjpeg_available(): def cjpeg_available() -> bool:
if shutil.which("cjpeg"): if shutil.which("cjpeg"):
try: try:
subprocess.check_call(["cjpeg", "-version"]) subprocess.check_call(["cjpeg", "-version"])
return True return True
except subprocess.CalledProcessError: # pragma: no cover except subprocess.CalledProcessError: # pragma: no cover
return False return False
return False
def netpbm_available(): def netpbm_available() -> bool:
return bool(shutil.which("ppmquant") and shutil.which("ppmtogif")) return bool(shutil.which("ppmquant") and shutil.which("ppmtogif"))
def magick_command(): def magick_command() -> list[str] | None:
if sys.platform == "win32": if sys.platform == "win32":
magickhome = os.environ.get("MAGICK_HOME") magickhome = os.environ.get("MAGICK_HOME")
if magickhome: if magickhome:
@ -298,47 +328,48 @@ def magick_command():
return imagemagick return imagemagick
if graphicsmagick and shutil.which(graphicsmagick[0]): if graphicsmagick and shutil.which(graphicsmagick[0]):
return graphicsmagick return graphicsmagick
return None
def on_appveyor(): def on_appveyor() -> bool:
return "APPVEYOR" in os.environ return "APPVEYOR" in os.environ
def on_github_actions(): def on_github_actions() -> bool:
return "GITHUB_ACTIONS" in os.environ return "GITHUB_ACTIONS" in os.environ
def on_ci(): def on_ci() -> bool:
# GitHub Actions and AppVeyor have "CI" # GitHub Actions and AppVeyor have "CI"
return "CI" in os.environ return "CI" in os.environ
def is_big_endian(): def is_big_endian() -> bool:
return sys.byteorder == "big" return sys.byteorder == "big"
def is_ppc64le(): def is_ppc64le() -> bool:
import platform import platform
return platform.machine() == "ppc64le" return platform.machine() == "ppc64le"
def is_win32(): def is_win32() -> bool:
return sys.platform.startswith("win32") return sys.platform.startswith("win32")
def is_pypy(): def is_pypy() -> bool:
return hasattr(sys, "pypy_translation_info") return hasattr(sys, "pypy_translation_info")
def is_mingw(): def is_mingw() -> bool:
return sysconfig.get_platform() == "mingw" return sysconfig.get_platform() == "mingw"
class CachedProperty: class CachedProperty:
def __init__(self, func): def __init__(self, func: Callable[[Any], Any]) -> None:
self.func = func self.func = func
def __get__(self, instance, cls=None): def __get__(self, instance: Any, cls: type[Any] | None = None) -> Any:
result = instance.__dict__[self.func.__name__] = self.func(instance) result = instance.__dict__[self.func.__name__] = self.func(instance)
return result return result

BIN
Tests/icc/sGrey-v2-nano.icc Normal file

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 578 B

Binary file not shown.

BIN
Tests/images/2422.flc Normal file

Binary file not shown.

BIN
Tests/images/9bit.j2k Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 B

BIN
Tests/images/bgr15.dds Normal file

Binary file not shown.

BIN
Tests/images/bgr15.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

View File

@ -1,5 +1,3 @@
#!/usr/bin/gnuplot
#This is the script that was used to create our sample EPS files #This is the script that was used to create our sample EPS files
#We used the following version of the gnuplot program #We used the following version of the gnuplot program
#G N U P L O T #G N U P L O T

BIN
Tests/images/hopper.pfm Normal file

Binary file not shown.

BIN
Tests/images/hopper_be.pfm Normal file

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 B

Binary file not shown.

BIN
Tests/images/m13.fits Normal file

Binary file not shown.

366
Tests/images/m13_gzip.fits Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 B

Binary file not shown.

BIN
Tests/images/p_8.tga Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -13,7 +13,6 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
from __future__ import annotations
import atheris import atheris
@ -24,7 +23,7 @@ with atheris.instrument_imports():
import fuzzers import fuzzers
def TestOneInput(data): def TestOneInput(data: bytes) -> None:
try: try:
fuzzers.fuzz_font(data) fuzzers.fuzz_font(data)
except Exception: except Exception:
@ -33,7 +32,7 @@ def TestOneInput(data):
pass pass
def main(): def main() -> None:
fuzzers.enable_decompressionbomb_error() fuzzers.enable_decompressionbomb_error()
atheris.Setup(sys.argv, TestOneInput) atheris.Setup(sys.argv, TestOneInput)
atheris.Fuzz() atheris.Fuzz()

View File

@ -1,5 +1,3 @@
#!/usr/bin/python3
# Copyright 2020 Google LLC # Copyright 2020 Google LLC
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
@ -13,7 +11,6 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
from __future__ import annotations
import atheris import atheris
@ -24,7 +21,7 @@ with atheris.instrument_imports():
import fuzzers import fuzzers
def TestOneInput(data): def TestOneInput(data: bytes) -> None:
try: try:
fuzzers.fuzz_image(data) fuzzers.fuzz_image(data)
except Exception: except Exception:
@ -33,7 +30,7 @@ def TestOneInput(data):
pass pass
def main(): def main() -> None:
fuzzers.enable_decompressionbomb_error() fuzzers.enable_decompressionbomb_error()
atheris.Setup(sys.argv, TestOneInput) atheris.Setup(sys.argv, TestOneInput)
atheris.Fuzz() atheris.Fuzz()

View File

@ -1,22 +1,23 @@
from __future__ import annotations from __future__ import annotations
import io import io
import warnings import warnings
from PIL import Image, ImageDraw, ImageFile, ImageFilter, ImageFont from PIL import Image, ImageDraw, ImageFile, ImageFilter, ImageFont
def enable_decompressionbomb_error(): def enable_decompressionbomb_error() -> None:
ImageFile.LOAD_TRUNCATED_IMAGES = True ImageFile.LOAD_TRUNCATED_IMAGES = True
warnings.filterwarnings("ignore") warnings.filterwarnings("ignore")
warnings.simplefilter("error", Image.DecompressionBombWarning) warnings.simplefilter("error", Image.DecompressionBombWarning)
def disable_decompressionbomb_error(): def disable_decompressionbomb_error() -> None:
ImageFile.LOAD_TRUNCATED_IMAGES = False ImageFile.LOAD_TRUNCATED_IMAGES = False
warnings.resetwarnings() warnings.resetwarnings()
def fuzz_image(data): def fuzz_image(data: bytes) -> None:
# This will fail on some images in the corpus, as we have many # This will fail on some images in the corpus, as we have many
# invalid images in the test suite. # invalid images in the test suite.
with Image.open(io.BytesIO(data)) as im: with Image.open(io.BytesIO(data)) as im:
@ -25,7 +26,7 @@ def fuzz_image(data):
im.save(io.BytesIO(), "BMP") im.save(io.BytesIO(), "BMP")
def fuzz_font(data): def fuzz_font(data: bytes) -> None:
wrapper = io.BytesIO(data) wrapper = io.BytesIO(data)
try: try:
font = ImageFont.truetype(wrapper) font = ImageFont.truetype(wrapper)

View File

@ -1,4 +1,5 @@
from __future__ import annotations from __future__ import annotations
import subprocess import subprocess
import sys import sys
@ -6,7 +7,7 @@ import fuzzers
import packaging import packaging
import pytest import pytest
from PIL import Image, features from PIL import Image, UnidentifiedImageError, features
from Tests.helper import skip_unless_feature from Tests.helper import skip_unless_feature
if sys.platform.startswith("win32"): if sys.platform.startswith("win32"):
@ -23,7 +24,7 @@ if features.check("libjpeg_turbo"):
"path", "path",
subprocess.check_output("find Tests/images -type f", shell=True).split(b"\n"), subprocess.check_output("find Tests/images -type f", shell=True).split(b"\n"),
) )
def test_fuzz_images(path): def test_fuzz_images(path: str) -> None:
fuzzers.enable_decompressionbomb_error() fuzzers.enable_decompressionbomb_error()
try: try:
with open(path, "rb") as f: with open(path, "rb") as f:
@ -42,7 +43,7 @@ def test_fuzz_images(path):
except ( except (
Image.DecompressionBombError, Image.DecompressionBombError,
Image.DecompressionBombWarning, Image.DecompressionBombWarning,
Image.UnidentifiedImageError, UnidentifiedImageError,
): ):
# Known Image.* exceptions # Known Image.* exceptions
assert True assert True
@ -54,7 +55,7 @@ def test_fuzz_images(path):
@pytest.mark.parametrize( @pytest.mark.parametrize(
"path", subprocess.check_output("find Tests/fonts -type f", shell=True).split(b"\n") "path", subprocess.check_output("find Tests/fonts -type f", shell=True).split(b"\n")
) )
def test_fuzz_fonts(path): def test_fuzz_fonts(path: str) -> None:
if not path: if not path:
return return
with open(path, "rb") as f: with open(path, "rb") as f:

View File

@ -1,8 +1,9 @@
from __future__ import annotations from __future__ import annotations
from PIL import Image from PIL import Image
def test_sanity(): def test_sanity() -> None:
# Make sure we have the binary extension # Make sure we have the binary extension
Image.core.new("L", (100, 100)) Image.core.new("L", (100, 100))

View File

@ -1,13 +1,14 @@
from __future__ import annotations from __future__ import annotations
from PIL import _binary from PIL import _binary
def test_standard(): def test_standard() -> None:
assert _binary.i8(b"*") == 42 assert _binary.i8(b"*") == 42
assert _binary.o8(42) == b"*" assert _binary.o8(42) == b"*"
def test_little_endian(): def test_little_endian() -> None:
assert _binary.i16le(b"\xff\xff\x00\x00") == 65535 assert _binary.i16le(b"\xff\xff\x00\x00") == 65535
assert _binary.i32le(b"\xff\xff\x00\x00") == 65535 assert _binary.i32le(b"\xff\xff\x00\x00") == 65535
@ -15,7 +16,7 @@ def test_little_endian():
assert _binary.o32le(65535) == b"\xff\xff\x00\x00" assert _binary.o32le(65535) == b"\xff\xff\x00\x00"
def test_big_endian(): def test_big_endian() -> None:
assert _binary.i16be(b"\x00\x00\xff\xff") == 0 assert _binary.i16be(b"\x00\x00\xff\xff") == 0
assert _binary.i32be(b"\x00\x00\xff\xff") == 65535 assert _binary.i32be(b"\x00\x00\xff\xff") == 65535

View File

@ -1,4 +1,5 @@
from __future__ import annotations from __future__ import annotations
import os import os
import warnings import warnings
@ -9,13 +10,13 @@ from .helper import assert_image_similar
base = os.path.join("Tests", "images", "bmp") base = os.path.join("Tests", "images", "bmp")
def get_files(d, ext=".bmp"): def get_files(d: str, ext: str = ".bmp") -> list[str]:
return [ return [
os.path.join(base, d, f) for f in os.listdir(os.path.join(base, d)) if ext in f os.path.join(base, d, f) for f in os.listdir(os.path.join(base, d)) if ext in f
] ]
def test_bad(): def test_bad() -> None:
"""These shouldn't crash/dos, but they shouldn't return anything """These shouldn't crash/dos, but they shouldn't return anything
either""" either"""
for f in get_files("b"): for f in get_files("b"):
@ -28,7 +29,7 @@ def test_bad():
pass pass
def test_questionable(): def test_questionable() -> None:
"""These shouldn't crash/dos, but it's not well defined that these """These shouldn't crash/dos, but it's not well defined that these
are in spec""" are in spec"""
supported = [ supported = [
@ -43,6 +44,9 @@ def test_questionable():
"pal8os2sp.bmp", "pal8os2sp.bmp",
"pal8rletrns.bmp", "pal8rletrns.bmp",
"rgb32bf-xbgr.bmp", "rgb32bf-xbgr.bmp",
"rgba32.bmp",
"rgb32h52.bmp",
"rgba32h56.bmp",
] ]
for f in get_files("q"): for f in get_files("q"):
try: try:
@ -55,7 +59,7 @@ def test_questionable():
raise raise
def test_good(): def test_good() -> None:
"""These should all work. There's a set of target files in the """These should all work. There's a set of target files in the
html directory that we can compare against.""" html directory that we can compare against."""
@ -79,7 +83,7 @@ def test_good():
"rgb32bf.bmp": "rgb24.png", "rgb32bf.bmp": "rgb24.png",
} }
def get_compare(f): def get_compare(f: str) -> str:
name = os.path.split(f)[1] name = os.path.split(f)[1]
if name in file_map: if name in file_map:
return os.path.join(base, "html", file_map[name]) return os.path.join(base, "html", file_map[name])

View File

@ -1,4 +1,5 @@
from __future__ import annotations from __future__ import annotations
import pytest import pytest
from PIL import Image, ImageFilter from PIL import Image, ImageFilter
@ -15,18 +16,18 @@ sample.putdata(sum([
# fmt: on # fmt: on
def test_imageops_box_blur(): def test_imageops_box_blur() -> None:
i = sample.filter(ImageFilter.BoxBlur(1)) i = sample.filter(ImageFilter.BoxBlur(1))
assert i.mode == sample.mode assert i.mode == sample.mode
assert i.size == sample.size assert i.size == sample.size
assert isinstance(i, Image.Image) assert isinstance(i, Image.Image)
def box_blur(image, radius=1, n=1): def box_blur(image: Image.Image, radius: float = 1, n: int = 1) -> Image.Image:
return image._new(image.im.box_blur((radius, radius), n)) return image._new(image.im.box_blur((radius, radius), n))
def assert_image(im, data, delta=0): def assert_image(im: Image.Image, data: list[list[int]], delta: int = 0) -> None:
it = iter(im.getdata()) it = iter(im.getdata())
for data_row in data: for data_row in data:
im_row = [next(it) for _ in range(im.size[0])] im_row = [next(it) for _ in range(im.size[0])]
@ -36,7 +37,13 @@ def assert_image(im, data, delta=0):
next(it) next(it)
def assert_blur(im, radius, data, passes=1, delta=0): def assert_blur(
im: Image.Image,
radius: float,
data: list[list[int]],
passes: int = 1,
delta: int = 0,
) -> None:
# check grayscale image # check grayscale image
assert_image(box_blur(im, radius, passes), data, delta) assert_image(box_blur(im, radius, passes), data, delta)
rgba = Image.merge("RGBA", (im, im, im, im)) rgba = Image.merge("RGBA", (im, im, im, im))
@ -44,7 +51,7 @@ def assert_blur(im, radius, data, passes=1, delta=0):
assert_image(band, data, delta) assert_image(band, data, delta)
def test_color_modes(): def test_color_modes() -> None:
with pytest.raises(ValueError): with pytest.raises(ValueError):
box_blur(sample.convert("1")) box_blur(sample.convert("1"))
with pytest.raises(ValueError): with pytest.raises(ValueError):
@ -64,7 +71,7 @@ def test_color_modes():
box_blur(sample.convert("YCbCr")) box_blur(sample.convert("YCbCr"))
def test_radius_0(): def test_radius_0() -> None:
assert_blur( assert_blur(
sample, sample,
0, 0,
@ -80,7 +87,7 @@ def test_radius_0():
) )
def test_radius_0_02(): def test_radius_0_02() -> None:
assert_blur( assert_blur(
sample, sample,
0.02, 0.02,
@ -97,7 +104,7 @@ def test_radius_0_02():
) )
def test_radius_0_05(): def test_radius_0_05() -> None:
assert_blur( assert_blur(
sample, sample,
0.05, 0.05,
@ -114,7 +121,7 @@ def test_radius_0_05():
) )
def test_radius_0_1(): def test_radius_0_1() -> None:
assert_blur( assert_blur(
sample, sample,
0.1, 0.1,
@ -131,7 +138,7 @@ def test_radius_0_1():
) )
def test_radius_0_5(): def test_radius_0_5() -> None:
assert_blur( assert_blur(
sample, sample,
0.5, 0.5,
@ -148,7 +155,7 @@ def test_radius_0_5():
) )
def test_radius_1(): def test_radius_1() -> None:
assert_blur( assert_blur(
sample, sample,
1, 1,
@ -165,7 +172,7 @@ def test_radius_1():
) )
def test_radius_1_5(): def test_radius_1_5() -> None:
assert_blur( assert_blur(
sample, sample,
1.5, 1.5,
@ -182,7 +189,7 @@ def test_radius_1_5():
) )
def test_radius_bigger_then_half(): def test_radius_bigger_then_half() -> None:
assert_blur( assert_blur(
sample, sample,
3, 3,
@ -199,7 +206,7 @@ def test_radius_bigger_then_half():
) )
def test_radius_bigger_then_width(): def test_radius_bigger_then_width() -> None:
assert_blur( assert_blur(
sample, sample,
10, 10,
@ -214,7 +221,7 @@ def test_radius_bigger_then_width():
) )
def test_extreme_large_radius(): def test_extreme_large_radius() -> None:
assert_blur( assert_blur(
sample, sample,
600, 600,
@ -229,7 +236,7 @@ def test_extreme_large_radius():
) )
def test_two_passes(): def test_two_passes() -> None:
assert_blur( assert_blur(
sample, sample,
1, 1,
@ -247,7 +254,7 @@ def test_two_passes():
) )
def test_three_passes(): def test_three_passes() -> None:
assert_blur( assert_blur(
sample, sample,
1, 1,

View File

@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
from array import array from array import array
from types import ModuleType
import pytest import pytest
@ -7,6 +9,7 @@ from PIL import Image, ImageFilter
from .helper import assert_image_equal from .helper import assert_image_equal
numpy: ModuleType | None
try: try:
import numpy import numpy
except ImportError: except ImportError:
@ -14,7 +17,9 @@ except ImportError:
class TestColorLut3DCoreAPI: class TestColorLut3DCoreAPI:
def generate_identity_table(self, channels, size): def generate_identity_table(
self, channels: int, size: int | tuple[int, int, int]
) -> tuple[int, int, int, int, list[float]]:
if isinstance(size, tuple): if isinstance(size, tuple):
size_1d, size_2d, size_3d = size size_1d, size_2d, size_3d = size
else: else:
@ -40,7 +45,7 @@ class TestColorLut3DCoreAPI:
[item for sublist in table for item in sublist], [item for sublist in table for item in sublist],
) )
def test_wrong_args(self): def test_wrong_args(self) -> None:
im = Image.new("RGB", (10, 10), 0) im = Image.new("RGB", (10, 10), 0)
with pytest.raises(ValueError, match="filter"): with pytest.raises(ValueError, match="filter"):
@ -100,7 +105,7 @@ class TestColorLut3DCoreAPI:
with pytest.raises(TypeError): with pytest.raises(TypeError):
im.im.color_lut_3d("RGB", Image.Resampling.BILINEAR, 3, 2, 2, 2, 16) im.im.color_lut_3d("RGB", Image.Resampling.BILINEAR, 3, 2, 2, 2, 16)
def test_correct_args(self): def test_correct_args(self) -> None:
im = Image.new("RGB", (10, 10), 0) im = Image.new("RGB", (10, 10), 0)
im.im.color_lut_3d( im.im.color_lut_3d(
@ -135,7 +140,7 @@ class TestColorLut3DCoreAPI:
*self.generate_identity_table(3, (3, 3, 65)), *self.generate_identity_table(3, (3, 3, 65)),
) )
def test_wrong_mode(self): def test_wrong_mode(self) -> None:
with pytest.raises(ValueError, match="wrong mode"): with pytest.raises(ValueError, match="wrong mode"):
im = Image.new("L", (10, 10), 0) im = Image.new("L", (10, 10), 0)
im.im.color_lut_3d( im.im.color_lut_3d(
@ -166,7 +171,7 @@ class TestColorLut3DCoreAPI:
"RGB", Image.Resampling.BILINEAR, *self.generate_identity_table(4, 3) "RGB", Image.Resampling.BILINEAR, *self.generate_identity_table(4, 3)
) )
def test_correct_mode(self): def test_correct_mode(self) -> None:
im = Image.new("RGBA", (10, 10), 0) im = Image.new("RGBA", (10, 10), 0)
im.im.color_lut_3d( im.im.color_lut_3d(
"RGBA", Image.Resampling.BILINEAR, *self.generate_identity_table(3, 3) "RGBA", Image.Resampling.BILINEAR, *self.generate_identity_table(3, 3)
@ -187,7 +192,7 @@ class TestColorLut3DCoreAPI:
"RGBA", Image.Resampling.BILINEAR, *self.generate_identity_table(4, 3) "RGBA", Image.Resampling.BILINEAR, *self.generate_identity_table(4, 3)
) )
def test_identities(self): def test_identities(self) -> None:
g = Image.linear_gradient("L") g = Image.linear_gradient("L")
im = Image.merge( im = Image.merge(
"RGB", "RGB",
@ -223,7 +228,7 @@ class TestColorLut3DCoreAPI:
), ),
) )
def test_identities_4_channels(self): def test_identities_4_channels(self) -> None:
g = Image.linear_gradient("L") g = Image.linear_gradient("L")
im = Image.merge( im = Image.merge(
"RGB", "RGB",
@ -246,7 +251,7 @@ class TestColorLut3DCoreAPI:
), ),
) )
def test_copy_alpha_channel(self): def test_copy_alpha_channel(self) -> None:
g = Image.linear_gradient("L") g = Image.linear_gradient("L")
im = Image.merge( im = Image.merge(
"RGBA", "RGBA",
@ -269,7 +274,7 @@ class TestColorLut3DCoreAPI:
), ),
) )
def test_channels_order(self): def test_channels_order(self) -> None:
g = Image.linear_gradient("L") g = Image.linear_gradient("L")
im = Image.merge( im = Image.merge(
"RGB", "RGB",
@ -294,7 +299,7 @@ class TestColorLut3DCoreAPI:
]))) ])))
# fmt: on # fmt: on
def test_overflow(self): def test_overflow(self) -> None:
g = Image.linear_gradient("L") g = Image.linear_gradient("L")
im = Image.merge( im = Image.merge(
"RGB", "RGB",
@ -347,7 +352,7 @@ class TestColorLut3DCoreAPI:
class TestColorLut3DFilter: class TestColorLut3DFilter:
def test_wrong_args(self): def test_wrong_args(self) -> None:
with pytest.raises(ValueError, match="should be either an integer"): with pytest.raises(ValueError, match="should be either an integer"):
ImageFilter.Color3DLUT("small", [1]) ImageFilter.Color3DLUT("small", [1])
@ -375,7 +380,7 @@ class TestColorLut3DFilter:
with pytest.raises(ValueError, match="Only 3 or 4 output"): with pytest.raises(ValueError, match="Only 3 or 4 output"):
ImageFilter.Color3DLUT((2, 2, 2), [[1, 1]] * 8, channels=2) ImageFilter.Color3DLUT((2, 2, 2), [[1, 1]] * 8, channels=2)
def test_convert_table(self): def test_convert_table(self) -> None:
lut = ImageFilter.Color3DLUT(2, [0, 1, 2] * 8) lut = ImageFilter.Color3DLUT(2, [0, 1, 2] * 8)
assert tuple(lut.size) == (2, 2, 2) assert tuple(lut.size) == (2, 2, 2)
assert lut.name == "Color 3D LUT" assert lut.name == "Color 3D LUT"
@ -393,7 +398,8 @@ class TestColorLut3DFilter:
assert lut.table == list(range(4)) * 8 assert lut.table == list(range(4)) * 8
@pytest.mark.skipif(numpy is None, reason="NumPy not installed") @pytest.mark.skipif(numpy is None, reason="NumPy not installed")
def test_numpy_sources(self): def test_numpy_sources(self) -> None:
assert numpy is not None
table = numpy.ones((5, 6, 7, 3), dtype=numpy.float16) table = numpy.ones((5, 6, 7, 3), dtype=numpy.float16)
with pytest.raises(ValueError, match="should have either channels"): with pytest.raises(ValueError, match="should have either channels"):
lut = ImageFilter.Color3DLUT((5, 6, 7), table) lut = ImageFilter.Color3DLUT((5, 6, 7), table)
@ -426,7 +432,8 @@ class TestColorLut3DFilter:
assert lut.table[0] == 33 assert lut.table[0] == 33
@pytest.mark.skipif(numpy is None, reason="NumPy not installed") @pytest.mark.skipif(numpy is None, reason="NumPy not installed")
def test_numpy_formats(self): def test_numpy_formats(self) -> None:
assert numpy is not None
g = Image.linear_gradient("L") g = Image.linear_gradient("L")
im = Image.merge( im = Image.merge(
"RGB", "RGB",
@ -465,7 +472,7 @@ class TestColorLut3DFilter:
lut.table = numpy.array(lut.table, dtype=numpy.int8) lut.table = numpy.array(lut.table, dtype=numpy.int8)
im.filter(lut) im.filter(lut)
def test_repr(self): def test_repr(self) -> None:
lut = ImageFilter.Color3DLUT(2, [0, 1, 2] * 8) lut = ImageFilter.Color3DLUT(2, [0, 1, 2] * 8)
assert repr(lut) == "<Color3DLUT from list size=2x2x2 channels=3>" assert repr(lut) == "<Color3DLUT from list size=2x2x2 channels=3>"
@ -483,7 +490,7 @@ class TestColorLut3DFilter:
class TestGenerateColorLut3D: class TestGenerateColorLut3D:
def test_wrong_channels_count(self): def test_wrong_channels_count(self) -> None:
with pytest.raises(ValueError, match="3 or 4 output channels"): with pytest.raises(ValueError, match="3 or 4 output channels"):
ImageFilter.Color3DLUT.generate( ImageFilter.Color3DLUT.generate(
5, channels=2, callback=lambda r, g, b: (r, g, b) 5, channels=2, callback=lambda r, g, b: (r, g, b)
@ -497,7 +504,7 @@ class TestGenerateColorLut3D:
5, channels=4, callback=lambda r, g, b: (r, g, b) 5, channels=4, callback=lambda r, g, b: (r, g, b)
) )
def test_3_channels(self): def test_3_channels(self) -> None:
lut = ImageFilter.Color3DLUT.generate(5, lambda r, g, b: (r, g, b)) lut = ImageFilter.Color3DLUT.generate(5, lambda r, g, b: (r, g, b))
assert tuple(lut.size) == (5, 5, 5) assert tuple(lut.size) == (5, 5, 5)
assert lut.name == "Color 3D LUT" assert lut.name == "Color 3D LUT"
@ -507,7 +514,7 @@ class TestGenerateColorLut3D:
1.0, 0.0, 0.0, 0.0, 0.25, 0.0, 0.25, 0.25, 0.0, 0.5, 0.25, 0.0] 1.0, 0.0, 0.0, 0.0, 0.25, 0.0, 0.25, 0.25, 0.0, 0.5, 0.25, 0.0]
# fmt: on # fmt: on
def test_4_channels(self): def test_4_channels(self) -> None:
lut = ImageFilter.Color3DLUT.generate( lut = ImageFilter.Color3DLUT.generate(
5, channels=4, callback=lambda r, g, b: (b, r, g, (r + g + b) / 2) 5, channels=4, callback=lambda r, g, b: (b, r, g, (r + g + b) / 2)
) )
@ -520,7 +527,7 @@ class TestGenerateColorLut3D:
] ]
# fmt: on # fmt: on
def test_apply(self): def test_apply(self) -> None:
lut = ImageFilter.Color3DLUT.generate(5, lambda r, g, b: (r, g, b)) lut = ImageFilter.Color3DLUT.generate(5, lambda r, g, b: (r, g, b))
g = Image.linear_gradient("L") g = Image.linear_gradient("L")
@ -536,7 +543,7 @@ class TestGenerateColorLut3D:
class TestTransformColorLut3D: class TestTransformColorLut3D:
def test_wrong_args(self): def test_wrong_args(self) -> None:
source = ImageFilter.Color3DLUT.generate(5, lambda r, g, b: (r, g, b)) source = ImageFilter.Color3DLUT.generate(5, lambda r, g, b: (r, g, b))
with pytest.raises(ValueError, match="Only 3 or 4 output"): with pytest.raises(ValueError, match="Only 3 or 4 output"):
@ -551,7 +558,7 @@ class TestTransformColorLut3D:
with pytest.raises(TypeError): with pytest.raises(TypeError):
source.transform(lambda r, g, b, a: (r, g, b)) source.transform(lambda r, g, b, a: (r, g, b))
def test_target_mode(self): def test_target_mode(self) -> None:
source = ImageFilter.Color3DLUT.generate( source = ImageFilter.Color3DLUT.generate(
2, lambda r, g, b: (r, g, b), target_mode="HSV" 2, lambda r, g, b: (r, g, b), target_mode="HSV"
) )
@ -562,7 +569,7 @@ class TestTransformColorLut3D:
lut = source.transform(lambda r, g, b: (r, g, b), target_mode="RGB") lut = source.transform(lambda r, g, b: (r, g, b), target_mode="RGB")
assert lut.mode == "RGB" assert lut.mode == "RGB"
def test_3_to_3_channels(self): def test_3_to_3_channels(self) -> None:
source = ImageFilter.Color3DLUT.generate((3, 4, 5), lambda r, g, b: (r, g, b)) source = ImageFilter.Color3DLUT.generate((3, 4, 5), lambda r, g, b: (r, g, b))
lut = source.transform(lambda r, g, b: (r * r, g * g, b * b)) lut = source.transform(lambda r, g, b: (r * r, g * g, b * b))
assert tuple(lut.size) == tuple(source.size) assert tuple(lut.size) == tuple(source.size)
@ -570,7 +577,7 @@ class TestTransformColorLut3D:
assert lut.table != source.table assert lut.table != source.table
assert lut.table[:10] == [0.0, 0.0, 0.0, 0.25, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0] assert lut.table[:10] == [0.0, 0.0, 0.0, 0.25, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0]
def test_3_to_4_channels(self): def test_3_to_4_channels(self) -> None:
source = ImageFilter.Color3DLUT.generate((6, 5, 4), lambda r, g, b: (r, g, b)) source = ImageFilter.Color3DLUT.generate((6, 5, 4), lambda r, g, b: (r, g, b))
lut = source.transform(lambda r, g, b: (r * r, g * g, b * b, 1), channels=4) lut = source.transform(lambda r, g, b: (r * r, g * g, b * b, 1), channels=4)
assert tuple(lut.size) == tuple(source.size) assert tuple(lut.size) == tuple(source.size)
@ -582,7 +589,7 @@ class TestTransformColorLut3D:
0.4**2, 0.0, 0.0, 1, 0.6**2, 0.0, 0.0, 1] 0.4**2, 0.0, 0.0, 1, 0.6**2, 0.0, 0.0, 1]
# fmt: on # fmt: on
def test_4_to_3_channels(self): def test_4_to_3_channels(self) -> None:
source = ImageFilter.Color3DLUT.generate( source = ImageFilter.Color3DLUT.generate(
(3, 6, 5), lambda r, g, b: (r, g, b, 1), channels=4 (3, 6, 5), lambda r, g, b: (r, g, b, 1), channels=4
) )
@ -598,7 +605,7 @@ class TestTransformColorLut3D:
1.0, 0.96, 1.0, 0.75, 0.96, 1.0, 0.0, 0.96, 1.0] 1.0, 0.96, 1.0, 0.75, 0.96, 1.0, 0.0, 0.96, 1.0]
# fmt: on # fmt: on
def test_4_to_4_channels(self): def test_4_to_4_channels(self) -> None:
source = ImageFilter.Color3DLUT.generate( source = ImageFilter.Color3DLUT.generate(
(6, 5, 4), lambda r, g, b: (r, g, b, 1), channels=4 (6, 5, 4), lambda r, g, b: (r, g, b, 1), channels=4
) )
@ -612,7 +619,7 @@ class TestTransformColorLut3D:
0.4**2, 0.0, 0.0, 0.5, 0.6**2, 0.0, 0.0, 0.5] 0.4**2, 0.0, 0.0, 0.5, 0.6**2, 0.0, 0.0, 0.5]
# fmt: on # fmt: on
def test_with_normals_3_channels(self): def test_with_normals_3_channels(self) -> None:
source = ImageFilter.Color3DLUT.generate( source = ImageFilter.Color3DLUT.generate(
(6, 5, 4), lambda r, g, b: (r * r, g * g, b * b) (6, 5, 4), lambda r, g, b: (r * r, g * g, b * b)
) )
@ -628,7 +635,7 @@ class TestTransformColorLut3D:
0.24, 0.0, 0.0, 0.8 - (0.8**2), 0, 0, 0, 0, 0] 0.24, 0.0, 0.0, 0.8 - (0.8**2), 0, 0, 0, 0, 0]
# fmt: on # fmt: on
def test_with_normals_4_channels(self): def test_with_normals_4_channels(self) -> None:
source = ImageFilter.Color3DLUT.generate( source = ImageFilter.Color3DLUT.generate(
(3, 6, 5), lambda r, g, b: (r * r, g * g, b * b, 1), channels=4 (3, 6, 5), lambda r, g, b: (r * r, g * g, b * b, 1), channels=4
) )

View File

@ -1,4 +1,5 @@
from __future__ import annotations from __future__ import annotations
import sys import sys
import pytest import pytest
@ -8,7 +9,7 @@ from PIL import Image
from .helper import is_pypy from .helper import is_pypy
def test_get_stats(): def test_get_stats() -> None:
# Create at least one image # Create at least one image
Image.new("RGB", (10, 10)) Image.new("RGB", (10, 10))
@ -21,7 +22,7 @@ def test_get_stats():
assert "blocks_cached" in stats assert "blocks_cached" in stats
def test_reset_stats(): def test_reset_stats() -> None:
Image.core.reset_stats() Image.core.reset_stats()
stats = Image.core.get_stats() stats = Image.core.get_stats()
@ -34,19 +35,19 @@ def test_reset_stats():
class TestCoreMemory: class TestCoreMemory:
def teardown_method(self): def teardown_method(self) -> None:
# Restore default values # Restore default values
Image.core.set_alignment(1) Image.core.set_alignment(1)
Image.core.set_block_size(1024 * 1024) Image.core.set_block_size(1024 * 1024)
Image.core.set_blocks_max(0) Image.core.set_blocks_max(0)
Image.core.clear_cache() Image.core.clear_cache()
def test_get_alignment(self): def test_get_alignment(self) -> None:
alignment = Image.core.get_alignment() alignment = Image.core.get_alignment()
assert alignment > 0 assert alignment > 0
def test_set_alignment(self): def test_set_alignment(self) -> None:
for i in [1, 2, 4, 8, 16, 32]: for i in [1, 2, 4, 8, 16, 32]:
Image.core.set_alignment(i) Image.core.set_alignment(i)
alignment = Image.core.get_alignment() alignment = Image.core.get_alignment()
@ -62,12 +63,12 @@ class TestCoreMemory:
with pytest.raises(ValueError): with pytest.raises(ValueError):
Image.core.set_alignment(3) Image.core.set_alignment(3)
def test_get_block_size(self): def test_get_block_size(self) -> None:
block_size = Image.core.get_block_size() block_size = Image.core.get_block_size()
assert block_size >= 4096 assert block_size >= 4096
def test_set_block_size(self): def test_set_block_size(self) -> None:
for i in [4096, 2 * 4096, 3 * 4096]: for i in [4096, 2 * 4096, 3 * 4096]:
Image.core.set_block_size(i) Image.core.set_block_size(i)
block_size = Image.core.get_block_size() block_size = Image.core.get_block_size()
@ -83,7 +84,7 @@ class TestCoreMemory:
with pytest.raises(ValueError): with pytest.raises(ValueError):
Image.core.set_block_size(4000) Image.core.set_block_size(4000)
def test_set_block_size_stats(self): def test_set_block_size_stats(self) -> None:
Image.core.reset_stats() Image.core.reset_stats()
Image.core.set_blocks_max(0) Image.core.set_blocks_max(0)
Image.core.set_block_size(4096) Image.core.set_block_size(4096)
@ -95,12 +96,12 @@ class TestCoreMemory:
if not is_pypy(): if not is_pypy():
assert stats["freed_blocks"] >= 64 assert stats["freed_blocks"] >= 64
def test_get_blocks_max(self): def test_get_blocks_max(self) -> None:
blocks_max = Image.core.get_blocks_max() blocks_max = Image.core.get_blocks_max()
assert blocks_max >= 0 assert blocks_max >= 0
def test_set_blocks_max(self): def test_set_blocks_max(self) -> None:
for i in [0, 1, 10]: for i in [0, 1, 10]:
Image.core.set_blocks_max(i) Image.core.set_blocks_max(i)
blocks_max = Image.core.get_blocks_max() blocks_max = Image.core.get_blocks_max()
@ -116,7 +117,7 @@ class TestCoreMemory:
Image.core.set_blocks_max(2**29) Image.core.set_blocks_max(2**29)
@pytest.mark.skipif(is_pypy(), reason="Images not collected") @pytest.mark.skipif(is_pypy(), reason="Images not collected")
def test_set_blocks_max_stats(self): def test_set_blocks_max_stats(self) -> None:
Image.core.reset_stats() Image.core.reset_stats()
Image.core.set_blocks_max(128) Image.core.set_blocks_max(128)
Image.core.set_block_size(4096) Image.core.set_block_size(4096)
@ -131,7 +132,7 @@ class TestCoreMemory:
assert stats["blocks_cached"] == 64 assert stats["blocks_cached"] == 64
@pytest.mark.skipif(is_pypy(), reason="Images not collected") @pytest.mark.skipif(is_pypy(), reason="Images not collected")
def test_clear_cache_stats(self): def test_clear_cache_stats(self) -> None:
Image.core.reset_stats() Image.core.reset_stats()
Image.core.clear_cache() Image.core.clear_cache()
Image.core.set_blocks_max(128) Image.core.set_blocks_max(128)
@ -148,7 +149,7 @@ class TestCoreMemory:
assert stats["freed_blocks"] >= 48 assert stats["freed_blocks"] >= 48
assert stats["blocks_cached"] == 16 assert stats["blocks_cached"] == 16
def test_large_images(self): def test_large_images(self) -> None:
Image.core.reset_stats() Image.core.reset_stats()
Image.core.set_blocks_max(0) Image.core.set_blocks_max(0)
Image.core.set_block_size(4096) Image.core.set_block_size(4096)
@ -165,14 +166,14 @@ class TestCoreMemory:
class TestEnvVars: class TestEnvVars:
def teardown_method(self): def teardown_method(self) -> None:
# Restore default values # Restore default values
Image.core.set_alignment(1) Image.core.set_alignment(1)
Image.core.set_block_size(1024 * 1024) Image.core.set_block_size(1024 * 1024)
Image.core.set_blocks_max(0) Image.core.set_blocks_max(0)
Image.core.clear_cache() Image.core.clear_cache()
def test_units(self): def test_units(self) -> None:
Image._apply_env_variables({"PILLOW_BLOCKS_MAX": "2K"}) Image._apply_env_variables({"PILLOW_BLOCKS_MAX": "2K"})
assert Image.core.get_blocks_max() == 2 * 1024 assert Image.core.get_blocks_max() == 2 * 1024
Image._apply_env_variables({"PILLOW_BLOCK_SIZE": "2m"}) Image._apply_env_variables({"PILLOW_BLOCK_SIZE": "2m"})
@ -186,6 +187,6 @@ class TestEnvVars:
{"PILLOW_BLOCKS_MAX": "wat"}, {"PILLOW_BLOCKS_MAX": "wat"},
), ),
) )
def test_warnings(self, var): def test_warnings(self, var: dict[str, str]) -> None:
with pytest.warns(UserWarning): with pytest.warns(UserWarning):
Image._apply_env_variables(var) Image._apply_env_variables(var)

View File

@ -1,4 +1,5 @@
from __future__ import annotations from __future__ import annotations
import pytest import pytest
from PIL import Image from PIL import Image
@ -11,16 +12,16 @@ ORIGINAL_LIMIT = Image.MAX_IMAGE_PIXELS
class TestDecompressionBomb: class TestDecompressionBomb:
def teardown_method(self, method): def teardown_method(self, method) -> None:
Image.MAX_IMAGE_PIXELS = ORIGINAL_LIMIT Image.MAX_IMAGE_PIXELS = ORIGINAL_LIMIT
def test_no_warning_small_file(self): def test_no_warning_small_file(self) -> None:
# Implicit assert: no warning. # Implicit assert: no warning.
# A warning would cause a failure. # A warning would cause a failure.
with Image.open(TEST_FILE): with Image.open(TEST_FILE):
pass pass
def test_no_warning_no_limit(self): def test_no_warning_no_limit(self) -> None:
# Arrange # Arrange
# Turn limit off # Turn limit off
Image.MAX_IMAGE_PIXELS = None Image.MAX_IMAGE_PIXELS = None
@ -32,7 +33,7 @@ class TestDecompressionBomb:
with Image.open(TEST_FILE): with Image.open(TEST_FILE):
pass pass
def test_warning(self): def test_warning(self) -> None:
# Set limit to trigger warning on the test file # Set limit to trigger warning on the test file
Image.MAX_IMAGE_PIXELS = 128 * 128 - 1 Image.MAX_IMAGE_PIXELS = 128 * 128 - 1
assert Image.MAX_IMAGE_PIXELS == 128 * 128 - 1 assert Image.MAX_IMAGE_PIXELS == 128 * 128 - 1
@ -41,7 +42,7 @@ class TestDecompressionBomb:
with Image.open(TEST_FILE): with Image.open(TEST_FILE):
pass pass
def test_exception(self): def test_exception(self) -> None:
# Set limit to trigger exception on the test file # Set limit to trigger exception on the test file
Image.MAX_IMAGE_PIXELS = 64 * 128 - 1 Image.MAX_IMAGE_PIXELS = 64 * 128 - 1
assert Image.MAX_IMAGE_PIXELS == 64 * 128 - 1 assert Image.MAX_IMAGE_PIXELS == 64 * 128 - 1
@ -50,22 +51,22 @@ class TestDecompressionBomb:
with Image.open(TEST_FILE): with Image.open(TEST_FILE):
pass pass
def test_exception_ico(self): def test_exception_ico(self) -> None:
with pytest.raises(Image.DecompressionBombError): with pytest.raises(Image.DecompressionBombError):
with Image.open("Tests/images/decompression_bomb.ico"): with Image.open("Tests/images/decompression_bomb.ico"):
pass pass
def test_exception_gif(self): def test_exception_gif(self) -> None:
with pytest.raises(Image.DecompressionBombError): with pytest.raises(Image.DecompressionBombError):
with Image.open("Tests/images/decompression_bomb.gif"): with Image.open("Tests/images/decompression_bomb.gif"):
pass pass
def test_exception_gif_extents(self): def test_exception_gif_extents(self) -> None:
with Image.open("Tests/images/decompression_bomb_extents.gif") as im: with Image.open("Tests/images/decompression_bomb_extents.gif") as im:
with pytest.raises(Image.DecompressionBombError): with pytest.raises(Image.DecompressionBombError):
im.seek(1) im.seek(1)
def test_exception_gif_zero_width(self): def test_exception_gif_zero_width(self) -> None:
# Set limit to trigger exception on the test file # Set limit to trigger exception on the test file
Image.MAX_IMAGE_PIXELS = 4 * 64 * 128 Image.MAX_IMAGE_PIXELS = 4 * 64 * 128
assert Image.MAX_IMAGE_PIXELS == 4 * 64 * 128 assert Image.MAX_IMAGE_PIXELS == 4 * 64 * 128
@ -74,7 +75,7 @@ class TestDecompressionBomb:
with Image.open("Tests/images/zero_width.gif"): with Image.open("Tests/images/zero_width.gif"):
pass pass
def test_exception_bmp(self): def test_exception_bmp(self) -> None:
with pytest.raises(Image.DecompressionBombError): with pytest.raises(Image.DecompressionBombError):
with Image.open("Tests/images/bmp/b/reallybig.bmp"): with Image.open("Tests/images/bmp/b/reallybig.bmp"):
pass pass
@ -82,15 +83,15 @@ class TestDecompressionBomb:
class TestDecompressionCrop: class TestDecompressionCrop:
@classmethod @classmethod
def setup_class(cls): def setup_class(cls) -> None:
width, height = 128, 128 width, height = 128, 128
Image.MAX_IMAGE_PIXELS = height * width * 4 - 1 Image.MAX_IMAGE_PIXELS = height * width * 4 - 1
@classmethod @classmethod
def teardown_class(cls): def teardown_class(cls) -> None:
Image.MAX_IMAGE_PIXELS = ORIGINAL_LIMIT Image.MAX_IMAGE_PIXELS = ORIGINAL_LIMIT
def test_enlarge_crop(self): def test_enlarge_crop(self) -> None:
# Crops can extend the extents, therefore we should have the # Crops can extend the extents, therefore we should have the
# same decompression bomb warnings on them. # same decompression bomb warnings on them.
with hopper() as src: with hopper() as src:
@ -98,7 +99,7 @@ class TestDecompressionCrop:
with pytest.warns(Image.DecompressionBombWarning): with pytest.warns(Image.DecompressionBombWarning):
src.crop(box) src.crop(box)
def test_crop_decompression_checks(self): def test_crop_decompression_checks(self) -> None:
im = Image.new("RGB", (100, 100)) im = Image.new("RGB", (100, 100))
for value in ((-9999, -9999, -9990, -9990), (-999, -999, -990, -990)): for value in ((-9999, -9999, -9990, -9990), (-999, -999, -990, -990)):

View File

@ -1,4 +1,5 @@
from __future__ import annotations from __future__ import annotations
import pytest import pytest
from PIL import _deprecate from PIL import _deprecate
@ -19,12 +20,12 @@ from PIL import _deprecate
), ),
], ],
) )
def test_version(version, expected): def test_version(version: int | None, expected: str) -> None:
with pytest.warns(DeprecationWarning, match=expected): with pytest.warns(DeprecationWarning, match=expected):
_deprecate.deprecate("Old thing", version, "new thing") _deprecate.deprecate("Old thing", version, "new thing")
def test_unknown_version(): def test_unknown_version() -> None:
expected = r"Unknown removal version: 12345. Update PIL\._deprecate\?" expected = r"Unknown removal version: 12345. Update PIL\._deprecate\?"
with pytest.raises(ValueError, match=expected): with pytest.raises(ValueError, match=expected):
_deprecate.deprecate("Old thing", 12345, "new thing") _deprecate.deprecate("Old thing", 12345, "new thing")
@ -45,13 +46,13 @@ def test_unknown_version():
), ),
], ],
) )
def test_old_version(deprecated, plural, expected): def test_old_version(deprecated: str, plural: bool, expected: str) -> None:
expected = r"" expected = r""
with pytest.raises(RuntimeError, match=expected): with pytest.raises(RuntimeError, match=expected):
_deprecate.deprecate(deprecated, 1, plural=plural) _deprecate.deprecate(deprecated, 1, plural=plural)
def test_plural(): def test_plural() -> None:
expected = ( expected = (
r"Old things are deprecated and will be removed in Pillow 11 \(2024-10-15\)\. " r"Old things are deprecated and will be removed in Pillow 11 \(2024-10-15\)\. "
r"Use new thing instead\." r"Use new thing instead\."
@ -60,7 +61,7 @@ def test_plural():
_deprecate.deprecate("Old things", 11, "new thing", plural=True) _deprecate.deprecate("Old things", 11, "new thing", plural=True)
def test_replacement_and_action(): def test_replacement_and_action() -> None:
expected = "Use only one of 'replacement' and 'action'" expected = "Use only one of 'replacement' and 'action'"
with pytest.raises(ValueError, match=expected): with pytest.raises(ValueError, match=expected):
_deprecate.deprecate( _deprecate.deprecate(
@ -75,7 +76,7 @@ def test_replacement_and_action():
"Upgrade to new thing.", "Upgrade to new thing.",
], ],
) )
def test_action(action): def test_action(action: str) -> None:
expected = ( expected = (
r"Old thing is deprecated and will be removed in Pillow 11 \(2024-10-15\)\. " r"Old thing is deprecated and will be removed in Pillow 11 \(2024-10-15\)\. "
r"Upgrade to new thing\." r"Upgrade to new thing\."
@ -84,7 +85,7 @@ def test_action(action):
_deprecate.deprecate("Old thing", 11, action=action) _deprecate.deprecate("Old thing", 11, action=action)
def test_no_replacement_or_action(): def test_no_replacement_or_action() -> None:
expected = ( expected = (
r"Old thing is deprecated and will be removed in Pillow 11 \(2024-10-15\)" r"Old thing is deprecated and will be removed in Pillow 11 \(2024-10-15\)"
) )

View File

@ -1,6 +1,8 @@
from __future__ import annotations from __future__ import annotations
import io import io
import re import re
from typing import Callable
import pytest import pytest
@ -14,7 +16,7 @@ except ImportError:
pass pass
def test_check(): def test_check() -> None:
# Check the correctness of the convenience function # Check the correctness of the convenience function
for module in features.modules: for module in features.modules:
assert features.check_module(module) == features.check(module) assert features.check_module(module) == features.check(module)
@ -24,17 +26,19 @@ def test_check():
assert features.check_feature(feature) == features.check(feature) assert features.check_feature(feature) == features.check(feature)
def test_version(): def test_version() -> None:
# Check the correctness of the convenience function # Check the correctness of the convenience function
# and the format of version numbers # and the format of version numbers
def test(name, function): def test(name: str, function: Callable[[str], bool]) -> None:
version = features.version(name) version = features.version(name)
if not features.check(name): if not features.check(name):
assert version is None assert version is None
else: else:
assert function(name) == version assert function(name) == version
if name != "PIL": if name != "PIL":
if name == "zlib" and version is not None:
version = version.replace(".zlib-ng", "")
assert version is None or re.search(r"\d+(\.\d+)*$", version) assert version is None or re.search(r"\d+(\.\d+)*$", version)
for module in features.modules: for module in features.modules:
@ -46,56 +50,56 @@ def test_version():
@skip_unless_feature("webp") @skip_unless_feature("webp")
def test_webp_transparency(): def test_webp_transparency() -> None:
assert features.check("transp_webp") != _webp.WebPDecoderBuggyAlpha() assert features.check("transp_webp") != _webp.WebPDecoderBuggyAlpha()
assert features.check("transp_webp") == _webp.HAVE_TRANSPARENCY assert features.check("transp_webp") == _webp.HAVE_TRANSPARENCY
@skip_unless_feature("webp") @skip_unless_feature("webp")
def test_webp_mux(): def test_webp_mux() -> None:
assert features.check("webp_mux") == _webp.HAVE_WEBPMUX assert features.check("webp_mux") == _webp.HAVE_WEBPMUX
@skip_unless_feature("webp") @skip_unless_feature("webp")
def test_webp_anim(): def test_webp_anim() -> None:
assert features.check("webp_anim") == _webp.HAVE_WEBPANIM assert features.check("webp_anim") == _webp.HAVE_WEBPANIM
@skip_unless_feature("libjpeg_turbo") @skip_unless_feature("libjpeg_turbo")
def test_libjpeg_turbo_version(): def test_libjpeg_turbo_version() -> None:
assert re.search(r"\d+\.\d+\.\d+$", features.version("libjpeg_turbo")) assert re.search(r"\d+\.\d+\.\d+$", features.version("libjpeg_turbo"))
@skip_unless_feature("libimagequant") @skip_unless_feature("libimagequant")
def test_libimagequant_version(): def test_libimagequant_version() -> None:
assert re.search(r"\d+\.\d+\.\d+$", features.version("libimagequant")) assert re.search(r"\d+\.\d+\.\d+$", features.version("libimagequant"))
@pytest.mark.parametrize("feature", features.modules) @pytest.mark.parametrize("feature", features.modules)
def test_check_modules(feature): def test_check_modules(feature: str) -> None:
assert features.check_module(feature) in [True, False] assert features.check_module(feature) in [True, False]
@pytest.mark.parametrize("feature", features.codecs) @pytest.mark.parametrize("feature", features.codecs)
def test_check_codecs(feature): def test_check_codecs(feature: str) -> None:
assert features.check_codec(feature) in [True, False] assert features.check_codec(feature) in [True, False]
def test_check_warns_on_nonexistent(): def test_check_warns_on_nonexistent() -> None:
with pytest.warns(UserWarning) as cm: with pytest.warns(UserWarning) as cm:
has_feature = features.check("typo") has_feature = features.check("typo")
assert has_feature is False assert has_feature is False
assert str(cm[-1].message) == "Unknown feature 'typo'." assert str(cm[-1].message) == "Unknown feature 'typo'."
def test_supported_modules(): def test_supported_modules() -> None:
assert isinstance(features.get_supported_modules(), list) assert isinstance(features.get_supported_modules(), list)
assert isinstance(features.get_supported_codecs(), list) assert isinstance(features.get_supported_codecs(), list)
assert isinstance(features.get_supported_features(), list) assert isinstance(features.get_supported_features(), list)
assert isinstance(features.get_supported(), list) assert isinstance(features.get_supported(), list)
def test_unsupported_codec(): def test_unsupported_codec() -> None:
# Arrange # Arrange
codec = "unsupported_codec" codec = "unsupported_codec"
# Act / Assert # Act / Assert
@ -105,7 +109,7 @@ def test_unsupported_codec():
features.version_codec(codec) features.version_codec(codec)
def test_unsupported_module(): def test_unsupported_module() -> None:
# Arrange # Arrange
module = "unsupported_module" module = "unsupported_module"
# Act / Assert # Act / Assert
@ -115,9 +119,10 @@ def test_unsupported_module():
features.version_module(module) features.version_module(module)
def test_pilinfo(): @pytest.mark.parametrize("supported_formats", (True, False))
def test_pilinfo(supported_formats) -> None:
buf = io.StringIO() buf = io.StringIO()
features.pilinfo(buf) features.pilinfo(buf, supported_formats=supported_formats)
out = buf.getvalue() out = buf.getvalue()
lines = out.splitlines() lines = out.splitlines()
assert lines[0] == "-" * 68 assert lines[0] == "-" * 68
@ -127,9 +132,15 @@ def test_pilinfo():
while lines[0].startswith(" "): while lines[0].startswith(" "):
lines = lines[1:] lines = lines[1:]
assert lines[0] == "-" * 68 assert lines[0] == "-" * 68
assert lines[1].startswith("Python modules loaded from ") assert lines[1].startswith("Python executable is")
assert lines[2].startswith("Binary modules loaded from ") lines = lines[2:]
assert lines[3] == "-" * 68 if lines[0].startswith("Environment Python files loaded from"):
lines = lines[1:]
assert lines[0].startswith("System Python files loaded from")
assert lines[1] == "-" * 68
assert lines[2].startswith("Python Pillow modules loaded from ")
assert lines[3].startswith("Binary Pillow modules loaded from ")
assert lines[4] == "-" * 68
jpeg = ( jpeg = (
"\n" "\n"
+ "-" * 68 + "-" * 68
@ -140,4 +151,4 @@ def test_pilinfo():
+ "-" * 68 + "-" * 68
+ "\n" + "\n"
) )
assert jpeg in out assert supported_formats == (jpeg in out)

View File

@ -1,4 +1,7 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path
import pytest import pytest
from PIL import Image, ImageSequence, PngImagePlugin from PIL import Image, ImageSequence, PngImagePlugin
@ -7,7 +10,7 @@ from PIL import Image, ImageSequence, PngImagePlugin
# APNG browser support tests and fixtures via: # APNG browser support tests and fixtures via:
# https://philip.html5.org/tests/apng/tests.html # https://philip.html5.org/tests/apng/tests.html
# (referenced from https://wiki.mozilla.org/APNG_Specification) # (referenced from https://wiki.mozilla.org/APNG_Specification)
def test_apng_basic(): def test_apng_basic() -> None:
with Image.open("Tests/images/apng/single_frame.png") as im: with Image.open("Tests/images/apng/single_frame.png") as im:
assert not im.is_animated assert not im.is_animated
assert im.n_frames == 1 assert im.n_frames == 1
@ -44,14 +47,14 @@ def test_apng_basic():
"filename", "filename",
("Tests/images/apng/split_fdat.png", "Tests/images/apng/split_fdat_zero_chunk.png"), ("Tests/images/apng/split_fdat.png", "Tests/images/apng/split_fdat_zero_chunk.png"),
) )
def test_apng_fdat(filename): def test_apng_fdat(filename: str) -> None:
with Image.open(filename) as im: with Image.open(filename) as im:
im.seek(im.n_frames - 1) im.seek(im.n_frames - 1)
assert im.getpixel((0, 0)) == (0, 255, 0, 255) assert im.getpixel((0, 0)) == (0, 255, 0, 255)
assert im.getpixel((64, 32)) == (0, 255, 0, 255) assert im.getpixel((64, 32)) == (0, 255, 0, 255)
def test_apng_dispose(): def test_apng_dispose() -> None:
with Image.open("Tests/images/apng/dispose_op_none.png") as im: with Image.open("Tests/images/apng/dispose_op_none.png") as im:
im.seek(im.n_frames - 1) im.seek(im.n_frames - 1)
assert im.getpixel((0, 0)) == (0, 255, 0, 255) assert im.getpixel((0, 0)) == (0, 255, 0, 255)
@ -83,7 +86,7 @@ def test_apng_dispose():
assert im.getpixel((64, 32)) == (0, 0, 0, 0) assert im.getpixel((64, 32)) == (0, 0, 0, 0)
def test_apng_dispose_region(): def test_apng_dispose_region() -> None:
with Image.open("Tests/images/apng/dispose_op_none_region.png") as im: with Image.open("Tests/images/apng/dispose_op_none_region.png") as im:
im.seek(im.n_frames - 1) im.seek(im.n_frames - 1)
assert im.getpixel((0, 0)) == (0, 255, 0, 255) assert im.getpixel((0, 0)) == (0, 255, 0, 255)
@ -105,7 +108,7 @@ def test_apng_dispose_region():
assert im.getpixel((64, 32)) == (0, 255, 0, 255) assert im.getpixel((64, 32)) == (0, 255, 0, 255)
def test_apng_dispose_op_previous_frame(): def test_apng_dispose_op_previous_frame() -> None:
# Test that the dispose settings being used are from the previous frame # Test that the dispose settings being used are from the previous frame
# #
# Image created with: # Image created with:
@ -130,14 +133,14 @@ def test_apng_dispose_op_previous_frame():
assert im.getpixel((0, 0)) == (255, 0, 0, 255) assert im.getpixel((0, 0)) == (255, 0, 0, 255)
def test_apng_dispose_op_background_p_mode(): def test_apng_dispose_op_background_p_mode() -> None:
with Image.open("Tests/images/apng/dispose_op_background_p_mode.png") as im: with Image.open("Tests/images/apng/dispose_op_background_p_mode.png") as im:
im.seek(1) im.seek(1)
im.load() im.load()
assert im.size == (128, 64) assert im.size == (128, 64)
def test_apng_blend(): def test_apng_blend() -> None:
with Image.open("Tests/images/apng/blend_op_source_solid.png") as im: with Image.open("Tests/images/apng/blend_op_source_solid.png") as im:
im.seek(im.n_frames - 1) im.seek(im.n_frames - 1)
assert im.getpixel((0, 0)) == (0, 255, 0, 255) assert im.getpixel((0, 0)) == (0, 255, 0, 255)
@ -164,20 +167,20 @@ def test_apng_blend():
assert im.getpixel((64, 32)) == (0, 255, 0, 255) assert im.getpixel((64, 32)) == (0, 255, 0, 255)
def test_apng_blend_transparency(): def test_apng_blend_transparency() -> None:
with Image.open("Tests/images/blend_transparency.png") as im: with Image.open("Tests/images/blend_transparency.png") as im:
im.seek(1) im.seek(1)
assert im.getpixel((0, 0)) == (255, 0, 0) assert im.getpixel((0, 0)) == (255, 0, 0)
def test_apng_chunk_order(): def test_apng_chunk_order() -> None:
with Image.open("Tests/images/apng/fctl_actl.png") as im: with Image.open("Tests/images/apng/fctl_actl.png") as im:
im.seek(im.n_frames - 1) im.seek(im.n_frames - 1)
assert im.getpixel((0, 0)) == (0, 255, 0, 255) assert im.getpixel((0, 0)) == (0, 255, 0, 255)
assert im.getpixel((64, 32)) == (0, 255, 0, 255) assert im.getpixel((64, 32)) == (0, 255, 0, 255)
def test_apng_delay(): def test_apng_delay() -> None:
with Image.open("Tests/images/apng/delay.png") as im: with Image.open("Tests/images/apng/delay.png") as im:
im.seek(1) im.seek(1)
assert im.info.get("duration") == 500.0 assert im.info.get("duration") == 500.0
@ -217,7 +220,7 @@ def test_apng_delay():
assert im.info.get("duration") == 1000.0 assert im.info.get("duration") == 1000.0
def test_apng_num_plays(): def test_apng_num_plays() -> None:
with Image.open("Tests/images/apng/num_plays.png") as im: with Image.open("Tests/images/apng/num_plays.png") as im:
assert im.info.get("loop") == 0 assert im.info.get("loop") == 0
@ -225,7 +228,7 @@ def test_apng_num_plays():
assert im.info.get("loop") == 1 assert im.info.get("loop") == 1
def test_apng_mode(): def test_apng_mode() -> None:
with Image.open("Tests/images/apng/mode_16bit.png") as im: with Image.open("Tests/images/apng/mode_16bit.png") as im:
assert im.mode == "RGBA" assert im.mode == "RGBA"
im.seek(im.n_frames - 1) im.seek(im.n_frames - 1)
@ -266,7 +269,7 @@ def test_apng_mode():
assert im.getpixel((64, 32)) == (0, 0, 255, 128) assert im.getpixel((64, 32)) == (0, 0, 255, 128)
def test_apng_chunk_errors(): def test_apng_chunk_errors() -> None:
with Image.open("Tests/images/apng/chunk_no_actl.png") as im: with Image.open("Tests/images/apng/chunk_no_actl.png") as im:
assert not im.is_animated assert not im.is_animated
@ -291,7 +294,7 @@ def test_apng_chunk_errors():
im.seek(im.n_frames - 1) im.seek(im.n_frames - 1)
def test_apng_syntax_errors(): def test_apng_syntax_errors() -> None:
with pytest.warns(UserWarning): with pytest.warns(UserWarning):
with Image.open("Tests/images/apng/syntax_num_frames_zero.png") as im: with Image.open("Tests/images/apng/syntax_num_frames_zero.png") as im:
assert not im.is_animated assert not im.is_animated
@ -335,14 +338,14 @@ def test_apng_syntax_errors():
"sequence_fdat_fctl.png", "sequence_fdat_fctl.png",
), ),
) )
def test_apng_sequence_errors(test_file): def test_apng_sequence_errors(test_file: str) -> None:
with pytest.raises(SyntaxError): with pytest.raises(SyntaxError):
with Image.open(f"Tests/images/apng/{test_file}") as im: with Image.open(f"Tests/images/apng/{test_file}") as im:
im.seek(im.n_frames - 1) im.seek(im.n_frames - 1)
im.load() im.load()
def test_apng_save(tmp_path): def test_apng_save(tmp_path: Path) -> None:
with Image.open("Tests/images/apng/single_frame.png") as im: with Image.open("Tests/images/apng/single_frame.png") as im:
test_file = str(tmp_path / "temp.png") test_file = str(tmp_path / "temp.png")
im.save(test_file, save_all=True) im.save(test_file, save_all=True)
@ -373,7 +376,7 @@ def test_apng_save(tmp_path):
assert im.getpixel((64, 32)) == (0, 255, 0, 255) assert im.getpixel((64, 32)) == (0, 255, 0, 255)
def test_apng_save_alpha(tmp_path): def test_apng_save_alpha(tmp_path: Path) -> None:
test_file = str(tmp_path / "temp.png") test_file = str(tmp_path / "temp.png")
im = Image.new("RGBA", (1, 1), (255, 0, 0, 255)) im = Image.new("RGBA", (1, 1), (255, 0, 0, 255))
@ -387,7 +390,7 @@ def test_apng_save_alpha(tmp_path):
assert reloaded.getpixel((0, 0)) == (255, 0, 0, 127) assert reloaded.getpixel((0, 0)) == (255, 0, 0, 127)
def test_apng_save_split_fdat(tmp_path): def test_apng_save_split_fdat(tmp_path: Path) -> None:
# test to make sure we do not generate sequence errors when writing # test to make sure we do not generate sequence errors when writing
# frames with image data spanning multiple fdAT chunks (in this case # frames with image data spanning multiple fdAT chunks (in this case
# both the default image and first animation frame will span multiple # both the default image and first animation frame will span multiple
@ -411,7 +414,7 @@ def test_apng_save_split_fdat(tmp_path):
assert exception is None assert exception is None
def test_apng_save_duration_loop(tmp_path): def test_apng_save_duration_loop(tmp_path: Path) -> None:
test_file = str(tmp_path / "temp.png") test_file = str(tmp_path / "temp.png")
with Image.open("Tests/images/apng/delay.png") as im: with Image.open("Tests/images/apng/delay.png") as im:
frames = [] frames = []
@ -474,7 +477,7 @@ def test_apng_save_duration_loop(tmp_path):
assert im.info["duration"] == 600 assert im.info["duration"] == 600
def test_apng_save_disposal(tmp_path): def test_apng_save_disposal(tmp_path: Path) -> None:
test_file = str(tmp_path / "temp.png") test_file = str(tmp_path / "temp.png")
size = (128, 64) size = (128, 64)
red = Image.new("RGBA", size, (255, 0, 0, 255)) red = Image.new("RGBA", size, (255, 0, 0, 255))
@ -575,7 +578,7 @@ def test_apng_save_disposal(tmp_path):
assert im.getpixel((64, 32)) == (0, 0, 0, 0) assert im.getpixel((64, 32)) == (0, 0, 0, 0)
def test_apng_save_disposal_previous(tmp_path): def test_apng_save_disposal_previous(tmp_path: Path) -> None:
test_file = str(tmp_path / "temp.png") test_file = str(tmp_path / "temp.png")
size = (128, 64) size = (128, 64)
blue = Image.new("RGBA", size, (0, 0, 255, 255)) blue = Image.new("RGBA", size, (0, 0, 255, 255))
@ -597,7 +600,7 @@ def test_apng_save_disposal_previous(tmp_path):
assert im.getpixel((64, 32)) == (0, 255, 0, 255) assert im.getpixel((64, 32)) == (0, 255, 0, 255)
def test_apng_save_blend(tmp_path): def test_apng_save_blend(tmp_path: Path) -> None:
test_file = str(tmp_path / "temp.png") test_file = str(tmp_path / "temp.png")
size = (128, 64) size = (128, 64)
red = Image.new("RGBA", size, (255, 0, 0, 255)) red = Image.new("RGBA", size, (255, 0, 0, 255))
@ -665,7 +668,17 @@ def test_apng_save_blend(tmp_path):
assert im.getpixel((0, 0)) == (0, 255, 0, 255) assert im.getpixel((0, 0)) == (0, 255, 0, 255)
def test_seek_after_close(): def test_apng_save_size(tmp_path: Path) -> None:
test_file = str(tmp_path / "temp.png")
im = Image.new("L", (100, 100))
im.save(test_file, save_all=True, append_images=[Image.new("L", (200, 200))])
with Image.open(test_file) as reloaded:
assert reloaded.size == (200, 200)
def test_seek_after_close() -> None:
im = Image.open("Tests/images/apng/delay.png") im = Image.open("Tests/images/apng/delay.png")
im.seek(1) im.seek(1)
im.close() im.close()
@ -677,7 +690,9 @@ def test_seek_after_close():
@pytest.mark.parametrize("mode", ("RGBA", "RGB", "P")) @pytest.mark.parametrize("mode", ("RGBA", "RGB", "P"))
@pytest.mark.parametrize("default_image", (True, False)) @pytest.mark.parametrize("default_image", (True, False))
@pytest.mark.parametrize("duplicate", (True, False)) @pytest.mark.parametrize("duplicate", (True, False))
def test_different_modes_in_later_frames(mode, default_image, duplicate, tmp_path): def test_different_modes_in_later_frames(
mode: str, default_image: bool, duplicate: bool, tmp_path: Path
) -> None:
test_file = str(tmp_path / "temp.png") test_file = str(tmp_path / "temp.png")
im = Image.new("L", (1, 1)) im = Image.new("L", (1, 1))
@ -689,3 +704,12 @@ def test_different_modes_in_later_frames(mode, default_image, duplicate, tmp_pat
) )
with Image.open(test_file) as reloaded: with Image.open(test_file) as reloaded:
assert reloaded.mode == mode assert reloaded.mode == mode
def test_apng_repeated_seeks_give_correct_info() -> None:
with Image.open("Tests/images/apng/different_durations.png") as im:
for i in range(3):
im.seek(0)
assert im.info["duration"] == 4000
im.seek(1)
assert im.info["duration"] == 1000

View File

@ -1,4 +1,7 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path
import pytest import pytest
from PIL import Image from PIL import Image
@ -11,7 +14,7 @@ from .helper import (
) )
def test_load_blp1(): def test_load_blp1() -> None:
with Image.open("Tests/images/blp/blp1_jpeg.blp") as im: with Image.open("Tests/images/blp/blp1_jpeg.blp") as im:
assert_image_equal_tofile(im, "Tests/images/blp/blp1_jpeg.png") assert_image_equal_tofile(im, "Tests/images/blp/blp1_jpeg.png")
@ -19,22 +22,22 @@ def test_load_blp1():
im.load() im.load()
def test_load_blp2_raw(): def test_load_blp2_raw() -> None:
with Image.open("Tests/images/blp/blp2_raw.blp") as im: with Image.open("Tests/images/blp/blp2_raw.blp") as im:
assert_image_equal_tofile(im, "Tests/images/blp/blp2_raw.png") assert_image_equal_tofile(im, "Tests/images/blp/blp2_raw.png")
def test_load_blp2_dxt1(): def test_load_blp2_dxt1() -> None:
with Image.open("Tests/images/blp/blp2_dxt1.blp") as im: with Image.open("Tests/images/blp/blp2_dxt1.blp") as im:
assert_image_equal_tofile(im, "Tests/images/blp/blp2_dxt1.png") assert_image_equal_tofile(im, "Tests/images/blp/blp2_dxt1.png")
def test_load_blp2_dxt1a(): def test_load_blp2_dxt1a() -> None:
with Image.open("Tests/images/blp/blp2_dxt1a.blp") as im: with Image.open("Tests/images/blp/blp2_dxt1a.blp") as im:
assert_image_equal_tofile(im, "Tests/images/blp/blp2_dxt1a.png") assert_image_equal_tofile(im, "Tests/images/blp/blp2_dxt1a.png")
def test_save(tmp_path): def test_save(tmp_path: Path) -> None:
f = str(tmp_path / "temp.blp") f = str(tmp_path / "temp.blp")
for version in ("BLP1", "BLP2"): for version in ("BLP1", "BLP2"):
@ -68,7 +71,7 @@ def test_save(tmp_path):
"Tests/images/timeout-ef9112a065e7183fa7faa2e18929b03e44ee16bf.blp", "Tests/images/timeout-ef9112a065e7183fa7faa2e18929b03e44ee16bf.blp",
], ],
) )
def test_crashes(test_file): def test_crashes(test_file: str) -> None:
with open(test_file, "rb") as f: with open(test_file, "rb") as f:
with Image.open(f) as im: with Image.open(f) as im:
with pytest.raises(OSError): with pytest.raises(OSError):

View File

@ -1,9 +1,11 @@
from __future__ import annotations from __future__ import annotations
import io import io
from pathlib import Path
import pytest import pytest
from PIL import BmpImagePlugin, Image from PIL import BmpImagePlugin, Image, _binary
from .helper import ( from .helper import (
assert_image_equal, assert_image_equal,
@ -13,8 +15,8 @@ from .helper import (
) )
def test_sanity(tmp_path): def test_sanity(tmp_path: Path) -> None:
def roundtrip(im): def roundtrip(im: Image.Image) -> None:
outfile = str(tmp_path / "temp.bmp") outfile = str(tmp_path / "temp.bmp")
im.save(outfile, "BMP") im.save(outfile, "BMP")
@ -34,20 +36,20 @@ def test_sanity(tmp_path):
roundtrip(hopper("RGB")) roundtrip(hopper("RGB"))
def test_invalid_file(): def test_invalid_file() -> None:
with open("Tests/images/flower.jpg", "rb") as fp: with open("Tests/images/flower.jpg", "rb") as fp:
with pytest.raises(SyntaxError): with pytest.raises(SyntaxError):
BmpImagePlugin.BmpImageFile(fp) BmpImagePlugin.BmpImageFile(fp)
def test_fallback_if_mmap_errors(): def test_fallback_if_mmap_errors() -> None:
# This image has been truncated, # This image has been truncated,
# so that the buffer is not large enough when using mmap # so that the buffer is not large enough when using mmap
with Image.open("Tests/images/mmap_error.bmp") as im: with Image.open("Tests/images/mmap_error.bmp") as im:
assert_image_equal_tofile(im, "Tests/images/pal8_offset.bmp") assert_image_equal_tofile(im, "Tests/images/pal8_offset.bmp")
def test_save_to_bytes(): def test_save_to_bytes() -> None:
output = io.BytesIO() output = io.BytesIO()
im = hopper() im = hopper()
im.save(output, "BMP") im.save(output, "BMP")
@ -59,7 +61,7 @@ def test_save_to_bytes():
assert reloaded.format == "BMP" assert reloaded.format == "BMP"
def test_small_palette(tmp_path): def test_small_palette(tmp_path: Path) -> None:
im = Image.new("P", (1, 1)) im = Image.new("P", (1, 1))
colors = [0, 0, 0, 125, 125, 125, 255, 255, 255] colors = [0, 0, 0, 125, 125, 125, 255, 255, 255]
im.putpalette(colors) im.putpalette(colors)
@ -71,7 +73,7 @@ def test_small_palette(tmp_path):
assert reloaded.getpalette() == colors assert reloaded.getpalette() == colors
def test_save_too_large(tmp_path): def test_save_too_large(tmp_path: Path) -> None:
outfile = str(tmp_path / "temp.bmp") outfile = str(tmp_path / "temp.bmp")
with Image.new("RGB", (1, 1)) as im: with Image.new("RGB", (1, 1)) as im:
im._size = (37838, 37838) im._size = (37838, 37838)
@ -79,7 +81,7 @@ def test_save_too_large(tmp_path):
im.save(outfile) im.save(outfile)
def test_dpi(): def test_dpi() -> None:
dpi = (72, 72) dpi = (72, 72)
output = io.BytesIO() output = io.BytesIO()
@ -91,7 +93,7 @@ def test_dpi():
assert reloaded.info["dpi"] == (72.008961115161, 72.008961115161) assert reloaded.info["dpi"] == (72.008961115161, 72.008961115161)
def test_save_bmp_with_dpi(tmp_path): def test_save_bmp_with_dpi(tmp_path: Path) -> None:
# Test for #1301 # Test for #1301
# Arrange # Arrange
outfile = str(tmp_path / "temp.jpg") outfile = str(tmp_path / "temp.jpg")
@ -109,7 +111,7 @@ def test_save_bmp_with_dpi(tmp_path):
assert reloaded.format == "JPEG" assert reloaded.format == "JPEG"
def test_save_float_dpi(tmp_path): def test_save_float_dpi(tmp_path: Path) -> None:
outfile = str(tmp_path / "temp.bmp") outfile = str(tmp_path / "temp.bmp")
with Image.open("Tests/images/hopper.bmp") as im: with Image.open("Tests/images/hopper.bmp") as im:
im.save(outfile, dpi=(72.21216100543306, 72.21216100543306)) im.save(outfile, dpi=(72.21216100543306, 72.21216100543306))
@ -117,7 +119,7 @@ def test_save_float_dpi(tmp_path):
assert reloaded.info["dpi"] == (72.21216100543306, 72.21216100543306) assert reloaded.info["dpi"] == (72.21216100543306, 72.21216100543306)
def test_load_dib(): def test_load_dib() -> None:
# test for #1293, Imagegrab returning Unsupported Bitfields Format # test for #1293, Imagegrab returning Unsupported Bitfields Format
with Image.open("Tests/images/clipboard.dib") as im: with Image.open("Tests/images/clipboard.dib") as im:
assert im.format == "DIB" assert im.format == "DIB"
@ -126,7 +128,30 @@ def test_load_dib():
assert_image_equal_tofile(im, "Tests/images/clipboard_target.png") assert_image_equal_tofile(im, "Tests/images/clipboard_target.png")
def test_save_dib(tmp_path): @pytest.mark.parametrize(
"header_size, path",
(
(12, "g/pal8os2.bmp"),
(40, "g/pal1.bmp"),
(52, "q/rgb32h52.bmp"),
(56, "q/rgba32h56.bmp"),
(64, "q/pal8os2v2.bmp"),
(108, "g/pal8v4.bmp"),
(124, "g/pal8v5.bmp"),
),
)
def test_dib_header_size(header_size, path):
image_path = "Tests/images/bmp/" + path
with open(image_path, "rb") as fp:
data = fp.read()[14:]
assert _binary.i32le(data) == header_size
dib = io.BytesIO(data)
with Image.open(dib) as im:
im.load()
def test_save_dib(tmp_path: Path) -> None:
outfile = str(tmp_path / "temp.dib") outfile = str(tmp_path / "temp.dib")
with Image.open("Tests/images/clipboard.dib") as im: with Image.open("Tests/images/clipboard.dib") as im:
@ -138,7 +163,7 @@ def test_save_dib(tmp_path):
assert_image_equal(im, reloaded) assert_image_equal(im, reloaded)
def test_rgba_bitfields(): def test_rgba_bitfields() -> None:
# This test image has been manually hexedited # This test image has been manually hexedited
# to change the bitfield compression in the header from XBGR to RGBA # to change the bitfield compression in the header from XBGR to RGBA
with Image.open("Tests/images/rgb32bf-rgba.bmp") as im: with Image.open("Tests/images/rgb32bf-rgba.bmp") as im:
@ -156,7 +181,7 @@ def test_rgba_bitfields():
) )
def test_rle8(): def test_rle8() -> None:
with Image.open("Tests/images/hopper_rle8.bmp") as im: with Image.open("Tests/images/hopper_rle8.bmp") as im:
assert_image_similar_tofile(im.convert("RGB"), "Tests/images/hopper.bmp", 12) assert_image_similar_tofile(im.convert("RGB"), "Tests/images/hopper.bmp", 12)
@ -176,7 +201,7 @@ def test_rle8():
im.load() im.load()
def test_rle4(): def test_rle4() -> None:
with Image.open("Tests/images/bmp/g/pal4rle.bmp") as im: with Image.open("Tests/images/bmp/g/pal4rle.bmp") as im:
assert_image_similar_tofile(im, "Tests/images/bmp/g/pal4.bmp", 12) assert_image_similar_tofile(im, "Tests/images/bmp/g/pal4.bmp", 12)
@ -192,7 +217,7 @@ def test_rle4():
("Tests/images/bmp/g/pal8rle.bmp", 1064), ("Tests/images/bmp/g/pal8rle.bmp", 1064),
), ),
) )
def test_rle8_eof(file_name, length): def test_rle8_eof(file_name: str, length: int) -> None:
with open(file_name, "rb") as fp: with open(file_name, "rb") as fp:
data = fp.read(length) data = fp.read(length)
with Image.open(io.BytesIO(data)) as im: with Image.open(io.BytesIO(data)) as im:
@ -200,7 +225,7 @@ def test_rle8_eof(file_name, length):
im.load() im.load()
def test_offset(): def test_offset() -> None:
# This image has been hexedited # This image has been hexedited
# to exclude the palette size from the pixel data offset # to exclude the palette size from the pixel data offset
with Image.open("Tests/images/pal8_offset.bmp") as im: with Image.open("Tests/images/pal8_offset.bmp") as im:

View File

@ -1,4 +1,7 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path
import pytest import pytest
from PIL import BufrStubImagePlugin, Image from PIL import BufrStubImagePlugin, Image
@ -8,7 +11,7 @@ from .helper import hopper
TEST_FILE = "Tests/images/gfs.t06z.rassda.tm00.bufr_d" TEST_FILE = "Tests/images/gfs.t06z.rassda.tm00.bufr_d"
def test_open(): def test_open() -> None:
# Act # Act
with Image.open(TEST_FILE) as im: with Image.open(TEST_FILE) as im:
# Assert # Assert
@ -19,7 +22,7 @@ def test_open():
assert im.size == (1, 1) assert im.size == (1, 1)
def test_invalid_file(): def test_invalid_file() -> None:
# Arrange # Arrange
invalid_file = "Tests/images/flower.jpg" invalid_file = "Tests/images/flower.jpg"
@ -28,7 +31,7 @@ def test_invalid_file():
BufrStubImagePlugin.BufrStubImageFile(invalid_file) BufrStubImagePlugin.BufrStubImageFile(invalid_file)
def test_load(): def test_load() -> None:
# Arrange # Arrange
with Image.open(TEST_FILE) as im: with Image.open(TEST_FILE) as im:
# Act / Assert: stub cannot load without an implemented handler # Act / Assert: stub cannot load without an implemented handler
@ -36,7 +39,7 @@ def test_load():
im.load() im.load()
def test_save(tmp_path): def test_save(tmp_path: Path) -> None:
# Arrange # Arrange
im = hopper() im = hopper()
tmpfile = str(tmp_path / "temp.bufr") tmpfile = str(tmp_path / "temp.bufr")
@ -46,13 +49,13 @@ def test_save(tmp_path):
im.save(tmpfile) im.save(tmpfile)
def test_handler(tmp_path): def test_handler(tmp_path: Path) -> None:
class TestHandler: class TestHandler:
opened = False opened = False
loaded = False loaded = False
saved = False saved = False
def open(self, im): def open(self, im) -> None:
self.opened = True self.opened = True
def load(self, im): def load(self, im):
@ -60,7 +63,7 @@ def test_handler(tmp_path):
im.fp.close() im.fp.close()
return Image.new("RGB", (1, 1)) return Image.new("RGB", (1, 1))
def save(self, im, fp, filename): def save(self, im, fp, filename) -> None:
self.saved = True self.saved = True
handler = TestHandler() handler = TestHandler()

View File

@ -1,4 +1,7 @@
from __future__ import annotations from __future__ import annotations
from typing import Literal
import pytest import pytest
from PIL import ContainerIO, Image from PIL import ContainerIO, Image
@ -8,21 +11,28 @@ from .helper import hopper
TEST_FILE = "Tests/images/dummy.container" TEST_FILE = "Tests/images/dummy.container"
def test_sanity(): def test_sanity() -> None:
dir(Image) dir(Image)
dir(ContainerIO) dir(ContainerIO)
def test_isatty(): def test_isatty() -> None:
with hopper() as im: with hopper() as im:
container = ContainerIO.ContainerIO(im, 0, 0) container = ContainerIO.ContainerIO(im, 0, 0)
assert container.isatty() is False assert container.isatty() is False
def test_seek_mode_0(): @pytest.mark.parametrize(
"mode, expected_position",
(
(0, 33),
(1, 66),
(2, 100),
),
)
def test_seek_mode(mode: Literal[0, 1, 2], expected_position: int) -> None:
# Arrange # Arrange
mode = 0
with open(TEST_FILE, "rb") as fh: with open(TEST_FILE, "rb") as fh:
container = ContainerIO.ContainerIO(fh, 22, 100) container = ContainerIO.ContainerIO(fh, 22, 100)
@ -31,39 +41,11 @@ def test_seek_mode_0():
container.seek(33, mode) container.seek(33, mode)
# Assert # Assert
assert container.tell() == 33 assert container.tell() == expected_position
def test_seek_mode_1():
# Arrange
mode = 1
with open(TEST_FILE, "rb") as fh:
container = ContainerIO.ContainerIO(fh, 22, 100)
# Act
container.seek(33, mode)
container.seek(33, mode)
# Assert
assert container.tell() == 66
def test_seek_mode_2():
# Arrange
mode = 2
with open(TEST_FILE, "rb") as fh:
container = ContainerIO.ContainerIO(fh, 22, 100)
# Act
container.seek(33, mode)
container.seek(33, mode)
# Assert
assert container.tell() == 100
@pytest.mark.parametrize("bytesmode", (True, False)) @pytest.mark.parametrize("bytesmode", (True, False))
def test_read_n0(bytesmode): def test_read_n0(bytesmode: bool) -> None:
# Arrange # Arrange
with open(TEST_FILE, "rb" if bytesmode else "r") as fh: with open(TEST_FILE, "rb" if bytesmode else "r") as fh:
container = ContainerIO.ContainerIO(fh, 22, 100) container = ContainerIO.ContainerIO(fh, 22, 100)
@ -79,7 +61,7 @@ def test_read_n0(bytesmode):
@pytest.mark.parametrize("bytesmode", (True, False)) @pytest.mark.parametrize("bytesmode", (True, False))
def test_read_n(bytesmode): def test_read_n(bytesmode: bool) -> None:
# Arrange # Arrange
with open(TEST_FILE, "rb" if bytesmode else "r") as fh: with open(TEST_FILE, "rb" if bytesmode else "r") as fh:
container = ContainerIO.ContainerIO(fh, 22, 100) container = ContainerIO.ContainerIO(fh, 22, 100)
@ -95,7 +77,7 @@ def test_read_n(bytesmode):
@pytest.mark.parametrize("bytesmode", (True, False)) @pytest.mark.parametrize("bytesmode", (True, False))
def test_read_eof(bytesmode): def test_read_eof(bytesmode: bool) -> None:
# Arrange # Arrange
with open(TEST_FILE, "rb" if bytesmode else "r") as fh: with open(TEST_FILE, "rb" if bytesmode else "r") as fh:
container = ContainerIO.ContainerIO(fh, 22, 100) container = ContainerIO.ContainerIO(fh, 22, 100)
@ -111,7 +93,7 @@ def test_read_eof(bytesmode):
@pytest.mark.parametrize("bytesmode", (True, False)) @pytest.mark.parametrize("bytesmode", (True, False))
def test_readline(bytesmode): def test_readline(bytesmode: bool) -> None:
# Arrange # Arrange
with open(TEST_FILE, "rb" if bytesmode else "r") as fh: with open(TEST_FILE, "rb" if bytesmode else "r") as fh:
container = ContainerIO.ContainerIO(fh, 0, 120) container = ContainerIO.ContainerIO(fh, 0, 120)
@ -126,7 +108,7 @@ def test_readline(bytesmode):
@pytest.mark.parametrize("bytesmode", (True, False)) @pytest.mark.parametrize("bytesmode", (True, False))
def test_readlines(bytesmode): def test_readlines(bytesmode: bool) -> None:
# Arrange # Arrange
expected = [ expected = [
"This is line 1\n", "This is line 1\n",

View File

@ -1,4 +1,5 @@
from __future__ import annotations from __future__ import annotations
import pytest import pytest
from PIL import CurImagePlugin, Image from PIL import CurImagePlugin, Image
@ -6,7 +7,7 @@ from PIL import CurImagePlugin, Image
TEST_FILE = "Tests/images/deerstalker.cur" TEST_FILE = "Tests/images/deerstalker.cur"
def test_sanity(): def test_sanity() -> None:
with Image.open(TEST_FILE) as im: with Image.open(TEST_FILE) as im:
assert im.size == (32, 32) assert im.size == (32, 32)
assert isinstance(im, CurImagePlugin.CurImageFile) assert isinstance(im, CurImagePlugin.CurImageFile)
@ -16,7 +17,7 @@ def test_sanity():
assert im.getpixel((16, 16)) == (84, 87, 86, 255) assert im.getpixel((16, 16)) == (84, 87, 86, 255)
def test_invalid_file(): def test_invalid_file() -> None:
invalid_file = "Tests/images/flower.jpg" invalid_file = "Tests/images/flower.jpg"
with pytest.raises(SyntaxError): with pytest.raises(SyntaxError):

View File

@ -1,4 +1,5 @@
from __future__ import annotations from __future__ import annotations
import warnings import warnings
import pytest import pytest
@ -11,7 +12,7 @@ from .helper import assert_image_equal, hopper, is_pypy
TEST_FILE = "Tests/images/hopper.dcx" TEST_FILE = "Tests/images/hopper.dcx"
def test_sanity(): def test_sanity() -> None:
# Arrange # Arrange
# Act # Act
@ -24,8 +25,8 @@ def test_sanity():
@pytest.mark.skipif(is_pypy(), reason="Requires CPython") @pytest.mark.skipif(is_pypy(), reason="Requires CPython")
def test_unclosed_file(): def test_unclosed_file() -> None:
def open(): def open() -> None:
im = Image.open(TEST_FILE) im = Image.open(TEST_FILE)
im.load() im.load()
@ -33,26 +34,26 @@ def test_unclosed_file():
open() open()
def test_closed_file(): def test_closed_file() -> None:
with warnings.catch_warnings(): with warnings.catch_warnings():
im = Image.open(TEST_FILE) im = Image.open(TEST_FILE)
im.load() im.load()
im.close() im.close()
def test_context_manager(): def test_context_manager() -> None:
with warnings.catch_warnings(): with warnings.catch_warnings():
with Image.open(TEST_FILE) as im: with Image.open(TEST_FILE) as im:
im.load() im.load()
def test_invalid_file(): def test_invalid_file() -> None:
with open("Tests/images/flower.jpg", "rb") as fp: with open("Tests/images/flower.jpg", "rb") as fp:
with pytest.raises(SyntaxError): with pytest.raises(SyntaxError):
DcxImagePlugin.DcxImageFile(fp) DcxImagePlugin.DcxImageFile(fp)
def test_tell(): def test_tell() -> None:
# Arrange # Arrange
with Image.open(TEST_FILE) as im: with Image.open(TEST_FILE) as im:
# Act # Act
@ -62,13 +63,13 @@ def test_tell():
assert frame == 0 assert frame == 0
def test_n_frames(): def test_n_frames() -> None:
with Image.open(TEST_FILE) as im: with Image.open(TEST_FILE) as im:
assert im.n_frames == 1 assert im.n_frames == 1
assert not im.is_animated assert not im.is_animated
def test_eoferror(): def test_eoferror() -> None:
with Image.open(TEST_FILE) as im: with Image.open(TEST_FILE) as im:
n_frames = im.n_frames n_frames = im.n_frames
@ -81,7 +82,7 @@ def test_eoferror():
im.seek(n_frames - 1) im.seek(n_frames - 1)
def test_seek_too_far(): def test_seek_too_far() -> None:
# Arrange # Arrange
with Image.open(TEST_FILE) as im: with Image.open(TEST_FILE) as im:
frame = 999 # too big on purpose frame = 999 # too big on purpose

Some files were not shown because too many files have changed in this diff Show More