Merge branch 'main' into nasm

This commit is contained in:
Andrew Murray 2024-06-08 11:05:27 +10:00 committed by GitHub
commit b8eb519562
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
156 changed files with 1974 additions and 1416 deletions

View File

@ -34,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.03-win64.zip - curl -fsSL -o nasm-win64.zip https://raw.githubusercontent.com/python-pillow/pillow-depends/main/nasm-2.16.03-win64.zip
- 7z x nasm-win64.zip -oc:\ - 7z x nasm-win64.zip -oc:\
- choco install ghostscript --version=10.3.0 - choco install ghostscript --version=10.3.1
- path c:\nasm-2.16.03;C:\Program Files\gs\gs10.00.0\bin;%PATH% - path c:\nasm-2.16.03;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.17.0 cibuildwheel==2.18.1

View File

@ -1 +1 @@
mypy==1.9.0 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

@ -55,6 +55,7 @@ jobs:
packages: > packages: >
gcc-g++ gcc-g++
ghostscript ghostscript
git
ImageMagick ImageMagick
jpeg jpeg
libfreetype-devel libfreetype-devel
@ -132,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.1.5 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

@ -36,8 +36,8 @@ 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,
@ -47,19 +47,20 @@ jobs:
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 }}
@ -81,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
@ -99,11 +100,12 @@ jobs:
MATRIX_DOCKER: ${{ matrix.docker }} MATRIX_DOCKER: ${{ matrix.docker }}
- name: Upload coverage - name: Upload coverage
uses: codecov/codecov-action@v3.1.5 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

@ -85,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.1.5 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

@ -86,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.3.0 --no-progress choco install ghostscript --version=10.3.1 --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
@ -213,11 +213,12 @@ jobs:
shell: pwsh shell: pwsh
- name: Upload coverage - name: Upload coverage
uses: codecov/codecov-action@v3.1.5 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

@ -57,9 +57,9 @@ jobs:
- python-version: "3.10" - python-version: "3.10"
PYTHONOPTIMIZE: 2 PYTHONOPTIMIZE: 2
# M1 only available for 3.10+ # M1 only available for 3.10+
- os: "macos-latest" - os: "macos-13"
python-version: "3.9" python-version: "3.9"
- os: "macos-latest" - os: "macos-13"
python-version: "3.8" python-version: "3.8"
exclude: exclude:
- os: "macos-14" - os: "macos-14"
@ -150,11 +150,12 @@ jobs:
.ci/after_success.sh .ci/after_success.sh
- name: Upload coverage - name: Upload coverage
uses: codecov/codecov-action@v3.1.5 uses: codecov/codecov-action@v4
with: with:
flags: ${{ matrix.os == 'ubuntu-latest' && 'GHA_Ubuntu' || 'GHA_macOS' }} 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,9 +16,9 @@ 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.4.0 HARFBUZZ_VERSION=8.5.0
LIBPNG_VERSION=1.6.43 LIBPNG_VERSION=1.6.43
JPEGTURBO_VERSION=3.0.2 JPEGTURBO_VERSION=3.0.3
OPENJPEG_VERSION=2.5.2 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
@ -33,9 +33,9 @@ if [[ -n "$IS_MACOS" ]] || [[ "$MB_ML_VER" != 2014 ]]; then
else else
ZLIB_VERSION=1.2.8 ZLIB_VERSION=1.2.8
fi fi
LIBWEBP_VERSION=1.3.2 LIBWEBP_VERSION=1.4.0
BZIP2_VERSION=1.0.8 BZIP2_VERSION=1.0.8
LIBXCB_VERSION=1.16.1 LIBXCB_VERSION=1.17.0
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
@ -70,7 +70,7 @@ function build {
fi fi
build_new_zlib build_new_zlib
build_simple xcb-proto 1.16.0 https://xorg.freedesktop.org/archive/individual/proto build_simple xcb-proto 1.17.0 https://xorg.freedesktop.org/archive/individual/proto
if [ -n "$IS_MACOS" ]; then if [ -n "$IS_MACOS" ]; then
build_simple xorgproto 2024.1 https://www.x.org/pub/individual/proto build_simple xorgproto 2024.1 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

View File

@ -12,7 +12,7 @@ elif [ "${AUDITWHEEL_POLICY::9}" == "musllinux" ]; then
else else
yum install -y fribidi yum install -y fribidi
fi fi
if [ "${AUDITWHEEL_POLICY::9}" != "musllinux" ]; then if [ "${AUDITWHEEL_POLICY::9}" != "musllinux" ] && !([[ "$OSTYPE" == "darwin"* ]] && [[ $(python3 --version) == *"3.13."* ]]); then
python3 -m pip install numpy python3 -m pip install numpy
fi fi

View File

@ -46,6 +46,7 @@ jobs:
- cp310 - cp310
- cp311 - cp311
- cp312 - cp312
- cp313
spec: spec:
- manylinux2014 - manylinux2014
- manylinux_2_28 - manylinux_2_28
@ -80,6 +81,7 @@ jobs:
CIBW_ARCHS: "aarch64" CIBW_ARCHS: "aarch64"
# Likewise, select only one Python version per job to speed this up. # Likewise, select only one Python version per job to speed this up.
CIBW_BUILD: "${{ matrix.python-version }}-${{ matrix.spec == 'musllinux' && 'musllinux' || 'manylinux' }}*" CIBW_BUILD: "${{ matrix.python-version }}-${{ matrix.spec == 'musllinux' && 'musllinux' || 'manylinux' }}*"
CIBW_PRERELEASE_PYTHONS: True
# Extra options for manylinux. # Extra options for manylinux.
CIBW_MANYLINUX_AARCH64_IMAGE: ${{ matrix.spec }} CIBW_MANYLINUX_AARCH64_IMAGE: ${{ matrix.spec }}
CIBW_MANYLINUX_PYPY_AARCH64_IMAGE: ${{ matrix.spec }} CIBW_MANYLINUX_PYPY_AARCH64_IMAGE: ${{ matrix.spec }}
@ -97,7 +99,7 @@ jobs:
matrix: matrix:
include: include:
- name: "macOS x86_64" - name: "macOS x86_64"
os: macos-latest os: macos-13
cibw_arch: x86_64 cibw_arch: x86_64
macosx_deployment_target: "10.10" macosx_deployment_target: "10.10"
- name: "macOS arm64" - name: "macOS arm64"
@ -133,6 +135,7 @@ jobs:
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_PRERELEASE_PYTHONS: True
CIBW_SKIP: pp38-* CIBW_SKIP: pp38-*
CIBW_TEST_SKIP: cp38-macosx_arm64 CIBW_TEST_SKIP: cp38-macosx_arm64
MACOSX_DEPLOYMENT_TARGET: ${{ matrix.macosx_deployment_target }} MACOSX_DEPLOYMENT_TARGET: ${{ matrix.macosx_deployment_target }}
@ -204,6 +207,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_PRERELEASE_PYTHONS: True
CIBW_SKIP: pp38-* 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

View File

@ -1,12 +1,12 @@
repos: repos:
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.3.4 rev: v0.4.7
hooks: hooks:
- id: ruff - id: ruff
args: [--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: 24.3.0 rev: 24.4.2
hooks: hooks:
- id: black - id: black
@ -23,13 +23,20 @@ repos:
- 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.5
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-shebang-scripts-are-executable
@ -43,7 +50,7 @@ repos:
exclude: ^.github/.*TEMPLATE|^Tests/(fonts|images)/ exclude: ^.github/.*TEMPLATE|^Tests/(fonts|images)/
- repo: https://github.com/python-jsonschema/check-jsonschema - repo: https://github.com/python-jsonschema/check-jsonschema
rev: 0.28.1 rev: 0.28.4
hooks: hooks:
- id: check-github-workflows - id: check-github-workflows
- id: check-readthedocs - id: check-readthedocs
@ -55,12 +62,12 @@ repos:
- id: sphinx-lint - id: sphinx-lint
- repo: https://github.com/tox-dev/pyproject-fmt - repo: https://github.com/tox-dev/pyproject-fmt
rev: 1.7.0 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.16 rev: v0.18
hooks: hooks:
- id: validate-pyproject - id: validate-pyproject

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

@ -5,6 +5,33 @@ Changelog (Pillow)
10.4.0 (unreleased) 10.4.0 (unreleased)
------------------- -------------------
- Added ImageDraw circle() #8085
[void4, hugovk, radarhere]
- Add mypy target to Makefile #8077
[Yay295]
- Added more modes to Image.MODES #7984
[radarhere]
- 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 - Add support for reading BITMAPV2INFOHEADER and BITMAPV3INFOHEADER #7956
[Cirras, radarhere] [Cirras, radarhere]

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
@ -119,3 +118,8 @@ lint-fix:
python3 -m black . python3 -m black .
python3 -c "import ruff" > /dev/null 2>&1 || python3 -m pip install ruff python3 -c "import ruff" > /dev/null 2>&1 || python3 -m pip install ruff
python3 -m ruff --fix . python3 -m ruff --fix .
.PHONY: mypy
mypy:
python3 -c "import tox" > /dev/null 2>&1 || python3 -m pip install tox
python3 -m tox -e mypy

View File

@ -174,12 +174,13 @@ def skip_unless_feature(feature: str) -> pytest.MarkDecorator:
def skip_unless_feature_version( def skip_unless_feature_version(
feature: str, required: str, reason: str | None = None feature: str, required: str, reason: str | None = None
) -> pytest.MarkDecorator: ) -> pytest.MarkDecorator:
if not features.check(feature): version = features.version(feature)
if version is None:
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 {required}" reason = f"{feature} is older than {required}"
version_required = parse_version(required) version_required = parse_version(required)
version_available = parse_version(features.version(feature)) version_available = parse_version(version)
return pytest.mark.skipif(version_available < version_required, reason=reason) return pytest.mark.skipif(version_available < version_required, reason=reason)
@ -189,12 +190,13 @@ def mark_if_feature_version(
version_blacklist: str, version_blacklist: str,
reason: str | None = None, reason: str | None = None,
) -> pytest.MarkDecorator: ) -> pytest.MarkDecorator:
if not features.check(feature): version = features.version(feature)
if version is None:
return pytest.mark.pil_noop_mark() return pytest.mark.pil_noop_mark()
if reason is None: if reason is None:
reason = f"{feature} is {version_blacklist}" reason = f"{feature} is {version_blacklist}"
version_required = parse_version(version_blacklist) version_required = parse_version(version_blacklist)
version_available = parse_version(features.version(feature)) version_available = parse_version(version)
if ( if (
version_available.major == version_required.major version_available.major == version_required.major
and version_available.minor == version_required.minor and version_available.minor == version_required.minor
@ -220,16 +222,11 @@ class PillowLeakTestCase:
from resource import RUSAGE_SELF, getrusage from resource import RUSAGE_SELF, getrusage
mem = getrusage(RUSAGE_SELF).ru_maxrss mem = getrusage(RUSAGE_SELF).ru_maxrss
if sys.platform == "darwin": # man 2 getrusage:
# man 2 getrusage: # ru_maxrss
# ru_maxrss # This is the maximum resident set size utilized
# This is the maximum resident set size utilized (in bytes). # in bytes on macOS, in kilobytes on Linux
return mem / 1024 # Kb return mem / 1024 if sys.platform == "darwin" else mem
# linux
# man 2 getrusage
# ru_maxrss (since Linux 2.6.32)
# This is the maximum resident set size used (in kilobytes).
return mem # Kb
def _test_leak(self, core: Callable[[], None]) -> None: def _test_leak(self, core: Callable[[], None]) -> None:
start_mem = self._get_mem_usage() start_mem = self._get_mem_usage()
@ -273,7 +270,18 @@ def _cached_hopper(mode: str) -> Image.Image:
im = hopper("L") im = hopper("L")
else: else:
im = hopper() im = hopper()
return im.convert(mode) if mode.startswith("BGR;"):
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() -> bool: def djpeg_available() -> bool:

View File

@ -12,8 +12,9 @@ from Tests.helper import skip_unless_feature
if sys.platform.startswith("win32"): if sys.platform.startswith("win32"):
pytest.skip("Fuzzer is linux only", allow_module_level=True) pytest.skip("Fuzzer is linux only", allow_module_level=True)
if features.check("libjpeg_turbo"): libjpeg_turbo_version = features.version("libjpeg_turbo")
version = packaging.version.parse(features.version("libjpeg_turbo")) if libjpeg_turbo_version is not None:
version = packaging.version.parse(libjpeg_turbo_version)
if version.major == 2 and version.minor == 0: if version.major == 2 and version.minor == 0:
pytestmark = pytest.mark.valgrind_known_error( pytestmark = pytest.mark.valgrind_known_error(
reason="Known failing with libjpeg_turbo 2.0" reason="Known failing with libjpeg_turbo 2.0"

View File

@ -30,13 +30,15 @@ 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: str, function: Callable[[str], bool]) -> None: def test(name: str, function: Callable[[str], str | None]) -> 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:
@ -65,12 +67,16 @@ def test_webp_anim() -> None:
@skip_unless_feature("libjpeg_turbo") @skip_unless_feature("libjpeg_turbo")
def test_libjpeg_turbo_version() -> None: def test_libjpeg_turbo_version() -> None:
assert re.search(r"\d+\.\d+\.\d+$", features.version("libjpeg_turbo")) version = features.version("libjpeg_turbo")
assert version is not None
assert re.search(r"\d+\.\d+\.\d+$", version)
@skip_unless_feature("libimagequant") @skip_unless_feature("libimagequant")
def test_libimagequant_version() -> None: def test_libimagequant_version() -> None:
assert re.search(r"\d+\.\d+\.\d+$", features.version("libimagequant")) version = features.version("libimagequant")
assert version is not None
assert re.search(r"\d+\.\d+\.\d+$", version)
@pytest.mark.parametrize("feature", features.modules) @pytest.mark.parametrize("feature", features.modules)

View File

@ -336,9 +336,7 @@ def test_readline_psfile(tmp_path: Path) -> None:
strings = ["something", "else", "baz", "bif"] strings = ["something", "else", "baz", "bif"]
def _test_readline(t: EpsImagePlugin.PSFile, ending: str) -> None: def _test_readline(t: EpsImagePlugin.PSFile, ending: str) -> None:
ending = "Failure with line ending: %s" % ( ending = f"Failure with line ending: {''.join(str(ord(s)) for s in ending)}"
"".join("%s" % ord(s) for s in ending)
)
assert t.readline().strip("\r\n") == "something", ending assert t.readline().strip("\r\n") == "something", ending
assert t.readline().strip("\r\n") == "else", ending assert t.readline().strip("\r\n") == "else", ending
assert t.readline().strip("\r\n") == "baz", ending assert t.readline().strip("\r\n") == "baz", ending

View File

@ -1252,10 +1252,11 @@ def test_palette_save_L(tmp_path: Path) -> None:
im = hopper("P") im = hopper("P")
im_l = Image.frombytes("L", im.size, im.tobytes()) im_l = Image.frombytes("L", im.size, im.tobytes())
palette = bytes(im.getpalette()) palette = im.getpalette()
assert palette is not None
out = str(tmp_path / "temp.gif") out = str(tmp_path / "temp.gif")
im_l.save(out, palette=palette) im_l.save(out, palette=bytes(palette))
with Image.open(out) as reloaded: with Image.open(out) as reloaded:
assert_image_equal(reloaded.convert("RGB"), im.convert("RGB")) assert_image_equal(reloaded.convert("RGB"), im.convert("RGB"))

View File

@ -70,7 +70,9 @@ class TestFileJpeg:
def test_sanity(self) -> None: def test_sanity(self) -> None:
# internal version number # internal version number
assert re.search(r"\d+\.\d+$", features.version_codec("jpg")) version = features.version_codec("jpg")
assert version is not None
assert re.search(r"\d+\.\d+$", version)
with Image.open(TEST_FILE) as im: with Image.open(TEST_FILE) as im:
im.load() im.load()
@ -152,7 +154,7 @@ class TestFileJpeg:
assert k > 0.9 assert k > 0.9
def test_rgb(self) -> None: def test_rgb(self) -> None:
def getchannels(im: Image.Image) -> tuple[int, int, int]: def getchannels(im: JpegImagePlugin.JpegImageFile) -> tuple[int, int, int]:
return tuple(v[0] for v in im.layer) return tuple(v[0] for v in im.layer)
im = hopper() im = hopper()
@ -441,7 +443,7 @@ class TestFileJpeg:
assert_image(im1, im2.mode, im2.size) assert_image(im1, im2.mode, im2.size)
def test_subsampling(self) -> None: def test_subsampling(self) -> None:
def getsampling(im: Image.Image): def getsampling(im: JpegImagePlugin.JpegImageFile):
layer = im.layer layer = im.layer
return layer[0][1:3] + layer[1][1:3] + layer[2][1:3] return layer[0][1:3] + layer[1][1:3] + layer[2][1:3]

View File

@ -48,7 +48,9 @@ def roundtrip(im: Image.Image, **options: Any) -> Image.Image:
def test_sanity() -> None: def test_sanity() -> None:
# Internal version number # Internal version number
assert re.search(r"\d+\.\d+\.\d+$", features.version_codec("jpg_2000")) version = features.version_codec("jpg_2000")
assert version is not None
assert re.search(r"\d+\.\d+\.\d+$", version)
with Image.open("Tests/images/test-card-lossless.jp2") as im: with Image.open("Tests/images/test-card-lossless.jp2") as im:
px = im.load() px = im.load()

View File

@ -52,7 +52,9 @@ class LibTiffTestCase:
class TestFileLibTiff(LibTiffTestCase): class TestFileLibTiff(LibTiffTestCase):
def test_version(self) -> None: def test_version(self) -> None:
assert re.search(r"\d+\.\d+\.\d+$", features.version_codec("libtiff")) version = features.version_codec("libtiff")
assert version is not None
assert re.search(r"\d+\.\d+\.\d+$", version)
def test_g4_tiff(self, tmp_path: Path) -> None: def test_g4_tiff(self, tmp_path: Path) -> None:
"""Test the ordinary file path load path""" """Test the ordinary file path load path"""
@ -185,7 +187,9 @@ class TestFileLibTiff(LibTiffTestCase):
assert field in reloaded, f"{field} not in metadata" assert field in reloaded, f"{field} not in metadata"
@pytest.mark.valgrind_known_error(reason="Known invalid metadata") @pytest.mark.valgrind_known_error(reason="Known invalid metadata")
def test_additional_metadata(self, tmp_path: Path) -> None: def test_additional_metadata(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
# these should not crash. Seriously dummy data, most of it doesn't make # these should not crash. Seriously dummy data, most of it doesn't make
# any sense, so we're running up against limits where we're asking # any sense, so we're running up against limits where we're asking
# libtiff to do stupid things. # libtiff to do stupid things.
@ -236,13 +240,28 @@ class TestFileLibTiff(LibTiffTestCase):
del new_ifd[338] del new_ifd[338]
out = str(tmp_path / "temp.tif") out = str(tmp_path / "temp.tif")
TiffImagePlugin.WRITE_LIBTIFF = True monkeypatch.setattr(TiffImagePlugin, "WRITE_LIBTIFF", True)
im.save(out, tiffinfo=new_ifd) im.save(out, tiffinfo=new_ifd)
TiffImagePlugin.WRITE_LIBTIFF = False @pytest.mark.parametrize(
"libtiff",
(
pytest.param(
True,
marks=pytest.mark.skipif(
not getattr(Image.core, "libtiff_support_custom_tags", False),
reason="Custom tags not supported by older libtiff",
),
),
False,
),
)
def test_custom_metadata(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, libtiff: bool
) -> None:
monkeypatch.setattr(TiffImagePlugin, "WRITE_LIBTIFF", libtiff)
def test_custom_metadata(self, tmp_path: Path) -> None:
class Tc(NamedTuple): class Tc(NamedTuple):
value: Any value: Any
type: int type: int
@ -281,53 +300,43 @@ class TestFileLibTiff(LibTiffTestCase):
) )
} }
libtiffs = [False] def check_tags(
if Image.core.libtiff_support_custom_tags: tiffinfo: TiffImagePlugin.ImageFileDirectory_v2 | dict[int, str]
libtiffs.append(True) ) -> None:
im = hopper()
for libtiff in libtiffs: out = str(tmp_path / "temp.tif")
TiffImagePlugin.WRITE_LIBTIFF = libtiff im.save(out, tiffinfo=tiffinfo)
def check_tags( with Image.open(out) as reloaded:
tiffinfo: TiffImagePlugin.ImageFileDirectory_v2 | dict[int, str] for tag, value in tiffinfo.items():
) -> None: reloaded_value = reloaded.tag_v2[tag]
im = hopper() if (
isinstance(reloaded_value, TiffImagePlugin.IFDRational)
and libtiff
):
# libtiff does not support real RATIONALS
assert round(abs(float(reloaded_value) - float(value)), 7) == 0
continue
out = str(tmp_path / "temp.tif") assert reloaded_value == value
im.save(out, tiffinfo=tiffinfo)
with Image.open(out) as reloaded: # Test with types
for tag, value in tiffinfo.items(): ifd = TiffImagePlugin.ImageFileDirectory_v2()
reloaded_value = reloaded.tag_v2[tag] for tag, tagdata in custom.items():
if ( ifd[tag] = tagdata.value
isinstance(reloaded_value, TiffImagePlugin.IFDRational) ifd.tagtype[tag] = tagdata.type
and libtiff check_tags(ifd)
):
# libtiff does not support real RATIONALS
assert (
round(abs(float(reloaded_value) - float(value)), 7) == 0
)
continue
assert reloaded_value == value # Test without types. This only works for some types, int for example are
# always encoded as LONG and not SIGNED_LONG.
# Test with types check_tags(
ifd = TiffImagePlugin.ImageFileDirectory_v2() {
for tag, tagdata in custom.items(): tag: tagdata.value
ifd[tag] = tagdata.value for tag, tagdata in custom.items()
ifd.tagtype[tag] = tagdata.type if tagdata.supported_by_default
check_tags(ifd) }
)
# Test without types. This only works for some types, int for example are
# always encoded as LONG and not SIGNED_LONG.
check_tags(
{
tag: tagdata.value
for tag, tagdata in custom.items()
if tagdata.supported_by_default
}
)
TiffImagePlugin.WRITE_LIBTIFF = False
def test_osubfiletype(self, tmp_path: Path) -> None: def test_osubfiletype(self, tmp_path: Path) -> None:
outfile = str(tmp_path / "temp.tif") outfile = str(tmp_path / "temp.tif")
@ -343,24 +352,24 @@ class TestFileLibTiff(LibTiffTestCase):
# Should not segfault # Should not segfault
im.save(outfile) im.save(outfile)
def test_xmlpacket_tag(self, tmp_path: Path) -> None: def test_xmlpacket_tag(
TiffImagePlugin.WRITE_LIBTIFF = True self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.setattr(TiffImagePlugin, "WRITE_LIBTIFF", True)
out = str(tmp_path / "temp.tif") out = str(tmp_path / "temp.tif")
hopper().save(out, tiffinfo={700: b"xmlpacket tag"}) hopper().save(out, tiffinfo={700: b"xmlpacket tag"})
TiffImagePlugin.WRITE_LIBTIFF = False
with Image.open(out) as reloaded: with Image.open(out) as reloaded:
if 700 in reloaded.tag_v2: if 700 in reloaded.tag_v2:
assert reloaded.tag_v2[700] == b"xmlpacket tag" assert reloaded.tag_v2[700] == b"xmlpacket tag"
def test_int_dpi(self, tmp_path: Path) -> None: def test_int_dpi(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
# issue #1765 # issue #1765
im = hopper("RGB") im = hopper("RGB")
out = str(tmp_path / "temp.tif") out = str(tmp_path / "temp.tif")
TiffImagePlugin.WRITE_LIBTIFF = True monkeypatch.setattr(TiffImagePlugin, "WRITE_LIBTIFF", True)
im.save(out, dpi=(72, 72)) im.save(out, dpi=(72, 72))
TiffImagePlugin.WRITE_LIBTIFF = False
with Image.open(out) as reloaded: with Image.open(out) as reloaded:
assert reloaded.info["dpi"] == (72.0, 72.0) assert reloaded.info["dpi"] == (72.0, 72.0)
@ -422,13 +431,13 @@ class TestFileLibTiff(LibTiffTestCase):
assert "temp.tif" == reread.tag_v2[269] assert "temp.tif" == reread.tag_v2[269]
assert "temp.tif" == reread.tag[269][0] assert "temp.tif" == reread.tag[269][0]
def test_12bit_rawmode(self) -> None: def test_12bit_rawmode(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Are we generating the same interpretation """Are we generating the same interpretation
of the image as Imagemagick is?""" of the image as Imagemagick is?"""
TiffImagePlugin.READ_LIBTIFF = True monkeypatch.setattr(TiffImagePlugin, "READ_LIBTIFF", True)
with Image.open("Tests/images/12bit.cropped.tif") as im: with Image.open("Tests/images/12bit.cropped.tif") as im:
im.load() im.load()
TiffImagePlugin.READ_LIBTIFF = False monkeypatch.setattr(TiffImagePlugin, "READ_LIBTIFF", False)
# to make the target -- # to make the target --
# convert 12bit.cropped.tif -depth 16 tmp.tif # convert 12bit.cropped.tif -depth 16 tmp.tif
# convert tmp.tif -evaluate RightShift 4 12in16bit2.tif # convert tmp.tif -evaluate RightShift 4 12in16bit2.tif
@ -514,12 +523,13 @@ class TestFileLibTiff(LibTiffTestCase):
assert_image_equal_tofile(im, out) assert_image_equal_tofile(im, out)
@pytest.mark.parametrize("im", (hopper("P"), Image.new("P", (1, 1), "#000"))) @pytest.mark.parametrize("im", (hopper("P"), Image.new("P", (1, 1), "#000")))
def test_palette_save(self, im: Image.Image, tmp_path: Path) -> None: def test_palette_save(
self, im: Image.Image, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
out = str(tmp_path / "temp.tif") out = str(tmp_path / "temp.tif")
TiffImagePlugin.WRITE_LIBTIFF = True monkeypatch.setattr(TiffImagePlugin, "WRITE_LIBTIFF", True)
im.save(out) im.save(out)
TiffImagePlugin.WRITE_LIBTIFF = False
with Image.open(out) as reloaded: with Image.open(out) as reloaded:
# colormap/palette tag # colormap/palette tag
@ -548,9 +558,9 @@ class TestFileLibTiff(LibTiffTestCase):
with pytest.raises(OSError): with pytest.raises(OSError):
os.close(fn) os.close(fn)
def test_multipage(self) -> None: def test_multipage(self, monkeypatch: pytest.MonkeyPatch) -> None:
# issue #862 # issue #862
TiffImagePlugin.READ_LIBTIFF = True monkeypatch.setattr(TiffImagePlugin, "READ_LIBTIFF", True)
with Image.open("Tests/images/multipage.tiff") as im: with Image.open("Tests/images/multipage.tiff") as im:
# file is a multipage tiff, 10x10 green, 10x10 red, 20x20 blue # file is a multipage tiff, 10x10 green, 10x10 red, 20x20 blue
@ -569,11 +579,9 @@ class TestFileLibTiff(LibTiffTestCase):
assert im.size == (20, 20) assert im.size == (20, 20)
assert im.convert("RGB").getpixel((0, 0)) == (0, 0, 255) assert im.convert("RGB").getpixel((0, 0)) == (0, 0, 255)
TiffImagePlugin.READ_LIBTIFF = False def test_multipage_nframes(self, monkeypatch: pytest.MonkeyPatch) -> None:
def test_multipage_nframes(self) -> None:
# issue #862 # issue #862
TiffImagePlugin.READ_LIBTIFF = True monkeypatch.setattr(TiffImagePlugin, "READ_LIBTIFF", True)
with Image.open("Tests/images/multipage.tiff") as im: with Image.open("Tests/images/multipage.tiff") as im:
frames = im.n_frames frames = im.n_frames
assert frames == 3 assert frames == 3
@ -582,10 +590,8 @@ class TestFileLibTiff(LibTiffTestCase):
# Should not raise ValueError: I/O operation on closed file # Should not raise ValueError: I/O operation on closed file
im.load() im.load()
TiffImagePlugin.READ_LIBTIFF = False def test_multipage_seek_backwards(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(TiffImagePlugin, "READ_LIBTIFF", True)
def test_multipage_seek_backwards(self) -> None:
TiffImagePlugin.READ_LIBTIFF = True
with Image.open("Tests/images/multipage.tiff") as im: with Image.open("Tests/images/multipage.tiff") as im:
im.seek(1) im.seek(1)
im.load() im.load()
@ -593,24 +599,21 @@ class TestFileLibTiff(LibTiffTestCase):
im.seek(0) im.seek(0)
assert im.convert("RGB").getpixel((0, 0)) == (0, 128, 0) assert im.convert("RGB").getpixel((0, 0)) == (0, 128, 0)
TiffImagePlugin.READ_LIBTIFF = False def test__next(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(TiffImagePlugin, "READ_LIBTIFF", True)
def test__next(self) -> None:
TiffImagePlugin.READ_LIBTIFF = True
with Image.open("Tests/images/hopper.tif") as im: with Image.open("Tests/images/hopper.tif") as im:
assert not im.tag.next assert not im.tag.next
im.load() im.load()
assert not im.tag.next assert not im.tag.next
def test_4bit(self) -> None: def test_4bit(self, monkeypatch: pytest.MonkeyPatch) -> None:
# Arrange # Arrange
test_file = "Tests/images/hopper_gray_4bpp.tif" test_file = "Tests/images/hopper_gray_4bpp.tif"
original = hopper("L") original = hopper("L")
# Act # Act
TiffImagePlugin.READ_LIBTIFF = True monkeypatch.setattr(TiffImagePlugin, "READ_LIBTIFF", True)
with Image.open(test_file) as im: with Image.open(test_file) as im:
TiffImagePlugin.READ_LIBTIFF = False
# Assert # Assert
assert im.size == (128, 128) assert im.size == (128, 128)
@ -650,12 +653,12 @@ class TestFileLibTiff(LibTiffTestCase):
assert im2.mode == "L" assert im2.mode == "L"
assert_image_equal(im, im2) assert_image_equal(im, im2)
def test_save_bytesio(self) -> None: def test_save_bytesio(self, monkeypatch: pytest.MonkeyPatch) -> None:
# PR 1011 # PR 1011
# Test TIFF saving to io.BytesIO() object. # Test TIFF saving to io.BytesIO() object.
TiffImagePlugin.WRITE_LIBTIFF = True monkeypatch.setattr(TiffImagePlugin, "WRITE_LIBTIFF", True)
TiffImagePlugin.READ_LIBTIFF = True monkeypatch.setattr(TiffImagePlugin, "READ_LIBTIFF", True)
# Generate test image # Generate test image
pilim = hopper() pilim = hopper()
@ -665,16 +668,14 @@ class TestFileLibTiff(LibTiffTestCase):
pilim.save(buffer_io, format="tiff", compression=compression) pilim.save(buffer_io, format="tiff", compression=compression)
buffer_io.seek(0) buffer_io.seek(0)
assert_image_similar_tofile(pilim, buffer_io, 0) with Image.open(buffer_io) as saved_im:
assert_image_similar(pilim, saved_im, 0)
save_bytesio() save_bytesio()
save_bytesio("raw") save_bytesio("raw")
save_bytesio("packbits") save_bytesio("packbits")
save_bytesio("tiff_lzw") save_bytesio("tiff_lzw")
TiffImagePlugin.WRITE_LIBTIFF = False
TiffImagePlugin.READ_LIBTIFF = False
def test_save_ycbcr(self, tmp_path: Path) -> None: def test_save_ycbcr(self, tmp_path: Path) -> None:
im = hopper("YCbCr") im = hopper("YCbCr")
outfile = str(tmp_path / "temp.tif") outfile = str(tmp_path / "temp.tif")
@ -694,15 +695,16 @@ class TestFileLibTiff(LibTiffTestCase):
if Image.core.libtiff_support_custom_tags: if Image.core.libtiff_support_custom_tags:
assert reloaded.tag_v2[34665] == 125456 assert reloaded.tag_v2[34665] == 125456
def test_crashing_metadata(self, tmp_path: Path) -> None: def test_crashing_metadata(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
# issue 1597 # issue 1597
with Image.open("Tests/images/rdf.tif") as im: with Image.open("Tests/images/rdf.tif") as im:
out = str(tmp_path / "temp.tif") out = str(tmp_path / "temp.tif")
TiffImagePlugin.WRITE_LIBTIFF = True monkeypatch.setattr(TiffImagePlugin, "WRITE_LIBTIFF", True)
# this shouldn't crash # this shouldn't crash
im.save(out, format="TIFF") im.save(out, format="TIFF")
TiffImagePlugin.WRITE_LIBTIFF = False
def test_page_number_x_0(self, tmp_path: Path) -> None: def test_page_number_x_0(self, tmp_path: Path) -> None:
# Issue 973 # Issue 973
@ -733,36 +735,41 @@ class TestFileLibTiff(LibTiffTestCase):
# Should not raise PermissionError. # Should not raise PermissionError.
os.remove(tmpfile) os.remove(tmpfile)
def test_read_icc(self) -> None: def test_read_icc(self, monkeypatch: pytest.MonkeyPatch) -> None:
with Image.open("Tests/images/hopper.iccprofile.tif") as img: with Image.open("Tests/images/hopper.iccprofile.tif") as img:
icc = img.info.get("icc_profile") icc = img.info.get("icc_profile")
assert icc is not None assert icc is not None
TiffImagePlugin.READ_LIBTIFF = True monkeypatch.setattr(TiffImagePlugin, "READ_LIBTIFF", True)
with Image.open("Tests/images/hopper.iccprofile.tif") as img: with Image.open("Tests/images/hopper.iccprofile.tif") as img:
icc_libtiff = img.info.get("icc_profile") icc_libtiff = img.info.get("icc_profile")
assert icc_libtiff is not None assert icc_libtiff is not None
TiffImagePlugin.READ_LIBTIFF = False
assert icc == icc_libtiff assert icc == icc_libtiff
def test_write_icc(self, tmp_path: Path) -> None: @pytest.mark.parametrize(
def check_write(libtiff: bool) -> None: "libtiff",
TiffImagePlugin.WRITE_LIBTIFF = libtiff (
pytest.param(
True,
marks=pytest.mark.skipif(
not getattr(Image.core, "libtiff_support_custom_tags", False),
reason="Custom tags not supported by older libtiff",
),
),
False,
),
)
def test_write_icc(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, libtiff: bool
) -> None:
monkeypatch.setattr(TiffImagePlugin, "WRITE_LIBTIFF", libtiff)
with Image.open("Tests/images/hopper.iccprofile.tif") as img: with Image.open("Tests/images/hopper.iccprofile.tif") as img:
icc_profile = img.info["icc_profile"] icc_profile = img.info["icc_profile"]
out = str(tmp_path / "temp.tif") out = str(tmp_path / "temp.tif")
img.save(out, icc_profile=icc_profile) img.save(out, icc_profile=icc_profile)
with Image.open(out) as reloaded: with Image.open(out) as reloaded:
assert icc_profile == reloaded.info["icc_profile"] assert icc_profile == reloaded.info["icc_profile"]
libtiffs = []
if Image.core.libtiff_support_custom_tags:
libtiffs.append(True)
libtiffs.append(False)
for libtiff in libtiffs:
check_write(libtiff)
def test_multipage_compression(self) -> None: def test_multipage_compression(self) -> None:
with Image.open("Tests/images/compression.tif") as im: with Image.open("Tests/images/compression.tif") as im:
@ -840,12 +847,13 @@ class TestFileLibTiff(LibTiffTestCase):
assert_image_equal_tofile(im, "Tests/images/copyleft.png", mode="RGB") assert_image_equal_tofile(im, "Tests/images/copyleft.png", mode="RGB")
def test_sampleformat_write(self, tmp_path: Path) -> None: def test_sampleformat_write(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
im = Image.new("F", (1, 1)) im = Image.new("F", (1, 1))
out = str(tmp_path / "temp.tif") out = str(tmp_path / "temp.tif")
TiffImagePlugin.WRITE_LIBTIFF = True monkeypatch.setattr(TiffImagePlugin, "WRITE_LIBTIFF", True)
im.save(out) im.save(out)
TiffImagePlugin.WRITE_LIBTIFF = False
with Image.open(out) as reloaded: with Image.open(out) as reloaded:
assert reloaded.mode == "F" assert reloaded.mode == "F"
@ -1091,15 +1099,14 @@ class TestFileLibTiff(LibTiffTestCase):
with Image.open(out) as im: with Image.open(out) as im:
im.load() im.load()
def test_realloc_overflow(self) -> None: def test_realloc_overflow(self, monkeypatch: pytest.MonkeyPatch) -> None:
TiffImagePlugin.READ_LIBTIFF = True monkeypatch.setattr(TiffImagePlugin, "READ_LIBTIFF", True)
with Image.open("Tests/images/tiff_overflow_rows_per_strip.tif") as im: with Image.open("Tests/images/tiff_overflow_rows_per_strip.tif") as im:
with pytest.raises(OSError) as e: with pytest.raises(OSError) as e:
im.load() im.load()
# Assert that the error code is IMAGING_CODEC_MEMORY # Assert that the error code is IMAGING_CODEC_MEMORY
assert str(e.value) == "-9" assert str(e.value) == "-9"
TiffImagePlugin.READ_LIBTIFF = False
@pytest.mark.parametrize("compression", ("tiff_adobe_deflate", "jpeg")) @pytest.mark.parametrize("compression", ("tiff_adobe_deflate", "jpeg"))
def test_save_multistrip(self, compression: str, tmp_path: Path) -> None: def test_save_multistrip(self, compression: str, tmp_path: Path) -> None:

39
Tests/test_file_mpeg.py Normal file
View File

@ -0,0 +1,39 @@
from __future__ import annotations
from io import BytesIO
import pytest
from PIL import Image, MpegImagePlugin
def test_identify() -> None:
# Arrange
b = BytesIO(b"\x00\x00\x01\xb3\x01\x00\x01")
# Act
with Image.open(b) as im:
# Assert
assert im.format == "MPEG"
assert im.mode == "RGB"
assert im.size == (16, 1)
def test_invalid_file() -> None:
# Arrange
invalid_file = "Tests/images/flower.jpg"
# Act / Assert
with pytest.raises(SyntaxError):
MpegImagePlugin.MpegImageFile(invalid_file)
def test_load() -> None:
# Arrange
b = BytesIO(b"\x00\x00\x01\xb3\x01\x00\x01")
with Image.open(b) as im:
# Act / Assert: cannot load
with pytest.raises(OSError):
im.load()

View File

@ -85,7 +85,9 @@ class TestFilePng:
def test_sanity(self, tmp_path: Path) -> None: def test_sanity(self, tmp_path: Path) -> None:
# internal version number # internal version number
assert re.search(r"\d+(\.\d+){1,3}$", features.version_codec("zlib")) version = features.version_codec("zlib")
assert version is not None
assert re.search(r"\d+(\.\d+){1,3}(\.zlib\-ng)?$", version)
test_file = str(tmp_path / "temp.png") test_file = str(tmp_path / "temp.png")

View File

@ -49,7 +49,9 @@ class TestFileWebp:
def test_version(self) -> None: def test_version(self) -> None:
_webp.WebPDecoderVersion() _webp.WebPDecoderVersion()
_webp.WebPDecoderBuggyAlpha() _webp.WebPDecoderBuggyAlpha()
assert re.search(r"\d+\.\d+\.\d+$", features.version_module("webp")) version = features.version_module("webp")
assert version is not None
assert re.search(r"\d+\.\d+\.\d+$", version)
def test_read_rgb(self) -> None: def test_read_rgb(self) -> None:
""" """

View File

@ -52,8 +52,9 @@ def test_write_animation_L(tmp_path: Path) -> None:
assert_image_similar(im, orig.convert("RGBA"), 32.9) assert_image_similar(im, orig.convert("RGBA"), 32.9)
if is_big_endian(): if is_big_endian():
webp = parse_version(features.version_module("webp")) version = features.version_module("webp")
if webp < parse_version("1.2.2"): assert version is not None
if parse_version(version) < parse_version("1.2.2"):
pytest.skip("Fails with libwebp earlier than 1.2.2") pytest.skip("Fails with libwebp earlier than 1.2.2")
orig.seek(orig.n_frames - 1) orig.seek(orig.n_frames - 1)
im.seek(im.n_frames - 1) im.seek(im.n_frames - 1)
@ -78,8 +79,9 @@ def test_write_animation_RGB(tmp_path: Path) -> None:
# Compare second frame to original # Compare second frame to original
if is_big_endian(): if is_big_endian():
webp = parse_version(features.version_module("webp")) version = features.version_module("webp")
if webp < parse_version("1.2.2"): assert version is not None
if parse_version(version) < parse_version("1.2.2"):
pytest.skip("Fails with libwebp earlier than 1.2.2") pytest.skip("Fails with libwebp earlier than 1.2.2")
im.seek(1) im.seek(1)
im.load() im.load()

View File

@ -12,7 +12,7 @@ class TestTTypeFontLeak(PillowLeakTestCase):
iterations = 10 iterations = 10
mem_limit = 4096 # k mem_limit = 4096 # k
def _test_font(self, font: ImageFont.FreeTypeFont) -> None: def _test_font(self, font: ImageFont.FreeTypeFont | ImageFont.ImageFont) -> None:
im = Image.new("RGB", (255, 255), "white") im = Image.new("RGB", (255, 255), "white")
draw = ImageDraw.ImageDraw(im) draw = ImageDraw.ImageDraw(im)
self._test_leak( self._test_leak(

View File

@ -25,48 +25,30 @@ from PIL import (
from .helper import ( from .helper import (
assert_image_equal, assert_image_equal,
assert_image_equal_tofile, assert_image_equal_tofile,
assert_image_similar,
assert_image_similar_tofile, assert_image_similar_tofile,
assert_not_all_same, assert_not_all_same,
hopper, hopper,
is_big_endian,
is_win32, is_win32,
mark_if_feature_version, mark_if_feature_version,
skip_unless_feature, skip_unless_feature,
) )
# name, pixel size
image_modes = (
("1", 1),
("L", 1),
("LA", 4),
("La", 4),
("P", 1),
("PA", 4),
("F", 4),
("I", 4),
("I;16", 2),
("I;16L", 2),
("I;16B", 2),
("I;16N", 2),
("RGB", 4),
("RGBA", 4),
("RGBa", 4),
("RGBX", 4),
("BGR;15", 2),
("BGR;16", 2),
("BGR;24", 3),
("CMYK", 4),
("YCbCr", 4),
("HSV", 4),
("LAB", 4),
)
image_mode_names = [name for name, _ in image_modes] # Deprecation helper
def helper_image_new(mode: str, size: tuple[int, int]) -> Image.Image:
if mode.startswith("BGR;"):
with pytest.warns(DeprecationWarning):
return Image.new(mode, size)
else:
return Image.new(mode, size)
class TestImage: class TestImage:
@pytest.mark.parametrize("mode", image_mode_names) @pytest.mark.parametrize("mode", Image.MODES + ["BGR;15", "BGR;16", "BGR;24"])
def test_image_modes_success(self, mode: str) -> None: def test_image_modes_success(self, mode: str) -> None:
Image.new(mode, (1, 1)) helper_image_new(mode, (1, 1))
@pytest.mark.parametrize("mode", ("", "bad", "very very long")) @pytest.mark.parametrize("mode", ("", "bad", "very very long"))
def test_image_modes_fail(self, mode: str) -> None: def test_image_modes_fail(self, mode: str) -> None:
@ -118,10 +100,18 @@ class TestImage:
JPGFILE = "Tests/images/hopper.jpg" JPGFILE = "Tests/images/hopper.jpg"
with pytest.raises(TypeError): with pytest.raises(TypeError):
with Image.open(PNGFILE, formats=123): with Image.open(PNGFILE, formats=123): # type: ignore[arg-type]
pass pass
for formats in [["JPEG"], ("JPEG",), ["jpeg"], ["Jpeg"], ["jPeG"], ["JpEg"]]: format_list: list[list[str] | tuple[str, ...]] = [
["JPEG"],
("JPEG",),
["jpeg"],
["Jpeg"],
["jPeG"],
["JpEg"],
]
for formats in format_list:
with pytest.raises(UnidentifiedImageError): with pytest.raises(UnidentifiedImageError):
with Image.open(PNGFILE, formats=formats): with Image.open(PNGFILE, formats=formats):
pass pass
@ -157,7 +147,7 @@ class TestImage:
def test_bad_mode(self) -> None: def test_bad_mode(self) -> None:
with pytest.raises(ValueError): with pytest.raises(ValueError):
with Image.open("filename", "bad mode"): with Image.open("filename", "bad mode"): # type: ignore[arg-type]
pass pass
def test_stringio(self) -> None: def test_stringio(self) -> None:
@ -204,7 +194,8 @@ class TestImage:
with tempfile.TemporaryFile() as fp: with tempfile.TemporaryFile() as fp:
im.save(fp, "JPEG") im.save(fp, "JPEG")
fp.seek(0) fp.seek(0)
assert_image_similar_tofile(im, fp, 20) with Image.open(fp) as reloaded:
assert_image_similar(im, reloaded, 20)
def test_unknown_extension(self, tmp_path: Path) -> None: def test_unknown_extension(self, tmp_path: Path) -> None:
im = hopper() im = hopper()
@ -516,9 +507,11 @@ class TestImage:
def test_check_size(self) -> None: def test_check_size(self) -> None:
# Checking that the _check_size function throws value errors when we want it to # Checking that the _check_size function throws value errors when we want it to
with pytest.raises(ValueError): with pytest.raises(ValueError):
Image.new("RGB", 0) # not a tuple # not a tuple
Image.new("RGB", 0) # type: ignore[arg-type]
with pytest.raises(ValueError): with pytest.raises(ValueError):
Image.new("RGB", (0,)) # Tuple too short # tuple too short
Image.new("RGB", (0,)) # type: ignore[arg-type]
with pytest.raises(ValueError): with pytest.raises(ValueError):
Image.new("RGB", (-1, -1)) # w,h < 0 Image.new("RGB", (-1, -1)) # w,h < 0
@ -1045,30 +1038,33 @@ class TestImage:
class TestImageBytes: class TestImageBytes:
@pytest.mark.parametrize("mode", image_mode_names) @pytest.mark.parametrize("mode", Image.MODES + ["BGR;15", "BGR;16", "BGR;24"])
def test_roundtrip_bytes_constructor(self, mode: str) -> None: def test_roundtrip_bytes_constructor(self, mode: str) -> None:
im = hopper(mode) im = hopper(mode)
source_bytes = im.tobytes() source_bytes = im.tobytes()
reloaded = Image.frombytes(mode, im.size, source_bytes) if mode.startswith("BGR;"):
with pytest.warns(DeprecationWarning):
reloaded = Image.frombytes(mode, im.size, source_bytes)
else:
reloaded = Image.frombytes(mode, im.size, source_bytes)
assert reloaded.tobytes() == source_bytes assert reloaded.tobytes() == source_bytes
@pytest.mark.parametrize("mode", image_mode_names) @pytest.mark.parametrize("mode", Image.MODES + ["BGR;15", "BGR;16", "BGR;24"])
def test_roundtrip_bytes_method(self, mode: str) -> None: def test_roundtrip_bytes_method(self, mode: str) -> None:
im = hopper(mode) im = hopper(mode)
source_bytes = im.tobytes() source_bytes = im.tobytes()
reloaded = Image.new(mode, im.size) reloaded = helper_image_new(mode, im.size)
reloaded.frombytes(source_bytes) reloaded.frombytes(source_bytes)
assert reloaded.tobytes() == source_bytes assert reloaded.tobytes() == source_bytes
@pytest.mark.parametrize(("mode", "pixelsize"), image_modes) @pytest.mark.parametrize("mode", Image.MODES + ["BGR;15", "BGR;16", "BGR;24"])
def test_getdata_putdata(self, mode: str, pixelsize: int) -> None: def test_getdata_putdata(self, mode: str) -> None:
im = Image.new(mode, (2, 2)) if is_big_endian() and mode == "BGR;15":
source_bytes = bytes(range(im.width * im.height * pixelsize)) pytest.xfail("Known failure of BGR;15 on big-endian")
im.frombytes(source_bytes) im = hopper(mode)
reloaded = helper_image_new(mode, im.size)
reloaded = Image.new(mode, im.size)
reloaded.putdata(im.getdata()) reloaded.putdata(im.getdata())
assert_image_equal(im, reloaded) assert_image_equal(im, reloaded)

View File

@ -33,7 +33,7 @@ except ImportError:
class AccessTest: class AccessTest:
# initial value # Initial value
_init_cffi_access = Image.USE_CFFI_ACCESS _init_cffi_access = Image.USE_CFFI_ACCESS
_need_cffi_access = False _need_cffi_access = False
@ -138,8 +138,8 @@ class TestImageGetPixel(AccessTest):
if bands == 1: if bands == 1:
return 1 return 1
if mode in ("BGR;15", "BGR;16"): if mode in ("BGR;15", "BGR;16"):
# These modes have less than 8 bits per band # These modes have less than 8 bits per band,
# So (1, 2, 3) cannot be roundtripped # so (1, 2, 3) cannot be roundtripped.
return (16, 32, 49) return (16, 32, 49)
return tuple(range(1, bands + 1)) return tuple(range(1, bands + 1))
@ -151,7 +151,7 @@ class TestImageGetPixel(AccessTest):
self.color(mode) if expected_color_int is None else expected_color_int self.color(mode) if expected_color_int is None else expected_color_int
) )
# check putpixel # Check putpixel
im = Image.new(mode, (1, 1), None) im = Image.new(mode, (1, 1), None)
im.putpixel((0, 0), expected_color) im.putpixel((0, 0), expected_color)
actual_color = im.getpixel((0, 0)) actual_color = im.getpixel((0, 0))
@ -160,7 +160,7 @@ class TestImageGetPixel(AccessTest):
f"expected {expected_color} got {actual_color}" f"expected {expected_color} got {actual_color}"
) )
# check putpixel negative index # Check putpixel negative index
im.putpixel((-1, -1), expected_color) im.putpixel((-1, -1), expected_color)
actual_color = im.getpixel((-1, -1)) actual_color = im.getpixel((-1, -1))
assert actual_color == expected_color, ( assert actual_color == expected_color, (
@ -168,22 +168,21 @@ class TestImageGetPixel(AccessTest):
f"expected {expected_color} got {actual_color}" f"expected {expected_color} got {actual_color}"
) )
# Check 0 # Check 0x0 image with None initial color
im = Image.new(mode, (0, 0), None) im = Image.new(mode, (0, 0), None)
assert im.load() is not None assert im.load() is not None
error = ValueError if self._need_cffi_access else IndexError error = ValueError if self._need_cffi_access else IndexError
with pytest.raises(error): with pytest.raises(error):
im.putpixel((0, 0), expected_color) im.putpixel((0, 0), expected_color)
with pytest.raises(error): with pytest.raises(error):
im.getpixel((0, 0)) im.getpixel((0, 0))
# Check 0 negative index # Check negative index
with pytest.raises(error): with pytest.raises(error):
im.putpixel((-1, -1), expected_color) im.putpixel((-1, -1), expected_color)
with pytest.raises(error): with pytest.raises(error):
im.getpixel((-1, -1)) im.getpixel((-1, -1))
# check initial color # Check initial color
im = Image.new(mode, (1, 1), expected_color) im = Image.new(mode, (1, 1), expected_color)
actual_color = im.getpixel((0, 0)) actual_color = im.getpixel((0, 0))
assert actual_color == expected_color, ( assert actual_color == expected_color, (
@ -191,46 +190,30 @@ class TestImageGetPixel(AccessTest):
f"expected {expected_color} got {actual_color}" f"expected {expected_color} got {actual_color}"
) )
# check initial color negative index # Check initial color negative index
actual_color = im.getpixel((-1, -1)) actual_color = im.getpixel((-1, -1))
assert actual_color == expected_color, ( assert actual_color == expected_color, (
f"initial color failed with negative index for mode {mode}, " f"initial color failed with negative index for mode {mode}, "
f"expected {expected_color} got {actual_color}" f"expected {expected_color} got {actual_color}"
) )
# Check 0 # Check 0x0 image with initial color
im = Image.new(mode, (0, 0), expected_color) im = Image.new(mode, (0, 0), expected_color)
with pytest.raises(error): with pytest.raises(error):
im.getpixel((0, 0)) im.getpixel((0, 0))
# Check 0 negative index # Check negative index
with pytest.raises(error): with pytest.raises(error):
im.getpixel((-1, -1)) im.getpixel((-1, -1))
@pytest.mark.parametrize( @pytest.mark.parametrize("mode", Image.MODES)
"mode",
(
"1",
"L",
"LA",
"I",
"I;16",
"I;16B",
"F",
"P",
"PA",
"BGR;15",
"BGR;16",
"BGR;24",
"RGB",
"RGBA",
"RGBX",
"CMYK",
"YCbCr",
),
)
def test_basic(self, mode: str) -> None: def test_basic(self, mode: str) -> None:
self.check(mode) self.check(mode)
@pytest.mark.parametrize("mode", ("BGR;15", "BGR;16", "BGR;24"))
def test_deprecated(self, mode: str) -> None:
with pytest.warns(DeprecationWarning):
self.check(mode)
def test_list(self) -> None: def test_list(self) -> None:
im = hopper() im = hopper()
assert im.getpixel([0, 0]) == (20, 20, 70) assert im.getpixel([0, 0]) == (20, 20, 70)
@ -238,7 +221,7 @@ class TestImageGetPixel(AccessTest):
@pytest.mark.parametrize("mode", ("I;16", "I;16B")) @pytest.mark.parametrize("mode", ("I;16", "I;16B"))
@pytest.mark.parametrize("expected_color", (2**15 - 1, 2**15, 2**15 + 1, 2**16 - 1)) @pytest.mark.parametrize("expected_color", (2**15 - 1, 2**15, 2**15 + 1, 2**16 - 1))
def test_signedness(self, mode: str, expected_color: int) -> None: def test_signedness(self, mode: str, expected_color: int) -> None:
# see https://github.com/python-pillow/Pillow/issues/452 # See https://github.com/python-pillow/Pillow/issues/452
# pixelaccess is using signed int* instead of uint* # pixelaccess is using signed int* instead of uint*
self.check(mode, expected_color) self.check(mode, expected_color)
@ -298,13 +281,6 @@ class TestCffi(AccessTest):
im = Image.new(mode, (10, 10), 40000) im = Image.new(mode, (10, 10), 40000)
self._test_get_access(im) self._test_get_access(im)
# These don't actually appear to be modes that I can actually make,
# as unpack sets them directly into the I mode.
# im = Image.new('I;32L', (10, 10), -2**10)
# self._test_get_access(im)
# im = Image.new('I;32B', (10, 10), 2**10)
# self._test_get_access(im)
def _test_set_access(self, im: Image.Image, color: tuple[int, ...] | float) -> None: def _test_set_access(self, im: Image.Image, color: tuple[int, ...] | float) -> None:
"""Are we writing the correct bits into the image? """Are we writing the correct bits into the image?
@ -336,23 +312,18 @@ class TestCffi(AccessTest):
self._test_set_access(hopper("LA"), (128, 128)) self._test_set_access(hopper("LA"), (128, 128))
self._test_set_access(hopper("1"), 255) self._test_set_access(hopper("1"), 255)
self._test_set_access(hopper("P"), 128) self._test_set_access(hopper("P"), 128)
# self._test_set_access(i, (128, 128)) #PA -- undone how to make self._test_set_access(hopper("PA"), (128, 128))
self._test_set_access(hopper("F"), 1024.0) self._test_set_access(hopper("F"), 1024.0)
for mode in ("I;16", "I;16L", "I;16B", "I;16N", "I"): for mode in ("I;16", "I;16L", "I;16B", "I;16N", "I"):
im = Image.new(mode, (10, 10), 40000) im = Image.new(mode, (10, 10), 40000)
self._test_set_access(im, 45000) self._test_set_access(im, 45000)
# im = Image.new('I;32L', (10, 10), -(2**10))
# self._test_set_access(im, -(2**13)+1)
# im = Image.new('I;32B', (10, 10), 2**10)
# self._test_set_access(im, 2**13-1)
@pytest.mark.filterwarnings("ignore::DeprecationWarning") @pytest.mark.filterwarnings("ignore::DeprecationWarning")
def test_not_implemented(self) -> None: def test_not_implemented(self) -> None:
assert PyAccess.new(hopper("BGR;15")) is None assert PyAccess.new(hopper("BGR;15")) is None
# ref https://github.com/python-pillow/Pillow/pull/2009 # Ref https://github.com/python-pillow/Pillow/pull/2009
def test_reference_counting(self) -> None: def test_reference_counting(self) -> None:
size = 10 size = 10
@ -361,7 +332,7 @@ class TestCffi(AccessTest):
with pytest.warns(DeprecationWarning): with pytest.warns(DeprecationWarning):
px = Image.new("L", (size, 1), 0).load() px = Image.new("L", (size, 1), 0).load()
for i in range(size): for i in range(size):
# pixels can contain garbage if image is released # Pixels can contain garbage if image is released
assert px[i, 0] == 0 assert px[i, 0] == 0
@pytest.mark.parametrize("mode", ("P", "PA")) @pytest.mark.parametrize("mode", ("P", "PA"))
@ -439,13 +410,14 @@ class TestEmbeddable:
from setuptools.command import build_ext from setuptools.command import build_ext
with open("embed_pil.c", "w", encoding="utf-8") as fh: with open("embed_pil.c", "w", encoding="utf-8") as fh:
home = sys.prefix.replace("\\", "\\\\")
fh.write( fh.write(
""" f"""
#include "Python.h" #include "Python.h"
int main(int argc, char* argv[]) int main(int argc, char* argv[])
{ {{
char *home = "%s"; char *home = "{home}";
wchar_t *whome = Py_DecodeLocale(home, NULL); wchar_t *whome = Py_DecodeLocale(home, NULL);
Py_SetPythonHome(whome); Py_SetPythonHome(whome);
@ -460,9 +432,8 @@ int main(int argc, char* argv[])
PyMem_RawFree(whome); PyMem_RawFree(whome);
return 0; return 0;
} }}
""" """
% sys.prefix.replace("\\", "\\\\")
) )
compiler = getattr(build_ext, "new_compiler")() compiler = getattr(build_ext, "new_compiler")()
@ -478,7 +449,7 @@ int main(int argc, char* argv[])
env = os.environ.copy() env = os.environ.copy()
env["PATH"] = sys.prefix + ";" + env["PATH"] env["PATH"] = sys.prefix + ";" + env["PATH"]
# do not display the Windows Error Reporting dialog # Do not display the Windows Error Reporting dialog
getattr(ctypes, "windll").kernel32.SetErrorMode(0x0002) getattr(ctypes, "windll").kernel32.SetErrorMode(0x0002)
process = subprocess.Popen(["embed_pil.exe"], env=env) process = subprocess.Popen(["embed_pil.exe"], env=env)

View File

@ -86,11 +86,21 @@ def test_fromarray() -> None:
assert test("RGBX") == ("RGBA", (128, 100), True) assert test("RGBX") == ("RGBA", (128, 100), True)
# Test mode is None with no "typestr" in the array interface # Test mode is None with no "typestr" in the array interface
wrapped = Wrapper(hopper("L"), {"shape": (100, 128)})
with pytest.raises(TypeError): with pytest.raises(TypeError):
wrapped = Wrapper(test("L"), {"shape": (100, 128)})
Image.fromarray(wrapped) Image.fromarray(wrapped)
def test_fromarray_strides_without_tobytes() -> None:
class Wrapper:
def __init__(self, arr_params: dict[str, Any]) -> None:
self.__array_interface__ = arr_params
with pytest.raises(ValueError):
wrapped = Wrapper({"shape": (1, 1), "strides": (1, 1)})
Image.fromarray(wrapped, "L")
def test_fromarray_palette() -> None: def test_fromarray_palette() -> None:
# Arrange # Arrange
i = im.convert("L") i = im.convert("L")

View File

@ -18,7 +18,7 @@ def test_crop(mode: str) -> None:
def test_wide_crop() -> None: def test_wide_crop() -> None:
def crop(*bbox: int) -> tuple[int, ...]: def crop(bbox: tuple[int, int, int, int]) -> tuple[int, ...]:
i = im.crop(bbox) i = im.crop(bbox)
h = i.histogram() h = i.histogram()
while h and not h[-1]: while h and not h[-1]:
@ -27,23 +27,23 @@ def test_wide_crop() -> None:
im = Image.new("L", (100, 100), 1) im = Image.new("L", (100, 100), 1)
assert crop(0, 0, 100, 100) == (0, 10000) assert crop((0, 0, 100, 100)) == (0, 10000)
assert crop(25, 25, 75, 75) == (0, 2500) assert crop((25, 25, 75, 75)) == (0, 2500)
# sides # sides
assert crop(-25, 0, 25, 50) == (1250, 1250) assert crop((-25, 0, 25, 50)) == (1250, 1250)
assert crop(0, -25, 50, 25) == (1250, 1250) assert crop((0, -25, 50, 25)) == (1250, 1250)
assert crop(75, 0, 125, 50) == (1250, 1250) assert crop((75, 0, 125, 50)) == (1250, 1250)
assert crop(0, 75, 50, 125) == (1250, 1250) assert crop((0, 75, 50, 125)) == (1250, 1250)
assert crop(-25, 25, 125, 75) == (2500, 5000) assert crop((-25, 25, 125, 75)) == (2500, 5000)
assert crop(25, -25, 75, 125) == (2500, 5000) assert crop((25, -25, 75, 125)) == (2500, 5000)
# corners # corners
assert crop(-25, -25, 25, 25) == (1875, 625) assert crop((-25, -25, 25, 25)) == (1875, 625)
assert crop(75, -25, 125, 25) == (1875, 625) assert crop((75, -25, 125, 25)) == (1875, 625)
assert crop(75, 75, 125, 125) == (1875, 625) assert crop((75, 75, 125, 125)) == (1875, 625)
assert crop(-25, 75, 25, 125) == (1875, 625) assert crop((-25, 75, 25, 125)) == (1875, 625)
@pytest.mark.parametrize("box", ((8, 2, 2, 8), (2, 8, 8, 2), (8, 8, 2, 2))) @pytest.mark.parametrize("box", ((8, 2, 2, 8), (2, 8, 8, 2), (8, 8, 2, 2)))

View File

@ -46,9 +46,9 @@ def test_sanity(filter_to_apply: ImageFilter.Filter, mode: str) -> None:
@pytest.mark.parametrize("mode", ("L", "I", "RGB", "CMYK")) @pytest.mark.parametrize("mode", ("L", "I", "RGB", "CMYK"))
def test_sanity_error(mode: str) -> None: def test_sanity_error(mode: str) -> None:
im = hopper(mode)
with pytest.raises(TypeError): with pytest.raises(TypeError):
im = hopper(mode) im.filter("hello") # type: ignore[arg-type]
im.filter("hello")
# crashes on small images # crashes on small images

View File

@ -6,7 +6,7 @@ from .helper import hopper
def test_extrema() -> None: def test_extrema() -> None:
def extrema(mode: str) -> tuple[int, int] | tuple[tuple[int, int], ...]: def extrema(mode: str) -> tuple[float, float] | tuple[tuple[int, int], ...]:
return hopper(mode).getextrema() return hopper(mode).getextrema()
assert extrema("1") == (0, 255) assert extrema("1") == (0, 255)

View File

@ -81,7 +81,8 @@ def test_mode_F() -> None:
@pytest.mark.parametrize("mode", ("BGR;15", "BGR;16", "BGR;24")) @pytest.mark.parametrize("mode", ("BGR;15", "BGR;16", "BGR;24"))
def test_mode_BGR(mode: str) -> None: def test_mode_BGR(mode: str) -> None:
data = [(16, 32, 49), (32, 32, 98)] data = [(16, 32, 49), (32, 32, 98)]
im = Image.new(mode, (1, 2)) with pytest.warns(DeprecationWarning):
im = Image.new(mode, (1, 2))
im.putdata(data) im.putdata(data)
assert list(im.getdata()) == data assert list(im.getdata()) == data

View File

@ -24,8 +24,9 @@ def test_sanity() -> None:
def test_libimagequant_quantize() -> None: def test_libimagequant_quantize() -> None:
image = hopper() image = hopper()
if is_ppc64le(): if is_ppc64le():
libimagequant = parse_version(features.version_feature("libimagequant")) version = features.version_feature("libimagequant")
if libimagequant < parse_version("4"): assert version is not None
if parse_version(version) < parse_version("4"):
pytest.skip("Fails with libimagequant earlier than 4.0.0 on ppc64le") pytest.skip("Fails with libimagequant earlier than 4.0.0 on ppc64le")
converted = image.quantize(100, Image.Quantize.LIBIMAGEQUANT) converted = image.quantize(100, Image.Quantize.LIBIMAGEQUANT)
assert converted.mode == "P" assert converted.mode == "P"

View File

@ -102,7 +102,7 @@ def test_unsupported_modes(mode: str) -> None:
def get_image(mode: str) -> Image.Image: def get_image(mode: str) -> Image.Image:
mode_info = ImageMode.getmode(mode) mode_info = ImageMode.getmode(mode)
if mode_info.basetype == "L": if mode_info.basetype == "L":
bands = [gradients_image] bands: list[Image.Image] = [gradients_image]
for _ in mode_info.bands[1:]: for _ in mode_info.bands[1:]:
# rotate previous image # rotate previous image
band = bands[-1].transpose(Image.Transpose.ROTATE_90) band = bands[-1].transpose(Image.Transpose.ROTATE_90)

View File

@ -7,7 +7,7 @@ import shutil
import sys import sys
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, Literal, cast
import pytest import pytest
@ -60,10 +60,13 @@ def test_sanity() -> None:
assert list(map(type, v)) == [str, str, str, str] assert list(map(type, v)) == [str, str, str, str]
# internal version number # internal version number
assert re.search(r"\d+\.\d+(\.\d+)?$", features.version_module("littlecms2")) version = features.version_module("littlecms2")
assert version is not None
assert re.search(r"\d+\.\d+(\.\d+)?$", version)
skip_missing() skip_missing()
i = ImageCms.profileToProfile(hopper(), SRGB, SRGB) i = ImageCms.profileToProfile(hopper(), SRGB, SRGB)
assert i is not None
assert_image(i, "RGB", (128, 128)) assert_image(i, "RGB", (128, 128))
i = hopper() i = hopper()
@ -72,23 +75,27 @@ def test_sanity() -> None:
t = ImageCms.buildTransform(SRGB, SRGB, "RGB", "RGB") t = ImageCms.buildTransform(SRGB, SRGB, "RGB", "RGB")
i = ImageCms.applyTransform(hopper(), t) i = ImageCms.applyTransform(hopper(), t)
assert i is not None
assert_image(i, "RGB", (128, 128)) assert_image(i, "RGB", (128, 128))
with hopper() as i: with hopper() as i:
t = ImageCms.buildTransform(SRGB, SRGB, "RGB", "RGB") t = ImageCms.buildTransform(SRGB, SRGB, "RGB", "RGB")
ImageCms.applyTransform(hopper(), t, inPlace=True) ImageCms.applyTransform(hopper(), t, inPlace=True)
assert i is not None
assert_image(i, "RGB", (128, 128)) assert_image(i, "RGB", (128, 128))
p = ImageCms.createProfile("sRGB") p = ImageCms.createProfile("sRGB")
o = ImageCms.getOpenProfile(SRGB) o = ImageCms.getOpenProfile(SRGB)
t = ImageCms.buildTransformFromOpenProfiles(p, o, "RGB", "RGB") t = ImageCms.buildTransformFromOpenProfiles(p, o, "RGB", "RGB")
i = ImageCms.applyTransform(hopper(), t) i = ImageCms.applyTransform(hopper(), t)
assert i is not None
assert_image(i, "RGB", (128, 128)) assert_image(i, "RGB", (128, 128))
t = ImageCms.buildProofTransform(SRGB, SRGB, SRGB, "RGB", "RGB") t = ImageCms.buildProofTransform(SRGB, SRGB, SRGB, "RGB", "RGB")
assert t.inputMode == "RGB" assert t.inputMode == "RGB"
assert t.outputMode == "RGB" assert t.outputMode == "RGB"
i = ImageCms.applyTransform(hopper(), t) i = ImageCms.applyTransform(hopper(), t)
assert i is not None
assert_image(i, "RGB", (128, 128)) assert_image(i, "RGB", (128, 128))
# test PointTransform convenience API # test PointTransform convenience API
@ -202,13 +209,13 @@ def test_exceptions() -> None:
ImageCms.buildTransform("foo", "bar", "RGB", "RGB") ImageCms.buildTransform("foo", "bar", "RGB", "RGB")
with pytest.raises(ImageCms.PyCMSError, match="Invalid type for Profile"): with pytest.raises(ImageCms.PyCMSError, match="Invalid type for Profile"):
ImageCms.getProfileName(None) ImageCms.getProfileName(None) # type: ignore[arg-type]
skip_missing() skip_missing()
# Python <= 3.9: "an integer is required (got type NoneType)" # Python <= 3.9: "an integer is required (got type NoneType)"
# Python > 3.9: "'NoneType' object cannot be interpreted as an integer" # Python > 3.9: "'NoneType' object cannot be interpreted as an integer"
with pytest.raises(ImageCms.PyCMSError, match="integer"): with pytest.raises(ImageCms.PyCMSError, match="integer"):
ImageCms.isIntentSupported(SRGB, None, None) ImageCms.isIntentSupported(SRGB, None, None) # type: ignore[arg-type]
def test_display_profile() -> None: def test_display_profile() -> None:
@ -232,7 +239,7 @@ def test_unsupported_color_space() -> None:
"Color space not supported for on-the-fly profile creation (unsupported)" "Color space not supported for on-the-fly profile creation (unsupported)"
), ),
): ):
ImageCms.createProfile("unsupported") ImageCms.createProfile("unsupported") # type: ignore[arg-type]
def test_invalid_color_temperature() -> None: def test_invalid_color_temperature() -> None:
@ -240,7 +247,7 @@ def test_invalid_color_temperature() -> None:
ImageCms.PyCMSError, ImageCms.PyCMSError,
match='Color temperature must be numeric, "invalid" not valid', match='Color temperature must be numeric, "invalid" not valid',
): ):
ImageCms.createProfile("LAB", "invalid") ImageCms.createProfile("LAB", "invalid") # type: ignore[arg-type]
@pytest.mark.parametrize("flag", ("my string", -1)) @pytest.mark.parametrize("flag", ("my string", -1))
@ -249,7 +256,7 @@ def test_invalid_flag(flag: str | int) -> None:
with pytest.raises( with pytest.raises(
ImageCms.PyCMSError, match="flags must be an integer between 0 and " ImageCms.PyCMSError, match="flags must be an integer between 0 and "
): ):
ImageCms.profileToProfile(im, "foo", "bar", flags=flag) ImageCms.profileToProfile(im, "foo", "bar", flags=flag) # type: ignore[arg-type]
def test_simple_lab() -> None: def test_simple_lab() -> None:
@ -260,7 +267,7 @@ def test_simple_lab() -> None:
t = ImageCms.buildTransform(psRGB, pLab, "RGB", "LAB") t = ImageCms.buildTransform(psRGB, pLab, "RGB", "LAB")
i_lab = ImageCms.applyTransform(i, t) i_lab = ImageCms.applyTransform(i, t)
assert i_lab is not None
assert i_lab.mode == "LAB" assert i_lab.mode == "LAB"
k = i_lab.getpixel((0, 0)) k = i_lab.getpixel((0, 0))
@ -284,6 +291,7 @@ def test_lab_color() -> None:
# Need to add a type mapping for some PIL type to TYPE_Lab_8 in findLCMSType, and # Need to add a type mapping for some PIL type to TYPE_Lab_8 in findLCMSType, and
# have that mapping work back to a PIL mode (likely RGB). # have that mapping work back to a PIL mode (likely RGB).
i = ImageCms.applyTransform(hopper(), t) i = ImageCms.applyTransform(hopper(), t)
assert i is not None
assert_image(i, "LAB", (128, 128)) assert_image(i, "LAB", (128, 128))
# i.save('temp.lab.tif') # visually verified vs PS. # i.save('temp.lab.tif') # visually verified vs PS.
@ -298,6 +306,7 @@ def test_lab_srgb() -> None:
with Image.open("Tests/images/hopper.Lab.tif") as img: with Image.open("Tests/images/hopper.Lab.tif") as img:
img_srgb = ImageCms.applyTransform(img, t) img_srgb = ImageCms.applyTransform(img, t)
assert img_srgb is not None
# img_srgb.save('temp.srgb.tif') # visually verified vs ps. # img_srgb.save('temp.srgb.tif') # visually verified vs ps.
@ -317,11 +326,11 @@ def test_lab_roundtrip() -> None:
t2 = ImageCms.buildTransform(pLab, psRGB, "LAB", "RGB") t2 = ImageCms.buildTransform(pLab, psRGB, "LAB", "RGB")
i = ImageCms.applyTransform(hopper(), t) i = ImageCms.applyTransform(hopper(), t)
assert i is not None
assert i.info["icc_profile"] == ImageCmsProfile(pLab).tobytes() assert i.info["icc_profile"] == ImageCmsProfile(pLab).tobytes()
out = ImageCms.applyTransform(i, t2) out = ImageCms.applyTransform(i, t2)
assert out is not None
assert_image_similar(hopper(), out, 2) assert_image_similar(hopper(), out, 2)
@ -343,7 +352,7 @@ def test_extended_information() -> None:
p = o.profile p = o.profile
def assert_truncated_tuple_equal( def assert_truncated_tuple_equal(
tup1: tuple[Any, ...], tup2: tuple[Any, ...], digits: int = 10 tup1: tuple[Any, ...] | None, tup2: tuple[Any, ...], digits: int = 10
) -> None: ) -> None:
# Helper function to reduce precision of tuples of floats # Helper function to reduce precision of tuples of floats
# recursively and then check equality. # recursively and then check equality.
@ -359,6 +368,7 @@ def test_extended_information() -> None:
for val in tuple_value for val in tuple_value
) )
assert tup1 is not None
assert truncate_tuple(tup1) == truncate_tuple(tup2) assert truncate_tuple(tup1) == truncate_tuple(tup2)
assert p.attributes == 4294967296 assert p.attributes == 4294967296
@ -504,22 +514,22 @@ def test_non_ascii_path(tmp_path: Path) -> None:
def test_profile_typesafety() -> None: def test_profile_typesafety() -> None:
# does not segfault # does not segfault
with pytest.raises(TypeError, match="Invalid type for Profile"): with pytest.raises(TypeError, match="Invalid type for Profile"):
ImageCms.ImageCmsProfile(0).tobytes() ImageCms.ImageCmsProfile(0) # type: ignore[arg-type]
with pytest.raises(TypeError, match="Invalid type for Profile"): with pytest.raises(TypeError, match="Invalid type for Profile"):
ImageCms.ImageCmsProfile(1).tobytes() ImageCms.ImageCmsProfile(1) # type: ignore[arg-type]
# also check core function # also check core function
with pytest.raises(TypeError): with pytest.raises(TypeError):
ImageCms.core.profile_tobytes(0) ImageCms.core.profile_tobytes(0) # type: ignore[arg-type]
with pytest.raises(TypeError): with pytest.raises(TypeError):
ImageCms.core.profile_tobytes(1) ImageCms.core.profile_tobytes(1) # type: ignore[arg-type]
if not is_pypy(): if not is_pypy():
# core profile should not be directly instantiable # core profile should not be directly instantiable
with pytest.raises(TypeError): with pytest.raises(TypeError):
ImageCms.core.CmsProfile() ImageCms.core.CmsProfile()
with pytest.raises(TypeError): with pytest.raises(TypeError):
ImageCms.core.CmsProfile(0) ImageCms.core.CmsProfile(0) # type: ignore[call-arg]
@pytest.mark.skipif(is_pypy(), reason="fails on PyPy") @pytest.mark.skipif(is_pypy(), reason="fails on PyPy")
@ -528,7 +538,7 @@ def test_transform_typesafety() -> None:
with pytest.raises(TypeError): with pytest.raises(TypeError):
ImageCms.core.CmsTransform() ImageCms.core.CmsTransform()
with pytest.raises(TypeError): with pytest.raises(TypeError):
ImageCms.core.CmsTransform(0) ImageCms.core.CmsTransform(0) # type: ignore[call-arg]
def assert_aux_channel_preserved( def assert_aux_channel_preserved(
@ -578,11 +588,13 @@ def assert_aux_channel_preserved(
) )
# apply transform # apply transform
result_image: Image.Image | None
if transform_in_place: if transform_in_place:
ImageCms.applyTransform(source_image, t, inPlace=True) ImageCms.applyTransform(source_image, t, inPlace=True)
result_image = source_image result_image = source_image
else: else:
result_image = ImageCms.applyTransform(source_image, t, inPlace=False) result_image = ImageCms.applyTransform(source_image, t, inPlace=False)
assert result_image is not None
result_image_aux = result_image.getchannel(preserved_channel) result_image_aux = result_image.getchannel(preserved_channel)
assert_image_equal(source_image_aux, result_image_aux) assert_image_equal(source_image_aux, result_image_aux)
@ -628,7 +640,8 @@ def test_auxiliary_channels_isolated() -> None:
continue continue
# convert with and without AUX data, test colors are equal # convert with and without AUX data, test colors are equal
source_profile = ImageCms.createProfile(src_format[1]) src_colorSpace = cast(Literal["LAB", "XYZ", "sRGB"], src_format[1])
source_profile = ImageCms.createProfile(src_colorSpace)
destination_profile = ImageCms.createProfile(dst_format[1]) destination_profile = ImageCms.createProfile(dst_format[1])
source_image = src_format[3] source_image = src_format[3]
test_transform = ImageCms.buildTransform( test_transform = ImageCms.buildTransform(
@ -639,6 +652,7 @@ def test_auxiliary_channels_isolated() -> None:
) )
# test conversion from aux-ful source # test conversion from aux-ful source
test_image: Image.Image | None
if transform_in_place: if transform_in_place:
test_image = source_image.copy() test_image = source_image.copy()
ImageCms.applyTransform(test_image, test_transform, inPlace=True) ImageCms.applyTransform(test_image, test_transform, inPlace=True)
@ -646,6 +660,7 @@ def test_auxiliary_channels_isolated() -> None:
test_image = ImageCms.applyTransform( test_image = ImageCms.applyTransform(
source_image, test_transform, inPlace=False source_image, test_transform, inPlace=False
) )
assert test_image is not None
# reference conversion from aux-less source # reference conversion from aux-less source
reference_transform = ImageCms.buildTransform( reference_transform = ImageCms.buildTransform(
@ -657,7 +672,7 @@ def test_auxiliary_channels_isolated() -> None:
reference_image = ImageCms.applyTransform( reference_image = ImageCms.applyTransform(
source_image.convert(src_format[2]), reference_transform source_image.convert(src_format[2]), reference_transform
) )
assert reference_image is not None
assert_image_equal(test_image.convert(dst_format[2]), reference_image) assert_image_equal(test_image.convert(dst_format[2]), reference_image)

View File

@ -2,6 +2,7 @@ from __future__ import annotations
import contextlib import contextlib
import os.path import os.path
from typing import Sequence
import pytest import pytest
@ -265,6 +266,21 @@ def test_chord_too_fat() -> None:
assert_image_equal_tofile(im, "Tests/images/imagedraw_chord_too_fat.png") assert_image_equal_tofile(im, "Tests/images/imagedraw_chord_too_fat.png")
@pytest.mark.parametrize("mode", ("RGB", "L"))
@pytest.mark.parametrize("xy", ((W / 2, H / 2), [W / 2, H / 2]))
def test_circle(mode: str, xy: Sequence[float]) -> None:
# Arrange
im = Image.new(mode, (W, H))
draw = ImageDraw.Draw(im)
expected = f"Tests/images/imagedraw_ellipse_{mode}.png"
# Act
draw.circle(xy, 25, fill="green", outline="blue")
# Assert
assert_image_similar_tofile(im, expected, 1)
@pytest.mark.parametrize("mode", ("RGB", "L")) @pytest.mark.parametrize("mode", ("RGB", "L"))
@pytest.mark.parametrize("bbox", BBOX) @pytest.mark.parametrize("bbox", BBOX)
def test_ellipse(mode: str, bbox: Coords) -> None: def test_ellipse(mode: str, bbox: Coords) -> None:
@ -1067,8 +1083,8 @@ def test_line_horizontal() -> None:
) )
@pytest.mark.xfail(reason="failing test")
def test_line_h_s1_w2() -> None: def test_line_h_s1_w2() -> None:
pytest.skip("failing")
img, draw = create_base_image_draw((20, 20)) img, draw = create_base_image_draw((20, 20))
draw.line((5, 5, 14, 6), BLACK, 2) draw.line((5, 5, 14, 6), BLACK, 2)
assert_image_equal_tofile( assert_image_equal_tofile(

View File

@ -202,6 +202,8 @@ class TestImageFile:
class MockPyDecoder(ImageFile.PyDecoder): class MockPyDecoder(ImageFile.PyDecoder):
last: MockPyDecoder
def __init__(self, mode: str, *args: Any) -> None: def __init__(self, mode: str, *args: Any) -> None:
MockPyDecoder.last = self MockPyDecoder.last = self
@ -213,6 +215,8 @@ class MockPyDecoder(ImageFile.PyDecoder):
class MockPyEncoder(ImageFile.PyEncoder): class MockPyEncoder(ImageFile.PyEncoder):
last: MockPyEncoder | None
def __init__(self, mode: str, *args: Any) -> None: def __init__(self, mode: str, *args: Any) -> None:
MockPyEncoder.last = self MockPyEncoder.last = self
@ -315,6 +319,7 @@ class TestPyEncoder(CodecsTest):
im, fp, [("MOCK", (xoff, yoff, xoff + xsize, yoff + ysize), 0, "RGB")] im, fp, [("MOCK", (xoff, yoff, xoff + xsize, yoff + ysize), 0, "RGB")]
) )
assert MockPyEncoder.last
assert MockPyEncoder.last.state.xoff == xoff assert MockPyEncoder.last.state.xoff == xoff
assert MockPyEncoder.last.state.yoff == yoff assert MockPyEncoder.last.state.yoff == yoff
assert MockPyEncoder.last.state.xsize == xsize assert MockPyEncoder.last.state.xsize == xsize
@ -329,6 +334,7 @@ class TestPyEncoder(CodecsTest):
fp = BytesIO() fp = BytesIO()
ImageFile._save(im, fp, [("MOCK", None, 0, "RGB")]) ImageFile._save(im, fp, [("MOCK", None, 0, "RGB")])
assert MockPyEncoder.last
assert MockPyEncoder.last.state.xoff == 0 assert MockPyEncoder.last.state.xoff == 0
assert MockPyEncoder.last.state.yoff == 0 assert MockPyEncoder.last.state.yoff == 0
assert MockPyEncoder.last.state.xsize == 200 assert MockPyEncoder.last.state.xsize == 200

View File

@ -34,7 +34,9 @@ pytestmark = skip_unless_feature("freetype2")
def test_sanity() -> None: def test_sanity() -> None:
assert re.search(r"\d+\.\d+\.\d+$", features.version_module("freetype2")) version = features.version_module("freetype2")
assert version is not None
assert re.search(r"\d+\.\d+\.\d+$", version)
@pytest.fixture( @pytest.fixture(
@ -547,11 +549,10 @@ def test_find_font(
def loadable_font( def loadable_font(
filepath: str, size: int, index: int, encoding: str, *args: Any filepath: str, size: int, index: int, encoding: str, *args: Any
): ):
_freeTypeFont = getattr(ImageFont, "_FreeTypeFont")
if filepath == path_to_fake: if filepath == path_to_fake:
return ImageFont._FreeTypeFont( return _freeTypeFont(FONT_PATH, size, index, encoding, *args)
FONT_PATH, size, index, encoding, *args return _freeTypeFont(filepath, size, index, encoding, *args)
)
return ImageFont._FreeTypeFont(filepath, size, index, encoding, *args)
m.setattr(ImageFont, "FreeTypeFont", loadable_font) m.setattr(ImageFont, "FreeTypeFont", loadable_font)
font = ImageFont.truetype(fontname) font = ImageFont.truetype(fontname)
@ -630,7 +631,9 @@ def test_complex_font_settings() -> None:
def test_variation_get(font: ImageFont.FreeTypeFont) -> None: def test_variation_get(font: ImageFont.FreeTypeFont) -> None:
freetype = parse_version(features.version_module("freetype2")) version = features.version_module("freetype2")
assert version is not None
freetype = parse_version(version)
if freetype < parse_version("2.9.1"): if freetype < parse_version("2.9.1"):
with pytest.raises(NotImplementedError): with pytest.raises(NotImplementedError):
font.get_variation_names() font.get_variation_names()
@ -700,7 +703,9 @@ def _check_text(font: ImageFont.FreeTypeFont, path: str, epsilon: float) -> None
def test_variation_set_by_name(font: ImageFont.FreeTypeFont) -> None: def test_variation_set_by_name(font: ImageFont.FreeTypeFont) -> None:
freetype = parse_version(features.version_module("freetype2")) version = features.version_module("freetype2")
assert version is not None
freetype = parse_version(version)
if freetype < parse_version("2.9.1"): if freetype < parse_version("2.9.1"):
with pytest.raises(NotImplementedError): with pytest.raises(NotImplementedError):
font.set_variation_by_name("Bold") font.set_variation_by_name("Bold")
@ -725,7 +730,9 @@ def test_variation_set_by_name(font: ImageFont.FreeTypeFont) -> None:
def test_variation_set_by_axes(font: ImageFont.FreeTypeFont) -> None: def test_variation_set_by_axes(font: ImageFont.FreeTypeFont) -> None:
freetype = parse_version(features.version_module("freetype2")) version = features.version_module("freetype2")
assert version is not None
freetype = parse_version(version)
if freetype < parse_version("2.9.1"): if freetype < parse_version("2.9.1"):
with pytest.raises(NotImplementedError): with pytest.raises(NotImplementedError):
font.set_variation_by_axes([100]) font.set_variation_by_axes([100])

View File

@ -4,11 +4,11 @@ from typing import Generator
import pytest import pytest
from PIL import Image, ImageFilter from PIL import Image, ImageFile, ImageFilter
@pytest.fixture @pytest.fixture
def test_images() -> Generator[dict[str, Image.Image], None, None]: def test_images() -> Generator[dict[str, ImageFile.ImageFile], None, None]:
ims = { ims = {
"im": Image.open("Tests/images/hopper.ppm"), "im": Image.open("Tests/images/hopper.ppm"),
"snakes": Image.open("Tests/images/color_snakes.png"), "snakes": Image.open("Tests/images/color_snakes.png"),
@ -20,7 +20,7 @@ def test_images() -> Generator[dict[str, Image.Image], None, None]:
im.close() im.close()
def test_filter_api(test_images: dict[str, Image.Image]) -> None: def test_filter_api(test_images: dict[str, ImageFile.ImageFile]) -> None:
im = test_images["im"] im = test_images["im"]
test_filter = ImageFilter.GaussianBlur(2.0) test_filter = ImageFilter.GaussianBlur(2.0)
@ -34,7 +34,7 @@ def test_filter_api(test_images: dict[str, Image.Image]) -> None:
assert i.size == (128, 128) assert i.size == (128, 128)
def test_usm_formats(test_images: dict[str, Image.Image]) -> None: def test_usm_formats(test_images: dict[str, ImageFile.ImageFile]) -> None:
im = test_images["im"] im = test_images["im"]
usm = ImageFilter.UnsharpMask usm = ImageFilter.UnsharpMask
@ -52,7 +52,7 @@ def test_usm_formats(test_images: dict[str, Image.Image]) -> None:
im.convert("YCbCr").filter(usm) im.convert("YCbCr").filter(usm)
def test_blur_formats(test_images: dict[str, Image.Image]) -> None: def test_blur_formats(test_images: dict[str, ImageFile.ImageFile]) -> None:
im = test_images["im"] im = test_images["im"]
blur = ImageFilter.GaussianBlur blur = ImageFilter.GaussianBlur
@ -70,7 +70,7 @@ def test_blur_formats(test_images: dict[str, Image.Image]) -> None:
im.convert("YCbCr").filter(blur) im.convert("YCbCr").filter(blur)
def test_usm_accuracy(test_images: dict[str, Image.Image]) -> None: def test_usm_accuracy(test_images: dict[str, ImageFile.ImageFile]) -> None:
snakes = test_images["snakes"] snakes = test_images["snakes"]
src = snakes.convert("RGB") src = snakes.convert("RGB")
@ -79,7 +79,7 @@ def test_usm_accuracy(test_images: dict[str, Image.Image]) -> None:
assert i.tobytes() == src.tobytes() assert i.tobytes() == src.tobytes()
def test_blur_accuracy(test_images: dict[str, Image.Image]) -> None: def test_blur_accuracy(test_images: dict[str, ImageFile.ImageFile]) -> None:
snakes = test_images["snakes"] snakes = test_images["snakes"]
i = snakes.filter(ImageFilter.GaussianBlur(0.4)) i = snakes.filter(ImageFilter.GaussianBlur(0.4))

View File

@ -47,7 +47,11 @@ def test_iterator_min_frame() -> None:
assert i[index] == next(i) assert i[index] == next(i)
def _test_multipage_tiff() -> None: @pytest.mark.parametrize(
"libtiff", (pytest.param(True, marks=skip_unless_feature("libtiff")), False)
)
def test_multipage_tiff(monkeypatch: pytest.MonkeyPatch, libtiff: bool) -> None:
monkeypatch.setattr(TiffImagePlugin, "READ_LIBTIFF", libtiff)
with Image.open("Tests/images/multipage.tiff") as im: with Image.open("Tests/images/multipage.tiff") as im:
for index, frame in enumerate(ImageSequence.Iterator(im)): for index, frame in enumerate(ImageSequence.Iterator(im)):
frame.load() frame.load()
@ -55,17 +59,6 @@ def _test_multipage_tiff() -> None:
frame.convert("RGB") frame.convert("RGB")
def test_tiff() -> None:
_test_multipage_tiff()
@skip_unless_feature("libtiff")
def test_libtiff() -> None:
TiffImagePlugin.READ_LIBTIFF = True
_test_multipage_tiff()
TiffImagePlugin.READ_LIBTIFF = False
def test_consecutive() -> None: def test_consecutive() -> None:
with Image.open("Tests/images/multipage.tiff") as im: with Image.open("Tests/images/multipage.tiff") as im:
first_frame = None first_frame = None

View File

@ -25,10 +25,10 @@ def test_sanity() -> None:
st.stddev st.stddev
with pytest.raises(AttributeError): with pytest.raises(AttributeError):
st.spam() st.spam() # type: ignore[attr-defined]
with pytest.raises(TypeError): with pytest.raises(TypeError):
ImageStat.Stat(1) ImageStat.Stat(1) # type: ignore[arg-type]
def test_hopper() -> None: def test_hopper() -> None:

View File

@ -216,7 +216,10 @@ class TestLibPack:
) )
def test_I16(self) -> None: def test_I16(self) -> None:
self.assert_pack("I;16N", "I;16N", 2, 0x0201, 0x0403, 0x0605) if sys.byteorder == "little":
self.assert_pack("I;16N", "I;16N", 2, 0x0201, 0x0403, 0x0605)
else:
self.assert_pack("I;16N", "I;16N", 2, 0x0102, 0x0304, 0x0506)
def test_F_float(self) -> None: def test_F_float(self) -> None:
self.assert_pack("F", "F;32F", 4, 1.539989614439558e-36, 4.063216068939723e-34) self.assert_pack("F", "F;32F", 4, 1.539989614439558e-36, 4.063216068939723e-34)
@ -359,11 +362,14 @@ class TestLibUnpack:
) )
def test_BGR(self) -> None: def test_BGR(self) -> None:
self.assert_unpack("BGR;15", "BGR;15", 3, (8, 131, 0), (24, 0, 8), (41, 131, 8)) with pytest.warns(DeprecationWarning):
self.assert_unpack( self.assert_unpack(
"BGR;16", "BGR;16", 3, (8, 64, 0), (24, 129, 0), (41, 194, 0) "BGR;15", "BGR;15", 3, (8, 131, 0), (24, 0, 8), (41, 131, 8)
) )
self.assert_unpack("BGR;24", "BGR;24", 3, (1, 2, 3), (4, 5, 6), (7, 8, 9)) self.assert_unpack(
"BGR;16", "BGR;16", 3, (8, 64, 0), (24, 129, 0), (41, 194, 0)
)
self.assert_unpack("BGR;24", "BGR;24", 3, (1, 2, 3), (4, 5, 6), (7, 8, 9))
def test_RGBA(self) -> None: def test_RGBA(self) -> None:
self.assert_unpack("RGBA", "LA", 2, (1, 1, 1, 2), (3, 3, 3, 4), (5, 5, 5, 6)) self.assert_unpack("RGBA", "LA", 2, (1, 1, 1, 2), (3, 3, 3, 4), (5, 5, 5, 6))

View File

@ -3,10 +3,12 @@ from __future__ import annotations
from fractions import Fraction from fractions import Fraction
from pathlib import Path from pathlib import Path
from PIL import Image, TiffImagePlugin, features import pytest
from PIL import Image, TiffImagePlugin
from PIL.TiffImagePlugin import IFDRational from PIL.TiffImagePlugin import IFDRational
from .helper import hopper from .helper import hopper, skip_unless_feature
def _test_equal(num, denom, target) -> None: def _test_equal(num, denom, target) -> None:
@ -52,18 +54,18 @@ def test_nonetype() -> None:
assert xres and yres assert xres and yres
def test_ifd_rational_save(tmp_path: Path) -> None: @pytest.mark.parametrize(
methods = [True] "libtiff", (pytest.param(True, marks=skip_unless_feature("libtiff")), False)
if features.check("libtiff"): )
methods.append(False) def test_ifd_rational_save(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, libtiff: bool
) -> None:
im = hopper()
out = str(tmp_path / "temp.tiff")
res = IFDRational(301, 1)
for libtiff in methods: monkeypatch.setattr(TiffImagePlugin, "WRITE_LIBTIFF", libtiff)
TiffImagePlugin.WRITE_LIBTIFF = libtiff im.save(out, dpi=(res, res), compression="raw")
im = hopper() with Image.open(out) as reloaded:
out = str(tmp_path / "temp.tiff") assert float(IFDRational(301, 1)) == float(reloaded.tag_v2[282])
res = IFDRational(301, 1)
im.save(out, dpi=(res, res), compression="raw")
with Image.open(out) as reloaded:
assert float(IFDRational(301, 1)) == float(reloaded.tag_v2[282])

View File

@ -2,7 +2,7 @@
# install libimagequant # install libimagequant
archive_name=libimagequant archive_name=libimagequant
archive_version=4.3.0 archive_version=4.3.1
archive=$archive_name-$archive_version archive=$archive_name-$archive_version

View File

@ -1,7 +1,7 @@
#!/bin/bash #!/bin/bash
# install webp # install webp
archive=libwebp-1.3.2 archive=libwebp-1.4.0
./download-and-extract.sh $archive https://raw.githubusercontent.com/python-pillow/pillow-depends/main/$archive.tar.gz ./download-and-extract.sh $archive https://raw.githubusercontent.com/python-pillow/pillow-depends/main/$archive.tar.gz

View File

@ -9,9 +9,9 @@ PAPER =
BUILDDIR = _build BUILDDIR = _build
# Internal variables. # Internal variables.
PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_a4 = --define latex_paper_size=a4
PAPEROPT_letter = -D latex_paper_size=letter PAPEROPT_letter = --define latex_paper_size=letter
ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . ALLSPHINXOPTS = --doctree-dir $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
# the i18n builder cannot share the environment and doctrees with the others # the i18n builder cannot share the environment and doctrees with the others
I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
@ -51,42 +51,42 @@ install-sphinx:
.PHONY: html .PHONY: html
html: html:
$(MAKE) install-sphinx $(MAKE) install-sphinx
$(SPHINXBUILD) -b html -W --keep-going $(ALLSPHINXOPTS) $(BUILDDIR)/html $(SPHINXBUILD) --builder html --fail-on-warning --keep-going $(ALLSPHINXOPTS) $(BUILDDIR)/html
@echo @echo
@echo "Build finished. The HTML pages are in $(BUILDDIR)/html." @echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
.PHONY: dirhtml .PHONY: dirhtml
dirhtml: dirhtml:
$(MAKE) install-sphinx $(MAKE) install-sphinx
$(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml $(SPHINXBUILD) --builder dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
@echo @echo
@echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
.PHONY: singlehtml .PHONY: singlehtml
singlehtml: singlehtml:
$(MAKE) install-sphinx $(MAKE) install-sphinx
$(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml $(SPHINXBUILD) --builder singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
@echo @echo
@echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
.PHONY: pickle .PHONY: pickle
pickle: pickle:
$(MAKE) install-sphinx $(MAKE) install-sphinx
$(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle $(SPHINXBUILD) --builder pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
@echo @echo
@echo "Build finished; now you can process the pickle files." @echo "Build finished; now you can process the pickle files."
.PHONY: json .PHONY: json
json: json:
$(MAKE) install-sphinx $(MAKE) install-sphinx
$(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json $(SPHINXBUILD) --builder json $(ALLSPHINXOPTS) $(BUILDDIR)/json
@echo @echo
@echo "Build finished; now you can process the JSON files." @echo "Build finished; now you can process the JSON files."
.PHONY: htmlhelp .PHONY: htmlhelp
htmlhelp: htmlhelp:
$(MAKE) install-sphinx $(MAKE) install-sphinx
$(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp $(SPHINXBUILD) --builder htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
@echo @echo
@echo "Build finished; now you can run HTML Help Workshop with the" \ @echo "Build finished; now you can run HTML Help Workshop with the" \
".hhp project file in $(BUILDDIR)/htmlhelp." ".hhp project file in $(BUILDDIR)/htmlhelp."
@ -94,7 +94,7 @@ htmlhelp:
.PHONY: qthelp .PHONY: qthelp
qthelp: qthelp:
$(MAKE) install-sphinx $(MAKE) install-sphinx
$(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp $(SPHINXBUILD) --builder qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
@echo @echo
@echo "Build finished; now you can run "qcollectiongenerator" with the" \ @echo "Build finished; now you can run "qcollectiongenerator" with the" \
".qhcp project file in $(BUILDDIR)/qthelp, like this:" ".qhcp project file in $(BUILDDIR)/qthelp, like this:"
@ -105,7 +105,7 @@ qthelp:
.PHONY: devhelp .PHONY: devhelp
devhelp: devhelp:
$(MAKE) install-sphinx $(MAKE) install-sphinx
$(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp $(SPHINXBUILD) --builder devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
@echo @echo
@echo "Build finished." @echo "Build finished."
@echo "To view the help file:" @echo "To view the help file:"
@ -116,14 +116,14 @@ devhelp:
.PHONY: epub .PHONY: epub
epub: epub:
$(MAKE) install-sphinx $(MAKE) install-sphinx
$(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub $(SPHINXBUILD) --builder epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
@echo @echo
@echo "Build finished. The epub file is in $(BUILDDIR)/epub." @echo "Build finished. The epub file is in $(BUILDDIR)/epub."
.PHONY: latex .PHONY: latex
latex: latex:
$(MAKE) install-sphinx $(MAKE) install-sphinx
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex $(SPHINXBUILD) --builder latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
@echo @echo
@echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
@echo "Run \`make' in that directory to run these through (pdf)latex" \ @echo "Run \`make' in that directory to run these through (pdf)latex" \
@ -132,7 +132,7 @@ latex:
.PHONY: latexpdf .PHONY: latexpdf
latexpdf: latexpdf:
$(MAKE) install-sphinx $(MAKE) install-sphinx
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex $(SPHINXBUILD) --builder latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
@echo "Running LaTeX files through pdflatex..." @echo "Running LaTeX files through pdflatex..."
$(MAKE) -C $(BUILDDIR)/latex all-pdf $(MAKE) -C $(BUILDDIR)/latex all-pdf
@echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
@ -140,21 +140,21 @@ latexpdf:
.PHONY: text .PHONY: text
text: text:
$(MAKE) install-sphinx $(MAKE) install-sphinx
$(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text $(SPHINXBUILD) --builder text $(ALLSPHINXOPTS) $(BUILDDIR)/text
@echo @echo
@echo "Build finished. The text files are in $(BUILDDIR)/text." @echo "Build finished. The text files are in $(BUILDDIR)/text."
.PHONY: man .PHONY: man
man: man:
$(MAKE) install-sphinx $(MAKE) install-sphinx
$(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man $(SPHINXBUILD) --builder man $(ALLSPHINXOPTS) $(BUILDDIR)/man
@echo @echo
@echo "Build finished. The manual pages are in $(BUILDDIR)/man." @echo "Build finished. The manual pages are in $(BUILDDIR)/man."
.PHONY: texinfo .PHONY: texinfo
texinfo: texinfo:
$(MAKE) install-sphinx $(MAKE) install-sphinx
$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo $(SPHINXBUILD) --builder texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
@echo @echo
@echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo."
@echo "Run \`make' in that directory to run these through makeinfo" \ @echo "Run \`make' in that directory to run these through makeinfo" \
@ -163,7 +163,7 @@ texinfo:
.PHONY: info .PHONY: info
info: info:
$(MAKE) install-sphinx $(MAKE) install-sphinx
$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo $(SPHINXBUILD) --builder texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
@echo "Running Texinfo files through makeinfo..." @echo "Running Texinfo files through makeinfo..."
make -C $(BUILDDIR)/texinfo info make -C $(BUILDDIR)/texinfo info
@echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo."
@ -171,21 +171,21 @@ info:
.PHONY: gettext .PHONY: gettext
gettext: gettext:
$(MAKE) install-sphinx $(MAKE) install-sphinx
$(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale $(SPHINXBUILD) --builder gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale
@echo @echo
@echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale."
.PHONY: changes .PHONY: changes
changes: changes:
$(MAKE) install-sphinx $(MAKE) install-sphinx
$(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes $(SPHINXBUILD) --builder changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
@echo @echo
@echo "The overview file is in $(BUILDDIR)/changes." @echo "The overview file is in $(BUILDDIR)/changes."
.PHONY: linkcheck .PHONY: linkcheck
linkcheck: linkcheck:
$(MAKE) install-sphinx $(MAKE) install-sphinx
$(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck -j auto $(SPHINXBUILD) --builder linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck -j auto
@echo @echo
@echo "Link check complete; look for any errors in the above output " \ @echo "Link check complete; look for any errors in the above output " \
"or in $(BUILDDIR)/linkcheck/output.txt." "or in $(BUILDDIR)/linkcheck/output.txt."
@ -193,7 +193,7 @@ linkcheck:
.PHONY: doctest .PHONY: doctest
doctest: doctest:
$(MAKE) install-sphinx $(MAKE) install-sphinx
$(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest $(SPHINXBUILD) --builder doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
@echo "Testing of doctests in the sources finished, look at the " \ @echo "Testing of doctests in the sources finished, look at the " \
"results in $(BUILDDIR)/doctest/output.txt." "results in $(BUILDDIR)/doctest/output.txt."

View File

@ -28,6 +28,7 @@ needs_sphinx = "7.3"
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones. # ones.
extensions = [ extensions = [
"dater",
"sphinx.ext.autodoc", "sphinx.ext.autodoc",
"sphinx.ext.extlinks", "sphinx.ext.extlinks",
"sphinx.ext.intersphinx", "sphinx.ext.intersphinx",

48
docs/dater.py Normal file
View File

@ -0,0 +1,48 @@
"""
Sphinx extension to add timestamps to release notes based on Git versions.
Based on https://github.com/jaraco/rst.linker, with thanks to Jason R. Coombs.
"""
from __future__ import annotations
import re
import subprocess
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from sphinx.application import Sphinx
DOC_NAME_REGEX = re.compile(r"releasenotes/\d+\.\d+\.\d+")
VERSION_TITLE_REGEX = re.compile(r"^(\d+\.\d+\.\d+)\n-+\n")
def get_date_for(git_version: str) -> str | None:
cmd = ["git", "log", "-1", "--format=%ai", git_version]
try:
out = subprocess.check_output(
cmd, stderr=subprocess.DEVNULL, text=True, encoding="utf-8"
)
except subprocess.CalledProcessError:
return None
return out.split()[0]
def add_date(app: Sphinx, doc_name: str, source: list[str]) -> None:
if DOC_NAME_REGEX.match(doc_name) and (m := VERSION_TITLE_REGEX.match(source[0])):
old_title = m.group(1)
if tag_date := get_date_for(old_title):
new_title = f"{old_title} ({tag_date})"
else:
new_title = f"{old_title} (unreleased)"
new_underline = "-" * len(new_title)
result = source[0].replace(m.group(0), f"{new_title}\n{new_underline}\n", 1)
source[0] = result
def setup(app: Sphinx) -> dict[str, bool]:
app.connect("source-read", add_date)
return {"parallel_read_safe": True}

View File

@ -100,6 +100,21 @@ ImageMath eval()
``ImageMath.eval()`` has been deprecated. Use :py:meth:`~PIL.ImageMath.lambda_eval` or ``ImageMath.eval()`` has been deprecated. Use :py:meth:`~PIL.ImageMath.lambda_eval` or
:py:meth:`~PIL.ImageMath.unsafe_eval` instead. :py:meth:`~PIL.ImageMath.unsafe_eval` instead.
BGR;15, BGR 16 and BGR;24
^^^^^^^^^^^^^^^^^^^^^^^^^
.. deprecated:: 10.4.0
The experimental BGR;15, BGR;16 and BGR;24 modes have been deprecated.
Support for LibTIFF earlier than 4
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.. deprecated:: 10.4.0
Support for LibTIFF earlier than version 4 has been deprecated.
Upgrade to a newer version of LibTIFF instead.
Removed features Removed features
---------------- ----------------

View File

@ -59,9 +59,6 @@ Pillow also provides limited support for a few additional modes, including:
* ``I;16L`` (16-bit little endian unsigned integer pixels) * ``I;16L`` (16-bit little endian unsigned integer pixels)
* ``I;16B`` (16-bit big endian unsigned integer pixels) * ``I;16B`` (16-bit big endian unsigned integer pixels)
* ``I;16N`` (16-bit native endian unsigned integer pixels) * ``I;16N`` (16-bit native endian unsigned integer pixels)
* ``BGR;15`` (15-bit reversed true colour)
* ``BGR;16`` (16-bit reversed true colour)
* ``BGR;24`` (24-bit reversed true colour)
Premultiplied alpha is where the values for each other channel have been Premultiplied alpha is where the values for each other channel have been
multiplied by the alpha. For example, an RGBA pixel of ``(10, 20, 30, 127)`` multiplied by the alpha. For example, an RGBA pixel of ``(10, 20, 30, 127)``

View File

@ -68,7 +68,7 @@ Many of Pillow's features require external libraries:
* **libimagequant** provides improved color quantization * **libimagequant** provides improved color quantization
* Pillow has been tested with libimagequant **2.6-4.3** * Pillow has been tested with libimagequant **2.6-4.3.1**
* Libimagequant is licensed GPLv3, which is more restrictive than * Libimagequant is licensed GPLv3, which is more restrictive than
the Pillow license, therefore we will not be distributing binaries the Pillow license, therefore we will not be distributing binaries
with libimagequant support enabled. with libimagequant support enabled.

View File

@ -31,13 +31,13 @@ These platforms are built and tested for every change.
+----------------------------------+----------------------------+---------------------+ +----------------------------------+----------------------------+---------------------+
| Debian 12 Bookworm | 3.11 | x86, x86-64 | | Debian 12 Bookworm | 3.11 | x86, x86-64 |
+----------------------------------+----------------------------+---------------------+ +----------------------------------+----------------------------+---------------------+
| Fedora 38 | 3.11 | x86-64 |
+----------------------------------+----------------------------+---------------------+
| Fedora 39 | 3.12 | x86-64 | | Fedora 39 | 3.12 | x86-64 |
+----------------------------------+----------------------------+---------------------+ +----------------------------------+----------------------------+---------------------+
| Fedora 40 | 3.12 | x86-64 |
+----------------------------------+----------------------------+---------------------+
| Gentoo | 3.9 | x86-64 | | Gentoo | 3.9 | x86-64 |
+----------------------------------+----------------------------+---------------------+ +----------------------------------+----------------------------+---------------------+
| macOS 12 Monterey | 3.8, 3.9 | x86-64 | | macOS 13 Ventura | 3.8, 3.9 | x86-64 |
+----------------------------------+----------------------------+---------------------+ +----------------------------------+----------------------------+---------------------+
| macOS 14 Sonoma | 3.10, 3.11, 3.12, 3.13, | arm64 | | macOS 14 Sonoma | 3.10, 3.11, 3.12, 3.13, | arm64 |
| | PyPy3 | | | | PyPy3 | |
@ -47,7 +47,9 @@ These platforms are built and tested for every change.
| Ubuntu Linux 22.04 LTS (Jammy) | 3.8, 3.9, 3.10, 3.11, | x86-64 | | Ubuntu Linux 22.04 LTS (Jammy) | 3.8, 3.9, 3.10, 3.11, | x86-64 |
| | 3.12, 3.13, PyPy3 | | | | 3.12, 3.13, PyPy3 | |
| +----------------------------+---------------------+ | +----------------------------+---------------------+
| | 3.10 | arm64v8, ppc64le, | | | 3.10 | arm64v8 |
+----------------------------------+----------------------------+---------------------+
| Ubuntu Linux 24.04 LTS (Noble) | 3.12 | x86-64, ppc64le, |
| | | s390x | | | | s390x |
+----------------------------------+----------------------------+---------------------+ +----------------------------------+----------------------------+---------------------+
| Windows Server 2016 | 3.8 | x86-64 | | Windows Server 2016 | 3.8 | x86-64 |

View File

@ -78,6 +78,8 @@ Constructing images
^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^
.. autofunction:: new .. autofunction:: new
.. autoclass:: SupportsArrayInterface
:show-inheritance:
.. autofunction:: fromarray .. autofunction:: fromarray
.. autofunction:: frombytes .. autofunction:: frombytes
.. autofunction:: frombuffer .. autofunction:: frombuffer

View File

@ -227,6 +227,18 @@ Methods
.. versionadded:: 5.3.0 .. versionadded:: 5.3.0
.. py:method:: ImageDraw.circle(xy, radius, fill=None, outline=None, width=1)
Draws a circle with a given radius centering on a point.
.. versionadded:: 10.4.0
:param xy: The point for the center of the circle, e.g. ``(x, y)``.
:param radius: Radius of the circle.
:param outline: Color to use for the outline.
:param fill: Color to use for the fill.
:param width: The line width, in pixels.
.. py:method:: ImageDraw.ellipse(xy, fill=None, outline=None, width=1) .. py:method:: ImageDraw.ellipse(xy, fill=None, outline=None, width=1)
Draws an ellipse inside the given bounding box. Draws an ellipse inside the given bounding box.

View File

@ -57,6 +57,10 @@ Classes
:undoc-members: :undoc-members:
:show-inheritance: :show-inheritance:
.. autoclass:: PIL.ImageFile.StubHandler()
:members:
:show-inheritance:
.. autoclass:: PIL.ImageFile.StubImageFile() .. autoclass:: PIL.ImageFile.StubImageFile()
:members: :members:
:show-inheritance: :show-inheritance:

View File

@ -0,0 +1,68 @@
10.4.0
------
Security
========
TODO
^^^^
TODO
:cve:`YYYY-XXXXX`: TODO
^^^^^^^^^^^^^^^^^^^^^^^
TODO
Backwards Incompatible Changes
==============================
TODO
^^^^
Deprecations
============
BGR;15, BGR 16 and BGR;24
^^^^^^^^^^^^^^^^^^^^^^^^^
The experimental BGR;15, BGR;16 and BGR;24 modes have been deprecated.
Support for LibTIFF earlier than 4
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Support for LibTIFF earlier than version 4 has been deprecated.
Upgrade to a newer version of LibTIFF instead.
API Changes
===========
TODO
^^^^
TODO
API Additions
=============
ImageDraw.circle
^^^^^^^^^^^^^^^^
Added :py:meth:`~PIL.ImageDraw.ImageDraw.circle`. It provides the same functionality as
:py:meth:`~PIL.ImageDraw.ImageDraw.ellipse`, but instead of taking a bounding box, it
takes a center point and radius.
TODO
^^^^
TODO
Other Changes
=============
Python 3.13 beta
^^^^^^^^^^^^^^^^
To help others prepare for Python 3.13, wheels have been built against the 3.13 beta as
a preview. This is not official support for Python 3.13, but simply an opportunity for
users to test how Pillow works with the beta and report any problems.

View File

@ -14,6 +14,7 @@ expected to be backported to earlier versions.
.. toctree:: .. toctree::
:maxdepth: 2 :maxdepth: 2
10.4.0
10.3.0 10.3.0
10.2.0 10.2.0
10.1.0 10.1.0

View File

@ -165,9 +165,9 @@ if __name__ == "__main__":
print("Running selftest:") print("Running selftest:")
status = doctest.testmod(sys.modules[__name__]) status = doctest.testmod(sys.modules[__name__])
if status[0]: if status[0]:
print("*** %s tests of %d failed." % status) print(f"*** {status[0]} tests of {status[1]} failed.")
exit_status = 1 exit_status = 1
else: else:
print("--- %s tests passed." % status[1]) print(f"--- {status[1]} tests passed.")
sys.exit(exit_status) sys.exit(exit_status)

View File

@ -23,8 +23,7 @@ from setuptools.command.build_ext import build_ext
def get_version(): def get_version():
version_file = "src/PIL/_version.py" version_file = "src/PIL/_version.py"
with open(version_file, encoding="utf-8") as f: with open(version_file, encoding="utf-8") as f:
exec(compile(f.read(), version_file, "exec")) return f.read().split('"')[1]
return locals()["__version__"]
configuration = {} configuration = {}

View File

@ -35,6 +35,7 @@ import os
import struct import struct
from enum import IntEnum from enum import IntEnum
from io import BytesIO from io import BytesIO
from typing import IO
from . import Image, ImageFile from . import Image, ImageFile
@ -55,7 +56,7 @@ class AlphaEncoding(IntEnum):
DXT5 = 7 DXT5 = 7
def unpack_565(i): def unpack_565(i: int) -> tuple[int, int, int]:
return ((i >> 11) & 0x1F) << 3, ((i >> 5) & 0x3F) << 2, (i & 0x1F) << 3 return ((i >> 11) & 0x1F) << 3, ((i >> 5) & 0x3F) << 2, (i & 0x1F) << 3
@ -253,7 +254,7 @@ class BlpImageFile(ImageFile.ImageFile):
format = "BLP" format = "BLP"
format_description = "Blizzard Mipmap Format" format_description = "Blizzard Mipmap Format"
def _open(self): def _open(self) -> None:
self.magic = self.fp.read(4) self.magic = self.fp.read(4)
self.fp.seek(5, os.SEEK_CUR) self.fp.seek(5, os.SEEK_CUR)
@ -284,7 +285,8 @@ class _BLPBaseDecoder(ImageFile.PyDecoder):
raise OSError(msg) from e raise OSError(msg) from e
return -1, 0 return -1, 0
def _read_blp_header(self): def _read_blp_header(self) -> None:
assert self.fd is not None
self.fd.seek(4) self.fd.seek(4)
(self._blp_compression,) = struct.unpack("<i", self._safe_read(4)) (self._blp_compression,) = struct.unpack("<i", self._safe_read(4))
@ -303,10 +305,10 @@ class _BLPBaseDecoder(ImageFile.PyDecoder):
self._blp_offsets = struct.unpack("<16I", self._safe_read(16 * 4)) self._blp_offsets = struct.unpack("<16I", self._safe_read(16 * 4))
self._blp_lengths = struct.unpack("<16I", self._safe_read(16 * 4)) self._blp_lengths = struct.unpack("<16I", self._safe_read(16 * 4))
def _safe_read(self, length): def _safe_read(self, length: int) -> bytes:
return ImageFile._safe_read(self.fd, length) return ImageFile._safe_read(self.fd, length)
def _read_palette(self): def _read_palette(self) -> list[tuple[int, int, int, int]]:
ret = [] ret = []
for i in range(256): for i in range(256):
try: try:
@ -333,7 +335,7 @@ class _BLPBaseDecoder(ImageFile.PyDecoder):
class BLP1Decoder(_BLPBaseDecoder): class BLP1Decoder(_BLPBaseDecoder):
def _load(self): def _load(self) -> None:
if self._blp_compression == Format.JPEG: if self._blp_compression == Format.JPEG:
self._decode_jpeg_stream() self._decode_jpeg_stream()
@ -349,29 +351,30 @@ class BLP1Decoder(_BLPBaseDecoder):
msg = f"Unsupported BLP compression {repr(self._blp_encoding)}" msg = f"Unsupported BLP compression {repr(self._blp_encoding)}"
raise BLPFormatError(msg) raise BLPFormatError(msg)
def _decode_jpeg_stream(self): def _decode_jpeg_stream(self) -> None:
from .JpegImagePlugin import JpegImageFile from .JpegImagePlugin import JpegImageFile
(jpeg_header_size,) = struct.unpack("<I", self._safe_read(4)) (jpeg_header_size,) = struct.unpack("<I", self._safe_read(4))
jpeg_header = self._safe_read(jpeg_header_size) jpeg_header = self._safe_read(jpeg_header_size)
assert self.fd is not None
self._safe_read(self._blp_offsets[0] - self.fd.tell()) # What IS this? self._safe_read(self._blp_offsets[0] - self.fd.tell()) # What IS this?
data = self._safe_read(self._blp_lengths[0]) data = self._safe_read(self._blp_lengths[0])
data = jpeg_header + data data = jpeg_header + data
data = BytesIO(data) image = JpegImageFile(BytesIO(data))
image = JpegImageFile(data)
Image._decompression_bomb_check(image.size) Image._decompression_bomb_check(image.size)
if image.mode == "CMYK": if image.mode == "CMYK":
decoder_name, extents, offset, args = image.tile[0] decoder_name, extents, offset, args = image.tile[0]
image.tile = [(decoder_name, extents, offset, (args[0], "CMYK"))] image.tile = [(decoder_name, extents, offset, (args[0], "CMYK"))]
r, g, b = image.convert("RGB").split() r, g, b = image.convert("RGB").split()
image = Image.merge("RGB", (b, g, r)) reversed_image = Image.merge("RGB", (b, g, r))
self.set_as_raw(image.tobytes()) self.set_as_raw(reversed_image.tobytes())
class BLP2Decoder(_BLPBaseDecoder): class BLP2Decoder(_BLPBaseDecoder):
def _load(self): def _load(self) -> None:
palette = self._read_palette() palette = self._read_palette()
assert self.fd is not None
self.fd.seek(self._blp_offsets[0]) self.fd.seek(self._blp_offsets[0])
if self._blp_compression == 1: if self._blp_compression == 1:
@ -418,7 +421,7 @@ class BLP2Decoder(_BLPBaseDecoder):
class BLPEncoder(ImageFile.PyEncoder): class BLPEncoder(ImageFile.PyEncoder):
_pushes_fd = True _pushes_fd = True
def _write_palette(self): def _write_palette(self) -> bytes:
data = b"" data = b""
palette = self.im.getpalette("RGBA", "RGBA") palette = self.im.getpalette("RGBA", "RGBA")
for i in range(len(palette) // 4): for i in range(len(palette) // 4):
@ -446,7 +449,7 @@ class BLPEncoder(ImageFile.PyEncoder):
return len(data), 0, data return len(data), 0, data
def _save(im, fp, filename): def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None:
if im.mode != "P": if im.mode != "P":
msg = "Unsupported BLP image mode" msg = "Unsupported BLP image mode"
raise ValueError(msg) raise ValueError(msg)

View File

@ -25,6 +25,7 @@
from __future__ import annotations from __future__ import annotations
import os import os
from typing import IO
from . import Image, ImageFile, ImagePalette from . import Image, ImageFile, ImagePalette
from ._binary import i16le as i16 from ._binary import i16le as i16
@ -52,7 +53,7 @@ def _accept(prefix: bytes) -> bool:
return prefix[:2] == b"BM" return prefix[:2] == b"BM"
def _dib_accept(prefix): def _dib_accept(prefix: bytes) -> bool:
return i32(prefix) in [12, 40, 52, 56, 64, 108, 124] return i32(prefix) in [12, 40, 52, 56, 64, 108, 124]
@ -283,7 +284,7 @@ class BmpImageFile(ImageFile.ImageFile):
) )
] ]
def _open(self): def _open(self) -> None:
"""Open file, check magic number and read header""" """Open file, check magic number and read header"""
# read 14 bytes: magic number, filesize, reserved, header final offset # read 14 bytes: magic number, filesize, reserved, header final offset
head_data = self.fp.read(14) head_data = self.fp.read(14)
@ -376,7 +377,7 @@ class DibImageFile(BmpImageFile):
format = "DIB" format = "DIB"
format_description = "Windows Bitmap" format_description = "Windows Bitmap"
def _open(self): def _open(self) -> None:
self._bitmap() self._bitmap()
@ -394,11 +395,13 @@ SAVE = {
} }
def _dib_save(im, fp, filename): def _dib_save(im: Image.Image, fp: IO[bytes], filename: str) -> None:
_save(im, fp, filename, False) _save(im, fp, filename, False)
def _save(im, fp, filename, bitmap_header=True): def _save(
im: Image.Image, fp: IO[bytes], filename: str, bitmap_header: bool = True
) -> None:
try: try:
rawmode, bits, colors = SAVE[im.mode] rawmode, bits, colors = SAVE[im.mode]
except KeyError as e: except KeyError as e:

View File

@ -10,12 +10,14 @@
# #
from __future__ import annotations from __future__ import annotations
from typing import IO
from . import Image, ImageFile from . import Image, ImageFile
_handler = None _handler = None
def register_handler(handler): def register_handler(handler: ImageFile.StubHandler) -> None:
""" """
Install application-specific BUFR image handler. Install application-specific BUFR image handler.
@ -37,7 +39,7 @@ class BufrStubImageFile(ImageFile.StubImageFile):
format = "BUFR" format = "BUFR"
format_description = "BUFR" format_description = "BUFR"
def _open(self): def _open(self) -> None:
offset = self.fp.tell() offset = self.fp.tell()
if not _accept(self.fp.read(4)): if not _accept(self.fp.read(4)):
@ -54,11 +56,11 @@ class BufrStubImageFile(ImageFile.StubImageFile):
if loader: if loader:
loader.open(self) loader.open(self)
def _load(self): def _load(self) -> ImageFile.StubHandler | None:
return _handler return _handler
def _save(im, fp, filename): def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None:
if _handler is None or not hasattr(_handler, "save"): if _handler is None or not hasattr(_handler, "save"):
msg = "BUFR save handler not installed" msg = "BUFR save handler not installed"
raise OSError(msg) raise OSError(msg)

View File

@ -37,7 +37,7 @@ class CurImageFile(BmpImagePlugin.BmpImageFile):
format = "CUR" format = "CUR"
format_description = "Windows Cursor" format_description = "Windows Cursor"
def _open(self): def _open(self) -> None:
offset = self.fp.tell() offset = self.fp.tell()
# check magic # check magic

View File

@ -42,7 +42,7 @@ class DcxImageFile(PcxImageFile):
format_description = "Intel DCX" format_description = "Intel DCX"
_close_exclusive_fp_after_loading = False _close_exclusive_fp_after_loading = False
def _open(self): def _open(self) -> None:
# Header # Header
s = self.fp.read(4) s = self.fp.read(4)
if not _accept(s): if not _accept(s):
@ -58,12 +58,12 @@ class DcxImageFile(PcxImageFile):
self._offset.append(offset) self._offset.append(offset)
self._fp = self.fp self._fp = self.fp
self.frame = None self.frame = -1
self.n_frames = len(self._offset) self.n_frames = len(self._offset)
self.is_animated = self.n_frames > 1 self.is_animated = self.n_frames > 1
self.seek(0) self.seek(0)
def seek(self, frame): def seek(self, frame: int) -> None:
if not self._seek_check(frame): if not self._seek_check(frame):
return return
self.frame = frame self.frame = frame
@ -71,7 +71,7 @@ class DcxImageFile(PcxImageFile):
self.fp.seek(self._offset[frame]) self.fp.seek(self._offset[frame])
PcxImageFile._open(self) PcxImageFile._open(self)
def tell(self): def tell(self) -> int:
return self.frame return self.frame

View File

@ -16,6 +16,7 @@ import io
import struct import struct
import sys import sys
from enum import IntEnum, IntFlag from enum import IntEnum, IntFlag
from typing import IO
from . import Image, ImageFile, ImagePalette from . import Image, ImageFile, ImagePalette
from ._binary import i32le as i32 from ._binary import i32le as i32
@ -271,16 +272,16 @@ class D3DFMT(IntEnum):
module = sys.modules[__name__] module = sys.modules[__name__]
for item in DDSD: for item in DDSD:
assert item.name is not None assert item.name is not None
setattr(module, "DDSD_" + item.name, item.value) setattr(module, f"DDSD_{item.name}", item.value)
for item1 in DDSCAPS: for item1 in DDSCAPS:
assert item1.name is not None assert item1.name is not None
setattr(module, "DDSCAPS_" + item1.name, item1.value) setattr(module, f"DDSCAPS_{item1.name}", item1.value)
for item2 in DDSCAPS2: for item2 in DDSCAPS2:
assert item2.name is not None assert item2.name is not None
setattr(module, "DDSCAPS2_" + item2.name, item2.value) setattr(module, f"DDSCAPS2_{item2.name}", item2.value)
for item3 in DDPF: for item3 in DDPF:
assert item3.name is not None assert item3.name is not None
setattr(module, "DDPF_" + item3.name, item3.value) setattr(module, f"DDPF_{item3.name}", item3.value)
DDS_FOURCC = DDPF.FOURCC DDS_FOURCC = DDPF.FOURCC
DDS_RGB = DDPF.RGB DDS_RGB = DDPF.RGB
@ -331,7 +332,7 @@ class DdsImageFile(ImageFile.ImageFile):
format = "DDS" format = "DDS"
format_description = "DirectDraw Surface" format_description = "DirectDraw Surface"
def _open(self): def _open(self) -> None:
if not _accept(self.fp.read(4)): if not _accept(self.fp.read(4)):
msg = "not a DDS file" msg = "not a DDS file"
raise SyntaxError(msg) raise SyntaxError(msg)
@ -472,7 +473,7 @@ class DdsImageFile(ImageFile.ImageFile):
else: else:
self.tile = [ImageFile._Tile("raw", extents, 0, rawmode or self.mode)] self.tile = [ImageFile._Tile("raw", extents, 0, rawmode or self.mode)]
def load_seek(self, pos): def load_seek(self, pos: int) -> None:
pass pass
@ -510,7 +511,7 @@ class DdsRgbDecoder(ImageFile.PyDecoder):
return -1, 0 return -1, 0
def _save(im, fp, filename): def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None:
if im.mode not in ("RGB", "RGBA", "L", "LA"): if im.mode not in ("RGB", "RGBA", "L", "LA"):
msg = f"cannot write mode {im.mode} as DDS" msg = f"cannot write mode {im.mode} as DDS"
raise OSError(msg) raise OSError(msg)

View File

@ -42,7 +42,7 @@ gs_binary: str | bool | None = None
gs_windows_binary = None gs_windows_binary = None
def has_ghostscript(): def has_ghostscript() -> bool:
global gs_binary, gs_windows_binary global gs_binary, gs_windows_binary
if gs_binary is None: if gs_binary is None:
if sys.platform.startswith("win"): if sys.platform.startswith("win"):
@ -178,7 +178,7 @@ class PSFile:
self.char = None self.char = None
self.fp.seek(offset, whence) self.fp.seek(offset, whence)
def readline(self): def readline(self) -> str:
s = [self.char or b""] s = [self.char or b""]
self.char = None self.char = None
@ -212,7 +212,7 @@ class EpsImageFile(ImageFile.ImageFile):
mode_map = {1: "L", 2: "LAB", 3: "RGB", 4: "CMYK"} mode_map = {1: "L", 2: "LAB", 3: "RGB", 4: "CMYK"}
def _open(self): def _open(self) -> None:
(length, offset) = self._find_offset(self.fp) (length, offset) = self._find_offset(self.fp)
# go to offset - start of "%!PS" # go to offset - start of "%!PS"
@ -228,7 +228,7 @@ class EpsImageFile(ImageFile.ImageFile):
reading_trailer_comments = False reading_trailer_comments = False
trailer_reached = False trailer_reached = False
def check_required_header_comments(): def check_required_header_comments() -> None:
if "PS-Adobe" not in self.info: if "PS-Adobe" not in self.info:
msg = 'EPS header missing "%!PS-Adobe" comment' msg = 'EPS header missing "%!PS-Adobe" comment'
raise SyntaxError(msg) raise SyntaxError(msg)
@ -404,7 +404,7 @@ class EpsImageFile(ImageFile.ImageFile):
self.tile = [] self.tile = []
return Image.Image.load(self) return Image.Image.load(self)
def load_seek(self, pos): def load_seek(self, pos: int) -> None:
# we can't incrementally load, so force ImageFile.parser to # we can't incrementally load, so force ImageFile.parser to
# use our custom load method by defining this method. # use our custom load method by defining this method.
pass pass

View File

@ -346,7 +346,7 @@ class Interop(IntEnum):
InteropVersion = 2 InteropVersion = 2
RelatedImageFileFormat = 4096 RelatedImageFileFormat = 4096
RelatedImageWidth = 4097 RelatedImageWidth = 4097
RleatedImageHeight = 4098 RelatedImageHeight = 4098
class IFD(IntEnum): class IFD(IntEnum):

View File

@ -123,7 +123,7 @@ class FliImageFile(ImageFile.ImageFile):
palette[i] = (r, g, b) palette[i] = (r, g, b)
i += 1 i += 1
def seek(self, frame): def seek(self, frame: int) -> None:
if not self._seek_check(frame): if not self._seek_check(frame):
return return
if frame < self.__frame: if frame < self.__frame:
@ -132,7 +132,7 @@ class FliImageFile(ImageFile.ImageFile):
for f in range(self.__frame + 1, frame + 1): for f in range(self.__frame + 1, frame + 1):
self._seek(f) self._seek(f)
def _seek(self, frame): def _seek(self, frame: int) -> None:
if frame == 0: if frame == 0:
self.__frame = -1 self.__frame = -1
self._fp.seek(self.__rewind) self._fp.seek(self.__rewind)
@ -162,7 +162,7 @@ class FliImageFile(ImageFile.ImageFile):
self.__offset += framesize self.__offset += framesize
def tell(self): def tell(self) -> int:
return self.__frame return self.__frame

View File

@ -70,7 +70,7 @@ class FpxImageFile(ImageFile.ImageFile):
self._open_index(1) self._open_index(1)
def _open_index(self, index=1): def _open_index(self, index: int = 1) -> None:
# #
# get the Image Contents Property Set # get the Image Contents Property Set
@ -85,7 +85,7 @@ class FpxImageFile(ImageFile.ImageFile):
size = max(self.size) size = max(self.size)
i = 1 i = 1
while size > 64: while size > 64:
size = size / 2 size = size // 2
i += 1 i += 1
self.maxid = i - 1 self.maxid = i - 1
@ -118,7 +118,7 @@ class FpxImageFile(ImageFile.ImageFile):
self._open_subimage(1, self.maxid) self._open_subimage(1, self.maxid)
def _open_subimage(self, index=1, subimage=0): def _open_subimage(self, index: int = 1, subimage: int = 0) -> None:
# #
# setup tile descriptors for a given subimage # setup tile descriptors for a given subimage
@ -237,7 +237,7 @@ class FpxImageFile(ImageFile.ImageFile):
return ImageFile.ImageFile.load(self) return ImageFile.ImageFile.load(self)
def close(self): def close(self) -> None:
self.ole.close() self.ole.close()
super().close() super().close()

View File

@ -71,7 +71,7 @@ class FtexImageFile(ImageFile.ImageFile):
format = "FTEX" format = "FTEX"
format_description = "Texture File Format (IW2:EOC)" format_description = "Texture File Format (IW2:EOC)"
def _open(self): def _open(self) -> None:
if not _accept(self.fp.read(4)): if not _accept(self.fp.read(4)):
msg = "not an FTEX file" msg = "not an FTEX file"
raise SyntaxError(msg) raise SyntaxError(msg)
@ -103,7 +103,7 @@ class FtexImageFile(ImageFile.ImageFile):
self.fp.close() self.fp.close()
self.fp = BytesIO(data) self.fp = BytesIO(data)
def load_seek(self, pos): def load_seek(self, pos: int) -> None:
pass pass

View File

@ -41,7 +41,7 @@ class GbrImageFile(ImageFile.ImageFile):
format = "GBR" format = "GBR"
format_description = "GIMP brush file" format_description = "GIMP brush file"
def _open(self): def _open(self) -> None:
header_size = i32(self.fp.read(4)) header_size = i32(self.fp.read(4))
if header_size < 20: if header_size < 20:
msg = "not a GIMP brush" msg = "not a GIMP brush"

View File

@ -30,6 +30,8 @@ import math
import os import os
import subprocess import subprocess
from enum import IntEnum from enum import IntEnum
from functools import cached_property
from typing import IO
from . import ( from . import (
Image, Image,
@ -76,19 +78,19 @@ class GifImageFile(ImageFile.ImageFile):
global_palette = None global_palette = None
def data(self): def data(self) -> bytes | None:
s = self.fp.read(1) s = self.fp.read(1)
if s and s[0]: if s and s[0]:
return self.fp.read(s[0]) return self.fp.read(s[0])
return None return None
def _is_palette_needed(self, p): def _is_palette_needed(self, p: bytes) -> bool:
for i in range(0, len(p), 3): for i in range(0, len(p), 3):
if not (i // 3 == p[i] == p[i + 1] == p[i + 2]): if not (i // 3 == p[i] == p[i + 1] == p[i + 2]):
return True return True
return False return False
def _open(self): def _open(self) -> None:
# Screen # Screen
s = self.fp.read(13) s = self.fp.read(13)
if not _accept(s): if not _accept(s):
@ -112,8 +114,7 @@ class GifImageFile(ImageFile.ImageFile):
self._fp = self.fp # FIXME: hack self._fp = self.fp # FIXME: hack
self.__rewind = self.fp.tell() self.__rewind = self.fp.tell()
self._n_frames = None self._n_frames: int | None = None
self._is_animated = None
self._seek(0) # get ready to read first frame self._seek(0) # get ready to read first frame
@property @property
@ -128,26 +129,25 @@ class GifImageFile(ImageFile.ImageFile):
self.seek(current) self.seek(current)
return self._n_frames return self._n_frames
@property @cached_property
def is_animated(self): def is_animated(self) -> bool:
if self._is_animated is None: if self._n_frames is not None:
if self._n_frames is not None: return self._n_frames != 1
self._is_animated = self._n_frames != 1
else:
current = self.tell()
if current:
self._is_animated = True
else:
try:
self._seek(1, False)
self._is_animated = True
except EOFError:
self._is_animated = False
self.seek(current) current = self.tell()
return self._is_animated if current:
return True
def seek(self, frame): try:
self._seek(1, False)
is_animated = True
except EOFError:
is_animated = False
self.seek(current)
return is_animated
def seek(self, frame: int) -> None:
if not self._seek_check(frame): if not self._seek_check(frame):
return return
if frame < self.__frame: if frame < self.__frame:
@ -337,14 +337,13 @@ class GifImageFile(ImageFile.ImageFile):
self._mode = "RGB" self._mode = "RGB"
self.im = self.im.convert("RGB", Image.Dither.FLOYDSTEINBERG) self.im = self.im.convert("RGB", Image.Dither.FLOYDSTEINBERG)
def _rgb(color): def _rgb(color: int) -> tuple[int, int, int]:
if self._frame_palette: if self._frame_palette:
if color * 3 + 3 > len(self._frame_palette.palette): if color * 3 + 3 > len(self._frame_palette.palette):
color = 0 color = 0
color = tuple(self._frame_palette.palette[color * 3 : color * 3 + 3]) return tuple(self._frame_palette.palette[color * 3 : color * 3 + 3])
else: else:
color = (color, color, color) return (color, color, color)
return color
self.dispose_extent = frame_dispose_extent self.dispose_extent = frame_dispose_extent
try: try:
@ -417,7 +416,7 @@ class GifImageFile(ImageFile.ImageFile):
elif k in self.info: elif k in self.info:
del self.info[k] del self.info[k]
def load_prepare(self): def load_prepare(self) -> None:
temp_mode = "P" if self._frame_palette else "L" temp_mode = "P" if self._frame_palette else "L"
self._prev_im = None self._prev_im = None
if self.__frame == 0: if self.__frame == 0:
@ -437,7 +436,7 @@ class GifImageFile(ImageFile.ImageFile):
super().load_prepare() super().load_prepare()
def load_end(self): def load_end(self) -> None:
if self.__frame == 0: if self.__frame == 0:
if self.mode == "P" and LOADING_STRATEGY == LoadingStrategy.RGB_ALWAYS: if self.mode == "P" and LOADING_STRATEGY == LoadingStrategy.RGB_ALWAYS:
if self._frame_transparency is not None: if self._frame_transparency is not None:
@ -463,7 +462,7 @@ class GifImageFile(ImageFile.ImageFile):
else: else:
self.im.paste(frame_im, self.dispose_extent) self.im.paste(frame_im, self.dispose_extent)
def tell(self): def tell(self) -> int:
return self.__frame return self.__frame
@ -474,7 +473,7 @@ class GifImageFile(ImageFile.ImageFile):
RAWMODE = {"1": "L", "L": "L", "P": "P"} RAWMODE = {"1": "L", "L": "L", "P": "P"}
def _normalize_mode(im): def _normalize_mode(im: Image.Image) -> Image.Image:
""" """
Takes an image (or frame), returns an image in a mode that is appropriate Takes an image (or frame), returns an image in a mode that is appropriate
for saving in a Gif. for saving in a Gif.
@ -710,11 +709,13 @@ def _write_multiple_frames(im, fp, palette):
return True return True
def _save_all(im, fp, filename): def _save_all(im: Image.Image, fp: IO[bytes], filename: str) -> None:
_save(im, fp, filename, save_all=True) _save(im, fp, filename, save_all=True)
def _save(im, fp, filename, save_all=False): def _save(
im: Image.Image, fp: IO[bytes], filename: str, save_all: bool = False
) -> None:
# header # header
if "palette" in im.encoderinfo or "palette" in im.info: if "palette" in im.encoderinfo or "palette" in im.info:
palette = im.encoderinfo.get("palette", im.info.get("palette")) palette = im.encoderinfo.get("palette", im.info.get("palette"))
@ -731,7 +732,7 @@ def _save(im, fp, filename, save_all=False):
fp.flush() fp.flush()
def get_interlace(im): def get_interlace(im: Image.Image) -> int:
interlace = im.encoderinfo.get("interlace", 1) interlace = im.encoderinfo.get("interlace", 1)
# workaround for @PIL153 # workaround for @PIL153
@ -887,7 +888,7 @@ def _get_optimize(im, info):
return used_palette_colors return used_palette_colors
def _get_color_table_size(palette_bytes): def _get_color_table_size(palette_bytes: bytes) -> int:
# calculate the palette size for the header # calculate the palette size for the header
if not palette_bytes: if not palette_bytes:
return 0 return 0
@ -897,7 +898,7 @@ def _get_color_table_size(palette_bytes):
return math.ceil(math.log(len(palette_bytes) // 3, 2)) - 1 return math.ceil(math.log(len(palette_bytes) // 3, 2)) - 1
def _get_header_palette(palette_bytes): def _get_header_palette(palette_bytes: bytes) -> bytes:
""" """
Returns the palette, null padded to the next power of 2 (*3) bytes Returns the palette, null padded to the next power of 2 (*3) bytes
suitable for direct inclusion in the GIF header suitable for direct inclusion in the GIF header
@ -915,7 +916,7 @@ def _get_header_palette(palette_bytes):
return palette_bytes return palette_bytes
def _get_palette_bytes(im): def _get_palette_bytes(im: Image.Image) -> bytes:
""" """
Gets the palette for inclusion in the gif header Gets the palette for inclusion in the gif header

View File

@ -16,6 +16,7 @@
from __future__ import annotations from __future__ import annotations
import re import re
from typing import IO
from ._binary import o8 from ._binary import o8
@ -25,8 +26,8 @@ class GimpPaletteFile:
rawmode = "RGB" rawmode = "RGB"
def __init__(self, fp): def __init__(self, fp: IO[bytes]) -> None:
self.palette = [o8(i) * 3 for i in range(256)] palette = [o8(i) * 3 for i in range(256)]
if fp.readline()[:12] != b"GIMP Palette": if fp.readline()[:12] != b"GIMP Palette":
msg = "not a GIMP palette file" msg = "not a GIMP palette file"
@ -49,9 +50,9 @@ class GimpPaletteFile:
msg = "bad palette entry" msg = "bad palette entry"
raise ValueError(msg) raise ValueError(msg)
self.palette[i] = o8(v[0]) + o8(v[1]) + o8(v[2]) palette[i] = o8(v[0]) + o8(v[1]) + o8(v[2])
self.palette = b"".join(self.palette) self.palette = b"".join(palette)
def getpalette(self): def getpalette(self) -> tuple[bytes, str]:
return self.palette, self.rawmode return self.palette, self.rawmode

View File

@ -10,12 +10,14 @@
# #
from __future__ import annotations from __future__ import annotations
from typing import IO
from . import Image, ImageFile from . import Image, ImageFile
_handler = None _handler = None
def register_handler(handler): def register_handler(handler: ImageFile.StubHandler) -> None:
""" """
Install application-specific GRIB image handler. Install application-specific GRIB image handler.
@ -37,7 +39,7 @@ class GribStubImageFile(ImageFile.StubImageFile):
format = "GRIB" format = "GRIB"
format_description = "GRIB" format_description = "GRIB"
def _open(self): def _open(self) -> None:
offset = self.fp.tell() offset = self.fp.tell()
if not _accept(self.fp.read(8)): if not _accept(self.fp.read(8)):
@ -54,11 +56,11 @@ class GribStubImageFile(ImageFile.StubImageFile):
if loader: if loader:
loader.open(self) loader.open(self)
def _load(self): def _load(self) -> ImageFile.StubHandler | None:
return _handler return _handler
def _save(im, fp, filename): def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None:
if _handler is None or not hasattr(_handler, "save"): if _handler is None or not hasattr(_handler, "save"):
msg = "GRIB save handler not installed" msg = "GRIB save handler not installed"
raise OSError(msg) raise OSError(msg)

View File

@ -10,12 +10,14 @@
# #
from __future__ import annotations from __future__ import annotations
from typing import IO
from . import Image, ImageFile from . import Image, ImageFile
_handler = None _handler = None
def register_handler(handler): def register_handler(handler: ImageFile.StubHandler) -> None:
""" """
Install application-specific HDF5 image handler. Install application-specific HDF5 image handler.
@ -37,7 +39,7 @@ class HDF5StubImageFile(ImageFile.StubImageFile):
format = "HDF5" format = "HDF5"
format_description = "HDF5" format_description = "HDF5"
def _open(self): def _open(self) -> None:
offset = self.fp.tell() offset = self.fp.tell()
if not _accept(self.fp.read(8)): if not _accept(self.fp.read(8)):
@ -54,11 +56,11 @@ class HDF5StubImageFile(ImageFile.StubImageFile):
if loader: if loader:
loader.open(self) loader.open(self)
def _load(self): def _load(self) -> ImageFile.StubHandler | None:
return _handler return _handler
def _save(im, fp, filename): def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None:
if _handler is None or not hasattr(_handler, "save"): if _handler is None or not hasattr(_handler, "save"):
msg = "HDF5 save handler not installed" msg = "HDF5 save handler not installed"
raise OSError(msg) raise OSError(msg)

View File

@ -252,7 +252,7 @@ class IcnsImageFile(ImageFile.ImageFile):
format = "ICNS" format = "ICNS"
format_description = "Mac OS icns resource" format_description = "Mac OS icns resource"
def _open(self): def _open(self) -> None:
self.icns = IcnsFile(self.fp) self.icns = IcnsFile(self.fp)
self._mode = "RGBA" self._mode = "RGBA"
self.info["sizes"] = self.icns.itersizes() self.info["sizes"] = self.icns.itersizes()

View File

@ -25,6 +25,7 @@ from __future__ import annotations
import warnings import warnings
from io import BytesIO from io import BytesIO
from math import ceil, log from math import ceil, log
from typing import IO
from . import BmpImagePlugin, Image, ImageFile, PngImagePlugin from . import BmpImagePlugin, Image, ImageFile, PngImagePlugin
from ._binary import i16le as i16 from ._binary import i16le as i16
@ -39,7 +40,7 @@ from ._binary import o32le as o32
_MAGIC = b"\0\0\1\0" _MAGIC = b"\0\0\1\0"
def _save(im, fp, filename): def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None:
fp.write(_MAGIC) # (2+2) fp.write(_MAGIC) # (2+2)
bmp = im.encoderinfo.get("bitmap_format") == "bmp" bmp = im.encoderinfo.get("bitmap_format") == "bmp"
sizes = im.encoderinfo.get( sizes = im.encoderinfo.get(
@ -194,7 +195,7 @@ class IcoFile:
""" """
return self.frame(self.getentryindex(size, bpp)) return self.frame(self.getentryindex(size, bpp))
def frame(self, idx): def frame(self, idx: int) -> Image.Image:
""" """
Get an image from frame idx Get an image from frame idx
""" """
@ -205,6 +206,7 @@ class IcoFile:
data = self.buf.read(8) data = self.buf.read(8)
self.buf.seek(header["offset"]) self.buf.seek(header["offset"])
im: Image.Image
if data[:8] == PngImagePlugin._MAGIC: if data[:8] == PngImagePlugin._MAGIC:
# png frame # png frame
im = PngImagePlugin.PngImageFile(self.buf) im = PngImagePlugin.PngImageFile(self.buf)
@ -302,7 +304,7 @@ class IcoImageFile(ImageFile.ImageFile):
format = "ICO" format = "ICO"
format_description = "Windows Icon" format_description = "Windows Icon"
def _open(self): def _open(self) -> None:
self.ico = IcoFile(self.fp) self.ico = IcoFile(self.fp)
self.info["sizes"] = self.ico.sizes() self.info["sizes"] = self.ico.sizes()
self.size = self.ico.entry[0]["dim"] self.size = self.ico.entry[0]["dim"]
@ -341,7 +343,7 @@ class IcoImageFile(ImageFile.ImageFile):
self.size = im.size self.size = im.size
def load_seek(self, pos): def load_seek(self, pos: int) -> None:
# Flag the ImageFile.Parser so that it # Flag the ImageFile.Parser so that it
# just does all the decode at the end. # just does all the decode at the end.
pass pass

View File

@ -28,6 +28,7 @@ from __future__ import annotations
import os import os
import re import re
from typing import IO, Any
from . import Image, ImageFile, ImagePalette from . import Image, ImageFile, ImagePalette
@ -103,7 +104,7 @@ for j in range(2, 33):
split = re.compile(rb"^([A-Za-z][^:]*):[ \t]*(.*)[ \t]*$") split = re.compile(rb"^([A-Za-z][^:]*):[ \t]*(.*)[ \t]*$")
def number(s): def number(s: Any) -> float:
try: try:
return int(s) return int(s)
except ValueError: except ValueError:
@ -119,7 +120,7 @@ class ImImageFile(ImageFile.ImageFile):
format_description = "IFUNC Image Memory" format_description = "IFUNC Image Memory"
_close_exclusive_fp_after_loading = False _close_exclusive_fp_after_loading = False
def _open(self): def _open(self) -> None:
# Quick rejection: if there's not an LF among the first # Quick rejection: if there's not an LF among the first
# 100 bytes, this is (probably) not a text header. # 100 bytes, this is (probably) not a text header.
@ -196,7 +197,7 @@ class ImImageFile(ImageFile.ImageFile):
n += 1 n += 1
else: else:
msg = "Syntax error in IM header: " + s.decode("ascii", "replace") msg = f"Syntax error in IM header: {s.decode('ascii', 'replace')}"
raise SyntaxError(msg) raise SyntaxError(msg)
if not n: if not n:
@ -271,14 +272,14 @@ class ImImageFile(ImageFile.ImageFile):
self.tile = [("raw", (0, 0) + self.size, offs, (self.rawmode, 0, -1))] self.tile = [("raw", (0, 0) + self.size, offs, (self.rawmode, 0, -1))]
@property @property
def n_frames(self): def n_frames(self) -> int:
return self.info[FRAMES] return self.info[FRAMES]
@property @property
def is_animated(self): def is_animated(self) -> bool:
return self.info[FRAMES] > 1 return self.info[FRAMES] > 1
def seek(self, frame): def seek(self, frame: int) -> None:
if not self._seek_check(frame): if not self._seek_check(frame):
return return
@ -296,7 +297,7 @@ class ImImageFile(ImageFile.ImageFile):
self.tile = [("raw", (0, 0) + self.size, offs, (self.rawmode, 0, -1))] self.tile = [("raw", (0, 0) + self.size, offs, (self.rawmode, 0, -1))]
def tell(self): def tell(self) -> int:
return self.frame return self.frame
@ -325,7 +326,7 @@ SAVE = {
} }
def _save(im, fp, filename): def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None:
try: try:
image_type, rawmode = SAVE[im.mode] image_type, rawmode = SAVE[im.mode]
except KeyError as e: except KeyError as e:

View File

@ -41,7 +41,7 @@ import warnings
from collections.abc import Callable, MutableMapping from collections.abc import Callable, MutableMapping
from enum import IntEnum from enum import IntEnum
from types import ModuleType from types import ModuleType
from typing import IO, TYPE_CHECKING, Any, Literal, cast from typing import IO, TYPE_CHECKING, Any, Literal, Protocol, Sequence, cast
# VERSION was removed in Pillow 6.0.0. # VERSION was removed in Pillow 6.0.0.
# PILLOW_VERSION was removed in Pillow 9.0.0. # PILLOW_VERSION was removed in Pillow 9.0.0.
@ -55,6 +55,7 @@ from . import (
_plugins, _plugins,
) )
from ._binary import i32le, o32be, o32le from ._binary import i32le, o32be, o32le
from ._deprecate import deprecate
from ._typing import StrOrBytesPath, TypeGuard from ._typing import StrOrBytesPath, TypeGuard
from ._util import DeferredError, is_path from ._util import DeferredError, is_path
@ -248,7 +249,28 @@ def _conv_type_shape(im):
return shape, m.typestr return shape, m.typestr
MODES = ["1", "CMYK", "F", "HSV", "I", "L", "LAB", "P", "RGB", "RGBA", "RGBX", "YCbCr"] MODES = [
"1",
"CMYK",
"F",
"HSV",
"I",
"I;16",
"I;16B",
"I;16L",
"I;16N",
"L",
"LA",
"La",
"LAB",
"P",
"PA",
"RGB",
"RGBA",
"RGBa",
"RGBX",
"YCbCr",
]
# raw modes that may be memory mapped. NOTE: if you change this, you # raw modes that may be memory mapped. NOTE: if you change this, you
# may have to modify the stride calculation in map.c too! # may have to modify the stride calculation in map.c too!
@ -404,7 +426,7 @@ def _getdecoder(mode, decoder_name, args, extra=()):
try: try:
# get decoder # get decoder
decoder = getattr(core, decoder_name + "_decoder") decoder = getattr(core, f"{decoder_name}_decoder")
except AttributeError as e: except AttributeError as e:
msg = f"decoder {decoder_name} not available" msg = f"decoder {decoder_name} not available"
raise OSError(msg) from e raise OSError(msg) from e
@ -427,7 +449,7 @@ def _getencoder(mode, encoder_name, args, extra=()):
try: try:
# get encoder # get encoder
encoder = getattr(core, encoder_name + "_encoder") encoder = getattr(core, f"{encoder_name}_encoder")
except AttributeError as e: except AttributeError as e:
msg = f"encoder {encoder_name} not available" msg = f"encoder {encoder_name} not available"
raise OSError(msg) from e raise OSError(msg) from e
@ -602,7 +624,7 @@ class Image:
) -> str: ) -> str:
suffix = "" suffix = ""
if format: if format:
suffix = "." + format suffix = f".{format}"
if not file: if not file:
f, filename = tempfile.mkstemp(suffix) f, filename = tempfile.mkstemp(suffix)
@ -876,7 +898,7 @@ class Image:
return self.pyaccess return self.pyaccess
return self.im.pixel_access(self.readonly) return self.im.pixel_access(self.readonly)
def verify(self): def verify(self) -> None:
""" """
Verifies the contents of a file. For data read from a file, this Verifies the contents of a file. For data read from a file, this
method attempts to determine if the file is broken, without method attempts to determine if the file is broken, without
@ -939,6 +961,9 @@ class Image:
:returns: An :py:class:`~PIL.Image.Image` object. :returns: An :py:class:`~PIL.Image.Image` object.
""" """
if mode in ("BGR;15", "BGR;16", "BGR;24"):
deprecate(mode, 12)
self.load() self.load()
has_transparency = "transparency" in self.info has_transparency = "transparency" in self.info
@ -1263,7 +1288,9 @@ class Image:
return im.crop((x0, y0, x1, y1)) return im.crop((x0, y0, x1, y1))
def draft(self, mode, size): def draft(
self, mode: str, size: tuple[int, int]
) -> tuple[str, tuple[int, int, float, float]] | None:
""" """
Configures the image file loader so it returns a version of the Configures the image file loader so it returns a version of the
image that as closely as possible matches the given mode and image that as closely as possible matches the given mode and
@ -1286,13 +1313,16 @@ class Image:
""" """
pass pass
def _expand(self, xmargin, ymargin=None): def _expand(self, xmargin: int, ymargin: int | None = None) -> Image:
if ymargin is None: if ymargin is None:
ymargin = xmargin ymargin = xmargin
self.load() self.load()
return self._new(self.im.expand(xmargin, ymargin)) return self._new(self.im.expand(xmargin, ymargin))
def filter(self, filter): if TYPE_CHECKING:
from . import ImageFilter
def filter(self, filter: ImageFilter.Filter | type[ImageFilter.Filter]) -> Image:
""" """
Filters this image using the given filter. For a list of Filters this image using the given filter. For a list of
available filters, see the :py:mod:`~PIL.ImageFilter` module. available filters, see the :py:mod:`~PIL.ImageFilter` module.
@ -1304,7 +1334,7 @@ class Image:
self.load() self.load()
if isinstance(filter, Callable): if callable(filter):
filter = filter() filter = filter()
if not hasattr(filter, "filter"): if not hasattr(filter, "filter"):
msg = "filter argument should be ImageFilter.Filter instance or class" msg = "filter argument should be ImageFilter.Filter instance or class"
@ -1481,7 +1511,7 @@ class Image:
self._exif._loaded = False self._exif._loaded = False
self.getexif() self.getexif()
def get_child_images(self): def get_child_images(self) -> list[ImageFile.ImageFile]:
child_images = [] child_images = []
exif = self.getexif() exif = self.getexif()
ifds = [] ifds = []
@ -1505,10 +1535,7 @@ class Image:
fp = self.fp fp = self.fp
thumbnail_offset = ifd.get(513) thumbnail_offset = ifd.get(513)
if thumbnail_offset is not None: if thumbnail_offset is not None:
try: thumbnail_offset += getattr(self, "_exif_offset", 0)
thumbnail_offset += self._exif_offset
except AttributeError:
pass
self.fp.seek(thumbnail_offset) self.fp.seek(thumbnail_offset)
data = self.fp.read(ifd.get(514)) data = self.fp.read(ifd.get(514))
fp = io.BytesIO(data) fp = io.BytesIO(data)
@ -1574,7 +1601,7 @@ class Image:
or "transparency" in self.info or "transparency" in self.info
) )
def apply_transparency(self): def apply_transparency(self) -> None:
""" """
If a P mode image has a "transparency" key in the info dictionary, If a P mode image has a "transparency" key in the info dictionary,
remove the key and instead apply the transparency to the palette. remove the key and instead apply the transparency to the palette.
@ -1586,6 +1613,7 @@ class Image:
from . import ImagePalette from . import ImagePalette
palette = self.getpalette("RGBA") palette = self.getpalette("RGBA")
assert palette is not None
transparency = self.info["transparency"] transparency = self.info["transparency"]
if isinstance(transparency, bytes): if isinstance(transparency, bytes):
for i, alpha in enumerate(transparency): for i, alpha in enumerate(transparency):
@ -1918,7 +1946,9 @@ class Image:
self.im.putband(alpha.im, band) self.im.putband(alpha.im, band)
def putdata(self, data, scale=1.0, offset=0.0): def putdata(
self, data: Sequence[float], scale: float = 1.0, offset: float = 0.0
) -> None:
""" """
Copies pixel data from a flattened sequence object into the image. The Copies pixel data from a flattened sequence object into the image. The
values should start at the upper left corner (0, 0), continue to the values should start at the upper left corner (0, 0), continue to the
@ -2174,7 +2204,7 @@ class Image:
(Resampling.HAMMING, "Image.Resampling.HAMMING"), (Resampling.HAMMING, "Image.Resampling.HAMMING"),
) )
] ]
msg += " Use " + ", ".join(filters[:-1]) + " or " + filters[-1] msg += f" Use {', '.join(filters[:-1])} or {filters[-1]}"
raise ValueError(msg) raise ValueError(msg)
if reducing_gap is not None and reducing_gap < 1.0: if reducing_gap is not None and reducing_gap < 1.0:
@ -2819,7 +2849,7 @@ class Image:
(Resampling.BICUBIC, "Image.Resampling.BICUBIC"), (Resampling.BICUBIC, "Image.Resampling.BICUBIC"),
) )
] ]
msg += " Use " + ", ".join(filters[:-1]) + " or " + filters[-1] msg += f" Use {', '.join(filters[:-1])} or {filters[-1]}"
raise ValueError(msg) raise ValueError(msg)
image.load() image.load()
@ -2845,7 +2875,7 @@ class Image:
self.load() self.load()
return self._new(self.im.transpose(method)) return self._new(self.im.transpose(method))
def effect_spread(self, distance): def effect_spread(self, distance: int) -> Image:
""" """
Randomly spread pixels in an image. Randomly spread pixels in an image.
@ -2956,6 +2986,9 @@ def new(
:returns: An :py:class:`~PIL.Image.Image` object. :returns: An :py:class:`~PIL.Image.Image` object.
""" """
if mode in ("BGR;15", "BGR;16", "BGR;24"):
deprecate(mode, 12)
_check_size(size) _check_size(size)
if color is None: if color is None:
@ -2979,7 +3012,7 @@ def new(
return im._new(core.fill(mode, size, color)) return im._new(core.fill(mode, size, color))
def frombytes(mode, size, data, decoder_name="raw", *args) -> Image: def frombytes(mode, size, data, decoder_name: str = "raw", *args) -> Image:
""" """
Creates a copy of an image memory from pixel data in a buffer. Creates a copy of an image memory from pixel data in a buffer.
@ -3018,7 +3051,7 @@ def frombytes(mode, size, data, decoder_name="raw", *args) -> Image:
return im return im
def frombuffer(mode, size, data, decoder_name="raw", *args): def frombuffer(mode: str, size, data, decoder_name: str = "raw", *args) -> Image:
""" """
Creates an image memory referencing pixel data in a byte buffer. Creates an image memory referencing pixel data in a byte buffer.
@ -3074,7 +3107,17 @@ def frombuffer(mode, size, data, decoder_name="raw", *args):
return frombytes(mode, size, data, decoder_name, args) return frombytes(mode, size, data, decoder_name, args)
def fromarray(obj, mode=None): class SupportsArrayInterface(Protocol):
"""
An object that has an ``__array_interface__`` dictionary.
"""
@property
def __array_interface__(self) -> dict[str, Any]:
raise NotImplementedError()
def fromarray(obj: SupportsArrayInterface, mode: str | None = None) -> Image:
""" """
Creates an image memory from an object exporting the array interface Creates an image memory from an object exporting the array interface
(using the buffer protocol):: (using the buffer protocol)::
@ -3153,8 +3196,11 @@ def fromarray(obj, mode=None):
if strides is not None: if strides is not None:
if hasattr(obj, "tobytes"): if hasattr(obj, "tobytes"):
obj = obj.tobytes() obj = obj.tobytes()
else: elif hasattr(obj, "tostring"):
obj = obj.tostring() obj = obj.tostring()
else:
msg = "'strides' requires either tobytes() or tostring()"
raise ValueError(msg)
return frombuffer(mode, size, obj, "raw", rawmode, 0, 1) return frombuffer(mode, size, obj, "raw", rawmode, 0, 1)
@ -3201,8 +3247,8 @@ _fromarray_typemap = {
((1, 1, 3), "|u1"): ("RGB", "RGB"), ((1, 1, 3), "|u1"): ("RGB", "RGB"),
((1, 1, 4), "|u1"): ("RGBA", "RGBA"), ((1, 1, 4), "|u1"): ("RGBA", "RGBA"),
# shortcuts: # shortcuts:
((1, 1), _ENDIAN + "i4"): ("I", "I"), ((1, 1), f"{_ENDIAN}i4"): ("I", "I"),
((1, 1), _ENDIAN + "f4"): ("F", "F"), ((1, 1), f"{_ENDIAN}f4"): ("F", "F"),
} }
@ -3430,7 +3476,7 @@ def eval(image, *args):
return image.point(args[0]) return image.point(args[0])
def merge(mode, bands): def merge(mode: str, bands: Sequence[Image]) -> Image:
""" """
Merge a set of single band images into a new multiband image. Merge a set of single band images into a new multiband image.
@ -3507,7 +3553,7 @@ def register_save(id: str, driver) -> None:
SAVE[id.upper()] = driver SAVE[id.upper()] = driver
def register_save_all(id, driver) -> None: def register_save_all(id: str, driver) -> None:
""" """
Registers an image function to save all the frames Registers an image function to save all the frames
of a multiframe format. This function should not be of a multiframe format. This function should not be
@ -3519,7 +3565,7 @@ def register_save_all(id, driver) -> None:
SAVE_ALL[id.upper()] = driver SAVE_ALL[id.upper()] = driver
def register_extension(id, extension) -> None: def register_extension(id: str, extension: str) -> None:
""" """
Registers an image extension. This function should not be Registers an image extension. This function should not be
used in application code. used in application code.
@ -3530,7 +3576,7 @@ def register_extension(id, extension) -> None:
EXTENSION[extension.lower()] = id.upper() EXTENSION[extension.lower()] = id.upper()
def register_extensions(id, extensions) -> None: def register_extensions(id: str, extensions: list[str]) -> None:
""" """
Registers image extensions. This function should not be Registers image extensions. This function should not be
used in application code. used in application code.
@ -3542,7 +3588,7 @@ def register_extensions(id, extensions) -> None:
register_extension(id, extension) register_extension(id, extension)
def registered_extensions(): def registered_extensions() -> dict[str, str]:
""" """
Returns a dictionary containing all file extensions belonging Returns a dictionary containing all file extensions belonging
to registered plugins to registered plugins
@ -3604,7 +3650,7 @@ def effect_mandelbrot(size, extent, quality):
return Image()._new(core.effect_mandelbrot(size, extent, quality)) return Image()._new(core.effect_mandelbrot(size, extent, quality))
def effect_noise(size, sigma): def effect_noise(size: tuple[int, int], sigma: float) -> Image:
""" """
Generate Gaussian noise centered around 128. Generate Gaussian noise centered around 128.
@ -3615,7 +3661,7 @@ def effect_noise(size, sigma):
return Image()._new(core.effect_noise(size, sigma)) return Image()._new(core.effect_noise(size, sigma))
def linear_gradient(mode): def linear_gradient(mode: str) -> Image:
""" """
Generate 256x256 linear gradient from black to white, top to bottom. Generate 256x256 linear gradient from black to white, top to bottom.
@ -3624,7 +3670,7 @@ def linear_gradient(mode):
return Image()._new(core.linear_gradient(mode)) return Image()._new(core.linear_gradient(mode))
def radial_gradient(mode): def radial_gradient(mode: str) -> Image:
""" """
Generate 256x256 radial gradient from black to white, centre to edge. Generate 256x256 radial gradient from black to white, centre to edge.

View File

@ -754,7 +754,7 @@ def applyTransform(
def createProfile( def createProfile(
colorSpace: Literal["LAB", "XYZ", "sRGB"], colorTemp: SupportsFloat = -1 colorSpace: Literal["LAB", "XYZ", "sRGB"], colorTemp: SupportsFloat = 0
) -> core.CmsProfile: ) -> core.CmsProfile:
""" """
(pyCMS) Creates a profile. (pyCMS) Creates a profile.
@ -777,7 +777,7 @@ def createProfile(
:param colorSpace: String, the color space of the profile you wish to :param colorSpace: String, the color space of the profile you wish to
create. create.
Currently only "LAB", "XYZ", and "sRGB" are supported. Currently only "LAB", "XYZ", and "sRGB" are supported.
:param colorTemp: Positive integer for the white point for the profile, in :param colorTemp: Positive number for the white point for the profile, in
degrees Kelvin (i.e. 5000, 6500, 9600, etc.). The default is for D50 degrees Kelvin (i.e. 5000, 6500, 9600, etc.). The default is for D50
illuminant if omitted (5000k). colorTemp is ONLY applied to LAB illuminant if omitted (5000k). colorTemp is ONLY applied to LAB
profiles, and is ignored for XYZ and sRGB. profiles, and is ignored for XYZ and sRGB.
@ -838,8 +838,8 @@ def getProfileName(profile: _CmsProfileCompatible) -> str:
if not (model or manufacturer): if not (model or manufacturer):
return (profile.profile.profile_description or "") + "\n" return (profile.profile.profile_description or "") + "\n"
if not manufacturer or len(model) > 30: # type: ignore[arg-type] if not manufacturer or (model and len(model) > 30):
return model + "\n" # type: ignore[operator] return f"{model}\n"
return f"{model} - {manufacturer}\n" return f"{model} - {manufacturer}\n"
except (AttributeError, OSError, TypeError, ValueError) as v: except (AttributeError, OSError, TypeError, ValueError) as v:
@ -1089,7 +1089,7 @@ def isIntentSupported(
raise PyCMSError(v) from v raise PyCMSError(v) from v
def versions() -> tuple[str, str, str, str]: def versions() -> tuple[str, str | None, str, str]:
""" """
(pyCMS) Fetches versions. (pyCMS) Fetches versions.
""" """

View File

@ -34,7 +34,7 @@ from __future__ import annotations
import math import math
import numbers import numbers
import struct import struct
from typing import Sequence, cast from typing import TYPE_CHECKING, Sequence, cast
from . import Image, ImageColor from . import Image, ImageColor
from ._typing import Coords from ._typing import Coords
@ -92,7 +92,10 @@ class ImageDraw:
self.fontmode = "L" # aliasing is okay for other modes self.fontmode = "L" # aliasing is okay for other modes
self.fill = False self.fill = False
def getfont(self): if TYPE_CHECKING:
from . import ImageFont
def getfont(self) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
""" """
Get the current default font. Get the current default font.
@ -178,6 +181,13 @@ class ImageDraw:
if ink is not None and ink != fill and width != 0: if ink is not None and ink != fill and width != 0:
self.draw.draw_ellipse(xy, ink, 0, width) self.draw.draw_ellipse(xy, ink, 0, width)
def circle(
self, xy: Sequence[float], radius: float, fill=None, outline=None, width=1
) -> None:
"""Draw a circle given center coordinates and a radius."""
ellipse_xy = (xy[0] - radius, xy[1] - radius, xy[0] + radius, xy[1] + radius)
self.ellipse(ellipse_xy, fill, outline, width)
def line(self, xy: Coords, fill=None, width=0, joint=None) -> None: def line(self, xy: Coords, fill=None, width=0, joint=None) -> None:
"""Draw a line, or a connected sequence of line segments.""" """Draw a line, or a connected sequence of line segments."""
ink = self._getink(fill)[0] ink = self._getink(fill)[0]
@ -898,7 +908,13 @@ def getdraw(im=None, hints=None):
return im, handler return im, handler
def floodfill(image: Image.Image, xy, value, border=None, thresh=0) -> None: def floodfill(
image: Image.Image,
xy: tuple[int, int],
value: float | tuple[int, ...],
border: float | tuple[int, ...] | None = None,
thresh: float = 0,
) -> None:
""" """
(experimental) Fills a bounded region with a given color. (experimental) Fills a bounded region with a given color.

View File

@ -23,7 +23,10 @@ from . import Image, ImageFilter, ImageStat
class _Enhance: class _Enhance:
def enhance(self, factor): image: Image.Image
degenerate: Image.Image
def enhance(self, factor: float) -> Image.Image:
""" """
Returns an enhanced image. Returns an enhanced image.
@ -46,7 +49,7 @@ class Color(_Enhance):
the original image. the original image.
""" """
def __init__(self, image): def __init__(self, image: Image.Image) -> None:
self.image = image self.image = image
self.intermediate_mode = "L" self.intermediate_mode = "L"
if "A" in image.getbands(): if "A" in image.getbands():
@ -63,7 +66,7 @@ class Contrast(_Enhance):
gives a solid gray image. A factor of 1.0 gives the original image. gives a solid gray image. A factor of 1.0 gives the original image.
""" """
def __init__(self, image): def __init__(self, image: Image.Image) -> None:
self.image = image self.image = image
mean = int(ImageStat.Stat(image.convert("L")).mean[0] + 0.5) mean = int(ImageStat.Stat(image.convert("L")).mean[0] + 0.5)
self.degenerate = Image.new("L", image.size, mean).convert(image.mode) self.degenerate = Image.new("L", image.size, mean).convert(image.mode)
@ -80,7 +83,7 @@ class Brightness(_Enhance):
original image. original image.
""" """
def __init__(self, image): def __init__(self, image: Image.Image) -> None:
self.image = image self.image = image
self.degenerate = Image.new(image.mode, image.size, 0) self.degenerate = Image.new(image.mode, image.size, 0)
@ -96,7 +99,7 @@ class Sharpness(_Enhance):
original image, and a factor of 2.0 gives a sharpened image. original image, and a factor of 2.0 gives a sharpened image.
""" """
def __init__(self, image): def __init__(self, image: Image.Image) -> None:
self.image = image self.image = image
self.degenerate = image.filter(ImageFilter.SMOOTH) self.degenerate = image.filter(ImageFilter.SMOOTH)

View File

@ -28,6 +28,7 @@
# #
from __future__ import annotations from __future__ import annotations
import abc
import io import io
import itertools import itertools
import struct import struct
@ -163,7 +164,7 @@ class ImageFile(Image.Image):
self.tile = [] self.tile = []
super().__setstate__(state) super().__setstate__(state)
def verify(self): def verify(self) -> None:
"""Check file integrity""" """Check file integrity"""
# raise exception if something's wrong. must be called # raise exception if something's wrong. must be called
@ -311,7 +312,7 @@ class ImageFile(Image.Image):
return Image.Image.load(self) return Image.Image.load(self)
def load_prepare(self): def load_prepare(self) -> None:
# create image memory if necessary # create image memory if necessary
if not self.im or self.im.mode != self.mode or self.im.size != self.size: if not self.im or self.im.mode != self.mode or self.im.size != self.size:
self.im = Image.core.new(self.mode, self.size) self.im = Image.core.new(self.mode, self.size)
@ -319,16 +320,16 @@ class ImageFile(Image.Image):
if self.mode == "P": if self.mode == "P":
Image.Image.load(self) Image.Image.load(self)
def load_end(self): def load_end(self) -> None:
# may be overridden # may be overridden
pass pass
# may be defined for contained formats # may be defined for contained formats
# def load_seek(self, pos): # def load_seek(self, pos: int) -> None:
# pass # pass
# may be defined for blocked formats (e.g. PNG) # may be defined for blocked formats (e.g. PNG)
# def load_read(self, read_bytes): # def load_read(self, read_bytes: int) -> bytes:
# pass # pass
def _seek_check(self, frame): def _seek_check(self, frame):
@ -347,6 +348,15 @@ class ImageFile(Image.Image):
return self.tell() != frame return self.tell() != frame
class StubHandler:
def open(self, im: StubImageFile) -> None:
pass
@abc.abstractmethod
def load(self, im: StubImageFile) -> Image.Image:
pass
class StubImageFile(ImageFile): class StubImageFile(ImageFile):
""" """
Base class for stub image loaders. Base class for stub image loaders.
@ -390,7 +400,7 @@ class Parser:
offset = 0 offset = 0
finished = 0 finished = 0
def reset(self): def reset(self) -> None:
""" """
(Consumer) Reset the parser. Note that you can only call this (Consumer) Reset the parser. Note that you can only call this
method immediately after you've created a parser; parser method immediately after you've created a parser; parser
@ -605,7 +615,7 @@ def _safe_read(fp, size):
class PyCodecState: class PyCodecState:
def __init__(self): def __init__(self) -> None:
self.xsize = 0 self.xsize = 0
self.ysize = 0 self.ysize = 0
self.xoff = 0 self.xoff = 0
@ -634,7 +644,7 @@ class PyCodec:
""" """
self.args = args self.args = args
def cleanup(self): def cleanup(self) -> None:
""" """
Override to perform codec specific cleanup Override to perform codec specific cleanup

View File

@ -16,11 +16,16 @@
# #
from __future__ import annotations from __future__ import annotations
import abc
import functools import functools
from types import ModuleType
from typing import Any, Sequence
class Filter: class Filter:
pass @abc.abstractmethod
def filter(self, image):
pass
class MultibandFilter(Filter): class MultibandFilter(Filter):
@ -53,7 +58,13 @@ class Kernel(BuiltinFilter):
name = "Kernel" name = "Kernel"
def __init__(self, size, kernel, scale=None, offset=0): def __init__(
self,
size: tuple[int, int],
kernel: Sequence[float],
scale: float | None = None,
offset: float = 0,
) -> None:
if scale is None: if scale is None:
# default scale is sum of kernel # default scale is sum of kernel
scale = functools.reduce(lambda a, b: a + b, kernel) scale = functools.reduce(lambda a, b: a + b, kernel)
@ -76,7 +87,7 @@ class RankFilter(Filter):
name = "Rank" name = "Rank"
def __init__(self, size, rank): def __init__(self, size: int, rank: int) -> None:
self.size = size self.size = size
self.rank = rank self.rank = rank
@ -98,7 +109,7 @@ class MedianFilter(RankFilter):
name = "Median" name = "Median"
def __init__(self, size=3): def __init__(self, size: int = 3) -> None:
self.size = size self.size = size
self.rank = size * size // 2 self.rank = size * size // 2
@ -113,7 +124,7 @@ class MinFilter(RankFilter):
name = "Min" name = "Min"
def __init__(self, size=3): def __init__(self, size: int = 3) -> None:
self.size = size self.size = size
self.rank = 0 self.rank = 0
@ -128,7 +139,7 @@ class MaxFilter(RankFilter):
name = "Max" name = "Max"
def __init__(self, size=3): def __init__(self, size: int = 3) -> None:
self.size = size self.size = size
self.rank = size * size - 1 self.rank = size * size - 1
@ -144,7 +155,7 @@ class ModeFilter(Filter):
name = "Mode" name = "Mode"
def __init__(self, size=3): def __init__(self, size: int = 3) -> None:
self.size = size self.size = size
def filter(self, image): def filter(self, image):
@ -162,7 +173,7 @@ class GaussianBlur(MultibandFilter):
name = "GaussianBlur" name = "GaussianBlur"
def __init__(self, radius=2): def __init__(self, radius: float | Sequence[float] = 2) -> None:
self.radius = radius self.radius = radius
def filter(self, image): def filter(self, image):
@ -190,10 +201,8 @@ class BoxBlur(MultibandFilter):
name = "BoxBlur" name = "BoxBlur"
def __init__(self, radius): def __init__(self, radius: float | Sequence[float]) -> None:
xy = radius xy = radius if isinstance(radius, (tuple, list)) else (radius, radius)
if not isinstance(xy, (tuple, list)):
xy = (xy, xy)
if xy[0] < 0 or xy[1] < 0: if xy[0] < 0 or xy[1] < 0:
msg = "radius must be >= 0" msg = "radius must be >= 0"
raise ValueError(msg) raise ValueError(msg)
@ -225,7 +234,9 @@ class UnsharpMask(MultibandFilter):
name = "UnsharpMask" name = "UnsharpMask"
def __init__(self, radius=2, percent=150, threshold=3): def __init__(
self, radius: float = 2, percent: int = 150, threshold: int = 3
) -> None:
self.radius = radius self.radius = radius
self.percent = percent self.percent = percent
self.threshold = threshold self.threshold = threshold
@ -375,7 +386,9 @@ class Color3DLUT(MultibandFilter):
name = "Color 3D LUT" name = "Color 3D LUT"
def __init__(self, size, table, channels=3, target_mode=None, **kwargs): def __init__(
self, size, table, channels: int = 3, target_mode: str | None = None, **kwargs
):
if channels not in (3, 4): if channels not in (3, 4):
msg = "Only 3 or 4 output channels are supported" msg = "Only 3 or 4 output channels are supported"
raise ValueError(msg) raise ValueError(msg)
@ -389,7 +402,7 @@ class Color3DLUT(MultibandFilter):
items = size[0] * size[1] * size[2] items = size[0] * size[1] * size[2]
wrong_size = False wrong_size = False
numpy = None numpy: ModuleType | None = None
if hasattr(table, "shape"): if hasattr(table, "shape"):
try: try:
import numpy import numpy
@ -436,7 +449,7 @@ class Color3DLUT(MultibandFilter):
self.table = table self.table = table
@staticmethod @staticmethod
def _check_size(size): def _check_size(size: Any) -> list[int]:
try: try:
_, _, _ = size _, _, _ = size
except ValueError as e: except ValueError as e:
@ -541,7 +554,7 @@ class Color3DLUT(MultibandFilter):
_copy_table=False, _copy_table=False,
) )
def __repr__(self): def __repr__(self) -> str:
r = [ r = [
f"{self.__class__.__name__} from {self.table.__class__.__name__}", f"{self.__class__.__name__} from {self.table.__class__.__name__}",
"size={:d}x{:d}x{:d}".format(*self.size), "size={:d}x{:d}x{:d}".format(*self.size),

View File

@ -160,10 +160,6 @@ class ImageFont:
.. versionadded:: 9.2.0 .. versionadded:: 9.2.0
:param text: Text to render. :param text: Text to render.
:param mode: Used by some graphics drivers to indicate what mode the
driver prefers; if empty, the renderer may return either
mode. Note that the mode is always a string, to simplify
C-level implementations.
:return: ``(left, top, right, bottom)`` bounding box :return: ``(left, top, right, bottom)`` bounding box
""" """
@ -261,7 +257,7 @@ class FreeTypeFont:
""" """
return self.font.family, self.font.style return self.font.family, self.font.style
def getmetrics(self): def getmetrics(self) -> tuple[int, int]:
""" """
:return: A tuple of the font ascent (the distance from the baseline to :return: A tuple of the font ascent (the distance from the baseline to
the highest outline point) and descent (the distance from the the highest outline point) and descent (the distance from the
@ -628,7 +624,7 @@ class FreeTypeFont:
layout_engine=layout_engine or self.layout_engine, layout_engine=layout_engine or self.layout_engine,
) )
def get_variation_names(self): def get_variation_names(self) -> list[bytes]:
""" """
:returns: A list of the named styles in a variation font. :returns: A list of the named styles in a variation font.
:exception OSError: If the font is not a variation font. :exception OSError: If the font is not a variation font.

View File

@ -61,7 +61,7 @@ class _Operand:
out = Image.new(mode or im_1.mode, im_1.size, None) out = Image.new(mode or im_1.mode, im_1.size, None)
im_1.load() im_1.load()
try: try:
op = getattr(_imagingmath, op + "_" + im_1.mode) op = getattr(_imagingmath, f"{op}_{im_1.mode}")
except AttributeError as e: except AttributeError as e:
msg = f"bad operand type for '{op}'" msg = f"bad operand type for '{op}'"
raise TypeError(msg) from e raise TypeError(msg) from e
@ -89,7 +89,7 @@ class _Operand:
im_1.load() im_1.load()
im_2.load() im_2.load()
try: try:
op = getattr(_imagingmath, op + "_" + im_1.mode) op = getattr(_imagingmath, f"{op}_{im_1.mode}")
except AttributeError as e: except AttributeError as e:
msg = f"bad operand type for '{op}'" msg = f"bad operand type for '{op}'"
raise TypeError(msg) from e raise TypeError(msg) from e

View File

@ -18,6 +18,8 @@ import sys
from functools import lru_cache from functools import lru_cache
from typing import NamedTuple from typing import NamedTuple
from ._deprecate import deprecate
class ModeDescriptor(NamedTuple): class ModeDescriptor(NamedTuple):
"""Wrapper for mode strings.""" """Wrapper for mode strings."""
@ -42,8 +44,8 @@ def getmode(mode: str) -> ModeDescriptor:
# Bits need to be extended to bytes # Bits need to be extended to bytes
"1": ("L", "L", ("1",), "|b1"), "1": ("L", "L", ("1",), "|b1"),
"L": ("L", "L", ("L",), "|u1"), "L": ("L", "L", ("L",), "|u1"),
"I": ("L", "I", ("I",), endian + "i4"), "I": ("L", "I", ("I",), f"{endian}i4"),
"F": ("L", "F", ("F",), endian + "f4"), "F": ("L", "F", ("F",), f"{endian}f4"),
"P": ("P", "L", ("P",), "|u1"), "P": ("P", "L", ("P",), "|u1"),
"RGB": ("RGB", "L", ("R", "G", "B"), "|u1"), "RGB": ("RGB", "L", ("R", "G", "B"), "|u1"),
"RGBX": ("RGB", "L", ("R", "G", "B", "X"), "|u1"), "RGBX": ("RGB", "L", ("R", "G", "B", "X"), "|u1"),
@ -63,6 +65,8 @@ def getmode(mode: str) -> ModeDescriptor:
"PA": ("RGB", "L", ("P", "A"), "|u1"), "PA": ("RGB", "L", ("P", "A"), "|u1"),
} }
if mode in modes: if mode in modes:
if mode in ("BGR;15", "BGR;16", "BGR;24"):
deprecate(mode, 12)
base_mode, base_type, bands, type_str = modes[mode] base_mode, base_type, bands, type_str = modes[mode]
return ModeDescriptor(mode, bands, base_mode, base_type, type_str) return ModeDescriptor(mode, bands, base_mode, base_type, type_str)
@ -74,8 +78,8 @@ def getmode(mode: str) -> ModeDescriptor:
"I;16LS": "<i2", "I;16LS": "<i2",
"I;16B": ">u2", "I;16B": ">u2",
"I;16BS": ">i2", "I;16BS": ">i2",
"I;16N": endian + "u2", "I;16N": f"{endian}u2",
"I;16NS": endian + "i2", "I;16NS": f"{endian}i2",
"I;32": "<u4", "I;32": "<u4",
"I;32B": ">u4", "I;32B": ">u4",
"I;32L": "<u4", "I;32L": "<u4",

View File

@ -84,7 +84,7 @@ class LutBuilder:
], ],
} }
if op_name not in known_patterns: if op_name not in known_patterns:
msg = "Unknown pattern " + op_name + "!" msg = f"Unknown pattern {op_name}!"
raise Exception(msg) raise Exception(msg)
self.patterns = known_patterns[op_name] self.patterns = known_patterns[op_name]
@ -200,7 +200,7 @@ class MorphOp:
elif patterns is not None: elif patterns is not None:
self.lut = LutBuilder(patterns=patterns).build_lut() self.lut = LutBuilder(patterns=patterns).build_lut()
def apply(self, image: Image.Image): def apply(self, image: Image.Image) -> tuple[int, Image.Image]:
"""Run a single morphological operation on an image """Run a single morphological operation on an image
Returns a tuple of the number of changed pixels and the Returns a tuple of the number of changed pixels and the
@ -216,7 +216,7 @@ class MorphOp:
count = _imagingmorph.apply(bytes(self.lut), image.im.id, outimage.im.id) count = _imagingmorph.apply(bytes(self.lut), image.im.id, outimage.im.id)
return count, outimage return count, outimage
def match(self, image: Image.Image): def match(self, image: Image.Image) -> list[tuple[int, int]]:
"""Get a list of coordinates matching the morphological operation on """Get a list of coordinates matching the morphological operation on
an image. an image.
@ -231,7 +231,7 @@ class MorphOp:
raise ValueError(msg) raise ValueError(msg)
return _imagingmorph.match(bytes(self.lut), image.im.id) return _imagingmorph.match(bytes(self.lut), image.im.id)
def get_on_pixels(self, image: Image.Image): def get_on_pixels(self, image: Image.Image) -> list[tuple[int, int]]:
"""Get a list of all turned on pixels in a binary image """Get a list of all turned on pixels in a binary image
Returns a list of tuples of (x,y) coordinates Returns a list of tuples of (x,y) coordinates

View File

@ -18,7 +18,7 @@
from __future__ import annotations from __future__ import annotations
import array import array
from typing import Sequence from typing import IO, Sequence
from . import GimpGradientFile, GimpPaletteFile, ImageColor, PaletteFile from . import GimpGradientFile, GimpPaletteFile, ImageColor, PaletteFile
@ -66,7 +66,7 @@ class ImagePalette:
def colors(self, colors): def colors(self, colors):
self._colors = colors self._colors = colors
def copy(self): def copy(self) -> ImagePalette:
new = ImagePalette() new = ImagePalette()
new.mode = self.mode new.mode = self.mode
@ -77,7 +77,7 @@ class ImagePalette:
return new return new
def getdata(self): def getdata(self) -> tuple[str, bytes]:
""" """
Get palette contents in format suitable for the low-level Get palette contents in format suitable for the low-level
``im.putpalette`` primitive. ``im.putpalette`` primitive.
@ -88,7 +88,7 @@ class ImagePalette:
return self.rawmode, self.palette return self.rawmode, self.palette
return self.mode, self.tobytes() return self.mode, self.tobytes()
def tobytes(self): def tobytes(self) -> bytes:
"""Convert palette to bytes. """Convert palette to bytes.
.. warning:: This method is experimental. .. warning:: This method is experimental.
@ -166,7 +166,7 @@ class ImagePalette:
msg = f"unknown color specifier: {repr(color)}" msg = f"unknown color specifier: {repr(color)}"
raise ValueError(msg) raise ValueError(msg)
def save(self, fp): def save(self, fp: str | IO[str]) -> None:
"""Save palette to text file. """Save palette to text file.
.. warning:: This method is experimental. .. warning:: This method is experimental.
@ -213,29 +213,29 @@ def make_linear_lut(black, white):
raise NotImplementedError(msg) # FIXME raise NotImplementedError(msg) # FIXME
def make_gamma_lut(exp): def make_gamma_lut(exp: float) -> list[int]:
return [int(((i / 255.0) ** exp) * 255.0 + 0.5) for i in range(256)] return [int(((i / 255.0) ** exp) * 255.0 + 0.5) for i in range(256)]
def negative(mode="RGB"): def negative(mode: str = "RGB") -> ImagePalette:
palette = list(range(256 * len(mode))) palette = list(range(256 * len(mode)))
palette.reverse() palette.reverse()
return ImagePalette(mode, [i // len(mode) for i in palette]) return ImagePalette(mode, [i // len(mode) for i in palette])
def random(mode="RGB"): def random(mode: str = "RGB") -> ImagePalette:
from random import randint from random import randint
palette = [randint(0, 255) for _ in range(256 * len(mode))] palette = [randint(0, 255) for _ in range(256 * len(mode))]
return ImagePalette(mode, palette) return ImagePalette(mode, palette)
def sepia(white="#fff0c0"): def sepia(white: str = "#fff0c0") -> ImagePalette:
bands = [make_linear_lut(0, band) for band in ImageColor.getrgb(white)] bands = [make_linear_lut(0, band) for band in ImageColor.getrgb(white)]
return ImagePalette("RGB", [bands[i % 3][i // 3] for i in range(256 * 3)]) return ImagePalette("RGB", [bands[i % 3][i // 3] for i in range(256 * 3)])
def wedge(mode="RGB"): def wedge(mode: str = "RGB") -> ImagePalette:
palette = list(range(256 * len(mode))) palette = list(range(256 * len(mode)))
return ImagePalette(mode, [i // len(mode) for i in palette]) return ImagePalette(mode, [i // len(mode) for i in palette])

View File

@ -128,7 +128,7 @@ class PhotoImage:
if image: if image:
self.paste(image) self.paste(image)
def __del__(self): def __del__(self) -> None:
name = self.__photo.name name = self.__photo.name
self.__photo.name = None self.__photo.name = None
try: try:
@ -136,7 +136,7 @@ class PhotoImage:
except Exception: except Exception:
pass # ignore internal errors pass # ignore internal errors
def __str__(self): def __str__(self) -> str:
""" """
Get the Tkinter photo image identifier. This method is automatically Get the Tkinter photo image identifier. This method is automatically
called by Tkinter whenever a PhotoImage object is passed to a Tkinter called by Tkinter whenever a PhotoImage object is passed to a Tkinter
@ -146,7 +146,7 @@ class PhotoImage:
""" """
return str(self.__photo) return str(self.__photo)
def width(self): def width(self) -> int:
""" """
Get the width of the image. Get the width of the image.
@ -154,7 +154,7 @@ class PhotoImage:
""" """
return self.__size[0] return self.__size[0]
def height(self): def height(self) -> int:
""" """
Get the height of the image. Get the height of the image.
@ -219,7 +219,7 @@ class BitmapImage:
kw["data"] = image.tobitmap() kw["data"] = image.tobitmap()
self.__photo = tkinter.BitmapImage(**kw) self.__photo = tkinter.BitmapImage(**kw)
def __del__(self): def __del__(self) -> None:
name = self.__photo.name name = self.__photo.name
self.__photo.name = None self.__photo.name = None
try: try:
@ -227,7 +227,7 @@ class BitmapImage:
except Exception: except Exception:
pass # ignore internal errors pass # ignore internal errors
def width(self): def width(self) -> int:
""" """
Get the width of the image. Get the width of the image.
@ -235,7 +235,7 @@ class BitmapImage:
""" """
return self.__size[0] return self.__size[0]
def height(self): def height(self) -> int:
""" """
Get the height of the image. Get the height of the image.
@ -243,7 +243,7 @@ class BitmapImage:
""" """
return self.__size[1] return self.__size[1]
def __str__(self): def __str__(self) -> str:
""" """
Get the Tkinter bitmap image identifier. This method is automatically Get the Tkinter bitmap image identifier. This method is automatically
called by Tkinter whenever a BitmapImage object is passed to a Tkinter called by Tkinter whenever a BitmapImage object is passed to a Tkinter

View File

@ -28,10 +28,10 @@ class HDC:
methods. methods.
""" """
def __init__(self, dc): def __init__(self, dc: int) -> None:
self.dc = dc self.dc = dc
def __int__(self): def __int__(self) -> int:
return self.dc return self.dc
@ -42,10 +42,10 @@ class HWND:
methods, instead of a DC. methods, instead of a DC.
""" """
def __init__(self, wnd): def __init__(self, wnd: int) -> None:
self.wnd = wnd self.wnd = wnd
def __int__(self): def __int__(self) -> int:
return self.wnd return self.wnd
@ -149,7 +149,9 @@ class Dib:
result = self.image.query_palette(handle) result = self.image.query_palette(handle)
return result return result
def paste(self, im, box=None): def paste(
self, im: Image.Image, box: tuple[int, int, int, int] | None = None
) -> None:
""" """
Paste a PIL image into the bitmap image. Paste a PIL image into the bitmap image.
@ -169,16 +171,16 @@ class Dib:
else: else:
self.image.paste(im.im) self.image.paste(im.im)
def frombytes(self, buffer): def frombytes(self, buffer: bytes) -> None:
""" """
Load display memory contents from byte data. Load display memory contents from byte data.
:param buffer: A buffer containing display data (usually :param buffer: A buffer containing display data (usually
data returned from :py:func:`~PIL.ImageWin.Dib.tobytes`) data returned from :py:func:`~PIL.ImageWin.Dib.tobytes`)
""" """
return self.image.frombytes(buffer) self.image.frombytes(buffer)
def tobytes(self): def tobytes(self) -> bytes:
""" """
Copy display memory contents to bytes object. Copy display memory contents to bytes object.
@ -190,13 +192,15 @@ class Dib:
class Window: class Window:
"""Create a Window with the given title size.""" """Create a Window with the given title size."""
def __init__(self, title="PIL", width=None, height=None): def __init__(
self, title: str = "PIL", width: int | None = None, height: int | None = None
) -> None:
self.hwnd = Image.core.createwindow( self.hwnd = Image.core.createwindow(
title, self.__dispatcher, width or 0, height or 0 title, self.__dispatcher, width or 0, height or 0
) )
def __dispatcher(self, action, *args): def __dispatcher(self, action, *args):
return getattr(self, "ui_handle_" + action)(*args) return getattr(self, f"ui_handle_{action}")(*args)
def ui_handle_clear(self, dc, x0, y0, x1, y1): def ui_handle_clear(self, dc, x0, y0, x1, y1):
pass pass
@ -204,7 +208,7 @@ class Window:
def ui_handle_damage(self, x0, y0, x1, y1): def ui_handle_damage(self, x0, y0, x1, y1):
pass pass
def ui_handle_destroy(self): def ui_handle_destroy(self) -> None:
pass pass
def ui_handle_repair(self, dc, x0, y0, x1, y1): def ui_handle_repair(self, dc, x0, y0, x1, y1):
@ -213,7 +217,7 @@ class Window:
def ui_handle_resize(self, width, height): def ui_handle_resize(self, width, height):
pass pass
def mainloop(self): def mainloop(self) -> None:
Image.core.eventloop() Image.core.eventloop()

View File

@ -57,7 +57,7 @@ def dump(c: Sequence[int | bytes]) -> None:
""".. deprecated:: 10.2.0""" """.. deprecated:: 10.2.0"""
deprecate("IptcImagePlugin.dump", 12) deprecate("IptcImagePlugin.dump", 12)
for i in c: for i in c:
print("%02x" % _i8(i), end=" ") print(f"{_i8(i):02x}", end=" ")
print() print()

View File

@ -34,7 +34,7 @@ class BoxReader:
self.length = length self.length = length
self.remaining_in_box = -1 self.remaining_in_box = -1
def _can_read(self, num_bytes): def _can_read(self, num_bytes: int) -> bool:
if self.has_length and self.fp.tell() + num_bytes > self.length: if self.has_length and self.fp.tell() + num_bytes > self.length:
# Outside box: ensure we don't read past the known file length # Outside box: ensure we don't read past the known file length
return False return False
@ -44,7 +44,7 @@ class BoxReader:
else: else:
return True # No length known, just read return True # No length known, just read
def _read_bytes(self, num_bytes): def _read_bytes(self, num_bytes: int) -> bytes:
if not self._can_read(num_bytes): if not self._can_read(num_bytes):
msg = "Not enough data in header" msg = "Not enough data in header"
raise SyntaxError(msg) raise SyntaxError(msg)
@ -63,18 +63,18 @@ class BoxReader:
data = self._read_bytes(size) data = self._read_bytes(size)
return struct.unpack(field_format, data) return struct.unpack(field_format, data)
def read_boxes(self): def read_boxes(self) -> BoxReader:
size = self.remaining_in_box size = self.remaining_in_box
data = self._read_bytes(size) data = self._read_bytes(size)
return BoxReader(io.BytesIO(data), size) return BoxReader(io.BytesIO(data), size)
def has_next_box(self): def has_next_box(self) -> bool:
if self.has_length: if self.has_length:
return self.fp.tell() + self.remaining_in_box < self.length return self.fp.tell() + self.remaining_in_box < self.length
else: else:
return True return True
def next_box_type(self): def next_box_type(self) -> bytes:
# Skip the rest of the box if it has not been read # Skip the rest of the box if it has not been read
if self.remaining_in_box > 0: if self.remaining_in_box > 0:
self.fp.seek(self.remaining_in_box, os.SEEK_CUR) self.fp.seek(self.remaining_in_box, os.SEEK_CUR)
@ -215,7 +215,7 @@ class Jpeg2KImageFile(ImageFile.ImageFile):
format = "JPEG2000" format = "JPEG2000"
format_description = "JPEG 2000 (ISO 15444)" format_description = "JPEG 2000 (ISO 15444)"
def _open(self): def _open(self) -> None:
sig = self.fp.read(4) sig = self.fp.read(4)
if sig == b"\xff\x4f\xff\x51": if sig == b"\xff\x4f\xff\x51":
self.codec = "j2k" self.codec = "j2k"
@ -267,7 +267,7 @@ class Jpeg2KImageFile(ImageFile.ImageFile):
) )
] ]
def _parse_comment(self): def _parse_comment(self) -> None:
hdr = self.fp.read(2) hdr = self.fp.read(2)
length = _binary.i16be(hdr) length = _binary.i16be(hdr)
self.fp.seek(length - 2, os.SEEK_CUR) self.fp.seek(length - 2, os.SEEK_CUR)

View File

@ -42,6 +42,7 @@ import subprocess
import sys import sys
import tempfile import tempfile
import warnings import warnings
from typing import Any
from . import Image, ImageFile from . import Image, ImageFile
from ._binary import i16be as i16 from ._binary import i16be as i16
@ -54,7 +55,7 @@ from .JpegPresets import presets
# Parser # Parser
def Skip(self, marker): def Skip(self: JpegImageFile, marker: int) -> None:
n = i16(self.fp.read(2)) - 2 n = i16(self.fp.read(2)) - 2
ImageFile._safe_read(self.fp, n) ImageFile._safe_read(self.fp, n)
@ -191,7 +192,7 @@ def APP(self, marker):
self.info["dpi"] = 72, 72 self.info["dpi"] = 72, 72
def COM(self, marker): def COM(self: JpegImageFile, marker: int) -> None:
# #
# Comment marker. Store these in the APP dictionary. # Comment marker. Store these in the APP dictionary.
n = i16(self.fp.read(2)) - 2 n = i16(self.fp.read(2)) - 2
@ -202,7 +203,7 @@ def COM(self, marker):
self.applist.append(("COM", s)) self.applist.append(("COM", s))
def SOF(self, marker): def SOF(self: JpegImageFile, marker: int) -> None:
# #
# Start of frame marker. Defines the size and mode of the # Start of frame marker. Defines the size and mode of the
# image. JPEG is colour blind, so we use some simple # image. JPEG is colour blind, so we use some simple
@ -250,7 +251,7 @@ def SOF(self, marker):
self.layer.append((t[0], t[1] // 16, t[1] & 15, t[2])) self.layer.append((t[0], t[1] // 16, t[1] & 15, t[2]))
def DQT(self, marker): def DQT(self: JpegImageFile, marker: int) -> None:
# #
# Define quantization table. Note that there might be more # Define quantization table. Note that there might be more
# than one table in each marker. # than one table in each marker.
@ -408,7 +409,7 @@ class JpegImageFile(ImageFile.ImageFile):
msg = "no marker found" msg = "no marker found"
raise SyntaxError(msg) raise SyntaxError(msg)
def load_read(self, read_bytes): def load_read(self, read_bytes: int) -> bytes:
""" """
internal: read more image data internal: read more image data
For premature EOF and LOAD_TRUNCATED_IMAGES adds EOI marker For premature EOF and LOAD_TRUNCATED_IMAGES adds EOI marker
@ -424,13 +425,15 @@ class JpegImageFile(ImageFile.ImageFile):
return s return s
def draft(self, mode, size): def draft(
self, mode: str, size: tuple[int, int]
) -> tuple[str, tuple[int, int, float, float]] | None:
if len(self.tile) != 1: if len(self.tile) != 1:
return return None
# Protect from second call # Protect from second call
if self.decoderconfig: if self.decoderconfig:
return return None
d, e, o, a = self.tile[0] d, e, o, a = self.tile[0]
scale = 1 scale = 1
@ -460,7 +463,7 @@ class JpegImageFile(ImageFile.ImageFile):
box = (0, 0, original_size[0] / scale, original_size[1] / scale) box = (0, 0, original_size[0] / scale, original_size[1] / scale)
return self.mode, box return self.mode, box
def load_djpeg(self): def load_djpeg(self) -> None:
# ALTERNATIVE: handle JPEGs via the IJG command line utilities # ALTERNATIVE: handle JPEGs via the IJG command line utilities
f, path = tempfile.mkstemp() f, path = tempfile.mkstemp()
@ -491,13 +494,13 @@ class JpegImageFile(ImageFile.ImageFile):
self.tile = [] self.tile = []
def _getexif(self): def _getexif(self) -> dict[str, Any] | None:
return _getexif(self) return _getexif(self)
def _getmp(self): def _getmp(self):
return _getmp(self) return _getmp(self)
def getxmp(self): def getxmp(self) -> dict[str, Any]:
""" """
Returns a dictionary containing the XMP tags. Returns a dictionary containing the XMP tags.
Requires defusedxml to be installed. Requires defusedxml to be installed.
@ -513,7 +516,7 @@ class JpegImageFile(ImageFile.ImageFile):
return {} return {}
def _getexif(self): def _getexif(self) -> dict[str, Any] | None:
if "exif" not in self.info: if "exif" not in self.info:
return None return None
return self.getexif()._get_merged_dict() return self.getexif()._get_merged_dict()

View File

@ -38,7 +38,7 @@ class MicImageFile(TiffImagePlugin.TiffImageFile):
format_description = "Microsoft Image Composer" format_description = "Microsoft Image Composer"
_close_exclusive_fp_after_loading = False _close_exclusive_fp_after_loading = False
def _open(self): def _open(self) -> None:
# read the OLE directory and see if this is a likely # read the OLE directory and see if this is a likely
# to be a Microsoft Image Composer file # to be a Microsoft Image Composer file
@ -88,7 +88,7 @@ class MicImageFile(TiffImagePlugin.TiffImageFile):
def tell(self): def tell(self):
return self.frame return self.frame
def close(self): def close(self) -> None:
self.__fp.close() self.__fp.close()
self.ole.close() self.ole.close()
super().close() super().close()

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