diff --git a/.appveyor.yml b/.appveyor.yml index b817cd9d8..0f5dea9c5 100644 --- a/.appveyor.yml +++ b/.appveyor.yml @@ -10,48 +10,48 @@ environment: TEST_OPTIONS: DEPLOY: YES matrix: - - PYTHON: C:/Python311 + - PYTHON: C:/Python312 ARCHITECTURE: x86 APPVEYOR_BUILD_WORKER_IMAGE: Visual Studio 2022 - - PYTHON: C:/Python37-x64 + - PYTHON: C:/Python38-x64 ARCHITECTURE: x64 APPVEYOR_BUILD_WORKER_IMAGE: Visual Studio 2017 install: - '%PYTHON%\%EXECUTABLE% --version' -- curl -fsSL -o pillow-depends.zip https://github.com/python-pillow/pillow-depends/archive/main.zip -- 7z x pillow-depends.zip -oc:\ -- mv c:\pillow-depends-main c:\pillow-depends -- xcopy /S /Y c:\pillow-depends\test_images\* c:\pillow\tests\images -- 7z x ..\pillow-depends\nasm-2.15.05-win64.zip -oc:\ -- ..\pillow-depends\gs1000w32.exe /S -- path c:\nasm-2.15.05;C:\Program Files (x86)\gs\gs10.0.0\bin;%PATH% +- '%PYTHON%\%EXECUTABLE% -m pip install --upgrade pip' +- curl -fsSL -o pillow-test-images.zip https://github.com/python-pillow/test-images/archive/main.zip +- 7z x pillow-test-images.zip -oc:\ +- xcopy /S /Y c:\test-images-main\* c:\pillow\tests\images +- curl -fsSL -o nasm-win64.zip https://raw.githubusercontent.com/python-pillow/pillow-depends/main/nasm-2.16.01-win64.zip +- 7z x nasm-win64.zip -oc:\ +- choco install ghostscript --version=10.0.0.20230317 +- path c:\nasm-2.16.01;C:\Program Files\gs\gs10.00.0\bin;%PATH% - cd c:\pillow\winbuild\ - ps: | - c:\python37\python.exe c:\pillow\winbuild\build_prepare.py -v --depends=C:\pillow-depends\ + c:\python38\python.exe c:\pillow\winbuild\build_prepare.py -v --depends=C:\pillow-depends\ c:\pillow\winbuild\build\build_dep_all.cmd $host.SetShouldExit(0) - path C:\pillow\winbuild\build\bin;%PATH% build_script: -- ps: | - c:\pillow\winbuild\build\build_pillow.cmd install - $host.SetShouldExit(0) - cd c:\pillow +- winbuild\build\build_env.cmd +- '%PYTHON%\%EXECUTABLE% -m pip install -v -C raqm=vendor -C fribidi=vendor .' - '%PYTHON%\%EXECUTABLE% selftest.py --installed' test_script: - cd c:\pillow -- '%PYTHON%\%EXECUTABLE% -m pip install pytest pytest-cov pytest-timeout' +- '%PYTHON%\%EXECUTABLE% -m pip install pytest pytest-cov pytest-timeout defusedxml numpy olefile pyroma' - c:\"Program Files (x86)"\"Windows Kits"\10\Debuggers\x86\gflags.exe /p /enable %PYTHON%\%EXECUTABLE% - '%PYTHON%\%EXECUTABLE% -c "from PIL import Image"' - '%PYTHON%\%EXECUTABLE% -m pytest -vx --cov PIL --cov Tests --cov-report term --cov-report xml Tests' #- '%PYTHON%\%EXECUTABLE% test-installed.py -v -s %TEST_OPTIONS%' TODO TEST_OPTIONS with pytest? after_test: -- python -m pip install codecov -- codecov --file coverage.xml --name %PYTHON% --flags AppVeyor +- curl -Os https://uploader.codecov.io/latest/windows/codecov.exe +- .\codecov.exe --file coverage.xml --name %PYTHON% --flags AppVeyor matrix: fast_finish: true @@ -60,18 +60,15 @@ cache: - '%LOCALAPPDATA%\pip\Cache' artifacts: -- path: pillow\dist\*.egg +- path: pillow\*.egg name: egg -- path: pillow\dist\*.wheel +- path: pillow\*.whl name: wheel before_deploy: - cd c:\pillow - - '%PYTHON%\%EXECUTABLE% -m pip install wheel' - - cd c:\pillow\winbuild\ - - c:\pillow\winbuild\build\build_pillow.cmd bdist_wheel - - cd c:\pillow - - ps: Get-ChildItem .\dist\*.* | % { Push-AppveyorArtifact $_.FullName -FileName $_.Name } + - '%PYTHON%\%EXECUTABLE% -m pip wheel -v -C raqm=vendor -C fribidi=vendor .' + - ps: Get-ChildItem .\*.whl | % { Push-AppveyorArtifact $_.FullName -FileName $_.Name } deploy: provider: S3 diff --git a/.ci/after_success.sh b/.ci/after_success.sh index 23a6fcd4d..c71546f00 100755 --- a/.ci/after_success.sh +++ b/.ci/after_success.sh @@ -1,7 +1,7 @@ #!/bin/bash # gather the coverage data -python3 -m pip install codecov +python3 -m pip install coverage if [[ $MATRIX_DOCKER ]]; then python3 -m coverage xml --ignore-errors else diff --git a/.ci/install.sh b/.ci/install.sh index 518b66acc..30b64349d 100755 --- a/.ci/install.sh +++ b/.ci/install.sh @@ -22,12 +22,14 @@ set -e if [[ $(uname) != CYGWIN* ]]; then sudo apt-get -qq install libfreetype6-dev liblcms2-dev python3-tk\ ghostscript libffi-dev libjpeg-turbo-progs libopenjp2-7-dev\ - cmake meson imagemagick libharfbuzz-dev libfribidi-dev + cmake meson imagemagick libharfbuzz-dev libfribidi-dev\ + sway wl-clipboard libopenblas-dev fi python3 -m pip install --upgrade pip python3 -m pip install --upgrade wheel -PYTHONOPTIMIZE=0 python3 -m pip install cffi +# TODO Update condition when cffi supports 3.13 +if ! [[ "$GHA_PYTHON_VERSION" == "3.13" ]]; then PYTHONOPTIMIZE=0 python3 -m pip install cffi ; fi python3 -m pip install coverage python3 -m pip install defusedxml python3 -m pip install olefile @@ -37,14 +39,25 @@ python3 -m pip install -U pytest-timeout python3 -m pip install pyroma if [[ $(uname) != CYGWIN* ]]; then - python3 -m pip install numpy + # TODO Update condition when NumPy supports 3.13 + if ! [[ "$GHA_PYTHON_VERSION" == "3.13" ]]; then python3 -m pip install numpy ; fi # PyQt6 doesn't support PyPy3 if [[ $GHA_PYTHON_VERSION == 3.* ]]; then - sudo apt-get -qq install libegl1 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-shape0 libxkbcommon-x11-0 + sudo apt-get -qq install libegl1 libxcb-cursor0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-shape0 libxkbcommon-x11-0 python3 -m pip install pyqt6 fi + # Pyroma uses non-isolated build and fails with old setuptools + if [[ + $GHA_PYTHON_VERSION == pypy3.9 + || $GHA_PYTHON_VERSION == 3.8 + || $GHA_PYTHON_VERSION == 3.9 + ]]; then + # To match pyproject.toml + python3 -m pip install "setuptools>=67.8" + fi + # webp pushd depends && ./install_webp.sh && popd diff --git a/.ci/requirements-cibw.txt b/.ci/requirements-cibw.txt new file mode 100644 index 000000000..dd61634cd --- /dev/null +++ b/.ci/requirements-cibw.txt @@ -0,0 +1 @@ +cibuildwheel==2.16.2 diff --git a/.editorconfig b/.editorconfig index 449530717..c3627ae4f 100644 --- a/.editorconfig +++ b/.editorconfig @@ -13,11 +13,7 @@ indent_style = space trim_trailing_whitespace = true -[*.rst] -# Four-space indentation -indent_size = 4 - -[*.yml] +[*.{toml,yml}] # Two-space indentation indent_size = 2 diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 000000000..a2be59c52 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,6 @@ +# Flake8 +8de95676e0fd89f2326b3953488ab66ff29cd2d0 +# Format with Black +53a7e3500437a9fd5826bc04758f7116bd7e52dc +# Format the C code with ClangFormat +46b7e86bab79450ec0a2866c6c0c679afb659d17 diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index ba2b7d8ed..d03fcf0d9 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -19,6 +19,7 @@ Please send a pull request to the `main` branch. Please include [documentation]( - Follow PEP 8. - When committing only documentation changes please include `[ci skip]` in the commit message to avoid running tests on AppVeyor. - Include [release notes](https://github.com/python-pillow/Pillow/tree/main/docs/releasenotes) as needed or appropriate with your bug fixes, feature additions and tests. +- Do not add to the [changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst) for proposed changes, as that is updated after changes are merged. ## Reporting Issues diff --git a/.github/mergify.yml b/.github/mergify.yml index 8dfa07f4e..3c2066137 100644 --- a/.github/mergify.yml +++ b/.github/mergify.yml @@ -7,7 +7,7 @@ pull_request_rules: - status-success=Test Successful - status-success=Docker Test Successful - status-success=Windows Test Successful - - status-success=MinGW Test Successful + - status-success=MinGW - status-success=Cygwin Test Successful - status-success=continuous-integration/appveyor/pr actions: diff --git a/.github/workflows/cifuzz.yml b/.github/workflows/cifuzz.yml index db0307046..eb73fc6a7 100644 --- a/.github/workflows/cifuzz.yml +++ b/.github/workflows/cifuzz.yml @@ -2,11 +2,15 @@ name: CIFuzz on: push: + branches: + - "**" paths: + - ".github/workflows/cifuzz.yml" - "**.c" - "**.h" pull_request: paths: + - ".github/workflows/cifuzz.yml" - "**.c" - "**.h" workflow_dispatch: @@ -14,7 +18,7 @@ on: permissions: contents: read -concurrency: +concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true @@ -38,13 +42,13 @@ jobs: language: python dry-run: false - name: Upload New Crash - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: failure() && steps.build.outcome == 'success' with: name: artifacts path: ./out/artifacts - name: Upload Legacy Crash - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: steps.run.outcome == 'success' with: name: crash diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 000000000..9fe345c8a --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,57 @@ +name: Docs + +on: + push: + branches: + - "**" + paths: + - ".github/workflows/docs.yml" + - "docs/**" + pull_request: + paths: + - ".github/workflows/docs.yml" + - "docs/**" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + FORCE_COLOR: 1 + +jobs: + build: + + runs-on: ubuntu-latest + name: Docs + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + cache: pip + cache-dependency-path: ".ci/*.sh" + + - name: Build system information + run: python3 .github/workflows/system-info.py + + - name: Install Linux dependencies + run: | + .ci/install.sh + env: + GHA_PYTHON_VERSION: "3.x" + + - name: Build + run: | + .ci/build.sh + + - name: Docs + run: | + make doccheck diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 49611e287..9069fc615 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -2,6 +2,9 @@ name: Lint on: [push, pull_request, workflow_dispatch] +env: + FORCE_COLOR: 1 + permissions: contents: read @@ -17,7 +20,7 @@ jobs: name: Lint steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: pre-commit cache uses: actions/cache@v3 @@ -28,7 +31,7 @@ jobs: lint-pre-commit- - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: "3.x" cache: pip @@ -46,3 +49,6 @@ jobs: run: tox -e lint env: PRE_COMMIT_COLOR: always + + - name: Mypy + run: tox -e mypy diff --git a/.github/workflows/macos-install.sh b/.github/workflows/macos-install.sh index dfd7d0553..f41324c4b 100755 --- a/.github/workflows/macos-install.sh +++ b/.github/workflows/macos-install.sh @@ -3,8 +3,11 @@ set -e brew install libtiff libjpeg openjpeg libimagequant webp little-cms2 freetype libraqm +export PKG_CONFIG_PATH="/usr/local/opt/openblas/lib/pkgconfig" + +# TODO Update condition when cffi supports 3.13 +if ! [[ "$GHA_PYTHON_VERSION" == "3.13" ]]; then PYTHONOPTIMIZE=0 python3 -m pip install cffi ; fi -PYTHONOPTIMIZE=0 python3 -m pip install cffi python3 -m pip install coverage python3 -m pip install defusedxml python3 -m pip install olefile @@ -13,7 +16,8 @@ python3 -m pip install -U pytest-cov python3 -m pip install -U pytest-timeout python3 -m pip install pyroma -python3 -m pip install numpy +# TODO Update condition when NumPy supports 3.13 +if ! [[ "$GHA_PYTHON_VERSION" == "3.13" ]]; then python3 -m pip install numpy ; fi # extra test images pushd depends && ./install_extra_test_images.sh && popd diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 9e2fdc096..8fc7bd379 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -10,7 +10,7 @@ on: permissions: contents: read -concurrency: +concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 8c210bc90..545c2e364 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -8,7 +8,7 @@ on: permissions: issues: write -concurrency: +concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true @@ -20,7 +20,7 @@ jobs: steps: - name: "Check issues" - uses: actions/stale@v7 + uses: actions/stale@v9 with: repo-token: ${{ secrets.GITHUB_TOKEN }} only-labels: "Awaiting OP Action" diff --git a/.github/workflows/system-info.py b/.github/workflows/system-info.py index 8e840319a..57f28c620 100644 --- a/.github/workflows/system-info.py +++ b/.github/workflows/system-info.py @@ -6,6 +6,8 @@ This sort of info is missing from GitHub Actions. Requested here: https://github.com/actions/virtual-environments/issues/79 """ +from __future__ import annotations + import os import platform import sys diff --git a/.github/workflows/test-cygwin.yml b/.github/workflows/test-cygwin.yml index 7b8070d34..32ac6f65e 100644 --- a/.github/workflows/test-cygwin.yml +++ b/.github/workflows/test-cygwin.yml @@ -1,6 +1,25 @@ name: Test Cygwin -on: [push, pull_request, workflow_dispatch] +on: + push: + branches: + - "**" + paths-ignore: + - ".github/workflows/docs.yml" + - ".github/workflows/wheels*" + - ".gitmodules" + - ".travis.yml" + - "docs/**" + - "wheels/**" + pull_request: + paths-ignore: + - ".github/workflows/docs.yml" + - ".github/workflows/wheels*" + - ".gitmodules" + - ".travis.yml" + - "docs/**" + - "wheels/**" + workflow_dispatch: permissions: contents: read @@ -27,38 +46,63 @@ jobs: git config --global core.autocrlf input - name: Checkout Pillow - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Install Cygwin - uses: cygwin/cygwin-install-action@v3 + uses: cygwin/cygwin-install-action@v4 with: platform: x86_64 packages: > - ImageMagick gcc-g++ ghostscript jpeg libfreetype-devel - libimagequant-devel libjpeg-devel liblapack-devel - liblcms2-devel libopenjp2-devel libraqm-devel - libtiff-devel libwebp-devel libxcb-devel libxcb-xinerama0 - make netpbm perl + gcc-g++ + ghostscript + ImageMagick + jpeg + libfreetype-devel + libimagequant-devel + libjpeg-devel + liblapack-devel + liblcms2-devel + libopenjp2-devel + libraqm-devel + libtiff-devel + libwebp-devel + libxcb-devel + libxcb-xinerama0 + make + netpbm + perl python3${{ matrix.python-minor-version }}-cffi python3${{ matrix.python-minor-version }}-cython python3${{ matrix.python-minor-version }}-devel python3${{ matrix.python-minor-version }}-numpy python3${{ matrix.python-minor-version }}-sip python3${{ matrix.python-minor-version }}-tkinter - qt5-devel-tools subversion xorg-server-extra zlib-devel + wget + xorg-server-extra + zlib-devel - name: Add Lapack to PATH uses: egor-tensin/cleanup-path@v3 with: dirs: 'C:\cygwin\bin;C:\cygwin\lib\lapack' + - name: Select Python version + run: | + ln -sf c:/cygwin/bin/python3.${{ matrix.python-minor-version }} c:/cygwin/bin/python3 + + - name: Get latest NumPy version + id: latest-numpy + shell: bash.exe -eo pipefail -o igncr "{0}" + run: | + python3 -m pip list --outdated | grep numpy | sed -r 's/ +/ /g' | cut -d ' ' -f 3 | sed 's/^/version=/' >> $GITHUB_OUTPUT + - name: pip cache uses: actions/cache@v3 with: path: 'C:\cygwin\home\runneradmin\.cache\pip' - key: ${{ runner.os }}-cygwin-pip3.${{ matrix.python-minor-version }}-${{ hashFiles('.ci/install.sh') }} + key: ${{ runner.os }}-cygwin-pip3.${{ matrix.python-minor-version }}-numpy${{ steps.latest-numpy.outputs.version }}-${{ hashFiles('.ci/install.sh') }} restore-keys: | - ${{ runner.os }}-cygwin-pip3.${{ matrix.python-minor-version }}- + ${{ runner.os }}-cygwin-pip3.${{ matrix.python-minor-version }}-numpy${{ steps.latest-numpy.outputs.version }}- - name: Build system information run: | @@ -68,15 +112,15 @@ jobs: run: | bash.exe .ci/install.sh - - name: Install a different NumPy + - name: Upgrade NumPy shell: dash.exe -l "{0}" run: | - python3 -m pip install -U 'numpy!=1.21.*' + python3 -m pip install -U "numpy<1.26" - name: Build shell: bash.exe -eo pipefail -o igncr "{0}" run: | - SETUPTOOLS_USE_DISTUTILS=stdlib .ci/build.sh + .ci/build.sh - name: Test run: | @@ -88,7 +132,7 @@ jobs: dash.exe -c "mkdir -p Tests/errors" - name: Upload errors - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: failure() with: name: errors diff --git a/.github/workflows/test-docker.yml b/.github/workflows/test-docker.yml index 7331cf8ee..eb27b4bf7 100644 --- a/.github/workflows/test-docker.yml +++ b/.github/workflows/test-docker.yml @@ -1,6 +1,25 @@ name: Test Docker -on: [push, pull_request, workflow_dispatch] +on: + push: + branches: + - "**" + paths-ignore: + - ".github/workflows/docs.yml" + - ".github/workflows/wheels*" + - ".gitmodules" + - ".travis.yml" + - "docs/**" + - "wheels/**" + pull_request: + paths-ignore: + - ".github/workflows/docs.yml" + - ".github/workflows/wheels*" + - ".gitmodules" + - ".travis.yml" + - "docs/**" + - "wheels/**" + workflow_dispatch: permissions: contents: read @@ -24,16 +43,17 @@ jobs: # Then run the remainder alpine, amazon-2-amd64, + amazon-2023-amd64, arch, centos-7-amd64, centos-stream-8-amd64, centos-stream-9-amd64, - debian-10-buster-x86, - debian-11-bullseye-x86, - fedora-36-amd64, - fedora-37-amd64, + debian-11-bullseye-amd64, + debian-12-bookworm-x86, + debian-12-bookworm-amd64, + fedora-38-amd64, + fedora-39-amd64, gentoo, - ubuntu-18.04-bionic-amd64, ubuntu-20.04-focal-amd64, ubuntu-22.04-jammy-amd64, ] @@ -49,7 +69,7 @@ jobs: name: ${{ matrix.docker }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Build system information run: python3 .github/workflows/system-info.py @@ -87,6 +107,7 @@ jobs: with: flags: GHA_Docker name: ${{ matrix.docker }} + gcov: true success: permissions: diff --git a/.github/workflows/test-mingw.yml b/.github/workflows/test-mingw.yml index ccf6e193a..115c2e9be 100644 --- a/.github/workflows/test-mingw.yml +++ b/.github/workflows/test-mingw.yml @@ -1,42 +1,50 @@ name: Test MinGW -on: [push, pull_request, workflow_dispatch] +on: + push: + branches: + - "**" + paths-ignore: + - ".github/workflows/docs.yml" + - ".github/workflows/wheels*" + - ".gitmodules" + - ".travis.yml" + - "docs/**" + - "wheels/**" + pull_request: + paths-ignore: + - ".github/workflows/docs.yml" + - ".github/workflows/wheels*" + - ".gitmodules" + - ".travis.yml" + - "docs/**" + - "wheels/**" + workflow_dispatch: permissions: contents: read -concurrency: +concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: build: runs-on: windows-latest - strategy: - fail-fast: false - matrix: - mingw: ["MINGW32", "MINGW64"] - include: - - mingw: "MINGW32" - name: "MSYS2 MinGW 32-bit" - package: "mingw-w64-i686" - - mingw: "MINGW64" - name: "MSYS2 MinGW 64-bit" - package: "mingw-w64-x86_64" defaults: run: shell: bash.exe --login -eo pipefail "{0}" env: - MSYSTEM: ${{ matrix.mingw }} + MSYSTEM: MINGW64 CHERE_INVOKING: 1 timeout-minutes: 30 - name: ${{ matrix.name }} + name: "MinGW" steps: - name: Checkout Pillow - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up shell run: echo "C:\msys64\usr\bin\" >> $env:GITHUB_PATH @@ -45,30 +53,29 @@ jobs: - name: Install dependencies run: | pacman -S --noconfirm \ - ${{ matrix.package }}-python3-cffi \ - ${{ matrix.package }}-python3-numpy \ - ${{ matrix.package }}-python3-olefile \ - ${{ matrix.package }}-python3-pip \ - ${{ matrix.package }}-python-pyqt6 \ - ${{ matrix.package }}-python3-setuptools \ - ${{ matrix.package }}-freetype \ - ${{ matrix.package }}-gcc \ - ${{ matrix.package }}-ghostscript \ - ${{ matrix.package }}-lcms2 \ - ${{ matrix.package }}-libimagequant \ - ${{ matrix.package }}-libjpeg-turbo \ - ${{ matrix.package }}-libraqm \ - ${{ matrix.package }}-libtiff \ - ${{ matrix.package }}-libwebp \ - ${{ matrix.package }}-openjpeg2 \ - subversion + mingw-w64-x86_64-freetype \ + mingw-w64-x86_64-gcc \ + mingw-w64-x86_64-ghostscript \ + mingw-w64-x86_64-lcms2 \ + mingw-w64-x86_64-libimagequant \ + mingw-w64-x86_64-libjpeg-turbo \ + mingw-w64-x86_64-libraqm \ + mingw-w64-x86_64-libtiff \ + mingw-w64-x86_64-libwebp \ + mingw-w64-x86_64-openjpeg2 \ + mingw-w64-x86_64-python3-cffi \ + mingw-w64-x86_64-python3-numpy \ + mingw-w64-x86_64-python3-olefile \ + mingw-w64-x86_64-python3-pip \ + mingw-w64-x86_64-python3-setuptools \ + mingw-w64-x86_64-python-pyqt6 python3 -m pip install pyroma pytest pytest-cov pytest-timeout pushd depends && ./install_extra_test_images.sh && popd - name: Build Pillow - run: CFLAGS="-coverage" python3 -m pip install --global-option="build_ext" . + run: SETUPTOOLS_USE_DISTUTILS="stdlib" CFLAGS="-coverage" python3 -m pip install . - name: Test Pillow run: | @@ -81,14 +88,4 @@ jobs: with: file: ./coverage.xml flags: GHA_Windows - name: ${{ matrix.name }} - - success: - permissions: - contents: none - needs: build - runs-on: ubuntu-latest - name: MinGW Test Successful - steps: - - name: Success - run: echo MinGW Test Successful + name: "MSYS2 MinGW" diff --git a/.github/workflows/test-valgrind.yml b/.github/workflows/test-valgrind.yml index 219189cf2..59bb958ec 100644 --- a/.github/workflows/test-valgrind.yml +++ b/.github/workflows/test-valgrind.yml @@ -1,14 +1,18 @@ name: Test Valgrind -# like the docker tests, but running valgrind only on *.c/*.h changes. +# like the Docker tests, but running valgrind only on *.c/*.h changes. on: push: + branches: + - "**" paths: + - ".github/workflows/test-valgrind.yml" - "**.c" - "**.h" pull_request: paths: + - ".github/workflows/test-valgrind.yml" - "**.c" - "**.h" workflow_dispatch: @@ -16,7 +20,7 @@ on: permissions: contents: read -concurrency: +concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true @@ -35,7 +39,7 @@ jobs: name: ${{ matrix.docker }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Build system information run: python3 .github/workflows/system-info.py @@ -48,5 +52,5 @@ jobs: run: | # The Pillow user in the docker container is UID 1000 sudo chown -R 1000 $GITHUB_WORKSPACE - docker run --name pillow_container -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 diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml index 487c3586f..86cd5b5fa 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.yml @@ -1,6 +1,23 @@ name: Test Windows -on: [push, pull_request, workflow_dispatch] +on: + push: + paths-ignore: + - ".github/workflows/docs.yml" + - ".github/workflows/wheels*" + - ".gitmodules" + - ".travis.yml" + - "docs/**" + - "wheels/**" + pull_request: + paths-ignore: + - ".github/workflows/docs.yml" + - ".github/workflows/wheels*" + - ".gitmodules" + - ".travis.yml" + - "docs/**" + - "wheels/**" + workflow_dispatch: permissions: contents: read @@ -15,54 +32,54 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] - architecture: ["x86", "x64"] - include: - # PyPy 7.3.4+ only ships 64-bit binaries for Windows - - python-version: "pypy3.8" - architecture: "x64" - - python-version: "pypy3.9" - architecture: "x64" + python-version: ["pypy3.10", "pypy3.9", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] timeout-minutes: 30 - name: Python ${{ matrix.python-version }} ${{ matrix.architecture }} + name: Python ${{ matrix.python-version }} steps: - name: Checkout Pillow - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Checkout cached dependencies - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: repository: python-pillow/pillow-depends path: winbuild\depends + - name: Checkout extra test images + uses: actions/checkout@v4 + with: + repository: python-pillow/test-images + path: Tests\test-images + # sets env: pythonLocation - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - architecture: ${{ matrix.architecture }} + allow-prereleases: true cache: pip cache-dependency-path: ".github/workflows/test-windows.yml" - name: Print build system information run: python3 .github/workflows/system-info.py - - name: python3 -m pip install wheel pytest pytest-cov pytest-timeout defusedxml - run: python3 -m pip install wheel pytest pytest-cov pytest-timeout defusedxml + - name: python3 -m pip install pytest pytest-cov pytest-timeout defusedxml olefile pyroma + run: python3 -m pip install pytest pytest-cov pytest-timeout defusedxml olefile pyroma - name: Install dependencies id: install run: | - 7z x winbuild\depends\nasm-2.15.05-win64.zip "-o$env:RUNNER_WORKSPACE\" - echo "$env:RUNNER_WORKSPACE\nasm-2.15.05" >> $env:GITHUB_PATH + choco install nasm --no-progress + echo "C:\Program Files\NASM" >> $env:GITHUB_PATH - winbuild\depends\gs1000w32.exe /S - echo "C:\Program Files (x86)\gs\gs10.0.0\bin" >> $env:GITHUB_PATH + choco install ghostscript --version=10.0.0.20230317 --no-progress + echo "C:\Program Files\gs\gs10.00.0\bin" >> $env:GITHUB_PATH - xcopy /S /Y winbuild\depends\test_images\* Tests\images\ + # Install extra test images + xcopy /S /Y Tests\test-images\* Tests\images # make cache key depend on VS version & "C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe" ` @@ -81,7 +98,7 @@ jobs: - name: Prepare build if: steps.build-cache.outputs.cache-hit != 'true' run: | - & python.exe winbuild\build_prepare.py -v --python=$env:pythonLocation --srcdir + & python.exe winbuild\build_prepare.py -v shell: pwsh - name: Build dependencies / libjpeg-turbo @@ -149,9 +166,8 @@ jobs: - name: Build Pillow run: | - $FLAGS="" - if ('${{ github.event_name }}' -ne 'pull_request') { $FLAGS="--disable-imagequant" } - & winbuild\build\build_pillow.cmd $FLAGS install + $FLAGS="-C raqm=vendor -C fribidi=vendor" + cmd /c "winbuild\build\build_env.cmd && $env:pythonLocation\python.exe -m pip install -v $FLAGS ." & $env:pythonLocation\python.exe selftest.py --installed shell: pwsh @@ -174,7 +190,7 @@ jobs: shell: bash - name: Upload errors - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: failure() with: name: errors @@ -190,47 +206,7 @@ jobs: with: file: ./coverage.xml flags: GHA_Windows - name: ${{ runner.os }} Python ${{ matrix.python-version }} ${{ matrix.architecture }} - - - name: Build wheel - id: wheel - if: "github.event_name != 'pull_request'" - run: | - mkdir fribidi\${{ matrix.architecture }} - copy winbuild\build\bin\fribidi* fribidi\${{ matrix.architecture }} - setlocal EnableDelayedExpansion - for %%f in (winbuild\build\license\*) do ( - set x=%%~nf - rem Skip FriBiDi license, it is not included in the wheel. - set fribidi=!x:~0,7! - if NOT !fribidi!==fribidi ( - rem Skip imagequant license, it is not included in the wheel. - set libimagequant=!x:~0,13! - if NOT !libimagequant!==libimagequant ( - echo. >> LICENSE - echo ===== %%~nf ===== >> LICENSE - echo. >> LICENSE - type %%f >> LICENSE - ) - ) - ) - for /f "tokens=3 delims=/" %%a in ("${{ github.ref }}") do echo dist=dist-%%a >> %GITHUB_OUTPUT% - winbuild\\build\\build_pillow.cmd --disable-imagequant bdist_wheel - shell: cmd - - - name: Upload wheel - uses: actions/upload-artifact@v3 - if: "github.event_name != 'pull_request'" - with: - name: ${{ steps.wheel.outputs.dist }} - path: dist\*.whl - - - name: Upload fribidi.dll - if: "github.event_name != 'pull_request' && matrix.python-version == 3.11" - uses: actions/upload-artifact@v3 - with: - name: fribidi - path: fribidi\* + name: ${{ runner.os }} Python ${{ matrix.python-version }} success: permissions: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 11c7b77be..aa0e25138 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,6 +1,25 @@ name: Test -on: [push, pull_request, workflow_dispatch] +on: + push: + branches: + - "**" + paths-ignore: + - ".github/workflows/docs.yml" + - ".github/workflows/wheels*" + - ".gitmodules" + - ".travis.yml" + - "docs/**" + - "wheels/**" + pull_request: + paths-ignore: + - ".github/workflows/docs.yml" + - ".github/workflows/wheels*" + - ".gitmodules" + - ".travis.yml" + - "docs/**" + - "wheels/**" + workflow_dispatch: permissions: contents: read @@ -20,16 +39,17 @@ jobs: "ubuntu-latest", ] python-version: [ + "pypy3.10", "pypy3.9", - "pypy3.8", + "3.13", + "3.12", "3.11", "3.10", "3.9", "3.8", - "3.7", ] include: - - python-version: "3.7" + - python-version: "3.9" PYTHONOPTIMIZE: 1 REVERSE: "--reverse" - python-version: "3.8" @@ -39,12 +59,13 @@ jobs: name: ${{ matrix.os }} Python ${{ matrix.python-version }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true cache: pip cache-dependency-path: ".ci/*.sh" @@ -75,7 +96,9 @@ jobs: python3 -m pip install pytest-reverse fi if [ "${{ matrix.os }}" = "ubuntu-latest" ]; then - xvfb-run -s '-screen 0 1024x768x24' .ci/test.sh + xvfb-run -s '-screen 0 1024x768x24' sway& + export WAYLAND_DISPLAY=wayland-1 + .ci/test.sh else .ci/test.sh fi @@ -89,17 +112,12 @@ jobs: mkdir -p Tests/errors - name: Upload errors - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: failure() with: name: errors path: Tests/errors - - name: Docs - if: startsWith(matrix.os, 'ubuntu') && matrix.python-version == 3.11 - run: | - make doccheck - - name: After success run: | .ci/after_success.sh @@ -107,9 +125,9 @@ jobs: - name: Upload coverage uses: codecov/codecov-action@v3 with: - file: ./coverage.xml flags: ${{ matrix.os == 'macos-latest' && 'GHA_macOS' || 'GHA_Ubuntu' }} name: ${{ matrix.os }} Python ${{ matrix.python-version }} + gcov: true success: permissions: diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh new file mode 100755 index 000000000..3ec314873 --- /dev/null +++ b/.github/workflows/wheels-dependencies.sh @@ -0,0 +1,151 @@ +#!/bin/bash +# Define custom utilities +# Test for macOS with [ -n "$IS_MACOS" ] +if [ -z "$IS_MACOS" ]; then + export MB_ML_LIBC=${AUDITWHEEL_POLICY::9} + export MB_ML_VER=${AUDITWHEEL_POLICY:9} +fi +export PLAT=$CIBW_ARCHS +source wheels/multibuild/common_utils.sh +source wheels/multibuild/library_builders.sh +if [ -z "$IS_MACOS" ]; then + source wheels/multibuild/manylinux_utils.sh +fi + +ARCHIVE_SDIR=pillow-depends-main + +# Package versions for fresh source builds +FREETYPE_VERSION=2.13.2 +HARFBUZZ_VERSION=8.3.0 +LIBPNG_VERSION=1.6.40 +JPEGTURBO_VERSION=3.0.1 +OPENJPEG_VERSION=2.5.0 +XZ_VERSION=5.4.5 +TIFF_VERSION=4.6.0 +LCMS2_VERSION=2.16 +if [[ -n "$IS_MACOS" ]]; then + GIFLIB_VERSION=5.1.4 +else + GIFLIB_VERSION=5.2.1 +fi +if [[ -n "$IS_MACOS" ]] || [[ "$MB_ML_VER" != 2014 ]]; then + ZLIB_VERSION=1.3 +else + ZLIB_VERSION=1.2.8 +fi +LIBWEBP_VERSION=1.3.2 +BZIP2_VERSION=1.0.8 +LIBXCB_VERSION=1.16 +BROTLI_VERSION=1.1.0 + +if [[ -n "$IS_MACOS" ]] && [[ "$CIBW_ARCHS" == "x86_64" ]]; then + function build_openjpeg { + local out_dir=$(fetch_unpack https://github.com/uclouvain/openjpeg/archive/v${OPENJPEG_VERSION}.tar.gz openjpeg-2.5.0.tar.gz) + (cd $out_dir \ + && cmake -DCMAKE_INSTALL_PREFIX=$BUILD_PREFIX -DCMAKE_INSTALL_NAME_DIR=$BUILD_PREFIX/lib . \ + && make install) + touch openjpeg-stamp + } +fi + +function build_brotli { + local cmake=$(get_modern_cmake) + local out_dir=$(fetch_unpack https://github.com/google/brotli/archive/v$BROTLI_VERSION.tar.gz brotli-1.1.0.tar.gz) + (cd $out_dir \ + && $cmake -DCMAKE_INSTALL_PREFIX=$BUILD_PREFIX -DCMAKE_INSTALL_NAME_DIR=$BUILD_PREFIX/lib . \ + && make install) + if [[ "$MB_ML_LIBC" == "manylinux" ]]; then + cp /usr/local/lib64/libbrotli* /usr/local/lib + cp /usr/local/lib64/pkgconfig/libbrotli* /usr/local/lib/pkgconfig + fi +} + +function build { + if [[ -n "$IS_MACOS" ]] && [[ "$CIBW_ARCHS" == "arm64" ]]; then + export BUILD_PREFIX="/usr/local" + fi + build_xz + if [ -z "$IS_ALPINE" ] && [ -z "$IS_MACOS" ]; then + yum remove -y zlib-devel + fi + build_new_zlib + + build_simple xcb-proto 1.16.0 https://xorg.freedesktop.org/archive/individual/proto + if [ -n "$IS_MACOS" ]; then + if [[ "$CIBW_ARCHS" == "arm64" ]]; then + build_simple xorgproto 2023.2 https://www.x.org/pub/individual/proto + build_simple libXau 1.0.11 https://www.x.org/pub/individual/lib + build_simple libpthread-stubs 0.5 https://xcb.freedesktop.org/dist + if [ -f /Library/Frameworks/Python.framework/Versions/Current/share/pkgconfig/xcb-proto.pc ]; then + cp /Library/Frameworks/Python.framework/Versions/Current/share/pkgconfig/xcb-proto.pc /Library/Frameworks/Python.framework/Versions/Current/lib/pkgconfig/xcb-proto.pc + fi + fi + else + sed s/\${pc_sysrootdir\}// /usr/local/share/pkgconfig/xcb-proto.pc > /usr/local/lib/pkgconfig/xcb-proto.pc + fi + build_simple libxcb $LIBXCB_VERSION https://www.x.org/releases/individual/lib + + build_libjpeg_turbo + build_tiff + build_libpng + build_lcms2 + if [[ -n "$IS_MACOS" ]] && [[ "$CIBW_ARCHS" == "arm64" ]]; then + for dylib in libjpeg.dylib libtiff.dylib liblcms2.dylib; do + cp $BUILD_PREFIX/lib/$dylib /opt/arm64-builds/lib + done + fi + build_openjpeg + + ORIGINAL_CFLAGS=$CFLAGS + CFLAGS="$CFLAGS -O3 -DNDEBUG" + if [[ -n "$IS_MACOS" ]]; then + CFLAGS="$CFLAGS -Wl,-headerpad_max_install_names" + fi + build_libwebp + CFLAGS=$ORIGINAL_CFLAGS + + build_brotli + + if [ -n "$IS_MACOS" ]; then + # Custom freetype build + build_simple freetype $FREETYPE_VERSION https://download.savannah.gnu.org/releases/freetype tar.gz --with-harfbuzz=no + else + build_freetype + fi + + if [ -z "$IS_MACOS" ]; then + export FREETYPE_LIBS=-lfreetype + export FREETYPE_CFLAGS=-I/usr/local/include/freetype2/ + fi + build_simple harfbuzz $HARFBUZZ_VERSION https://github.com/harfbuzz/harfbuzz/releases/download/$HARFBUZZ_VERSION tar.xz --with-freetype=yes --with-glib=no + if [ -z "$IS_MACOS" ]; then + export FREETYPE_LIBS="" + export FREETYPE_CFLAGS="" + fi +} + +# Any stuff that you need to do before you start building the wheels +# Runs in the root directory of this repository. +curl -fsSL -o pillow-depends-main.zip https://github.com/python-pillow/pillow-depends/archive/main.zip +untar pillow-depends-main.zip + +if [[ -n "$IS_MACOS" ]]; then + # webp, libtiff, libxcb cause a conflict with building webp, libtiff, libxcb + # libxdmcp causes an issue on macOS < 11 + # if php is installed, brew tries to reinstall these after installing openblas + # remove cairo to fix building harfbuzz on arm64 + # remove lcms2 and libpng to fix building openjpeg on arm64 + # remove zstd to avoid inclusion on x86_64 + # curl from brew requires zstd, use system curl + brew remove --ignore-dependencies webp libpng libtiff libxcb libxdmcp curl php cairo lcms2 ghostscript zstd + + brew install pkg-config +fi + +wrap_wheel_builder build + +# Append licenses +for filename in wheels/dependency_licenses/*; do + echo -e "\n\n----\n\n$(basename $filename | cut -f 1 -d '.')\n" | cat >> LICENSE + cat $filename >> LICENSE +done diff --git a/.github/workflows/wheels-test.ps1 b/.github/workflows/wheels-test.ps1 new file mode 100644 index 000000000..f593c7228 --- /dev/null +++ b/.github/workflows/wheels-test.ps1 @@ -0,0 +1,22 @@ +param ([string]$venv, [string]$pillow="C:\pillow") +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +Set-PSDebug -Trace 1 +if ("$venv" -like "*\cibw-run-*\pp*-win_amd64\*") { + # unlike CPython, PyPy requires Visual C++ Redistributable to be installed + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + Invoke-WebRequest -Uri 'https://aka.ms/vs/15/release/vc_redist.x64.exe' -OutFile 'vc_redist.x64.exe' + C:\vc_redist.x64.exe /install /quiet /norestart | Out-Null +} +$env:path += ";$pillow\winbuild\build\bin\" +& "$venv\Scripts\activate.ps1" +& reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options\python.exe" /v "GlobalFlag" /t REG_SZ /d "0x02000000" /f +cd $pillow +& python -VV +if (!$?) { exit $LASTEXITCODE } +& python selftest.py +if (!$?) { exit $LASTEXITCODE } +& python -m pytest -vx Tests\check_wheel.py +if (!$?) { exit $LASTEXITCODE } +& python -m pytest -vx Tests +if (!$?) { exit $LASTEXITCODE } diff --git a/.github/workflows/wheels-test.sh b/.github/workflows/wheels-test.sh new file mode 100755 index 000000000..207ec1567 --- /dev/null +++ b/.github/workflows/wheels-test.sh @@ -0,0 +1,25 @@ +#!/bin/bash +set -e + +if [[ "$OSTYPE" == "darwin"* ]]; then + brew install fribidi + export PKG_CONFIG_PATH="/usr/local/opt/openblas/lib/pkgconfig" +elif [ "${AUDITWHEEL_POLICY::9}" == "musllinux" ]; then + apk add curl fribidi +else + yum install -y fribidi +fi +if [ "${AUDITWHEEL_POLICY::9}" != "musllinux" ]; then + python3 -m pip install numpy +fi + +if [ ! -d "test-images-main" ]; then + curl -fsSL -o pillow-test-images.zip https://github.com/python-pillow/test-images/archive/main.zip + unzip pillow-test-images.zip + mv test-images-main/* Tests/images +fi + +# Runs tests +python3 selftest.py +python3 -m pytest Tests/check_wheel.py +python3 -m pytest diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml new file mode 100644 index 000000000..060fc497e --- /dev/null +++ b/.github/workflows/wheels.yml @@ -0,0 +1,204 @@ +name: Wheels + +on: + push: + paths: + - ".ci/requirements-cibw.txt" + - ".github/workflows/wheel*" + - "wheels/*" + - "winbuild/build_prepare.py" + - "winbuild/fribidi.cmake" + tags: + - "*" + pull_request: + paths: + - ".ci/requirements-cibw.txt" + - ".github/workflows/wheel*" + - "wheels/*" + - "winbuild/build_prepare.py" + - "winbuild/fribidi.cmake" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + FORCE_COLOR: 1 + +jobs: + build: + name: ${{ matrix.name }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - name: "macOS x86_64" + os: macos-latest + archs: x86_64 + macosx_deployment_target: "10.10" + - name: "macOS arm64" + os: macos-latest + archs: arm64 + macosx_deployment_target: "11.0" + - name: "manylinux2014 and musllinux x86_64" + os: ubuntu-latest + archs: x86_64 + - name: "manylinux_2_28 x86_64" + os: ubuntu-latest + archs: x86_64 + build: "*manylinux*" + manylinux: "manylinux_2_28" + steps: + - uses: actions/checkout@v4 + with: + submodules: true + + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Build wheels + run: | + python3 -m pip install -r .ci/requirements-cibw.txt + python3 -m cibuildwheel --output-dir wheelhouse + env: + CIBW_ARCHS: ${{ matrix.archs }} + CIBW_BUILD: ${{ matrix.build }} + CIBW_MANYLINUX_PYPY_X86_64_IMAGE: ${{ matrix.manylinux }} + CIBW_MANYLINUX_X86_64_IMAGE: ${{ matrix.manylinux }} + CIBW_SKIP: pp38-* + CIBW_TEST_SKIP: "*-macosx_arm64" + MACOSX_DEPLOYMENT_TARGET: ${{ matrix.macosx_deployment_target }} + + - uses: actions/upload-artifact@v4 + with: + name: dist-${{ matrix.os }}-${{ matrix.archs }}${{ matrix.manylinux && format('-{0}', matrix.manylinux) }} + path: ./wheelhouse/*.whl + + windows: + name: Windows ${{ matrix.arch }} + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + include: + - arch: x86 + cibw_arch: x86 + - arch: x64 + cibw_arch: AMD64 + - arch: ARM64 + cibw_arch: ARM64 + steps: + - uses: actions/checkout@v4 + + - name: Checkout extra test images + uses: actions/checkout@v4 + with: + repository: python-pillow/test-images + path: Tests\test-images + + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Prepare for build + run: | + choco install nasm --no-progress + echo "C:\Program Files\NASM" >> $env:GITHUB_PATH + + # Install extra test images + xcopy /S /Y Tests\test-images\* Tests\images + + & python.exe -m pip install -r .ci/requirements-cibw.txt + + & python.exe winbuild\build_prepare.py -v --no-imagequant --architecture=${{ matrix.arch }} + shell: pwsh + + - name: Build wheels + run: | + setlocal EnableDelayedExpansion + for %%f in (winbuild\build\license\*) do ( + set x=%%~nf + rem Skip FriBiDi license, it is not included in the wheel. + set fribidi=!x:~0,7! + if NOT !fribidi!==fribidi ( + rem Skip imagequant license, it is not included in the wheel. + set libimagequant=!x:~0,13! + if NOT !libimagequant!==libimagequant ( + echo. >> LICENSE + echo ===== %%~nf ===== >> LICENSE + echo. >> LICENSE + type %%f >> LICENSE + ) + ) + ) + call winbuild\\build\\build_env.cmd + %pythonLocation%\python.exe -m cibuildwheel . --output-dir wheelhouse + env: + CIBW_ARCHS: ${{ matrix.cibw_arch }} + CIBW_BEFORE_ALL: "{package}\\winbuild\\build\\build_dep_all.cmd" + CIBW_CACHE_PATH: "C:\\cibw" + CIBW_TEST_SKIP: "*-win_arm64" + CIBW_TEST_COMMAND: 'docker run --rm + -v {project}:C:\pillow + -v C:\cibw:C:\cibw + -v %CD%\..\venv-test:%CD%\..\venv-test + -e CI -e GITHUB_ACTIONS + mcr.microsoft.com/windows/servercore:ltsc2022 + powershell C:\pillow\.github\workflows\wheels-test.ps1 %CD%\..\venv-test' + shell: cmd + + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: dist-windows-${{ matrix.arch }} + path: ./wheelhouse/*.whl + + - name: Upload fribidi.dll + uses: actions/upload-artifact@v4 + with: + name: fribidi-windows-${{ matrix.arch }} + path: winbuild\build\bin\fribidi* + + sdist: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + cache: pip + cache-dependency-path: "Makefile" + + - run: make sdist + + - uses: actions/upload-artifact@v4 + with: + name: dist-sdist + path: dist/*.tar.gz + + pypi-publish: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') + needs: [build, windows, sdist] + runs-on: ubuntu-latest + name: Upload release to PyPI + environment: + name: release-pypi + url: https://pypi.org/p/Pillow + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + pattern: dist-* + path: dist + merge-multiple: true + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore index 790404535..1dd6c9175 100644 --- a/.gitignore +++ b/.gitignore @@ -79,7 +79,7 @@ docs/_build/ # JetBrains .idea -# Extra test images installed from pillow-depends/test_images +# Extra test images installed from python-pillow/test-images Tests/images/README.md Tests/images/crash_1.tif Tests/images/crash_2.tif diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..80d5ab16c --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "multibuild"] + path = wheels/multibuild + url = https://github.com/multi-build/multibuild.git diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 609352f22..d1c4b8015 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,62 +1,63 @@ repos: - - repo: https://github.com/psf/black - rev: 22.12.0 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.1.7 + hooks: + - id: ruff + args: [--fix, --exit-non-zero-on-fix] + + - repo: https://github.com/psf/black-pre-commit-mirror + rev: 23.12.0 hooks: - id: black - args: [--target-version=py37] - # Only .py files, until https://github.com/psf/black/issues/402 resolved - files: \.py$ - types: [] - - - repo: https://github.com/PyCQA/isort - rev: 5.11.1 - hooks: - - id: isort - repo: https://github.com/PyCQA/bandit - rev: 1.7.4 + rev: 1.7.6 hooks: - id: bandit args: [--severity-level=high] files: ^src/ - - repo: https://github.com/asottile/yesqa - rev: v1.4.0 - hooks: - - id: yesqa - - repo: https://github.com/Lucas-C/pre-commit-hooks - rev: v1.3.1 + rev: v1.5.4 hooks: - id: remove-tabs - exclude: (Makefile$|\.bat$|\.cmake$|\.eps$|\.fits$|\.opt$) - - - repo: https://github.com/PyCQA/flake8 - rev: 6.0.0 - hooks: - - id: flake8 - additional_dependencies: [flake8-2020, flake8-implicit-str-concat] + exclude: (Makefile$|\.bat$|\.cmake$|\.eps$|\.fits$|\.gd$|\.opt$) - repo: https://github.com/pre-commit/pygrep-hooks - rev: v1.9.0 + rev: v1.10.0 hooks: - - id: python-check-blanket-noqa - id: rst-backticks - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v4.5.0 hooks: + - id: check-executables-have-shebangs - id: check-merge-conflict - id: check-json + - id: check-toml - id: check-yaml + - id: end-of-file-fixer + exclude: ^Tests/images/ + - id: trailing-whitespace + exclude: ^.github/.*TEMPLATE|^Tests/(fonts|images)/ - repo: https://github.com/sphinx-contrib/sphinx-lint - rev: v0.6.7 + rev: v0.9.1 hooks: - id: sphinx-lint + - repo: https://github.com/tox-dev/pyproject-fmt + rev: 1.5.3 + hooks: + - id: pyproject-fmt + + - repo: https://github.com/abravalheri/validate-pyproject + rev: v0.15 + hooks: + - id: validate-pyproject + - repo: https://github.com/tox-dev/tox-ini-fmt - rev: 0.5.2 + rev: 1.3.1 hooks: - id: tox-ini-fmt diff --git a/.readthedocs.yml b/.readthedocs.yml index 0f581ebba..0c8f935d5 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -1,5 +1,12 @@ version: 2 +formats: [pdf] + +build: + os: ubuntu-22.04 + tools: + python: "3" + python: install: - method: pip diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 000000000..8f8250809 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,52 @@ +if: tag IS present OR type = api + +env: + global: + - CIBW_ARCHS=aarch64 + - CIBW_SKIP=pp38-* + +language: python +# Default Python version is usually 3.6 +python: "3.12" +dist: jammy +services: docker + +jobs: + include: + - name: "manylinux2014 aarch64" + os: linux + arch: arm64 + env: + - CIBW_BUILD="*manylinux*" + - CIBW_MANYLINUX_AARCH64_IMAGE=manylinux2014 + - CIBW_MANYLINUX_PYPY_AARCH64_IMAGE=manylinux2014 + - name: "manylinux_2_28 aarch64" + os: linux + arch: arm64 + env: + - CIBW_BUILD="*manylinux*" + - CIBW_MANYLINUX_AARCH64_IMAGE=manylinux_2_28 + - CIBW_MANYLINUX_PYPY_AARCH64_IMAGE=manylinux_2_28 + - name: "musllinux aarch64" + os: linux + arch: arm64 + env: + - CIBW_BUILD="*musllinux*" + +install: + - python3 -m pip install -r .ci/requirements-cibw.txt + +script: + - python3 -m cibuildwheel --output-dir wheelhouse + - ls -l "${TRAVIS_BUILD_DIR}/wheelhouse/" + +# Upload wheels to GitHub Releases +deploy: + provider: releases + api_key: $GITHUB_RELEASE_TOKEN + file_glob: true + file: "${TRAVIS_BUILD_DIR}/wheelhouse/*.whl" + on: + repo: python-pillow/Pillow + tags: true + skip_cleanup: true diff --git a/CHANGES.rst b/CHANGES.rst index 04b3fc4c6..df4e11e0e 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,9 +2,474 @@ Changelog (Pillow) ================== -9.4.0 (unreleased) +10.2.0 (unreleased) +------------------- + +- Fix incorrect color blending for overlapping glyphs #7497 + [ZachNagengast, nulano, radarhere] + +- Attempt memory mapping when tile args is a string #7565 + [radarhere] + +- Fill identical pixels with transparency in subsequent frames when saving GIF #7568 + [radarhere] + +- Corrected duration when combining multiple GIF frames into single frame #7521 + [radarhere] + +- Handle disposing GIF background from outside palette #7515 + [radarhere] + +- Seek past the data when skipping a PSD layer #7483 + [radarhere] + +- Import plugins relative to the module #7576 + [deliangyang, jaxx0n] + +- Translate encoder error codes to strings; deprecate ``ImageFile.raise_oserror()`` #7609 + [bgilbert, radarhere] + +- Support reading BC4U and DX10 BC1 images #6486 + [REDxEYE, radarhere, hugovk] + +- Optimize ImageStat.Stat.extrema #7593 + [florath, radarhere] + +- Handle pathlib.Path in FreeTypeFont #7578 + [radarhere, hugovk, nulano] + +- Added support for reading DX10 BC4 DDS images #7603 + [sambvfx, radarhere] + +- Optimized ImageStat.Stat.count #7599 + [florath] + +- Correct PDF palette size when saving #7555 + [radarhere] + +- Fixed closing file pointer with olefile 0.47 #7594 + [radarhere] + +- Raise ValueError when TrueType font size is not greater than zero #7584, #7587 + [akx, radarhere] + +- If absent, do not try to close fp when closing image #7557 + [RaphaelVRossi, radarhere] + +- Allow configuring JPEG restart marker interval on save #7488 + [bgilbert, radarhere] + +- Decrement reference count for PyObject #7549 + [radarhere] + +- Implement ``streamtype=1`` option for tables-only JPEG encoding #7491 + [bgilbert, radarhere] + +- If save_all PNG only has one frame, do not create animated image #7522 + [radarhere] + +- Fixed frombytes() for images with a zero dimension #7493 + [radarhere] + +10.1.0 (2023-10-15) +------------------- + +- Added TrueType default font to allow for different sizes #7354 + [radarhere] + +- Fixed invalid argument warning #7442 + [radarhere] + +- Added ImageOps cover method #7412 + [radarhere, hugovk] + +- Catch struct.error from truncated EXIF when reading JPEG DPI #7458 + [radarhere] + +- Consider default image when selecting mode for PNG save_all #7437 + [radarhere] + +- Support BGR;15, BGR;16 and BGR;24 access, unpacking and putdata #7303 + [radarhere] + +- Added CMYK to RGB unpacker #7310 + [radarhere] + +- Improved flexibility of XMP parsing #7274 + [radarhere] + +- Support reading 8-bit YCbCr TIFF images #7415 + [radarhere] + +- Allow saving I;16B images as PNG #7302 + [radarhere] + +- Corrected drawing I;16 points and writing I;16 text #7257 + [radarhere] + +- Set blue channel to 128 for BC5S #7413 + [radarhere] + +- Increase flexibility when reading IPTC fields #7319 + [radarhere] + +- Set C palette to be empty by default #7289 + [radarhere] + +- Added gs_binary to control Ghostscript use on all platforms #7392 + [radarhere] + +- Read bounding box information from the trailer of EPS files if specified #7382 + [nopperl, radarhere] + +- Added reading 8-bit color DDS images #7426 + [radarhere] + +- Added has_transparency_data #7420 + [radarhere, hugovk] + +- Fixed bug when reading BC5S DDS images #7401 + [radarhere] + +- Prevent TIFF orientation from being applied more than once #7383 + [radarhere] + +- Use previous pixel alpha for QOI_OP_RGB #7357 + [radarhere] + +- Added BC5U reading #7358 + [radarhere] + +- Allow getpixel() to accept a list #7355 + [radarhere, homm] + +- Allow GaussianBlur and BoxBlur to accept a sequence of x and y radii #7336 + [radarhere] + +- Expand JPEG buffer size when saving optimized or progressive #7345 + [radarhere] + +- Added session type check for Linux in ImageGrab.grabclipboard() #7332 + [TheNooB2706, radarhere, hugovk] + +- Allow "loop=None" when saving GIF images #7329 + [radarhere] + +- Fixed transparency when saving P mode images to PDF #7323 + [radarhere] + +- Added saving LA images as PDFs #7299 + [radarhere] + +- Set SMaskInData to 1 for PDFs with alpha #7316, #7317 + [radarhere] + +- Changed Image mode property to be read-only by default #7307 + [radarhere] + +- Silence exceptions in _repr_jpeg_ and _repr_png_ #7266 + [mtreinish, radarhere] + +- Do not use transparency when saving GIF if it has been removed when normalizing mode #7284 + [radarhere] + +- Fix missing symbols when libtiff depends on libjpeg #7270 + [heitbaum] + +10.0.1 (2023-09-15) +------------------- + +- Updated libwebp to 1.3.2 #7395 + [radarhere] + +- Updated zlib to 1.3 #7344 + [radarhere] + +10.0.0 (2023-07-01) +------------------- + +- Fixed deallocating mask images #7246 + [radarhere] + +- Added ImageFont.MAX_STRING_LENGTH #7244 + [radarhere, hugovk] + +- Fix Windows build with pyproject.toml #7230 + [hugovk, nulano, radarhere] + +- Do not close provided file handles with libtiff #7199 + [radarhere] + +- Convert to HSV if mode is HSV in getcolor() #7226 + [radarhere] + +- Added alpha_only argument to getbbox() #7123 + [radarhere. hugovk] + +- Prioritise speed in _repr_png_ #7242 + [radarhere] + +- Do not use CFFI access by default on PyPy #7236 + [radarhere] + +- Limit size even if one dimension is zero in decompression bomb check #7235 + [radarhere] + +- Use --config-settings instead of deprecated --global-option #7171 + [radarhere] + +- Better C integer definitions #6645 + [Yay295, hugovk] + +- Fixed finding dependencies on Cygwin #7175 + [radarhere] + +- Changed grabclipboard() to use PNG instead of JPG compression on macOS #7219 + [abey79, radarhere] + +- Added in_place argument to ImageOps.exif_transpose() #7092 + [radarhere] + +- Fixed calling putpalette() on L and LA images before load() #7187 + [radarhere] + +- Fixed saving TIFF multiframe images with LONG8 tag types #7078 + [radarhere] + +- Fixed combining single duration across duplicate APNG frames #7146 + [radarhere] + +- Remove temporary file when error is raised #7148 + [radarhere] + +- Do not use temporary file when grabbing clipboard on Linux #7200 + [radarhere] + +- If the clipboard fails to open on Windows, wait and try again #7141 + [radarhere] + +- Fixed saving multiple 1 mode frames to GIF #7181 + [radarhere] + +- Replaced absolute PIL import with relative import #7173 + [radarhere] + +- Replaced deprecated Py_FileSystemDefaultEncoding for Python >= 3.12 #7192 + [radarhere] + +- Improved wl-paste mimetype handling in ImageGrab #7094 + [rrcgat, radarhere] + +- Added _repr_jpeg_() for IPython display_jpeg #7135 + [n3011, radarhere, nulano] + +- Use "/sbin/ldconfig" if ldconfig is not found #7068 + [radarhere] + +- Prefer screenshots using XCB over gnome-screenshot #7143 + [nulano, radarhere] + +- Fixed joined corners for ImageDraw rounded_rectangle() odd dimensions #7151 + [radarhere] + +- Support reading signed 8-bit TIFF images #7111 + [radarhere] + +- Added width argument to ImageDraw regular_polygon #7132 + [radarhere] + +- Support I mode for ImageFilter.BuiltinFilter #7108 + [radarhere] + +- Raise error from stderr of Linux ImageGrab.grabclipboard() command #7112 + [radarhere] + +- Added unpacker from I;16B to I;16 #7125 + [radarhere] + +- Support float font sizes #7107 + [radarhere] + +- Use later value for duplicate xref entries in PdfParser #7102 + [radarhere] + +- Load before getting size in __getstate__ #7105 + [bigcat88, radarhere] + +- Fixed type handling for include and lib directories #7069 + [adisbladis, radarhere] + +- Remove deprecations for Pillow 10.0.0 #7059, #7080 + [hugovk, radarhere] + +- Drop support for soon-EOL Python 3.7 #7058 + [hugovk, radarhere] + +9.5.0 (2023-04-01) ------------------ +- Added ImageSourceData to TAGS_V2 #7053 + [radarhere] + +- Clear PPM half token after use #7052 + [radarhere] + +- Removed absolute path to ldconfig #7044 + [radarhere] + +- Support custom comments and PLT markers when saving JPEG2000 images #6903 + [joshware, radarhere, hugovk] + +- Load before getting size in __array_interface__ #7034 + [radarhere] + +- Support creating BGR;15, BGR;16 and BGR;24 images, but drop support for BGR;32 #7010 + [radarhere] + +- Consider transparency when applying APNG blend mask #7018 + [radarhere] + +- Round duration when saving animated WebP images #6996 + [radarhere] + +- Added reading of JPEG2000 comments #6909 + [radarhere] + +- Decrement reference count #7003 + [radarhere, nulano] + +- Allow libtiff_support_custom_tags to be missing #7020 + [radarhere] + +- Improved I;16N support #6834 + [radarhere] + +- Added QOI reading #6852 + [radarhere, hugovk] + +- Added saving RGBA images as PDFs #6925 + [radarhere] + +- Do not raise an error if os.environ does not contain PATH #6935 + [radarhere, hugovk] + +- Close OleFileIO instance when closing or exiting FPX or MIC #7005 + [radarhere] + +- Added __int__ to IFDRational for Python >= 3.11 #6998 + [radarhere] + +- Added memoryview support to Dib.frombytes() #6988 + [radarhere, nulano] + +- Close file pointer copy in the libtiff encoder if still open #6986 + [fcarron, radarhere] + +- Raise an error if ImageDraw co-ordinates are incorrectly ordered #6978 + [radarhere] + +- Added "corners" argument to ImageDraw rounded_rectangle() #6954 + [radarhere] + +- Added memoryview support to frombytes() #6974 + [radarhere] + +- Allow comments in FITS images #6973 + [radarhere] + +- Support saving PDF with different X and Y resolutions #6961 + [jvanderneutstulen, radarhere, hugovk] + +- Fixed writing int as UNDEFINED tag #6950 + [radarhere] + +- Raise an error if EXIF data is too long when saving JPEG #6939 + [radarhere] + +- Handle more than one directory returned by pkg-config #6896 + [sebastic, radarhere] + +- Do not retry past formats when loading all formats for the first time #6902 + [radarhere] + +- Do not retry specified formats if they failed when opening #6893 + [radarhere] + +- Do not unintentionally load TIFF format at first #6892 + [radarhere] + +- Stop reading when EPS line becomes too long #6897 + [radarhere] + +- Allow writing IFDRational to BYTE tag #6890 + [radarhere] + +- Raise ValueError for BoxBlur filter with negative radius #6874 + [hugovk, radarhere] + +- Support arbitrary number of loaded modules on Windows #6761 + [javidcf, radarhere, nulano] + +9.4.0 (2023-01-02) +------------------ + +- Fixed null pointer dereference crash with malformed font #6846 + [wiredfool, radarhere] + +- Return from ImagingFill early if image has a zero dimension #6842 + [radarhere] + +- Reversed deprecations for Image constants, except for duplicate Resampling attributes #6830 + [radarhere] + +- Improve exception traceback readability #6836 + [hugovk, radarhere] + +- Do not attempt to read IFD1 if absent #6840 + [radarhere] + +- Fixed writing int as ASCII tag #6800 + [radarhere] + +- If available, use wl-paste or xclip for grabclipboard() on Linux #6783 + [radarhere] + +- Added signed option when saving JPEG2000 images #6709 + [radarhere] + +- Patch OpenJPEG to include ARM64 fix #6718 + [radarhere] + +- Added support for I;16 modes in putdata() #6825 + [radarhere] + +- Added conversion from RGBa to RGB #6708 + [radarhere] + +- Added DDS support for uncompressed L and LA images #6820 + [radarhere, REDxEYE] + +- Added LightSource tag values to ExifTags #6749 + [radarhere] + +- Fixed PyAccess after changing ICO size #6821 + [radarhere] + +- Do not use EXIF from info when saving PNG images #6819 + [radarhere] + +- Fixed saving EXIF data to MPO #6817 + [radarhere] + +- Added Exif hide_offsets() #6762 + [radarhere] + +- Only compare to previous frame when checking for duplicate GIF frames while saving #6787 + [radarhere] + +- Always initialize all plugins in registered_extensions() #6811 + [radarhere] + - Ignore non-opaque WebP background when saving as GIF #6792 [radarhere] @@ -1795,7 +2260,7 @@ Changelog (Pillow) - Cache EXIF information #3498 [Glandos] -- Added transparency for all PNG greyscale modes #3744 +- Added transparency for all PNG grayscale modes #3744 [radarhere] - Fix deprecation warnings in Python 3.8 #3749 @@ -4297,7 +4762,7 @@ Changelog (Pillow) - Fix Bicubic interpolation #970 [homm] -- Support for 4-bit greyscale TIFF images #980 +- Support for 4-bit grayscale TIFF images #980 [hugovk] - Updated manifest #957 @@ -5447,8 +5912,8 @@ http://svn.effbot.org/public/pil/ a polyline, independent of line angle. - Fixed bearing calculation and clipping in the ImageFont truetype - renderer; this could lead to clipped text, or crashes in the low- - level _imagingft module. (based on input from Adam Twardoch and + renderer; this could lead to clipped text, or crashes in the low-level + _imagingft module. (based on input from Adam Twardoch and others). - Added ImageQt wrapper module, for converting PIL Image objects to @@ -5529,8 +5994,7 @@ http://svn.effbot.org/public/pil/ 1.1.5c2 and 1.1.5 final ----------------------- -- Added experimental PERSPECTIVE transform method (from Jeff Breiden- - bach). +- Added experimental PERSPECTIVE transform method (from Jeff Breidenbach). 1.1.5c1 ------- @@ -5602,8 +6066,8 @@ http://svn.effbot.org/public/pil/ - Fixed BILINEAR/BICUBIC/ANTIALIAS filtering for mode "LA". -- Added "getcolors()" method. This is similar to the existing histo- - gram method, but looks at color values instead of individual layers, +- Added "getcolors()" method. This is similar to the existing histogram + method, but looks at color values instead of individual layers, and returns an unsorted list of (count, color) tuples. By default, the method returns None if finds more than 256 colors. @@ -5819,8 +6283,8 @@ http://svn.effbot.org/public/pil/ - Added limited support for "bitfield compression" in BMP files and DIB buffers, for 15-bit, 16-bit, and 32-bit images. This - also fixes a problem with ImageGrab module when copying screen- - dumps from the clipboard on 15/16/32-bit displays. + also fixes a problem with ImageGrab module when copying screendumps + from the clipboard on 15/16/32-bit displays. - Added experimental WAL (Quake 2 textures) loader. To use this loader, import WalImageFile and call the "open" method in that @@ -5931,8 +6395,8 @@ http://svn.effbot.org/public/pil/ 1.1.3 final ----------- -- Made setup.py look for old versions of zlib. For some back- - ground, see: https://zlib.net/advisory-2002-03-11.txt +- Made setup.py look for old versions of zlib. For some background, + see: https://zlib.net/advisory-2002-03-11.txt 1.1.3c2 ------- @@ -6123,8 +6587,8 @@ http://svn.effbot.org/public/pil/ supports all major PIL image modes (including F and I). - The ImageFile module now includes a Parser class, which can - be used to incrementally decode an image file (while down- - loading it from the net, for example). See the handbook for + be used to incrementally decode an image file (while downloading + it from the net, for example). See the handbook for details. - "show" now converts non-standard modes to "L" or "RGB" (as @@ -6262,8 +6726,8 @@ http://svn.effbot.org/public/pil/ - The Image "transform" method now supports Image.QUAD transforms. The data argument is an 8-tuple giving the upper left, lower - left, lower right, and upper right corner of the source quadri- - lateral. Also added Image.MESH transform which takes a list + left, lower right, and upper right corner of the source quadrilateral. + Also added Image.MESH transform which takes a list of quadrilaterals. - The Image "resize", "rotate", and "transform" methods now support @@ -6373,7 +6837,7 @@ The test suite includes 750 individual tests. - You can now convert directly between all modes supported by PIL. When converting colour images to "P", PIL defaults to - a "web" palette and dithering. When converting greyscale + a "web" palette and dithering. When converting grayscale images to "1", PIL uses a thresholding and dithering. - Added a "dither" option to "convert". By default, "convert" @@ -6451,13 +6915,13 @@ The test suite includes 530 individual tests. - Fixed "paste" to allow a mask also for mode "F" images. - The BMP driver now saves mode "1" images. When loading images, the mode - is set to "L" for 8-bit files with greyscale palettes, and to "P" for + is set to "L" for 8-bit files with grayscale palettes, and to "P" for other 8-bit files. - The IM driver now reads and saves "1" images (file modes "0 1" or "L 1"). - The JPEG and GIF drivers now saves "1" images. For JPEG, the image - is saved as 8-bit greyscale (it will load as mode "L"). For GIF, the + is saved as 8-bit grayscale (it will load as mode "L"). For GIF, the image will be loaded as a "P" image. - Fixed a potential buffer overrun in the GIF encoder. @@ -6488,8 +6952,8 @@ The test suite includes 400 individual tests. neither "short", "int" nor "long" are 32-bit wide. - Added file= and data= keyword arguments to PhotoImage and BitmapImage. - This allows you to use them as drop-in replacements for the corre- - sponding Tkinter classes. + This allows you to use them as drop-in replacements for the corresponding + Tkinter classes. - Removed bogus references to the crack coder (ImagingCrack). @@ -6761,7 +7225,7 @@ The test suite includes 400 individual tests. drawing capabilities can be used to render vector and metafile formats. -- Added restricted drivers for images from Image Tools (greyscale +- Added restricted drivers for images from Image Tools (grayscale only) and LabEye/IFUNC (common interchange modes only). - Some minor improvements to the sample scripts provided in the diff --git a/LICENSE b/LICENSE index 40aabc323..cf65e86d7 100644 --- a/LICENSE +++ b/LICENSE @@ -5,7 +5,7 @@ The Python Imaging Library (PIL) is Pillow is the friendly PIL fork. It is - Copyright © 2010-2022 by Alex Clark and contributors + Copyright © 2010-2023 by Jeffrey A. Clark (Alex) and contributors. Like PIL, Pillow is licensed under the open source HPND License: @@ -13,8 +13,8 @@ By obtaining, using, and/or copying this software and/or its associated documentation, you agree that you have read, understood, and will comply with the following terms and conditions: -Permission to use, copy, modify, and distribute this software and its -associated documentation for any purpose and without fee is hereby granted, +Permission to use, copy, modify and distribute this software and its +documentation for any purpose and without fee is hereby granted, provided that the above copyright notice appears in all copies, and that both that copyright notice and this permission notice appear in supporting documentation, and that the name of Secret Labs AB or the author not be diff --git a/MANIFEST.in b/MANIFEST.in index f51551303..af25dfd2d 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -5,8 +5,10 @@ include *.md include *.py include *.rst include *.sh +include *.toml include *.txt include *.yaml +include .flake8 include LICENSE include Makefile include tox.ini @@ -15,6 +17,7 @@ graft src graft depends graft winbuild graft docs +graft _custom_build # build/src control detritus exclude .appveyor.yml @@ -28,3 +31,4 @@ global-exclude .git* global-exclude *.pyc global-exclude *.so prune .ci +prune wheels diff --git a/Makefile b/Makefile index a2545b54e..ad0a1adab 100644 --- a/Makefile +++ b/Makefile @@ -16,10 +16,16 @@ coverage: python3 -m coverage report .PHONY: doc -doc: +.PHONY: html +doc html: python3 -c "import PIL" > /dev/null 2>&1 || python3 -m pip install . $(MAKE) -C docs html +.PHONY: htmlview +htmlview: + python3 -c "import PIL" > /dev/null 2>&1 || python3 -m pip install . + $(MAKE) -C docs htmlview + .PHONY: doccheck doccheck: $(MAKE) doc @@ -38,19 +44,15 @@ help: @echo " coverage run coverage test (in progress)" @echo " doc make HTML docs" @echo " docserve run an HTTP server on the docs directory" - @echo " html to make standalone HTML files" - @echo " inplace make inplace extension" + @echo " html make HTML docs" + @echo " htmlview open the index page built by the html target in your browser" @echo " install make and install" @echo " install-coverage make and install with C coverage" @echo " lint run the lint checks" - @echo " lint-fix run Black and isort to (mostly) fix lint issues" + @echo " lint-fix run Ruff to (mostly) fix lint issues" @echo " release-test run code and package tests before release" @echo " test run tests on installed Pillow" -.PHONY: inplace -inplace: clean - python3 -m pip install -e --global-option="build_ext" --global-option="--inplace" . - .PHONY: install install: python3 -m pip -v install . @@ -58,7 +60,7 @@ install: .PHONY: install-coverage install-coverage: - CFLAGS="-coverage -Werror=implicit-function-declaration" python3 -m pip -v install --global-option="build_ext" . + CFLAGS="-coverage -Werror=implicit-function-declaration" python3 -m pip -v install . python3 selftest.py .PHONY: debug @@ -67,10 +69,11 @@ debug: # for our stuff, kills optimization, and redirects to dev null so we # see any build failures. make clean > /dev/null - CFLAGS='-g -O0' python3 -m pip -v install --global-option="build_ext" . > /dev/null + CFLAGS='-g -O0' python3 -m pip -v install . > /dev/null .PHONY: release-test release-test: + python3 Tests/check_release_notes.py python3 -m pip install -e .[tests] python3 selftest.py python3 -m pytest Tests @@ -115,6 +118,6 @@ lint: .PHONY: lint-fix lint-fix: python3 -c "import black" > /dev/null 2>&1 || python3 -m pip install black - python3 -c "import isort" > /dev/null 2>&1 || python3 -m pip install isort - python3 -m black --target-version py37 . - python3 -m isort . + python3 -m black . + python3 -c "import ruff" > /dev/null 2>&1 || python3 -m pip install ruff + python3 -m ruff --fix . diff --git a/README.md b/README.md index 8ee68f9b8..e11bd2faa 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ ## Python Imaging Library (Fork) -Pillow is the friendly PIL fork by [Alex Clark and -Contributors](https://github.com/python-pillow/Pillow/graphs/contributors). +Pillow is the friendly PIL fork by [Jeffrey A. Clark (Alex) and +contributors](https://github.com/python-pillow/Pillow/graphs/contributors). PIL is the Python Imaging Library by Fredrik Lundh and Contributors. As of 2019, Pillow development is [supported by Tidelift](https://tidelift.com/subscription/pkg/pypi-pillow?utm_source=pypi-pillow&utm_medium=readme&utm_campaign=enterprise). @@ -45,12 +45,12 @@ As of 2019, Pillow development is AppVeyor CI build status (Windows) - GitHub Actions wheels build status (Wheels) - GitHub Actions build status (Wheels) + Travis CI wheels build status (aarch64) + src="https://img.shields.io/travis/com/python-pillow/Pillow/main.svg?label=aarch64%20wheels"> Code coverage @@ -74,9 +74,9 @@ As of 2019, Pillow development is Number of PyPI downloads - OpenSSF Best Practices + src="https://www.bestpractices.dev/projects/6331/badge"> @@ -88,6 +88,10 @@ As of 2019, Pillow development is Follow on https://twitter.com/PythonPillow + Follow on https://fosstodon.org/@pillow diff --git a/RELEASING.md b/RELEASING.md index b05067484..74f427f03 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -10,30 +10,28 @@ Released quarterly on January 2nd, April 1st, July 1st and October 15th. * [ ] Open a release ticket e.g. https://github.com/python-pillow/Pillow/issues/3154 * [ ] Develop and prepare release in `main` branch. * [ ] Check [GitHub Actions](https://github.com/python-pillow/Pillow/actions) and [AppVeyor](https://ci.appveyor.com/project/python-pillow/Pillow) to confirm passing tests in `main` branch. -* [ ] Check that all of the wheel builds [Pillow Wheel Builder](https://github.com/python-pillow/pillow-wheels) pass the tests in Travis CI and GitHub Actions. -* [ ] In compliance with [PEP 440](https://www.python.org/dev/peps/pep-0440/), update version identifier in `src/PIL/_version.py` +* [ ] Check that all of the wheel builds pass the tests in the [GitHub Actions "Wheels" workflow](https://github.com/python-pillow/Pillow/actions/workflows/wheels.yml) and [Travis CI](https://app.travis-ci.com/github/python-pillow/pillow) jobs by manually triggering them. +* [ ] In compliance with [PEP 440](https://peps.python.org/pep-0440/), update version identifier in `src/PIL/_version.py` * [ ] Update `CHANGES.rst`. * [ ] Run pre-release check via `make release-test` in a freshly cloned repo. * [ ] Create branch and tag for release e.g.: ```bash git branch 5.2.x git tag 5.2.0 - git push --all git push --tags ``` -* [ ] Create and check source distribution: - ```bash - make sdist - ``` -* [ ] Create [binary distributions](https://github.com/python-pillow/Pillow/blob/main/RELEASING.md#binary-distributions) -* [ ] Check and upload all binaries and source distributions e.g.: +* [ ] Create [source and binary distributions](https://github.com/python-pillow/Pillow/blob/main/RELEASING.md#source-and-binary-distributions) +* [ ] Check and upload all source and binary distributions e.g.: ```bash python3 -m twine check --strict dist/* python3 -m twine upload dist/Pillow-5.2.0* ``` * [ ] Publish the [release on GitHub](https://github.com/python-pillow/Pillow/releases) -* [ ] In compliance with [PEP 440](https://www.python.org/dev/peps/pep-0440/), increment and append `.dev0` to version identifier in `src/PIL/_version.py` - +* [ ] In compliance with [PEP 440](https://peps.python.org/pep-0440/), + increment and append `.dev0` to version identifier in `src/PIL/_version.py` and then: + ```bash + git push --all + ``` ## Point Release Released as needed for security, installation or critical bug fixes. @@ -45,29 +43,28 @@ Released as needed for security, installation or critical bug fixes. git checkout -t remotes/origin/5.2.x ``` * [ ] Cherry pick individual commits from `main` branch to release branch e.g. `5.2.x`, then `git push`. - - - * [ ] Check [GitHub Actions](https://github.com/python-pillow/Pillow/actions) and [AppVeyor](https://ci.appveyor.com/project/python-pillow/Pillow) to confirm passing tests in release branch e.g. `5.2.x`. -* [ ] In compliance with [PEP 440](https://www.python.org/dev/peps/pep-0440/), update version identifier in `src/PIL/_version.py` +* [ ] In compliance with [PEP 440](https://peps.python.org/pep-0440/), update version identifier in `src/PIL/_version.py` * [ ] Run pre-release check via `make release-test`. * [ ] Create tag for release e.g.: ```bash git tag 5.2.1 - git push git push --tags ``` * [ ] Create and check source distribution: ```bash make sdist ``` -* [ ] Create [binary distributions](https://github.com/python-pillow/Pillow/blob/main/RELEASING.md#binary-distributions) -* [ ] Check and upload all binaries and source distributions e.g.: +* [ ] Create [source and binary distributions](https://github.com/python-pillow/Pillow/blob/main/RELEASING.md#source-and-binary-distributions) +* [ ] Check and upload all source and binary distributions e.g.: ```bash python3 -m twine check --strict dist/* python3 -m twine upload dist/Pillow-5.2.1* ``` -* [ ] Publish the [release on GitHub](https://github.com/python-pillow/Pillow/releases) +* [ ] Publish the [release on GitHub](https://github.com/python-pillow/Pillow/releases) and then: + ```bash + git push + ``` ## Embargoed Release @@ -83,35 +80,32 @@ Released as needed privately to individual vendors for critical security-related ```bash git checkout 2.5.x git tag 2.5.3 - git push origin 2.5.x git push origin --tags ``` * [ ] Create and check source distribution: ```bash make sdist ``` -* [ ] Create [binary distributions](https://github.com/python-pillow/Pillow/blob/main/RELEASING.md#binary-distributions) -* [ ] Publish the [release on GitHub](https://github.com/python-pillow/Pillow/releases) - -## Binary Distributions - -### Windows -* [ ] Download the artifacts from the [GitHub Actions "Test Windows" workflow](https://github.com/python-pillow/Pillow/actions/workflows/test-windows.yml) - and copy into `dist/` - -### Mac and Linux -* [ ] Use the [Pillow Wheel Builder](https://github.com/python-pillow/pillow-wheels): +* [ ] Create [source and binary distributions](https://github.com/python-pillow/Pillow/blob/main/RELEASING.md#source-and-binary-distributions) +* [ ] Publish the [release on GitHub](https://github.com/python-pillow/Pillow/releases) and then: ```bash - git clone https://github.com/python-pillow/pillow-wheels - cd pillow-wheels - ./update-pillow-tag.sh [[release tag]] + git push origin 2.5.x ``` -* [ ] Download wheels from the [Pillow Wheel Builder release](https://github.com/python-pillow/pillow-wheels/releases) - and copy into `dist/` + +## Source and Binary Distributions + +* [ ] Download sdist and wheels from the [GitHub Actions "Wheels" workflow](https://github.com/python-pillow/Pillow/actions/workflows/wheels.yml) + and copy into `dist/`. For example using [GitHub CLI](https://github.com/cli/cli): + ```bash + gh run download --dir dist + # select dist + ``` +* [ ] Download the Linux aarch64 wheels created by Travis CI from [GitHub releases](https://github.com/python-pillow/Pillow/releases) + and copy into `dist`. ## Publicize Release -* [ ] Announce release availability via [Twitter](https://twitter.com/pythonpillow) e.g. https://twitter.com/PythonPillow/status/1013789184354603010 +* [ ] Announce release availability via [Twitter](https://twitter.com/pythonpillow) and [Mastodon](https://fosstodon.org/@pillow) e.g. https://twitter.com/PythonPillow/status/1013789184354603010 ## Documentation diff --git a/Tests/32bit_segfault_check.py b/Tests/32bit_segfault_check.py index 2ff7f908f..06ed2ed2f 100755 --- a/Tests/32bit_segfault_check.py +++ b/Tests/32bit_segfault_check.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +from __future__ import annotations import sys diff --git a/Tests/bench_cffi_access.py b/Tests/bench_cffi_access.py index 87cad699d..8a37c7d51 100644 --- a/Tests/bench_cffi_access.py +++ b/Tests/bench_cffi_access.py @@ -1,3 +1,4 @@ +from __future__ import annotations import time from PIL import PyAccess @@ -27,31 +28,25 @@ def timer(func, label, *args): for x in range(iterations): func(*args) if time.time() - starttime > 10: - print( - "{}: breaking at {} iterations, {:.6f} per iteration".format( - label, x + 1, (time.time() - starttime) / (x + 1.0) - ) - ) break - if x == iterations - 1: - endtime = time.time() - print( - "{}: {:.4f} s {:.6f} per iteration".format( - label, endtime - starttime, (endtime - starttime) / (x + 1.0) - ) + endtime = time.time() + print( + "{}: completed {} iterations in {:.4f}s, {:.6f}s per iteration".format( + label, x + 1, endtime - starttime, (endtime - starttime) / (x + 1.0) ) + ) def test_direct(): im = hopper() im.load() - # im = Image.new( "RGB", (2000, 2000), (1, 3, 2)) + # im = Image.new("RGB", (2000, 2000), (1, 3, 2)) caccess = im.im.pixel_access(False) access = PyAccess.new(im, False) assert caccess[(0, 0)] == access[(0, 0)] - print("Size: %sx%s" % im.size) + print(f"Size: {im.width}x{im.height}") timer(iterate_get, "PyAccess - get", im.size, access) timer(iterate_set, "PyAccess - set", im.size, access) timer(iterate_get, "C-api - get", im.size, caccess) diff --git a/Tests/check_fli_oob.py b/Tests/check_fli_oob.py index 7b3d4d7ee..ac46ff1eb 100644 --- a/Tests/check_fli_oob.py +++ b/Tests/check_fli_oob.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +from __future__ import annotations from PIL import Image diff --git a/Tests/check_fli_overflow.py b/Tests/check_fli_overflow.py index 08a55d349..0fabcb5d3 100644 --- a/Tests/check_fli_overflow.py +++ b/Tests/check_fli_overflow.py @@ -1,10 +1,10 @@ +from __future__ import annotations from PIL import Image TEST_FILE = "Tests/images/fli_overflow.fli" def test_fli_overflow(): - # this should not crash with a malloc error or access violation with Image.open(TEST_FILE) as im: im.load() diff --git a/Tests/check_icns_dos.py b/Tests/check_icns_dos.py index a34bee45c..ac6be4869 100644 --- a/Tests/check_icns_dos.py +++ b/Tests/check_icns_dos.py @@ -1,5 +1,6 @@ # Tests potential DOS of IcnsImagePlugin with 0 length block. # Run from anywhere that PIL is importable. +from __future__ import annotations from io import BytesIO diff --git a/Tests/check_imaging_leaks.py b/Tests/check_imaging_leaks.py index d07082aba..8c17c051d 100755 --- a/Tests/check_imaging_leaks.py +++ b/Tests/check_imaging_leaks.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +from __future__ import annotations import pytest from PIL import Image diff --git a/Tests/check_j2k_dos.py b/Tests/check_j2k_dos.py index 71dcea4f3..2c63c3402 100644 --- a/Tests/check_j2k_dos.py +++ b/Tests/check_j2k_dos.py @@ -1,5 +1,6 @@ # Tests potential DOS of Jpeg2kImagePlugin with 0 length block. # Run from anywhere that PIL is importable. +from __future__ import annotations from io import BytesIO diff --git a/Tests/check_j2k_leaks.py b/Tests/check_j2k_leaks.py old mode 100755 new mode 100644 index afe5836f3..83a12e2c2 --- a/Tests/check_j2k_leaks.py +++ b/Tests/check_j2k_leaks.py @@ -1,3 +1,4 @@ +from __future__ import annotations from io import BytesIO import pytest diff --git a/Tests/check_j2k_overflow.py b/Tests/check_j2k_overflow.py index b16412898..982f6ea74 100644 --- a/Tests/check_j2k_overflow.py +++ b/Tests/check_j2k_overflow.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import Image diff --git a/Tests/check_jp2_overflow.py b/Tests/check_jp2_overflow.py index 0210505f5..9afbff112 100755 --- a/Tests/check_jp2_overflow.py +++ b/Tests/check_jp2_overflow.py @@ -12,6 +12,7 @@ # the output should be empty. There may be python issues # in the valgrind especially if run in a debug python # version. +from __future__ import annotations from PIL import Image diff --git a/Tests/check_jpeg_leaks.py b/Tests/check_jpeg_leaks.py index ab8d77719..3cd37c7af 100644 --- a/Tests/check_jpeg_leaks.py +++ b/Tests/check_jpeg_leaks.py @@ -1,3 +1,4 @@ +from __future__ import annotations from io import BytesIO import pytest @@ -75,43 +76,42 @@ post-patch: """ -def test_qtables_leak(): +standard_l_qtable = ( + # fmt: off + 16, 11, 10, 16, 24, 40, 51, 61, + 12, 12, 14, 19, 26, 58, 60, 55, + 14, 13, 16, 24, 40, 57, 69, 56, + 14, 17, 22, 29, 51, 87, 80, 62, + 18, 22, 37, 56, 68, 109, 103, 77, + 24, 35, 55, 64, 81, 104, 113, 92, + 49, 64, 78, 87, 103, 121, 120, 101, + 72, 92, 95, 98, 112, 100, 103, 99, + # fmt: on +) + +standard_chrominance_qtable = ( + # fmt: off + 17, 18, 24, 47, 99, 99, 99, 99, + 18, 21, 26, 66, 99, 99, 99, 99, + 24, 26, 56, 99, 99, 99, 99, 99, + 47, 66, 99, 99, 99, 99, 99, 99, + 99, 99, 99, 99, 99, 99, 99, 99, + 99, 99, 99, 99, 99, 99, 99, 99, + 99, 99, 99, 99, 99, 99, 99, 99, + 99, 99, 99, 99, 99, 99, 99, 99, + # fmt: on +) + + +@pytest.mark.parametrize( + "qtables", + ( + (standard_l_qtable, standard_chrominance_qtable), + [standard_l_qtable, standard_chrominance_qtable], + ), +) +def test_qtables_leak(qtables): im = hopper("RGB") - - standard_l_qtable = [ - int(s) - for s in """ - 16 11 10 16 24 40 51 61 - 12 12 14 19 26 58 60 55 - 14 13 16 24 40 57 69 56 - 14 17 22 29 51 87 80 62 - 18 22 37 56 68 109 103 77 - 24 35 55 64 81 104 113 92 - 49 64 78 87 103 121 120 101 - 72 92 95 98 112 100 103 99 - """.split( - None - ) - ] - - standard_chrominance_qtable = [ - int(s) - for s in """ - 17 18 24 47 99 99 99 99 - 18 21 26 66 99 99 99 99 - 24 26 56 99 99 99 99 99 - 47 66 99 99 99 99 99 99 - 99 99 99 99 99 99 99 99 - 99 99 99 99 99 99 99 99 - 99 99 99 99 99 99 99 99 - 99 99 99 99 99 99 99 99 - """.split( - None - ) - ] - - qtables = [standard_l_qtable, standard_chrominance_qtable] - for _ in range(iterations): test_output = BytesIO() im.save(test_output, "JPEG", qtables=qtables) diff --git a/Tests/check_large_memory.py b/Tests/check_large_memory.py index d98f4a694..9b83798d5 100644 --- a/Tests/check_large_memory.py +++ b/Tests/check_large_memory.py @@ -1,3 +1,4 @@ +from __future__ import annotations import sys import pytest diff --git a/Tests/check_large_memory_numpy.py b/Tests/check_large_memory_numpy.py index 24cb1f722..0ff3de8dc 100644 --- a/Tests/check_large_memory_numpy.py +++ b/Tests/check_large_memory_numpy.py @@ -1,3 +1,4 @@ +from __future__ import annotations import sys import pytest diff --git a/Tests/check_libtiff_segfault.py b/Tests/check_libtiff_segfault.py index bd7f407e4..ee1d7d11f 100644 --- a/Tests/check_libtiff_segfault.py +++ b/Tests/check_libtiff_segfault.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import Image diff --git a/Tests/check_png_dos.py b/Tests/check_png_dos.py index d8d645189..292fe4b7f 100644 --- a/Tests/check_png_dos.py +++ b/Tests/check_png_dos.py @@ -1,3 +1,4 @@ +from __future__ import annotations import zlib from io import BytesIO @@ -23,7 +24,6 @@ def test_ignore_dos_text(): def test_dos_text(): - try: im = Image.open(TEST_FILE) im.load() diff --git a/Tests/check_release_notes.py b/Tests/check_release_notes.py new file mode 100644 index 000000000..ebfaffa47 --- /dev/null +++ b/Tests/check_release_notes.py @@ -0,0 +1,7 @@ +from __future__ import annotations +import sys +from pathlib import Path + +for rst in Path("docs/releasenotes").glob("[1-9]*.rst"): + if "TODO" in open(rst).read(): + sys.exit(f"Error: remove TODO from {rst}") diff --git a/Tests/check_wheel.py b/Tests/check_wheel.py new file mode 100644 index 000000000..afe4cc3ee --- /dev/null +++ b/Tests/check_wheel.py @@ -0,0 +1,42 @@ +from __future__ import annotations +import sys + +from PIL import features + + +def test_wheel_modules(): + expected_modules = {"pil", "tkinter", "freetype2", "littlecms2", "webp"} + + # tkinter is not available in cibuildwheel installed CPython on Windows + try: + import tkinter + + assert tkinter + except ImportError: + expected_modules.remove("tkinter") + + assert set(features.get_supported_modules()) == expected_modules + + +def test_wheel_codecs(): + expected_codecs = {"jpg", "jpg_2000", "zlib", "libtiff"} + + assert set(features.get_supported_codecs()) == expected_codecs + + +def test_wheel_features(): + expected_features = { + "webp_anim", + "webp_mux", + "transp_webp", + "raqm", + "fribidi", + "harfbuzz", + "libjpeg_turbo", + "xcb", + } + + if sys.platform == "win32": + expected_features.remove("xcb") + + assert set(features.get_supported_features()) == expected_features diff --git a/Tests/conftest.py b/Tests/conftest.py index 66da7593c..cd64bd755 100644 --- a/Tests/conftest.py +++ b/Tests/conftest.py @@ -1,3 +1,4 @@ +from __future__ import annotations import io diff --git a/Tests/createfontdatachunk.py b/Tests/createfontdatachunk.py index e318eb732..2e990b709 100755 --- a/Tests/createfontdatachunk.py +++ b/Tests/createfontdatachunk.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +from __future__ import annotations import base64 import os diff --git a/Tests/fonts/CBDTTestFont.ttf b/Tests/fonts/CBDTTestFont.ttf new file mode 100644 index 000000000..73444e8dc Binary files /dev/null and b/Tests/fonts/CBDTTestFont.ttf differ diff --git a/Tests/fonts/DejaVuSans/LICENSE.txt b/Tests/fonts/DejaVuSans/LICENSE.txt index 30516578f..be6a4d84c 100644 --- a/Tests/fonts/DejaVuSans/LICENSE.txt +++ b/Tests/fonts/DejaVuSans/LICENSE.txt @@ -37,4 +37,4 @@ The Font Software may be sold as part of a larger software package but no copy o THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL TAVMJONG BAH BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE. -Except as contained in this notice, the name of Tavmjong Bah shall not be used in advertising or otherwise to promote the sale, use or other dealings in this Font Software without prior written authorization from Tavmjong Bah. For further information, contact: tavmjong @ free . fr. \ No newline at end of file +Except as contained in this notice, the name of Tavmjong Bah shall not be used in advertising or otherwise to promote the sale, use or other dealings in this Font Software without prior written authorization from Tavmjong Bah. For further information, contact: tavmjong @ free . fr. diff --git a/Tests/fonts/EBDTTestFont.ttf b/Tests/fonts/EBDTTestFont.ttf new file mode 100644 index 000000000..046e9e45c Binary files /dev/null and b/Tests/fonts/EBDTTestFont.ttf differ diff --git a/Tests/fonts/LICENSE.txt b/Tests/fonts/LICENSE.txt index da559b3d3..3c8a23197 100644 --- a/Tests/fonts/LICENSE.txt +++ b/Tests/fonts/LICENSE.txt @@ -2,7 +2,6 @@ NotoNastaliqUrdu-Regular.ttf and NotoSansSymbols-Regular.ttf, from https://github.com/googlei18n/noto-fonts NotoSans-Regular.ttf, from https://www.google.com/get/noto/ NotoSansJP-Thin.otf, from https://www.google.com/get/noto/help/cjk/ -NotoColorEmoji.ttf, from https://github.com/googlefonts/noto-emoji AdobeVFPrototype.ttf, from https://github.com/adobe-fonts/adobe-variable-font-prototype TINY5x3GX.ttf, from http://velvetyne.fr/fonts/tiny ArefRuqaa-Regular.ttf, from https://github.com/google/fonts/tree/master/ofl/arefruqaa @@ -25,3 +24,5 @@ FreeMono.ttf is licensed under GPLv3. 10x20-ISO8859-1.pcf, from https://packages.ubuntu.com/xenial/xfonts-base "Public domain font. Share and enjoy." + +CBDTTestFont.ttf and EBDTTestFont.ttf from https://github.com/nulano/font-tests are public domain. diff --git a/Tests/fonts/NotoColorEmoji.ttf b/Tests/fonts/NotoColorEmoji.ttf deleted file mode 100644 index ef7b72575..000000000 Binary files a/Tests/fonts/NotoColorEmoji.ttf and /dev/null differ diff --git a/Tests/fonts/fuzz_font-5203009437302784 b/Tests/fonts/fuzz_font-5203009437302784 new file mode 100644 index 000000000..0465e48c2 --- /dev/null +++ b/Tests/fonts/fuzz_font-5203009437302784 @@ -0,0 +1,10 @@ +STARTFONT +FONT +SIZE 10 +FONTBOUNDINGBOX +CHARS +STARTCHAR +ENCODING +BBX 2 5 +ENDCHAR +ENDFONT diff --git a/Tests/fonts/oom-4da0210eb7081b0bf15bf16cc4c52ce02c1e1bbc.ttf b/Tests/fonts/oom-4da0210eb7081b0bf15bf16cc4c52ce02c1e1bbc.ttf new file mode 100644 index 000000000..fe200842e Binary files /dev/null and b/Tests/fonts/oom-4da0210eb7081b0bf15bf16cc4c52ce02c1e1bbc.ttf differ diff --git a/Tests/helper.py b/Tests/helper.py index 0d1d03ac8..b333c2fd4 100644 --- a/Tests/helper.py +++ b/Tests/helper.py @@ -1,10 +1,12 @@ """ Helper functions. """ +from __future__ import annotations import logging import os import shutil +import subprocess import sys import sysconfig import tempfile @@ -20,7 +22,7 @@ logger = logging.getLogger(__name__) HAS_UPLOADER = False -if os.environ.get("SHOW_ERRORS", None): +if os.environ.get("SHOW_ERRORS"): # local img.show for errors. HAS_UPLOADER = True @@ -91,11 +93,11 @@ def assert_image_equal(a, b, msg=None): if HAS_UPLOADER: try: url = test_image_results.upload(a, b) - logger.error(f"Url for test images: {url}") + logger.error("URL for test images: %s", url) except Exception: pass - assert False, msg or "got different content" + pytest.fail(msg or "got different content") def assert_image_equal_tofile(a, filename, msg=None, mode=None): @@ -126,7 +128,7 @@ def assert_image_similar(a, b, epsilon, msg=None): if HAS_UPLOADER: try: url = test_image_results.upload(a, b) - logger.error(f"Url for test images: {url}") + logger.exception("URL for test images: %s", url) except Exception: pass raise e @@ -258,11 +260,21 @@ def hopper(mode=None, cache={}): def djpeg_available(): - return bool(shutil.which("djpeg")) + if shutil.which("djpeg"): + try: + subprocess.check_call(["djpeg", "-version"]) + return True + except subprocess.CalledProcessError: # pragma: no cover + return False def cjpeg_available(): - return bool(shutil.which("cjpeg")) + if shutil.which("cjpeg"): + try: + subprocess.check_call(["cjpeg", "-version"]) + return True + except subprocess.CalledProcessError: # pragma: no cover + return False def netpbm_available(): @@ -271,7 +283,7 @@ def netpbm_available(): def magick_command(): if sys.platform == "win32": - magickhome = os.environ.get("MAGICK_HOME", "") + magickhome = os.environ.get("MAGICK_HOME") if magickhome: imagemagick = [os.path.join(magickhome, "convert.exe")] graphicsmagick = [os.path.join(magickhome, "gm.exe"), "convert"] diff --git a/Tests/icc/LICENSE.txt b/Tests/icc/LICENSE.txt index 7d289c331..7119461ed 100644 --- a/Tests/icc/LICENSE.txt +++ b/Tests/icc/LICENSE.txt @@ -22,4 +22,3 @@ and that the name of ICC shall not be used in advertising or publicity pertaining to distribution of the software without specific, written prior permission. ICC makes no representations about the suitability of this software for any purpose. - diff --git a/Tests/images/8bit.s.tif b/Tests/images/8bit.s.tif new file mode 100644 index 000000000..043cba6af Binary files /dev/null and b/Tests/images/8bit.s.tif differ diff --git a/Tests/images/apng/mode_greyscale.png b/Tests/images/apng/mode_grayscale.png similarity index 100% rename from Tests/images/apng/mode_greyscale.png rename to Tests/images/apng/mode_grayscale.png diff --git a/Tests/images/apng/mode_greyscale_alpha.png b/Tests/images/apng/mode_grayscale_alpha.png similarity index 100% rename from Tests/images/apng/mode_greyscale_alpha.png rename to Tests/images/apng/mode_grayscale_alpha.png diff --git a/Tests/images/background_outside_palette.gif b/Tests/images/background_outside_palette.gif new file mode 100644 index 000000000..63e767463 Binary files /dev/null and b/Tests/images/background_outside_palette.gif differ diff --git a/Tests/images/bc1.dds b/Tests/images/bc1.dds new file mode 100755 index 000000000..faec63a00 Binary files /dev/null and b/Tests/images/bc1.dds differ diff --git a/Tests/images/bc1_typeless.dds b/Tests/images/bc1_typeless.dds new file mode 100755 index 000000000..47a85e2d0 Binary files /dev/null and b/Tests/images/bc1_typeless.dds differ diff --git a/Tests/images/bc4_typeless.dds b/Tests/images/bc4_typeless.dds new file mode 100644 index 000000000..27f87889f Binary files /dev/null and b/Tests/images/bc4_typeless.dds differ diff --git a/Tests/images/bc4_unorm.dds b/Tests/images/bc4_unorm.dds new file mode 100644 index 000000000..13da711bd Binary files /dev/null and b/Tests/images/bc4_unorm.dds differ diff --git a/Tests/images/bc4_unorm.png b/Tests/images/bc4_unorm.png new file mode 100644 index 000000000..71d536c84 Binary files /dev/null and b/Tests/images/bc4_unorm.png differ diff --git a/Tests/images/bc4u.dds b/Tests/images/bc4u.dds new file mode 100644 index 000000000..7f9f050b6 Binary files /dev/null and b/Tests/images/bc4u.dds differ diff --git a/Tests/images/bc5s.png b/Tests/images/bc5s.png index 39d7811bf..5e7a1b95e 100644 Binary files a/Tests/images/bc5s.png and b/Tests/images/bc5s.png differ diff --git a/Tests/images/bc5u.dds b/Tests/images/bc5u.dds new file mode 100644 index 000000000..4e4773cd7 Binary files /dev/null and b/Tests/images/bc5u.dds differ diff --git a/Tests/images/bitmap_font_blend.png b/Tests/images/bitmap_font_blend.png new file mode 100644 index 000000000..a5acf3667 Binary files /dev/null and b/Tests/images/bitmap_font_blend.png differ diff --git a/Tests/images/bitmap_font_stroke_basic.png b/Tests/images/bitmap_font_stroke_basic.png index 86b2d09f6..26aa3ab8e 100644 Binary files a/Tests/images/bitmap_font_stroke_basic.png and b/Tests/images/bitmap_font_stroke_basic.png differ diff --git a/Tests/images/bitmap_font_stroke_raqm.png b/Tests/images/bitmap_font_stroke_raqm.png index 08029ce34..be273d7cb 100644 Binary files a/Tests/images/bitmap_font_stroke_raqm.png and b/Tests/images/bitmap_font_stroke_raqm.png differ diff --git a/Tests/images/blend_transparency.png b/Tests/images/blend_transparency.png new file mode 100644 index 000000000..cef0a16de Binary files /dev/null and b/Tests/images/blend_transparency.png differ diff --git a/Tests/images/cbdt.png b/Tests/images/cbdt.png new file mode 100644 index 000000000..542bb812e Binary files /dev/null and b/Tests/images/cbdt.png differ diff --git a/Tests/images/cbdt_mask.png b/Tests/images/cbdt_mask.png new file mode 100644 index 000000000..b0854a605 Binary files /dev/null and b/Tests/images/cbdt_mask.png differ diff --git a/Tests/images/cbdt_notocoloremoji.png b/Tests/images/cbdt_notocoloremoji.png deleted file mode 100644 index 1da12fba1..000000000 Binary files a/Tests/images/cbdt_notocoloremoji.png and /dev/null differ diff --git a/Tests/images/cbdt_notocoloremoji_mask.png b/Tests/images/cbdt_notocoloremoji_mask.png deleted file mode 100644 index 6d036a0b6..000000000 Binary files a/Tests/images/cbdt_notocoloremoji_mask.png and /dev/null differ diff --git a/Tests/images/comment.jp2 b/Tests/images/comment.jp2 new file mode 100644 index 000000000..4bdf91760 Binary files /dev/null and b/Tests/images/comment.jp2 differ diff --git a/Tests/images/default_font_freetype.png b/Tests/images/default_font_freetype.png new file mode 100644 index 000000000..bc1654a25 Binary files /dev/null and b/Tests/images/default_font_freetype.png differ diff --git a/Tests/images/duplicate_xref_entry.pdf b/Tests/images/duplicate_xref_entry.pdf new file mode 100644 index 000000000..f57a57d61 Binary files /dev/null and b/Tests/images/duplicate_xref_entry.pdf differ diff --git a/Tests/images/five_channels.psd b/Tests/images/five_channels.psd new file mode 100644 index 000000000..021a5fa63 Binary files /dev/null and b/Tests/images/five_channels.psd differ diff --git a/Tests/images/hopper.qoi b/Tests/images/hopper.qoi new file mode 100644 index 000000000..6b255aba1 Binary files /dev/null and b/Tests/images/hopper.qoi differ diff --git a/Tests/images/hopper_emboss_I.png b/Tests/images/hopper_emboss_I.png new file mode 100644 index 000000000..f4dab388f Binary files /dev/null and b/Tests/images/hopper_emboss_I.png differ diff --git a/Tests/images/hopper_emboss_more_I.png b/Tests/images/hopper_emboss_more_I.png new file mode 100644 index 000000000..c417c915f Binary files /dev/null and b/Tests/images/hopper_emboss_more_I.png differ diff --git a/Tests/images/hopper_rle8_greyscale.bmp b/Tests/images/hopper_rle8_grayscale.bmp similarity index 100% rename from Tests/images/hopper_rle8_greyscale.bmp rename to Tests/images/hopper_rle8_grayscale.bmp diff --git a/Tests/images/imagedraw_default_font_size.png b/Tests/images/imagedraw_default_font_size.png new file mode 100644 index 000000000..f695b5cd6 Binary files /dev/null and b/Tests/images/imagedraw_default_font_size.png differ diff --git a/Tests/images/imagedraw_ellipse_various_sizes.png b/Tests/images/imagedraw_ellipse_various_sizes.png index 11a1be6fa..5e3cf22b4 100644 Binary files a/Tests/images/imagedraw_ellipse_various_sizes.png and b/Tests/images/imagedraw_ellipse_various_sizes.png differ diff --git a/Tests/images/imagedraw_ellipse_various_sizes_filled.png b/Tests/images/imagedraw_ellipse_various_sizes_filled.png index d71e175b8..dd2f641f1 100644 Binary files a/Tests/images/imagedraw_ellipse_various_sizes_filled.png and b/Tests/images/imagedraw_ellipse_various_sizes_filled.png differ diff --git a/Tests/images/imagedraw_rectangle_I.png b/Tests/images/imagedraw_rectangle_I.png index 4e94f6943..a75f12c2e 100644 Binary files a/Tests/images/imagedraw_rectangle_I.png and b/Tests/images/imagedraw_rectangle_I.png differ diff --git a/Tests/images/imagedraw_rounded_rectangle_corners_nnnn.png b/Tests/images/imagedraw_rounded_rectangle_corners_nnnn.png new file mode 100644 index 000000000..3e79e21ae Binary files /dev/null and b/Tests/images/imagedraw_rounded_rectangle_corners_nnnn.png differ diff --git a/Tests/images/imagedraw_rounded_rectangle_corners_nnny.png b/Tests/images/imagedraw_rounded_rectangle_corners_nnny.png new file mode 100644 index 000000000..7fa09a3c0 Binary files /dev/null and b/Tests/images/imagedraw_rounded_rectangle_corners_nnny.png differ diff --git a/Tests/images/imagedraw_rounded_rectangle_corners_nnyn.png b/Tests/images/imagedraw_rounded_rectangle_corners_nnyn.png new file mode 100644 index 000000000..d825ad263 Binary files /dev/null and b/Tests/images/imagedraw_rounded_rectangle_corners_nnyn.png differ diff --git a/Tests/images/imagedraw_rounded_rectangle_corners_nnyy.png b/Tests/images/imagedraw_rounded_rectangle_corners_nnyy.png new file mode 100644 index 000000000..c19da698e Binary files /dev/null and b/Tests/images/imagedraw_rounded_rectangle_corners_nnyy.png differ diff --git a/Tests/images/imagedraw_rounded_rectangle_corners_nynn.png b/Tests/images/imagedraw_rounded_rectangle_corners_nynn.png new file mode 100644 index 000000000..f3e95d487 Binary files /dev/null and b/Tests/images/imagedraw_rounded_rectangle_corners_nynn.png differ diff --git a/Tests/images/imagedraw_rounded_rectangle_corners_nyny.png b/Tests/images/imagedraw_rounded_rectangle_corners_nyny.png new file mode 100644 index 000000000..274d27984 Binary files /dev/null and b/Tests/images/imagedraw_rounded_rectangle_corners_nyny.png differ diff --git a/Tests/images/imagedraw_rounded_rectangle_corners_nyyn.png b/Tests/images/imagedraw_rounded_rectangle_corners_nyyn.png new file mode 100644 index 000000000..c5f40bfdb Binary files /dev/null and b/Tests/images/imagedraw_rounded_rectangle_corners_nyyn.png differ diff --git a/Tests/images/imagedraw_rounded_rectangle_corners_nyyy.png b/Tests/images/imagedraw_rounded_rectangle_corners_nyyy.png new file mode 100644 index 000000000..01bfd1750 Binary files /dev/null and b/Tests/images/imagedraw_rounded_rectangle_corners_nyyy.png differ diff --git a/Tests/images/imagedraw_rounded_rectangle_corners_ynnn.png b/Tests/images/imagedraw_rounded_rectangle_corners_ynnn.png new file mode 100644 index 000000000..efd27be4f Binary files /dev/null and b/Tests/images/imagedraw_rounded_rectangle_corners_ynnn.png differ diff --git a/Tests/images/imagedraw_rounded_rectangle_corners_ynny.png b/Tests/images/imagedraw_rounded_rectangle_corners_ynny.png new file mode 100644 index 000000000..d3acd01ab Binary files /dev/null and b/Tests/images/imagedraw_rounded_rectangle_corners_ynny.png differ diff --git a/Tests/images/imagedraw_rounded_rectangle_corners_ynyn.png b/Tests/images/imagedraw_rounded_rectangle_corners_ynyn.png new file mode 100644 index 000000000..55ddbc033 Binary files /dev/null and b/Tests/images/imagedraw_rounded_rectangle_corners_ynyn.png differ diff --git a/Tests/images/imagedraw_rounded_rectangle_corners_ynyy.png b/Tests/images/imagedraw_rounded_rectangle_corners_ynyy.png new file mode 100644 index 000000000..c000b26e9 Binary files /dev/null and b/Tests/images/imagedraw_rounded_rectangle_corners_ynyy.png differ diff --git a/Tests/images/imagedraw_rounded_rectangle_corners_yynn.png b/Tests/images/imagedraw_rounded_rectangle_corners_yynn.png new file mode 100644 index 000000000..7056b4fd9 Binary files /dev/null and b/Tests/images/imagedraw_rounded_rectangle_corners_yynn.png differ diff --git a/Tests/images/imagedraw_rounded_rectangle_corners_yyny.png b/Tests/images/imagedraw_rounded_rectangle_corners_yyny.png new file mode 100644 index 000000000..5eca030b9 Binary files /dev/null and b/Tests/images/imagedraw_rounded_rectangle_corners_yyny.png differ diff --git a/Tests/images/imagedraw_rounded_rectangle_corners_yyyn.png b/Tests/images/imagedraw_rounded_rectangle_corners_yyyn.png new file mode 100644 index 000000000..7f1f00344 Binary files /dev/null and b/Tests/images/imagedraw_rounded_rectangle_corners_yyyn.png differ diff --git a/Tests/images/imagedraw_rounded_rectangle_corners_yyyy.png b/Tests/images/imagedraw_rounded_rectangle_corners_yyyy.png new file mode 100644 index 000000000..2e815f4ad Binary files /dev/null and b/Tests/images/imagedraw_rounded_rectangle_corners_yyyy.png differ diff --git a/Tests/images/imagedraw_rounded_rectangle_x_odd.png b/Tests/images/imagedraw_rounded_rectangle_x_odd.png new file mode 100644 index 000000000..f23f1945e Binary files /dev/null and b/Tests/images/imagedraw_rounded_rectangle_x_odd.png differ diff --git a/Tests/images/imagedraw_rounded_rectangle_y_odd.png b/Tests/images/imagedraw_rounded_rectangle_y_odd.png new file mode 100644 index 000000000..96441bc72 Binary files /dev/null and b/Tests/images/imagedraw_rounded_rectangle_y_odd.png differ diff --git a/Tests/images/imagedraw_triangle_width.png b/Tests/images/imagedraw_triangle_width.png new file mode 100644 index 000000000..3d35326e7 Binary files /dev/null and b/Tests/images/imagedraw_triangle_width.png differ diff --git a/Tests/images/negative_size.ppm b/Tests/images/negative_size.ppm old mode 100755 new mode 100644 diff --git a/Tests/images/orientation_rectangle.jpg b/Tests/images/orientation_rectangle.jpg new file mode 100644 index 000000000..85cfbd0a8 Binary files /dev/null and b/Tests/images/orientation_rectangle.jpg differ diff --git a/Tests/images/palette.dds b/Tests/images/palette.dds new file mode 100644 index 000000000..7171cb1f2 Binary files /dev/null and b/Tests/images/palette.dds differ diff --git a/Tests/images/pil123rgba.qoi b/Tests/images/pil123rgba.qoi new file mode 100644 index 000000000..1e46036c7 Binary files /dev/null and b/Tests/images/pil123rgba.qoi differ diff --git a/Tests/images/test_combine_caron_below_ttb.png b/Tests/images/test_combine_caron_below_ttb.png index 5c7576de0..2b7cc89ea 100644 Binary files a/Tests/images/test_combine_caron_below_ttb.png and b/Tests/images/test_combine_caron_below_ttb.png differ diff --git a/Tests/images/test_combine_caron_below_ttb_lb.png b/Tests/images/test_combine_caron_below_ttb_lb.png index bacd6a141..3ced2dbfc 100644 Binary files a/Tests/images/test_combine_caron_below_ttb_lb.png and b/Tests/images/test_combine_caron_below_ttb_lb.png differ diff --git a/Tests/images/test_combine_caron_ttb.png b/Tests/images/test_combine_caron_ttb.png index a94be2f0a..569cc1ec3 100644 Binary files a/Tests/images/test_combine_caron_ttb.png and b/Tests/images/test_combine_caron_ttb.png differ diff --git a/Tests/images/test_combine_caron_ttb_lt.png b/Tests/images/test_combine_caron_ttb_lt.png index a94be2f0a..569cc1ec3 100644 Binary files a/Tests/images/test_combine_caron_ttb_lt.png and b/Tests/images/test_combine_caron_ttb_lt.png differ diff --git a/Tests/images/truncated_exif_dpi.jpg b/Tests/images/truncated_exif_dpi.jpg new file mode 100644 index 000000000..b41ab4004 Binary files /dev/null and b/Tests/images/truncated_exif_dpi.jpg differ diff --git a/Tests/images/uncompressed_l.dds b/Tests/images/uncompressed_l.dds new file mode 100644 index 000000000..b82282587 Binary files /dev/null and b/Tests/images/uncompressed_l.dds differ diff --git a/Tests/images/uncompressed_l.png b/Tests/images/uncompressed_l.png new file mode 100644 index 000000000..9d22a26a4 Binary files /dev/null and b/Tests/images/uncompressed_l.png differ diff --git a/Tests/images/uncompressed_la.dds b/Tests/images/uncompressed_la.dds new file mode 100644 index 000000000..30bf93576 Binary files /dev/null and b/Tests/images/uncompressed_la.dds differ diff --git a/Tests/images/uncompressed_la.png b/Tests/images/uncompressed_la.png new file mode 100644 index 000000000..0d4ea602f Binary files /dev/null and b/Tests/images/uncompressed_la.png differ diff --git a/Tests/images/unimplemented_pixel_format.dds b/Tests/images/unimplemented_pfflags.dds similarity index 99% rename from Tests/images/unimplemented_pixel_format.dds rename to Tests/images/unimplemented_pfflags.dds index 41a343886..e3fc8344d 100755 Binary files a/Tests/images/unimplemented_pixel_format.dds and b/Tests/images/unimplemented_pfflags.dds differ diff --git a/Tests/images/unsupported_bitcount_luminance.dds b/Tests/images/unsupported_bitcount_luminance.dds new file mode 100644 index 000000000..f9bb82254 Binary files /dev/null and b/Tests/images/unsupported_bitcount_luminance.dds differ diff --git a/Tests/images/unsupported_bitcount_rgb.dds b/Tests/images/unsupported_bitcount_rgb.dds new file mode 100644 index 000000000..77d527507 Binary files /dev/null and b/Tests/images/unsupported_bitcount_rgb.dds differ diff --git a/Tests/images/xmp_no_prefix.jpg b/Tests/images/xmp_no_prefix.jpg new file mode 100644 index 000000000..bcd78c7ed Binary files /dev/null and b/Tests/images/xmp_no_prefix.jpg differ diff --git a/Tests/images/xmp_padded.jpg b/Tests/images/xmp_padded.jpg new file mode 100644 index 000000000..9ecfb3efe Binary files /dev/null and b/Tests/images/xmp_padded.jpg differ diff --git a/Tests/images/zero_bb_eof_before_boundingbox.eps b/Tests/images/zero_bb_eof_before_boundingbox.eps new file mode 100644 index 000000000..c268bffd1 Binary files /dev/null and b/Tests/images/zero_bb_eof_before_boundingbox.eps differ diff --git a/Tests/images/zero_bb_trailer.eps b/Tests/images/zero_bb_trailer.eps new file mode 100644 index 000000000..964889965 Binary files /dev/null and b/Tests/images/zero_bb_trailer.eps differ diff --git a/Tests/images/zero_width.gif b/Tests/images/zero_width.gif new file mode 100644 index 000000000..da6823b60 Binary files /dev/null and b/Tests/images/zero_width.gif differ diff --git a/Tests/oss-fuzz/build.sh b/Tests/oss-fuzz/build.sh index b459ee47a..3aa6c7f6a 100755 --- a/Tests/oss-fuzz/build.sh +++ b/Tests/oss-fuzz/build.sh @@ -15,17 +15,17 @@ # ################################################################################ -python3 setup.py build --build-base=/tmp/build install +python3 -m pip install . # Build fuzzers in $OUT. for fuzzer in $(find $SRC -name 'fuzz_*.py'); do compile_python_fuzzer $fuzzer \ - --add-binary /usr/local/lib/libjpeg.so.62.3.0:. \ + --add-binary /usr/local/lib/libjpeg.so.62.4.0:. \ --add-binary /usr/local/lib/libfreetype.so.6:. \ --add-binary /usr/local/lib/liblcms2.so.2:. \ --add-binary /usr/local/lib/libopenjp2.so.7:. \ --add-binary /usr/local/lib/libpng16.so.16:. \ - --add-binary /usr/local/lib/libtiff.so.5:. \ + --add-binary /usr/local/lib/libtiff.so.6:. \ --add-binary /usr/local/lib/libwebp.so.7:. \ --add-binary /usr/local/lib/libwebpdemux.so.2:. \ --add-binary /usr/local/lib/libwebpmux.so.3:. \ diff --git a/Tests/oss-fuzz/fuzz_font.py b/Tests/oss-fuzz/fuzz_font.py index bc2ba9a7e..024117c56 100755 --- a/Tests/oss-fuzz/fuzz_font.py +++ b/Tests/oss-fuzz/fuzz_font.py @@ -1,5 +1,7 @@ #!/usr/bin/python3 +from __future__ import annotations + # Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/Tests/oss-fuzz/fuzz_pillow.py b/Tests/oss-fuzz/fuzz_pillow.py index 545daccb6..c1ab42e56 100644 --- a/Tests/oss-fuzz/fuzz_pillow.py +++ b/Tests/oss-fuzz/fuzz_pillow.py @@ -1,5 +1,7 @@ #!/usr/bin/python3 +from __future__ import annotations + # Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/Tests/oss-fuzz/fuzzers.py b/Tests/oss-fuzz/fuzzers.py index 10a172b46..3f3c1e388 100644 --- a/Tests/oss-fuzz/fuzzers.py +++ b/Tests/oss-fuzz/fuzzers.py @@ -1,3 +1,4 @@ +from __future__ import annotations import io import warnings diff --git a/Tests/oss-fuzz/test_fuzzers.py b/Tests/oss-fuzz/test_fuzzers.py index 629e9ac00..68834045a 100644 --- a/Tests/oss-fuzz/test_fuzzers.py +++ b/Tests/oss-fuzz/test_fuzzers.py @@ -1,3 +1,4 @@ +from __future__ import annotations import subprocess import sys @@ -6,6 +7,7 @@ import packaging import pytest from PIL import Image, features +from Tests.helper import skip_unless_feature if sys.platform.startswith("win32"): pytest.skip("Fuzzer is linux only", allow_module_level=True) @@ -48,6 +50,7 @@ def test_fuzz_images(path): fuzzers.disable_decompressionbomb_error() +@skip_unless_feature("freetype2") @pytest.mark.parametrize( "path", subprocess.check_output("find Tests/fonts -type f", shell=True).split(b"\n") ) @@ -57,6 +60,6 @@ def test_fuzz_fonts(path): with open(path, "rb") as f: try: fuzzers.fuzz_font(f.read()) - except (Image.DecompressionBombError, Image.DecompressionBombWarning): + except (Image.DecompressionBombError, Image.DecompressionBombWarning, OSError): pass assert True diff --git a/Tests/test_000_sanity.py b/Tests/test_000_sanity.py index 3fd982474..c582dfad3 100644 --- a/Tests/test_000_sanity.py +++ b/Tests/test_000_sanity.py @@ -1,3 +1,4 @@ +from __future__ import annotations from PIL import Image diff --git a/Tests/test_binary.py b/Tests/test_binary.py index 4882e65e6..62da26636 100644 --- a/Tests/test_binary.py +++ b/Tests/test_binary.py @@ -1,3 +1,4 @@ +from __future__ import annotations from PIL import _binary diff --git a/Tests/test_bmp_reference.py b/Tests/test_bmp_reference.py index ed9aff9cc..bed8dc3a8 100644 --- a/Tests/test_bmp_reference.py +++ b/Tests/test_bmp_reference.py @@ -1,3 +1,4 @@ +from __future__ import annotations import os import warnings @@ -18,7 +19,6 @@ def test_bad(): """These shouldn't crash/dos, but they shouldn't return anything either""" for f in get_files("b"): - # Assert that there is no unclosed file warning with warnings.catch_warnings(): try: diff --git a/Tests/test_box_blur.py b/Tests/test_box_blur.py index 3bdd5177d..e798cba3d 100644 --- a/Tests/test_box_blur.py +++ b/Tests/test_box_blur.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import Image, ImageFilter @@ -22,7 +23,7 @@ def test_imageops_box_blur(): def box_blur(image, radius=1, n=1): - return image._new(image.im.box_blur(radius, n)) + return image._new(image.im.box_blur((radius, radius), n)) def assert_image(im, data, delta=0): diff --git a/Tests/test_color_lut.py b/Tests/test_color_lut.py index 6d9a60570..448ba2fac 100644 --- a/Tests/test_color_lut.py +++ b/Tests/test_color_lut.py @@ -1,3 +1,4 @@ +from __future__ import annotations from array import array import pytest diff --git a/Tests/test_core_resources.py b/Tests/test_core_resources.py index 385192a3c..5275652f6 100644 --- a/Tests/test_core_resources.py +++ b/Tests/test_core_resources.py @@ -1,3 +1,4 @@ +from __future__ import annotations import sys import pytest @@ -177,13 +178,14 @@ class TestEnvVars: Image._apply_env_variables({"PILLOW_BLOCK_SIZE": "2m"}) assert Image.core.get_block_size() == 2 * 1024 * 1024 - def test_warnings(self): - pytest.warns( - UserWarning, Image._apply_env_variables, {"PILLOW_ALIGNMENT": "15"} - ) - pytest.warns( - UserWarning, Image._apply_env_variables, {"PILLOW_BLOCK_SIZE": "1024"} - ) - pytest.warns( - UserWarning, Image._apply_env_variables, {"PILLOW_BLOCKS_MAX": "wat"} - ) + @pytest.mark.parametrize( + "var", + ( + {"PILLOW_ALIGNMENT": "15"}, + {"PILLOW_BLOCK_SIZE": "1024"}, + {"PILLOW_BLOCKS_MAX": "wat"}, + ), + ) + def test_warnings(self, var): + with pytest.warns(UserWarning): + Image._apply_env_variables(var) diff --git a/Tests/test_decompression_bomb.py b/Tests/test_decompression_bomb.py index 63071b78c..391948d40 100644 --- a/Tests/test_decompression_bomb.py +++ b/Tests/test_decompression_bomb.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import Image @@ -36,12 +37,10 @@ class TestDecompressionBomb: Image.MAX_IMAGE_PIXELS = 128 * 128 - 1 assert Image.MAX_IMAGE_PIXELS == 128 * 128 - 1 - def open(): + with pytest.warns(Image.DecompressionBombWarning): with Image.open(TEST_FILE): pass - pytest.warns(Image.DecompressionBombWarning, open) - def test_exception(self): # Set limit to trigger exception on the test file Image.MAX_IMAGE_PIXELS = 64 * 128 - 1 @@ -66,6 +65,15 @@ class TestDecompressionBomb: with pytest.raises(Image.DecompressionBombError): im.seek(1) + def test_exception_gif_zero_width(self): + # Set limit to trigger exception on the test file + Image.MAX_IMAGE_PIXELS = 4 * 64 * 128 + assert Image.MAX_IMAGE_PIXELS == 4 * 64 * 128 + + with pytest.raises(Image.DecompressionBombError): + with Image.open("Tests/images/zero_width.gif"): + pass + def test_exception_bmp(self): with pytest.raises(Image.DecompressionBombError): with Image.open("Tests/images/bmp/b/reallybig.bmp"): @@ -87,7 +95,8 @@ class TestDecompressionCrop: # same decompression bomb warnings on them. with hopper() as src: box = (0, 0, src.width * 2, src.height * 2) - pytest.warns(Image.DecompressionBombWarning, src.crop, box) + with pytest.warns(Image.DecompressionBombWarning): + src.crop(box) def test_crop_decompression_checks(self): im = Image.new("RGB", (100, 100)) @@ -95,7 +104,8 @@ class TestDecompressionCrop: for value in ((-9999, -9999, -9990, -9990), (-999, -999, -990, -990)): assert im.crop(value).size == (9, 9) - pytest.warns(Image.DecompressionBombWarning, im.crop, (-160, -160, 99, 99)) + with pytest.warns(Image.DecompressionBombWarning): + im.crop((-160, -160, 99, 99)) with pytest.raises(Image.DecompressionBombError): im.crop((-99909, -99990, 99999, 99999)) diff --git a/Tests/test_deprecate.py b/Tests/test_deprecate.py index 30ed4a808..d45a6603c 100644 --- a/Tests/test_deprecate.py +++ b/Tests/test_deprecate.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import _deprecate @@ -7,9 +8,9 @@ from PIL import _deprecate "version, expected", [ ( - 10, - "Old thing is deprecated and will be removed in Pillow 10 " - r"\(2023-07-01\)\. Use new thing instead\.", + 11, + "Old thing is deprecated and will be removed in Pillow 11 " + r"\(2024-10-15\)\. Use new thing instead\.", ), ( None, @@ -24,7 +25,7 @@ def test_version(version, expected): def test_unknown_version(): - expected = r"Unknown removal version, update PIL\._deprecate\?" + expected = r"Unknown removal version: 12345. Update PIL\._deprecate\?" with pytest.raises(ValueError, match=expected): _deprecate.deprecate("Old thing", 12345, "new thing") @@ -52,18 +53,18 @@ def test_old_version(deprecated, plural, expected): def test_plural(): expected = ( - r"Old things are deprecated and will be removed in Pillow 10 \(2023-07-01\)\. " + r"Old things are deprecated and will be removed in Pillow 11 \(2024-10-15\)\. " r"Use new thing instead\." ) with pytest.warns(DeprecationWarning, match=expected): - _deprecate.deprecate("Old things", 10, "new thing", plural=True) + _deprecate.deprecate("Old things", 11, "new thing", plural=True) def test_replacement_and_action(): expected = "Use only one of 'replacement' and 'action'" with pytest.raises(ValueError, match=expected): _deprecate.deprecate( - "Old thing", 10, replacement="new thing", action="Upgrade to new thing" + "Old thing", 11, replacement="new thing", action="Upgrade to new thing" ) @@ -76,16 +77,16 @@ def test_replacement_and_action(): ) def test_action(action): expected = ( - r"Old thing is deprecated and will be removed in Pillow 10 \(2023-07-01\)\. " + r"Old thing is deprecated and will be removed in Pillow 11 \(2024-10-15\)\. " r"Upgrade to new thing\." ) with pytest.warns(DeprecationWarning, match=expected): - _deprecate.deprecate("Old thing", 10, action=action) + _deprecate.deprecate("Old thing", 11, action=action) def test_no_replacement_or_action(): expected = ( - r"Old thing is deprecated and will be removed in Pillow 10 \(2023-07-01\)" + r"Old thing is deprecated and will be removed in Pillow 11 \(2024-10-15\)" ) with pytest.warns(DeprecationWarning, match=expected): - _deprecate.deprecate("Old thing", 10) + _deprecate.deprecate("Old thing", 11) diff --git a/Tests/test_deprecated_imageqt.py b/Tests/test_deprecated_imageqt.py deleted file mode 100644 index 2528ff3f7..000000000 --- a/Tests/test_deprecated_imageqt.py +++ /dev/null @@ -1,18 +0,0 @@ -import warnings - -with warnings.catch_warnings(record=True) as w: - # Arrange: cause all warnings to always be triggered - warnings.simplefilter("always") - - # Act: trigger a warning with Qt5 - from PIL import ImageQt - - -def test_deprecated(): - # Assert - if ImageQt.qt_version in ("5", "side2"): - assert len(w) == 1 - assert issubclass(w[0].category, DeprecationWarning) - assert "deprecated" in str(w[0].message) - else: - assert len(w) == 0 diff --git a/Tests/test_features.py b/Tests/test_features.py index c4e9cd368..8f0e4b418 100644 --- a/Tests/test_features.py +++ b/Tests/test_features.py @@ -1,3 +1,4 @@ +from __future__ import annotations import io import re diff --git a/Tests/test_file_apng.py b/Tests/test_file_apng.py index 51637c786..60d951636 100644 --- a/Tests/test_file_apng.py +++ b/Tests/test_file_apng.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import Image, ImageSequence, PngImagePlugin @@ -163,6 +164,12 @@ def test_apng_blend(): assert im.getpixel((64, 32)) == (0, 255, 0, 255) +def test_apng_blend_transparency(): + with Image.open("Tests/images/blend_transparency.png") as im: + im.seek(1) + assert im.getpixel((0, 0)) == (255, 0, 0) + + def test_apng_chunk_order(): with Image.open("Tests/images/apng/fctl_actl.png") as im: im.seek(im.n_frames - 1) @@ -225,13 +232,13 @@ def test_apng_mode(): assert im.getpixel((0, 0)) == (0, 0, 128, 191) assert im.getpixel((64, 32)) == (0, 0, 128, 191) - with Image.open("Tests/images/apng/mode_greyscale.png") as im: + with Image.open("Tests/images/apng/mode_grayscale.png") as im: assert im.mode == "L" im.seek(im.n_frames - 1) assert im.getpixel((0, 0)) == 128 assert im.getpixel((64, 32)) == 255 - with Image.open("Tests/images/apng/mode_greyscale_alpha.png") as im: + with Image.open("Tests/images/apng/mode_grayscale_alpha.png") as im: assert im.mode == "LA" im.seek(im.n_frames - 1) assert im.getpixel((0, 0)) == (128, 191) @@ -263,13 +270,11 @@ def test_apng_chunk_errors(): with Image.open("Tests/images/apng/chunk_no_actl.png") as im: assert not im.is_animated - def open(): + with pytest.warns(UserWarning): with Image.open("Tests/images/apng/chunk_multi_actl.png") as im: im.load() assert not im.is_animated - pytest.warns(UserWarning, open) - with Image.open("Tests/images/apng/chunk_actl_after_idat.png") as im: assert not im.is_animated @@ -287,21 +292,17 @@ def test_apng_chunk_errors(): def test_apng_syntax_errors(): - def open_frames_zero(): + with pytest.warns(UserWarning): with Image.open("Tests/images/apng/syntax_num_frames_zero.png") as im: assert not im.is_animated with pytest.raises(OSError): im.load() - pytest.warns(UserWarning, open_frames_zero) - - def open_frames_zero_default(): + with pytest.warns(UserWarning): with Image.open("Tests/images/apng/syntax_num_frames_zero_default.png") as im: assert not im.is_animated im.load() - pytest.warns(UserWarning, open_frames_zero_default) - # we can handle this case gracefully exception = None with Image.open("Tests/images/apng/syntax_num_frames_low.png") as im: @@ -316,13 +317,11 @@ def test_apng_syntax_errors(): im.seek(im.n_frames - 1) im.load() - def open(): + with pytest.warns(UserWarning): with Image.open("Tests/images/apng/syntax_num_frames_invalid.png") as im: assert not im.is_animated im.load() - pytest.warns(UserWarning, open) - @pytest.mark.parametrize( "test_file", @@ -352,15 +351,13 @@ def test_apng_save(tmp_path): im.load() assert not im.is_animated assert im.n_frames == 1 - assert im.get_format_mimetype() == "image/apng" + assert im.get_format_mimetype() == "image/png" assert im.info.get("default_image") is None assert im.getpixel((0, 0)) == (0, 255, 0, 255) assert im.getpixel((64, 32)) == (0, 255, 0, 255) with Image.open("Tests/images/apng/single_frame_default.png") as im: - frames = [] - for frame_im in ImageSequence.Iterator(im): - frames.append(frame_im.copy()) + frames = [frame_im.copy() for frame_im in ImageSequence.Iterator(im)] frames[0].save( test_file, save_all=True, default_image=True, append_images=frames[1:] ) @@ -376,6 +373,20 @@ def test_apng_save(tmp_path): assert im.getpixel((64, 32)) == (0, 255, 0, 255) +def test_apng_save_alpha(tmp_path): + test_file = str(tmp_path / "temp.png") + + im = Image.new("RGBA", (1, 1), (255, 0, 0, 255)) + im2 = Image.new("RGBA", (1, 1), (255, 0, 0, 127)) + im.save(test_file, save_all=True, append_images=[im2]) + + with Image.open(test_file) as reloaded: + assert reloaded.getpixel((0, 0)) == (255, 0, 0, 255) + + reloaded.seek(1) + assert reloaded.getpixel((0, 0)) == (255, 0, 0, 127) + + def test_apng_save_split_fdat(tmp_path): # test to make sure we do not generate sequence errors when writing # frames with image data spanning multiple fdAT chunks (in this case @@ -438,15 +449,29 @@ def test_apng_save_duration_loop(tmp_path): test_file, save_all=True, append_images=[frame, frame], duration=[500, 100, 150] ) with Image.open(test_file) as im: - im.load() assert im.n_frames == 1 - assert im.info.get("duration") == 750 + assert "duration" not in im.info + + different_frame = Image.new("RGBA", (128, 64)) + frame.save( + test_file, + save_all=True, + append_images=[frame, different_frame], + duration=[500, 100, 150], + ) + with Image.open(test_file) as im: + assert im.n_frames == 2 + assert im.info["duration"] == 600 + + im.seek(1) + assert im.info["duration"] == 150 # test info duration - frame.info["duration"] = 750 - frame.save(test_file, save_all=True) + frame.info["duration"] = 300 + frame.save(test_file, save_all=True, append_images=[frame, different_frame]) with Image.open(test_file) as im: - assert im.info.get("duration") == 750 + assert im.n_frames == 2 + assert im.info["duration"] == 600 def test_apng_save_disposal(tmp_path): @@ -650,20 +675,17 @@ def test_seek_after_close(): @pytest.mark.parametrize("mode", ("RGBA", "RGB", "P")) -def test_different_modes_in_later_frames(mode, tmp_path): +@pytest.mark.parametrize("default_image", (True, False)) +@pytest.mark.parametrize("duplicate", (True, False)) +def test_different_modes_in_later_frames(mode, default_image, duplicate, tmp_path): test_file = str(tmp_path / "temp.png") im = Image.new("L", (1, 1)) - im.save(test_file, save_all=True, append_images=[Image.new(mode, (1, 1))]) + im.save( + test_file, + save_all=True, + default_image=default_image, + append_images=[im.convert(mode) if duplicate else Image.new(mode, (1, 1), 1)], + ) with Image.open(test_file) as reloaded: assert reloaded.mode == mode - - -def test_constants_deprecation(): - for enum, prefix in { - PngImagePlugin.Disposal: "APNG_DISPOSE_", - PngImagePlugin.Blend: "APNG_BLEND_", - }.items(): - for name in enum.__members__: - with pytest.warns(DeprecationWarning): - assert getattr(PngImagePlugin, prefix + name) == enum[name] diff --git a/Tests/test_file_blp.py b/Tests/test_file_blp.py index ba2781820..4c1e38d1d 100644 --- a/Tests/test_file_blp.py +++ b/Tests/test_file_blp.py @@ -1,6 +1,7 @@ +from __future__ import annotations import pytest -from PIL import BlpImagePlugin, Image +from PIL import Image from .helper import ( assert_image_equal, @@ -72,14 +73,3 @@ def test_crashes(test_file): with Image.open(f) as im: with pytest.raises(OSError): im.load() - - -def test_constants_deprecation(): - for enum, prefix in { - BlpImagePlugin.Format: "BLP_FORMAT_", - BlpImagePlugin.Encoding: "BLP_ENCODING_", - BlpImagePlugin.AlphaEncoding: "BLP_ALPHA_ENCODING_", - }.items(): - for name in enum.__members__: - with pytest.warns(DeprecationWarning): - assert getattr(BlpImagePlugin, prefix + name) == enum[name] diff --git a/Tests/test_file_bmp.py b/Tests/test_file_bmp.py index 5f6d52355..4cc92c5f6 100644 --- a/Tests/test_file_bmp.py +++ b/Tests/test_file_bmp.py @@ -1,3 +1,4 @@ +from __future__ import annotations import io import pytest @@ -141,7 +142,6 @@ def test_rgba_bitfields(): # This test image has been manually hexedited # to change the bitfield compression in the header from XBGR to RGBA with Image.open("Tests/images/rgb32bf-rgba.bmp") as im: - # So before the comparing the image, swap the channels b, g, r = im.split()[1:] im = Image.merge("RGB", (r, g, b)) @@ -160,7 +160,7 @@ def test_rle8(): with Image.open("Tests/images/hopper_rle8.bmp") as im: assert_image_similar_tofile(im.convert("RGB"), "Tests/images/hopper.bmp", 12) - with Image.open("Tests/images/hopper_rle8_greyscale.bmp") as im: + with Image.open("Tests/images/hopper_rle8_grayscale.bmp") as im: assert_image_equal_tofile(im, "Tests/images/bw_gradient.png") # This test image has been manually hexedited diff --git a/Tests/test_file_bufrstub.py b/Tests/test_file_bufrstub.py index e330404d6..5780232a2 100644 --- a/Tests/test_file_bufrstub.py +++ b/Tests/test_file_bufrstub.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import BufrStubImagePlugin, Image @@ -10,7 +11,6 @@ TEST_FILE = "Tests/images/gfs.t06z.rassda.tm00.bufr_d" def test_open(): # Act with Image.open(TEST_FILE) as im: - # Assert assert im.format == "BUFR" @@ -31,7 +31,6 @@ def test_invalid_file(): def test_load(): # Arrange with Image.open(TEST_FILE) as im: - # Act / Assert: stub cannot load without an implemented handler with pytest.raises(OSError): im.load() @@ -58,6 +57,7 @@ def test_handler(tmp_path): def load(self, im): self.loaded = True + im.fp.close() return Image.new("RGB", (1, 1)) def save(self, im, fp, filename): diff --git a/Tests/test_file_container.py b/Tests/test_file_container.py index 65cf6a75e..0da5d3824 100644 --- a/Tests/test_file_container.py +++ b/Tests/test_file_container.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import ContainerIO, Image diff --git a/Tests/test_file_cur.py b/Tests/test_file_cur.py index f04a20a22..08c3257f9 100644 --- a/Tests/test_file_cur.py +++ b/Tests/test_file_cur.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import CurImagePlugin, Image diff --git a/Tests/test_file_dcx.py b/Tests/test_file_dcx.py index 0f09c4b99..25e4badbc 100644 --- a/Tests/test_file_dcx.py +++ b/Tests/test_file_dcx.py @@ -1,3 +1,4 @@ +from __future__ import annotations import warnings import pytest @@ -15,7 +16,6 @@ def test_sanity(): # Act with Image.open(TEST_FILE) as im: - # Assert assert im.size == (128, 128) assert isinstance(im, DcxImagePlugin.DcxImageFile) @@ -29,7 +29,8 @@ def test_unclosed_file(): im = Image.open(TEST_FILE) im.load() - pytest.warns(ResourceWarning, open) + with pytest.warns(ResourceWarning): + open() def test_closed_file(): @@ -54,7 +55,6 @@ def test_invalid_file(): def test_tell(): # Arrange with Image.open(TEST_FILE) as im: - # Act frame = im.tell() diff --git a/Tests/test_file_dds.py b/Tests/test_file_dds.py index 4b9f8949e..2d60fbb64 100644 --- a/Tests/test_file_dds.py +++ b/Tests/test_file_dds.py @@ -1,4 +1,5 @@ """Test DdsImagePlugin""" +from __future__ import annotations from io import BytesIO import pytest @@ -12,25 +13,42 @@ TEST_FILE_DXT3 = "Tests/images/dxt3-argb-8bbp-explicitalpha_MipMaps-1.dds" TEST_FILE_DXT5 = "Tests/images/dxt5-argb-8bbp-interpolatedalpha_MipMaps-1.dds" TEST_FILE_ATI1 = "Tests/images/ati1.dds" TEST_FILE_ATI2 = "Tests/images/ati2.dds" +TEST_FILE_DX10_BC4_TYPELESS = "Tests/images/bc4_typeless.dds" +TEST_FILE_DX10_BC4_UNORM = "Tests/images/bc4_unorm.dds" TEST_FILE_DX10_BC5_TYPELESS = "Tests/images/bc5_typeless.dds" TEST_FILE_DX10_BC5_UNORM = "Tests/images/bc5_unorm.dds" TEST_FILE_DX10_BC5_SNORM = "Tests/images/bc5_snorm.dds" +TEST_FILE_DX10_BC1 = "Tests/images/bc1.dds" +TEST_FILE_DX10_BC1_TYPELESS = "Tests/images/bc1_typeless.dds" +TEST_FILE_BC4U = "Tests/images/bc4u.dds" TEST_FILE_BC5S = "Tests/images/bc5s.dds" +TEST_FILE_BC5U = "Tests/images/bc5u.dds" TEST_FILE_BC6H = "Tests/images/bc6h.dds" TEST_FILE_BC6HS = "Tests/images/bc6h_sf.dds" TEST_FILE_DX10_BC7 = "Tests/images/bc7-argb-8bpp_MipMaps-1.dds" TEST_FILE_DX10_BC7_UNORM_SRGB = "Tests/images/DXGI_FORMAT_BC7_UNORM_SRGB.dds" TEST_FILE_DX10_R8G8B8A8 = "Tests/images/argb-32bpp_MipMaps-1.dds" TEST_FILE_DX10_R8G8B8A8_UNORM_SRGB = "Tests/images/DXGI_FORMAT_R8G8B8A8_UNORM_SRGB.dds" +TEST_FILE_UNCOMPRESSED_L = "Tests/images/uncompressed_l.dds" +TEST_FILE_UNCOMPRESSED_L_WITH_ALPHA = "Tests/images/uncompressed_la.dds" TEST_FILE_UNCOMPRESSED_RGB = "Tests/images/hopper.dds" TEST_FILE_UNCOMPRESSED_RGB_WITH_ALPHA = "Tests/images/uncompressed_rgb.dds" -def test_sanity_dxt1(): - """Check DXT1 images can be opened""" +@pytest.mark.parametrize( + "image_path", + ( + TEST_FILE_DXT1, + # hexeditted to use DX10 FourCC + TEST_FILE_DX10_BC1, + TEST_FILE_DX10_BC1_TYPELESS, + ), +) +def test_sanity_dxt1_bc1(image_path): + """Check DXT1 and BC1 images can be opened""" with Image.open(TEST_FILE_DXT1.replace(".dds", ".png")) as target: target = target.convert("RGBA") - with Image.open(TEST_FILE_DXT1) as im: + with Image.open(image_path) as im: im.load() assert im.format == "DDS" @@ -66,10 +84,18 @@ def test_sanity_dxt5(): assert_image_equal_tofile(im, TEST_FILE_DXT5.replace(".dds", ".png")) -def test_sanity_ati1(): - """Check ATI1 images can be opened""" +@pytest.mark.parametrize( + "image_path", + ( + TEST_FILE_ATI1, + # hexeditted to use BC4U FourCC + TEST_FILE_BC4U, + ), +) +def test_sanity_ati1_bc4u(image_path): + """Check ATI1 and BC4U images can be opened""" - with Image.open(TEST_FILE_ATI1) as im: + with Image.open(image_path) as im: im.load() assert im.format == "DDS" @@ -79,10 +105,39 @@ def test_sanity_ati1(): assert_image_equal_tofile(im, TEST_FILE_ATI1.replace(".dds", ".png")) -def test_sanity_ati2(): - """Check ATI2 images can be opened""" +@pytest.mark.parametrize( + "image_path", + ( + TEST_FILE_DX10_BC4_UNORM, + # hexeditted to be typeless + TEST_FILE_DX10_BC4_TYPELESS, + ), +) +def test_dx10_bc4(image_path): + """Check DX10 BC4 images can be opened""" - with Image.open(TEST_FILE_ATI2) as im: + with Image.open(image_path) as im: + im.load() + + assert im.format == "DDS" + assert im.mode == "L" + assert im.size == (64, 64) + + assert_image_equal_tofile(im, TEST_FILE_DX10_BC4_UNORM.replace(".dds", ".png")) + + +@pytest.mark.parametrize( + "image_path", + ( + TEST_FILE_ATI2, + # hexeditted to use BC5U FourCC + TEST_FILE_BC5U, + ), +) +def test_sanity_ati2_bc5u(image_path): + """Check ATI2 and BC5U images can be opened""" + + with Image.open(image_path) as im: im.load() assert im.format == "DDS" @@ -188,32 +243,24 @@ def test_dx10_r8g8b8a8_unorm_srgb(): ) -def test_unimplemented_dxgi_format(): - with pytest.raises(NotImplementedError): - with Image.open("Tests/images/unimplemented_dxgi_format.dds"): - pass +@pytest.mark.parametrize( + ("mode", "size", "test_file"), + [ + ("L", (128, 128), TEST_FILE_UNCOMPRESSED_L), + ("LA", (128, 128), TEST_FILE_UNCOMPRESSED_L_WITH_ALPHA), + ("RGB", (128, 128), TEST_FILE_UNCOMPRESSED_RGB), + ("RGBA", (800, 600), TEST_FILE_UNCOMPRESSED_RGB_WITH_ALPHA), + ], +) +def test_uncompressed(mode, size, test_file): + """Check uncompressed images can be opened""" - -def test_uncompressed_rgb(): - """Check uncompressed RGB images can be opened""" - - # convert -format dds -define dds:compression=none hopper.jpg hopper.dds - with Image.open(TEST_FILE_UNCOMPRESSED_RGB) as im: + with Image.open(test_file) as im: assert im.format == "DDS" - assert im.mode == "RGB" - assert im.size == (128, 128) + assert im.mode == mode + assert im.size == size - assert_image_equal_tofile(im, "Tests/images/hopper.png") - - # Test image with alpha - with Image.open(TEST_FILE_UNCOMPRESSED_RGB_WITH_ALPHA) as im: - assert im.format == "DDS" - assert im.mode == "RGBA" - assert im.size == (800, 600) - - assert_image_equal_tofile( - im, TEST_FILE_UNCOMPRESSED_RGB_WITH_ALPHA.replace(".dds", ".png") - ) + assert_image_equal_tofile(im, test_file.replace(".dds", ".png")) def test__accept_true(): @@ -289,9 +336,34 @@ def test_dxt5_colorblock_alpha_issue_4142(): assert px[2] != 0 -def test_unimplemented_pixel_format(): +def test_palette(): + with Image.open("Tests/images/palette.dds") as im: + assert_image_equal_tofile(im, "Tests/images/transparent.gif") + + +@pytest.mark.parametrize( + "test_file", + ( + "Tests/images/unsupported_bitcount_rgb.dds", + "Tests/images/unsupported_bitcount_luminance.dds", + ), +) +def test_unsupported_bitcount(test_file): + with pytest.raises(OSError): + with Image.open(test_file): + pass + + +@pytest.mark.parametrize( + "test_file", + ( + "Tests/images/unimplemented_dxgi_format.dds", + "Tests/images/unimplemented_pfflags.dds", + ), +) +def test_not_implemented(test_file): with pytest.raises(NotImplementedError): - with Image.open("Tests/images/unimplemented_pixel_format.dds"): + with Image.open(test_file): pass @@ -305,6 +377,8 @@ def test_save_unsupported_mode(tmp_path): @pytest.mark.parametrize( ("mode", "test_file"), [ + ("L", "Tests/images/linear_gradient.png"), + ("LA", "Tests/images/uncompressed_la.png"), ("RGB", "Tests/images/hopper.png"), ("RGBA", "Tests/images/pil123rgba.png"), ], diff --git a/Tests/test_file_eps.py b/Tests/test_file_eps.py index 015dda992..c479c384a 100644 --- a/Tests/test_file_eps.py +++ b/Tests/test_file_eps.py @@ -1,3 +1,4 @@ +from __future__ import annotations import io import pytest @@ -8,6 +9,7 @@ from .helper import ( assert_image_similar, assert_image_similar_tofile, hopper, + is_win32, mark_if_feature_version, skip_unless_feature, ) @@ -28,34 +30,65 @@ FILE2_COMPARE_SCALE2 = "Tests/images/non_zero_bb_scale2.png" # EPS test files with binary preview FILE3 = "Tests/images/binary_preview_map.eps" +# Three unsigned 32bit little-endian values: +# 0xC6D3D0C5 magic number +# byte position of start of postscript section (12) +# byte length of postscript section (0) +# this byte length isn't valid, but we don't read it +simple_binary_header = b"\xc5\xd0\xd3\xc6\x0c\x00\x00\x00\x00\x00\x00\x00" + +# taken from page 8 of the specification +# https://web.archive.org/web/20220120164601/https://www.adobe.com/content/dam/acom/en/devnet/actionscript/articles/5002.EPSF_Spec.pdf +simple_eps_file = ( + b"%!PS-Adobe-3.0 EPSF-3.0", + b"%%BoundingBox: 5 5 105 105", + b"10 setlinewidth", + b"10 10 moveto", + b"0 90 rlineto 90 0 rlineto 0 -90 rlineto closepath", + b"stroke", +) +simple_eps_file_with_comments = ( + simple_eps_file[:1] + + ( + b"%%Comment1: Some Value", + b"%%SecondComment: Another Value", + ) + + simple_eps_file[1:] +) +simple_eps_file_without_version = simple_eps_file[1:] +simple_eps_file_without_boundingbox = simple_eps_file[:1] + simple_eps_file[2:] +simple_eps_file_with_invalid_boundingbox = ( + simple_eps_file[:1] + (b"%%BoundingBox: a b c d",) + simple_eps_file[2:] +) +simple_eps_file_with_invalid_boundingbox_valid_imagedata = ( + simple_eps_file_with_invalid_boundingbox + (b"%ImageData: 100 100 8 3",) +) +simple_eps_file_with_long_ascii_comment = ( + simple_eps_file[:2] + (b"%%Comment: " + b"X" * 300,) + simple_eps_file[2:] +) +simple_eps_file_with_long_binary_data = ( + simple_eps_file[:2] + + ( + b"%%BeginBinary: 300", + b"\0" * 300, + b"%%EndBinary", + ) + + simple_eps_file[2:] +) + @pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") -def test_sanity(): - # Regular scale - with Image.open(FILE1) as image1: - image1.load() - assert image1.mode == "RGB" - assert image1.size == (460, 352) - assert image1.format == "EPS" - - with Image.open(FILE2) as image2: - image2.load() - assert image2.mode == "RGB" - assert image2.size == (360, 252) - assert image2.format == "EPS" - - # Double scale - with Image.open(FILE1) as image1_scale2: - image1_scale2.load(scale=2) - assert image1_scale2.mode == "RGB" - assert image1_scale2.size == (920, 704) - assert image1_scale2.format == "EPS" - - with Image.open(FILE2) as image2_scale2: - image2_scale2.load(scale=2) - assert image2_scale2.mode == "RGB" - assert image2_scale2.size == (720, 504) - assert image2_scale2.format == "EPS" +@pytest.mark.parametrize( + ("filename", "size"), ((FILE1, (460, 352)), (FILE2, (360, 252))) +) +@pytest.mark.parametrize("scale", (1, 2)) +def test_sanity(filename, size, scale): + expected_size = tuple(s * scale for s in size) + with Image.open(filename) as image: + image.load(scale=scale) + assert image.mode == "RGB" + assert image.size == expected_size + assert image.format == "EPS" @pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") @@ -67,20 +100,94 @@ def test_load(): assert im.load()[0, 0] == (255, 255, 255) +def test_binary(): + if HAS_GHOSTSCRIPT: + assert EpsImagePlugin.gs_binary is not None + else: + assert EpsImagePlugin.gs_binary is False + + if not is_win32(): + assert EpsImagePlugin.gs_windows_binary is None + elif not HAS_GHOSTSCRIPT: + assert EpsImagePlugin.gs_windows_binary is False + else: + assert EpsImagePlugin.gs_windows_binary is not None + + def test_invalid_file(): invalid_file = "Tests/images/flower.jpg" - with pytest.raises(SyntaxError): EpsImagePlugin.EpsImageFile(invalid_file) +def test_binary_header_only(): + data = io.BytesIO(simple_binary_header) + with pytest.raises(SyntaxError, match='EPS header missing "%!PS-Adobe" comment'): + EpsImagePlugin.EpsImageFile(data) + + +@pytest.mark.parametrize("prefix", (b"", simple_binary_header)) +def test_missing_version_comment(prefix): + data = io.BytesIO(prefix + b"\n".join(simple_eps_file_without_version)) + with pytest.raises(SyntaxError): + EpsImagePlugin.EpsImageFile(data) + + +@pytest.mark.parametrize("prefix", (b"", simple_binary_header)) +def test_missing_boundingbox_comment(prefix): + data = io.BytesIO(prefix + b"\n".join(simple_eps_file_without_boundingbox)) + with pytest.raises(SyntaxError, match='EPS header missing "%%BoundingBox" comment'): + EpsImagePlugin.EpsImageFile(data) + + +@pytest.mark.parametrize("prefix", (b"", simple_binary_header)) +def test_invalid_boundingbox_comment(prefix): + data = io.BytesIO(prefix + b"\n".join(simple_eps_file_with_invalid_boundingbox)) + with pytest.raises(OSError, match="cannot determine EPS bounding box"): + EpsImagePlugin.EpsImageFile(data) + + +@pytest.mark.parametrize("prefix", (b"", simple_binary_header)) +def test_invalid_boundingbox_comment_valid_imagedata_comment(prefix): + data = io.BytesIO( + prefix + b"\n".join(simple_eps_file_with_invalid_boundingbox_valid_imagedata) + ) + with Image.open(data) as img: + assert img.mode == "RGB" + assert img.size == (100, 100) + assert img.format == "EPS" + + +@pytest.mark.parametrize("prefix", (b"", simple_binary_header)) +def test_ascii_comment_too_long(prefix): + data = io.BytesIO(prefix + b"\n".join(simple_eps_file_with_long_ascii_comment)) + with pytest.raises(SyntaxError, match="not an EPS file"): + EpsImagePlugin.EpsImageFile(data) + + +@pytest.mark.parametrize("prefix", (b"", simple_binary_header)) +def test_long_binary_data(prefix): + data = io.BytesIO(prefix + b"\n".join(simple_eps_file_with_long_binary_data)) + EpsImagePlugin.EpsImageFile(data) + + +@pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") +@pytest.mark.parametrize("prefix", (b"", simple_binary_header)) +def test_load_long_binary_data(prefix): + data = io.BytesIO(prefix + b"\n".join(simple_eps_file_with_long_binary_data)) + with Image.open(data) as img: + img.load() + assert img.mode == "RGB" + assert img.size == (100, 100) + assert img.format == "EPS" + + @mark_if_feature_version( pytest.mark.valgrind_known_error, "libjpeg_turbo", "2.0", reason="Known Failing" ) @pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") def test_cmyk(): with Image.open("Tests/images/pil_sample_cmyk.eps") as cmyk_image: - assert cmyk_image.mode == "CMYK" assert cmyk_image.size == (100, 100) assert cmyk_image.format == "EPS" @@ -101,7 +208,7 @@ def test_showpage(): with Image.open("Tests/images/reqd_showpage.png") as target: # should not crash/hang plot_image.load() - # fonts could be slightly different + # fonts could be slightly different assert_image_similar(plot_image, target, 6) @@ -112,7 +219,7 @@ def test_transparency(): assert plot_image.mode == "RGBA" with Image.open("Tests/images/reqd_showpage_transparency.png") as target: - # fonts could be slightly different + # fonts could be slightly different assert_image_similar(plot_image, target, 6) @@ -207,7 +314,6 @@ def test_resize(filename): @pytest.mark.parametrize("filename", (FILE1, FILE2)) def test_thumbnail(filename): # Issue #619 - # Arrange with Image.open(filename) as im: new_size = (100, 100) im.thumbnail(new_size) @@ -221,7 +327,7 @@ def test_read_binary_preview(): pass -def test_readline(tmp_path): +def test_readline_psfile(tmp_path): # check all the freaking line endings possible from the spec # test_string = u'something\r\nelse\n\rbaz\rbif\n' line_endings = ["\r\n", "\n", "\n\r", "\r"] @@ -238,7 +344,8 @@ def test_readline(tmp_path): def _test_readline_io_psfile(test_string, ending): f = io.BytesIO(test_string.encode("latin-1")) - t = EpsImagePlugin.PSFile(f) + with pytest.warns(DeprecationWarning): + t = EpsImagePlugin.PSFile(f) _test_readline(t, ending) def _test_readline_file_psfile(test_string, ending): @@ -247,7 +354,8 @@ def test_readline(tmp_path): w.write(test_string.encode("latin-1")) with open(f, "rb") as r: - t = EpsImagePlugin.PSFile(r) + with pytest.warns(DeprecationWarning): + t = EpsImagePlugin.PSFile(r) _test_readline(t, ending) for ending in line_endings: @@ -256,6 +364,25 @@ def test_readline(tmp_path): _test_readline_file_psfile(s, ending) +def test_psfile_deprecation(): + with pytest.warns(DeprecationWarning): + EpsImagePlugin.PSFile(None) + + +@pytest.mark.parametrize("prefix", (b"", simple_binary_header)) +@pytest.mark.parametrize( + "line_ending", + (b"\r\n", b"\n", b"\n\r", b"\r"), +) +def test_readline(prefix, line_ending): + simple_file = prefix + line_ending.join(simple_eps_file_with_comments) + data = io.BytesIO(simple_file) + test_file = EpsImagePlugin.EpsImageFile(data) + assert test_file.info["Comment1"] == "Some Value" + assert test_file.info["SecondComment"] == "Another Value" + assert test_file.size == (100, 100) + + @pytest.mark.parametrize( "filename", ( @@ -293,3 +420,18 @@ def test_timeout(test_file): with pytest.raises(Image.UnidentifiedImageError): with Image.open(f): pass + + +def test_bounding_box_in_trailer(): + # Check bounding boxes are parsed in the same way + # when specified in the header and the trailer + with Image.open("Tests/images/zero_bb_trailer.eps") as trailer_image, Image.open( + FILE1 + ) as header_image: + assert trailer_image.size == header_image.size + + +def test_eof_before_bounding_box(): + with pytest.raises(OSError): + with Image.open("Tests/images/zero_bb_eof_before_boundingbox.eps"): + pass diff --git a/Tests/test_file_fits.py b/Tests/test_file_fits.py index 447888acd..1383f9c5c 100644 --- a/Tests/test_file_fits.py +++ b/Tests/test_file_fits.py @@ -1,8 +1,9 @@ +from __future__ import annotations from io import BytesIO import pytest -from PIL import FitsImagePlugin, FitsStubImagePlugin, Image +from PIL import FitsImagePlugin, Image from .helper import assert_image_equal, hopper @@ -12,7 +13,6 @@ TEST_FILE = "Tests/images/hopper.fits" def test_open(): # Act with Image.open(TEST_FILE) as im: - # Assert assert im.format == "FITS" assert im.size == (128, 128) @@ -45,36 +45,7 @@ def test_naxis_zero(): pass -def test_stub_deprecated(): - class Handler: - opened = False - loaded = False - - def open(self, im): - self.opened = True - - def load(self, im): - self.loaded = True - return Image.new("RGB", (1, 1)) - - handler = Handler() - with pytest.warns(DeprecationWarning): - FitsStubImagePlugin.register_handler(handler) - - with Image.open(TEST_FILE) as im: - assert im.format == "FITS" - assert im.size == (128, 128) - assert im.mode == "L" - - assert handler.opened - assert not handler.loaded - - im.load() - assert handler.loaded - - FitsStubImagePlugin._handler = None - Image.register_open( - FitsImagePlugin.FitsImageFile.format, - FitsImagePlugin.FitsImageFile, - FitsImagePlugin._accept, - ) +def test_comment(): + image_data = b"SIMPLE = T / comment string" + with pytest.raises(OSError): + FitsImagePlugin.FitsImageFile(BytesIO(image_data)) diff --git a/Tests/test_file_fli.py b/Tests/test_file_fli.py index b8b999d70..10bf36cc2 100644 --- a/Tests/test_file_fli.py +++ b/Tests/test_file_fli.py @@ -1,3 +1,4 @@ +from __future__ import annotations import warnings import pytest @@ -36,7 +37,8 @@ def test_unclosed_file(): im = Image.open(static_test_file) im.load() - pytest.warns(ResourceWarning, open) + with pytest.warns(ResourceWarning): + open() def test_closed_file(): @@ -64,7 +66,6 @@ def test_context_manager(): def test_tell(): # Arrange with Image.open(static_test_file) as im: - # Act frame = im.tell() @@ -110,7 +111,6 @@ def test_eoferror(): def test_seek_tell(): with Image.open(animated_test_file) as im: - layer_number = im.tell() assert layer_number == 0 diff --git a/Tests/test_file_fpx.py b/Tests/test_file_fpx.py index fa22e90f6..af3b79815 100644 --- a/Tests/test_file_fpx.py +++ b/Tests/test_file_fpx.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import Image @@ -18,6 +19,16 @@ def test_sanity(): assert_image_equal_tofile(im, "Tests/images/input_bw_one_band.png") +def test_close(): + with Image.open("Tests/images/input_bw_one_band.fpx") as im: + pass + assert im.ole.fp.closed + + im = Image.open("Tests/images/input_bw_one_band.fpx") + im.close() + assert im.ole.fp.closed + + def test_invalid_file(): # Test an invalid OLE file invalid_file = "Tests/images/flower.jpg" diff --git a/Tests/test_file_ftex.py b/Tests/test_file_ftex.py index cae20fa46..a494c8029 100644 --- a/Tests/test_file_ftex.py +++ b/Tests/test_file_ftex.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import FtexImagePlugin, Image @@ -21,12 +22,3 @@ def test_invalid_file(): with pytest.raises(SyntaxError): FtexImagePlugin.FtexImageFile(invalid_file) - - -def test_constants_deprecation(): - for enum, prefix in { - FtexImagePlugin.Format: "FORMAT_", - }.items(): - for name in enum.__members__: - with pytest.warns(DeprecationWarning): - assert getattr(FtexImagePlugin, prefix + name) == enum[name] diff --git a/Tests/test_file_gbr.py b/Tests/test_file_gbr.py index 1ea8af8ee..7dfe05396 100644 --- a/Tests/test_file_gbr.py +++ b/Tests/test_file_gbr.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import GbrImagePlugin, Image diff --git a/Tests/test_file_gd.py b/Tests/test_file_gd.py index 5594e5bbb..ec80c54a1 100644 --- a/Tests/test_file_gd.py +++ b/Tests/test_file_gd.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import GdImageFile, UnidentifiedImageError diff --git a/Tests/test_file_gif.py b/Tests/test_file_gif.py index d85daf47e..ed5657d41 100644 --- a/Tests/test_file_gif.py +++ b/Tests/test_file_gif.py @@ -1,3 +1,4 @@ +from __future__ import annotations import warnings from io import BytesIO @@ -36,7 +37,8 @@ def test_unclosed_file(): im = Image.open(TEST_GIF) im.load() - pytest.warns(ResourceWarning, open) + with pytest.warns(ResourceWarning): + open() def test_closed_file(): @@ -158,39 +160,42 @@ def test_optimize(): assert test_bilevel(1) == 799 -def test_optimize_correctness(): - # 256 color Palette image, posterize to > 128 and < 128 levels - # Size bigger and smaller than 512x512 +@pytest.mark.parametrize( + "colors, size, expected_palette_length", + ( + # These do optimize the palette + (256, 511, 256), + (255, 511, 255), + (129, 511, 129), + (128, 511, 128), + (64, 511, 64), + (4, 511, 4), + # These don't optimize the palette + (128, 513, 256), + (64, 513, 256), + (4, 513, 256), + ), +) +def test_optimize_correctness(colors, size, expected_palette_length): + # 256 color Palette image, posterize to > 128 and < 128 levels. + # Size bigger and smaller than 512x512. # Check the palette for number of colors allocated. - # Check for correctness after conversion back to RGB - def check(colors, size, expected_palette_length): - # make an image with empty colors in the start of the palette range - im = Image.frombytes( - "P", (colors, colors), bytes(range(256 - colors, 256)) * colors - ) - im = im.resize((size, size)) - outfile = BytesIO() - im.save(outfile, "GIF") - outfile.seek(0) - with Image.open(outfile) as reloaded: - # check palette length - palette_length = max(i + 1 for i, v in enumerate(reloaded.histogram()) if v) - assert expected_palette_length == palette_length + # Check for correctness after conversion back to RGB. - assert_image_equal(im.convert("RGB"), reloaded.convert("RGB")) + # make an image with empty colors in the start of the palette range + im = Image.frombytes( + "P", (colors, colors), bytes(range(256 - colors, 256)) * colors + ) + im = im.resize((size, size)) + outfile = BytesIO() + im.save(outfile, "GIF") + outfile.seek(0) + with Image.open(outfile) as reloaded: + # check palette length + palette_length = max(i + 1 for i, v in enumerate(reloaded.histogram()) if v) + assert expected_palette_length == palette_length - # These do optimize the palette - check(256, 511, 256) - check(255, 511, 255) - check(129, 511, 129) - check(128, 511, 128) - check(64, 511, 64) - check(4, 511, 4) - - # These don't optimize the palette - check(128, 513, 256) - check(64, 513, 256) - check(4, 513, 256) + assert_image_equal(im.convert("RGB"), reloaded.convert("RGB")) def test_optimize_full_l(): @@ -201,24 +206,44 @@ def test_optimize_full_l(): def test_optimize_if_palette_can_be_reduced_by_half(): - with Image.open("Tests/images/test.colors.gif") as im: - # Reduce dimensions because original is too big for _get_optimize() - im = im.resize((591, 443)) - im_rgb = im.convert("RGB") + im = Image.new("P", (8, 1)) + im.palette = ImagePalette.raw("RGB", bytes((0, 0, 0) * 150)) + for i in range(8): + im.putpixel((i, 0), (i + 1, 0, 0)) - for (optimize, colors) in ((False, 256), (True, 8)): + for optimize, colors in ((False, 256), (True, 8)): out = BytesIO() - im_rgb.save(out, "GIF", optimize=optimize) + im.save(out, "GIF", optimize=optimize) with Image.open(out) as reloaded: assert len(reloaded.palette.palette) // 3 == colors +def test_full_palette_second_frame(tmp_path): + out = str(tmp_path / "temp.gif") + im = Image.new("P", (1, 256)) + + full_palette_im = Image.new("P", (1, 256)) + for i in range(256): + full_palette_im.putpixel((0, i), i) + full_palette_im.palette = ImagePalette.ImagePalette( + "RGB", bytearray(i // 3 for i in range(768)) + ) + full_palette_im.palette.dirty = 1 + + im.save(out, save_all=True, append_images=[full_palette_im]) + + with Image.open(out) as reloaded: + reloaded.seek(1) + + for i in range(256): + reloaded.getpixel((0, i)) == i + + def test_roundtrip(tmp_path): out = str(tmp_path / "temp.gif") im = hopper() im.save(out) with Image.open(out) as reread: - assert_image_similar(reread.convert("RGB"), im, 50) @@ -229,7 +254,6 @@ def test_roundtrip2(tmp_path): im2 = im.copy() im2.save(out) with Image.open(out) as reread: - assert_image_similar(reread.convert("RGB"), hopper(), 50) @@ -239,7 +263,6 @@ def test_roundtrip_save_all(tmp_path): im = hopper() im.save(out, save_all=True) with Image.open(out) as reread: - assert_image_similar(reread.convert("RGB"), im, 50) # Multiframe image @@ -251,6 +274,19 @@ def test_roundtrip_save_all(tmp_path): assert reread.n_frames == 5 +def test_roundtrip_save_all_1(tmp_path): + out = str(tmp_path / "temp.gif") + im = Image.new("1", (1, 1)) + im2 = Image.new("1", (1, 1), 1) + im.save(out, save_all=True, append_images=[im2]) + + with Image.open(out) as reloaded: + assert reloaded.getpixel((0, 0)) == 0 + + reloaded.seek(1) + assert reloaded.getpixel((0, 0)) == 255 + + @pytest.mark.parametrize( "path, mode", ( @@ -281,13 +317,11 @@ def test_headers_saving_for_animated_gifs(tmp_path): important_headers = ["background", "version", "duration", "loop"] # Multiframe image with Image.open("Tests/images/dispose_bgnd.gif") as im: - info = im.info.copy() out = str(tmp_path / "temp.gif") im.save(out, save_all=True) with Image.open(out) as reread: - for header in important_headers: assert info[header] == reread.info[header] @@ -305,7 +339,6 @@ def test_palette_handling(tmp_path): im2.save(f, optimize=True) with Image.open(f) as reloaded: - assert_image_similar(im, reloaded.convert("RGB"), 10) @@ -321,7 +354,6 @@ def test_palette_434(tmp_path): orig = "Tests/images/test.colors.gif" with Image.open(orig) as im: - with roundtrip(im) as reloaded: assert_image_similar(im, reloaded, 1) with roundtrip(im, optimize=True) as reloaded: @@ -572,7 +604,6 @@ def test_save_dispose(tmp_path): ) with Image.open(out) as img: - for i in range(2): img.seek(img.tell() + 1) assert img.disposal_method == i + 1 @@ -581,7 +612,7 @@ def test_save_dispose(tmp_path): def test_dispose2_palette(tmp_path): out = str(tmp_path / "temp.gif") - # Four colors: white, grey, black, red + # Four colors: white, gray, black, red circles = [(255, 255, 255), (153, 153, 153), (0, 0, 0), (255, 0, 0)] im_list = [] @@ -677,6 +708,24 @@ def test_dispose2_background(tmp_path): assert im.getpixel((0, 0)) == (255, 0, 0) +def test_dispose2_background_frame(tmp_path): + out = str(tmp_path / "temp.gif") + + im_list = [Image.new("RGBA", (1, 20))] + + different_frame = Image.new("RGBA", (1, 20)) + different_frame.putpixel((0, 10), (255, 0, 0, 255)) + im_list.append(different_frame) + + # Frame that matches the background + im_list.append(Image.new("RGBA", (1, 20))) + + im_list[0].save(out, save_all=True, append_images=im_list[1:], disposal=2) + + with Image.open(out) as im: + assert im.n_frames == 3 + + def test_transparency_in_second_frame(tmp_path): out = str(tmp_path / "temp.gif") with Image.open("Tests/images/different_transparency.gif") as im: @@ -752,7 +801,6 @@ def test_multiple_duration(tmp_path): out, save_all=True, append_images=im_list[1:], duration=duration_list ) with Image.open(out) as reread: - for duration in duration_list: assert reread.info["duration"] == duration try: @@ -765,7 +813,6 @@ def test_multiple_duration(tmp_path): out, save_all=True, append_images=im_list[1:], duration=tuple(duration_list) ) with Image.open(out) as reread: - for duration in duration_list: assert reread.info["duration"] == duration try: @@ -823,7 +870,6 @@ def test_identical_frames(tmp_path): out, save_all=True, append_images=im_list[1:], duration=duration_list ) with Image.open(out) as reread: - # Assert that the first three frames were combined assert reread.n_frames == 2 @@ -832,7 +878,14 @@ def test_identical_frames(tmp_path): @pytest.mark.parametrize( - "duration", ([1000, 1500, 2000, 4000], (1000, 1500, 2000, 4000), 8500) + "duration", + ( + [1000, 1500, 2000], + (1000, 1500, 2000), + # One more duration than the number of frames + [1000, 1500, 2000, 4000], + 1500, + ), ) def test_identical_frames_to_single_frame(duration, tmp_path): out = str(tmp_path / "temp.gif") @@ -848,7 +901,15 @@ def test_identical_frames_to_single_frame(duration, tmp_path): assert reread.n_frames == 1 # Assert that the new duration is the total of the identical frames - assert reread.info["duration"] == 8500 + assert reread.info["duration"] == 4500 + + +def test_loop_none(tmp_path): + out = str(tmp_path / "temp.gif") + im = Image.new("L", (100, 100), "#000") + im.save(out, loop=None) + with Image.open(out) as reread: + assert "loop" not in reread.info def test_number_of_loops(tmp_path): @@ -1062,6 +1123,21 @@ def test_transparent_optimize(tmp_path): assert reloaded.info["transparency"] == reloaded.getpixel((252, 0)) +def test_removed_transparency(tmp_path): + out = str(tmp_path / "temp.gif") + im = Image.new("RGB", (256, 1)) + + for x in range(256): + im.putpixel((x, 0), (x, 0, 0)) + + im.info["transparency"] = (255, 255, 255) + with pytest.warns(UserWarning): + im.save(out) + + with Image.open(out) as reloaded: + assert "transparency" not in reloaded.info + + def test_rgb_transparency(tmp_path): out = str(tmp_path / "temp.gif") @@ -1077,7 +1153,8 @@ def test_rgb_transparency(tmp_path): im = Image.new("RGB", (1, 1)) im.info["transparency"] = b"" ims = [Image.new("RGB", (1, 1))] - pytest.warns(UserWarning, im.save, out, save_all=True, append_images=ims) + with pytest.warns(UserWarning): + im.save(out, save_all=True, append_images=ims) with Image.open(out) as reloaded: assert "transparency" not in reloaded.info @@ -1094,6 +1171,12 @@ def test_rgba_transparency(tmp_path): assert_image_equal(hopper("P").convert("RGB"), reloaded) +def test_background_outside_palettte(tmp_path): + with Image.open("Tests/images/background_outside_palette.gif") as im: + im.seek(1) + assert im.info["background"] == 255 + + def test_bbox(tmp_path): out = str(tmp_path / "temp.gif") @@ -1105,6 +1188,18 @@ def test_bbox(tmp_path): assert reread.n_frames == 2 +def test_bbox_alpha(tmp_path): + out = str(tmp_path / "temp.gif") + + im = Image.new("RGBA", (1, 2), (255, 0, 0, 255)) + im.putpixel((0, 1), (255, 0, 0, 0)) + im2 = Image.new("RGBA", (1, 2), (255, 0, 0, 0)) + im.save(out, save_all=True, append_images=[im2]) + + with Image.open(out) as reread: + assert reread.n_frames == 2 + + def test_palette_save_L(tmp_path): # Generate an L mode image with a separate palette @@ -1120,18 +1215,17 @@ def test_palette_save_L(tmp_path): def test_palette_save_P(tmp_path): - # Pass in a different palette, then construct what the image would look like. - # Forcing a non-straight grayscale palette. - - im = hopper("P") - palette = bytes(255 - i // 3 for i in range(768)) + im = Image.new("P", (1, 2)) + im.putpixel((0, 1), 1) out = str(tmp_path / "temp.gif") - im.save(out, palette=palette) + im.save(out, palette=bytes((1, 2, 3, 4, 5, 6))) with Image.open(out) as reloaded: - im.putpalette(palette) - assert_image_equal(reloaded, im) + reloaded_rgb = reloaded.convert("RGB") + + assert reloaded_rgb.getpixel((0, 0)) == (1, 2, 3) + assert reloaded_rgb.getpixel((0, 1)) == (4, 5, 6) def test_palette_save_duplicate_entries(tmp_path): diff --git a/Tests/test_file_gimpgradient.py b/Tests/test_file_gimpgradient.py index 3f056fdae..d5be46dc3 100644 --- a/Tests/test_file_gimpgradient.py +++ b/Tests/test_file_gimpgradient.py @@ -1,3 +1,4 @@ +from __future__ import annotations from PIL import GimpGradientFile, ImagePalette diff --git a/Tests/test_file_gimppalette.py b/Tests/test_file_gimppalette.py index caec9cf21..775d3b7cd 100644 --- a/Tests/test_file_gimppalette.py +++ b/Tests/test_file_gimppalette.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL.GimpPaletteFile import GimpPaletteFile diff --git a/Tests/test_file_gribstub.py b/Tests/test_file_gribstub.py index fd427746e..d962e85a4 100644 --- a/Tests/test_file_gribstub.py +++ b/Tests/test_file_gribstub.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import GribStubImagePlugin, Image @@ -10,7 +11,6 @@ TEST_FILE = "Tests/images/WAlaska.wind.7days.grb" def test_open(): # Act with Image.open(TEST_FILE) as im: - # Assert assert im.format == "GRIB" @@ -31,7 +31,6 @@ def test_invalid_file(): def test_load(): # Arrange with Image.open(TEST_FILE) as im: - # Act / Assert: stub cannot load without an implemented handler with pytest.raises(OSError): im.load() @@ -58,6 +57,7 @@ def test_handler(tmp_path): def load(self, im): self.loaded = True + im.fp.close() return Image.new("RGB", (1, 1)) def save(self, im, fp, filename): diff --git a/Tests/test_file_hdf5stub.py b/Tests/test_file_hdf5stub.py index 20b4b9619..9c776b712 100644 --- a/Tests/test_file_hdf5stub.py +++ b/Tests/test_file_hdf5stub.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import Hdf5StubImagePlugin, Image @@ -8,7 +9,6 @@ TEST_FILE = "Tests/images/hdf5.h5" def test_open(): # Act with Image.open(TEST_FILE) as im: - # Assert assert im.format == "HDF5" @@ -29,7 +29,6 @@ def test_invalid_file(): def test_load(): # Arrange with Image.open(TEST_FILE) as im: - # Act / Assert: stub cannot load without an implemented handler with pytest.raises(OSError): im.load() @@ -59,6 +58,7 @@ def test_handler(tmp_path): def load(self, im): self.loaded = True + im.fp.close() return Image.new("RGB", (1, 1)) def save(self, im, fp, filename): diff --git a/Tests/test_file_icns.py b/Tests/test_file_icns.py index 55632909c..c62fffc5b 100644 --- a/Tests/test_file_icns.py +++ b/Tests/test_file_icns.py @@ -1,3 +1,4 @@ +from __future__ import annotations import io import os import warnings @@ -16,7 +17,6 @@ def test_sanity(): # Loading this icon by default should result in the largest size # (512x512@2x) being loaded with Image.open(TEST_FILE) as im: - # Assert that there is no unclosed file warning with warnings.catch_warnings(): im.load() diff --git a/Tests/test_file_ico.py b/Tests/test_file_ico.py index 3fcd5c61f..de9fa353a 100644 --- a/Tests/test_file_ico.py +++ b/Tests/test_file_ico.py @@ -1,3 +1,4 @@ +from __future__ import annotations import io import os @@ -71,6 +72,19 @@ def test_save_to_bytes(): ) +def test_getpixel(tmp_path): + temp_file = str(tmp_path / "temp.ico") + + im = hopper() + im.save(temp_file, "ico", sizes=[(32, 32), (64, 64)]) + + with Image.open(temp_file) as reloaded: + reloaded.load() + reloaded.size = (32, 32) + + assert reloaded.getpixel((0, 0)) == (18, 20, 62) + + def test_no_duplicates(tmp_path): temp_file = str(tmp_path / "temp.ico") temp_file2 = str(tmp_path / "temp2.ico") @@ -162,7 +176,6 @@ def test_save_256x256(tmp_path): # Act im.save(outfile) with Image.open(outfile) as im_saved: - # Assert assert im_saved.size == (256, 256) @@ -200,12 +213,10 @@ def test_save_append_images(tmp_path): def test_unexpected_size(): # This image has been manually hexedited to state that it is 16x32 # while the image within is still 16x16 - def open(): + with pytest.warns(UserWarning): with Image.open("Tests/images/hopper_unexpected.ico") as im: assert im.size == (16, 16) - pytest.warns(UserWarning, open) - def test_draw_reloaded(tmp_path): with Image.open(TEST_ICO_FILE) as im: diff --git a/Tests/test_file_im.py b/Tests/test_file_im.py index 5cf93713b..0cb26d06a 100644 --- a/Tests/test_file_im.py +++ b/Tests/test_file_im.py @@ -1,3 +1,4 @@ +from __future__ import annotations import filecmp import warnings @@ -32,7 +33,8 @@ def test_unclosed_file(): im = Image.open(TEST_IM) im.load() - pytest.warns(ResourceWarning, open) + with pytest.warns(ResourceWarning): + open() def test_closed_file(): @@ -51,7 +53,6 @@ def test_context_manager(): def test_tell(): # Arrange with Image.open(TEST_IM) as im: - # Act frame = im.tell() diff --git a/Tests/test_file_imt.py b/Tests/test_file_imt.py index f56acc429..3db488558 100644 --- a/Tests/test_file_imt.py +++ b/Tests/test_file_imt.py @@ -1,3 +1,4 @@ +from __future__ import annotations import io import pytest diff --git a/Tests/test_file_iptc.py b/Tests/test_file_iptc.py index 2d0e6977a..d0ecde393 100644 --- a/Tests/test_file_iptc.py +++ b/Tests/test_file_iptc.py @@ -1,5 +1,8 @@ +from __future__ import annotations import sys -from io import StringIO +from io import BytesIO, StringIO + +import pytest from PIL import Image, IptcImagePlugin @@ -11,7 +14,6 @@ TEST_FILE = "Tests/images/iptc.jpg" def test_getiptcinfo_jpg_none(): # Arrange with hopper() as im: - # Act iptc = IptcImagePlugin.getiptcinfo(im) @@ -22,7 +24,6 @@ def test_getiptcinfo_jpg_none(): def test_getiptcinfo_jpg_found(): # Arrange with Image.open(TEST_FILE) as im: - # Act iptc = IptcImagePlugin.getiptcinfo(im) @@ -32,10 +33,39 @@ def test_getiptcinfo_jpg_found(): assert iptc[(2, 101)] == b"Hungary" +def test_getiptcinfo_fotostation(): + # Arrange + with open(TEST_FILE, "rb") as fp: + data = bytearray(fp.read()) + data[86] = 240 + f = BytesIO(data) + with Image.open(f) as im: + # Act + iptc = IptcImagePlugin.getiptcinfo(im) + + # Assert + for tag in iptc.keys(): + if tag[0] == 240: + return + pytest.fail("FotoStation tag not found") + + +def test_getiptcinfo_zero_padding(): + # Arrange + with Image.open(TEST_FILE) as im: + im.info["photoshop"][0x0404] += b"\x00\x00\x00" + + # Act + iptc = IptcImagePlugin.getiptcinfo(im) + + # Assert + assert isinstance(iptc, dict) + assert len(iptc) == 3 + + def test_getiptcinfo_tiff_none(): # Arrange with Image.open("Tests/images/hopper.tif") as im: - # Act iptc = IptcImagePlugin.getiptcinfo(im) diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py index 5b8e155fc..4779d4113 100644 --- a/Tests/test_file_jpeg.py +++ b/Tests/test_file_jpeg.py @@ -1,3 +1,4 @@ +from __future__ import annotations import os import re import warnings @@ -57,7 +58,6 @@ class TestFileJpeg: return Image.frombytes(mode, size, os.urandom(size[0] * size[1] * len(mode))) def test_sanity(self): - # internal version number assert re.search(r"\d+\.\d+$", features.version_codec("jpg")) @@ -215,13 +215,20 @@ class TestFileJpeg: # Should not raise OSError for image with icc larger than image size. im.save( f, - format="JPEG", progressive=True, quality=95, icc_profile=icc_profile, optimize=True, ) + with Image.open("Tests/images/flower2.jpg") as im: + f = str(tmp_path / "temp2.jpg") + im.save(f, progressive=True, quality=94, icc_profile=b" " * 53955) + + with Image.open("Tests/images/flower2.jpg") as im: + f = str(tmp_path / "temp3.jpg") + im.save(f, progressive=True, quality=94, exif=b" " * 43668) + def test_optimize(self): im1 = self.roundtrip(hopper()) im2 = self.roundtrip(hopper(), optimize=0) @@ -271,7 +278,10 @@ class TestFileJpeg: # https://github.com/python-pillow/Pillow/issues/148 f = str(tmp_path / "temp.jpg") im = hopper() - im.save(f, "JPEG", quality=90, exif=b"1" * 65532) + im.save(f, "JPEG", quality=90, exif=b"1" * 65533) + + with pytest.raises(ValueError): + im.save(f, "JPEG", quality=90, exif=b"1" * 65534) def test_exif_typeerror(self): with Image.open("Tests/images/exif_typeerror.jpg") as im: @@ -368,7 +378,6 @@ class TestFileJpeg: def test_exif_gps_typeerror(self): with Image.open("Tests/images/exif_gps_typeerror.jpg") as im: - # Should not raise a TypeError im._getexif() @@ -447,7 +456,7 @@ class TestFileJpeg: ims = im.get_child_images() assert len(ims) == 1 - assert_image_equal_tofile(ims[0], "Tests/images/flower_thumbnail.png") + assert_image_similar_tofile(ims[0], "Tests/images/flower_thumbnail.png", 2.1) def test_mp(self): with Image.open("Tests/images/pil_sample_rgb.jpg") as im: @@ -635,11 +644,22 @@ class TestFileJpeg: assert max(im2.quantization[0]) <= 255 assert max(im2.quantization[1]) <= 255 - def test_convert_dict_qtables_deprecation(self): - with pytest.warns(DeprecationWarning): - qtable = {0: [1, 2, 3, 4]} - qtable2 = JpegImagePlugin.convert_dict_qtables(qtable) - assert qtable == qtable2 + @pytest.mark.parametrize( + "blocks, rows, markers", + ((0, 0, 0), (1, 0, 15), (3, 0, 5), (8, 0, 1), (0, 1, 3), (0, 2, 1)), + ) + def test_restart_markers(self, blocks, rows, markers): + im = Image.new("RGB", (32, 32)) # 16 MCUs + out = BytesIO() + im.save( + out, + format="JPEG", + restart_marker_blocks=blocks, + restart_marker_rows=rows, + # force 8x8 pixel MCUs + subsampling=0, + ) + assert len(re.findall(b"\xff[\xd0-\xd7]", out.getvalue())) == markers @pytest.mark.skipif(not djpeg_available(), reason="djpeg not available") def test_load_djpeg(self): @@ -682,7 +702,6 @@ class TestFileJpeg: # Shouldn't raise error fn = "Tests/images/sugarshack_bad_mpo_header.jpg" with pytest.warns(UserWarning, Image.open, fn) as im: - # Assert assert im.format == "JPEG" @@ -715,7 +734,6 @@ class TestFileJpeg: # Arrange outfile = str(tmp_path / "temp.tif") with Image.open("Tests/images/hopper.tif") as im: - # Act im.save(outfile, "JPEG", dpi=im.info["dpi"]) @@ -742,7 +760,6 @@ class TestFileJpeg: # This Photoshop CC 2017 image has DPI in EXIF not metadata # EXIF XResolution is (2000000, 10000) with Image.open("Tests/images/photoshop-200dpi.jpg") as im: - # Act / Assert assert im.info.get("dpi") == (200, 200) @@ -751,7 +768,6 @@ class TestFileJpeg: # This image has DPI in EXIF not metadata # EXIF XResolution is 72 with Image.open("Tests/images/exif-72dpi-int.jpg") as im: - # Act / Assert assert im.info.get("dpi") == (72, 72) @@ -760,7 +776,6 @@ class TestFileJpeg: # This is photoshop-200dpi.jpg with EXIF resolution unit set to cm: # exiftool -exif:ResolutionUnit=cm photoshop-200dpi.jpg with Image.open("Tests/images/exif-200dpcm.jpg") as im: - # Act / Assert assert im.info.get("dpi") == (508, 508) @@ -769,7 +784,6 @@ class TestFileJpeg: # This is photoshop-200dpi.jpg with EXIF resolution set to 0/0: # exiftool -XResolution=0/0 -YResolution=0/0 photoshop-200dpi.jpg with Image.open("Tests/images/exif-dpi-zerodivision.jpg") as im: - # Act / Assert # This should return the default, and not raise a ZeroDivisionError assert im.info.get("dpi") == (72, 72) @@ -778,7 +792,13 @@ class TestFileJpeg: # Arrange # 0x011A tag in this exif contains string '300300\x02' with Image.open("Tests/images/broken_exif_dpi.jpg") as im: + # Act / Assert + # This should return the default + assert im.info.get("dpi") == (72, 72) + def test_dpi_exif_truncated(self): + # Arrange + with Image.open("Tests/images/truncated_exif_dpi.jpg") as im: # Act / Assert # This should return the default assert im.info.get("dpi") == (72, 72) @@ -788,7 +808,6 @@ class TestFileJpeg: # This is photoshop-200dpi.jpg with resolution removed from EXIF: # exiftool "-*resolution*"= photoshop-200dpi.jpg with Image.open("Tests/images/no-dpi-in-exif.jpg") as im: - # Act / Assert # "When the image resolution is unknown, 72 [dpi] is designated." # https://exiv2.org/tags.html @@ -798,7 +817,6 @@ class TestFileJpeg: # This is no-dpi-in-exif with the tiff header of the exif block # hexedited from MM * to FF FF FF FF with Image.open("Tests/images/invalid-exif.jpg") as im: - # This should return the default, and not a SyntaxError or # OSError for unidentified image. assert im.info.get("dpi") == (72, 72) @@ -821,7 +839,6 @@ class TestFileJpeg: def test_invalid_exif_x_resolution(self): # When no x or y resolution is defined in EXIF with Image.open("Tests/images/invalid-exif-without-x-resolution.jpg") as im: - # This should return the default, and not a ValueError or # OSError for an unidentified image. assert im.info.get("dpi") == (72, 72) @@ -831,7 +848,6 @@ class TestFileJpeg: # This image has been manually hexedited to have an IFD offset of 10, # in contrast to normal 8 with Image.open("Tests/images/exif-ifd-offset.jpg") as im: - # Act / Assert assert im._getexif()[306] == "2017:03:13 23:03:09" @@ -902,7 +918,10 @@ class TestFileJpeg: def test_getxmp(self): with Image.open("Tests/images/xmp_test.jpg") as im: if ElementTree is None: - with pytest.warns(UserWarning): + with pytest.warns( + UserWarning, + match="XMP data cannot be read without defusedxml dependency", + ): assert im.getxmp() == {} else: xmp = im.getxmp() @@ -925,6 +944,28 @@ class TestFileJpeg: with Image.open("Tests/images/hopper.jpg") as im: assert im.getxmp() == {} + def test_getxmp_no_prefix(self): + with Image.open("Tests/images/xmp_no_prefix.jpg") as im: + if ElementTree is None: + with pytest.warns( + UserWarning, + match="XMP data cannot be read without defusedxml dependency", + ): + assert im.getxmp() == {} + else: + assert im.getxmp() == {"xmpmeta": {"key": "value"}} + + def test_getxmp_padded(self): + with Image.open("Tests/images/xmp_padded.jpg") as im: + if ElementTree is None: + with pytest.warns( + UserWarning, + match="XMP data cannot be read without defusedxml dependency", + ): + assert im.getxmp() == {} + else: + assert im.getxmp() == {"xmpmeta": None} + @pytest.mark.timeout(timeout=1) def test_eof(self): # Even though this decoder never says that it is finished @@ -949,6 +990,40 @@ class TestFileJpeg: im.load() ImageFile.LOAD_TRUNCATED_IMAGES = False + def test_separate_tables(self): + im = hopper() + data = [] # [interchange, tables-only, image-only] + for streamtype in range(3): + out = BytesIO() + im.save(out, format="JPEG", streamtype=streamtype) + data.append(out.getvalue()) + + # SOI, EOI + for marker in b"\xff\xd8", b"\xff\xd9": + assert marker in data[1] and marker in data[2] + # DHT, DQT + for marker in b"\xff\xc4", b"\xff\xdb": + assert marker in data[1] and marker not in data[2] + # SOF0, SOS, APP0 (JFIF header) + for marker in b"\xff\xc0", b"\xff\xda", b"\xff\xe0": + assert marker not in data[1] and marker in data[2] + + with Image.open(BytesIO(data[0])) as interchange_im: + with Image.open(BytesIO(data[1] + data[2])) as combined_im: + assert_image_equal(interchange_im, combined_im) + + def test_repr_jpeg(self): + im = hopper() + + with Image.open(BytesIO(im._repr_jpeg_())) as repr_jpeg: + assert repr_jpeg.format == "JPEG" + assert_image_similar(im, repr_jpeg, 17) + + def test_repr_jpeg_error_returns_none(self): + im = hopper("F") + + assert im._repr_jpeg_() is None + @pytest.mark.skipif(not is_win32(), reason="Windows only") @skip_unless_feature("jpg") diff --git a/Tests/test_file_jpeg2k.py b/Tests/test_file_jpeg2k.py index cd142e67f..aaa4104e5 100644 --- a/Tests/test_file_jpeg2k.py +++ b/Tests/test_file_jpeg2k.py @@ -1,16 +1,25 @@ +from __future__ import annotations import os import re from io import BytesIO import pytest -from PIL import Image, ImageFile, Jpeg2KImagePlugin, UnidentifiedImageError, features +from PIL import ( + Image, + ImageFile, + Jpeg2KImagePlugin, + UnidentifiedImageError, + _binary, + features, +) from .helper import ( assert_image_equal, assert_image_similar, assert_image_similar_tofile, skip_unless_feature, + skip_unless_feature_version, ) EXTRA_DIR = "Tests/images/jpeg2000" @@ -252,18 +261,29 @@ def test_mct(): assert_image_similar(im, jp2, 1.0e-3) -def test_rgba(): +def test_sgnd(tmp_path): + outfile = str(tmp_path / "temp.jp2") + + im = Image.new("L", (1, 1)) + im.save(outfile) + with Image.open(outfile) as reloaded: + assert reloaded.getpixel((0, 0)) == 0 + + im = Image.new("L", (1, 1)) + im.save(outfile, signed=True) + with Image.open(outfile) as reloaded_signed: + assert reloaded_signed.getpixel((0, 0)) == 128 + + +@pytest.mark.parametrize("ext", (".j2k", ".jp2")) +def test_rgba(ext): # Arrange - with Image.open("Tests/images/rgb_trns_ycbc.j2k") as j2k: - with Image.open("Tests/images/rgb_trns_ycbc.jp2") as jp2: + with Image.open("Tests/images/rgb_trns_ycbc" + ext) as im: + # Act + im.load() - # Act - j2k.load() - jp2.load() - - # Assert - assert j2k.mode == "RGBA" - assert jp2.mode == "RGBA" + # Assert + assert im.mode == "RGBA" @pytest.mark.parametrize("ext", (".j2k", ".jp2")) @@ -340,6 +360,35 @@ def test_subsampling_decode(name): assert_image_similar(im, expected, epsilon) +def test_comment(): + with Image.open("Tests/images/comment.jp2") as im: + assert im.info["comment"] == b"Created by OpenJPEG version 2.5.0" + + # Test an image that is truncated partway through a codestream + with open("Tests/images/comment.jp2", "rb") as fp: + b = BytesIO(fp.read(130)) + with Image.open(b) as im: + pass + + +def test_save_comment(): + for comment in ("Created by Pillow", b"Created by Pillow"): + out = BytesIO() + test_card.save(out, "JPEG2000", comment=comment) + + with Image.open(out) as im: + assert im.info["comment"] == b"Created by Pillow" + + out = BytesIO() + long_comment = b" " * 65531 + test_card.save(out, "JPEG2000", comment=long_comment) + with Image.open(out) as im: + assert im.info["comment"] == long_comment + + with pytest.raises(ValueError): + test_card.save(out, "JPEG2000", comment=long_comment + b" ") + + @pytest.mark.parametrize( "test_file", [ @@ -357,3 +406,29 @@ def test_crashes(test_file): im.load() except OSError: pass + + +@skip_unless_feature_version("jpg_2000", "2.4.0") +def test_plt_marker(): + # Search the start of the codesteam for PLT + out = BytesIO() + test_card.save(out, "JPEG2000", no_jp2=True, plt=True) + out.seek(0) + while True: + marker = out.read(2) + if not marker: + pytest.fail("End of stream without PLT") + + jp2_boxid = _binary.i16be(marker) + if jp2_boxid == 0xFF4F: + # SOC has no length + continue + elif jp2_boxid == 0xFF58: + # PLT + return + elif jp2_boxid == 0xFF93: + pytest.fail("SOD without finding PLT first") + + hdr = out.read(2) + length = _binary.i16be(hdr) + out.seek(length - 2, os.SEEK_CUR) diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py index 1109cd15e..65adf449d 100644 --- a/Tests/test_file_libtiff.py +++ b/Tests/test_file_libtiff.py @@ -1,3 +1,4 @@ +from __future__ import annotations import base64 import io import itertools @@ -8,7 +9,7 @@ from collections import namedtuple import pytest -from PIL import Image, ImageFilter, TiffImagePlugin, TiffTags, features +from PIL import Image, ImageFilter, ImageOps, TiffImagePlugin, TiffTags, features from PIL.TiffImagePlugin import SAMPLEFORMAT, STRIPOFFSETS, SUBIFD from .helper import ( @@ -645,7 +646,6 @@ class TestFileLibTiff(LibTiffTestCase): pilim = hopper() def save_bytesio(compression=None): - buffer_io = io.BytesIO() pilim.save(buffer_io, format="tiff", compression=compression) buffer_io.seek(0) @@ -669,6 +669,16 @@ class TestFileLibTiff(LibTiffTestCase): assert reloaded.tag_v2[530] == (1, 1) assert reloaded.tag_v2[532] == (0, 255, 128, 255, 128, 255) + def test_exif_ifd(self, tmp_path): + outfile = str(tmp_path / "temp.tif") + with Image.open("Tests/images/tiff_adobe_deflate.tif") as im: + assert im.tag_v2[34665] == 125456 + im.save(outfile) + + with Image.open(outfile) as reloaded: + if Image.core.libtiff_support_custom_tags: + assert reloaded.tag_v2[34665] == 125456 + def test_crashing_metadata(self, tmp_path): # issue 1597 with Image.open("Tests/images/rdf.tif") as im: @@ -740,7 +750,6 @@ class TestFileLibTiff(LibTiffTestCase): def test_multipage_compression(self): with Image.open("Tests/images/compression.tif") as im: - im.seek(0) assert im._compression == "tiff_ccitt" assert im.size == (10, 10) @@ -986,6 +995,36 @@ class TestFileLibTiff(LibTiffTestCase): ) as im: assert_image_equal_tofile(im, "Tests/images/old-style-jpeg-compression.png") + @pytest.mark.parametrize( + "file_name, mode, size, tile", + [ + ( + "tiff_wrong_bits_per_sample.tiff", + "RGBA", + (52, 53), + [("raw", (0, 0, 52, 53), 160, ("RGBA", 0, 1))], + ), + ( + "tiff_wrong_bits_per_sample_2.tiff", + "RGB", + (16, 16), + [("raw", (0, 0, 16, 16), 8, ("RGB", 0, 1))], + ), + ( + "tiff_wrong_bits_per_sample_3.tiff", + "RGBA", + (512, 256), + [("libtiff", (0, 0, 512, 256), 0, ("RGBA", "tiff_lzw", False, 48782))], + ), + ], + ) + def test_wrong_bits_per_sample(self, file_name, mode, size, tile): + with Image.open("Tests/images/" + file_name) as im: + assert im.mode == mode + assert im.size == size + assert im.tile == tile + im.load() + def test_no_rows_per_strip(self): # This image does not have a RowsPerStrip TIFF tag infile = "Tests/images/no_rows_per_strip.tif" @@ -997,7 +1036,18 @@ class TestFileLibTiff(LibTiffTestCase): with Image.open("Tests/images/g4_orientation_1.tif") as base_im: for i in range(2, 9): with Image.open("Tests/images/g4_orientation_" + str(i) + ".tif") as im: + assert 274 in im.tag_v2 + im.load() + assert 274 not in im.tag_v2 + + assert_image_similar(base_im, im, 0.7) + + def test_exif_transpose(self): + with Image.open("Tests/images/g4_orientation_1.tif") as base_im: + for i in range(2, 9): + with Image.open("Tests/images/g4_orientation_" + str(i) + ".tif") as im: + im = ImageOps.exif_transpose(im) assert_image_similar(base_im, im, 0.7) @@ -1067,3 +1117,27 @@ class TestFileLibTiff(LibTiffTestCase): out = str(tmp_path / "temp.tif") with pytest.raises(SystemError): im.save(out, compression=compression) + + def test_save_many_compressed(self, tmp_path): + im = hopper() + out = str(tmp_path / "temp.tif") + for _ in range(10000): + im.save(out, compression="jpeg") + + @pytest.mark.parametrize( + "path, sizes", + ( + ("Tests/images/hopper.tif", ()), + ("Tests/images/child_ifd.tiff", (16, 8)), + ("Tests/images/child_ifd_jpeg.tiff", (20,)), + ), + ) + def test_get_child_images(self, path, sizes): + with Image.open(path) as im: + ims = im.get_child_images() + + assert len(ims) == len(sizes) + for i, im in enumerate(ims): + w = sizes[i] + expected = Image.new("RGB", (w, w), "#f00") + assert_image_similar(im, expected, 1) diff --git a/Tests/test_file_libtiff_small.py b/Tests/test_file_libtiff_small.py index 03137c8b6..9501c55a6 100644 --- a/Tests/test_file_libtiff_small.py +++ b/Tests/test_file_libtiff_small.py @@ -1,3 +1,4 @@ +from __future__ import annotations from io import BytesIO from PIL import Image diff --git a/Tests/test_file_mcidas.py b/Tests/test_file_mcidas.py index 41f22cf0c..4b31aaa78 100644 --- a/Tests/test_file_mcidas.py +++ b/Tests/test_file_mcidas.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import Image, McIdasImagePlugin diff --git a/Tests/test_file_mic.py b/Tests/test_file_mic.py index 464d138e2..e7ea39ea9 100644 --- a/Tests/test_file_mic.py +++ b/Tests/test_file_mic.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import Image, ImagePalette @@ -51,6 +52,16 @@ def test_seek(): assert im.tell() == 0 +def test_close(): + with Image.open(TEST_FILE) as im: + pass + assert im.ole.fp.closed + + im = Image.open(TEST_FILE) + im.close() + assert im.ole.fp.closed + + def test_invalid_file(): # Test an invalid OLE file invalid_file = "Tests/images/flower.jpg" diff --git a/Tests/test_file_mpo.py b/Tests/test_file_mpo.py index dba1ec1b1..da62bc6d4 100644 --- a/Tests/test_file_mpo.py +++ b/Tests/test_file_mpo.py @@ -1,3 +1,4 @@ +from __future__ import annotations import warnings from io import BytesIO @@ -42,7 +43,8 @@ def test_unclosed_file(): im = Image.open(test_files[0]) im.load() - pytest.warns(ResourceWarning, open) + with pytest.warns(ResourceWarning): + open() def test_closed_file(): @@ -80,7 +82,10 @@ def test_app(test_file): @pytest.mark.parametrize("test_file", test_files) def test_exif(test_file): - with Image.open(test_file) as im: + with Image.open(test_file) as im_original: + im_reloaded = roundtrip(im_original, save_all=True, exif=im_original.getexif()) + + for im in (im_original, im_reloaded): info = im._getexif() assert info[272] == "Nintendo 3DS" assert info[296] == 2 @@ -165,8 +170,7 @@ def test_mp_no_data(): def test_mp_attribute(test_file): with Image.open(test_file) as im: mpinfo = im._getmp() - frame_number = 0 - for mpentry in mpinfo[0xB002]: + for frame_number, mpentry in enumerate(mpinfo[0xB002]): mpattr = mpentry["Attribute"] if frame_number: assert not mpattr["RepresentativeImageFlag"] @@ -177,7 +181,6 @@ def test_mp_attribute(test_file): assert mpattr["ImageDataFormat"] == "JPEG" assert mpattr["MPType"] == "Multi-Frame Image: (Disparity)" assert mpattr["Reserved"] == 0 - frame_number += 1 @pytest.mark.parametrize("test_file", test_files) diff --git a/Tests/test_file_msp.py b/Tests/test_file_msp.py index 50d7c590b..f4e357ae0 100644 --- a/Tests/test_file_msp.py +++ b/Tests/test_file_msp.py @@ -1,3 +1,4 @@ +from __future__ import annotations import os import pytest @@ -44,7 +45,6 @@ def test_open_windows_v1(): # Arrange # Act with Image.open(TEST_FILE) as im: - # Assert assert_image_equal(im, hopper("1")) assert isinstance(im, MspImagePlugin.MspImageFile) @@ -59,7 +59,6 @@ def _assert_file_image_equal(source_path, target_path): not os.path.exists(EXTRA_DIR), reason="Extra image files not installed" ) def test_open_windows_v2(): - files = ( os.path.join(EXTRA_DIR, f) for f in os.listdir(EXTRA_DIR) diff --git a/Tests/test_file_palm.py b/Tests/test_file_palm.py index be7c8d0c8..735840de4 100644 --- a/Tests/test_file_palm.py +++ b/Tests/test_file_palm.py @@ -1,3 +1,4 @@ +from __future__ import annotations import os.path import subprocess @@ -26,8 +27,7 @@ def open_with_magick(magick, tmp_path, f): rc = subprocess.call( magick + [f, outfile], stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT ) - if rc: - raise OSError + assert not rc return Image.open(outfile) diff --git a/Tests/test_file_pcd.py b/Tests/test_file_pcd.py index dc45a48c1..596a3414f 100644 --- a/Tests/test_file_pcd.py +++ b/Tests/test_file_pcd.py @@ -1,3 +1,4 @@ +from __future__ import annotations from PIL import Image diff --git a/Tests/test_file_pcx.py b/Tests/test_file_pcx.py index 485adf785..f42ec4a68 100644 --- a/Tests/test_file_pcx.py +++ b/Tests/test_file_pcx.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import Image, ImageFile, PcxImagePlugin diff --git a/Tests/test_file_pdf.py b/Tests/test_file_pdf.py index 9667b6a4a..9e07d9ed0 100644 --- a/Tests/test_file_pdf.py +++ b/Tests/test_file_pdf.py @@ -1,3 +1,4 @@ +from __future__ import annotations import io import os import os.path @@ -8,7 +9,7 @@ import pytest from PIL import Image, PdfParser, features -from .helper import hopper, mark_if_feature_version +from .helper import hopper, mark_if_feature_version, skip_unless_feature def helper_save_as_pdf(tmp_path, mode, **kwargs): @@ -42,6 +43,28 @@ def test_save(tmp_path, mode): helper_save_as_pdf(tmp_path, mode) +@skip_unless_feature("jpg_2000") +@pytest.mark.parametrize("mode", ("LA", "RGBA")) +def test_save_alpha(tmp_path, mode): + helper_save_as_pdf(tmp_path, mode) + + +def test_p_alpha(tmp_path): + # Arrange + outfile = str(tmp_path / "temp.pdf") + with Image.open("Tests/images/pil123p.png") as im: + assert im.mode == "P" + assert isinstance(im.info["transparency"], bytes) + + # Act + im.save(outfile) + + # Assert + with open(outfile, "rb") as fp: + contents = fp.read() + assert b"\n/SMask " in contents + + def test_monochrome(tmp_path): # Arrange mode = "1" @@ -52,8 +75,8 @@ def test_monochrome(tmp_path): def test_unsupported_mode(tmp_path): - im = hopper("LA") - outfile = str(tmp_path / "temp_LA.pdf") + im = hopper("PA") + outfile = str(tmp_path / "temp_PA.pdf") with pytest.raises(ValueError): im.save(outfile) @@ -80,6 +103,34 @@ def test_resolution(tmp_path): assert size == (61.44, 61.44) +@pytest.mark.parametrize( + "params", + ( + {"dpi": (75, 150)}, + {"dpi": (75, 150), "resolution": 200}, + ), +) +def test_dpi(params, tmp_path): + im = hopper() + + outfile = str(tmp_path / "temp.pdf") + im.save(outfile, **params) + + with open(outfile, "rb") as fp: + contents = fp.read() + + size = tuple( + float(d) + for d in contents.split(b"stream\nq ")[1].split(b" 0 0 cm")[0].split(b" 0 0 ") + ) + assert size == (122.88, 61.44) + + size = tuple( + float(d) for d in contents.split(b"/MediaBox [ 0 0 ")[1].split(b"]")[0].split() + ) + assert size == (122.88, 61.44) + + @mark_if_feature_version( pytest.mark.valgrind_known_error, "libjpeg_turbo", "2.0", reason="Known Failing" ) @@ -89,7 +140,6 @@ def test_save_all(tmp_path): # Multiframe image with Image.open("Tests/images/dispose_bgnd.gif") as im: - outfile = str(tmp_path / "temp.pdf") im.save(outfile, save_all=True) @@ -123,7 +173,6 @@ def test_save_all(tmp_path): def test_multiframe_normal_save(tmp_path): # Test saving a multiframe image without save_all with Image.open("Tests/images/dispose_bgnd.gif") as im: - outfile = str(tmp_path / "temp.pdf") im.save(outfile) @@ -286,6 +335,7 @@ def test_pdf_append_to_bytesio(): @pytest.mark.timeout(1) +@pytest.mark.skipif("PILLOW_VALGRIND_TEST" in os.environ, reason="Valgrind is slower") @pytest.mark.parametrize("newline", (b"\r", b"\n")) def test_redos(newline): malicious = b" trailer<<>>" + newline * 3456 diff --git a/Tests/test_file_pixar.py b/Tests/test_file_pixar.py index 315ea4676..63779f202 100644 --- a/Tests/test_file_pixar.py +++ b/Tests/test_file_pixar.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import Image, PixarImagePlugin diff --git a/Tests/test_file_png.py b/Tests/test_file_png.py index 1ff6b6700..61446b4eb 100644 --- a/Tests/test_file_png.py +++ b/Tests/test_file_png.py @@ -1,3 +1,4 @@ +from __future__ import annotations import re import sys import warnings @@ -78,9 +79,8 @@ class TestFilePng: return chunks def test_sanity(self, tmp_path): - # internal version number - assert re.search(r"\d+\.\d+\.\d+(\.\d+)?$", features.version_codec("zlib")) + assert re.search(r"\d+(\.\d+){1,3}$", features.version_codec("zlib")) test_file = str(tmp_path / "temp.png") @@ -93,11 +93,11 @@ class TestFilePng: assert im.format == "PNG" assert im.get_format_mimetype() == "image/png" - for mode in ["1", "L", "P", "RGB", "I", "I;16"]: + for mode in ["1", "L", "P", "RGB", "I", "I;16", "I;16B"]: im = hopper(mode) im.save(test_file) with Image.open(test_file) as reloaded: - if mode == "I;16": + if mode in ("I;16", "I;16B"): reloaded = reloaded.convert(mode) assert_image_equal(reloaded, im) @@ -156,7 +156,6 @@ class TestFilePng: assert im.info == {"spam": "egg"} def test_bad_itxt(self): - im = load(HEAD + chunk(b"iTXt") + TAIL) assert im.info == {} @@ -201,7 +200,6 @@ class TestFilePng: assert im.info["spam"].tkey == "Spam" def test_interlace(self): - test_file = "Tests/images/pil123p.png" with Image.open(test_file) as im: assert_image(im, "P", (162, 150)) @@ -308,7 +306,7 @@ class TestFilePng: assert_image(im, "RGBA", (10, 10)) assert im.getcolors() == [(100, (0, 0, 0, 0))] - def test_save_greyscale_transparency(self, tmp_path): + def test_save_grayscale_transparency(self, tmp_path): for mode, num_transparent in {"1": 1994, "L": 559, "I": 559}.items(): in_file = "Tests/images/" + mode.lower() + "_trns.png" with Image.open(in_file) as im: @@ -503,7 +501,6 @@ class TestFilePng: # Check reading images with null tRNS value, issue #1239 test_file = "Tests/images/tRNS_null_1x1.png" with Image.open(test_file) as im: - assert im.info["transparency"] == 0 def test_save_icc_profile(self): @@ -544,11 +541,10 @@ class TestFilePng: assert repr_png.format == "PNG" assert_image_equal(im, repr_png) - def test_repr_png_error(self): + def test_repr_png_error_returns_none(self): im = hopper("F") - with pytest.raises(ValueError): - im._repr_png_() + assert im._repr_png_() is None def test_chunk_order(self, tmp_path): with Image.open("Tests/images/icc_profile.png") as im: @@ -601,7 +597,7 @@ class TestFilePng: def test_textual_chunks_after_idat(self): with Image.open("Tests/images/hopper.png") as im: - assert "comment" in im.text.keys() + assert "comment" in im.text for k, v in { "date:create": "2014-09-04T09:37:08+03:00", "date:modify": "2014-09-04T09:37:08+03:00", @@ -678,7 +674,10 @@ class TestFilePng: def test_getxmp(self): with Image.open("Tests/images/color_snakes.png") as im: if ElementTree is None: - with pytest.warns(UserWarning): + with pytest.warns( + UserWarning, + match="XMP data cannot be read without defusedxml dependency", + ): assert im.getxmp() == {} else: xmp = im.getxmp() @@ -714,10 +713,18 @@ class TestFilePng: assert exif[274] == 3 def test_exif_save(self, tmp_path): + # Test exif is not saved from info + test_file = str(tmp_path / "temp.png") with Image.open("Tests/images/exif.png") as im: - test_file = str(tmp_path / "temp.png") im.save(test_file) + with Image.open(test_file) as reloaded: + assert reloaded._getexif() is None + + # Test passing in exif + with Image.open("Tests/images/exif.png") as im: + im.save(test_file, exif=im.getexif()) + with Image.open(test_file) as reloaded: exif = reloaded._getexif() assert exif[274] == 1 @@ -728,7 +735,7 @@ class TestFilePng: def test_exif_from_jpg(self, tmp_path): with Image.open("Tests/images/pil_sample_rgb.jpg") as im: test_file = str(tmp_path / "temp.png") - im.save(test_file) + im.save(test_file, exif=im.getexif()) with Image.open(test_file) as reloaded: exif = reloaded._getexif() diff --git a/Tests/test_file_ppm.py b/Tests/test_file_ppm.py index fbcbea6c6..bb49a46d3 100644 --- a/Tests/test_file_ppm.py +++ b/Tests/test_file_ppm.py @@ -1,3 +1,4 @@ +from __future__ import annotations import sys from io import BytesIO @@ -256,6 +257,16 @@ def test_truncated_file(tmp_path): im.load() +def test_not_enough_image_data(tmp_path): + path = str(tmp_path / "temp.ppm") + with open(path, "wb") as f: + f.write(b"P2 1 2 255 255") + + with Image.open(path) as im: + with pytest.raises(ValueError): + im.load() + + @pytest.mark.parametrize("maxval", (b"0", b"65536")) def test_invalid_maxval(maxval, tmp_path): path = str(tmp_path / "temp.ppm") diff --git a/Tests/test_file_psd.py b/Tests/test_file_psd.py index 4f934375c..8b06ce2b1 100644 --- a/Tests/test_file_psd.py +++ b/Tests/test_file_psd.py @@ -1,3 +1,4 @@ +from __future__ import annotations import warnings import pytest @@ -27,7 +28,8 @@ def test_unclosed_file(): im = Image.open(test_file) im.load() - pytest.warns(ResourceWarning, open) + with pytest.warns(ResourceWarning): + open() def test_closed_file(): @@ -77,7 +79,6 @@ def test_eoferror(): def test_seek_tell(): with Image.open(test_file) as im: - layer_number = im.tell() assert layer_number == 1 @@ -95,7 +96,6 @@ def test_seek_tell(): def test_seek_eoferror(): with Image.open(test_file) as im: - with pytest.raises(EOFError): im.seek(-1) @@ -112,6 +112,11 @@ def test_rgba(): assert_image_equal_tofile(im, "Tests/images/imagedraw_square.png") +def test_layer_skip(): + with Image.open("Tests/images/five_channels.psd") as im: + assert im.n_frames == 1 + + def test_icc_profile(): with Image.open(test_file) as im: assert "icc_profile" in im.info diff --git a/Tests/test_file_qoi.py b/Tests/test_file_qoi.py new file mode 100644 index 000000000..b7c945729 --- /dev/null +++ b/Tests/test_file_qoi.py @@ -0,0 +1,29 @@ +from __future__ import annotations +import pytest + +from PIL import Image, QoiImagePlugin + +from .helper import assert_image_equal_tofile + + +def test_sanity(): + with Image.open("Tests/images/hopper.qoi") as im: + assert im.mode == "RGB" + assert im.size == (128, 128) + assert im.format == "QOI" + + assert_image_equal_tofile(im, "Tests/images/hopper.png") + + with Image.open("Tests/images/pil123rgba.qoi") as im: + assert im.mode == "RGBA" + assert im.size == (162, 150) + assert im.format == "QOI" + + assert_image_equal_tofile(im, "Tests/images/pil123rgba.png") + + +def test_invalid_file(): + invalid_file = "Tests/images/flower.jpg" + + with pytest.raises(SyntaxError): + QoiImagePlugin.QoiImageFile(invalid_file) diff --git a/Tests/test_file_sgi.py b/Tests/test_file_sgi.py index 6a5d8887d..13698276b 100644 --- a/Tests/test_file_sgi.py +++ b/Tests/test_file_sgi.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import Image, SgiImagePlugin diff --git a/Tests/test_file_spider.py b/Tests/test_file_spider.py index 0e3b705a2..f21098754 100644 --- a/Tests/test_file_spider.py +++ b/Tests/test_file_spider.py @@ -1,3 +1,4 @@ +from __future__ import annotations import tempfile import warnings from io import BytesIO @@ -25,7 +26,8 @@ def test_unclosed_file(): im = Image.open(TEST_FILE) im.load() - pytest.warns(ResourceWarning, open) + with pytest.warns(ResourceWarning): + open() def test_closed_file(): @@ -79,7 +81,6 @@ def test_is_spider_image(): def test_tell(): # Arrange with Image.open(TEST_FILE) as im: - # Act index = im.tell() diff --git a/Tests/test_file_sun.py b/Tests/test_file_sun.py index 05c78c316..874b37b52 100644 --- a/Tests/test_file_sun.py +++ b/Tests/test_file_sun.py @@ -1,3 +1,4 @@ +from __future__ import annotations import os import pytest @@ -16,7 +17,6 @@ def test_sanity(): # Act with Image.open(test_file) as im: - # Assert assert im.size == (128, 128) diff --git a/Tests/test_file_tar.py b/Tests/test_file_tar.py index 5daab47fc..4470823cd 100644 --- a/Tests/test_file_tar.py +++ b/Tests/test_file_tar.py @@ -1,3 +1,4 @@ +from __future__ import annotations import warnings import pytest @@ -10,27 +11,28 @@ from .helper import is_pypy TEST_TAR_FILE = "Tests/images/hopper.tar" -def test_sanity(): - for codec, test_path, format in [ - ["zlib", "hopper.png", "PNG"], - ["jpg", "hopper.jpg", "JPEG"], - ]: - if features.check(codec): - with TarIO.TarIO(TEST_TAR_FILE, test_path) as tar: - with Image.open(tar) as im: - im.load() - assert im.mode == "RGB" - assert im.size == (128, 128) - assert im.format == format +@pytest.mark.parametrize( + "codec, test_path, format", + ( + ("zlib", "hopper.png", "PNG"), + ("jpg", "hopper.jpg", "JPEG"), + ), +) +def test_sanity(codec, test_path, format): + if features.check(codec): + with TarIO.TarIO(TEST_TAR_FILE, test_path) as tar: + with Image.open(tar) as im: + im.load() + assert im.mode == "RGB" + assert im.size == (128, 128) + assert im.format == format @pytest.mark.skipif(is_pypy(), reason="Requires CPython") def test_unclosed_file(): - def open(): + with pytest.warns(ResourceWarning): TarIO.TarIO(TEST_TAR_FILE, "hopper.jpg") - pytest.warns(ResourceWarning, open) - def test_close(): with warnings.catch_warnings(): diff --git a/Tests/test_file_tga.py b/Tests/test_file_tga.py index 7d8b5139a..d0f228573 100644 --- a/Tests/test_file_tga.py +++ b/Tests/test_file_tga.py @@ -1,3 +1,4 @@ +from __future__ import annotations import os from glob import glob from itertools import product @@ -78,7 +79,6 @@ def test_id_field(): # Act with Image.open(test_file) as im: - # Assert assert im.size == (100, 100) @@ -89,7 +89,6 @@ def test_id_field_rle(): # Act with Image.open(test_file) as im: - # Assert assert im.size == (199, 199) @@ -165,13 +164,14 @@ def test_save_id_section(tmp_path): # Save with custom id section greater than 255 characters id_section = b"Test content" * 25 - pytest.warns(UserWarning, lambda: im.save(out, id_section=id_section)) + with pytest.warns(UserWarning): + im.save(out, id_section=id_section) + with Image.open(out) as test_im: assert test_im.info["id_section"] == id_section[:255] test_file = "Tests/images/tga_id_field.tga" with Image.open(test_file) as im: - # Save with no id section im.save(out, id_section="") with Image.open(out) as test_im: diff --git a/Tests/test_file_tiff.py b/Tests/test_file_tiff.py index 4f3c8e390..0851796d0 100644 --- a/Tests/test_file_tiff.py +++ b/Tests/test_file_tiff.py @@ -1,3 +1,4 @@ +from __future__ import annotations import os import warnings from io import BytesIO @@ -25,7 +26,6 @@ except ImportError: class TestFileTiff: def test_sanity(self, tmp_path): - filename = str(tmp_path / "temp.tif") hopper("RGB").save(filename) @@ -62,7 +62,8 @@ class TestFileTiff: im = Image.open("Tests/images/multipage.tiff") im.load() - pytest.warns(ResourceWarning, open) + with pytest.warns(ResourceWarning): + open() def test_closed_file(self): with warnings.catch_warnings(): @@ -84,24 +85,6 @@ class TestFileTiff: with Image.open("Tests/images/multipage.tiff") as im: im.load() - @pytest.mark.parametrize( - "path, sizes", - ( - ("Tests/images/hopper.tif", ()), - ("Tests/images/child_ifd.tiff", (16, 8)), - ("Tests/images/child_ifd_jpeg.tiff", (20,)), - ), - ) - def test_get_child_images(self, path, sizes): - with Image.open(path) as im: - ims = im.get_child_images() - - assert len(ims) == len(sizes) - for i, im in enumerate(ims): - w = sizes[i] - expected = Image.new("RGB", (w, w), "#f00") - assert_image_similar(im, expected, 1) - def test_mac_tiff(self): # Read RGBa images from macOS [@PIL136] @@ -114,39 +97,16 @@ class TestFileTiff: assert_image_similar_tofile(im, "Tests/images/pil136.png", 1) - def test_bigtiff(self): + def test_bigtiff(self, tmp_path): with Image.open("Tests/images/hopper_bigtiff.tif") as im: assert_image_equal_tofile(im, "Tests/images/hopper.tif") - @pytest.mark.parametrize( - "file_name,mode,size,tile", - [ - ( - "tiff_wrong_bits_per_sample.tiff", - "RGBA", - (52, 53), - [("raw", (0, 0, 52, 53), 160, ("RGBA", 0, 1))], - ), - ( - "tiff_wrong_bits_per_sample_2.tiff", - "RGB", - (16, 16), - [("raw", (0, 0, 16, 16), 8, ("RGB", 0, 1))], - ), - ( - "tiff_wrong_bits_per_sample_3.tiff", - "RGBA", - (512, 256), - [("libtiff", (0, 0, 512, 256), 0, ("RGBA", "tiff_lzw", False, 48782))], - ), - ], - ) - def test_wrong_bits_per_sample(self, file_name, mode, size, tile): - with Image.open("Tests/images/" + file_name) as im: - assert im.mode == mode - assert im.size == size - assert im.tile == tile - im.load() + with Image.open("Tests/images/hopper_bigtiff.tif") as im: + # multistrip support not yet implemented + del im.tag_v2[273] + + outfile = str(tmp_path / "temp.tif") + im.save(outfile, save_all=True, append_images=[im], tiffinfo=im.tag_v2) def test_set_legacy_api(self): ifd = TiffImagePlugin.ImageFileDirectory_v2() @@ -157,7 +117,6 @@ class TestFileTiff: def test_xyres_tiff(self): filename = "Tests/images/pil168.tif" with Image.open(filename) as im: - # legacy api assert isinstance(im.tag[X_RESOLUTION][0], tuple) assert isinstance(im.tag[Y_RESOLUTION][0], tuple) @@ -171,7 +130,6 @@ class TestFileTiff: def test_xyres_fallback_tiff(self): filename = "Tests/images/compression.tif" with Image.open(filename) as im: - # v2 api assert isinstance(im.tag_v2[X_RESOLUTION], TiffImagePlugin.IFDRational) assert isinstance(im.tag_v2[Y_RESOLUTION], TiffImagePlugin.IFDRational) @@ -186,7 +144,6 @@ class TestFileTiff: def test_int_resolution(self): filename = "Tests/images/pil168.tif" with Image.open(filename) as im: - # Try to read a file where X,Y_RESOLUTION are ints im.tag_v2[X_RESOLUTION] = 71 im.tag_v2[Y_RESOLUTION] = 71 @@ -235,7 +192,8 @@ class TestFileTiff: def test_bad_exif(self): with Image.open("Tests/images/hopper_bad_exif.jpg") as i: # Should not raise struct.error. - pytest.warns(UserWarning, i._getexif) + with pytest.warns(UserWarning): + i._getexif() def test_save_rgba(self, tmp_path): im = hopper("RGBA") @@ -248,6 +206,12 @@ class TestFileTiff: with pytest.raises(OSError): im.save(outfile) + def test_8bit_s(self): + with Image.open("Tests/images/8bit.s.tif") as im: + im.load() + assert im.mode == "L" + assert im.getpixel((50, 50)) == 184 + def test_little_endian(self): with Image.open("Tests/images/16bit.cropped.tif") as im: assert im.getpixel((0, 0)) == 480 @@ -381,7 +345,6 @@ class TestFileTiff: def test___str__(self): filename = "Tests/images/pil136.tiff" with Image.open(filename) as im: - # Act ret = str(im.ifd) @@ -392,7 +355,6 @@ class TestFileTiff: # Arrange filename = "Tests/images/pil136.tiff" with Image.open(filename) as im: - # v2 interface v2_tags = { 256: 55, @@ -630,7 +592,6 @@ class TestFileTiff: filename = str(tmp_path / "temp.tif") hopper("RGB").save(filename, **kwargs) with Image.open(filename) as im: - # legacy interface assert im.tag[X_RESOLUTION][0][0] == 72 assert im.tag[Y_RESOLUTION][0][0] == 36 @@ -774,7 +735,10 @@ class TestFileTiff: def test_getxmp(self): with Image.open("Tests/images/lab.tif") as im: if ElementTree is None: - with pytest.warns(UserWarning): + with pytest.warns( + UserWarning, + match="XMP data cannot be read without defusedxml dependency", + ): assert im.getxmp() == {} else: xmp = im.getxmp() diff --git a/Tests/test_file_tiff_metadata.py b/Tests/test_file_tiff_metadata.py index b90dde3d9..edd57e6b5 100644 --- a/Tests/test_file_tiff_metadata.py +++ b/Tests/test_file_tiff_metadata.py @@ -1,3 +1,4 @@ +from __future__ import annotations import io import struct @@ -54,7 +55,6 @@ def test_rt_metadata(tmp_path): img.save(f, tiffinfo=info) with Image.open(f) as loaded: - assert loaded.tag[ImageJMetaDataByteCounts] == (len(bin_data),) assert loaded.tag_v2[ImageJMetaDataByteCounts] == (len(bin_data),) @@ -74,14 +74,12 @@ def test_rt_metadata(tmp_path): info[ImageJMetaDataByteCounts] = (8, len(bin_data) - 8) img.save(f, tiffinfo=info) with Image.open(f) as loaded: - assert loaded.tag[ImageJMetaDataByteCounts] == (8, len(bin_data) - 8) assert loaded.tag_v2[ImageJMetaDataByteCounts] == (8, len(bin_data) - 8) def test_read_metadata(): with Image.open("Tests/images/hopper_g4.tif") as img: - assert { "YResolution": IFDRational(4294967295, 113653537), "PlanarConfiguration": 1, @@ -185,30 +183,32 @@ def test_iptc(tmp_path): im.save(out) -def test_writing_bytes_to_ascii(tmp_path): - im = hopper() +@pytest.mark.parametrize("value, expected", ((b"test", "test"), (1, "1"))) +def test_writing_other_types_to_ascii(value, expected, tmp_path): info = TiffImagePlugin.ImageFileDirectory_v2() tag = TiffTags.TAGS_V2[271] assert tag.type == TiffTags.ASCII - info[271] = b"test" + info[271] = value + im = hopper() out = str(tmp_path / "temp.tiff") im.save(out, tiffinfo=info) with Image.open(out) as reloaded: - assert reloaded.tag_v2[271] == "test" + assert reloaded.tag_v2[271] == expected -def test_writing_int_to_bytes(tmp_path): +@pytest.mark.parametrize("value", (1, IFDRational(1))) +def test_writing_other_types_to_bytes(value, tmp_path): im = hopper() info = TiffImagePlugin.ImageFileDirectory_v2() tag = TiffTags.TAGS_V2[700] assert tag.type == TiffTags.BYTE - info[700] = 1 + info[700] = value out = str(tmp_path / "temp.tiff") im.save(out, tiffinfo=info) @@ -217,6 +217,22 @@ def test_writing_int_to_bytes(tmp_path): assert reloaded.tag_v2[700] == b"\x01" +def test_writing_other_types_to_undefined(tmp_path): + im = hopper() + info = TiffImagePlugin.ImageFileDirectory_v2() + + tag = TiffTags.TAGS_V2[33723] + assert tag.type == TiffTags.UNDEFINED + + info[33723] = 1 + + out = str(tmp_path / "temp.tiff") + im.save(out, tiffinfo=info) + + with Image.open(out) as reloaded: + assert reloaded.tag_v2[33723] == b"1" + + def test_undefined_zero(tmp_path): # Check that the tag has not been changed since this test was created tag = TiffTags.TAGS_V2[45059] @@ -237,7 +253,8 @@ def test_empty_metadata(): head = f.read(8) info = TiffImagePlugin.ImageFileDirectory(head) # Should not raise struct.error. - pytest.warns(UserWarning, info.load, f) + with pytest.warns(UserWarning): + info.load(f) def test_iccprofile(tmp_path): @@ -403,11 +420,12 @@ def test_too_many_entries(): ifd = TiffImagePlugin.ImageFileDirectory_v2() # 277: ("SamplesPerPixel", SHORT, 1), - ifd._tagdata[277] = struct.pack("hh", 4, 4) + ifd._tagdata[277] = struct.pack("